Public
Edited
Aug 19, 2023
Insert cell
Insert cell
Insert cell
chart = {
/**
*
* 创建 svg 容器
*
*/
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height]);
/**
*
* 绘制条形图内的柱子
*
*/
// 绘制的步骤与一般的条形图会有所不同
// 因为普通的条形图每一个条带都只是有一个矩形构成
// 而堆叠条形图的每一个条带是由多个小的矩形依次堆叠而成的
// 相应地,它们所绑定/对应的数据结构也不同
// 普通的条形图所绑定的数据是一个数组,页面上每一个条带对应数组中的一个元素
// 而堆叠条形图所绑定的数据是一个嵌套数组,页面上每一个堆叠层分别对应于数组的一个元素(一个系列数据,它也是一个数组),而同一堆叠层的不同小矩形则分别对应于嵌套数组中的一个元素
// 所以需要在绘制堆叠条形图时需要进行数据「二次绑定」
svg.append("g")
.selectAll("g") // 返回一个选择集,其中虚拟/占位元素是 <g> 它们作为各系列的容器
.data(series) // 绑定数据,每个容器 <g> 对应一个系列数据
.join("g")
// 设置颜色,不同系列/堆叠层对应不同的颜色
// 通过对每一个系列(数组具有属性 key)对象解构得到系列的名称 key,再通过颜色比例尺映射 color(key) 得到对应的颜色
.attr("fill", ({key}) => color(key))
// 基于原选择集进行「次级选择」,选择集会发生改变
// 详细介绍可以查看这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-binding#次级选择
.call(g => g.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 的一个嵌套数组),这里并没有进行额外的处理直接返回,让该系列里的每个数据点都与一个 <rect> 元素绑定
.data(d => d)
.join("rect") // 将元素绘制到页面上
// 为每个小矩形分别设置左上角 (x, y) 及其 width 和 height 来确定其定位和形状
// 每个矩形的左上角横轴定位 x 由它所属的年份的名称决定
// 可以通过所绑定数据的属性 d.data.year 来获取
// 使用横坐标轴的比例尺(带状比例尺)进行映射,求出具体的横轴坐标值
.attr("x", d => x(d.data.year))
// 每个矩形的左上角纵轴定位 y 由它的堆叠上边界决定
// 可以通过它所绑定的数据(一个数组)的第二个元素 d[1] 来获取
// 使用纵坐标轴的比例尺(线性比例尺)进行映射,求出具体的纵轴坐标值
.attr("y", d => y(d[1]))
// 每个矩形的宽度
// 通过横轴的比例尺的方法 x.bandwidth() 获取 band 的宽度,并减去 1px 作为条带之间的间隔
.attr("width", x.bandwidth() - 1)
// 每个矩形的高度
// 由所绑定的数据(一个数组)的两个元素(上边界和下边界)之间的差值所决定
// ⚠️ 注意这里的差值是 y(d[0]) - y(d[1]) 因为 svg 的坐标体系中向下是正方向
// 所以下边界 d[0] 所对应的纵坐标值 y(d[0]) 会更大,减去 y(d[1]) 的值求出的差值才是高度
.attr("height", d => y(d[0]) - y(d[1]))
// 最后为每个矩形 <rect> 元素之内添加 <title> 元素
// 以便鼠标 hover 在相应的小矩形之上时,可以显示 tooltip 提示信息
.append("title")
// 设置 tooltip 的文本内容
// 其中 d.data.name 是媒介格式名称,d.data.year 是所属的年份
// 而 d.data.value 是具体的营收值
.text(d => `${d.data.name}, ${d.data.year}
${formatRevenue(d.data.value)}`));

// 绘制横坐标轴
svg.append("g")
.call(xAxis);
// 绘制纵坐标轴
svg.append("g")
.call(yAxis);

