D3 面积图

d3
Created 3/4/2023
Updated 3/18/2024

D3 面积图

静态图

参考
  • 解读的官方样例为 Area 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() 构建一个时间比例尺

提示

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

提示

可以与另一种可视化形式 line chart 折线图相对比

数据缺失的面积图

参考

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

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

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

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

回调函数 callback函数传入三个参数:

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

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

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

js
const area = d3.area()
  // 💡 调用面积生成器方法 area.defined() 设置数据完整性检验函数
  // 该函数会在调用面积生成器时,为数组中的每一个元素都执行一次,返回布尔值,以判断该元素的数据是否完整
  // 该函数传入三个入参,当前的元素 `d`,该元素在数组中的索引 `i`,整个数组 `data`
  // 当函数返回 true 时,面积生成器就会执行下一步(调用坐标读取函数),最后生成该元素相应的坐标数据
  // 当函数返回 false 时,该元素就会就会跳过,当前面积就会截止,并在下一个有定义的元素再开始绘制,反映在图上就是一个个分离的面积区块
  // 具体可以参考官方文档 https://d3js.org/d3-shape/area#area_defined
  // 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-shape#面积生成器-areas
  // 这里通过判断数据点的属性 d.close(收盘价)是否为 NaN 来判定该数据是否缺失
  .defined(d => !isNaN(d.close))
  // 设置设置下边界线横坐标读取函数
  .x(d => x(d.date))
  // 设置下边界线的纵坐标的读取函数
  .y0(y(0))
  // 设置上边界线的纵坐标的读取函数
  .y1(d => y(d.close));

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

js
// 💡 先绘制灰色的区域
svg.append("path") // 使用路径 <path> 元素绘制面积形状
  // 将面积的填充颜色设置为灰色
  .attr("fill", "#ccc")
  .attr("d", area(aaplMissing.filter(d => !isNaN(d.close))));
// 其实以上的操作绘制了一个完整的面积图,由于过滤掉缺失的数据点,所以可以绘制出了一个完整(无缺口)的面积图
// 由于 **线性插值法 linear interpolation** 是面积生成器在绘制边界线时所采用的默认方法,所以对于那些缺失数据的位置,通过连接左右存在的完整点,绘制出的「模拟」线段来填补边界线的缺口
// 再绘制蓝色的面积区块(完整性的数据点)
svg.append("path") // 使用路径 <path> 元素绘制面积形状
  // 将面积的填充颜色设置为蓝色
  .attr("fill", "steelblue")
  .attr("d", area(aaplMissing));
// 由于含有缺失数据,所以绘制出含有缺口的面积图
// 蓝色面积图覆盖(重叠)在前面所绘制的灰色面积图上,所以最终的效果是在缺口位置由灰色的区块填补

交互性

可缩放的面积图

参考

关于缩放交互的核心代码如下

js
/**
 *
 * 缩放交互
 *
 */
// 缩放事件的回调函数
// 当缩放时,需要更新横坐标轴比例尺,并重绘面积图
// 其中参数 event 是 D3 的缩放事件对象
// 该缩放事件对象的属性 transform 包含当前的缩放变换值,还提供一些方法用于操作缩放
function zoomed(event) {
  // 调用方法 transform.rescaleX(x) 更新横轴轴比例尺
  // 返回一个定义域经过缩放变换的比例尺(这样映射关系就会相应的改变,会考虑上缩放变换对象 transform 的缩放比例)
  // 💡 关于方法 transform.rescaleX(x) 的介绍可以参考这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-interact#缩放变换对象的方法
  const xz = event.transform.rescaleX(x);
  // 调用函数 area(data, xz) 返回的结果是字符串,更新变量 path(它是一个选择集,里面包含一个 <path> 元素)的属性 d
  // 其中参数 xz 是更新后的的横坐标比例尺
  path.attr("d", area(data, xz));
  // 使用新的比例尺重新绘制横坐标轴
  gx.call(xAxis, xz);
}
// 创建缩放器
const zoom = d3.zoom()
    // 约束缩放比例的范围,默认值是 [0, ∞]
    // 入参是一个数组 [1, 32] 表示最小的缩放比例是 1 倍,最大的缩放比例是 8 倍
    .scaleExtent([1, 32])
    // 缩放器除了可以缩放,还可以进行平移,以下两个方法分别设置与平移相关参数
    // 设置视图范围 viewport extent
    // 入参是一个嵌套数组,第一个元素是面积图的矩形区域的左上角,第二个元素是右下角
    // 如果缩放器绑定的是 svg,则视图范围 viewport extent 默认是 viewBox
    // 这里「校正」为用于绘制面积图的区域大小(不包含 margin 的区域)
    .extent([[marginLeft, 0], [width - marginRight, height]])
    // 约束平移的范围 translate extent,默认值是 [[-∞, -∞], [+∞, +∞]]
    // 这里设置平移的范围:最左侧为面积图的左边;最右侧为面积图的右边(最上方和最下方的范围虽然是无限的,但是这里只会进行水平缩放,所以也只可能进行水平移动,并不能进行上下移动)
    // 所以即使放大后,画布也只能在面积图的最左边和最右边之间来回移动
    .translateExtent([[marginLeft, -Infinity], [width - marginRight, Infinity]])
    .on("zoom", zoomed); // 缩放事件的回调函数
    // 🔎 以上提及的视图范围 viewport extent 和平移范围 translate extent 这两个概念,具体可以查看 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-interact
// 为 svg 添加缩放事件监听器
svg.call(zoom)

其中值得学习的一个巧思是:面积图初始缩放状态是放大的,而且(从无缩放状态)切换到初始缩放状态时设置了过渡动效,这样就有让首次浏览器该可视化图形的用户知道该面积图是支持缩放交互的。

设置过渡动效的相关代码如下

js
// 为 svg 添加缩放事件监听器
svg.call(zoom)
  // 通过 selection.transition() 创建过渡管理器
  // 💡 这样(从无缩放状态)切换到初始缩放状态时,就可以有过渡动效
  .transition()
    .duration(750) // 设置过渡持续时间
    // 设置初始缩放状态
    // 💡 transition.call(function[, arguments…]) 执行一次函数 function 它其实和ff selection.call() 方法类似
    // 💡 而且将过渡管理器作为第一个入参传递给 function,而其他传入的参数 arguments... 同样传给 function
    // 💡 最后返回当前过渡管理器,这样是为了便于后续进行链式调用
    // 具体参考官方文档 https://d3js.org/d3-transition/control-flow#transition_call
    // 或这一篇文档 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-transition#过渡参数配置
    // 使用方法 zoom.scaleTo(selection, k[, p]) 对选择集的元素进行缩放操作,并将缩放比例设置为 k
    // 第三个参数 p 是构建平滑的缩放过渡的参照点,默认为视图的中点,该参考点在缩放过程中不会发生移动
    // 这里将初始状态设置为放大 4 倍,过渡参考点是设置为横坐标轴上的一个点(日期 Date.UTC(2001, 8, 1) 所对应的位置),也是靠近中间的位置
    .call(zoom.scaleTo, 4, [x(Date.UTC(2001, 8, 1)), 0]);

另外还有一个值得注意的设计细节:当面积图放大时,图形会「延伸」到纵轴后,阻碍纵轴的显示。所以需要为面积图的 <path> 元素设置属性 clip-path,引用一个预设的 <clipPath> 路径剪裁遮罩,对图形进行裁剪/约束,相关代码如下

