chart = {
const width = 928;
const height = 600;
const marginTop = 20;
const marginRight = 20;
const marginBottom = 30;
const marginLeft = 30;
const x = d3.scaleUtc()
.domain(d3.extent(unemployment, d => d.date))
.range([marginLeft, width - marginRight]);
const y = d3.scaleLinear()
.domain([0, d3.max(unemployment, d => d.unemployment)]).nice()
// 设置值域范围
// svg 元素的高度(减去留白区域)
.range([height - marginBottom, marginTop]);
/**
*
* 创建 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; overflow: visible; font: 10px sans-serif;");
/**
*
* 绘制坐标轴
*
*/
// 绘制横坐标轴
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)`)
// 纵轴是一个刻度值朝左的坐标轴
.call(d3.axisLeft(y))
// 删掉上一步所生成的坐标轴的轴线(它含有 domain 类名)
.call(g => g.select(".domain").remove())
// voronoi 是上面 ☝️ 的一个 cell 里的控件名称,它是一个 checkout 单选表单
// 所以该变量是一个布尔值,以表示是否显示了 Vornoi Digram 维诺图
// 这里表示在图表的初始状态下,如果有显示维诺图就不执行操作;如果没有显示维诺图就绘制纵坐标轴的参考辅助线
// 复制了一份刻度线(通过 CSS 类名 ".tick line" 选中它们),用以绘制图中纵向的网格参考线
.call(voronoi ? () => {} : g => g.selectAll(".tick line").clone()
// 调整复制后的刻度线的终点位置(往右移动)
.attr("x2", width - marginLeft - marginRight)
.attr("stroke-opacity", 0.1)) // 调小网格线的透明度
// 为坐标轴添加额外信息名称(一般是刻度值的单位等信息)
.call(g => g.append("text")
// 将该文本移动到坐标轴的顶部(即容器的左上角)
.attr("x", -marginLeft)
.attr("y", 10)
.attr("fill", "currentColor") // 设置文本的颜色
.attr("text-anchor", "start") // 设置文本的对齐方式
.text("↑ Unemployment (%)")); // 设置文本内容
/**
*
* 绘制维诺图
*
*/
// 对数据集进行转换
// 将每个数据点(一个对象)转换为一个数组 [x, y, z],其中第一、二个元素是数据点所对应的坐标值,第三个元素是所属的系列
// 从每个数据点提取出相应的数据 d.date 是日期,d.unemployment 是失业率,d.division 是都市分区
// 并用横坐标轴比例尺 x(d.date) 和纵坐标轴比例尺 y(d.unemployment) 进行映射,得到各数据点相应的坐标值
const points = unemployment.map((d) => [x(d.date), y(d.unemployment), d.division]);
// voronoi 是上面 ☝️ 的一个 cell 里的控件名称,它是一个 checkout 单选表单,以控制是否在页面上绘制出维诺图
if (voronoi) {
// 使用 <path> 路径元素绘制维诺图
svg.append("path")
// 只需要描边,而不需要填充,所以 fill 属性设置为 none
.attr("fill", "none")
// 设置描边颜色
.attr("stroke", "#ccc")
// 先使用 d3.Delaunay.from(points) 基于数据集 points 生成一个三角剖分器 delaunay
// 数据集的格式有特定的要求,它是一个嵌套数组,即每个元素也还是一个数组,嵌套数组的第一个元素是数据点的横坐标值,第二个元素是数据点的纵坐标值
// 然后调用方法 delaunay.voronoi(boundes) 创建一个维诺图生成器 voronoi,其中参数 boundes 设定视图的边界
// 最后通过 voronoi.render() 绘制维诺图(这里没有传入 Canvas 画布 context 绘图上下文,所以返回一个路径字符串,作为 svg 的 <path> 元素的属性 d 的值)
// 关于维诺图的详细介绍可以参考官方文档 https://d3js.org/d3-delaunay/delaunay
// 或查看这一篇笔记 https://datavis-note.benbinbin.com/article/d3/module-api/d3-module-delaunay
.attr("d",
d3.Delaunay
.from(points)
.voronoi([0, 0, width, height])
.render()
);
}
/**
*
* 绘制折线图内的线段
*
*/
// 将数据点基于不同的系列进行分组,以便后面绘制折线时进行数据绑定
// 方法 d3.rollup(iterable, reduce, ...keys) 基于指定的属性 keys 进行分组,并对各分组进行 reduce「压缩降维」,最后返回一个 InternMap 对象
// * 第一个参数 iterable 是可迭代对象,即数据集
// * 第二个参数 reduce 是对分组进行压缩的函数,每个分组会依次调用该函数(入参就是包含各个分组元素的数组),返回值会作为 InternMap 对象中(各分组的)键值对中的值
// * 余下的参数 ...keys 是一系列返回分组依据
// 具体参考官方文档 https://d3js.org/d3-array/group#rollup
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-process
// 这里是根据每个数据点所属的系列(都市分区)d[2] 进行分组
// 然后再对每个分组调用 reduce 函数 v => Object.assign(v, {z: v[0][2]}) 进行「压缩降维」
// 在这里 reduce 函数的作用其实并不是「压缩降维」,而是为每个分组添加上所属系列的名称
// 使用 Object.assign(obj1, obj2) 为传入的数组 v(也是对象)添加额外的属性 z,它的值是 v[0][2] 第一个元素(也是一个数组)的第三个元素(即所属的系列),然后再返回该数组
const groups = d3.rollup(points, v => Object.assign(v, {z: v[0][2]}), d => d[2]);
// 使用方法 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 line = d3.line();
const path = svg.append("g") // 为折线折线创建一个容器
// 只需要路径的描边作为折线,不需要填充,所以属性 fill 设置为 none
.attr("fill", "none")
// 设置(容器/父元素)描边颜色
.attr("stroke", "steelblue")
// 设置描边宽度
.attr("stroke-width", 1.5)
// 设置折线之间的连接样式(圆角让连接更加平滑)
.attr("stroke-linejoin", "round")
// 设置路径端点的样式
.attr("stroke-linecap", "round")
// 使用路径 <path> 元素绘制折线
// 由于是多个折线,所以使用 selectAll 选择一系列的 <path> 元素(虚拟占位元素)
.selectAll("path")
// 绑定数据
// groups 是前面 ☝️ 对数据集进行分组转换生成的,它是一个 InternMap 对象
// 通过调用方法 groups.values() 可以得到一个迭代器(可以遍历得到每个分组的值,即各系列对应的数组)
.data(groups.values())
.join("path") // 将线段路径绘制到页面上
// 为元素设置一个 CSS 属性 mix-blend-mode,设置为 multiple
// 当折线之间重叠时产生加深颜色的效果
// 关于该 CSS 属性具体可以参考 https://developer.mozilla.org/zh-CN/docs/Web/CSS/mix-blend-mode
.style("mix-blend-mode", "multiply")
// 调用线段生成器 line
// 将其返回的结果(字符串)作为 `<path>` 元素的属性 `d` 的值
.attr("d", line);
/**
*
* 创建 tooltip 以及实现交互
*
*/
// 当鼠标悬浮在 svg 元素上,会有一个提示框显示相应数据点的信息
// 该 tooltip 由一个圆点(表示数据点)和描述数据的文字构成
const dot = svg.append("g") // 创建一个容器
.attr("display", "none"); // 默认是不显示的
// 在容器内添加一个 <circle> 元素,以小圆形表示数据点
dot.append("circle")
.attr("r", 2.5); // 设置半径长度
// 在容器内添加一个 <text> 元素,用于展示注释内容
dot.append("text")
.attr("text-anchor", "middle") // 文本对齐方式
.attr("y", -8); // 在垂直方向设置一点(向上)小偏移
// 为 svg 元素设置一系列事件的监听器,以响应用户的交互
svg
.on("pointerenter", pointerentered) // 该事件处理函数在鼠标指针进入 svg 元素时触发
.on("pointermove", pointermoved) // 该事件处理函数在鼠标指针位于 svg 元素上移动时(不断)触发
.on("pointerleave", pointerleft) // 该事件处理函数在鼠标指针离开 svg 元素时触发
.on("touchstart", event => event.preventDefault()); // 取消触控事件的默认行为
return svg.node();
// 该函数在鼠标指针进入 svg 元素时被触发
// 其作用是将所有折线的颜色改为灰色,并显示 Tooltip
function pointerentered() {
// 取消折线与背景元素的混合模式
// 并将所有折线的描边颜色都改为灰色
path.style("mix-blend-mode", null).style("stroke", "#ddd");
dot.attr("display", null); // 显示 Tooltip
}
// 该函数在鼠标指针位于 svg 元素上移动时(不断)触发
// 其作用是寻找距离鼠标指针位置最近的数据点,并更新高亮的数据点和相应的系列,以及 Tooltip 的内容
// 💡 在寻找距离最近的数据点时,实际上并没有使用 Voronoi 维诺图,而是使用穷举法(因为对于较小的数据集,这个方法也足够快了)
// 即直接遍历所有数据点,并计算它们与指针位置的距离,以此来找到最近的数据点
function pointermoved(event) {
// 使用 d3.pointer(event, target) 获取指针相对于给定元素 target 的横纵坐标值(参数 target 是可选的,它的默认值是 currentTarget,即设置了该事件监听器的 DOM 元素)
// 虽然可以使用 `event.pageX` 和 `event.pageY` 来获取鼠标定位(位于网页的绝对值)
// 但是一般使用方法 d3.pointer 将鼠标位置转换为相对于接收事件的元素的局部坐标系,便于进行后续操作
// 可以参考官方文档 https://d3js.org/d3-selection/events#pointer
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/module-api/d3-module-selection#处理事件
// 这里得到的距离 (xm, ym) 是鼠标指针相对于 svg 元素(左上角)的横纵坐标值
const [xm, ym] = d3.pointer(event);
// 使用 d3.leastIndex(iterable, accessor) 获取可迭代对象 iterable 中的最小值所对应的索引值,其中参数 accessor 是访问器
// 访问器 accessor 是一个函数,接收一个参数 d,即当前遍历的可迭代对象的元素,其返回值代表该元素,用于进行对比
// 这里的可迭代对象是数据集 points
// 访问器 accessor 是 ([x, y]) => Math.hypot(x - xm, y - ym)
// 通过数组解构得到当前所遍历元素(一个三元数组)的前两个元素 [x, y] 是该数据点的横纵坐标值
// 并使用 JS 原生方法 Math.hypot()(用于计算欧几里得距离)计算数据点与鼠标指针的距离
const i = d3.leastIndex(points, ([x, y]) => Math.hypot(x - xm, y - ym));
// 通过索引值 i 从数据集 points 中获取到相应的数据点(一个三元数组)
// 并通过数组解构,得到它的横纵坐标值 x 与 y,以及所属的系列 z(都市分区)
const [x, y, k] = points[i];
// 筛选出系列 k 所对应的折线,并设置不同的描边颜色
// path 是包含一系列折线的选择集
// 它所绑定的数据是 groups.values() 的返回值,其中每个元素都额外添加了属性 z,表示所属的系列
// 通过解构出的属性 z 与 k 进行对比,如果该折线所属的系列就是 k,则描边颜色设置为 null,则继承/采用容器/父元素的描边颜色,即蓝色;如果该折线所属的系列不是 k,则设置为灰色
// 然后通过 selection.filter() 对选择集进行二次筛选,选出系列 k 所对应的折线构建一个新的选择集
// 再使用 selection.raise() 将选择集的元素重新插入页面,这样就可以让系列 k 所对应的折线位于其他折线的上方(避免遮挡)
path.style("stroke", ({z}) => z === k ? null : "#ddd").filter(({z}) => z === k).raise();
// 将 Tooltip 移动到高亮数据点的位置
dot.attr("transform", `translate(${x},${y})`);
// 设置 Tooltip 的文本内容(所属系列的名称)
dot.select("text").text(k);
// 使用方法 selection.property(name, value) 为选择集中的元素添加值为 value 名字为 name 的属性
// 这里为 <svg> 元素(svg 选择集其中只有它一个元素)添加了名为 value 的属性,其值为 unemployment[i],即当前高亮的数据点所对应的原始数据
// 并使用方法 selection.dispatch(type, parameters) 依次在选择集的元素上分发特定类型 type 的事件
// 这里在 <svg> 元素上分发了 input 事件(Observable 平台所需要的特有操作,用于更新前面 ☝️ 的 Cell),事件支持冒泡
svg.property("value", unemployment[i]).dispatch("input", {bubbles: true});
}
// 该函数在鼠标指针离开 svg 元素时触发
function pointerleft() {
// 恢复折线与背景元素的混合模式
// 并将取消所有折线的描边颜色,继承/采用容器/父元素的描边颜色,即蓝色
path.style("mix-blend-mode", "multiply").style("stroke", null);
// 隐藏 Tooltip
dot.attr("display", "none");
// 移除 <svg> 元素的 value 属性
svg.node().value = null;
// 在 <svg> 元素上分发了 input 事件(Observable 平台所需要的特有操作,用于更新前面 ☝️ 的 Cell),事件支持冒泡
svg.dispatch("input", {bubbles: true});
}
}