Public
Edited
Dec 19, 2022
1 fork
Insert cell
Insert cell
// 读取并解析数据
data = FileAttachment("category-brands.csv").csv({typed: true})
Insert cell
Insert cell
chart = {
replay; // 引入按钮❓响应点击事件❓

const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height]);

// 初始化,调用相应的函数创建各种图像元素
// 这些函数最后都会返回一个函数,以便之后进行调用来更新相应的图像元素
const updateBars = bars(svg); // 创建条形图里的矩形柱子
const updateAxis = axis(svg); // 创建横坐标轴
const updateLabels = labels(svg); // 创建(矩形柱子)标注信息
const updateTicker = ticker(svg); // 更新坐标轴的刻度

yield svg.node(); // 生成图像

// 将每一个日期(时间点)的数据依次更新到页面上,并在数据更新时应用一些过渡动效
// 根据不同时间点的数据更新页面的图像元素时,就像是在绘制动画的「关键帧」
// 因此所使用的变量名称是 keyframe
// 通过过渡动效将这些「关键帧」连起来就构成了连续流程的动画
for (const keyframe of keyframes) {
// 每次循环都创建一个新的过渡管理器
const transition = svg.transition()
.duration(duration) // 设置过渡持续时间
.ease(d3.easeLinear); // 设置过渡的缓动函数

// 这里先要更新横轴比例尺的定义域
// 以(当前时间点)新数据的最大值作为定义域的最大值
// 后面就会使用新的比例尺更新坐标轴
x.domain([0, keyframe[1][0].value]);

// 依次调用函数并传入相应的参数,更新相应的图像元素
updateAxis(keyframe, transition);
updateBars(keyframe, transition);
updateLabels(keyframe, transition);
updateTicker(keyframe, transition);

// invalidation 是 Observale 平台(内置的函数/标准库)所提供的 promise
// 具体查看官方文档 https://observablehq.com/@observablehq/invalidation
invalidation.then(() => svg.interrupt());
// 等待当前的过渡管理器 transition 所创建的过渡都结束时(会抛出 end 事件)
// 再执行下一次循环
// transition.end() 方法返回的是一个 Promise,所以这里使用 await 来等待异步操作的完成
await transition.end();
}
}
Insert cell
// 过渡动效的默认持续时间是 250ms
duration = 250
Insert cell
// 需要在 svg 中展示的柱子数量
n = 12
Insert cell
// 从原始数据中提取出企业的名称
// 每一个数据点(就是表格的每一行)依次调用 data.map(d => d.name)
// 传入的参数 d 是当前所遍历的数据点,其中 d.name 属性就是该数据项所表示公司的名称
// 再基于这些企业名称(一个数组),创建一个 InternSet 对象,以便去重
// 所以 names 是数据集中所包含的所有企业名称(非重复,从 2000 年到 2019 年入选的企业共有 173 家)
names = new Set(data.map(d => d.name))
Insert cell
// 基于日期(年份)对数据进行转换,并将数据基于日期进行升序排序(从 2000 年到 2009 年)
// d3.rollup() 方法的作用解析参考 👇 下一个 📝 cell
// 通过 d3.rollup() 得到的是一个 InternMap 对象
// 再使用 Array.from() 方法将其转换回数组(转换后的结果可以查看 👇👇 下下一个 📝 cell)
datevalues = Array.from(d3.rollup(data, ([d]) => d.value, d => +d.date, d => d.name))
// 因为在上一步转换时,+d.date 将日期转换为毫秒数
// 所以调用 JavaScript 数组的原生方法 array.map() 对每一个分组(一个二维数组)进行遍历
// 并将其中的用毫秒表示的日期,new Date(date) 变回用日期对象表示
.map(([date, data]) => [new Date(date), data])
// 最后对各分组基于日期进行升序排序
// 其中 ([a], [b]) => d3.ascending(a, b) 接收两个需要对比的分组
// 然后分别对它们(二维数组)进行解构 [a] 和 [b],获取它们的日期
.sort(([a], [b]) => d3.ascending(a, b))
Insert cell
// 📝 该 cell 只是用于演示效果
// d3.rollup(iterable, reduce, ...keys) 基于指定的属性进行分组,并对各分组进行「压缩降维」,返回一个 InternMap 对象
// 第一个参数是需要进行转换的可迭代对象
// 第二个参数是对分组进行压缩的函数,每个分组会依次调用该函数(入参就是包含各个分组元素的数组),返回值会作为 InternMap 对象中(各分组的)键值对中的值
// 余下的参数 ...keys 是一系列的分组依据(依次基于这些函数的返回值对数据进行「嵌套式」的分组)
// 更多关于 d3.rollup() 方法的信息可以参考官方文档 https://github.com/d3/d3-array/#rollup 或笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-process#转换 的相关部分
// 对于该示例,先基于日期(年份) d => +d.date 对原始数据 data 进行分组,这里将日期对象先转换为数值(毫秒数)作为各分组的键名(但是这一步不是必须的,因为 JavaScript 的 Map 数据类型中,允许任何数据类型的值作为键名)
// 然后再对各分组基于公司名称 d => d.name 进行分组
// 所以最终(嵌套的)分组里只有一个元素(因为每一年每一家公司都只有一个数据项)
// 在对最终的各分组进行压缩时,因为每个分组中只有一个元素,所以可以通过 [d] 数组解构获取其中的唯一元素,然后返回该公司的价值 d.value 来描述该分组
d3.rollup(data, ([d]) => d.value, d => +d.date, d => d.name)
Insert cell
// 📝 该 cell 只是用于演示效果
// 使用 Array.from() 方法将 Map 对象转换回数组
// 其中每个元素都是一个二维数组
// 因为将每个分组转换为数组里的一个元素时,要保留 Map 的键名和键值,所以用一个二维数组来表示一个分组
Array.from(d3.rollup(data, ([d]) => d.value, d => +d.date, d => d.name))
Insert cell
// 计算 names 集合中所列出的所有公司的价值,并添加上相应的排序信息
// 传入的 valueFunc 参数是一个函数,用于获取给定名称的公司的价值
function rank(valueFunc) {
// 构建一个数组 data,它的每一个元素都是一个对象,该对象的 name 属性是公司的名称,value 属性是该公司的价值
// Array.from(literator, mapFunc) 方法对给定的可迭代对象里的每一个元素都调用映射函数 mapFunc 进行转换处理
// 其中参数 names 是一个包含所有公司名称的集合 set
// valueFunc 是一个函数,它接收一个参数 name 即公司的名称,然后返回该公司的价值
const data = Array.from(names, name => ({name, value: valueFunc(name)}));
// 再基于各元素的值 value 进行降序排序
data.sort((a, b) => d3.descending(a.value, b.value));
// 为数组 data 的每一个元素(对象)添加一个 rank 属性,属性值是(该元素在数组中的)索引值 i 和 n 变量两者中的较小值
// 其中 n 是需要在 svg 中展示的条形柱子数量
for (let i = 0; i < data.length; ++i) data[i].rank = Math.min(n, i);
return data;
}
Insert cell
// 因为原始数据是以年份为时间单位,间隔太大
// k 是指在两个相应(年份)的真实数据点之间插值的数量,用以创建更流畅的动画
k = 10
Insert cell
// 计算更多时间点的数据(对应于过渡动画的「每一帧」)
// 例如当 k=10 时,即在两个(相邻年份)原始数据之间插入 9 个数据点
// 所以原来从 2000 至 2019 年原有 20 个数据点,在相邻的年份之间都插入 9 个数据点
// ✨ 最终返回的 keyframes 数组共有 20+19*9=191 个元素
// ✨ 而且每一个元素的形式都是 [date, data] 二元数组,第一个值 date 就是时间点;第二个值 data 就是一个包含公司价值的对象数组,具有 173 个元素(即每一个时间点的数据都包含所有的公司的价值数据)
keyframes = {
const keyframes = []; // 容器是每一帧(对应于某个日期的数据)
let ka, a, kb, b;
// d3.pairs(iterable[, reducer]) 方法将相邻元素两两配对,生成一个新的数组
// 转换后结果可以查看 👇 下一个 📝 cell
// 更多关于 d3.rollup() 方法的信息可以参考官方文档 https://github.com/d3/d3-array/#pairs 或笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-process#转换 的相关部分
// 然后遍历所得的数组(每一个元素都是一个二维数组)
// 并通过解构数组得到相应的数据
// ka 是年份(日期对象),a 是该年份(分组)所对应的数据
// kb 是紧接着 ka 下一年的年份(日期对象),b 就是该年份对应的数据
for ([[ka, a], [kb, b]] of d3.pairs(datevalues)) {
// 计算插值数据
// 其中 k 表示在两个(时间间隔跨度为年份)原始数据之间需要插值的数量
// k 的值越大,表示插值的数量越多,这样(各横向柱子的长度变化)过渡动画就显得越「流畅」
// 例如当 k=10 时,则插入 9 个数据
// ⚠️ 遍历时从 i=0 开始,到 i<k(所以只包含下限,不包含上限)
// 即 push 到 keygrames 数组的原始数据只有 [ka, a]
for (let i = 0; i < k; ++i) {
// 使用 linear interpolation 线性插值法,估算出给定日期 date 所对应的数据
// 关于线性插值法的公式具体解释可以参考 https://datavis-note.benbinbin.com/article/theory/algorithm/linear-interpolation
// t 是当前创建的插值所对应的归一化距离,其范围是 (0, 1)
// 那么两个原始数据 a 和 b 就分别位于 [0, 1] 的两个端点
const t = i / k;
// 计算插值,并 push 到 keyframes 数组中
keyframes.push([
// 该插值所对应的的日期(对象)
new Date(ka * (1 - t) + kb * t),
// ✨ 这里调用 rank() 函数来估算出在当前日期**所有公司**的价值
// 其中在方法 rank(valueFunc) 传入的参数 valueFunc 是一个函数,用于获取给定公司(名称)的价值
// 这个函数的核心就是用线性插值法 $y=y_{0}(1-t)+y_{1}t$ 基于该公司在两个(年份)时间点的已知价值,估算出(在这两个时间点之间)特定时间点(用归一化距离 t 表示)该公司的价值
// 但是看起来比价复杂,因为其中还包含一些条件判断逻辑
// 因为每一年的数据中,并非包含所有公司的价值,所以 a.get(name) 和 b.get(name) 从原数据中获取指定公司的价值时可能返回 undefined,此时就假设该公司在该年份的价值为 0,
rank(name => (a.get(name) || 0) * (1 - t) + (b.get(name) || 0) * t)
]);
}
}
// ⚠️ 因为前面遍历插值过程中,只包含下限,不包含上限
// 所以最后还需要将原始数据里的最后一个数据 [kb, b] 追加进去,即 2019 年的数据
keyframes.push([new Date(kb), rank(name => b.get(name) || 0)]);
return keyframes;
}
Insert cell
// 📝 该 cell 只是用于演示效果
d3.pairs(datevalues)
Insert cell
/**
* 先使用 JS 数组的原生方法 arr.flatMap(mapFunc) 将嵌套数组 keyframes 展平
*/
// keyframes 是一个具有多级嵌套的数组,其中每一个元素都是一个二维数组
// 二维数组的第一个值是日期,第二个值则是所有公司在该时间点的价值(而这个值也是一个数组)
// arr.flatMap(mapFunc) 方法会先对数组的每个元素进行映射,然后再展平(一级)这个嵌套数组
// mapFunc 映射函数 ([, data]) => data 的作用是将原来每个元素中的第二个值抽取出来,只保留公司的价值,而不需要日期信息(转换后得到的依然是一个嵌套数组,只不过嵌套层级相较于原数组 keyframes 少一层,转换结果可以查看 👇 下一个 📝 cell)
// 再将嵌套数组「拍平」一级,会得到一个对象数组
// 每一个元素都是一个对象,这些对象都具有 3 个属性 name 公司名,value 公司价值,rank 排名
// 其结果可以查看 👇👇 下下一个 📝 cell
// 然后使用 D3 的内置方法 d3.groups(iterable, ...keys) 对展平的数组进行分组转换
// 第一参数是需要分组的可迭代对象,即展平的数组
// 第二个参数 d => d.name 是分组依据,基于每个元素(对象)的 name 属性
/**
* 最后返回一个数组 nameframes,其中每一个元素就是一个分组
* 因为共有 173 家企业,所以共有 173 个元素/分组
*/
// 每一个元素都以一个二元数组来表示
// 在二元数组中,第一个元素就是该分组所属的 key 属性值,在该示例中就是公司的名称;第二元素则是一个数组,其中包含了属于该分组的数据集中的元素,在该示例中就是在 keyFrames 中属于该公司的那些数据,经过前面的插值操作后,每个公司最后就会具有 191 个数据,并按照日期从旧到新的顺序排列(从 2000 年到 2019 年)
nameframes = d3.groups(keyframes.flatMap(([, data]) => data), d => d.name)
Insert cell
// 📝 该 cell 只是用于演示效果
keyframes.map(([, data]) => data)
Insert cell
// 📝 该 cell 只是用于演示效果
keyframes.flatMap(([, data]) => data)
Insert cell
Insert cell
// 📝 该 cell 只是用于演示效果
nameframes.map(([, data]) => d3.pairs(data, (a, b) => [b, a]))
Insert cell
// 📝 该 cell 只是用于演示效果
nameframes.flatMap(([, data]) => d3.pairs(data, (a, b) => [b, a]))
Insert cell
Insert cell
// 创建/更新条形图的矩形柱子
// 接收一个参数 svg(一个包含 svg 元素的选择集),在其中绘制条形图
function bars(svg) {
let bar = svg.append("g") // 创建一个矩形柱子的容器 <g>
.attr("fill-opacity", 0.6) // 将其中的元素的填充透明度设置为 0.6
.selectAll("rect");

// 返回一个函数,接收两个参数
// 第一个参数 [date, data] 是由日期和数据构成的二元数组(keyframe 数组的元素)
// 第二个参数 transition 是过渡管理器
// 返回的函数的作用是基于上面传入的参数更新 bar
return ([date, data], transition) => bar = bar
// 绑定新数据
// 其中 data 是一个对象数组,就是该时间点 173 家公司的价值数据,该对象的结果样例是 {name: "Coca-Cola", value: 72537, rank: 0}
// 通过 data.slice(0, n) 复制其中前 n 个元素(从索引值为 0 到 n-1 的元素),用于绘制 n 个矩形柱子
// 并将公司名 d.name 作为 key(唯一标识符),以便更新页面时复用矩形柱子元素
.data(data.slice(0, n), d => d.name)
// 更新页面图像元素
.join(
// 设置 entering 选择集
enter => enter.append("rect")
// 使用 <rect> 元素来绘制柱子
// 通过设置矩形的左上角 (x, y) 及其 width 和 height 来确定其定位和形状
.attr("fill", color) // 设置柱子的颜色(根据公司所属的产业)
// 矩形的高度
// 即柱子的大小,通过纵轴的比例尺的方法 y.bandwidth() 获取 band 的宽度(不包含间隙 padding)
.attr("height", y.bandwidth())
// 因为绘制的是水平方向的条形图
// 所以每个柱子都是对齐到 y 轴的,即矩形的左上角横坐标值都是 x(0)
.attr("x", x(0))
// 左上角纵坐标值
// 其中 d 是当前遍历的矩形柱子所绑定的数据,一个对象,其中属性 rank 是该公司价值的排名
// 通过排名值来获取相应的矩形柱子的左上角纵坐标值
// ⚠️ 这里值得注意的是,默认采用的数据(对象)的时间点并不是「当前」的,而是邻近的上一个时间点 prev.get(d)(而当前时间点的数据 d 则作为回退的备选项)
// 这是想为新增/新插入的柱子元素设置动效,所以先通过上一个时间点的数据 prev.get(d) 来获取这些元素的定位,作为它们的**初始状态**(它们的排序 rank 必然是大于 n 的,即定位远超于页面视图)
// 然后再通过一个过渡管理器,以当前时间点的数据 d 来获取这些元素的定位(👇在该函数的最后部分)
// 这样 entering 选择集中新增的矩形元素就会有一个(从底部)缓进的动效
.attr("y", d => y((prev.get(d) || d).rank))
// 矩形的宽度
// 即水平柱子的长度,通过比例尺映射后,柱子的宽度是 x(d.value) - x(0)) 的差值
// ⚠️ 这里值得注意的是,默认采用的数据也是邻近的上一个时间点 prev.get(d) 也是为设置动效(长度变化)
.attr("width", d => x((prev.get(d) || d).value) - x(0)),
// 设置 updating 选择集
// 不进行处理,直接返回该选择集
update => update,
// 设置 exting 选择集
// 将该选择集中的元素移除,并使用过渡管理器 transition 的配置,为该过程该过程创建一个过渡
exit => exit.transition(transition).remove()
// 过渡最终状态矩形柱子左上角的纵坐标值
.attr("y", d => y((next.get(d) || d).rank))
// 过渡最终状态矩形柱子的宽度
.attr("width", d => x((next.get(d) || d).value) - x(0))
// 最终状态使用下一个时间点的数据 next.get(d) 来设置(而当前时间点的数据 d 则作为回退的备选项)
// 所以移除的矩形柱子会有一个(从底部)缓出,而且宽度同步变化的动效
)
// 最后用当前时间点的数据来更新矩形元素的定位和长度
// 并使用过渡管理器 transition 的配置,为该过程该过程创建一个过渡
.call(bar => bar.transition(transition)
// 前面没有根据新数据更新 updating 选择集的元素的定位和长度
// 而且前面将 entering 选择集的元素采用上一个时间点数据 prev.get(d) 进行定位和设置长度
.attr("y", d => y(d.rank)) // 更新选择集(合并了 updating 和 entering 选择集)元素的定位 👈
.attr("width", d => x(d.value) - x(0))); // 更新选择集元素的宽度
}
Insert cell
// 创建/更新每个柱子标注的信息
// 接收一个参数 svg(一个包含 svg 元素的选择集),在其中绘制柱子的标注信息
function labels(svg) {
let label = svg.append("g") // 创建一个标注信息的容器 <g>
// 以下 CSS 属性用于设置字体样式
.style("font", "bold 12px var(--sans-serif)")
.style("font-variant-numeric", "tabular-nums") // 使数字等宽,易于对齐
.attr("text-anchor", "end") // 对齐到尾部,所以文字向左侧延伸
.selectAll("text");

// 返回一个函数,接收两个参数
// 第一个参数 [date, data] 是由日期和数据构成的二元数组(keyframe 数组的元素)
// 第二个参数 transition 是过渡管理器
// 返回的函数的作用是基于上面传入的参数更新 label
return ([date, data], transition) => label = label
// 绑定新数据,其中 data 是一个对象数组,就是该时间点 173 家公司的价值数据,该对象的结果样例是 {name: "Coca-Cola", value: 72537, rank: 0}
.data(data.slice(0, n), d => d.name) // 绑定新数据,只取前 n 个元素,并将公司名 d.name 作为 key
.join(
// 设置 entering 选择集
enter => enter.append("text")
// 通过 CSS 的 transform 属性来设置各标注信息的定位
// 其中 d 是当前遍历的文字元素所绑定的数据,一个对象
// ⚠️ 这里值得注意的是,默认采用的数据是邻近的上一个时间点 prev.get(d)(而当前时间点的数据 d 则作为回退的备选项)
// 这是想为新增/新插入(柱子元素)的标注信息设置动效
// 所以先通过上一个时间点的数据 prev.get(d) 来计算出元素的定位,作为它的**初始状态**
// 然后再通过一个过渡管理器,以当前时间点的数据 d 来计算元素的定位(👇在该函数的最后部分)
// 这样 entering 选择集中新增(柱子元素)的标注信息就会有一个(从底部)缓进的动效
.attr("transform", d => `translate(${x((prev.get(d) || d).value)},${y((prev.get(d) || d).rank)})`)
// 对文字进行偏移微调
.attr("y", y.bandwidth() / 2)
.attr("x", -6)
.attr("dy", "-0.25em")
.text(d => d.name) // 文字内容,公司的名称
// 在 <text> 元素中添加一个 <tspan> 元素
// 相当于 SVG 的 <span> 元素,可以为其中的文字内容另外设置不同的样式
// 用于显示该公司的相应价值
.call(text => text.append("tspan")
.attr("fill-opacity", 0.7) // 设置透明度
.attr("font-weight", "normal") // 设置字重
// 对文字进行偏移微调
.attr("x", -6)
.attr("dy", "1.15em") // 设置纵向偏移,避免公司的价值(数字)与公司的名字重叠
),
// 设置 updating 选择集
// 不进行处理,直接返回该选择集
update => update,
// 设置 exting 选择集
// 将该选择集中的元素移除,并使用过渡管理器 transition 的配置,为该过程该过程创建一个过渡
exit => exit.transition(transition).remove()
// 过渡最终状态的文字定位通过 CSS 的 transform 属性来设置
// 最终状态使用下一个时间点的数据 next.get(d) 来设置(而当前时间点的数据 d 则作为回退的备选项)
// 所以移除(柱子元素)的标注信息会有一个(从底部)缓出
.attr("transform", d => `translate(${x((next.get(d) || d).value)},${y((next.get(d) || d).rank)})`)
// 在过渡的过程中,还同时为公司价值的数字设置 tween 动画(类似秒表计数的动效)
// 使用 transition.tween(name[, value]) 方法设置补间动画
// 第一个参数是需要设置的元素属性
// 第二个参数是一个返回插值器的函数(选择集的每个元素依次调用它,传入的参数 d 是元素所绑定的数据)
// 更详细的说明可以查看官方文档 https://github.com/d3/d3-transition/#transition_tween
// 其中 textTween() 返回一个插值器,可以基于当前时间点的公司价值 d.value 和下一个时间点的公司价值 next.get(d),在过渡期间计数出一系列的插值(如果下一个时间点的公司价值未定义,则以当前时间点的数据 d 则作为回退的备选项)
.call(g => g.select("tspan").tween("text", d => textTween(d.value, (next.get(d) || d).value)))
)
// 最后用当前时间点的数据来更新标注信息的定位
// 并使用过渡管理器 transition 的配置,为该过程该过程创建一个过渡
.call(bar => bar.transition(transition)
// 前面没有根据新数据更新 updating 选择集的元素的定位
// 而且前面将 entering 选择集的元素采用上一个时间点数据 prev.get(d) 进行定位
.attr("transform", d => `translate(${x(d.value)},${y(d.rank)})`) // 在这里更新选择集(合并了 updating 和 entering 选择集)元素的定位 👈
// 另外在过渡的过程中,也对 updating 和 entering 选择集中的元素中表示公司价值的数字设置 tween 动画
// 从上一个时间点的公司价值 prev.get(d) 变动到当前时间点的公司价值 d.value
.call(g => g.select("tspan").tween("text", d => textTween((prev.get(d) || d).value, d.value))));
}
Insert cell
// 该函数用于制作数字 tween 动画(类似秒表计数的动效)
function textTween(a, b) {
// 使用 d3.interpolateNumber(a, b) 创建一个插值范围为 [a, b] 的数值插值器
// 关于插值器的详细说明可以参考官方文档 https://github.com/d3/d3-interpolate 或笔记的相关部分 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-transition#插值器
const i = d3.interpolateNumber(a, b);
// 最后返回的插值器,可以在 a 和 b 之间进行插值
// 插值器是用于计算过渡值的,以便实现补间动画
// 在过渡期间调用插值器函数,它接受一个**标准时间** `t` 作为入参,然后结合起始值和结束值,返回该时间点的过渡值
return function(t) {
// 使用 formatNumber() 方法对插值结果 i(t) 进行数字格式的转换
// 因为在过渡期间,调用(传入时间 t)插值器的是选择集中的每一个元素,所以 this 就是当前所遍历 DOM 元素
// 所以可以通过 this.textContent 来设置当前所遍历的元素的内容
this.textContent = formatNumber(i(t));
};
}
Insert cell
// 创建数字格式器
// 每千位以逗号 , 进行分组
// 有效数字四舍五入保留到整数
formatNumber = d3.format(",d")
Insert cell
tickFormat = undefined // override as desired
Insert cell
// 绘制/更新横坐标轴
// 接收一个参数 svg(一个包含 svg 元素的选择集),在其中绘制横坐标轴
function axis(svg) {
const g = svg.append("g") // 创建一个横坐标轴容器 <g>
// 通过设置 CSS 的 transform 属性将横向坐标轴容器「移动」到顶部
.attr("transform", `translate(0,${margin.top})`);

// 横轴是一个刻度朝上的坐标轴
const axis = d3.axisTop(x)
// 以下设置坐标轴的刻度数量和样式
.ticks(width / 160, tickFormat)
.tickSizeOuter(0) // 将坐标轴的外侧刻度 tickSizeOuter 长度设置为 0(即取消坐标轴首尾两端的刻度)
// 将内侧刻度线的长度设置为与条形图的高度一样长 -barSize * (n + y.padding()) ,前面添加负号,表示刻度线的延伸方向是向下(因为 d3.axisTop() 创建的是一个刻度向上的坐标轴)
// 这样第一条刻度线可以作为 y 轴,而其他刻度线就可以作为纵向的参考线
.tickSizeInner(-barSize * (n + y.padding()));

// 返回一个函数,接收两个参数(虽然只是用到第二个参数,这是为了统一返回函数的形式❓)
// 第一个参数 [date, data] 是由日期和数据构成的二元数组(keyframe 数组的元素),但是在这里用不到,所以用下划线 _ 作为一个占位符
// 第二个参数 transition 是过渡管理器
// 返回的函数的作用是更新横坐标轴
return (_, transition) => {
// 调用坐标轴(对象)方法,在页面绘制/更新横向坐标轴
// 并使用过渡管理器 transition 的配置,为该过程该过程创建一个过渡
g.transition(transition).call(axis);
g.select(".tick:first-of-type text").remove(); // 移除了第一个刻度值(即横坐标轴的零点 0)
// 除了第一条内侧的刻度线(它作为 y 轴),其他内侧的刻度线都设置为白色
g.selectAll(".tick:not(:first-of-type) line").attr("stroke", "white");
g.select(".domain").remove(); // 删掉横坐标轴的轴线
};
}
Insert cell
// 创建/更新条形图右下角的年份标记
// 接收一个参数 svg(一个包含 svg 元素的选择集),在其中绘制年份标记
function ticker(svg) {
const now = svg.append("text")
// 设置文字样式
.style("font", `bold ${barSize}px var(--sans-serif)`)
.style("font-variant-numeric", "tabular-nums")
.attr("text-anchor", "end")
// 文字的定位,「移到」右下角
.attr("x", width - 6)
.attr("y", margin.top + barSize * (n - 0.45))
.attr("dy", "0.32em")
// 文字内容(当前时间点的年份)
// 从 keyframes 的第一个元素中获取初始日期
// 然后用 formatDate() 方法进行日期格式的转换,提取出年份
.text(formatDate(keyframes[0][0]));

// 返回一个函数,接收两个参数
// 第一个参数 [date] 其实传入的是 keyframe 数组的元素,这里解构出第一个元素,因为只需要用到日期
// 第二个参数 transition 是过渡管理器
// 返回的函数的作用是基于传入的参数更新 ticker
return ([date], transition) => {
// 当过渡结束(页面的元素更新完成)时,再更新年份标记
// 这里使用 transition.end() 方法
// 它返回一个 Promise,再通过链式调用 then() 就可以在过渡结束时做出响应,执行特定的操作
// 这涉及到 D3 过渡的生命周期,具体内容可以查看官方文档 https://github.com/d3/d3-transition#transition_end 或笔记的相关部分 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-transition#过渡的生命周期
transition.end().then(() => now.text(formatDate(date)));
};
}
Insert cell
// 时间格式器
formatDate = d3.utcFormat("%Y") // 提取年份(时间采用 UTC 世界时间标准)
Insert cell
// 分类比例尺
// 将离散的数据映射为不同的颜色
// 在该示例中为不同的产业类别映射为不同的颜色
// d3.schemeTableau10 是一个 Color Scheme
// 它是一个分类型 categorical 配色方案,由 Tableau 预选了 10 中色彩用于标识不同的类别
// 它是一个包含 12 个元素的数组,每一个元素是一个色值字符串,具体的颜色可以看 👇 下一个 📝 cell
color = {
const scale = d3.scaleOrdinal(d3.schemeTableau10);
// 当原始数据集 data 的数据点存在 category 属性时,才进一步设置该比例尺(定义域)
if (data.some(d => d.category !== undefined)) {
// 创建一个映射 map,它包含了所有公司名称 d.name 与其所属产业 d.category 的映射
// 具体结果可以看 👇👇 下下一个 📝 cell
const categoryByName = new Map(data.map(d => [d.name, d.category]))
// categoryByName.values() 返回一个可迭代对象,包含所有的产业(共 22 个),作为比例尺的定义域
scale.domain(categoryByName.values());
// 返回一个函数,可以基于数据点的 d.name 公司名,通过比例尺的映射得到其所属产业对应的颜色
return d => scale(categoryByName.get(d.name));
}
// 如果当原始数据集 data 的数据点没有 category 属性时,则先不设置定义域
// 也是返回一个函数,基于数据点的 d.name 公司名,通过比例尺的映射得到其所对应的颜色(同时将该公司记录到比例尺的定义域中)
// 在不断地调用比例尺时,才不断记录和创建定义域,这样在下一次调用比例尺时,如果传入相同的公司名,就会映射得到相同的颜色
return d => scale(d.name);
}
// 💡 如果定义域数量多于值域,就进行「循环」映射
// 例如在该示例中,d3.schemeTableau10 配色方案只有 10 种不同的颜色,而不同的产业有 22 个,公司名称有 173 个,所以可能会出现「撞色」,即重复使用色值的情况
Insert cell
// 📝 该 cell 只是用于演示效果
d3.schemeTableau10
Insert cell
// 📝 该 cell 只是用于演示效果
categoryByName = new Map(data.map(d => [d.name, d.category]))
Insert cell
// 📝 该 cell 只是用于演示效果
new Set(categoryByName.values())
Insert cell
// 横轴比例尺
// 对于数值型数据,默认采用线性比例尺
// 这里先将定义域设置为 [0, 1],作为一个占位符❓之后再用真实数据来进行更新
// 值域就是视觉图形元素的展示范围,即 svg 的宽度,还考虑了 svg 的留白区域 margin 的影响
x = d3.scaleLinear([0, 1], [margin.left, width - margin.right])
Insert cell
// 纵轴比例尺
// 纵坐标轴的数据是条形图的各种分类,使用 d3.scaleBand 构建一个带状比例尺
y = d3.scaleBand()
// 设置定义域
// 先通过 d3.range(n + 1) 创建一个等差数列,从 0 到 n,共有 n 个元素
// 这个从 0 开始的等差数列正好就对应于公司价值的排名高低(也是从 0 开始)
// 所以使用该比例尺时,可以通过公司的排名 rank 映射得到相应矩形柱子的
.domain(d3.range(n + 1))
// 设置值域
// 如果纵坐标是映射定量数值时,应该特别留意 svg 的坐标体系的正方向(向右,向下)
// 但是因为当前绘制的是横向条形图,纵轴映射的是分类数据
// 💡 所以这里的值域**不一定需要**采用从下往上与定义域进行映射 [bottom, top]
// 这里默认就采用 [top, bottom]
// 值域就是视觉图形元素的展示范围,即 svg 所需的高度,主要是由矩形柱子数量所 n 决定的(其中 barSize 是带宽,在该示例还考虑了邻近柱子之间的间隔大小 0.1),还考虑了 svg 的留白区域 margin 的影响
// 一般使用 scale.range() 方法来设置值域,但这里采用的是 scale.rangeRound() 方法
// 相对而言,后一个方法会对传入的范围的**两端进行四舍五入的修约**,让两端成为整数,更适合可视化
.rangeRound([margin.top, margin.top + barSize * (n + 1 + 0.1)])
.padding(0.1) // 设置间隔占据(柱子)区间的比例
Insert cell
// svg 的高度
height = margin.top + barSize * n + margin.bottom
Insert cell
// 条形图中矩形柱子的带宽
barSize = 48
Insert cell
// 在 svg 外四边留白,构建一个显示的安全区,以便在四周显示坐标轴
margin = ({top: 16, right: 6, bottom: 6, left: 0})
Insert cell
d3 = require("d3@6")
Insert cell

One platform to build and deploy the best data apps

Experiment and prototype by building visualizations in live JavaScript notebooks. Collaborate with your team and decide which concepts to build out.
Use Observable Framework to build data apps locally. Use data loaders to build in any language or library, including Python, SQL, and R.
Seamlessly deploy to Observable. Test before you ship, use automatic deploy-on-commit, and ensure your projects are always up-to-date.
Learn more