return svg.node();
}
Insert cell
// 读取 csv 文件
// 参考 d3-dsv 模块 https://github.com/d3/d3-dsv
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-fetch-and-parse-data
// 这里使用 d3.csvParse(string, row) 来解析数据
// 其中第一个参数 string 是 csv 字符串
// 第二个参数 row 是每一行数据的 accessor 读取函数
// 这里的 accessor 读取函数的作用是先通过对象解构的方式,只读取每行数据的其中三个属性 Format、Year、"Revenue (Inflation Adjusted)"(采用经过通胀校正的数据),然后对这些属性进行重命名以及属性值进行格式转换(字符串变成数值)
// 返回一个对象数组,每一个对象对应于 csv 表格的一行数据
// D3 还会为数组添加一个 columns 属性,其属性值是一个数组,元素分别是原数据的列属性(即原来的二维数据表的表头信息)
// 最后还通过 Object.assign() 为该数组添加一个对象 {y: "Revenue (billions, adj.)"} 作为纵坐标轴的标注信息
data = Object.assign(d3.csvParse(await FileAttachment("music.csv").text(), ({Format, Year, ["Revenue (Inflation Adjusted)"]: Revenue}) => ({name: Format, year: +Year, value: +Revenue})), {y: "Revenue (billions, adj.)"})
Insert cell
// 设置图例
// 将不同的音乐媒介格式映射为不同的颜色
// 共 23 种不同的音乐媒介格式,对应于堆叠条形图中 23 个系列
// 由于不同的音乐媒介存续的时期不同,所以并不是每一个条带都包含完整的 23 个堆叠层
colors = new Map([
// 黒胶/磁带
["LP/EP", "#2A5784"],
["Vinyl Single", "#43719F"],
["8 - Track", "#5B8DB8"],
["Cassette", "#7AAAD0"],
["Cassette Single", "#9BC7E4"],
["Other Tapes", "#BADDF1"],
// CD/DVD
["Kiosk", "#E1575A"],
["CD", "#EE7423"],
["CD Single", "#F59D3D"],
["SACD", "#FFC686"],
["DVD Audio", "#9D7760"],
["Music Video (Physical)", "#F1CF63"],
// 离线下载
["Download Album", "#7C4D79"],
["Download Single", "#9B6A97"],
["Ringtones & Ringbacks", "#BE89AC"],
["Download Music Video", "#D5A5C4"],
["Other Digital", "#EFC9E6"],
["Synchronization", "#BBB1AC"],
// 在线播放
["Paid Subscription", "#24693D"],
["On-Demand Streaming (Ad-Supported)", "#398949"],
["Other Ad-Supported Streaming", "#61AA57"],
["SoundExchange Distributions", "#7DC470"],
["Limited Tier Paid Subscription", "#B4E0A7"]
])
Insert cell
/**
*
* 对数据进行转换
*
*/
// 通过堆叠生成器对数据进行转换,便于后续绘制堆叠图
// 返回一个数组,每一个元素都是一个系列(条形图中每个条带就是由多个系列堆叠而成的)
// 具体可以参考官方文档 https://d3js.org/d3-shape/stack 或 https://github.com/d3/d3-shape/blob/main/README.md#stacks
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-shape#堆叠生成器-stacks
series = d3.stack()
// 设置系列的名称(数组)
.keys(colors.keys()) // 采用映射 colors 中的键名称,D3 为每一个系列都设置了一个属性 key,其值是系列名称
// 设置各系列的数据读取函数
// 在调用堆叠生成器对原始数据进行转换过程中,每一个原始数据 d 和系列名称 key(就是在 stack.keys([keys]) 设定的数组中的元素)会作为入参,分别调用该函数,以从原始数据中获取相应系列的数据
// 数据读取函数的逻辑要如何写,和后面 👇👇 调用堆叠生成器时,所传入的数据格式紧密相关
// 因为传入的数据是 map.values() 一个映射的值迭代器(共有 46 个年份的数据)
// 遍历数据点时 group 就是依次从迭代器中获取到的一个数据点(一个年份的数据),该数据也是一个映射(包含 23 个格式名称)
// 要从中获取相应系列 key 的数据,通过 group.get(key) 获取相应系列(音乐格式名称)的数据(一个对象)
.value((group, key) => group.get(key).value)
// 设置系列堆叠排序函数,即对应于堆叠条形图中该系列叠放的次序
// 采用 D3 的一种内置的排序函数 d3.stackOrderReverse 堆叠次序和系列名称数组 keys 次序相反
.order(d3.stackOrderReverse)
// 调用堆叠生成器,传入数据(并不是直接传入 data 数据集,进行了一些转换处理)
// 使用方法 d3.rollup() 对数据集 data 进行分组,并对各分组进行「压缩降维」,返回一个 InternMap 对象
// 具体参考官方文档 d3-array 模块 https://github.com/d3/d3-array#rollup
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-process
// 对原始数据 data 中 1058 个元素进行分组,先基于元素的 year 年份进行分组,然后**对各分组再基于 name 格式名称**进行分组
// 最后得到一个嵌套的 InternMap 对象,共有 46 个映射(外层,基于年份)
// 而每个映射的值也是一个 InternMap 对象,都有 23 个映射(内层,基于格式名称)
// 其实这里使用 d3.rollup() 方法并没有对数据集进行「压缩降维」,只是将数组的扁平化数据结构,变成映射的层级数据结构
// 最后通过 map.values() 返回一个映射的值迭代器,通过循环结构可以遍历所有值(46 个年份的数据)
(d3.rollup(
// 第一个参数是需要分组的数据 data
data,
// 第二个参数是对分组进行压缩的函数,每个分组会依次调用该函数(入参就是包含各个分组元素的数组)
// 如果存在嵌套分组,则该函数只会被「叶子」分组(嵌套得最深的各个分组)调用,因为这些分组才直接包含原数据集中的元素
// 返回值会作为 InternMap 对象中(各分组的)键值对中的值
// 由于按照年份和格式名称进行分组后,每个嵌套分组中只会有 1 个元素
// 所以这里通过数组解构,读取里面唯一的元素(原始数据集中的元素)并没有进行变换直接返回
([d]) => d,
// 余下的参数是一系列返回**分组依据**的函数
d => d.year,
d => d.name).values())
// 最后对于堆叠生成器所返回的数组(嵌套数组)进行遍历,对具体的数据点进行处理,并返回遍历的数据 s
// 其中 s 是当前所遍历的元素(共有 46 个元素),它是一个系列的数据(也是一个数组)
// 然后在回调函数中对 s(嵌套数组)进行遍历,这些元素是该系列的数据点,每个元素分别对应于不同条带的该堆叠层的值,所以有 23 个元素
// 每一个数据点 d 都是一个二元数组(里面的元素依次对应于堆叠的下边界和上边界),并且具有属性 data(它是一个映射,映射值就是对应的原始数据集的数据点,即 csv 表格中的一行数据)
// 这里使用 d.data.get(s.key) 从原始数据点中提取出该系列的数据(s.key 为当前所遍历的系列名称)
// 然后通过赋值覆盖原始值 d.data = d.data.get(s.key)
// 相当于简化了 d.data 的数据
.map(s => (s.forEach(d => d.data = d.data.get(s.key)), s))
Insert cell
// 📝 该 cell 只是用于演示效果
d3.rollup(data, ([d]) => d, d => d.year, d => d.name)
Insert cell
// 设置横坐标轴的比例尺
// 横坐标轴的数据是条形图的各种分类(不同年份),使用 d3.scaleBand 构建一个带状比例尺
// 使用 d3-scale 模块
// 具体参考官方文档 https://d3js.org/d3-scale/band 或 https://github.com/d3/d3-scale#scaleBand
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-scale#带状比例尺-band-scales
x = d3.scaleBand()
// 设置定义域范围
// 直接将 data 数据集中所提取到的年份作为定义域的数组,虽然存在重复值,但是并不影响映射 ❓
// 由于 D3 会在内部使用 InternMap 对数据进行去重后再映射
.domain(data.map(d => d.year))
// 设置值域范围
// svg 元素的宽度(减去留白区域)
// 使用 scale.rangeRound() 方法,可以进行修约,以便实现整数(46 个年份)映射到整数(像素)
.rangeRound([margin.left, width - margin.right])
Insert cell
// 设置纵坐标轴的比例尺
// 纵坐标轴的数据是连续型的数值(营收值),使用 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
y = d3.scaleLinear()
// 设置定义域范围
// [0, ymax] 其中 ymax 是各年份的音乐在不同(媒介)格式营收的累计值中的最大值
// 通过 d3.max() 从数据 series 中获取各年份的音乐营收值的最大值
// 但是系列数据 series 是一个嵌套的数组,所以需要比较复杂的访问函数
// 第一层 d.max() 的数据访问函数,其入参是当前所遍历的系列数据
// 第二层的 d.max() 的数据访问函数,其入参是该系列当前所遍历的数据点(某个年份在该媒介格式的营收值)
// 但是这是计算好的堆叠数据,所以数据点是一个二维数组 [stackBottom, stackTop],表示某个年份在该媒介格式的营收值在堆叠图中的堆叠位置,因为要获取最大值,所以返回当前系列的堆叠顶部值 stackTop 即可,即二维数组的第二个元素 d[1]
// 并通过 continuous.nice() 编辑定义域的范围,通过四舍五入使其两端的值更「整齐」nice,便于进行映射
.domain([0, d3.max(series, d => d3.max(d, d => d[1]))]).nice()
// 设置值域范围
// svg 元素的高度(减去留白区域)
.range([height - margin.bottom, margin.top])
Insert cell
// 设置颜色比例尺
// 为不同系列设置不同的配色
// 使用 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
color = d3.scaleOrdinal()
// 设置定义域范围
// 各系列的名称,即 23 个媒介格式
.domain(colors.keys())
// 设置值域范围
// (使用自定义的配色方案)从前面创建的映射 colors 中提取颜色值
// 通过 colors.values() 返回一个映射的值迭代器,可以在循环结构中遍历获取颜色值
.range(colors.values())
Insert cell
// 绘制横坐标轴
xAxis = g => g
// 通过设置 CSS 的 transform 属性将横坐标轴容器「移动」到底部
.attr("transform", `translate(0,${height - margin.bottom})`)
// 横轴是一个刻度值朝下的坐标轴
// 💡 注意这里使用的是方法 selection.call(axis) 的方式来调用坐标轴对象(方法)
// 会将选择集中的元素 <g> 传递给坐标轴对象的方法,作为第一个参数
// 以便将坐标轴在相应容器内部渲染出来
// 具体参考官方文档 https://d3js.org/d3-selection/control-flow#selection_call 或 https://github.com/d3/d3-selection#selection_call
// 或这一篇文档 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-binding#其他方法
.call(d3.axisBottom(x)
// 自定义坐标轴的刻度值
// 通过 axis.tickValues([values]) 传递一个数组,用其中的元素覆盖比例尺自动生成的刻度值
// 方法 d3.ticks(start, stop, count) 根据 count 数量对特定范围(由 start 和 stop 指定)进行均分
// 返回一个包含一系列分隔值的数组,用作刻度值
// 第一、二个参数 start 和 stop 分别指定范围的起始和结束值
// 这里先通过 d3.extent(x.domain()) 获取横坐标轴比例尺的定义域范围,在通过解构来获取 start 和 stop
// 第三个参数 count 作为分割数量的参考值,避免过多的刻度值出现,相互重叠影响阅读
// 具体参考官方文档 https://d3js.org/d3-array/ticks#ticks 或 https://github.com/d3/d3-array/tree/main#ticks
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-process#刻度生成
.tickValues(d3.ticks(...d3.extent(x.domain()), width / 80))
// 将坐标轴的外侧刻度 tickSizeOuter 长度设置为 0(即取消坐标轴首尾两端的刻度)
.tickSizeOuter(0))
Insert cell
// 绘制纵坐标轴
yAxis = g => g
// 通过设置 CSS 的 transform 属性将纵向坐标轴容器「移动」到左侧
.attr("transform", `translate(${margin.left},0)`)
// 纵轴是一个刻度值朝左的坐标轴
.call(d3.axisLeft(y)
// 设定刻度值的格式
// 通过自定义函数来设置刻度值,比例尺默认生成的每个刻度都会调用该函数
// 入参 x 是当前所遍历的刻度值,这里将原数值除以 10 亿,并将结果保留到个位数
// 即纵坐标轴刻度值的单位是 billion
// JavaScript 中支持数字的缩写,通过在数字后面附加字母 "e" 并指定零的个数来缩短数字,1 billion = 1e9
.tickFormat(x => (x / 1e9).toFixed(0)))
// 删掉上一步所生成的坐标轴的轴线(它含有 domain 类名)
.call(g => g.select(".domain").remove())
// 为纵坐标轴添加标注信息
// 并选中最后一个刻度值,即 <text> 元素,进行复制
.call(g => g.select(".tick:last-of-type text").clone()
.attr("x", 3) // 设置元素的偏移量
.attr("text-anchor", "start") // 设置文字的对齐方式
.attr("font-weight", "bold") // 设置字体的样式
.text(data.y)) // 设置文本内容
Insert cell
// 用于格式化 tooltip 文本内容
formatRevenue = x => (+(x / 1e9).toFixed(2) >= 1)
? `${(x / 1e9).toFixed(2)}B` // 如果数值大于 10 亿,则以 10 亿作为基数,修约到小数点后 2 位,并添加 B 后缀
: `${(x / 1e6).toFixed(0)}M` // 如果数值小于 10 亿,则以 100 万作为基数,修约到个位,并添加 M 后缀
Insert cell
height = 500 // svg 元素的高
Insert cell
// margin 的作用是在 svg 的外周留白,构建一个显示的安全区,以便在四周显示坐标轴
margin = ({top: 20, right: 30, bottom: 30, left: 30})
Insert cell
d3 = require("d3@6")
Insert cell
import {swatches} from "@d3/color-legend"
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