D3 折线图

d3
Created 8/21/2023
Updated 3/4/2024

D3 折线图

静态图

参考
  • 解读的官方样例为 Line chart
  • 对代码进行注释解读的 Notebook 是这个
  • 复现可以查看该网页,完整代码可以查看 这里

官方样例的构建流程概述:

  • 读取数据
  • 构建比例尺
  • 绘制折线图的容器(边框和坐标轴)
  • 创建一个线段生成器
  • 绘制折线图内的线段

在该例子中需要学习留意的一点是,由于数据集是 Apple 股票的(从 2007-04-23 至 2012-05-01)每日收盘价,所以(横坐标轴)需要使用时间比例尺。

D3 提供了两种方法构建时间比例尺,两者所创建的时间比例尺具有相同的方法,但是所采用的时间表示方式不同:

  • d3.scaleTime(domain, range) 所创建的时间比例尺所采用的是地方时 Local Time,如果用户的浏览器所采用的时区不同,则显示不同的时间
  • d3.scaleUtc(domain, range) 所创建的时间比例尺采用协调世界时 Universal Time Coordinated,简称为 UTC,所以即使处于不同时区的用户也会显示同样的时间

在该例子中使用 d3.scaleUtc() 构建一个时间比例尺

提示

关于协调世界时地方时的具体介绍可以查看这一篇笔记

数据缺失的折线图

参考

该示例使用与前一个例子一样的数据源,但是进行一些修改,以手动模拟数据缺失的情况

关于数据处理的相关代码如下

js
// 💡 遍历 aapl 数组的每一个元素,修改数据点(对象)的属性 close 的值,以手动模拟数据缺失的情况
// 当数据点所对应的日期的月份小于三月份(包含),则收盘价改为 NaN;否则就采用原始值
// 📢 由于 JS 的日期中,月份是按 0 开始算起的,所以 1、2、3 月份是满足以下的判断条件 d.date.getUTCMonth() < 3
const aaplMissing = aapl.map(d => ({ ...d, close: d.date.getUTCMonth() < 3 ? NaN : d.close })) // simulate gaps

调用线段生成器方法 line.defined(callback) 可以设置数据完整性检验。所设置的回调函数 callback 会在调用线段生成器时,为数组中的每一个元素都执行一次,返回布尔值,以判断该元素的数据是否完整。

回调函数 callback函数也是有三个入参:

  • 当前的元素 d
  • 该元素在数组中的索引 i
  • 整个数组 data

当函数返回 true 时,线段生成器就会执行下一步(调用坐标读取函数),最后生成该元素相应的坐标数据;当函数返回 false 时,该元素就会就会跳过,当前线段就会截止,并在下一个有定义的元素再开始绘制,反映在图上就是一段段分离的线段

在该例子中通过判断值是否为 NaN 来判定该数据是否缺失,相关代码如下

js
// 使用方法 d3.line() 创建一个线段生成器
// 线段生成器会基于给定的坐标点生成线段(或曲线)
// 调用线段生成器时返回的结果,会基于生成器是否设置了父容器 context 而不同。如果设置了父容器,则生成 `<path>` 元素,并添加到父容器中;如果没有则生成字符串,可以作为 `<path>` 元素的属性 `d` 的值
// 具体可以参考官方文档 https://d3js.org/d3-shape/line 或 https://github.com/d3/d3-shape/tree/main#lines
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-shape#线段生成器-lines
const line = d3.line()
  // 💡 调用线段生成器方法 line.defined() 设置数据完整性检验函数
  // 这里通过判断数据点的属性 d.close(收盘价)是否为 NaN 来判定该数据是否缺失
  .defined(d => !isNaN(d.close))
  // 设置横坐标读取函数
  // 该函数会在调用线段生成器时,为数组中的每一个元素都执行一次,以返回该数据所对应的横坐标
  // 这里基于每个数据点的日期(时间)d.date 并采用比例尺 x 进行映射,计算出相应的横坐标
  .x(d => x(d.date))
  // 设置纵坐标读取函数
  .y(d => y(d.close));

该示例实际上绘制了两种颜色的线段(灰色和蓝色),并进行覆盖叠加,最终的效果是在折线图缺口位置由灰色的线段填补,相关代码如下

js
// 💡 先绘制灰色的线段
svg.append("path") // 使用路径 <path> 元素绘制折线
  // 只需要路径的描边作为折线,不需要填充,所以属性 fill 设置为 none
  .attr("fill", "none")
  // 设置描边颜色
  .attr("stroke", "#ccc")
  // 设置描边宽度
  .attr("stroke-width", 1.5)
  // 这里采用过滤掉缺失的数据点的 aaplMissing 作为绘制线段的数据集
  // 由于线段生成器并没有调用方法 line.context(parentDOM) 设置父容器
  // 所以调用线段生成器 line(aapl) 返回的结果是字符串
  // 该值作为 `<path>` 元素的属性 `d` 的值
  .attr("d", line(aaplMissing.filter(d => !isNaN(d.close))));
// 其实以上的操作绘制了一个完整的折线图,由于过滤掉缺失的数据点,所以可以绘制出了一个完整(无缺口)的折线图
// 由于 **线性插值法 linear interpolation** 是线段生成器在绘制线段时所采用的默认方法,所以对于那些缺失数据的位置,通过连接左右存在的完整点,绘制出的「模拟」线段来填补缺口
// 将线段路径绘制到页面上
svg.append("path")
  .attr("fill", "none")
  .attr("stroke", "steelblue")
  .attr("stroke-width", 1.5)
  // 这里采用含有缺失数据的 aaplMissing 作为绘制线段的数据集
  .attr("d", line(aaplMissing));
// 由于含有缺失数据,所以绘制出含有缺口的折线图
// 蓝色折线图覆盖(重叠)在前面所绘制的灰色折线图上,所以最终的效果是在缺口位置由灰色的线段填补

双轴图

参考

该示例绘制了一个条形图-折线图的双轴图

在复合叠加两种图表时应该关注元素的对齐问题,由于条形图的条带是具有宽度的,而折线图每个数据都只是占据一个点,所以将两者叠加起来时,应该将折线图的数据点对齐到相应条带的垂直中心

通过在线段生成器的横坐标轴读取函数中,设置相应「偏移」以实现对齐,相关代码如下

js
// 使用方法 d3.line() 创建一个线段生成器
// 线段生成器会基于给定的坐标点生成线段(或曲线)
// 调用线段生成器时返回的结果,会基于生成器是否设置了画布上下文 context 而不同。如果设置了画布上下文 context,则生成一系列在画布上绘制路径的方法,通过调用它们可以将路径绘制到画布上;如果没有设置画布上下文 context,则生成字符串,可以作为 `<path>` 元素的属性 `d` 的值
// 具体可以参考官方文档 https://d3js.org/d3-shape/line 或 https://github.com/d3/d3-shape/tree/main#lines
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-shape#线段生成器-lines
line = d3.line()
    // 设置横坐标读取函数
    // 该函数会在调用线段生成器时,为数组中的每一个元素都执行一次,以返回该数据所对应的横坐标
    // 这里基于每个数据点的年份 d.year 并采用比例尺 x 进行映射,计算出相应的横坐标
    // 💡 实际的横坐标宽度还要加上条带的一半宽度 x.bandwidth() / 2
    // 💡 这是为了让折线图的每个数据点与条形图的相应条带的(垂直)中心对齐,所以横坐标添加上条带的一半宽度
    .x(d => x(d.year) + x.bandwidth() / 2)
    // 设置纵坐标读取函数
    .y(d => y2(d.efficiency))

在该实例中绘制折线图时,为元素 <path> 添加了属性 stroke-miterlimit 并将属性值设置为 1,这是为了避免折线「锋利」交接处过渡延伸,导致该点的数据偏移,相关代码如下

js
/**
 *
 * 绘制折线图内的线段
 *
 */
svg.append("path") // 使用路径 <path> 元素绘制折线
    .attr("fill", "none") // 只需要路径的描边作为折线,不需要填充,所以属性 fill 设置为 none
    .attr("stroke", "currentColor") // 设置描边颜色
    // 避免折线「锋利」交接处过渡延伸,导致该点的数据偏移
    .attr("stroke-miterlimit", 1)
    .attr("stroke-width", 3) // 设置描边宽度
    // 调用线段生成器 line(data) 返回的结果是字符串
    // 该值作为 `<path>` 元素的属性 `d` 的值
    .attr("d", line(data));
stroke-miterlimit 属性

stroke-miterlimit 属性约束两段折线相交时接头的尖端长度

在该实例中另一点值得学习的显示注释信息的交互方式

通过在原来的双轴图上再叠放一层「不可见」的条形图,其中每个条带的高度都是和 svg 画布一样高的,所以它们可以铺满整个画布,然后为每个条形图内添加 <title> 元素,当鼠标 hover 到特定的(横轴方向上)区域时,就会出现一个带有注释文本的浮窗以显示相应的数据点的信息

注意

为了让叠加的条形图不可见(以免挡住原来的双轴图),所以为容器添加了属性 fill 并将属性值设置为 none,但是这会导致该条形图无法成为鼠标事件的目标

所以需要为该容器设置 pointer-events 属性,并将属性值设置为 all 进行「校正」,使得该元素在任何情况下(无论属性 fill 设置为任何值)都可以响应指针事件

相关代码如下

js
  /**
   *
   * 为图表添加注释信息
   *
   */
  // 以 tooltip 的方式展示注释信息,即鼠标 hover 到特定的区域时才显示一个带有注释信息的浮窗
  svg.append("g")
      // 这里在原来的图表(折线图和条形图)上面再添加一层「不可见」的条形图
      // 所以这里将填充 fill 设置为 none
      .attr("fill", "none")
      // ⚠️ 由于属性 fill 设置为 none 的 SVG 元素无法成为鼠标事件的目标
      // 需要将 pointer-events 设置为 all 进行「校正」,则该元素在任何情况下(无论属性 fill 设置为任何值)都可以响应指针事件
      .attr("pointer-events", "all")
    // 以下代码是再绘制一个「不可见」的条形图
    // 大部分步骤都是和前面所绘制条形图的步骤一致
    .selectAll("rect")
    .data(data)
    .join("rect")
      .attr("x", d => x(d.year))
      .attr("width", x.bandwidth())
      // 这些矩形是铺满覆盖整个 svg 画布区域
      // 每个矩形的左上角纵轴定位 y 都是 0,即位于 svg 的最顶端
      .attr("y", 0)
      //  每个矩形的高度都是整个 svg 的高度
      .attr("height", height)
    // 最后为每个矩形 <rect> 元素之内添加 <title> 元素
    // 以便鼠标 hover 在相应的小矩形之上时,可以显示 tooltip 提示信息
    .append("title")
      // 设置 tooltip 的文本内容
      // 其中 {d.year 是所属的年份
      // 而 d.sales.toLocaleString("en") 是汽车销量,并将数字转换为特定语言环境下的字符串形式
      // 而 d.efficiency.toLocaleString("en") 就是汽车油耗,并将数字转换为特定语言环境下的字符串形式
      .text(d => `${d.year}
${d.sales.toLocaleString("en")} new cars sold
${d.efficiency.toLocaleString("en")} mpg average fuel efficiency`);

彩色折线图

参考

在该例子中实现了彩色折线图的方法,并不是将线段「拆分」(使用多个 <path> 元素)再为不同的部分设置不同颜色,而是依然使用一条线段(一个 <path> 元素)再通过渐变色(基于 <linearGradient> 元素)实现对不同的路径设置不同的颜色

渐变色

SVG 通过元素 <linearGradient> 创建渐变色

html
<svg>
  <defs>
    <!-- 定义渐变色 -->
    <linearGradient id="myGradient" gradientTransform="rotate(90)">
      <!-- 使用多个 <stop> 元素设置渐变色的切换 -->
      <!-- 属性 offset 设置该颜色的定位 -->
      <!-- 属性 stop-color 设置颜色 -->
      <stop offset="5%" stop-color="gold" />
      <stop offset="95%" stop-color="red" />
    </linearGradient>
  </defs>
  <!-- 使用渐变色 -->
  <!-- 通过 id 拼接出特定格式的字符串(链接)来使用前面所创建的渐变色 -->
  <circle cx="5" cy="5" r="4" fill="url('#myGradient')" />
</svg>

另外需要特别注意 <linearGradient> 元素的属性 gradientUnits 的作用,它是用于设置渐变的坐标(涉及 x1, x2 等属性)的参考系

与渐变色的相关代码如下

js
// 不同天气状况(用英文缩写表示)与不同的对象进行映射,每个对象中具有 label 和 color 属性
// * 属性 label 是对英文缩写的注释(具体的内容)
// * 属性 color 是对应的线段颜色
const conditions = new Map([
  // 晴朗
  ["CLR", { label: "Clear", color: "deepskyblue" }],
  // ...
])
// 设置颜色比例尺
// 为不同天气状况设置不同的配色
// 使用 d3.scaleOrdinal() 排序比例尺 Ordinal Scales 将离散型的定义域映射到离散型值域
// 具体参考官方文档 https://d3js.org/d3-scale/ordinal 或 https://github.com/d3/d3-scale/tree/main#scaleOrdinal
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-scale#排序比例尺-ordinal-scales
const color = d3.scaleOrdinal(
  // 设置定义域范围
  // 各种天气状况的名称(英文缩写)
  // 从 conditions.keys() 返回一个可迭代对象(包含映射的所有键名)读取共 6 种天气状况
  conditions.keys(),
  // 设置值域范围
  // 从 conditions.values() 返回一个可迭代对象(包含映射的所有值),构成为一个数组 Array.from()
  // 再从每个元素(一个对象)中读取出自定义的颜色值 d.color
  Array.from(conditions.values(), d => d.color))
  // 设置默认值,对于未手动设置颜色映射值的天气状况,采用黑色作为默认的映射颜色
  .unknown("black");
// ...
/**
 *
 * 创建线性渐变色
 *
 */