js
// 创建一个 identifier 唯一标识符(字符串)
// 它会作为一些 <clipPath> 元素的 id 属性值(方便其他元素基于 id 来使用),以避免与其他元素发生冲突
// 💡 在参考的 Observable Notebook 使用了平台的标准库所提供的方法 DOM.uid(namespace) 创建一个唯一 ID 号
// 💡 具体参考官方文档 https://observablehq.com/documentation/misc/standard-library#dom-uid-name
// 💡 方法 DOM.uid() 的具体实现可参考源码 https://github.com/observablehq/stdlib/blob/main/src/dom/uid.js
// const clip = DOM.uid("clip");
// 这里使用硬编码(手动指定)id 值
const clipId = "clipId";
// 创建一个元素 <clipPath> (一般具有属性 id 以便被其他元素引用)路径剪裁遮罩,其作用充当一层剪贴蒙版,具体形状由其包含的元素决定
// 💡 它不会直接在页面渲染出图形,而是被其他元素(通过设置属性 clip-path)引用的方式来起作用,为其他元素自定义了视口
// 这里在 <clipPath> 内部添加了一个 <rect> 设置剪裁路径的形状,以约束面积图容器的可视区域
// 则放大面积图时,超出其容器的部分就不会显示(避免遮挡坐标轴)
svg.append("clipPath")
  // 为 <clipPath> 设置属性 id
  .attr("id", clipId)
// 在其中添加 <rect> 子元素,以设置剪切路径的形状
.append("rect")
  // 设置矩形的定位和尺寸
  .attr("x", marginLeft) // 设置该元素的左上角的横坐标值(距离 svg 左侧 marginLeft 个像素大小)
  .attr("y", marginTop) // 设置该元素的左上角的纵坐标值(距离 svg 顶部 marginTop 个像素大小)
  .attr("width", width - marginLeft - marginRight) // 设置宽度(采用 svg 的宽度,并减去左右留白区域)
  .attr("height", height - marginTop - marginBottom); // 设置宽度(采用 svg 的高度,并减去上下留白区域)
// 将面积形状绘制到页面上
// 变量 path 是一个选择集,里面包含一个元素 <path>,它是绘制面积形状的元素
const path = svg.append("path") // 使用路径 <path> 元素绘制面积形状
  // 设置属性 clip-path 以采用前面预设的 <clipPath> 元素对图形进行裁剪/约束
  .attr("clip-path", clipId)
  .attr("fill", "steelblue") // 将面积的填充颜色设置为蓝色
  // 调用函数 area(data, x) 返回的结果是字符串,作为 `<path>` 元素的属性 `d` 的值
  .attr("d", area(data, x));

动效

变体

堆叠式面积图

堆叠面积图和普通的面积图类似,而实际上是由多个系列的小面积(一般采用不同的颜色进行标记)依次堆叠构成的,所以与一般的面积图相比,它所对应的数据格式,以及可视化时所使用的 D3 模块也会不同

参考

其中主要不同是需要使用 d3-shape 模块 里面的关于 堆叠 stack 的一些方法

使用方法 d3.stack() 创建一个堆叠生成器(以下称为 stack)对数据进行转换,便于后续绘制堆叠图

提示

关于堆叠生成器的详细介绍可以查看这一篇笔记

例如对于如下数据

js
// 原始数据样例
// 数组每个元素都是一条数据记录,它包含了多个系列的数据(不同类型的水果在当月的销量)
const data = [
  {month: new Date(2015, 0, 1), apples: 3840, bananas: 1920},
  {month: new Date(2015, 1, 1), apples: 1600, bananas: 1440},
];
// 创建一个堆叠生成器
const stack = d3.stack()
    .keys(["apples", "bananas"]) // 设定系列名称
// 对原数据进行转换
const series = stack(data);
console.log(series)

经过堆叠生成器的转换,可以得到一个嵌套数组,其中每一个元素(嵌套的数组)就是一个系列

js
// 返回的结果,转换后得到各系列的数据
[
  // 每一个系列的数据都独立「抽取」出来组成一个数组
  // apple 系列数据
  [
    // 每个元素都是 apple 的月销量数据点
    // 前两个数值类似于面积图中的下边界线和上边界线
    // data 对象保留了转换前该数据点对应的原始数据来源
    [0, 3840, data: {month: 2014-12-31T16:00Z, apples: 3840, bananas: 1920}], // 12 月销量
    [0, 1600, data: {month: 2015-01-31T16:00Z, apples: 1600, bananas: 1440}], // 1 月销量
    key: "apples", // 该系列对应的 key(名称)
    index: 0 // 该系列在 series 中的顺序(即对应于堆叠条形图中该系列叠放的次序)
  ],
  // bananas 系列数据
  [
    [3840, 5760, data: {month: 2014-12-31T16:00Z, apples: 3840, bananas: 1920}],
    [1600, 3040, data: {month: 2015-01-31T16:00Z, apples: 1600, bananas: 1440}],
    key: "bananas",
    index: 1
  ]
]

在该样例中,使用堆叠器对原始数据进行转换的相关代码如下

js
/**
 *
 * 对数据进行转换
 *
 */
// 决定有哪些系列进行堆叠可视化
// 通过堆叠生成器对数据进行转换,便于后续绘制堆叠图
// 返回一个数组,每一个元素都是一个系列(整个面积图就是由多个系列堆叠而成的)
// 而每一个元素(系列)也是一个数组,其中每个元素是属于该系列的一个数据点,例如在本示例中,有 122 个月份的数据,所以每个系列会有 122 个数据点
// 具体可以参考官方文档 https://d3js.org/d3-shape/stack
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-shape#堆叠生成器-stacks
const series = d3.stack()
  // 设置系列的名称(数组)
  // 使用 d3.union() 从所有数据点的属性 industry 的值中求出并集,返回一个集合 set
  // 即有哪几种行业
  // 该方法来自 d3-array 模块,具体可以参考官方文档 https://d3js.org/d3-array/sets#union
  // 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-process#集合
  // D3 为每一个系列都设置了一个属性 key,其值是系列名称(生成面积图时,系列堆叠的顺序就按照系列名称的排序)
  .keys(d3.union(data.map(d => d.industry)))
  // 设置各系列的数据读取函数
  // 在调用堆叠生成器对原始数据进行转换过程中,每一个原始数据 d 和系列名称 key(就是在 stack.keys([keys]) 设定的数组中的元素)会作为入参,分别调用该函数,以从原始数据中获取相应系列的数据
  // 数据读取函数的逻辑要如何写,和后面 👇👇 调用堆叠生成器时,所传入的数据格式紧密相关
  // 因为传入的数据 d3.index(data, d => d.date, d => d.industry) 是一个嵌套映射
  // 在遍历数据点时(映射会变成一个二元数组 [键名,值] 的形式),要从中获取相应系列的数据
  // 首先要对当前所遍历的数据点进行解构 [key, value] 第二个元素就是映射(第一层)的值,它也是一个映射
  // 然后再通过 D.get(key) 获取相应系列(行业)的数据(一个对象)
  // 堆叠的数据是失业人数,所以最后返回的是该系列数据(对象)的 unemployed 属性
  .value(([, D], key) => D.get(key).unemployed)
  // 调用堆叠生成器,传入数据
  // 传入的数据并不是 data 而是经过 d3.index() 进行分组归类转换的
  (d3.index(data, d => d.date, d => d.industry));

另外为了便于区分不同系列,一般需要设置颜色比例尺,将不同系列映射到不同的颜色上,即不同的堆叠层会填充上不同的颜色,以下关于颜色比例尺的核心代码

js
// 设置颜色比例尺
// 为不同系列设置不同的配色
// 使用 d3.scaleOrdinal() 排序比例尺 Ordinal Scales 将离散型的定义域映射到离散型值域
// 具体参考官方文档 https://d3js.org/d3-scale/ordinal
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-scale#排序比例尺-ordinal-scales
const color = d3.scaleOrdinal()
  // 设置定义域范围
  // 各系列的名称,即 14 种行业
  .domain(series.map(d => d.key))
  // 设置值域范围
  // 使用 D3 内置的一种配色方案 d3.schemeTableau10
  // 它是一个数组,包含一些预设的颜色(共 10 种)
  // 具体可以参考官方文档 https://d3js.org/d3-scale-chromatic/categorical#schemeTableau10
  // 这里的系列数量是 14 种,而 d3.schemeTableau10 配色方案种只有 10 种颜色
  // 💡 排序比例尺会将定义域数组的第一个元素映射到值域的第一个元素,依此类推。如果值域的数组长度小于定义域的数组长度,则值域的元素会被从头重复使用进行映射,即进行「循环」映射
  // 所以仔细查看会发现有些系列所对应的颜色有重复
  // 但是在堆叠图中只要相邻的系列不采用相同的颜色,即可达到区分的作用,所以系列数量和颜色数量不相等也不影响实际效果
  // 也可以查看官方文档 https://d3js.org/d3-scale-chromatic/categorical 采用其他(提供更多颜色的)配色方案,让各种系列都有唯一的颜色进行标识
  .range(d3.schemeTableau10);

