chart = {
const signs = new Map([].concat(
data.negatives.map(d => [d, -1]),
data.positives.map(d => [d, +1])
));
const bias = d3.sort(
d3.rollup(
data,
v => d3.sum(v, d => d.value * Math.min(0, signs.get(d.category))),
d => d.name
),
([, a]) => a
);
// 其中以 margin 为前缀的参数
// 其作用是在 svg 的外周留白,构建一个显示的安全区,以便在四周显示坐标轴
const marginTop = 40;
const marginRight = 30;
const marginBottom = 0;
const marginLeft = 80;
// svg 的宽度
const width = 928;
// svg 的高度
const height = bias.length * 33 + marginTop + marginBottom; // 每一个条带高度(包含间隔)为 33 px
// Prepare the stack; the values are stacked from the inside out, starting with more
// moderate values (“mostly false”, “half true”), and ending with the extreme values.
/**
*
* 对数据进行转换
*
*/
// 通过堆叠生成器对数据进行转换,便于后续绘制堆叠图
// ⚠️ 需要留意的是发散型堆叠条形图有两个方向,即对正面系列和负面系列的堆叠方向是不同的
// 返回一个数组,每一个元素都是一个系列(条形图中每个条带就是由多个系列堆叠而成的)
// D3 为每一个系列都设置了一个属性 key 其值是系列名称
// 而每一个元素(系列)也是一个数组,其中每个元素是该系列在条形图的每个条带中的相应值,例如在本示例中,有 9 个总统候选人,所以每个系列会有 9 个数据点
// 返回结果可以查看 👇 下面第三个 📝 cell
// 具体可以参考官方文档 https://d3js.org/d3-shape/stack 或 https://github.com/d3/d3-shape/blob/main/README.md
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-shape#堆叠生成器-stacks
const series = d3.stack()
// 设置系列的名称(数组),即诚信度的各种分类
.keys([].concat(data.negatives.slice().reverse(), data.positives))
// 设置各系列的数据读取函数
// 在调用堆叠生成器对原始数据进行转换过程中,每一个原始数据 d 和系列名称 key(就是在 stack.keys([keys]) 设定的数组中的元素)会作为入参,分别调用该函数,以从原始数据中获取相应系列的数据
// 数据读取函数的逻辑要如何写,和后面 👇👇 调用堆叠生成器时,所传入的数据格式紧密相关
// 因为(调用堆叠生成器)传入的数据是一个嵌套映射
// 在遍历数据点时
// 传入的第一个参数是当前所遍历的数据点,通过解构 [key, value] 第二个元素就是映射(第一层)的值(它也是一个映射)
// 传入的第二个参数 category 是当前所遍历的系列名称
// 然后通过 value.get(category) 从映射中获取相应系列(诚信度)的数据(如果该候选人没有这个系列的数据,则默认值为 0)
// 由于正面系列和负面系列的堆叠方向是不同的,所以最后返回的值还需要乘上 signs.get(category) 它的值是 +1 或 -1 以表示方向
.value(([, value], category) => signs.get(category) * (value.get(category) || 0))
// 设置堆叠基线函数,这里采用 D3 所提供的一种默认基线函数
// 允许正值和负值分别进行堆叠,正值会在零之上进行堆叠,负值会在零之下堆叠
// 具体可以参考官方文档 https://github.com/d3/d3-shape#stackOffsetDiverging
.offset(d3.stackOffsetDiverging)
// 调用堆叠生成器,传入数据
// 传入的数据并不是 data 而是经过 d3.rollup() 进行分组归类转换的
// 传入的具体数据可以查看 👇 下面第四个 📝 cell
(d3.rollup(data, data => d3.rollup(data, ([d]) => d.value, d => d.category), d => d.name));
/**
*
* 构建比例尺
*
*/
// 设置横坐标轴的比例尺
// 横坐标轴的数据是连续型的数值(各种诚信度类别的占比),使用 d3.scaleLinear 构建一个线性比例尺
// 具体参考官方文档 https://d3js.org/d3-scale/linear 或 https://github.com/d3/d3-scale/tree/main#linear-scales
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-scale#线性比例尺-linear-scales
const x = d3.scaleLinear()
// 设置定义域范围
// 使用方法 d3.extent() 获取可迭代对象的范围,即返回一个由最小值和最大值构成的数组 [min, max]
// 使用 series.flat(2) 将嵌套数组 series「拍平」变成一个一维数组
.domain(d3.extent(series.flat(2)))
// 设置值域范围(所映射的可视元素)
// 使用 scale.rangeRound() 方法,可以进行修约,以便实现整数(百分比)映射到整数(像素)
// svg 元素的宽度(减去留白区域)
.rangeRound([marginLeft, width - marginRight])
// 设置纵坐标轴的比例尺
// 纵坐标轴的数据是条形图的各种分类,使用 d3.scaleBand 构建一个带状比例尺
// 使用 d3-scale 模块
// 具体参考官方文档 https://d3js.org/d3-scale/band 或 https://github.com/d3/d3-scale/blob/v4.0.2/README.md#scaleBand
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-scale#带状比例尺-band-scales
const y = d3.scaleBand()
// 设置定义域范围(9 个总统候选人的名称)
.domain(bias.map(([name]) => name))
// scale.rangeRound() 方法,可以进行修约,以便实现整数(人)映射到整数(像素)
.rangeRound([marginTop, height - marginBottom])
.padding(2 / 33) // 并设置间隔占据(柱子)区间的比例
// 设置颜色比例尺
// 为不同系列设置不同的配色
// 使用 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()
// 设置定义域范围
// 各系列的名称,即各种诚信度的类别(包括正面和负面共 6 种)
.domain([].concat(data.negatives, data.positives))
// 设置值域范围
// 使用 D3 内置的一种颜色比例尺 d3.schemeSpectral
// 它是一个数组,包含一些预设的配色方案
// 通过 d3.schemeSpectral[k] 的形式可以快速获取一个数组,其中包含 k 个元素,每个元素都是一个表示颜色的字符串
// 其中 k 需要是 3~11 (包含)之间的数值
// 具体参考官方文档 https://d3js.org/d3-scale-chromatic/diverging#schemeSpectral 或 https://github.com/d3/d3-scale-chromatic/tree/main#schemeSpectral
// 这里根据系列的数量生成相应数量的不同颜色值
.range(d3.schemeSpectral[data.negatives.length + data.positives.length])
// 用于格式化数据的函数,用于堆叠的小矩形 tooltip 文本内容,以及坐标轴刻度
// 数据点先取绝对值,再转换以百分比的形式(且精度保留到百分位)
const formatValue = ((format) => (x) => format(Math.abs(x)))(d3.format(".0%"));
/**
*
* 创建 svg 容器
*
*/
// 返回的是一个包含 svg 元素的选择集
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto; font: 10px sans-serif;");
/**
*
* 绘制条形图内的柱子
*
*/
// 绘制的步骤与一般的条形图会有所不同
// 因为普通的条形图每一个条带都只是有一个矩形构成
// 而堆叠条形图的每一个条带是由多个小的矩形依次堆叠而成的
// 相应地,它们所绑定/对应的数据结构也不同
// 普通的条形图所绑定的数据是一个数组,页面上每一个条带对应数组中的一个元素
// 而堆叠条形图所绑定的数据是一个嵌套数组,页面上每一个堆叠层分别对应于数组的一个元素(一个系列数据,它也是一个数组),而同一堆叠层的不同小矩形则分别对应于嵌套数组中的一个元素
// 所以需要在绘制堆叠条形图时需要进行数据「二次绑定」
svg.append("g")
.selectAll("g") // 返回一个选择集,其中虚拟/占位元素是 <g> 它们作为各系列的容器
.data(series) // 绑定数据,每个容器 <g> 对应一个系列数据
.join("g")
.attr("fill", d => color(d.key)) // 设置颜色,不同系列/堆叠层对应不同的颜色
// 基于原有的选择集进行「次级选择」,选择集会发生改变
// 详细介绍可以查看这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-binding#次级选择
.selectAll("rect") // 使用 <rect> 元素为每一堆叠层绘制出一系列的小矩形
// 返回的选择集是由多个分组(各个 <g> 容器中)的虚拟/占位 <rect> 元素构成的
// ⚠️ 使用 select.selectAll() 所创建的新选择集会有多个分组
// 由于新的选择集会创建多个分组,那么原来所绑定数据与(选择集中的)元素的对照关系会发生改变
// 从原来的一对一关系,变成了一对多关系,所以新的选择集中的元素**不会**自动「传递/继承」父节点所绑定的数据
// 所以如果要将原来选择集中所绑定的数据继续「传递」下去,就需要手动调用 selection.data() 方法,以显式声明要继续传递数据
// 在这种场景下,该方法的入参应该是一个返回数组的**函数**
// 每一个分组都会调用该方法,并依次传入三个参数:
// * 当前所遍历的分组的父节点所绑定的数据 datum
// * 当前所遍历的分组的索引 index
// * 选择集的所有父节点 parent nodes
// 详细介绍可以查看这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-binding#绑定数据
// 所以入参 d 是一个堆叠系列的数据(即 series 的一个嵌套数组)
// 每个元素是一个二元数组,第一个元素是堆叠小矩阵的下/左边界;第二个元素是上/右边界;另外数组对象还具有一个属性 data 它包含原始数据(它也是一个二元数组,其中第一个元素 data[0] 就是所对应的总统候选人的名称)
// 这个函数的作用相当于为每个元素(数组对象)添加一个 key 属性(所属的系列名称/诚信度类别),返回所构造的新对象
.data(d => d.map(v => Object.assign(v, {key: d.key})))
.join("rect") // 将元素绘制到页面上
// 为每个小矩形分别设置左上角 (x, y) 及其 width 和 height 来确定其定位和形状
// 每个矩形的左上角横轴定位 x 由它的堆叠上边界决定
// 可以通过它所绑定的数据(一个数组)的第一个元素 d[0] 来获取
// 使用横坐标轴的比例尺(线性比例尺)进行映射,求出具体的横轴坐标值
.attr("x", d => x(d[0]))
// 每个矩形的左上角纵轴定位 y 由它所属的总统候选人的名称决定
// 可以通过所绑定数据的属性 d.data[0] 来获取,下面直接通过数组解构同时赋值的方式,将该值传递给变量 name
.attr("y", ({data: [name]}) => y(name))
// 每个矩形的宽度
// 由所绑定的数据(一个数组)的两个元素(上边界和下边界)之间的差值所决定
.attr("width", d => x(d[1]) - x(d[0]))
// 每个矩形的高度
// 通过纵轴的比例尺的方法 y.bandwidth() 获取 band 的宽度(不包含间隙 padding)
.attr("height", y.bandwidth())
// 最后为每个矩形 <rect> 元素之内添加 <title> 元素
// 以便鼠标 hover 在相应的小矩形之上时,可以显示 tooltip 提示信息
.append("title")
// 设置 tooltip 的文本内容
// 通过解构来获取所绑定的(数组)对象的属性 key 和 data
// 其中属性 key 是当前小矩形所属的诚信度类别的
// 而属性 data 是一个二元数组
// 对它进一步进行解构 [name, value] 第一个元素 name 是总统候选人的名称,第二个元素 value 是一个 Map 映射,包含该总统候选人的所有诚信度类别的映射值
// 所以可以通过 value.get(key) 获取到该总统候选人的诚信度类别 key 的占比(百分比)
.text(({key, data: [name, value]}) => `${name}
${formatValue(value.get(key))} ${key}`);
/**
*
* 绘制坐标轴
*
*/
// 绘制横坐标轴
svg.append("g")
// 通过设置 CSS 的 transform 属性将横坐标轴容器「移动」到顶部
.attr("transform", `translate(0,${marginTop})`)
// 💡 横轴是一个刻度值朝上的坐标轴
// 并使用坐标轴对象的方法 axis.ticks() 设置坐标轴的刻度数量
// 使用方法 axis.tickFormat() 设置刻度值格式,数据点先取绝对值,再转换以百分比的形式(且精度保留到百分位)
// 使用方法 axis.tickSizeOuter(0) 将坐标轴的外侧刻度 tickSizeOuter 长度设置为 0(即取消坐标轴首尾两端的刻度)
.call(d3.axisTop(x)
.ticks(width / 80)
.tickFormat(formatValue)
.tickSizeOuter(0))
// 删掉上一步所生成的坐标轴的轴线(它含有 domain 类名)
.call(g => g.select(".domain").remove())
// 为正向横坐标轴添加注释文字
.call(g => g.append("text")
// 设置文本的定位(在 x 和 y 方向上的偏移量)
.attr("x", x(0) + 20)
.attr("y", -24)
.attr("fill", "currentColor") // 设置文字的颜色
.attr("text-anchor", "start") // 设置文字的对齐方式
.text(data.positive))
// 为负向横坐标轴添加注释文字
.call(g => g.append("text")
.attr("x", x(0) - 20)
.attr("y", -24)
.attr("fill", "currentColor")
.attr("text-anchor", "end")
.text(data.negative));
// 绘制纵坐标轴
svg.append("g")
// 💡 纵轴是一个刻度值朝左的坐标轴
.call(d3.axisLeft(y).tickSizeOuter(0))
// 设置坐标轴刻度线和刻度值的定位
// 通过 class 类名 ".tick" 选中所有的刻度(容器,其中分别包括两个元素,<line> 是刻度线,<text> 是刻度值)
// 绑定数据 bias
// 它一个嵌套数组,即每一个元素都是一个数组,这些内嵌的数组都有两个元素
// 可以通过解构 [name, min] 获取到总统候选人的名称 name,及其对应的负面类别的数据的总和 min
// 然后通过 x(min) 可以得到该总统候选人所对应的条带的左端的横坐标轴的值
// 通过 y(name) 可以得到该总统候选人所对应的条带的纵坐标轴的值
// 那么 y(name) + y.bandwidth() / 2 就是条带的中间位置(由于 svg 的正方向是向右和向下的)
// 然后通过设置 CSS 的 transform 属性基于以上的计算值,将刻度移到相应的(条带左端)位置
.call(g => g.selectAll(".tick").data(bias).attr("transform", ([name, min]) => `translate(${x(min)},${y(name) + y.bandwidth() / 2})`))
// 而纵坐标轴的轴线(含有 class 类名 ".domain" 从 svg 的左侧移动到横坐标轴的零点位置 x(0)
.call(g => g.select(".domain").attr("transform", `translate(${x(0)},0)`));
// Return the color scale as a property of the node, for the legend.
return Object.assign(svg.node(), {scales: {color}});
}