// 在参考的 Observable Notebook 使用了平台的标准库所提供的方法 DOM.uid(namespace) 创建一个唯一 ID 号
// 具体参考 https://observablehq.com/@observablehq/stdlib#cell-790
// 用作元素 <linearGradient> 的 id 属性值
// const colorId = DOM.uid("color");
// 这里使用硬编码(手动指定)id 值
const colorId = "colorGradient";
// 使用 svg 元素 <linearGradient> 定义线性渐变色,用于图形元素的填充或描边
svg.append("linearGradient")
  // 设置 id 属性
  // 使用该值来引用/指向该渐变色,以将其应用到图形元素上
  .attr("id", colorId)
  // 💡 设置 gradientUnits 属性,它用于配置渐变的坐标系(涉及 x1, x2 等属性)
  // 它的属性值可以设置为 `userSpaceOnUse` 或 `objectBoundingBox`(默认值)
  // 这两个值的区别在于坐标的**参考系**不同
  // * 属性值 userSpaceOnUse 表示渐变中的坐标值是相对于用户坐标系统的,即无论渐变被应用到哪个元素上,它的坐标都是相对于**整个 SVG 画布的 viewport 视图**
  // * 属性值 objectBoundingBox(默认值)表示渐变中的坐标值是相对于**引用元素的边界框**的,即渐变的坐标将根据引用元素的大小和位置进行缩放和定位,其中 (0,0) 表示边界框的左上角,(1,1) 表示边界框的右下角
  // 这里采用 userSpaceOnUse 即以整个 SVG 视图作为渐变坐标的参考系
  // 由于使用 <stop> 元素进行渐变色的切换时,其定位(属性 offset)是通过横坐标值 x(d.date) 和 svg 的宽度计算得到的(比例)
  // 而横坐标轴比例尺 x 和 svg 的宽度,这两者的坐标的参考系都是相对于整个 SVG 视图的
  .attr("gradientUnits", "userSpaceOnUse")
  .attr("x1", 0) // 渐变色的起始点的横坐标
  .attr("x2", width) // 渐变色的终止点的横坐标
  // 这里没有设置起始点和终止点的纵坐标,由于渐变色是沿横坐标轴变化的,而且它是应用到线段的描边上(并不是填充)
  // 所以纵坐标采用默认值 0% 即可
  // 进行二次选择,在元素 <linearGradient> 内添加一系列的 <stop> 元素,以切换渐变色
  .selectAll("stop")
  .data(data) // 绑定数据
  .join("stop") // 将 <stop> 元素添加到页面上
  // 通过 offset 属性设置该颜色的定位(相对于整个 SVG 视图),采用 x(d.date) / width 百分比形式
  // 所绑定的数据(对象)的属性 d.date 获取日期,再通过比例尺 x 的映射 x(d.date) 得到对应的横坐标
  // 再与 svg 的宽度相比 x(d.date) / width 得到百分比
  .attr("offset", d => x(d.date) / width)
  // 通过 stop-color 属性设置颜色
  // 所绑定的数据(对象)的属性 d.condition 获取天气状况,再通过比例尺 color 的映射 color(d.condition) 得到对应的颜色值
  .attr("stop-color", d => color(d.condition));
// ...
// 将线段路径绘制到页面上
svg.append("path") // 使用路径 <path> 元素绘制折线
  // 绑定数据
  // 这里采用 selection.datum(value) 为选择集中的每个元素上绑定的数据(该选择集里只有一个 <path> 元素)
  // ⚠️ 它与 selection.data(data) 不同,该方法不会将数组进行「拆解」,即这个方法不会进行数据链接计算并且不影响索引,不影响(不产生)enter 和 exit 选择集,而是将数据 value 作为一个整体绑定到选择的各个元素上,因此使用该方法选择集的所有 DOM 元素绑定的数据都一样
  // 具体参考官方文档 https://d3js.org/d3-selection/joining#selection_datum 或 https://github.com/d3/d3-selection/tree/main#selection_datum
  // 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/module-api/d3-module-selection#绑定数据
  .datum(data)
  // 只需要路径的描边作为折线,不需要填充,所以属性 fill 设置为 none
  .attr("fill", "none")
  // 设置描边颜色
  // 这里通过 id 值(拼接出特定格式的字符串/链接)来引用/使用前面所创建的渐变色
  .attr("stroke", `url('#${colorId}')`)
  // ...

另外该实例为折线图添加了网格参考线,相关代码如下

js
/**
 *
 * 绘制网格参考线
 *
 */
svg.append("g")
  .attr("stroke", "currentColor") // 设置网格参考线的颜色
  .attr("stroke-opacity", 0.1) // 调小网格线的透明度
  // 绘制纵向的网格参考线
  .call(g => g.append("g")
    // 使用 <line> 元素来绘制参考线
    .selectAll("line")
    // 绑定的数据是时间比例尺 x 所对应的刻度数据
    // 调用时间比例尺的方法 x.ticks() 会返回其定义域的数据(一个数组,被用作刻度值)
    .data(x.ticks())
    .join("line") // 将线段绘制到页面上
      // 分别设置各个线段的起始点的坐标 (x1, y1) 和终止点的坐标 (x2, y2),以绘制一条条纵向参考线(垂直于横坐标轴)
      // 起始点的横坐标 x1 由其所绑定的数据(日期)所决定
      // 通过时间比例尺 x(d) 通过日期映射/计算出相应的横坐标轴的值,并在该基础上加上 0.5px
      // ❓ 由于刻度线的宽度默认是 1px,所以进行 0.5px 偏移,让网格参考线位于刻度线的中间开始延伸
      .attr("x1", d => 0.5 + x(d))
      //(纵向参考线)终止点的横坐标 x2 和 x1 的值是一样的
      .attr("x2", d => 0.5 + x(d))
      // 起始点的纵坐标 y1 等于 marginTop(相当于位于 svg 顶部,但减去了顶部的留白)
      .attr("y1", marginTop)
      // 终止点的纵坐标 y2 等于 height - marginBottom(相当于位于 svg 底,但减去了底部的留白)
      .attr("y2", height - marginBottom))
  // 绘制横向的网格参考线
  .call(g => g.append("g")
    .selectAll("line")
    // 绑定的数据是线性比例尺 y 所对应的刻度数据
    .data(y.ticks())
    .join("line")
      .attr("y1", d => 0.5 + y(d))
      .attr("y2", d => 0.5 + y(d))
      .attr("x1", marginLeft)
      .attr("x2", width - marginRight));
对比

在之前的散点图的实例中,也有进行网格参考线的绘制可以查看对比,其实采用的原理是类似的,但是方法有些不同

在该实例中数据点之间并不是直接用直线段相连,而是设置了曲线插值器,D3 提供了多种内置的曲线插值器,可以生成多样的连线方式,相关代码如下

js
/**
 *
 * 绘制折线图内的线段
 *
 */
// 使用方法 d3.line() 创建一个线段生成器
// 线段生成器会基于给定的坐标点生成线段(或曲线)
// 具体可以参考官方文档 https://d3js.org/d3-shape/line 或 https://github.com/d3/d3-shape/tree/main#lines
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-shape#线段生成器-lines
const line = d3.line()
    // 设置两点之间的曲线插值器,这里使用 D3 所提供的一种内置曲线插值器 d3.curveStep
    // 该插值效果是在两个数据点之间,生成阶梯形状的线段
    // 具体效果参考 https://d3js.org/d3-shape/curve#curveStep 或 https://github.com/d3/d3-shape#curveStep
    .curve(d3.curveStep)
    // 设置横坐标读取函数
    // 该函数会在调用线段生成器时,为数组中的每一个元素都执行一次,以返回该数据所对应的横坐标
    // 这里基于每个数据点的日期 d.date 并采用比例尺 x 进行映射,计算出相应的横坐标
    .x(d => x(d.date))
    // 设置纵坐标读取函数
    .y(d => y(d.temperature));
提示

可以查看官方文档了解 D3 所提供的其他类型的曲线插值器,或者在 Spline Editor 这个 Observable Notebook 里进行具体的交互操作以查看具体的效果

另外有一个小点值得学习,就是为坐标轴添加注释的方法。

一般会在页面添加一个新的 <text> 元素,再将它移动到坐标轴的相应位置,但是在该实例中只需要添加很少的注释内容(温度单位),所以就直接修改最后一个纵坐标轴的最后一个刻度的刻度值,通过在已有的元素 <text> 内添加 <tspan> 元素以插入文本内容,相关代码如下

js
// 绘制纵坐标轴
svg.append("g")
    // 通过设置 CSS 的 transform 属性将纵向坐标轴容器「移动」到左侧
    .attr("transform", `translate(${marginLeft},0)`)
    // 纵轴是一个刻度值朝左的坐标轴
    .call(d3.axisLeft(y))
    // 删掉上一步所生成的坐标轴的轴线(它含有 domain 类名)
    .call(g => g.select(".domain").remove())
    // 💡 为纵坐标轴添加注释(温度的单位标记)
    // 这里并没有添加一个 <text> 元素
    // 而是直接选取坐标轴的最后一个刻度(通过 class 选择器 .tick:last-of-type)里面的 `<text>` 标签
    // 再在 `<text>` 元素里添加 `<tspan>` 元素,并设置内容,这样就可以为 `<text>` 元素添加额外的文本
    .call(g => g.select(".tick:last-of-type text").append("tspan").text("℃"));

还有一个类似的例子,折线图的颜色随着 y 值而改变

参考

该示例的大部分代码和前一个例子相同,不同点在于设置渐变色的方式。

由于在前一个示例中,颜色值是与天气状况相关,所以在设置渐变色时需要绑定的是原始数据,再从相应的数据点中获取到天气状况(类型),相应地渐变色的起始点和终止点是 x 轴的起点和终点(由于按照时间排序好的数据点是沿着 x 轴展开)。

而在这个例子中,颜色值与温度(y 轴的值)相关,所以在设置渐变色时可以不绑定原始数据,将 y 轴看作单位 1,构造出一个数组(里面的元素是从 0 到 1 的等差数列,不同的元素表示 y 轴的相对位置),将颜色值映射到不同的 y 轴位置,相应地渐变色的起始点和终止点需要是 y 轴的起点和终点。设置渐变色的相关代码如下

js
/**
 *
 * 创建线性渐变色
 *
 */
// 在参考的 Observable Notebook 使用了平台的标准库所提供的方法 DOM.uid(namespace) 创建一个唯一 ID 号
// 具体参考 https://observablehq.com/@observablehq/stdlib#cell-790
// 用作元素 <linearGradient> 的 id 属性值
// const colorId = DOM.uid("color");
// 这里使用硬编码(手动指定)id 值
const colorId = "colorGradient";
// 使用 svg 元素 <linearGradient> 定义线性渐变色,用于图形元素的填充或描边
svg.append("linearGradient")
    // 设置 id 属性
    // 使用该值来引用/指向该渐变色,以将其应用到图形元素上
    .attr("id", colorId)
    // 💡 设置 gradientUnits 属性,它用于配置渐变的坐标系(涉及 x1, x2 等属性)
    // 它的属性值可以设置为 `userSpaceOnUse` 或 `objectBoundingBox`(默认值)
    // 这两个值的区别在于坐标的**参考系**不同
    // * 属性值 userSpaceOnUse 表示渐变中的坐标值是相对于用户坐标系统的,即无论渐变被应用到哪个元素上,它的坐标都是相对于**整个 SVG 画布的 viewport 视图**
    // * 属性值 objectBoundingBox(默认值)表示渐变中的坐标值是相对于**引用元素的边界框**的,即渐变的坐标将根据引用元素的大小和位置进行缩放和定位,其中 (0,0) 表示边界框的左上角,(1,1) 表示边界框的右下角
    // 这里采用 userSpaceOnUse 即以整个 SVG 视图作为渐变坐标的参考系
    // 由于使用 <stop> 元素进行渐变色的切换时,其定位(属性 offset)是通过横坐标值 x(d.date) 和 svg 的宽度计算得到的(比例)
    // 而横坐标轴比例尺 x 和 svg 的宽度,这两者的坐标的参考系都是相对于整个 SVG 视图的
    .attr("gradientUnits", "userSpaceOnUse")
    .attr("x1", 0) // 渐变色的起始点的横坐标
    .attr("y1", height - marginBottom) // 渐变色的起始点的纵坐标
    .attr("x2", 0) // 渐变色的终止点的横坐标
    .attr("y2", marginTop) // 渐变色的终止点的纵坐标
    // 这里设置起始点和终止点的横坐标都是 0,由于渐变色是沿纵坐标轴变化的(以显示温度的变化),所以横坐标采用 0 即可
  // 进行二次选择,在元素 <linearGradient> 内添加一系列的 <stop> 元素,以切换渐变色
  .selectAll("stop")
    // 绑定数据
    // 使用方法 d3.ticks(start, stop, count) 根据 count 数量对特定范围(由 start 和 stop 指定)进行均分
    // 返回一个包含一系列分隔值的数组(一般作刻度值)
    .data(d3.ticks(0, 1, 10))
    // ⚠️ 注意这里所绑定的数据并不是原始数据集 data,而是构建出来的从 0 到 1,共 11 个元素的等差数列
    // 由于渐变色随纵坐标轴的值变化(而不是随时间变化,而数据点是随时间变化的,所以不是绑定原始数据集)
    // 这里构建出来数组(从 0 到 1 的等差数列),每个元素表示一个百分比,即相对于 y 轴的位置(偏移量)
  .join("stop")
    .attr("offset", d => d)
    // 针对(相对于纵坐标轴)不同的偏移量设置不同的颜色
    // 其中 color.interpolator() 返回颜色比例尺 color 所采用的插值器,即 d3.interpolateTurbo
    // 插值器会根据当前的相对偏移量 d(百分比形式,其范围是 [0, 1])进行采样,返回相应的颜色值
    // 这里共采样生成 11 种颜色
    .attr("stop-color", color.interpolator());
    // ❓ 在颜色采样时不需要基于温度值数据,所以前面所构建的(与温度相关)颜色比例尺实际没有用处
    // 这里可以直接使用 d3.interpolateTurbo 就不需要在前面构建颜色比例尺 ❓
    // .attr("stop-color", d3.interpolateTurbo);