标准化的堆叠式面积图

参考

标准化的堆叠式面积图与普通的堆叠面积图相比,在代码上大部分都是相同的,相当于只是将基线函数更改为 d3.stackOffsetExpand 相关的代码如下

js
const series = d3.stack()
  // 💡 设置堆叠基线函数,这里采用 D3 所提供的一种基线函数 d3.stackOffsetExpand
  // 对数据进行标准化(相当于把各系列的绝对数值转换为所占的百分比),基线是零,上边界线是 1
  // 所以每个横坐标值所对应的总堆叠高度都一致(即纵坐标值为 1)
  // 具体可以参考官方文档 https://d3js.org/d3-shape/stack#stackOffsetExpand
  .offset(d3.stackOffsetExpand)
  // 设置系列的名称(数组)
  // 使用 d3.union() 从所有数据点的属性 industry 的值中求出并集,返回一个集合 set
  // 即有哪几种行业
  // 该方法来自 d3-array 模块,具体可以参考官方文档 https://d3js.org/d3-array/sets#union
  // 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-process#集合
  // D3 为每一个系列都设置了一个属性 key,其值是系列名称(生成面积图时,系列堆叠的顺序就按照系列名称的排序)
  .keys(d3.union(data.map(d => d.industry)))
  // 设置各系列的数据读取函数
  // 在调用堆叠生成器对原始数据进行转换过程中,每一个原始数据 d 和系列名称 key(就是在 stack.keys([keys]) 设定的数组中的元素)会作为入参,分别调用该函数,以从原始数据中获取相应系列的数据
  // 数据读取函数的逻辑要如何写,和后面 👇👇 调用堆叠生成器时,所传入的数据格式紧密相关
  // 因为传入的数据 d3.index(data, d => d.date, d => d.industry) 是一个嵌套映射
  // 在遍历数据点时(映射会变成一个二元数组 [键名,值] 的形式),要从中获取相应系列的数据
  // 首先要对当前所遍历的数据点进行解构 [key, value] 第二个元素就是映射(第一层)的值,它也是一个映射
  // 然后再通过 D.get(key) 获取相应系列(行业)的数据(一个对象)
  // 堆叠的数据是失业人数,所以最后返回的是该系列数据(对象)的 unemployed 属性
  .value(([, D], key) => D.get(key).unemployed)
  // 调用堆叠生成器,传入数据
  // 传入的数据并不是 data 而是经过 d3.index() 进行分组归类转换的
  (d3.index(data, d => d.date, d => d.industry));

河流图

河流图 Streamgraph 是堆叠式面积图的一种变体,它的构建方法和常见的堆叠式面积图大体相同,只是在堆叠算法和基线设置有所不同。(由于河流图采用特殊的布局)可能还需要对纵坐标轴的刻度值进行相应的处理。

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

河流图的基线并不是零(并不在横坐标轴),而是在 svg 图的中间,所以需要采用不同的基线函数 d3.stackOffsetWiggle,一般配合的排序函数是 d3.stackOrderInsideOut 让河流图看起来更美观、流畅、易读

提示

可以阅读相关文章 Stacked Graphs – Geometry & Aesthetics 对这种布局的介绍

与河流图相关的数据转换(堆叠器的配置)核心代码如下

js
/**
 *
 * 对数据进行转换
 *
 */
// 决定有哪些系列进行堆叠可视化
// 通过堆叠生成器对数据进行转换,便于后续绘制堆叠图
// 返回一个数组,每一个元素都是一个系列(整个面积图就是由多个系列堆叠而成的)
// 而每一个元素(系列)也是一个数组,其中每个元素是属于该系列的一个数据点,例如在本示例中,有 122 个月份的数据,所以每个系列会有 122 个数据点
// 具体可以参考官方文档 https://d3js.org/d3-shape/stack
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-shape#堆叠生成器-stacks
const series = d3.stack()
    // 设置基线函数,通过更新堆叠图的上下界的值,可以调整图形整体的定位
    // D3 提供了一系列内置的基线函数,它们的具体效果可以参考 https://d3js.org/d3-shape/stack#stack-offsets
    // 默认使用内置基线函数 d3.stackOffsetNone 以零为基线
    // 这里使用另一种内置基线函数 d3.stackOffsetWiggle 通过移动基线,以最大程度地减小各系列的「振幅」(即各系列沿着横轴上下摆动的幅度),让河流图看起来更美观、流畅、易读
    // 它一般用在河流图中,并与排序函数 d3.stackOrderInsideOut 配合使用
    // 可以阅读相关文章 https://leebyron.com/streamgraph/ 对这种算法的介绍
    .offset(d3.stackOffsetWiggle)
    // 设置排序函数,即决定堆叠图中各系列的叠放次序
    // 该函数返回的是一个数组(称为排序数组 order),里面的元素是一个表示索引的数值,依次对应于系列名称数组的元素,表示各系列的排序/叠放优先次序
    // D3 提供了一系列内置的排序函数,它们的具体效果可以参考 https://d3js.org/d3-shape/stack#stack-orders
    // 默认使用内置排序函数 d3.stackOrderNone 它不对排序/叠放次序进行改变
    // 即按照系列名称数组(通过方法 stack.keys() 所设置的)来排序
    // 这里使用了另一种内置的排序函数 d3.stackOrderInsideOut
    // 它是根据各系列的最大值进行排序,将较大的系列置于堆叠图的中间(一般用于河流图中)
    // 可以阅读相关文章 https://leebyron.com/streamgraph/ 对这种布局的介绍
    .order(d3.stackOrderInsideOut)
    // 设置系列的名称(数组)
    // 使用 d3.union() 从所有数据点的属性 industry 的值中求出并集,返回一个集合 set
    // 即数据集中包含了哪几种(名称不同的)行业
    // 该方法来自 d3-array 模块,具体可以参考官方文档 https://d3js.org/d3-array/sets#union
    // 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-process#集合
    // D3 为每一个系列都设置了一个属性 key,其值是系列名称(生成面积图时,系列堆叠的顺序就按照系列名称的排序)
    .keys(d3.union(data.map(d => d.industry)))
    // 设置各系列的数据读取函数
    // 在调用堆叠生成器对原始数据进行转换过程中,每一个原始数据 d 和系列名称 key(就是通过方法 stack.keys() 所设置的数组中的元素)会作为入参,分别调用该函数,以从原始数据中获取相应系列的数据
    // 数据读取函数的逻辑要如何写,和后面 👇👇 调用堆叠生成器时,所传入的数据格式紧密相关
    // 因为传入的数据 d3.index(data, d => d.date, d => d.industry) 是一个嵌套映射
    // 在遍历数据点时(映射会变成一个二元数组 [键名,值] 的形式),要从中获取相应系列的数据
    // 首先要对当前所遍历的数据点进行解构 [key, value] 第二个元素就是映射(第一层)的值,它也是一个映射
    // 然后再通过 D.get(key) 获取相应系列(行业)的数据(一个对象)
    // 堆叠的数据是失业人数,所以最后返回的是该系列数据(对象)的 unemployed 属性
    .value(([, D], key) => D.get(key).unemployed)
  // 调用堆叠生成器,传入数据
  // 传入的数据并不是 data 而是经过 d3.index() 进行分组归类转换的
  (d3.index(data, d => d.date, d => d.industry));
