chart = {
const width = 928;
const height = 720;
const marginTop = 10;
const marginRight = 10;
const marginBottom = 30;
const marginLeft = 40;
const x = d3.scaleUtc()
.domain(d3.extent(data, d => d.date))
.range([marginLeft, width - marginRight]);
const y = d3.scaleLinear()
.range([height - marginBottom, marginTop]);
// 设置颜色比例尺
// 为不同系列设置不同的配色
// 使用 d3.scaleOrdinal() 排序比例尺 Ordinal Scales 将离散型的定义域映射到离散型值域
// 具体参考官方文档 https://d3js.org/d3-scale/ordinal
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-scale#排序比例尺-ordinal-scales
const color = d3.scaleOrdinal()
// 设置定义域范围
// 各区域的名称(即各州所对应的 9 个区域)
.domain(regionRank)
// 设置值域范围
// 使用 D3 内置的一种配色方案 d3.schemeCategory10
// 它是一个数组,包含一些预设的颜色(共 10 种)
// 具体可以查看 👇 下一个 📝 cell 或参考官方文档 https://d3js.org/d3-scale-chromatic/categorical#schemeCategory10
// 这里区域数量是 9 种,依次对应 d3.schemeTableau10 配色方案前 9 种颜色
.range(d3.schemeCategory10)
// 设置默认颜色
// 当使用颜色比例尺时 color(value) 传入的参数不在定义域范围中,默认返回的颜色值
.unknown("gray");
/**
*
* 创建 svg 容器
*
*/
// 返回的是一个包含 svg 元素的选择集
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto;");
/**
*
* 绘制坐标轴
*
*/
// 绘制横坐标轴
svg.append("g")
// 通过设置 CSS 的 transform 属性将横坐标轴容器「移动」到底部
.attr("transform", `translate(0,${height - marginBottom})`)
// 横轴是一个刻度值朝下的坐标轴
// 通过 axis.ticks(count) 设置刻度数量的参考值(避免刻度过多导致刻度值重叠而影响图表的可读性)
// 将坐标轴的外侧刻度 tickSizeOuter 长度设置为 0(即取消坐标轴首尾两端的刻度)
.call(d3.axisBottom(x).ticks(width / 80).tickSizeOuter(0));
// 绘制纵坐标轴
svg.append("g")
// 通过设置 CSS 的 transform 属性将纵向坐标轴容器「移动」到左侧
.attr("transform", `translate(${marginLeft},0)`)
// 纵轴是一个刻度值朝左的坐标轴
// 并使用坐标轴对象的方法 axis.ticks() 设置坐标轴的刻度数量和刻度值格式
// 其中第一个参数用于设置刻度数量(这里设置的是预期值,并不是最终值,D3 会基于出入的数量进行调整,以便刻度更可视)
// 而第二个参数用于设置刻度值格式,这里设置为 "%" 表示数值采用百分比表示
.call(d3.axisLeft(y).ticks(10, "%"))
// 删掉上一步所生成的坐标轴的轴线(它含有 domain 类名)
.call(g => g.select(".domain").remove());
/**
*
* 绘制面积图内的面积形状
*
*/
// 使用 d3.area() 创建一个面积生成器
// 面积生成器会基于给定的数据生成面积形状
// 调用面积生成器时返回的结果,会基于生成器是否设置了画布上下文 context 而不同。如果设置了画布上下文 context,则生成一系列在画布上绘制路径的方法,通过调用它们可以将路径绘制到画布上;如果没有设置画布上下文 context,则生成字符串,可以作为 `<path>` 元素的属性 `d` 的值
// 具体可以参考官方文档 https://d3js.org/d3-shape/area
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-shape#面积生成器-areas
const area = d3.area()
// 设置下边界线横坐标读取函数
// 💡 不需要再设置上边界线横坐标读取函数,因为默认会复用相应的下边界线横坐标值,这符合横向延伸的面积图
// 该函数会在调用面积生成器时,为数组中的每一个元素都执行一次,以返回该数据所对应的横坐标
// 这里基于每个数据点的年份(时间)d.data.date(这里的 d.data 是该数据点 d 转换前/原始的数据结构,它的属性 date 就是该数据点对应的年份)并采用比例尺 x 进行映射,计算出相应的横坐标
.x(d => x(d.data.date))
// 设置下边界线的纵坐标的读取函数
// 这里基于每个数据点(二元数组)的第一个元素 d[0] 并采用比例尺 y 进行映射,计算出相应的纵坐标
.y0(d => y(d[0]))
// 设置上边界线的纵坐标的读取函数
// 这里基于每个数据点(二元数组)的第二个元素 d[1] 并采用比例尺 y 进行映射,计算出相应的纵坐标
.y1(d => y(d[1]));
// 将每个系列的面积形状绘制到页面上
// 创建一个元素 <g> 作为容器
svg.append("g")
.attr("fill-opacity", 0.8) // 设置填充的透明度
.selectAll("path") // 返回一个选择集,其中虚拟/占位元素是一系列的 <path> 路径元素,用于绘制各系列的形状
.data(series) // 绑定数据,每个路径元素 <path> 对应一个系列数据
.join("path") // 将元素绘制到页面上
// 设置颜色,不同系列/堆叠层对应不同的颜色
// 其中所绑定数据是一个数组,但具有属性 key 表示该系列对应的州的名称,填充色是由该州所属的区域所决定的
// 首先通过映射 regionByState.get(key) 获取该州所对应的区域,然后使用颜色比例尺 color() 获取相应的颜色
.attr("fill", ({key}) => color(regionByState.get(key)))
// 由于面积生成器并没有调用方法 area.context(parentDOM) 设置画布上下文
// 所以调用面积生成器 area 返回的结果是字符串
// 该值作为 `<path>` 元素的属性 `d` 的值
.attr("d", area)
// 最后在每个路径元素 <path> 里添加一个 <title> 元素
// 以便鼠标 hover 在相应的各系列的面积之上时,可以显示 tooltip 提示信息
.append("title")
// 设置 tooltip 的文本内容,采用所绑定数据的属性 key,表示当前所遍历的系列名称
.text(({key}) => key);
/**
*
* 绘制面积图内的各系列(堆叠形状)之间的分隔线(上边界线)
*
*/
// 创建一个容器
svg.append("g")
// 只需要路径的描边作为折线,不需要填充,所以属性 fill 设置为 none
.attr("fill", "none")
.attr("stroke-width", 0.75) // 描边的宽度
// 使用路径 <path> 元素绘制折线
.selectAll("path") // 返回一个选择集,其中虚拟/占位元素是一系列的 <path> 路径元素,用于绘制各系列的边界线
.data(series) // 绑定数据,每个路径元素 <path> 对应一个系列数据
.join("path") // 将元素绘制到页面上
// 设置描边颜色
// 基于原来系列的填充色,采用一个更深的颜色
// 首先通过映射 regionByState.get(key) 获取当前系列表示的州所对应的区域,然后使用颜色比例尺 color() 获取相应的颜色
// 然后使用 d3.lab(color) 创建一个符合 CIELAB 色彩空间的颜色对象,具体参考官方文档 https://d3js.org/d3-color#lab
// 💡 该色彩空间旨在作为一个感知上统一的空间,更多介绍可以参考 https://en.wikipedia.org/wiki/CIELAB_color_space
// 最后使用 colorObj.darker() 基于原来的颜色得到一个更深的颜色
.attr("stroke", ({key}) => d3.lab(color(regionByState.get(key))).darker())
// 方法 area.lineY1() 返回一个线段生成器,用于在绘制面积图的上边界线
// 调用该线段生成器,返回的结果是字符串,该值作为 `<path>` 元素的属性 `d` 的值
.attr("d", area.lineY1());
/**
*
* 为每个系列添加标注信息
*
*/
// 为每个系列的面积创建一个中线,其路径作为标注信息(文本)的延伸方向
// 使用方法 d3.line() 创建一个线段生成器
// 线段生成器会基于给定的坐标点生成线段(或曲线)
// 调用线段生成器时返回的结果,会基于生成器是否设置了画布上下文 context 而不同。如果设置了画布上下文 context,则生成一系列在画布上绘制路径的方法,通过调用它们可以将路径绘制到画布上;如果没有设置画布上下文 context,则生成字符串,可以作为 `<path>` 元素的属性 `d` 的值
// 具体可以参考官方文档 https://d3js.org/d3-shape/line
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-shape#线段生成器-lines
const midline = d3.line()
// 设置两点之间的曲线插值器,这里使用 D3 所提供的一种内置曲线插值器 d3.curveBasis
// 该插值效果是在两个数据点之间,生成三次样条曲线 cubic basis spline
// 具体效果参考 https://d3js.org/d3-shape/curve#curveBasis
.curve(d3.curveBasis)
// 设置横坐标读取函数
// 该函数会在调用线段生成器时,为数组中的每一个元素都执行一次,以返回该数据所对应的横坐标
// 这里基于每个数据点的年份(时间)d.data.date 并采用比例尺 x 进行映射,计算出相应的横坐标
.x(d => x(d.data.date))
// 设置纵坐标读取函数
// 这里采用每个数据在该系列的上下界的纵坐标的和的一半(中点)
.y(d => y((d[0] + d[1]) / 2));
// 💡 创建一个 <defs> 元素,在其中定义一些图形元素(一般具有属性 id 以便被其他元素引用),以便之后使用(而不在当前渲染出来)
// 在其中添加一系列 <path> 元素,作为各系列的标注信息(文本)的路径
const defs = svg.append("defs")
// 返回一个选择集,其中虚拟/占位元素是一系列的 <path> 路径元素
.selectAll("path")
.data(series) // 绑定数据,每个路径元素 <path> 对应一个系列数据
.join("path") // 将元素绘制到页面上
// 为 <path> 设置属性 id(方便其他元素基于 id 来引用),以避免与其他元素发生冲突
// 使用了该平台的标准库所提供的方法 DOM.uid(namespace) 创建一个唯一 ID 号
// 💡 具体参考官方文档 https://observablehq.com/documentation/misc/standard-library#dom-uid-name
// 💡 方法 DOM.uid() 的具体实现可参考源码 https://github.com/observablehq/stdlib/blob/main/src/dom/uid.js
// 并将该属性值添加到所绑定的数据(对象)的属性 id 中(以便后面绑定同样的 series 数据集时,可以通过 id 引用相应的路径元素)
.attr("id", d => (d.id = DOM.uid("state")).id)
// 调用线段生成器 midline 生成各堆叠面积形状的中线
// 返回的结果是字符串,该值作为 `<path>` 元素的属性 `d` 的值
.attr("d", midline);
// console.log(defs)
// 为每个系列添加文本标注
svg.append("g")
.style("font", "10px sans-serif") // 设置字体
.attr("text-anchor", "middle") // 设置文本对齐方式
// 使用 <text> 元素添加添加标注信息
.selectAll("text") // 返回一个选择集,其中虚拟/占位元素是一系列的 <text> 路径元素
.data(series) // 绑定数据,每个文本元素 <text> 对应一个系列数据
.join("text") // 将元素绘制到页面上
.attr("dy", "0.35em") // 设置文本在垂直方向上的偏移(让文本居中对齐)
// 在每个 <text> 元素里添加 <textPath> 元素(使用该元素包裹具体的文本内容),让文本沿着指定的路径放置
.append("textPath")
// 设置属性 href,采用所绑定数据的属性 d.id.href,指向前面在元素 <defs> 所创建的相应路径元素 <path>
// 所以每个系列的标注文本会沿着所在系列的中线延伸
.attr("href", d => d.id.href)
// 设置文字距离路径开头多远(采用百分比)开始排布,让文本定位到(所在系列面积形状中)纵向空间较大的位置(如果在狭窄的位置放置文字,可能会与系列分界线重叠而影响可读性,文字还可能叠到在其他系列的面积上)
// 首先Math.max(0.05, Math.min(0.95, ...) 表示文字排布约束在距离路径的开头 5% 和 95% 区间中
// 其中最佳的放置点是在该系列上下界(同一个横坐标点)差距最大的地方,即 d3.maxIndex(d, d => d[1] - d[0]) 返回的索引值所对应的数据点,然后通过 d3.maxIndex(d, d => d[1] - d[0]) / (d.length - 1))) * 100}% 得到该数据点到路径的开头的距离占总路径的比例
.attr("startOffset", d => `${Math.max(0.05, Math.min(0.95, d.offset = d3.maxIndex(d, d => d[1] - d[0]) / (d.length - 1))) * 100}%`)
.text(d => d.key); // 设置文本内容
return Object.assign(svg.node(), {scales: {color}});
}