此外还有一个例子很类似,折线图的颜色也是随着 y 值而改变,但是并没有彩虹色这么多,只是基于 y 值与一个 threshold 阈值/临界值的大小关系,而编码两种不同的颜色

参考

该示例的大部分代码和前一个例子相同,不同点在于设置渐变色的方式。

由于在前一个示例中,将多种颜色值与映射到不同的 y 轴位置,需要构建出一个含有多个元素的数组,里面的元素是从 0 到 1 的等差数列(这是将 y 轴看作单位 1,不同的元素表示 y 轴的相对位置)

而在这个例子中由于只需要将两种颜色分别映射到 y 轴上下两部分(这两部分的分隔位置是根据数据集的中位数而定的),所以构建出一个二元数组

js
[
  {offset: y(threshold) / height, color: "red"},
  {offset: y(threshold) / height, color: "black"}
]

二元数组的顺序需要与渐变色的起始点和终止点相匹配才可以设置相应的颜色,⚠️ 需要留意 svg 的坐标体系中向下是正方向,所以渐变色的起始点 (0, 0) 是在 y 轴的顶部,终止点 (0, height) 是在 y 轴的底部,所以以上的二元数组是将 y 轴上半部分的线段设置为红色,下半部分的线段设置为黑色。设置渐变色的相关代码如下

js
/**
 *
 * 创建线性渐变色
 *
 */
// 使用 Observable 平台的标准库所提供的方法 DOM.uid(namespace) 创建一个唯一 ID 号
// 具体参考 https://observablehq.com/@observablehq/stdlib#cell-790
// 用作元素 <linearGradient> 的 id 属性值
const gradient = DOM.uid();
// 使用 svg 元素 <linearGradient> 定义线性渐变色,用于图形元素的填充或描边
svg.append("linearGradient")
    .attr("id", gradient.id)
    .attr("gradientUnits", "userSpaceOnUse")
    .attr("x1", 0) // 渐变色的起始点的横坐标
    .attr("y1", 0) // 渐变色的起始点的纵坐标
    .attr("x2", 0) // 渐变色的终止点的横坐标
    .attr("y2", height) // 渐变色的终止点的纵坐标
    // 这里设置起始点和终止点的横坐标都是 0,由于渐变色是沿纵坐标轴变化的(以显示温度的变化),所以横坐标采用 0 即可
    // ⚠️ 注意 svg 的坐标体系中向下是正方向,所以渐变色的起始点 (0, 0) 是在 y 轴的顶部,终止点 (0, height) 是在 y 轴的底部
  // 进行二次选择,在元素 <linearGradient> 内添加一系列的 <stop> 元素,以切换渐变色
  .selectAll("stop")
    // 绑定数据
    // 手动构建出一个数组,每个元素都是一个对象,其中包含了属性 offset(偏移量)和属性 color(对应的颜色)
    // 其中第一个元素的偏移量是一个百分比 y(threshold) / height 即中位数相对于 y 轴的位置,颜色是红色
    // 而第二个元素的偏移量是一样的,也是 y(threshold) / height 中位数相对于 y 轴的位置,颜色是黑色的
    .data([
      {offset: y(threshold) / height, color: "red"},
      {offset: y(threshold) / height, color: "black"}
    ])
  // 将一系列的 <stop> 元素添加到 <linearGradient> 元素里
  .join("stop")
    .attr("offset", d => d.offset) // 设置 offset 偏移量
    .attr("stop-color", d => d.color); // 设置 stop-color 颜色值
  // 根据以上手动构建的数组,可以知道会对应生成两个 <stop> 元素
  // ⚠️ 因为 svg 的坐标体系中向下是正方向,渐变色的起始点是 (0, 0) 终止点是 (0, height)
  // 所以从起始点到第一个 <stop> 所设置的偏移量的位置(y 轴的中间)为止,这一个范围的线段都是红色
  // 由于第二个 <stop> 所设置的偏移量和第一个 <stop> 的偏移量一样,所以从 y 轴的中间位置到 y 轴底部,这一个范围的线段都是黑色

带标注的折线图

可以为折线图的添加标注,突出显示某些特别的数据点,例如在每个数据点处添加了标签来显示具体的数值,可以替代(省略)y 轴。

参考

其中为线段添加标注的相关代码如下

js
/**
 *
 * 为线段添加标注
 *
 */
// 在上述的每个线段容器 <g> 中再添加一个 <g> 作为子元素,用作该线段标签的容器
// 新添加的元素构成新的选择集(组)
// 💡 由于新的选择集组,每个都只包含一个元素,所以会**继承**父元素所绑定的数据
// 前面为每个(父元素)<g> 绑定的数据是 InterMap 对象(映射)的一个键值对
// 而绑定数据后,会将这个键值对(隐式)转换为数组 [key, arrValue] 形式 ❓
// 这里的 key 就是水果类型,arrValue 就是属于该水果类型的数据点
series.append("g")
  // 设置文字笔画端点的样式
  .attr("stroke-linecap", "round")
  // 设置文字笔画之间连接样式(圆角让连接更加平滑)
  .attr("stroke-linejoin", "round")
  // 设置文字对齐方式("middle" 表示居中对齐)
  .attr("text-anchor", "middle")
  // 使用 selection.selectAll() 基于原有的选择集进行「次级选择」,选择集会发生改变
  // 详细介绍可以查看这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-binding#次级选择
  .selectAll()
  // 返回的选择集是由多个分组(各个 <g> 容器中)的虚拟/占位 <text> 元素构成的
  // ⚠️ 使用 select.selectAll() 所创建的新选择集会有多个分组
  // 由于新的选择集会创建多个分组,那么原来所绑定数据与(选择集中的)元素的对照关系会发生改变
  // 从原来的一对一关系,变成了一对多关系,所以新的选择集中的元素**不会**自动「传递/继承」父节点所绑定的数据
  // 所以如果要将原来选择集中所绑定的数据继续「传递」下去,就需要手动调用 selection.data() 方法,以显式声明要继续传递数据
  // 在这种场景下,该方法的入参应该是一个返回数组的**函数**
  // 每一个分组都会调用该方法,并依次传入三个参数:
  // * 当前所遍历的分组的父节点所绑定的数据 datum
  // * 当前所遍历的分组的索引 index
  // * 选择集的所有父节点 parent nodes
  // 详细介绍可以查看这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-binding#绑定数据
  // 所以入参 d 是 InterMap 对象(映射)的一个键值对(转换为二元数组 [key, arrValue] 形式)
  // 这里的 d[1] 是指所绑定的数据(一个二元数组 [key, arrValue])的第二个元素,就是当前水果类型的数据点
  .data(d => d[1])
  .join("text") // 将一系列 <text> 元素绘制到页面上
  .text(d => d.value) // 设置文本内容,就是对应的水果数值
  .attr("dy", "0.35em") // 设置文本在纵轴的偏移量
  // 设置 <text> 元素的定位 (x, y)
  .attr("x", d => x(d.date)) // 横坐标值
  .attr("y", d => y(d.value))  // 纵坐标值
  // 对于每个 <text> 元素执行以下函数
  // 基于它在选择集中的的索引值 i 与该选择集的所有数据集 data 的长度,判断当前所遍历的 <text> 是否为最后一个元素
  .call(text => text.filter((d, i, data) => i === data.length - 1)
    // 如果是最后一个 <text> 元素,就在其中插入 <tspan> 元素
    .append("tspan")
    // 设置字体样式为粗体
    .attr("font-weight", "bold")
    // 内容是当前 <text> 元素所绑定的数据的属性 d.fruit 即该数据点所属的水果类型
    .text(d => ` ${d.fruit}`))
  // 以上操作可以在每个线段的最后添加上对应的水果类型标记
  // 使用方法 selection.clone(deep) 克隆选择集中的元素,如果参数 deep 是 true,表示进行深度拷贝(包含子元素)
  // 这里会对前面所有 <text> 元素进行复制
  // 再通过方法 selection.lower() 采用 prepend 方式(作为父节点的第一个子元素),重新将选择集的元素插入页面
  // 即这些拷贝而生成的 <text> 元素会在(标注信息)容器中排在较前的位置,而原本的 <text> 元素会排在较后的位置
  // 根据 SVG 绘图顺序与显示层叠关系,原本的 <text> 元素会覆盖掉拷贝生成的 <text> 元素
  // 实际上拷贝生成的 <text> 元素的作用只是作为白色的描边(背景),让原本的 <text> 元素的文字内容更易阅读
  .clone(true).lower()
  // 没有填充色
  .attr("fill", "none")
  // 描边为白色
  .attr("stroke", "white")
  // 设置描边宽度
  .attr("stroke-width", 6);

其中有一个小技巧值得学习,如果直接在线段上添加标注 inline label,文字与线段重叠会降低可读性,可以将这些 <text> 元素拷贝一份,并将它们的描边调粗、颜色设置为白色,置于原文字和线段之间(相当于为文字添加白色背景),让文字内容更易阅读

提示

对于多线段的折线图,也可以在图表中添加标注,以指明各条线段的具体代表哪一种数据集

另一种区分多线段的方式是使用图例 legend 进行说明,但是采用这种设计时,读者必须来回查看图表和图例,会造成认知上的反复切换,而且对理解数据毫无帮助。更佳的解决方案或许是直接在折线上进行标注。

在 Observable 社区中,有一个示例 Directly labelling lines(这是翻译版本)介绍了一种算法/步骤,可以自动为不同线段选择标签定位的,具体流程如下:

  1. 对图表上的每个数据点计算其 voronoi 图
  2. 对每条折线上的点,计算其最大的 voronoi 单元
  3. 将标签放置在最大 voronoi 单元所对应的数据点上
  4. 计算每个标签的「边界盒子」 bounding box
  5. 将标签移向 voronoi 单元的中心,直到折线上没有任何数据点落入到标签的「边界盒子」内

该方法对于三条或更少的线段的图表效果非常好,有时甚至可以处理更多的线条,有时候它在小屏幕上效果也行。

值得对该 Notebook 进行深入研究,对其进行扩展或修改以适用更广的使用场景。

交互性

带有提示框的折线图

为单一条折线添加交互式 tooltip 提示框功能,关键是判断鼠标当前的位置距离哪个数据点最近。

参考

由于该实例中数据点沿着横坐标轴(时间轴)均匀分布(不存在重叠值的情况),所以可以只需要考虑水平方向上鼠标所在的位置与哪个数据点最近。

该示例使用数组分割器 bisector 来寻找最近的数据点(所对应的索引值),大概流程如下:

  1. 使用方法 d3.pointer(event) 获取指针的坐标值
  2. 使用方法 x.invert(rangeValue) 传递一个横坐标轴的值域的值 value,反过来得到定义域的值
  3. 使用方法 bisect(arr, xValue) 基于鼠标所在位置(所对应的横坐标轴定义域的值 xValue),使用分割器对数组 arr 进行「临近分割」(一分为二),返回索引值 i,该索引值所对应的数据点是最靠近鼠标(只考虑/基于横坐标值)

相关的代码如下:

js
// 使用方法 d3.bisector(accessor) 创建一个数组分割器 bisector,它会基于特定值,将(有序)的数组里的元素一分为二
// 参数 accessor 是访问函数,在调用分割器对数组进行分割时,数组的每个元素都调用该访问函数,将函数返回的值用于分割/比较时该元素的代表值
// 这里返回的值是时间(用于计算数据点所对应的横坐标值)
// bisector.center 将分割的标准设置为「临近分割」
// 关于分割器的介绍参考官方文档 https://d3js.org/d3-array/bisect#bisector
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-process#二元分割
const bisect = d3.bisector((d) => d.Date).center;
// 鼠标指针在 svg 元素上移动时,触发该函数
function pointermoved(event) {
  // 基于将鼠标所在位置(对应的横坐标值 x),使用分割器对数组 aapl 进行「临近分割」
  // 💡 返回索引值 i,如果将当前鼠标所对应的横坐标值插入到该位置(可以使用数组的 arr.splice() 方法),依然保持数组有序
  // 💡 也就是所该索引值所对应的数据点是最靠近鼠标(只考虑/基于横坐标值)
  // 首先使用 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#处理事件
  // 然后通过 continuous.invert(value) 向比例尺传递一个值域的值 value,反过来得到定义域的值
  // 这对于交互很有用,例如根据鼠标在图表的位置,反向求出并显式对应的数据
  // ⚠️ 该方法只支持值域为数值类型的比例尺,否则返回 `NaN`
  const i = bisect(aapl, x.invert(d3.pointer(event)[0]));
  // ❓ null 是 display 属性的无效值,所以采用默认值(实际上继承自父元素,其实是 block ❓)
  // tootlip 默认是隐藏的,这里将其显示出来
  tooltip.style("display", null);
  // 使用 CSS 的 transform 属性将 tooltip 移动到相应的位置
  // 索引值 i 是 ☝️ 前面对数组 aapl 进行「临近分割」所得到的
  // 通过 aapl[i] 获取最靠近鼠标的数据点,再通过比例尺 x 或 y 进行映射得到数据点相应的横纵坐标值,作为 tooltip 容器的坐标值
  // 所以 tooltip 容器会定位到离鼠标最近的数据点上
  tooltip.attr(
    "transform",
    `translate(${x(aapl[i].Date)},${y(aapl[i].Close)})`
  );
  // ...
}

另外关于制作 tooltip 的代码中有很多小技巧值得学习,例如根据文本内容来调整提示框的大小;使用 <tspan> 元素实现 2 行文本布局;如何将大写字母和小写字母的命令相结合,通过 <path> 元素绘制一个 callout (带小三角形指针的)对话框,相关代码如下:

