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));
// 由于含有缺失数据,所以绘制出含有缺口的面积图
// 蓝色面积图覆盖(重叠)在前面所绘制的灰色面积图上,所以最终的效果是在缺口位置由灰色的区块填补

交互性

动效

变体

堆叠式面积图

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

带状图

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

实例


Copyright © 2024 Ben

Theme BlogiNote

Icons from Icônes