// 💡 虽然所绘制的河流图中,所对应的纵坐标值都是正数,这是由于坐标轴的刻度值是经过处理的,实际上在零点下方的刻度值是负数
// 相应地经过以上堆叠器转换所得的(表示上下界)数据中是有正负值,在纵坐标轴的零点之上的堆叠面积所对应的数据为正,在零点之下堆叠的面积所对应的数据为负

另外还需要对纵坐标轴的刻度值进行处理,相关代码如下

js
// 绘制纵坐标轴
// 它和一般图表的坐标轴不一样,因为河流图的基线位于 svg 的中间(不一定是零点)
// 💡 所以纵坐标轴的零点不一定在 x 轴的位置
svg.append("g")
    // 通过设置 CSS 的 transform 属性将纵向坐标轴容器「移动」到左侧
    .attr("transform", `translate(${marginLeft},0)`)
    // 纵轴是一个刻度值朝左的坐标轴
    // 通过 axis.ticks(count) 设置刻度数量的参考值(避免刻度过多导致刻度值重叠而影响图表的可读性)
    // 然后使用方法 axis.tickFormat() 设置刻度值的格式
    // 💡 数据集 series 是对原始数据处理后所得的,为了方便实现河流图的布局,它是含有正负值
    // 然后采用比例尺 y 将 series 数据映射为河流图,则基于比例尺 y 所生成的纵坐标轴的刻度值也有正负值(与 series 数据相对应),但是实际上失业人数并不存在负数
    // 所以这里需要对刻度值进行处理,使用 Math.abs() 取绝对值,将负值变成正值,所以最终纵坐标轴的刻度值在零点上下都是正值
    // 并且使用 number.toLocaleString("en-US") 格式化数字(转换为字符串,以符合特定的语言环境的表达方式)
    .call(d3.axisLeft(y).ticks(height / 80).tickFormat((d) => Math.abs(d).toLocaleString("en-US")))
    // 删掉上一步所生成的坐标轴的轴线(它含有 domain 类名)
    .call(g => g.select(".domain").remove())
    // 复制了一份刻度线,用以绘制图中横向的网格参考线
    .call(g => g.selectAll(".tick line").clone()
        // 调整复制后的刻度线的终点位置(往右移动)
        .attr("x2", width - marginLeft - marginRight)
        .attr("stroke-opacity", 0.1))
    // 为坐标轴添加额外信息名称(一般是刻度值的单位等信息)
    .call(g => g.append("text")
        // 将该文本移动到坐标轴的顶部(即容器的左上角)
        .attr("x", -marginLeft)
        .attr("y", 10)
        .attr("fill", "currentColor") // 设置文本的颜色
        .attr("text-anchor", "start") // 设置文本的对齐方式
        .text("↑ Unemployed persons")); // 设置文本内容

带状图

Band Chart 带装图是面积图的一种变形;也可以称为范围面积图,因为它通过上边界和下边界展示数据范围的变化

提示

带状图也常常与折线图一起使用,构成一种增强型的折线图,称为 band line

band line
band line

上图摘自 seaborn.lineplot

例如折线图描述均值的变化趋势,采用高亮的颜色,它展示了具代表性的数据是如何变化的;而带状图描述最小值和最大值的变化趋势,作为「背景」阴影区域,补充说明数据的整体是如何变化的

参考