js
// 将文本使用一个 callout 提示框包裹起来,而且根据文本内容设置提示框的大小
// 该函数的第一个参数 text 是包含一个 <text> 元素的选择集
// 第二个元素 path 是包含一个 <path> 元素的选择集
function size(text, path) {
  // 使用方法 selection.node() 返回选择集第一个非空的元素,这里返回的是 <text> 元素
  // 然后通过 SVGGraphicsElement.getBBox() 获取到该元素的大小尺寸
  // 返回值是一个对象 {x: number, y: number, width: number, height: number } 表示一个矩形
  // 这个矩形是刚好可以包裹该 svg 元素的最小矩形
  const { x, y, width: w, height: h } = text.node().getBBox();
  console.log({x, y})
  // 通过 CSS 属性 transform 调整文本的定位(以关联数据点的位置作为基准,因为 tooltip 容器已经基于数据点进行了定位),让文本落入提示框中
  // 在水平方向上,向左偏移 <text> 元素宽度的一半
  // 在垂直方向上,向下偏移 15px(大概一个半字符高度)与原来纵坐标值 y 的差值
  // 这样就可以让文本与数据点在水平方向上居中对齐,在垂直方向上位于数据点的下方
  text.attr("transform", `translate(${-w / 2},${15 - y})`);
  // 绘制 tooltip 边框,设置 <path> 元素的属性 `d`(具体路径形状)
  // 命令 M 是将画笔进行移动
  // 画笔的起始点是以关联的数据点的位置作为基准,因为 tooltip 容器已经基于数据点进行了定位
  // (M${-w / 2 - 10},5 相当于将画笔移到数据点的左侧,距离大小为文本宽度的一半并加上 10px,垂直方向移到数据点的下方,距离 10px
  // 接着绘制提示框的顶部边框部分
  // 命令 H 绘制水平线,H-5 从画笔所在的位置绘制一条水平线到距离数据点 -5px 的位置(即相对于向右绘制一条水平线)
  // 然后使用 l 命令,采用相对坐标(基于前一个命令)在中间绘制出一个小三角凸起(构成 tooltip 的指针形状,指向数据点)
  // 然后再使用命令 H 绘制顶部边框的(右边)另一半的水平线
  // 命令 V 绘制垂直线,然后使用 l 命令,采用相对坐标(基于前一个命令)绘制底部边框的水平线
  // 最后使用 z 命令自动绘制一条(左侧边框)垂直线,以构成一个闭合边框
  // 最终绘制出的 tooltip 边框,距离文本内容 10px(可以看作是 padding)
  path.attr(
    "d",
    `M${-w / 2 - 10},5H-5l5,-5l5,5H${w / 2 + 10}v${h + 20}h-${w + 20}z`
  );
}

多系列折线图

当折线图的系列数较多时,可视性可能较差,可以为图表添加交互以便按需展示更多细节,例如高亮距离鼠标指针位置最近的数据点及相应折线

参考

要实现邻近数据点的高亮,关键是要找到离鼠标指针(所在位置)最近的是哪个数据点,在该实例中使用穷举法(因为对于较小的数据集,这个方法也足够快了),即直接遍历所有数据点,并计算它们与指针位置的距离,以此来找到最近的数据点,相关代码如下

js
/**
 *
 * 创建 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()); // 取消触控事件的默认行为
// 该函数在鼠标指针进入 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});
}
说明

维诺图 Voronoi Diagram 可以基于数据点对特定区域(面积)进行划分,让所得的每一个小面积/单元格 cell 只包含一个数据点,如果为这些面积添加相关指针事件的监听器,可以实现鼠标悬停到某个区域时,高亮显示相应的数据点(及所属系列的折线)

D3 提供了一个关于 Voronoi 维诺图和 d3-delaunay 模块的介绍可以查看另一篇笔记

在该官方样例中使用了 d3-delaunay 模块,相关代码如下

js
// 使用 <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()
    );

但实际上在交互中没有利用它,只是演示了使用 voronoi 维诺图对整个 svg 面积进行划分。

如果要利用维诺图划分来获取离鼠标指针(所在位置)最近的数据点,可以参考下一个例子 Marey 火车时刻表

时刻线图

这是实现多系列折线图 tooltip 交互的另一种方案,基于数据点通过 Voronoi 维诺图对 svg 的面积进行划分,实现鼠标悬浮在 svg 元素上,会有一个提示框显示相近数据点的信息

参考

其中关于创建 tooltip 以及实现交互的相关代码如下

js
/**
 * 创建 tooltip 以及实现交互
 */
// 创建一个 <g> 元素,作为一个 tooltip 的大容器
const tooltipContainer = svg.append("g")
// 创建一个 <g> 元素,作为 tooltip 弹出提示框的容器
const tooltip = tooltipContainer.append("g")
  // 设置字体样式
  .style("font", "10px sans-serif");
// 在容器中添加一个 <path> 元素,用于绘制 tooltip 的外框(带小尖角)
const path = tooltip.append("path")
  .attr("fill", "white");
// 在容器中添加一个 <text> 元素,用于显示提示内容
const text = tooltip.append("text");
// 在 <text> 元素内添加一个 <tspan> 元素
// 它相当于在 svg 语境下的 span 元素,用于为部分文本添加样式
const line1 = text.append("tspan")
  // 设置该元素的定位,位于 <text> 元素的左上角(作为第一行)
  .attr("x", 0)
  .attr("y", 0)
  // 设置字体样式为加粗
  .style("font-weight", "bold");
// 继续在 <text> 元素内添加一个 <tspan> 元素
const line2 = text.append("tspan")
  .attr("x", 0)
  // 纵向定位是 1.1em 相当于在第二行(em 单位是与字体大小相同的长度)
  .attr("y", "1.1em");
// 继续在 <text> 元素内添加一个 <tspan> 元素
const line3 = text.append("tspan")
  .attr("x", 0)
  // 纵向定位是 2.2em 相当于在第三行
  .attr("y", "2.2em");
// 使用 d3.utcFormat(specifier) 创建一个时间格式器,用于 tooltip 内容的格式化
// 将 Date 对象格式化为特定的字符串,参数 specifier 用于指定字符串的格式
// 其中 %-I 表示小时(采用 12 小时制),分隔符 `-` 表示对应单个数字(例如凌晨 1 点)不使用填充字符将其变成双位数字
// 其中 %M 表示分钟
// 其中 %p 表示上午或下午,用字符串 AM 或 PM 表示
const formatTime = d3.utcFormat("%-I:%M %p");
// 将所有列车停靠所有车站的信息提出提出出来,构成一个数组,对应于图表上的数据点
// 首先通过 data.map(d => d.stops.map(s => ({train: d, stop: s}))) 遍历所有列车,返回一个嵌套数组
// 即每个元素也是一个数组,表示当前列车所停靠的所有站点
// 而这些内嵌数组的元素是一个对象,具有如下属性
// * train 当前列车的在 data 数据集中对应的数据
// * stop 当前列车所停靠站点的数据
// 再通过 d3.merge() 将嵌套数据「拍平」,它可以将二次嵌套的数据(即数组内的元素也是数组),变成扁平化的数据
// 具体参考官方文档文档 https://d3js.org/d3-array/transform#merge
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-process
// 所以这个 stops 数组的每个元素是指某个列车停靠某个车站
const stops = d3.merge(alldata.map(d => d.stops.map(s => ({ train: d, stop: s }))))
// 创建一个维诺图生成器,在鼠标悬浮交互中使用
// 先使用 d3.Delaunay.from(points) 基于数据集 points 生成一个三角剖分器 delaunay
// 数据集的格式有特定的要求,它是一个嵌套数组,即每个元素也还是一个数组,嵌套数组的第一个元素是数据点的横坐标值,第二个元素是数据点的纵坐标值
// 由于这里传入的数组是 stop,它的格式并不规范,所以还需要设置 access function 访问函数
// 其中 x(d.stop.station.distance) 是横坐标值,基于当前列车所停靠车站(与第一个车站相比)的距离,并通过横坐标轴比例尺 x 进行映射而得到
// 其中 y(d.stop.time) 是纵坐标值,基于当前列车所停靠车站的时刻值,并提供纵坐标值比例尺 y 进行映射而得到
// 然后调用方法 delaunay.voronoi(bounds) 创建一个维诺图生成器 voronoi,其中参数 bounds 设定视图的边界
// 关于维诺图的详细介绍可以参考官方文档 https://d3js.org/d3-delaunay/delaunay
// 或查看这一篇笔记 https://datavis-note.benbinbin.com/article/d3/module-api/d3-module-delaunay
const voronoi = d3.Delaunay
  .from(stops, d => x(d.stop.station.distance), d => y(d.stop.time))
  .voronoi([0, 0, width, height])
// 创建一个 <g> 元素,作为 voronoi 维诺图的容器
tooltipContainer.append("g")
  // 由于所绘制的维诺图只用于交互,而不需要显示出来,所以填充颜色 fill 设置为 none
  .attr("fill", "none")
  // 由于 fill 设置为 none,但是需要借助这些元素进行交互(用户通过指针与维诺图进行交互)
  // 所以需要显式地允许指针事件 "pointer-events", "all"
  .attr("pointer-events", "all")
  // 使用 <path> 路径元素绘制维诺图
  .selectAll("path")
  // 绑定数据(stops 是各列车和各站点对应构建出来的数组,对应于图表上的数据点)
  .data(stops)
  .join("path") // 将一系列 <path> 元素(但是还没有设置具体的路径)挂载到容器中
  // 为每个 <path> 元素设置属性 `d`(具体的路径形状)
  // 通过调用维诺图生成器的方法 voronoi.renderCell(i) 绘制出第 i 格 cell 单元格
  // 返回一个路径字符串(用作 svg 元素 <path> 的属性 d 的属性值)
  .attr("d", (d, i) => voronoi.renderCell(i))
  // 通过方法 selection.on() 为选择集的元素设置事件监听器,以响应用户操作实现与图表的交互
  // 为每个维诺图 cell 单元格设置 mouseout 事件监听器
  // 当指针移离该单元格时,隐藏 tooltip
  .on("mouseout", () => tooltip.style("display", "none"))
  // 为每个维诺图 cell 单元格设置 mouseover 事件监听器
  // 当指针悬浮在该单元格时,显示 tooltip
  .on("mouseover", (event, d) => {
    // ❓ null 是 display 属性的无效值,所以采用默认值(实际上继承自父元素,其实是 block ❓)
    tooltip.style("display", null);
    // 设置 tooltip 里的文本内容
    // d 是当前鼠标所悬浮的维诺图 cell 单元格所绑定的数据
    line1.text(`${d.train.number}${d.train.direction}`); // 第一行的内容是列车号码和列车的方向
    line2.text(d.stop.station.name); // 第二行的内容是所停靠的车站名称
    line3.text(formatTime(d.stop.time)); // 第三行是停靠时刻值(通过方法 formatTime() 进行格式化)
    // 设置 tooltip 边框的描边颜色,通过 colors 对象进行映射得到与列车类型所对应的颜色值
    path.attr("stroke", colors[d.train.type]);
    // 使用方法 selection.node() 返回选择集第一个非空的元素,这里返回的是 <text> 元素
    // 然后通过 SVGGraphicsElement.getBBox() 获取到该元素的大小尺寸
    // 返回值是一个对象 {x: number, y: number, width: number, height: number } 表示一个矩形
    // 这个矩形是刚好可以包裹该 svg 元素的最小矩形
    const box = text.node().getBBox();
    // 绘制 tooltip 边框,设置 <path> 元素的属性 `d`(具体路径形状)
    // 命令 M 是将画笔移动到左上角
    // 命令 H 绘制水平线,并在中间有一个小三角凸起(构成 tooltip 的指针形状,指向数据点)
    // 命令 V 绘制垂直线
    // 最终绘制出的 tooltip 边框,距离文本内容 10px(可以看作是 padding)
    path.attr("d", `
        M${box.x - 10},${box.y - 10}
        H${box.width / 2 - 5}l5,-5l5,5
        H${box.width + 10}
        v${box.height + 20}
        h-${box.width + 20}
        z
      `);
    // 通过 CSS 的 transform 属性将 tooltip 「移动」到相应位置
    // 其中横坐标值是基于 d.stop.station.distance(当前列车所停靠车站与第一个车站的距离)并通过横坐标轴比例尺 x 进行映射
    // 纵坐标值是基于 d.stop.time(所停靠车站的时刻值)并提供纵坐标比例尺 y 进行映射
    // 为了将 tooltip 的指针形状与数据点对齐,需要对横纵坐标进行「校正」调整
    // 横坐标值偏移 box.width / 2 即 box 宽度的一半,纵坐标值偏移 28px 大概 3 行文字高度
    tooltip.attr("transform", `translate(${x(d.stop.station.distance) - box.width / 2},${y(d.stop.time) + 28
      })`);
  });

实现 tooltip 交互的主要流程如下:

  1. 先使用 d3.Delaunay.from(points) 基于数据集 points 生成一个三角剖分器 delaunay
  2. 使用 voronoi.renderCell(i) 依次生成维诺图的 cell 单元格
注意

通过 <path> 元素进行维诺图的 cell 单元格的绘制,但是由于它们并不需要在页面上显示出来,所以没有填充和描边,将元素的属性 fill 的值设置为 "none"

但是需要借助这些元素进行交互(用户通过指针与维诺图进行交互),所以需要显式地允许指针事件,即将元素的属性 pointer-events 的值设置为 "all"

  1. 为这些 cell 单元格设置鼠标相关的事件监听器(mouseoutmouseover 事件),以响应用户操作实现与图表的交互

其中关于制作 tooltip 的代码中有很多小技巧值得学习:

  • 在 svg 元素 <text> 中添加 <tspan> 就可以实现只为部分文本添加样式,它相当于在 svg 语境下的 span 元素。例如在本实例中,通过添加三个 <tspan> 元素,并调整它们相对于父元素 <text> 的定位(元素的属性 xy 的值),可以实现 3 行文本布局
  • 通过调用 svg 元素的方法 SVGGraphicsElement.getBBox() 获取到该元素的大小尺寸,返回值是一个对象 {x: number, y: number, width: number, height: number } 表示一个矩形,该矩形是刚好可以包裹该 svg 元素的最小矩形,可以根据该矩形创建一个适配 <text> 元素内容大小的 tooltip
  • 使用元素 <path> 创建 tooltip 边框时,可以将大写字母和小写字母的命令结合使用,创建复制的形状,例如创建一个指针(小三角形)形状,以指向数据点
