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 折线图相对比
数据缺失的面积图
参考
- 解读的官方样例为 Area chart with missing data
- 对代码进行注释解读的 Notebook 是这个
- 复现可以查看该网页,完整代码可以查看 这里
该示例使用与前一个例子一样的数据源,但是进行一些修改,以手动模拟数据缺失的情况
关于数据处理的相关代码如下
// 💡 遍历 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
来判定该数据是否缺失,相关代码如下
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));
该示例实际上绘制了两种颜色的面积(灰色和蓝色),并进行覆盖叠加,最终的效果是在面积图缺口位置由灰色的区块填补,相关代码如下
// 💡 先绘制灰色的区域
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));
// 由于含有缺失数据,所以绘制出含有缺口的面积图
// 蓝色面积图覆盖(重叠)在前面所绘制的灰色面积图上,所以最终的效果是在缺口位置由灰色的区块填补
交互性
- Zoomable area chart / D3 | Observable https://observablehq.com/@d3/zoomable-area-chart
- Pannable chart / D3 | Observable https://observablehq.com/@d3/pannable-chart
- Focus + Context / D3 | Observable https://observablehq.com/@d3/focus-context
- Focus + context II / D3 | Observable https://observablehq.com/@d3/focus-context/2?collection=@d3/d3-dispatch
- Moving average / D3 | Observable https://observablehq.com/@d3/moving-average
动效
- Streamgraph transitions / D3 | Observable https://observablehq.com/@d3/streamgraph-transitions
- Realtime horizon chart / D3 | Observable https://observablehq.com/@d3/realtime-horizon-chart
变体
- Bollinger bands / D3 | Observable https://observablehq.com/@d3/bollinger-bands/2
- Difference chart / D3 | Observable https://observablehq.com/@d3/difference-chart/2
- Streamgraph / D3 | Observable https://observablehq.com/@d3/streamgraph/2
- Horizon chart / D3 | Observable https://observablehq.com/@d3/horizon-chart/2
- Ridgeline plot / D3 | Observable https://observablehq.com/@d3/ridgeline-plot
堆叠式面积图
堆叠面积图和普通的面积图类似,而实际上是由多个系列的小面积(一般采用不同的颜色进行标记)依次堆叠构成的,所以与一般的面积图相比,它所对应的数据格式,以及可视化时所使用的 D3 模块也会不同
参考
- 解读的官方样例为 Stacked area chart
- 对代码进行注释解读的 Notebook 是这个
- 复现可以查看该网页,完整代码可以查看 这里
其中主要不同是需要使用 d3-shape 模块 里面的关于 堆叠 stack 的一些方法
使用方法 d3.stack()
创建一个堆叠生成器(以下称为 stack)对数据进行转换,便于后续绘制堆叠图
提示
关于堆叠生成器的详细介绍可以查看这一篇笔记
例如对于如下数据
// 原始数据样例
// 数组每个元素都是一条数据记录,它包含了多个系列的数据(不同类型的水果在当月的销量)
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)
经过堆叠生成器的转换,可以得到一个嵌套数组,其中每一个元素(嵌套的数组)就是一个系列
// 返回的结果,转换后得到各系列的数据
[
// 每一个系列的数据都独立「抽取」出来组成一个数组
// 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
]
]
在该样例中,使用堆叠器对原始数据进行转换的相关代码如下
/**
*
* 对数据进行转换
*
*/
// 决定有哪些系列进行堆叠可视化
// 通过堆叠生成器对数据进行转换,便于后续绘制堆叠图
// 返回一个数组,每一个元素都是一个系列(整个面积图就是由多个系列堆叠而成的)
// 而每一个元素(系列)也是一个数组,其中每个元素是属于该系列的一个数据点,例如在本示例中,有 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));
另外为了便于区分不同系列,一般需要设置颜色比例尺,将不同系列映射到不同的颜色上,即不同的堆叠层会填充上不同的颜色,以下关于颜色比例尺的核心代码
// 设置颜色比例尺
// 为不同系列设置不同的配色
// 使用 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);
标准化的堆叠式面积图
参考
- 解读的官方样例为 Normalized stacked area chart
- 对代码进行注释解读的 Notebook 是这个
- 复现可以查看该网页,完整代码可以查看 这里
标准化的堆叠式面积图与普通的堆叠面积图相比,在代码上大部分都是相同的,相当于只是将基线函数更改为 d3.stackOffsetExpand
相关的代码如下
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
上图摘自 seaborn.lineplot
例如折线图描述均值的变化趋势,采用高亮的颜色,它展示了具代表性的数据是如何变化的;而带状图描述最小值和最大值的变化趋势,作为「背景」阴影区域,补充说明数据的整体是如何变化的
参考
- 解读的官方样例为 Band chart
- 对代码进行注释解读的 Notebook 是这个
- 复现可以查看该网页,完整代码可以查看 这里
- 推荐阅读文章 An Underrated Chart Type: The Band Chart 作者对带状图的优缺点进行分析,并讨论了其适用场景
构建该类型的可视化图的代码和普通面积图基本一样,区别在于普通面积图的下边界线的纵坐标的读取函数一般设置为 y(0)
,因为它们的下边界线通常是横坐标轴,所以它的 y
值始终是 0
;而带状图的下边界线的纵坐标读取函数则会复杂些,它会基于不同数据点而生成不同的值(而不是恒为 0
)
该实例中关于面积生成器的核心代码如下:
/**
*
* 绘制面积图内的面积形状
*
*/
// 使用 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));
实例
- U.S. population by State, 1790–1990 / D3 | Observable https://observablehq.com/@d3/u-s-population-by-state-1790-1990