构建该类型的可视化图的代码和普通面积图基本一样,区别在于普通面积图的下边界线的纵坐标的读取函数一般设置为 y(0),因为它们的下边界线通常是横坐标轴,所以它的 y 值始终是 0;而带状图的下边界线的纵坐标读取函数则会复杂些,它会基于不同数据点而生成不同的值(而不是恒为 0

该实例中关于面积生成器的核心代码如下:

js
/**
  *
  * 绘制面积图内的面积形状
  *
  */
// 使用 d3.area() 创建一个面积生成器
// 面积生成器会基于给定的数据生成面积形状
// 调用面积生成器时返回的结果,会基于生成器是否设置了画布上下文 context 而不同。如果设置了画布上下文 context,则生成一系列在画布上绘制路径的方法,通过调用它们可以将路径绘制到画布上;如果没有设置画布上下文 context,则生成字符串,可以作为 `<path>` 元素的属性 `d` 的值
// 具体可以参考官方文档 https://d3js.org/d3-shape/area
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-shape#面积生成器-areas
const area = d3.area()
    // 设置下边界线横坐标读取函数
    // 该函数会在调用面积生成器时,为数组中的每一个元素都执行一次,以返回该数据所对应的横坐标
    // 这里基于每个数据点的日期(时间)d.date 并采用比例尺 x 进行映射,计算出相应的横坐标
    .x(d => x(d.date))
    // 设置下边界线的纵坐标的读取函数
    // 这里的面积图的下边界线是横坐标轴,所以它的 y 值始终是 0,并采用比例尺 y 进行映射,得到纵坐标轴在 svg 中的坐标位置
    .y0(y(0))
    // 设置上边界线的纵坐标的读取函数
    .y1(d => y(d.close));

差异图

Difference Chart 差异图如果不标注颜色其实就是一个 Area Chart 面积图(具体来说更像是 Band Chart,它的下界一般不是横轴)

但标注上颜色就显得不同了,该类型的图形是为了凸显两个研究目标之间的差异,而且绘制方式也和一般面积图不同。

理解差异图时可以将其看作由两条折线构成(不是单纯的面积图的上界和下界),例如在以下示例中加粗的线就表示三藩市的每日平均温度随时间的变化,那么相应地面积图的另一条边就是纽约的每日平均温度随时间的变化。

而将两条线之间的区域填充上颜色就构成了差异图,可以通过面积的大小直观地感知两者差距的大小(以及如何变化)。

并且可以根据两个折线的相对位置关系,为相应的面积区域标注不同的颜色,以更直观地凸显出差异的不同情况(谁的值更大),在以下实例中当三藩市的温度更高的日子面积标为橙色,当三藩市的温度更低的日子面积标为蓝色。

参考

在绘制差异图时与一般的面积图有所不同,首先是面积区域只是局限在两个折线之间,而且需要根据折线的相对位置填充上不同的颜色,所以一张差异图一般需要分两个步骤来绘制。

在以上示例中使用 <clipPath> 对普通的面积图(或它的互补图形)进行裁剪,只绘制出折线之间的面积,不同颜色的面积分开绘制,具体的

剪贴路径

剪贴路径 <clipPath> 可以自定义视口的形状和大小,作用于元素上就可以达到裁剪的效果

在以上示例通过设置上下错位的剪贴路径 <clipPath> 与面积路径 <path>(以达到上下钳制/约束的效果),可以基于普通的面积图(或它的互补图形)裁剪出所需的区域

该实例中关于绘制差异图的核心代码如下:

js
/**
 *
 * 绘制面积图内的面积形状
 *
 */
// 创建一个 <clipPath> 元素(一般具有属性 id 以便被其他元素引用),其作用充当一层剪贴蒙版,具体形状由其包含的元素决定
// 它一般与其他元素一起使用(通过属性 clip-path 来指定),为其他元素自定义了视口
// 即在 <clipPath> 所规定的区域以外的部分都会被裁剪掉
// 具体介绍可以参考 https://developer.mozilla.org/en-US/docs/Web/SVG/Element/clipPath
svg.append("clipPath")
    // 为 <clipPath> 设置属性 id
    .attr("id", "above")
  // 在其中添加 <path> 子元素,以设置剪切路径的形状
  .append("path")
    // 使用方法 d3.area() 创建一个面积生成器,它会根据给定的数据(svg 所绑定的数据)设置 <path> 路径形状
    // 💡 调用面积生成器时返回的结果,会基于生成器是否设置了画布上下文 context 而不同。如果设置了画布上下文 context,则生成一系列在画布上绘制路径的方法,通过调用它们可以将路径绘制到画布上;如果没有设置画布上下文 context,则生成字符串,可以作为 `<path>` 元素的属性 `d` 的值
    // 具体可以参考官方文档 https://d3js.org/d3-shape/area
    // 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-shape#面积生成器-areas
    .attr("d", d3.area()
        // 设置两点之间的曲线插值器,这里使用 D3 所提供的一种内置曲线插值器 d3.curveStep
        // 该插值效果是在两个数据点之间,生成阶梯形状的线段(作为面积图的边界)
        // 具体效果参考 https://d3js.org/d3-shape/curve#curveStep
        .curve(d3.curveStep)
        // 设置下边界线横坐标读取函数
        // 该函数会在调用面积生成器时,为数组中的每一个元素都执行一次,以返回该数据所对应的横坐标
        // 这里基于每个数据点的日期(时间)d.date 并采用比例尺 x 进行映射,计算出相应的横坐标
        .x(d => x(d.date))
        // 设置下边界线的纵坐标的读取函数,它始终是 0(即位于 svg 的顶部位置)
        .y0(0)
        // 设置上边界线的纵坐标的读取函数,基于 d.value1 纽约的温度,并采用比例尺 y 进行映射,得到纵坐标轴在 svg 中的坐标位置
        .y1(d => y(d.value1)));
// 正如该剪贴路径的 id 名称一样,该剪贴蒙版的显示区域/视口范围位于 svg 的上半部分,即从 svg 的顶部,直到橙色面积的下边缘
// 通过该剪切路径的约束,可以呈现由两地之间较低的日间温度所构成的的折线
// 创建一个 <clipPath> 元素
svg.append("clipPath")
    // 为 <clipPath> 设置属性 id
    .attr("id","below")
  // 在其中添加 <path> 子元素,以设置剪切路径的形状
  .append("path")
    // 使用方法 d3.area() 创建一个面积生成器,它会根据给定的数据(svg 所绑定的数据)设置 <path> 路径形状
    .attr("d", d3.area()
        // 设置两点之间的曲线插值器
        .curve(d3.curveStep)
        // 设置下边界线横坐标读取函数
        .x(d => x(d.date))
        // 设置下边界线的纵坐标的读取函数,它始终是 height(即位于 svg 的底部位置,即横坐标轴)
        .y0(height)
        // 设置上边界线的纵坐标的读取函数,基于 d.value1 纽约的温度,并采用比例尺 y 进行映射,得到纵坐标轴在 svg 中的坐标位置
        .y1(d => y(d.value1)));
// 正如该剪贴路径的 id 名称一样,该剪贴蒙版的显示区域/视口范围位于 svg 的下部分,即从 svg 的底部,直到蓝色面积的下边缘
// 通过该剪切路径的约束,可以呈现由两地之间较高的日间温度所构成的的折线
// 💡 通过以上两个 <clipPath> 对面积图的共同约束,就可以裁剪出所需的差异图
// 绘制表示三藩市温度的面积图,并通过 <clipPath> 进行裁剪
svg.append("path") // 使用路径 <path> 元素绘制面积形状
    // 设置属于 clip-path 以采用前面预设的 <clipPath id="above"> 对图形进行裁剪/约束
    .attr("clip-path", "url(#above)")
    .attr("fill", colors[1]) // 设置填充颜色为橙色
    // 使用方法 d3.area() 创建一个面积生成器,它会根据给定的数据(svg 所绑定的数据)设置 <path> 路径形状
    .attr("d", d3.area()
        // 设置两点之间的曲线插值器
        .curve(d3.curveStep)
        // 设置下边界线横坐标读取函数
        .x(d => x(d.date))
        // 设置下边界线的纵坐标的读取函数,它始终是 height(即位于 svg 的底部位置,即横坐标轴)
        .y0(height)
        // 设置上边界线的纵坐标的读取函数,基于 d.value0 三藩市的温度,并采用比例尺 y 进行映射,得到纵坐标轴在 svg 中的坐标位置
        .y1(d => y(d.value0)));
  // 💡 通过 <clipPath id="above"> 对面积图的裁剪,只显示三藩市温度面积图的部分,即橙色的部分,由于这部分的区域(所对应的时间里)纽约的温度更低
// 绘制表示纽约温度的面积图,并通过 <clipPath> 进行裁剪
svg.append("path") // 使用路径 <path> 元素绘制面积形状
    // 设置属于 clip-path 以采用前面预设的 <clipPath id="below"> 对图形进行裁剪/约束
    .attr("clip-path", "url(#below)")
    .attr("fill", colors[0]) // 设置填充颜色为浅蓝色
    // 使用方法 d3.area() 创建一个面积生成器,它会根据给定的数据(svg 所绑定的数据)设置 <path> 路径形状
    .attr("d", d3.area()
        // 设置两点之间的曲线插值器
        .curve(d3.curveStep)
        // 设置下边界线横坐标读取函数
        .x(d => x(d.date))
        // ⚠️ 设置下边界线的纵坐标的读取函数,它始终是 0(即位于 svg 的顶部位置)
        // ⚠️ 和普通的面积图有所不同,可以理解为真正表示纽约温度的面积图是透明的,而(这里绘制的)与它互补的部分则填充为浅蓝色
        // ⚠️ 结合 <clipPath> 的裁剪,剩余的部分就是三藩市温度比纽约高的日子
        .y0(0)
        // 设置上边界线的纵坐标的读取函数,基于 d.value0 三藩市的温度,并采用比例尺 y 进行映射,得到纵坐标轴在 svg 中的坐标位置
        .y1(d => y(d.value0)));
// 绘制一条黑色的线,表示三藩市的温度随时间的变化
svg.append("path") // 使用路径 <path> 元素绘制折线
    .attr("fill", "none") // 由于折线不需要填充颜色,所以属性 fill 设置为 none
    .attr("stroke", "black") // 设置折线的描边颜色为黑色
    .attr("stroke-width", 1.5) // 设置描边的宽度
    .attr("stroke-linejoin", "round") //
    .attr("stroke-linecap", "round") // 设置折线之间的连接样式(圆角让连接更加平滑)
    // 使用方法 d3.line() 创建一个线段生成器,线段生成器会基于给定的数据(svg 所绑定的数据)生成线段(或曲线)
    // 调用线段生成器时返回的结果,会基于生成器是否设置了画布上下文 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
    .attr("d", d3.line()
        // 设置两点之间的曲线插值器
        .curve(d3.curveStep)
        // 该函数会在调用线段生成器时,为数组中的每一个元素都执行一次横坐标读取函数和纵坐标读取函数,以返回该数据所对应的横纵坐标值
        // 设置横坐标读取函数
        // 这里基于每个数据点的日期(时间)d.date 并采用比例尺 x 进行映射,计算出相应的横坐标
        .x(d => x(d.date))
        // 设置纵坐标读取函数
        // 这里基于每个数据点的三藩市的温度 d.value0 并采用比例尺 y 进行映射,计算出相应的纵坐标
        .y(d => y(d.value0)));
提示

另外在复现以上官方示例时,还制作了一个简化版本 difference-chart-v2

不需要使用 <clipPath> 对原始面积图(或它的互补图形)进行裁剪,而是根据三藩市于纽约的温度的关系来控制面积图的上边界线/下边界线,直接分别构建出两种不同颜色的面积,最终渲染生成的可视化图形是相同的

相关代码如下:

js
/**
 *
 * 绘制面积图内的面积形状
 *
 */
// 绘制表示橙色的面积图(三藩市的温度高于纽约),面积图的上边界是三藩市的温度,下边界是纽约的温度,填充色为橙色
// 而对于三藩市低于纽约的日子,则不绘制面积图(可以将这段时间的面积图的下边界也设定为三藩市的温度,则该时间段的面积图绘制为一条线,再将折线的描边设置为透明即可)
svg.append("path") // 使用路径 <path> 元素绘制面积形状
    .attr("fill", colors[1]) // 设置填充颜色为橙色
    // 使用方法 d3.area() 创建一个面积生成器,它会根据给定的数据(svg 所绑定的数据)设置 <path> 路径形状
    .attr("d", d3.area()
        // 设置两点之间的曲线插值器
        .curve(d3.curveStep)
        // 设置下边界线横坐标读取函数
        .x(d => x(d.date))
        // 设置下边界线的纵坐标的读取函数,它始终是 height(即位于 svg 的底部位置,即横坐标轴)
        .y0(d => d.value0 > d.value1 ? y(d.value1) : y(d.value0))
        // 设置上边界线的纵坐标的读取函数,基于 d.value0 三藩市的温度,并采用比例尺 y 进行映射,得到纵坐标轴在 svg 中的坐标位置
        .y1(d => y(d.value0)));
// 绘制表示浅蓝色的面积图(三藩市的温度低纽约),面积图的上边界是纽约的温度,下边界是三藩市的温度,填充色为浅蓝色
// 而对于三藩市高于纽约的日子,则不绘制面积图(可以将这段时间的面积图的下边界也设定为纽约的温度,则该时间段的面积图绘制为一条线,再将折线的描边设置为透明即可)
svg.append("path") // 使用路径 <path> 元素绘制面积形状
    .attr("fill", colors[0]) // 设置填充颜色为浅蓝色
    // 使用方法 d3.area() 创建一个面积生成器,它会根据给定的数据(svg 所绑定的数据)设置 <path> 路径形状
    .attr("d", d3.area()
        // 设置两点之间的曲线插值器
        .curve(d3.curveStep)
        // 设置下边界线横坐标读取函数
        .x(d => x(d.date))
        // 设置下边界线的纵坐标的读取函数,它始终是 height(即位于 svg 的底部位置,即横坐标轴)
        .y0(d => d.value0 > d.value1 ? y(d.value1) : y(d.value0))
        // 设置上边界线的纵坐标的读取函数,基于 d.value1 纽约的温度,并采用比例尺 y 进行映射,得到纵坐标轴在 svg 中的坐标位置
        .y1(d => y(d.value1)));

山脊线图

山脊线图 Ridgeline plots 可以展示多个系列的数据

别名

它也被称为 joy plot,因为在 Joy Division 乐队的专辑《Unknown Pleasures》的封面上出现了山脊线图

展示多系列数据的面积图对比

山脊线图 Ridgeline plots、地平线图 Horizon charts、面积图矩阵 Small-multiple area charts 都可以用于展示多个系列的数据,但采用的方式各不相同,所以在绘图高度有限的前提下可提供的精度也有差异:

  • 面积图矩阵:通过将整体划分出多个小区域,就可以分别展示不同系列的数据,但是相应地每个小区域可展示的精度就变得更小了
  • 山脊图:通过将多个面积图尽可能地重叠,以展示更多的系列数据,只要图形之间重叠部分足够大,就可以尽可能地降低对精度的影响(虽然会有可能出现由于重叠遮盖,而无法准确显示一些较低的数值)
  • 地平线图:通过对面积图纵向切片分段,再将切片重叠在一起,从而控制各个面积图的纵向高度(到相同的高度),但是这种处理方式会掩盖掉面积图的真实高度,为了弥补这个缺点需要为面积图添加颜色和设置透明度,以颜色的深浅来编码面积的高低。这样的转换会显著地降低精度(颜色深浅相对更难定量),但是很适合在有限的空间展示大量不同系列的数据,颜色也可以让读者对各系列数据之间的差异有更快速和直观的感受
参考

山脊线图是由多个小面积图堆叠起来而构成的,采用点状比例尺将各个小面积图沿着纵向排列,相关代码如下

js
// 设置山脊图的纵坐标轴的比例尺(针对整体,用于将各个面积图在纵向定位)
// 在山脊图中多个面积图纵向铺开,在纵坐标轴上分别对应标注出不同地点,使用 d3.scalePoint 构建一个点状比例尺
// 该比例尺将基于定义域数组的离散元素(不同的地名)的数量,将值域的范围分割为等距的各段,各个分隔点与定义域中的离散元素依次映射
// 点状比例尺 Point Scales 和带状比例尺类似,就像是将 band 的宽度设置为 0
// 具体可以参考官方文档 https://d3js.org/d3-scale/point
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-scale#点状比例尺-point-scales
const y = d3.scalePoint()
    // 设置定义域范围,参数是一个数组,包含所需映射的系列名称
    // 使用 JS 数组的原生方法 array.map() 对数组 series 进行处理
    // 提取出各系列的名称(地名) d.name 构成一个新的数组
    .domain(series.map(d => d.name))
    // 设置值域范围
    // svg 元素的高度(减去留白区域)
    .range([marginTop, height - marginBottom]);
// ...
// 创建容器
// 首先建一个整体的容器
const group = svg.append("g")
  .selectAll("g") // 返回一个选择集,其中虚拟/占位元素是一系列的 <g> 元素,它们分别作为各个系列的面积图的容器
  .data(series) // 绑定数据,每个容器 <g> 元素对应一个系列的数据
  .join("g") // 将这些 <g> 元素绘制到页面上
    // 通过设置 CSS 的 transform 属性将各系列的容器定位不同的位置
    // 基于各系列的名称(地点名)并采用比例尺 y 进行映射 y(d.name) 计算出相应(分隔点)的纵坐标值,然后向下偏移 1px ❓ 可能是要覆盖掉横坐标轴的轴线
    .attr("transform", d => `translate(0,${y(d.name) + 1})`);

而绘制各个小面积图的步骤和绘制一个普通面积图类似,但是由于它们之间需要重叠,所以设置它们的纵向高度的方式会有所不同。(普通的面积图的纵向高度就是 svg 的高度)各个小面积图的默认最大高度可以先采用点状比例尺的步长作为基础 step,然后再乘上一个特定的倍数 k 以控制相邻面积图之间的重叠程度,即各个小面积图的纵向(最大)高度为 k*step,相关代码如下

js
// 设置面积图的纵坐标轴比例尺(针对各个面积图,用于计算它们的上边界线的纵坐标值)
// 面积图的纵轴数据是连续型的数值(车流量),使用 d3.scaleLinear 构建一个线性比例尺
const z = d3.scaleLinear()
    // 设置定义域范围
    // [0, ymax] 其中 ymax 是车流量的最大值
    // 首先使用 d3.max(d.values) 计算当前所遍历的系列的各个时间点的车流量中的最大值
    // 然后再一次使用 d3.max() 计算所有系列中的最大值
    // 并使用 continuous.nice() 方法编辑定义域的范围,通过四舍五入使其两端的值更「整齐」nice
    .domain([0, d3.max(series, d => d3.max(d.values))]).nice()
    // 设置值域范围
    // 定义域的最小值都映射到 0,定义域的最大值都映射到 -overlap*y.step()
    // 其中 y 是点状比例尺,调用方法 y.step() 返回步长,即分隔点之间的距离
    // 而 overlap 是前面所定义的一个变量,调节/控制相邻面积图之间的重叠程度
    // ⚠️ 根据 svg 的坐标系统,左上角才是坐标 (0,0),而向右和向下是正方向(坐标值为正值)
    // 所以在前面添加的负号 -overlap * y.step() 表示经过比例尺 z 映射后,各面积图的上边界线都是负值,即它们都是朝上的
    // 💡 最初各个面积图都是定位到 svg 的顶部,它们的(y 值)都是在 [0, -overlap*y.step] 范围中,即最大占据的空间/高度是 overlap*y.step
    // 然后它们会根据 y 比例尺重定位到点状比例尺的分隔点上,形成垂直排布
    // 如果 overlap=1 则各个面积图所占据的(最大)纵向空间正好是 y.step() 分隔点的间距,所以相邻面积图之间不会重叠;如果 overlap > 1 则面积图占据的纵空间比点状比例尺的间隔更大,则相邻面积图之间就可能发生重叠
    .range([0, -overlap * y.step()]);
// ...
/**
 *
 * 绘制山脊线图
 *
 */
// Create the area generator and its top-line generator.
// 使用 d3.area() 创建一个面积生成器,它适用于生成各个系列的面积图
// 面积生成器会基于给定的数据生成面积形状
// 调用面积生成器时返回的结果,会基于生成器是否设置了画布上下文 context 而不同。如果设置了画布上下文 context,则生成一系列在画布上绘制路径的方法,通过调用它们可以将路径绘制到画布上;如果没有设置画布上下文 context,则生成字符串,可以作为 `<path>` 元素的属性 `d` 的值
// 具体可以参考官方文档 https://d3js.org/d3-shape/area
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-shape#面积生成器-areas
const area = d3.area()
    // 设置两点之间的曲线插值器,这里使用 D3 所提供的一种内置曲线插值器 d3.curveBasis
    // 该插值效果是在两个数据点之间,生成三次样条曲线 cubic basis spline
    // 具体效果参考 https://d3js.org/d3-shape/curve#curveBasis
    .curve(d3.curveBasis)
    // 💡 调用面积生成器方法 area.defined() 设置数据完整性检验函数
    // 该函数会在调用面积生成器时,为数组中的每一个元素都执行一次,返回布尔值,以判断该元素的数据是否完整
    // 该函数传入三个入参,当前的元素 `d`,该元素在数组中的索引 `i`,整个数组 `data`
    // 当函数返回 true 时,面积生成器就会执行下一步(调用坐标读取函数),最后生成该元素相应的坐标数据
    // 当函数返回 false 时,该元素就会就会跳过,当前面积就会截止,并在下一个有定义的元素再开始绘制,反映在图上就是一个个分离的面积区块
    // 具体可以参考官方文档 https://d3js.org/d3-shape/area#area_defined
    // 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-shape#面积生成器-areas
    // 这里通过判断当前所遍历的值是否为 NaN 来判定该数据是否缺失
    .defined(d => !isNaN(d))
    // 设置下边界线横坐标读取函数
    // 该函数会在调用面积生成器时,为数组中的每一个元素都执行一次,以返回该数据所对应的横坐标
    // 这里基于当前所遍历的数据点的索引值,从数组 dates[i] 中读取出所对应的时间,并采用比例尺 x 进行映射,计算出相应的横坐标值
    .x((d, i) => x(dates[i]))
    // 设置下边界线的纵坐标的读取函数
    // 所有系列的面积图的下边界线的初始定位都是 svg 的顶部,所以纵坐标值都是 0
    .y0(0)
    // 设置上边界线的纵坐标的读取函数,基于当前所遍历的数据点(车流量)并采用比例尺  z 进行映射,计算出相应的纵坐标值
    .y1(d => z(d));

地平线图

下图演示了制作地平线图 Horizon chart 的 5 个步骤

地平线图制作步骤
地平线图制作步骤

The figure illustrates the five steps involved in transforming an area chart into a horizon chart: A. Define a horizontal axis to differentiate positive values from negative values. 定义一个水平轴,以划分正负值 B. Reflect negative values above the axis, using blue for positive values and red for negative values. 将负值(关于水平轴)向上翻转,使用蓝色标记正值,使用红色标记负值 C. Divide the graph into distinct bands. 将面积图划分为多个等宽的条带 D. Adjust the opacity of the areas based on the number of subdivisions. 调整各个条带的透明度 E. Layer the obtained bands on top of each other to create the final horizon chart. 将上层条带依次往下堆叠,将普通的面积图转换为地平线图

参考自 Horizon chart - Wikipedia

另外还可以观看视频 What's a horizon chart and how to present one effectively 对于如何制作地平线图 Horizon chart 进行了更详细的介绍,并且介绍了地平线图的阅读方法,可以从中获取哪些信息

展示多系列数据的面积图对比

山脊线图与地平线图或面积图矩阵相比可以提供更高的精确度,要注意其前提是垂直空间有限

在绘图高度有限的前提下,上述三种可视化图形都可以展示多个数据,但采用的方式各不相同,所以可提供的精度也有差异:

  • 面积图矩阵:通过将整体划分出多个小区域,就可以分别展示不同系列的数据,但是相应地每个小区域可展示的精度就变得更小了
  • 山脊图:通过将多个面积图尽可能地重叠,以展示更多的系列数据,只要图形之间重叠部分足够大,就可以尽可能地降低对精度的影响(虽然会有可能出现由于重叠遮盖,而无法准确显示一些较低的数值)
  • 地平线图:通过对面积图横向切片分段,再将切片重叠在一起,从而控制各个面积图的纵向高度(到相同的高度),但是这种处理方式会掩盖掉面积图的真实高度,为了弥补这个缺点需要为面积图添加颜色和设置透明度,以颜色的深浅来编码面积的高低。这样的转换会显著地降低精度(颜色深浅相对更难定量),但是很适合在有限的空间展示大量不同系列的数据,颜色也可以让读者对各系列数据之间的差异有更快速和直观的感受
地平线图的优势

由于地平线图是将面积图划分为多个条带后堆叠起来所构成的,所以地平线图还保留了原来面积图的分布走向,因此与单纯用颜色深浅表示数值大小的其他可视化图表相比,地平线图可以提供更多的信息

参考

绘制地平线图的关键是如何对面积图横向切片分段,再将切片重叠在一起

在以上示例通过元素 <use> 复用 svg 元素,可以很方便地在页面渲染出多个相同的面积图,然后利用元素 <clipPath> 的裁剪/约束视口功能,实现对面积图的切片效果,相关代码如下:

js
/**
 *
 * 绘制地平线图
 *
 */
// 使用 d3.area() 创建一个面积生成器,它适用于生成各个系列的(未折叠)面积图
// 面积生成器会基于给定的数据生成面积形状
// 调用面积生成器时返回的结果,会基于生成器是否设置了画布上下文 context 而不同。如果设置了画布上下文 context,则生成一系列在画布上绘制路径的方法,通过调用它们可以将路径绘制到画布上;如果没有设置画布上下文 context,则生成字符串,可以作为 `<path>` 元素的属性 `d` 的值
// 具体可以参考官方文档 https://d3js.org/d3-shape/area
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-shape#面积生成器-areas
const area = d3.area()
  // 💡 调用面积生成器方法 area.defined() 设置数据完整性检验函数
  // 该函数会在调用面积生成器时,为数组中的每一个元素都执行一次,返回布尔值,以判断该元素的数据是否完整
  // 该函数传入三个入参,当前的元素 `d`,该元素在数组中的索引 `i`,整个数组 `data`
  // 当函数返回 true 时,面积生成器就会执行下一步(调用坐标读取函数),最后生成该元素相应的坐标数据
  // 当函数返回 false 时,该元素就会就会跳过,当前面积就会截止,并在下一个有定义的元素再开始绘制,反映在图上就是一个个分离的面积区块
  // 具体可以参考官方文档 https://d3js.org/d3-shape/area#area_defined
  // 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-shape#面积生成器-areas
  // 这里通过判断当前所遍历的值是否为 NaN 来判定该数据是否缺失
  .defined(d => !isNaN(d.value))
  // 设置下边界线横坐标读取函数
  // 该函数会在调用面积生成器时,为数组中的每一个元素都执行一次,以返回该数据所对应的横坐标
  // 这里基于当前所遍历的数据点的属性 d.date,并采用比例尺 x 进行映射,计算出相应的横坐标值
  .x((d) => x(d.date))
  // 设置下边界线的纵坐标的读取函数
  // 所有系列的面积图的下边界线的初始定位都是 svg 的顶部往下一个带宽的高度(这样面积图「折叠」后都位于 svg 的顶部,便于之后将各系列面积图的重定位),所以纵坐标值都是 size
  .y0(size)
  // 设置(未折叠的面积图)上边界线的纵坐标的读取函数,基于当前所遍历的数据点的属性 d.value(车流量)并采用比例尺 y 进行映射,计算出相应的纵坐标值
  .y1((d) => y(d.value));
// 创建一个 identifier 唯一标识符(字符串)
// 它会作为一些 svg 元素(例如 <path> 和 <clipPath> 元素)的 id 属性值的一部分(方便其他元素基于 id 来使用),以避免与其他元素发生冲突
// 这个字符串以 `o-` 为前缀
// 字符串的后半部分使用 JS 原生方法 Math.random() 生成一个在 (0,1) 之间的随机数,并使用方法 number.toString() 将该数字转换为字符串,最后使用 string.slice(2) 从字符串的第三位开始截取至末尾,即舍去了小数点及其前面的数字(零),所以 uid 的后半部分由数字 0 到 9 和字母 A 到 F 共 16 种符号随机组成而成的字符串
const uid = `O-${Math.random().toString(16).slice(2)}`;
console.log(uid)
// 创建容器
// 首先建一个整体的容器
const g = svg.append("g")
  // 返回一个选择集,其中虚拟/占位元素是一系列的 <g> 元素,它们分别作为各个系列的容器
  .selectAll("g")
  .data(series) // 绑定数据,每个容器 <g> 元素对应一个系列的数据
  .join("g") // 将这些 <g> 元素绘制到页面上
    // 通过设置 CSS 的 transform 属性将各系列的容器定位不同的位置
    // 各系列容器的纵坐标值是(它在数组 series 中的)索引值 i 乘上条带的高度 size,还要加上 marginTop(考虑 svg 顶部的留白)
    .attr("transform", (d, i) => `translate(0,${i * size + marginTop})`);
// 💡 最后变量 g 是一个选择集,包含一系列的 `<g>` 元素,分别作为各个系列的面积图的容器
console.log(g);
// 在每个系列的容器 <g> 元素里分别添加 <defs> 元素
// 💡 在 <defs> 元素定义一些图形元素,以便之后使用(而不在当前渲染出来),一般通过元素 <use> 复用这些元素
const defs = g.append("defs");
// 💡 最后变量 defs 是一个选择集,包含一系列的 `<defs>` 元素(它们分别在各个系列的面积图的容器 <g> 里面)
// 在这些 <defs> 元素里分别定义一个 <clipPath> 元素和 <path> 元素
// ✂️ 其中元素 <clipPath>(一般具有属性 id 以便被其他元素引用)路径剪裁遮罩,其作用充当一层剪贴蒙版,具体形状由其包含的元素决定
// 这里在 <clipPath> 内部添加了一个 <rect> 设置剪裁路径的形状,让面积图约束在高度为 size 的矩形条带中
defs.append("clipPath")
    // 为 <clipPath> 设置属性 id,其属性值使用前面生成的 uid 唯一标识符(字符串),并(使用连字符 `"-"`)拼接上字符串 `"clip"` 和索引值 i
    .attr("id", (_, i) => `${uid}-clip-${i}`)
  // 在其中添加 <rect> 子元素,以设置剪切路径的形状(将各个面积图约束在该矩形内)
  .append("rect")
    // 设置矩形的定位和尺寸,考虑各系列之间的间隔 padding
    .attr("y", padding) // 设置纵坐标值(距离其容器顶部 padding 个像素大小,作为间隔相邻条带的空隙)
    .attr("width", width) // 设置宽度(采用 svg 的宽度)
    .attr("height", size - padding); // 设置高度(由于矩形纵坐标值为 padding,所以矩形的高度为 size - padding,这样每个条带的高度都可以保持为 size)
// ✒️ 而其中元素 <path> 路径用于绘制面积图
// 💡 将面积图定义在 <defs> 里便于复用,由于在地平线图里,每个条带都是由多个面积图堆叠而成的
defs.append("path")
  // 为 <path> 设置属性 id,其属性值使用前面生成的 uid 唯一标识符(字符串),并(使用连字符 `"-"`)拼接上字符串 `"path"` 和索引值 i
  .attr("id", (_, i) => `${uid}-path-${i}`)
  // 由于面积生成器并没有调用方法 area.context(parentDOM) 设置画布上下文
  // 所以调用面积生成器 area(values) 返回的结果是字符串
  // 该值作为 `<path>` 元素的属性 `d` 的值
  // 💡 在前面为每个系列容器绑定的数据是 series,它是一个 InternMap 对象
  // 在绑定数据时 InternMap 对象会转换为数组(嵌套数组),其中每个元素都是以二元数组 [键名,值] 的形式表示
  // 二元数组中,第一个元素对应于该系列名称,第二个元素是属于该系列的数据点(也是一个数组)
  // 这里通过解构二元数组,获取第二个元素(即属于该系列的数据点),赋值给变量 values 以绘制该系列的面积图
  .attr("d", ([, values]) => area(values));
// Create a group for each location, in which the reference area will be replicated
// (with the SVG:use element) for each band, and translated.
// 在每个系列的容器里分别添加一个 <g> 元素
g.append("g")
  // 通过设置属性 clip-path 以采用在前面(<defs> 元素里)预设的 <clipPath> 元素,对该 <g> 元素里的图形元素进行裁剪,约束在高度为 size 的条带里
  // 💡 这里属性 clip-path 的值也可以直接使用(<clipPath> 元素的 id 值)`#${uid}-clip-${i}`
  .attr("clip-path", (_, i) => `url(${new URL(`#${uid}-clip-${i}`, location)})`)
  // 进行二次选择,在 <g> 元素内添加多个 <use> 元素,以便通过重复引用在前面(<defs> 元素里)预设的 <path> 元素
  .selectAll("use")
  // ⚠️ 使用 select.selectAll() 所创建的新选择集会有多个分组
  // 返回的选择集是由多个分组(各个系列容器里的 <g> 元素中)的虚拟/占位 <use> 元素构成的
  // 由于新的选择集会创建多个分组,那么原来所绑定数据与(选择集中的)元素的对照关系会发生改变
  // 从原来的一对一关系,变成了一对多关系,所以新的选择集中的元素**不会**自动「传递/继承」父节点所绑定的数据
  // 所以如果要将原来选择集中所绑定的数据继续「传递」下去,就需要手动调用 selection.data() 方法,以显式声明要继续传递数据
  // 在这种场景下,该方法的入参应该是一个返回数组的**函数**
  // 每一个分组都会调用该方法,并依次传入三个参数:
  // * 当前所遍历的分组的父节点所绑定的数据 datum
  // * 当前所遍历的分组的索引 index
  // * 选择集的所有父节点 parent nodes
  // 详细介绍可以查看笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-binding#绑定数据
  // 这里所需要使用的是第二个参数 i(索引值,用于构建引用的 URL)
  // 使用 JS 原生方法 new Array(bands) 手动构建出绑定的数据,该数组所含的元素数量是 bands,而且它们的值都是 i
  .data((_ ,i) => new Array(bands).fill(i))
  // 将 <use> 元素添加到页面上
  .join("use")
    // 最终在页面上(每个系列的容器中)添加了 bands 个 <use> 元素
    // 为这些 <use> 元素设置属性 href,这里参数 i(并不是当前所遍历的 <use> 元素的索引值)是前面绑定的数据(即手动构建出来的数组),所以它表示该系列的索引值,即在同一个系列容器里这些 <use> 元素都是指向同一个 <path> 元素,绘制出相同的面积图
    // 所以每个系列都会在页面以采用在前面(<defs> 元素里)预设的 <path> 元素,在页面上渲染出 bands 个(相同的)面积图
    .attr("href", (i) => `${new URL(`#${uid}-path-${i}`, location)}`)
    // 设置面积图的填充颜色,基于当前所遍历的元素的索引值,并采用颜色比例尺 colors 进行映射,得到该面积图所对应的颜色值 colors[i]
    .attr("fill", (_, i) => colors[i])
    // 再使用 CSS 的 transform 属性,基于索引值将这些面积图进行不同的纵向偏移 translate(0,${i * size}) 是条带高度 size 的倍数
    // 💡 根据 svg 的坐标系统,左上角才是坐标 (0,0),而向右和向下是正方向(坐标值为正值),所以索引值越大,对应的面积图朝下的偏移量就越大
    // 由于在前面为各个系列的容器设置了属性 clip-path,所以裁剪后视口高度只有 size 大小,则各个面积图只展示(不同的)一部分
    .attr("transform", (_, i) => `translate(0,${i * size})`);
    // 最终的效果相当于将面积图划分为 bands 个条带并堆叠在一起

实例


Copyright © 2024 Ben

Theme BlogiNote

Icons from Icônes