提示

关于将 voronoi 维诺图应用于数据可视化交互的更多例子

另外在设置坐标轴的刻度线时,方法 axis.ticks(counts) 除了可以传递数值 counts 作为刻度线数量的参考值;如果坐标轴是时间轴(对应的比例尺是时间比例尺),还可以传递 interval 时距器,即 axis.ticks(interval),用于生成特定间距的时间。在该实例中纵坐标轴是时间轴,相关代码如下

js
// 绘制纵坐标轴的方法
svg.append("g")
  // 通过设置 CSS 的 transform 属性将纵向坐标轴容器「移动」到左侧
  .attr("transform", `translate(${margin.left},0)`)
  // 纵轴是一个刻度值朝左的坐标轴
  .call(d3.axisLeft(y)
    // 纵坐标是时间比例尺,通过 axis.ticks(interval) 生成时间轴
    // 具体参考官方文档 https://d3js.org/d3-axis#axis_ticks
    // 参数 interval 是时距器,用于生成特定间距的时间
    // 关于时距器的介绍,可以参考这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-process#时间边距计算器
    // 这里使用一个 D3 内置的时距器 d3.utcHour 创建一个以小时为间距的 interval
    .ticks(d3.utcHour)
    // 通过 axis.tickFormat(specifier) 设置刻度值的格式
    // 参数 specifier 是时间格式器,将一个 Date 对象格式化 format 为字符串
    // 关于时间格式器的介绍,可以参考这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-process#时间格式器
    // 其中 %-I 表示小时(采用 12 小时制),分隔符 `-` 表示对应单个数字(例如凌晨 1 点)不使用填充字符将其变成双位数字
    // 其中 %p 表示上午或下午,用字符串 AM 或 PM 表示
    .tickFormat(d3.utcFormat("%-I %p")))
  // 删掉上一步所生成的坐标轴的轴线(它含有 domain 类名)
  .call(g => g.select(".domain").remove())
  // 复制了一份刻度线(通过 CSS 类名 ".tick line" 选中它们),作为网格参考线的横线
  // 然后再用 lower() 方法将该插入的克隆移到父元素的顶部,作为第一个子元素,避免遮挡刻度线(根据 svg 元素渲染顺序,它们会先绘制,然后被之后绘制的刻度线覆盖/遮挡)
  .call(g => g.selectAll(".tick line").clone()
    .attr("stroke-opacity", 0.2) // 设置参考线的透明度为 20%
    .attr("x2", width)) // 调整参考线的终点位置(往右移动)

交互式绘制折线图

这个实例让用户可以通过拖拽的方式绘制折线图

参考
  • 解读的官方样例为 You Draw It
  • 对代码进行注释解读的 Notebook 是这个
  • 复现可以查看该网页,完整代码可以查看 这里

主要是通过 d3-drag 模块实现拖拽交互

对于用户通过交互所生成的数据的后处理/约束的做法值得学习。在这个例子中并不会将每次捕获所得的鼠标坐标点都直接拿来绘制折线图(否则会让折线图看起来就和鼠标轨迹一样,小的曲曲折折特别多,而且波动/抖动频率很高),需要先对横坐标进行处理,以确保创建/修改的数据点都约束在横坐标轴的刻度线上。

如果用户拖拽速度过快时,drag 事件分发的速度可能跟不上,则会出现折线断连的情况,所以在这个例子中还会对折线的「缺口」进行修补的操作。

与拖拽交互的相关代码如下:

js
// 在拖拽开始时执行函数 dragstarted
// 初始化 <path> 路径元素所绑定的数据
// 并触发函数 dragged(创建第一个数据点)
function dragstarted() {
  // 使用比例尺的方法 xScale.ticks(count) 获取它对横坐标定义域范围的采样结果
  // 返回结果是一个数组(作为刻度),其中元素的数量(刻度线的数量)是基于参数 count 进行调整的,以保证刻度的可读性
  // 具体介绍可以参考官方文档 https://d3js.org/d3-scale/linear#linear_ticks
  // 然后对数组进行遍历,将它的每个元素 x 转换为 `[x, null]` 二元数组的形式
  // 💡 进行如此转换是因为二元数组的格式和数据点的横纵坐标值相兼容(数据点的 x 值来源/约束在于横坐标轴的刻度值,它是已知的;而 y 值由用户绘制生成,它是未知的,所以用 null 来替代/占位)
  // 所以 path 所绑定的数据
  data = xScale.ticks(xSamples).map(x => [x, null]);
  // 为 <path> 元素(path 选择集中只有它一个元素)绑定数据
  // 这里采用 selection.datum(value) 为选择集中的每个元素上绑定的数据(该选择集里只有一个 <path> 元素)
  // ⚠️ 它与 selection.data(data) 不同,该方法不会将数组进行「拆解」,即这个方法不会进行数据链接计算并且不影响索引,不影响(不产生)enter 和 exit 选择集,而是将数据 value 作为一个整体绑定到选择的各个元素上,因此使用该方法选择集的所有 DOM 元素绑定的数据都一样
  // 具体参考官方文档 https://d3js.org/d3-selection/joining#selection_datum 或 https://github.com/d3/d3-selection/tree/main#selection_datum
  // 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/module-api/d3-module-selection#绑定数据
  path.datum(data);
  // 这里手动触发函数 dragged 这样就可以在按下鼠标时就创建一个数据点(作为折线的起始点)
  // 因为如果等到(拖拽)鼠标移动时才开始创建数据点,折线的起始点可能就不在按下鼠标的地方,看起来就像是鼠标定位飘了 ❓
  // 通过 func.call(context) 的形式来调用,以修改/设定它的上下文
  // 将函数 dragged 的 this 指向当前函数的 this(当前回调函数的 this 是指向触发 `start` 事件的触发元素 `<rect>` 矩形)
  dragged.call(this);
}
// 使用方法 d3.bisector(accessor) 创建一个数组分割器 bisector,它会基于特定值,将(有序)的数组里的元素一分为二
// 参数 accessor 是访问函数,在调用分割器对数组进行分割时,数组的每个元素都调用该访问函数,将返回值用于分割/比较时该元素的代表值
// bisector.center 将分割的标准设置为「临近分割」
// 关于分割器的介绍参考官方文档 https://d3js.org/d3-array/bisect#bisector
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-process#二元分割
// 这里对遍历的元素(一个二元数组)进行解构,基于它的第一个元素(它是横坐标值,时间)来比较
const bisectX = d3.bisector(([x]) => x).center;
// 在拖拽期间不断执行函数 dragged
// 该函数的参数是拖拽事件对象 event,这里对其进行解构 {x, y} 以得到拖拽目标(相对于其父容器,即 <svg> 元素)的横纵坐标
// 在这个实例中从视觉上没有看到拖拽的元素(因为拖拽的是隐形的 <rect> 元素),就可以理解为鼠标的横纵坐标值
function dragged({ x, y }) {
  // 通过比例尺的方法 `scale.invert()` 基于坐标值(值域的值),反过来得到定义域的相应值
  const dx = xScale.invert(x);
  const dy = yScale.invert(y);
  // ⚠️ 但是并不会将每次捕获所得的鼠标坐标点都直接拿来绘制折线图(否则会让折线图看起来就和鼠标轨迹一样,小的曲曲折折特别多,而且波动/抖动频率很高 ❓ ),需要先对横坐标进行处理,以确保创建/修改的数据点都约束在横坐标轴的刻度线上
  // 基于鼠标所对应的横坐标值 dx 对数据 data(一个有序的数组,包含了横坐标轴的刻度值)进行二元分割
  // 返回值 i 是 data 数组的索引值,表示如果 dx  插入到该位置,依然保持数组有序
  // 也可以理解为数组的第 i 个元素 data[i] 与 dx 值最接近
  let i = bisectX(data, dx);
  // 修改 data 的第 i 个元素,它是一个二元数组,其第二个元素表示当前数据点的 y 值,将其设置为鼠标的纵坐标值 dy
  data[i][1] = dy;
  // 假如用户的鼠标从横坐标的开头快速拖拽到结尾以绘制折线,但是速度过快而 drag 事件分发的速度可能跟不上,则会出现折线断连的情况(由于 data 数组中间的一些元素的 y 值没有被修改,依然是 null)
  // 修补正好出现在鼠标所在位置(第 i 个元素)前面的一段折线「缺口」 (从左往右快速拖拽绘制折线时)
  // 从索引值 i-1 开始向前遍历 data 的元素
  for (let k = i - 1; k >= 0; --k) {
    // 当遇到第一个 data[k][1] 不为 null(即这个第 k 个元素具有 y 值),进入「修补」程序
    // 即从第 k+1 到第 i-1 个元素都是缺少 y 值的
    if (data[k][1] != null) {
      // 循环遍历第 k+1 个元素到第 i-1 个元素,将它们的 y 值都设置为 dy(假定与当前第 i 个元素的纵坐标一致)
      // 其效果就是直接用一条水平直线来填补缺口
      while (++k < i) data[k][1] = dy;
      // 修补完成后跳出循环
      break;
    }
  }
  // 修补正好出现在鼠标所在位置(第 i 个元素)后面的一段折线「缺口」 (从右往左快速拖拽绘制折线时)
  for (let k = i + 1; k < data.length; ++k) {
    if (data[k][1] != null) {
      while (--k > i) data[k][1] = dy;
      break;
    }
  }
  // 最后(用更新后的数据)重新绘制折线
  // 调用线段生成器 line
  // 将其返回的结果(字符串)作为 `<path>` 元素的属性 `d` 的值
  path.attr("d", line);
}

动效

折线图展开动效

参考

还可以参考另一个 notebook 它绘制的图是一样的,但是对代码进行了更高度的封装,便于复用。之前也有对其进行复现,并对代码进行解读

首先折线延伸展开动效的关键是设置 <path> 元素的属性 stroke-dasharray,该属性的作用是设置路径(描边)的点划线的图案规则。

该属性值由一个或多个(用逗号或者空白隔开)数字构成,这些数字组合会依次表示划线和缺口的长度,即第一个数字表示划线的长度,第二个数表示缺口的长度,然后下一个数字又是划线的长度,依此类推。如果该属性值的数字之和小于路径长度,则重复这个数字来绘制划线和缺口,这样就会出现规律的点划线图案。

实现折线展开动效的相关代码如下

js
// 将线段路径绘制到页面上
svg.append("path")
  // ...
  // 这里首先将属性 stroke-dasharray 设置为 `0,${l}`
  // 即路径的划线部分为 0,全部都是缺口
  // 所以其效果是在过渡开始时,路径为空,即折线不可见
  .attr("stroke-dasharray", `0,${l}`)
  // 调用线段生成器,将所绑定的数据 driving 作为参数传递到方法 line() 中
  // 返回的结果是字符串,作为 `<path>` 元素的属性 `d` 的值
  .attr("d", line)
  // 设置过渡动效
  // 更改的属性是 stroke-dasharray
  .transition()
  .duration(5000) // 设置过渡的时间
  .ease(d3.easeLinear) // 设置缓动函数
  // 设置属性是 stroke-dasharray 过渡的最终状态 `${l},${l}`(其实也可以是 `${l},0` 最终效果一样)
  // 即路径的划线的长度和路径总长度相同,缺口也一样
  // 所以效果是过渡结束时,路径完全显示
  .attr("stroke-dasharray", `${l},${l}`);

此外还通过设置 <text> 元素的属性 opacity 来实现标注文本的隐藏显示动效,相关代码如下

js
const label = svg.append("g")
  // ...
  .attr("fill-opacity", 0) // 设置透明状态,初始值为 0%,即一开始是隐藏的
  // ...
// 为标注信息设置透明度的过渡动效
label.transition()
    // 为各个标注信息设置**不同**的延迟时间
    // 以实现标注信息的显式和路径的展开达到同步的效果
    // 参数 d 是当前所遍历的元素所绑定的数据,参数 i 是当前所遍历的元素在分组中的索引
    // 通过方法 line(driving.slice(0, i + 1)) 获取当前标注文本所对应的数据点,所在的路径
    // 然后再通过 length() 计算该路径的长度
    // 通过与总长度 l 相除得到相对值,用于计算需要延迟多长时间(路径正好延伸到该数据点)
    // duration - 125 做了一些小修正,在路径展开到来前,让标注信息提前一点点时间先显示
    .delay((d, i) => length(line(driving.slice(0, i + 1))) / l * (5000 - 125))
    .attr("fill-opacity", 1); // 设置透明度在过渡的最终状态为 1,即完全显示
提示

在另一个官方教程中 Learn D3: Animation 使用类似的方法,即基于 stroke-dasharray 属性也实现了折线展开动效

不同的是它通过方法 attrTween() 自定义了插值函数(计算动画过程中的各个时间点的值),而在这个示例中是通过方法 attr() 直接设置目标值(过渡结束时的最终值)

关于方法 transition.attr()transition.attrTween() 可以参考这一篇笔记


此外还有一个类似的示例,实现多条折线的展开,也是通过设置 <path> 元素的属性 stroke-dasharray 实现延伸展开动效,还有其他一些技巧值得学习

参考

该折线图也是通过设置 <path> 元素的属性 stroke-dasharray 实现折线延展动效,另外还通过 async-await 异步函数和 transition.end() 返回 Promise 相结合实现多条折线的依次展开的效果,相关代码如下:

