D3 折线图

d3
Created 8/21/2023
Updated 11/7/2023

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));

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

一般会在页面添加一个新的 <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 进行深入研究,对其进行扩展或修改以适用更广的使用场景。

交互性

https://observablehq.com/@d3/change-line-chart/2?collection=@observablehq/visualization | Line chart, percent change / D3 | Observable https://observablehq.com/@d3/line-with-tooltip/2 | Line chart with tooltip / D3 | Observable https://observablehq.com/@d3/index-chart/2?collection=@observablehq/visualization | Index chart / D3 | Observable https://observablehq.com/@d3/parallel-coordinates | Parallel coordinates / D3 | Observable

多系列折线图

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

参考

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

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)) // 调整参考线的终点位置(往右移动)

动效

https://observablehq.com/@d3/connected-scatterplot/2 | Connected scatterplot / D3 | Observable https://observablehq.com/@d3/learn-d3-animation | Learn D3: Animation / D3 | Observable https://observablehq.com/@mbostock/new-zealand-tourists-1921-2018 | New Zealand Tourists, 1921–2018 / Mike Bostock | Observable https://observablehq.com/@mbostock/sea-ice-extent-1978-2017 | Sea Ice Extent, 1978–2017 / Mike Bostock | Observable

变体

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

斜率图

斜率图 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])

其他

https://observablehq.com/@d3/d3-blur | d3.blur / D3 | Observable https://observablehq.com/@d3/spline-editor | Spline Editor / D3 | Observable https://observablehq.com/@d3/path-tween | Path tween / D3 | Observable https://observablehq.com/@d3/you-draw-it | You Draw It / D3 | Observable


Copyright © 2023 Ben

Theme BlogiNote

Icons from Icônes