js
function dashTween() {
  const length = this.getTotalLength();
  // 返回一个插值器
  // 计算从 (0, l) 到 (l, l) 之间的插值
  return d3.interpolate(`0,${length}`, `${length},${length}`);
}
// Animate: add lines iteratively.
// 使用异步函数 async-await 来实现依年份次序绘制多条折线
async function animate() {
  // 先使用方法 d3.group(iterable, ...keys) 对可迭代对象的元素进行分组转换
  // 第一个参数 iterable 是需要分组的可迭代对象
  // 第二个参数 ...keys 是一系列返回分组依据的函数,数据集中的每个元素都会调用该函数,入参就是当前遍历的元素 d
  // 并返回一个 InterMap 对象(映射,键名是分组依据,相应的值是在原始数组中属于该分组的元素)
  // 具体可以参考官方文档 https://d3js.org/d3-array/group#group
  // 或参考这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-process#转换
  // 这里是基于年份 d => d.date.getUTCFullYear() 对数据集 data 的元素进行分组
  // 然后在循环结构中对返回的 InterMap 对象进行遍历
  // 在遍历时它变成一系列的二元数组,这里h还对其进行了解构 [key, values]
  // 该数组的第一个元素是键名 key(即年份,是该分组的依据),第二个元素是的该分组的数据 values(一个数组,由原数据集中属于该分组的数据点组成)
  for (const [key, values] of d3.group(data, d => d.date.getUTCFullYear())) {
    // 将当前所遍历的分组数据(某个年份的数据)线段路径绘制到页面上
    await g.append("path") // 使用路径 <path> 元素绘制折线
      // 调用线段生成器,将所当前分组的数据 values 作为参数传递到方法 line() 中
      // 返回的结果是字符串,作为 `<path>` 元素的属性 `d` 的值
      .attr("d", line(values))
      // 设置折线描边的颜色,基于当前分组的年份,通过颜色比例尺 z 映射得到相应的颜色值
      .attr("stroke", z(key))
      // 通过属性 stroke-dasharray 设置路径(描边)的**点划线**的图案规则,作为路径展开动画的初始状态
      // 该属性值由一个或多个(用逗号或者空白隔开)数字构成
      // 这些数字组合会依次表示划线和缺口的长度(该数字可以表示长度或百分值)
      // 即第一个数字表示划线的长度,第二个数表示缺口的长度,然后下一个数字又是划线的长度,依此类推
      // 如果该属性值的数字之和小于路径长度,则重复这个数字来绘制划线和缺口,这样就会出现规律的点划线图案
      // 这里首先将属性 stroke-dasharray 设置为 `0, 1`
      // 即路径的划线部分为 0,全部都是缺口
      // 所以其效果是在过渡开始时,路径为空,即折线不可见
      .attr("stroke-dasharray", "0,1")
      // 设置过渡动效
      // 更改的属性是 stroke-dasharray
      .transition()
      .ease(d3.easeLinear) // 设置缓动函数
      // 方法 dashTween 返回一个插值器
      // 在过渡期间,会调用这个插值器计算 stroke-dasharray 的值,以实现折线的展开动效
      .attrTween("stroke-dasharray", dashTween)
      // 使用 transition.end() 方法,它返回一个 Promise
      // 这个 Promise 仅在过渡管理器所绑定的选择集合的所有过渡完成时才 resolve;如果过渡被中断或取消,就会被 reject
      // 这里可以实现在绘制完当前年份所对应的折线时(过渡结束时)才继续执行下一个操作(为当前折线添加标注文字,以及开启下一条折线的绘制)
      .end();
    // 当折线绘制完成后,在它的后面添加上年份标注
    // 由于标注文本的定位在折线的末端,即该分组的最后一个数据点的附近,这里先判断该分组最后一个数据是否完整(不是 NaN)
    if (!isNaN(values[values.length - 1].value)) {
      // 为前面绘制出来的折线添加注释信息
      g.append("text") // 使用 <text> 元素添加文本
        // 设置文本的 fill 填充、stroke 描边、mark 标记的绘制顺序
        // 这里是先绘制描边,然后再是填充,避免白色描边遮挡了黑色的字体
        // 具体介绍查看 https://developer.mozilla.org/en-US/docs/Web/CSS/paint-order
        .attr("paint-order", "stroke")
        .attr("stroke", "white") // 设置文字的描边颜色为白色
        .attr("stroke-width", 3) // 设置描边的宽度
        // 设置文字填充的颜色,基于该分组的年份,通过颜色比例尺 z 映射得到相应的颜色值
        .attr("fill", z(key))
        .attr("dx", 4) // 将文本稍微向右移动,避免与折线重叠
        .attr("dy", "0.32em") // 将文本稍微向下移动,让文本与折线(最后一个数据点)水平居中对齐
        // 设置 <text> 元素的定位 (x, y) 基于该折线最后一个数据点的位置
        // 但是横坐标轴的比例尺是使用 2000 年作为定义域范围的,所以这里要先使用方法 intrayear() 将该数据(时间对象 Date)的年份都改为 2000 年,再采用比例尺 x 进行映射,计算出相应的横坐标
        .attr("x", x(intrayear(values[values.length - 1].date)))
        .attr("y", y(values[values.length - 1].value)) // 纵坐标值
        .text(key); // 设置注释内容,是当前分组的依据,即年份
    }
  }
}
// Start the animation and return the chart.
// 开启动画,绘制折线
requestAnimationFrame(animate);
依次执行过渡动效

除了使用以上的方法(通过 async-await 异步函数和 transition.end() 返回 Promise 相结合),还有其他方法实现依次执行过渡动效

可以通过方法 transition.delay(value) 为不同的过渡设置不同的延迟启动时间(单位为毫秒),参数 value 可以是一个数字,还可以是一个返回时间的函数(过渡管理器绑定的选择集合中的元素会依次调用该函数),这样就可以为不同的元素设置不同的延迟启动时间,以实现依次过渡的效果

如果要对同一个选择集设置一系列依次执行的过渡动效,也可以使用方法 transition1.transition() 基于一个原有的过渡管理器 transition1 创建一个新的过渡管理器,它所绑定的选择集相同,而且继承了原有过渡的名称、时间、缓动函数等配置,而且(通过第二个过渡管理器所创建的)过渡会在通过前一个过渡管理器所创建的过渡结束后才会开始执行

对于时间轴(坐标轴)如何实现多种页面尺寸适配的问题值得思考。

在示例代码中横坐标轴是通过方法 axis.ticks(width / 80) 基于页面的宽度计算出刻度数量(参考值),避免在页面较窄时生成的刻度过多,导致刻度值重叠而影响图表的可读性。但是该方法用于生成时间轴的刻度(特别是在页面较宽时)是不妥的,例如对于由月份构成的刻度,即使在较宽的页面最多也只应有 12 个刻度线才合理,而使用方法 axis.ticks(count) 并不能对刻度数量进行精确的约束。

所以对于时间轴(由时间比例尺构建的坐标轴)应该采用 axis.ticks(count)axis.ticks(interval) 相混合的方案才更合理。改进的相关代码如下:

js
// 绘制横坐标轴
svg.append("g")
  // 通过设置 CSS 的 transform 属性将横坐标轴容器「移动」到底部
  .attr("transform", `translate(0,${height - marginBottom})`)
  // 横轴是一个刻度值朝下的坐标轴
  .call(d3.axisBottom(x)
    // ⚠️ 在 D3 的示例代码中是通过 axis.ticks(count, specifier) 设置刻度数量(参考值)和刻度值的格式
    // ⚠️ 第一个参数是一个数值,用于设置刻度数量(这里设置的是预期值,并不是最终值,D3 会基于出入的数量进行调整,以便刻度更可视)
    // 在示例代码中该参数值是 width / 80 它基于页面的宽度计算出刻度数量的参考值,避免刻度过多导致刻度值重叠而影响图表的可读性
    // ⚠️ 但是该方法用于生成时间轴的刻度依然不妥的,例如对于由月份构成的刻度,即使在较宽的页面,最多也只应有 12 个刻度线才合理
    // ⚠️ 而使用方法 axis.ticks(count) 并不能对刻度数量进行精确的约束
    // 💡 这里可以采用 count 和 interval 混合的方案
    // 💡 在小尺寸页面 width < 1024px 时,采用 axis.ticks(count) 基于页面宽度计算出可读书了的参考值;在大尺寸页面 width >= 1024px 时,采用 axis.ticks(interval) 更佳,根据时间间隔进行采样,生成更合理的刻度数量
    // 具体参考官方文档 https://d3js.org/d3-axis#axis_ticks
    // 参数 interval 是时距器,用于生成特定间距的时间
    // 关于时距器的介绍,可以参考这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-process#时间边距计算器
    // 这里使用一个 D3 内置的时距器 d3.utcMonth 创建一个以月份为间距的 interval
    .ticks(width < 1024 ? width/80 : d3.utcMonth)
    // 通过 axis.tickFormat(specifier) 设置刻度值的格式
    // 参数 specifier 是时间格式器,将一个 Date 对象格式化 format 为字符串
    // 关于时间格式器的介绍,可以参考这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-process#时间格式器
    // 这里 "%B" 表示刻度值采用月份的全写,例如「二月」使用英文 February 来表示
    .tickFormat(d3.utcFormat("%B"))
    // 将坐标轴的外侧刻度 tickSizeOuter 长度设置为 0(即取消坐标轴首尾两端的刻度)
    .tickSizeOuter(0));

另外在处理时间对象 Date 数据时,它表示一个具体的时间点(包括年月日时分秒,甚至精确到毫秒),而坐标轴往往是粗颗粒度的,例如以一年 12 个月、以一周 7 天、以一天 24 小时作为定义域范围,直接将原始数据点(时间对象 Date)用于比例尺的映射可能并不合适,则可以使用时间对象的相关方法对数据进行预处理。

例如在该示例中横坐标轴的比例尺是使用 2000 年作为定义域范围的,所以使用方法 date.setUTCFullYear(2000) 将所有数据点(时间对象)的年份改成 2000 年,便于将数据点映射到横坐标轴上。相关代码如下:

js
// 设置横坐标轴的比例尺
const x = d3.scaleUtc(
  // 设置定义域范围,构建一个数组,表示时间范围是 2000 年
  // 这里的年份是可以任意挑选的,只要定义域范围是一年(即包含 12 个月即可)
  [Date.UTC(2000, 0, 1), Date.UTC(2001, 0, 0)],
  // 设置值域范围(所映射的可视元素)
  // svg 元素的宽度(减去留白区域)
  [marginLeft, width - marginRight]
);
// 该方法用于更改时间对象 Date 的年份,统一为 2000 年
// 因为横坐标轴的比例尺是使用 2000 年作为定义域范围的,所以这里将入参 date(时间对象 Date)的年份都改为 2000 年,便于将数据点映射到横坐标轴上
function intrayear(date) {
  date = new Date(+date);
  date.setUTCFullYear(2000);
  return date;
}
/**
 *
 * 绘制折线图内的线段
 *
 */
// 使用方法 d3.line() 创建一个线段生成器
const line = d3.line()
  // 💡 调用线段生成器方法 line.defined() 设置数据完整性检验函数
  .defined(d => !isNaN(d.value))
  // 设置横坐标读取函数
  // 该函数会在调用线段生成器时,为数组中的每一个元素都执行一次,以返回该数据所对应的横坐标
  // 这里基于每个数据点的日期 d.date 并采用比例尺 x 进行映射,计算出相应的横坐标
  // 但是横坐标轴的比例尺是使用 2000 年作为定义域范围的,所以这里要先使用方法 intrayear() 将 d.date(时间对象 Date)的年份都改为 2000 年,再采用比例尺 x 进行映射,计算出相应的横坐标
  .x(d => x(intrayear(d.date)))
  // 设置纵坐标读取函数
  .y(d => y(d.value));

比例尺切换动效

参考

在该示例中纵坐标轴可以在 linear 线性比例尺和 log 对数比例尺之间切换。首先分别用两种不同的比例尺(线性比例尺 scaleLinear 和对数比例尺 scaleLog)构建构建两个不同的坐标轴,它们(容器)定位都是在 SVG 的左边作为纵坐标轴,通过设置(容器)元素的 Style 属性 opacity 来控制它们的显示/隐藏(每次只显示一个)。此外还实现了坐标轴刻度的切换动效,让坐标轴切换看起来更顺畅。相关代码如下

js
// 在更新纵坐标轴时调用该方法,以实现坐标轴刻度的切换动效
// 该函数接收两个参数
// 第一个参数 g 实际上是一个过渡管理器(以下称为 transition)
// 它和 selection 选择集类似,有相似的方法,例如使用 transition.selectAll(selector) 选中所有匹配的后代元素
// 这里用 g 表示因为选择集中包含一个 `<g>` 元素,它是纵坐标轴的容器,在里面已经包含了所生成的坐标轴
// 不同的是(在为选择集中的元素)所设置的属性值是过渡的**最终值/目标值**,然后自动在过渡过程中多次使用插值器,计算出起始值和目标值之间的过渡值,从而实现图形元素的某个可视化变量从起始值顺滑变换到目标值的效果
// 关于过渡管理器 transition 的介绍可以查看这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-transition
// 第二个参数 y 是比例尺
const yTickPosition = (g, y) => g.selectAll(".tick") // 选中所有刻度(包括刻度线和刻度值)
  // 💡 D3 为所生成的坐标轴的每个刻度元素(一个 <g> 元素,带有 `.tick` 类名)绑定了相应的数据,就是刻度值
  // 可以参考源码 https://github.com/d3/d3-axis/blob/main/src/axis.js#L53
  // 所以这里的入参 `d` 是每个刻度所对应的刻度值
  // 为了实现在切换比例尺时刻度也会随之「移动」的动效
  // 这里基于**新的比例尺 y** 重新计算当前所遍历的 `d` 值应该映射到图中哪个的位置
  // 另外需要考虑一个问题,即使用 yLinear 线性比例尺所绘制的坐标轴具有 0 刻度,它所绑定的值就是 0
  // 但如果比例尺切换到 yLog 时,如果将 0 作为参数 y(0) 返回的值是 NaN,不能作为 translate 的合法值
  // 对于这种情况,会采用回 yLinear 线性比例尺(而不是传递进来的比例尺 y,因为它是对数比例尺)计算出一个数值
  // ⚠️ 其实在这个场景,对于 0 采用不同的比例尺计算映射的位置,是不影响最后显示出来的纵坐标轴,因为从线性比例尺切换到对数比例尺时,axisLinear 最终会被隐藏掉
  .attr("transform", d => {
    // console.log({d, y: y(d)});
    return `translate(0,${(isNaN(y(d)) ? yLinear(d) : y(d)) + 0.5})`
  });
// ...
/**
 * 根据 yType 的值(纵坐标轴采用 yLinear 线性比例尺,还是选择 yLog 对数比例尺)更新折线图
 */
radioButtons.forEach(radioButton => {
  radioButton.addEventListener('change', function (event) {
    if (this.checked) {
      const yType = this.value;
      // 根据 yType 的值选择不同的比例尺
      const y = yType === "linear" ? yLinear : yLog;
      // 在根元素上创建一个过渡管理器
      const t = svg.transition().duration(750);
      // 基于所选择的比例尺,通过设置 opacity 来隐藏/显示 axisLinear 和 axisLog 坐标轴
      // 通过 selection.transition(t) 设置过渡动效
      // 会基于传入的(已有)过渡管理器,创建一个同名同 id 的过渡管理器,这样可以方便地复用过渡动画的设置
      // 并且同时调用 yTickPosition 实现坐标轴刻度的切换动效(⚠️ 传入的比例尺都是 y 当前所选中的比例尺)
      axisLinear.transition(t).style("opacity", y === yLinear ? 1 : 0).call(yTickPosition, y);
      axisLog.transition(t).style("opacity", y === yLog ? 1 : 0).call(yTickPosition, y);
      // 基于所选择的比例尺 y 更新折线
      // 而且通过 selection.transition(t) 采用相同的过渡管理器设置动效
      path.transition(t).attr("d", line(y));
      // 最终的效果是坐标轴的隐藏/显示、刻度的切换,以及折线的变换同时进行
    }
  });
});
提示

在这个例子中要实现不同(比例尺)类型的坐标轴的切换,需要使用 opacity 来隐藏/显示整个容器。

如果只是要实现坐标轴的范围切换(一般是定义域变化引起),则不必像该示例中预先构建两个坐标轴通过 opacity 手动控制切换,而是可以直接再调用一次坐标轴生成的相关方法来更新坐标轴,这样 D3 还可以会自动复用坐标轴相关的 DOM 元素(更高效第刷新页面)

如果是在过渡管理器 transition 里再调用一次坐标轴生成的相关方法,还会为坐标轴的刻度添加过渡动效,以下是相关代码

js
const gx = svg.append("g")
    .attr("transform", `translate(0,${height - marginBottom})`)
    .call(d3.axisBottom(x));
gx.transition()
    .duration(750)
    .call(d3.axisBottom(x));

路径切换补间动画

在展示动态数据时,折线图的形状(即路径)会随着数据更新而发生变化,如果添加 path tween 路径切换补间动画可以让变化更顺滑,并使得视觉可以跟随动画来追踪数据的变化

参考
  • 解读的官方样例为 Path tween
  • 对代码进行注释解读的 Notebook 是这个
  • 复现可以查看该网页,完整代码可以查看 这里

该实例通过对(过渡开始时的)source path 原始路径和(过渡结束时的)target path 目标路径进行均匀采样(可配置采样的精度,此处为 4px),在过渡期间将曲线替换为分段的 linear curve 线性曲线(实际是将采样所得的相邻数据点相连,形成一系列的线段)或 polyline 折线,即使用采样点模拟生成的近似形状,待过渡完成后,再将 <path> 恢复为原始的形状(例如三次贝塞尔曲线)。

这种实现路径切换补间动画的方法通用性很强,它适用于任何类型的路径(无论是曲线还是折线,只要路径可以通过 <path> 元素的属性 d 来表示即可)。

另外采样的方式也值得学习,在该实例中对采样的精度/步长进行标准化,使用 precise / pathLength 将精度的绝对像素值 precise 与路径的总长度 pathLength 作比(可以通过SVG 元素的原生方法 path.getTotalLength() 获取路径的总长度),得到精度的相对值,使用它进行迭代采样就可以得到采样点的相对(总路径)位置,以便将采样点进行配对。

在为不同的采样点构建一系列的插值器时,可以使用 SVG 元素的原生方法 path.getPointAtLength(distance) 获取距离路径起点特定距离 distance 的位置的具体信息(返回一个 DOMPoint 对象,它表示坐标系中的 2D 或 3D 点,这里主要使用它的属性 domPoint.xdomPoint.y 以获取该点水平和垂直坐标,以进行插值计算)

对路径进行采样和构建补间动画的相关代码如下

js
/**
 *
 * 绘制路径
 *
 */
  // 使用 <path> 元素将线段路径绘制到页面上
svg.append("path")
  // 通过设置 CSS 的 transform 属性将 <path> 元素移动到 svg 容器的中间
  .attr("transform", "translate(180,150)scale(2,2)")
  // 只需要路径的描边作为折线,不需要填充,所以属性 fill 设置为 none
  .attr("fill", "none")
  // 设置描边颜色,采用 "currentColor" 默认颜色(继承自父元素,这里是黑色)
  .attr("stroke", "currentColor")
  .attr("stroke-width", 1.5) // 设置描边宽度
  // 通过设置 `<path>` 元素的属性 `d` 绘制出路径的原始形状
  .attr("d", d0)
// 设置过渡动效(通过更改 `<path>` 的属性 d 实现)
// 通过 selection.transition() 创建过渡管理器
// 过渡管理器和选择集类似,有相似的方法,例如为选中的 DOM 元素设置样式属性
// 具体参考官方文档 https://d3js.org/d3-transition
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-transition
.transition()
  .duration(2000) // 设置过渡的时间
  // 使用过渡管理器方法 transition.on(typeNames[, listener]) 监听过渡所分发的(自定义)事件,并执行相应的回调操作 listener
  // 这里监听 `"start"` 事件,它在过渡开始被分发,然后回调函数  repeat() 会被执行
  .on("start", function repeat() {
    // 在回调函数中 this 指向(在过渡管理器所绑定的选择集合中)当前所遍历的元素
    // 在这里的过渡管理器所绑定的选择集中只有一个 `<path>` 元素
    // 通过方法 d3.active(node[, name]) 获取指定元素的指定名称的执行中的过渡管理器
    // 使用过渡管理器方法 `transition.attrTween(attrName[, factory])` 设置元素的属性 `attrName`,可以自定义插值器 `factory` 用于进行插值计算,即计算过渡期间属性 `attrName` 在各个时间点的值
    // 这里更改的是 `<path>` 元素的属性 `d`,自定义了插值函数 pathTween() 该函数的具体代码实现可以查看 👇 下一个 cell
    // 💡 另一类似的方法是 `transition.attr(attrName, value)` 它也是用于设置元素的属性 `attrName`,但直接设置了目标值 `value`(过渡结束时的最终值),而不需要设置过渡期间各个时间点的值(因为 D3 会根据属性值的数据类型,自动调用相应插值器
    // 关于方法 `transition.attr()` 和 `transition.attrTween()` 的详细介绍可以参考这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-transition#过渡参数配置
    d3.active(this)
        // 这里的过渡动画的目的将路径形状从 d0 变换为 d1
        .attrTween("d", pathTween(d1, 4))
      // 然后通过 `transition.transition()` 基于原有的过渡管理器所绑定的选择集合,创建一个新的过渡管理器
      // 新的过渡管理器会**继承了原有过渡的名称、时间、缓动函数等配置**
      // 而且新的过渡会**在前一个过渡结束后开始执行**
      // 一般通过该方法为同一个选择集合设置一系列**依次执行的过渡动效**
      .transition()
        // 同样使用方法 `transition.attrTween()` 设置 `<path>` 元素的属性 `d`
        // 这里的过渡动画的目的将路径形状从 d1 恢复为 d0
        .attrTween("d", pathTween(d0, 4))
      // 再使用创建一个过渡管理器(它会接着上一个过渡动画结束时触发)
      .transition()
        // 又再一次调用 repeat() 函数
        .on("start", repeat);
    // 函数 repeat() 的作用是先将路径的形成再一次从 d0 切换为 d1,然后再恢复为 d0,最后又递归调用自身,形成循环动画,所以过渡动画的最终效果是路径在 d0 和 d1 形状之间不断切换
// 该函数称为插值器工厂函数 interpolator factory,它生成一个插值器
// 💡 D3 在 d3-interpolate 模块提供了一些内置插值器,具体可以查看官方文档 https://d3js.org/d3-interpolate
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-transition#插值器
// 该函数接收两个参数,第一个参数 `d1` 是过渡的目标值/最终值,第二个参数 `precision` 是采样的精度
// 通过采样将路径从贝塞尔曲线转换为分段折线(便于插值计算)
function pathTween(d1, precision) {
  // 返回一个自定义的插值器
  return function() {
    // 函数内的 this 指向(在过渡管理器所绑定的选择集合中)当前所遍历的元素,在这个示例中选择集中只有一个 `<path>` 元素
    const path0 = this;
    // 通过 JS 原生方法 node.cloneNode() 拷贝该 DOM 元素
    const path1 = path0.cloneNode();
    // 将该 `<path>` 元素的属性 `d` 设置为 `d1`(过渡的目标值/最终值),所以该元素的形状与过渡完成时的路径形状一样
    path1.setAttribute("d", d1);
    // 使用方法 SVGGeometryElement.getTotalLength() 获取 `<path>` 元素的长度(以浮点数表示)
    const n0 = path0.getTotalLength(); // 过渡起始时路径的总长度
    const n1 = path1.getTotalLength(); // 过渡结束时路径的总长度
    // Uniform sampling of distance based on specified precision.
    // 基于给定的精度 precision 对(过渡前)path0 和(过渡后)path1 两个路径进行均匀采样
    // 💡 可以得到一系列配对的采样点(它们分别路径上某一点的起始状态和最终状态)
    // 💡 然后为**每对采样点(已知起始状态和最终值)构建一个插值器**,用于实现路径切换动画
    // 用一个数组 distances 来存储采样点(相对于路径的)位置,每一个元素都表示一个采样点
    // 即每个元素/采用点都是一个 0 到 1 的数字,它是采样点到该路径开头的距离与**该路径总长度**的比值(占比)
    // 💡 使用相对值来表示采样点的位置,以便将采样点进行配对
    const distances = [0]; // 第一个采样点是路径的起点
    // 对采样的精度/步长进行标准化,使用它进行迭代采样就可以得到采样点的相对(总路径)位置
    // 其中 precise 的单位是 px 像素,是采样精度的绝对值
    // 通过精度与路径的总长度作比 precise / Math.max(n0, n1) 将精度从绝对值转换为相对值
    // 其中路径总长度是基于变换前后最长的路径,以保证在较长的路径上的采样密度(数量)也是足够
    const dt = precision / Math.max(n0, n1);
    // 通过 while 循环进行采用,每次距离增加一个标准化的步长 dt
    let i = 0; while ((i += dt) < 1) distances.push(i);
    distances.push(1); // 最后一个采样点是路径的终点
    // Compute point-interpolators at each distance.
    // 遍历数组 distances 为不同的采样点构建一系列的插值器
    const points = distances.map((t) => {
      // t 为当前所遍历的采样点的位置的相对值(与它所在的路径总长度的占比)
      // 通过 t * n0 或 t * n1 可以求出该采样点距离 path0 或 path1 路径的起点的具体距离
      // 再使用 SVG 元素的原生方法 path.getPointAtLength(distance) 可以获取距离路径起点特定距离 distance 的位置的具体信息
      // 具体可以参考 https://developer.mozilla.org/en-US/docs/Web/API/SVGGeometryElement/getPointAtLength
      // 该方法返回一个 DOMPoint 对象,它表示坐标系中的 2D 或 3D 点,其中属性 x 和 y 分别描述该点的水平坐标和垂直坐标
      // 具体可以参考 https://developer.mozilla.org/en-US/docs/Web/API/DOMPoint
      // 在 path0(过渡开始时的路径)上的采样点作为插值的起始状态
      const p0 = path0.getPointAtLength(t * n0);
      // 在 path1(过渡结束时的路径)上的采样点作为插值的最终状态
      const p1 = path1.getPointAtLength(t * n1);
      // 所以 [p0.0, p0.y] 是插值的起点的坐标值,[p1.x, p1.y] 是插值的终点的坐标值
      // 这里使用 D3 所提供的内置通用插值器构造函数 d3.interpolate(a, b) 来构建一个插值器
      // 它会根据 b 的值类型自动调用相应的数据类型插值器
      // 具体可以参考这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-transition#通用类型插值器
      // 这里为每个采样位置构建出一个插值器,然后在过渡期间就可以计算出特定时间点该点运动到什么地方(即它的 x,y 坐标值)
      return d3.interpolate([p0.x, p0.y], [p1.x, p1.y]);
    });
    // 插值器最后需要返回一个函数,它接受标准时间 t 作为参数(其值的范围是 [0, 1])
    // 返回的这个函数会在过渡期间被不断调用,用于生成不同时间点的 `<path>` 元素的属性 `d` 的值
    // 当过渡未结束时(标准化时间 t < 1 时),通过调用一系列的插值器 points 计算各个采样点的运动到何处,并使用指令 `L` 将这些点连起来构成一个折线
    // 而过渡结束时(标准化时间 t = 1 时),将路径替换为真正的形状 d1(而不再使用采样点模拟生成的近似形状)
    return (t) => t < 1 ? "M" + points.map((p) => p(t)).join("L") : d1;
  };
}
提示

如果要实现不同形状切换的补间动画可以查看另一个 Observable Notebook Shape tweening

变体

有一些经典的图表与折线图很类似,可以将它们看作是折线图的变体,使用与折线图类似的代码可以实现绘制

斜率图

斜率图 Slope chart,也称为 slopegraph,该图标用于显示数据在两个(或几个)不同时间点之间变化的情况。

参考
  • 解读的官方样例为 Slope chart
  • 对代码进行注释解读的 Notebook 是这个
  • 复现可以查看该网页,完整代码可以查看 这里
  • 这个示例图表的设计参考自 Tufte 的作品(该链接指向的页面列举了很多其他斜率图,✨ 值得深入研究)。

可以将斜率图看作是含有多个线段的折线图,而每条折线都只含有(在线段起止端点位置的)两个数据点。所以用于绘制线段的核心代码与折线图类似,只是绑定的数据格式不一样,变成了嵌套数组,其中每个元素都是一个二元数组,对应于每条线段的起止端点,绘制出多条线段。

但是与折线图不同的是,斜率图需要为各条线段(在左右两端)添加文本标注。如果直接根据线段的端点来定位文本标注,当两个线段的端点相同或相近时,那么它们相对应的标签就会发生重叠,所以可能需要对一些标签的定位进行调整

在该示例代码中,使用方法 dodge() 对布局定位进一步优化,其核心功能是以循环迭代的方式逐步调整相邻标签的定位,以避免它们重叠,提高标签的视觉可视性,相关代码如下

js
/**
 *
 * 用于调整标签定位的核心函数
 *
 */
// 第一个参数是数组 positions,它的元素都是一个数值,表示对应文本标签沿着 y 轴定位的坐标值
// 对它们进行迭代处理,以便对布局定位进一步优化,提高标签的视觉可视性
// 第二个参数 separation 是标签间的距离(需要保证标签之间的间距要足够大于该值,这个值应该是基于标签字体大小/行高而设置的)
// 第三个参数 maxiter 是最大的迭代次数
// 第四个参数 maxerror 用于限制调整标签时所移动的步长(当所需调整移动步长过小,则可以提前结束迭代)
function dodge(positions, separation = 10, maxiter = 10, maxerror = 1e-1) {
  positions = Array.from(positions); // 转换为数组(保险操作 ❓ 这一步可忽略 ❓)
  let n = positions.length; // 数组长度
  // 使用 JavaScript 原生方法 isFinite() 检查数组中各元素(数值)是否均为有限的(即不是 NaN 也不是 ±Infinity),否则抛出错误
  if (!positions.every(isFinite)) throw new Error("invalid position");
  // 如果数组长度不大于 1(只有一个元素,则不存在标签重叠的问题),则不需要进行迭代处理,直接返回数组
  if (!(n > 1)) return positions;
  // 使用 d3.range(stop) 创建一个等差数列,参数 stop 表示数列的结束值(不包括),默认的开始值是 0,并用数列的各项构成一个数组
  // 所以这里基于数组 positions 的长度,创建一个等长的数组 [0, 1, 2, ..., (positions.length)-1]
  // 相当于为 positions 数组创建一个相应的**索引值数组**,可以对该数组进行排序,再基于该数组提取出相应的相邻标签进行调整,而不打乱原数组 positions
  let index = d3.range(positions.length);
  // 进行迭代优化布局定位
  for (let iter = 0; iter < maxiter; ++iter) {
    // 使用 index.sort((i, j) => comparator) 对数组进行排序,基于索引值在 positions 中所对应元素的值的大小来调整
    // 其中对比函数 comparator 采用 D3 内置的对比器 d3.ascending(a, b) 实现升序排列
    // 即索引值在 positions 中所对应的元素的值较小的排在前面(较大的则排在后面)
    index.sort((i, j) => d3.ascending(positions[i], positions[j]));
    // 用于记录标签调整/移动的步长的最大值,作为提前跳出迭代的条件
    let error = 0;
    // 遍历每个元素,对定位值进行调整
    for (let i = 1; i < n; ++i) {
      // 计算在 y 轴上定位相邻的两个标签的距离差值 delta
      // 这里 positions[index[i]] 和 positions[index[i - 1]] 可以获取到在 y 轴上定位相邻的标签的纵坐标值,因为前面对 index 进行了排序
      let delta = positions[index[i]] - positions[index[i - 1]];
      // 判断差值 delta 是否大于预设的距离 separation
      if (delta < separation) {
        // 如果两个相邻的标签间距不够,则需要对这两个标签的定位进行调整
        // 移动/调整的步长 (separation - delta)/2 是根据现有间距与预设间距的相差值计算出来的(它们差值的一半)
        // 因为两个标签都要(分别向相反的方向)移动,各移动一半的差距,就可以让最终标签的间距满足预设距离
        delta = (separation - delta) / 2;
        // 将当前遍历的标签所需调整/移动的步长,与之前存下的步长进行比较,取两者之间的**最大值**
        error = Math.max(error, delta);
        positions[index[i - 1]] -= delta; // 调整在纵轴方向上定位较低的标签的定位,再往下移动
        positions[index[i]] += delta; // 调整在纵轴方向上定位较高的标签的定位,再往上移动
        // 调整后两个标签间距就扩大了
      }
    }
    // 当经历了这一轮的迭代后,如果这些标签的移动步长中最大值 error 都小于参数 maxerror(很小的一个值)
    // 则表示这些标签所需调整的都不大,所以可以提前结束迭代了
    if (error < maxerror) break;
  }
  // 返回调整后的数组
  return positions;
}

D3 官方在样例中还提供有一个生存率的「变体」,一般斜率图的每条线段都只是由两个端点/时间点构成的,即默认为 1 步,而在癌症生存率图表中增添步骤 steps,即在各个线段(两个端点连线之间)里额外增加了一些时间点,以列出了不同癌症的不同时长(以年为单位)存活率(以百分比表示)。

参考

该样例的绘制代码和一般的斜率图很类似,只是每条折线所绑定的数据由原来的两个数据点(对应线段的两个端点),变成了多个数据点。

另外有一个小技巧值得学习,为了让重叠在折线上的文本内容更容易阅读,可以为文本设置白色的描边,但是过粗的描边可以会遮挡掉文字(填充),可以通过设置 CSS 属性 paint-order 调整文本的 fill 填充、stroke 描边、mark 标记的绘制顺序。相关代码如下

js
selection.text(([text]) => text)
  // 设置文字颜色(
  .attr("fill", "currentColor")
  // 设置文字的描边颜色为白色
  .attr("stroke", "white")
  // 设置文字描边的宽度为 5
  .attr("stroke-width", 5)
  // 设置文本的 fill 填充、stroke 描边、mark 标记的绘制顺序
  // 这里是先绘制描边,然后再是填充,避免白色描边遮挡了黑色的字体
  // 具体介绍查看 https://developer.mozilla.org/en-US/docs/Web/CSS/paint-order
  .attr("paint-order", "stroke");

蜡烛线

Candlestick chart 简称为 K 线,又称阴阳烛、蜡烛线,是反映价格走势的一种图线,一般用于对股价进行可视化。

参考

蜡烛线虽然整体看起来和折线图类似,但是绘制的流程和代码则是与挑选图类似,因为每个「蜡烛图形」都占有一定的宽度,和柱状图的条带类似,所以横坐标选择的是带状比例尺,而不是像一般的折线图使用连续型比例尺

另外需要需要注意的一点是对时间的处理,由于股票交易市场一般在周六、日休市,所以在绘制(横坐标)时间轴时需要筛掉这两天,以避免 K 线图由于数据的缺失而产生空白断连的情况,相关的代码如下

js
// 设置横坐标轴的比例尺
// 横坐标轴的数据是不同的日期(这里看作不同的类别),使用 d3.scaleBand 构建一个带状比例尺
// 使用 d3-scale 模块
// 具体参考官方文档 https://d3js.org/d3-scale/band
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-scale#带状比例尺-band-scales
// 💡 这里横坐标使用带状比例尺,而不是像一般的折线图使用连续型比例尺
// 💡 虽然蜡烛线虽然整体看起来和折线图类似,但是绘制的流程和代码则是与挑选图类似,因为每个「蜡烛图形」都占有一定的宽度,和柱状图的条带类似
const x = d3.scaleBand()
    // 设置定义域范围
    // 从数据集中获取日期范围(生成一个数组,包含一系列的日期)
    // 其中 d3.utcDay 是一个时间边距计算器(以下称为 interval),用于生成特定间距的时间
    // 具体参考 d3-time 模块的官方文档 https://d3js.org/d3-time
    // 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-process#时间边距计算器
    // d3.utcDay 是以「天」为间距的 interval,时间格式采用 UTC 世界协调时间
    // 调用方法 interval.range(start, stop[, step]) 返回一个包含一系列 Date 对象的数组
    // 其中第一个、第二个参数 start(包含) 和 stop(不包括)用于设置时间范围
    // 第三个(可选)参数 step 是一个整数,用于设置步长,即每距离多长的时间采集一个时间点,生成一个 Date 对象,默认值为 1
    // 由于数据集 ticker 的元素已经按时间顺序进行排序,所以可以直接通过第一个元素 ticker.at(0) 和最后一个元素 ticker.at(-1) 获取到数据集的时间范围
    // 再分别提取出时间对象 element.Date 作为方法 interval.range(start, stop) 第一个和第二参数
    // ⚠️ 由于方法 interval.range(start, stop) 所生成的一系列 Date 对象中,并不包含第二个参数 stop 所指定的时间
    // 所以这里不能直接传入 ticker.at(-1).Date(它是一个 Date 对象),而是要将它适当「延长」,这里就先将 Date 对象转换为毫秒数  +ticker.at(-1).Date 再加上 1 毫秒,这样就保证了所生成的一系列 Date 对象中包含数据集所有时间点
    // 然后通过 arr.filter() 将数据集中的日期进行筛选过滤
    // 由于 interval 是以「天」为间隔,所以从 start 至 stop(不包含)范围中的所有日期都包含在内
    // 但是因为周六日股票市场并没有交易,所以在数据集中是没有对应的股价数据的
    // 所以需要将这些日期从数组中删掉,以避免绘制蜡烛线时出现空白断连的情况
    // 通过调用 Date 对象的方法 date.getUTCDay() 所返回的数值,判定该日期对应周几
    // 周日对应的是 0,周六对应的是 6,要筛掉这些时间对象
    .domain(d3.utcDay
        .range(ticker.at(0).Date, +ticker.at(-1).Date + 1)
        .filter(d => d.getUTCDay() !== 0 && d.getUTCDay() !== 6))
    // 设置值域范围(所映射的可视元素)
    // svg 元素的宽度(减去留白区域)
    .range([marginLeft, width - marginRight])
    .padding(0.1); // 并设置间隔占据(柱子)区间的比例

美国线

OHLC 是 Open-High-Low-Close 的简写,称为美国线柱线图,正如其名该图线用于呈现「开盘价、最高价、最低价、收盘价」

参考
  • 解读的官方样例为 OHLC Chart
  • 对代码进行注释解读的 Notebook 是这个
  • 复现可以查看该网页,完整代码可以查看 这里

它是蜡烛线的「变形」,功能上相同,每个一天的股票价格变化用一条竖线表示,其中竖线呈现最高价和最低价的价差间距,然后用向左的短横线代表开盘价,向右的短横线表示收盘价(而不是用一根小柱棒表示),所以在绘制上比 K 线更简单

OHLC Chart 和蜡烛线很类似,大部分代码都是相同的,只是所绘制的是一条条竖线(而不是一根根小棒),相关代码如下

js
// 为股价图创建一个容器
const g = svg.append("g")
    // 设置路径描边的宽度
    .attr("stroke-width", 2)
    // 路径只需要描边,不需要填充
    .attr("fill", "none")
  .selectAll("path") // 使用 <path> 元素绘制线段
  // 绑定数据
  .data(ticker)
  .join("path") // 将这些路径绘制到页面上
    // 手动编写出每个线段的绘制路径
    // 首先 M${x(d.date)},${y(d.low)} 是将画笔移动到对应的位置,横坐标值是基于所绑定的数据点的 d.date 日期(并通过通过横坐标轴的比例尺 x 映射得到的);纵坐标值是基于当天股价的最低值(并通过纵坐标轴比例尺 y 映射得到的)
    // 然后 V${y(d.high)} 是垂直画出一条线段,终点位置是基于当天股价的最高值
    // 接着 M${x(d.date)},${y(d.open)} 将画笔(纵坐标值)移动到当天开盘价的位置(横坐标值基于日期)
    // 然后 h-4 是小写字母的命令,采用相对定位(即操作时基于上一个点的定位),这里表示从上一个点开始,向左绘制一小段水平线,长度为 4px
    // 接着 M${x(d.date)},${y(d.close)} 将画笔(纵坐标值)移动到当天收盘价的位置(横坐标值基于日期)
    // 然后 h4 是小写字母的命令,这里表示从上一个点开始,向右绘制一小段水平线,长度为 4px
    .attr("d", d => `
        M${x(d.date)},${y(d.low)}V${y(d.high)}
        M${x(d.date)},${y(d.open)}h-4
        M${x(d.date)},${y(d.close)}h4
      `)
    // 设置描边的颜色
    // 基于开盘价 d.Open 和收盘价 d.Close 的大小关系设置不同的颜色
    // d3.schemeSet1 是一个 Categorical Color Scheme 分类型的配色方案
    // 预选了 9 种色彩用于标识不同的类别
    // 具体可参考 d3-scale-chromatic 模块的官方文档 https://d3js.org/d3-scale-chromatic/categorical#schemeSet1
    // 当开盘价大于收盘价 d.Open > d.Close 则采用配色方案(数组)的第一个元素 d3.schemeSet1[0] 即红色 #e41a1c
    // 当收盘价大于开盘价 d.Close > d.Open 则采用配色方案(数组)的第三个元素 d3.schemeSet1[2] 即绿色 #4daf4a
    // 当开盘价等于收盘价,则采用配色方案(数组)的最后一个元素 d3.schemeSet1[8] 即灰色 #999999
    .attr("stroke", d => d.open > d.close ? d3.schemeSet1[0]
    : d.close > d.open ? d3.schemeSet1[2]
      : d3.schemeSet1[8])

Copyright © 2024 Ben

Theme BlogiNote

Icons from Icônes