Public
Edited
Jul 31, 2023
Insert cell
Insert cell
Insert cell
chart = {
// 设置一些关于尺寸的参数
const width = 928; // svg 元素的宽
const height = 600; // svg 元素的高
// margin 为前缀的参数
// 其作用是在 svg 的外周留白,构建一个显示的安全区(以便在四周显示坐标轴)
const marginTop = 10;
const marginRight = 10;
const marginBottom = 20;
const marginLeft = 40;

/**
*
* 构建比例尺
*
*/
// 设置横坐标轴的比例尺,它由「宏观」和「微观」两个比例尺构成
// 「宏观」比例尺用于将各组(整体)映射/定位到横坐标轴上
// 「微观」比例尺用于安排各组内的条带映射/定位到组内区间上
// fx 比例尺用于将 6 个州映射到横坐标轴上
// 由于数据是不同的州(不同的分类),使用 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
const fx = d3.scaleBand()
// 设置定义域范围(6 个州,从 data 数据点的属性 d.state 所构成的集合种提取出所有的州)
.domain(new Set(data.map(d => d.state)))
// 设置值域范围
// svg 元素的宽度(减去留白区域)
// 使用 scale.rangeRound() 方法,可以进行修约,以便实现整数(6 个州)映射到整数(像素)
.rangeRound([marginLeft, width - marginRight])
.paddingInner(0.1); // 并设置间隔占据(每个州)区间的比例

// 从 data 数据点的属性 d.age 所构成的集合种提取出所有的年龄类别
const ages = new Set(data.map(d => d.age));

// x 比例尺用于将条带映射到组内区间上
// 由于数据是的不同的年龄段(不同的分类),所以同样使用 d3.scaleBand 构建一个带状比例尺
const x = d3.scaleBand()
// 设置定义域范围(不同的年龄段的名称)
.domain(ages)
// 设置值域范围
// 就是每个州的区间宽度,所以值域的上边界是前面带状比例尺 fx 的带宽
.rangeRound([0, fx.bandwidth()])
.padding(0.05); // 并设置间隔占据(每个条带)的比例

// 设置颜色比例尺
// 为不同年龄段设置不同的配色
// 使用 d3.scaleOrdinal() 排序比例尺 Ordinal Scales 将离散型的定义域映射到离散型值域
const color = d3.scaleOrdinal()
// 设置定义域范围(不同的年龄段的名称)
.domain(ages)
// 设置值域范围
// 使用 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[ages.size])
// 设置默认颜色
// 当使用颜色比例尺时 color(value) 传入的参数定义域范围中,默认返回的颜色值
.unknown("#ccc");

// 设置纵坐标轴的比例尺
// 纵坐标轴的数据是连续型的数值(6 个州的不同年龄段的人口数量),使用 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 y = d3.scaleLinear()
// 设置定义域范围
// [0, ymax] 其中 ymax 是 6 个州不同年龄段的人口数量中的最大值
// 另外还使用 continuous.nice() 方法编辑定义域的范围,通过四舍五入使其两端的值更「整齐」nice
// 具体参考官方文档 https://github.com/d3/d3-scale#continuous_nice
.domain([0, d3.max(data, d => d.population)]).nice()
// 设置值域范围
// svg 元素的高度(减去留白区域)
// 使用 continue.rangeRound() 方法,可以进行修约,以便实现整数(人口)映射到整数(像素)
.rangeRound([height - marginBottom, marginTop]);

// 用于格式化 tooltip 文本内容
// 采用 en 格式来显示数值(即使用千位逗号)
// 如果所对应的数据不是数值时,则显式为 N/A
// 参考 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Number/toLocaleString
const formatValue = x => isNaN(x) ? "N/A" : x.toLocaleString("en")

/**
*
* 创建 svg 容器
*
*/
// 返回的是一个包含 svg 元素的选择集
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto;");

/**
*
* 绘制条形图内的柱子
*
*/
// 绘制的步骤与一般的条形图会有所不同
// 首先绘制每个组的容器 <g>,然后分别在每个容器中绘制条带 <rect>
// 每个容器需要根据所属的宏观分组在横坐标上定位,而每个条带需要根据所属的细分组别在宏观分组区间中定位
// 所以需要在绘制分组条形图时需要进行数据「二次绑定」
svg.append("g")
.selectAll() // 返回一个选择集,其中虚拟/占位元素是 <g> 它们作为宏观分组的容器
// 绑定数据,每个容器 <g> 对应一个系列数据
// 这里并不是直接绑定 data 数据
// 而是先使用 d3.group(iterable, ...keys) 基于指定的属性(键),将可迭代对象的元素进行分组
// 具体参考官方文档 https://github.com/d3/d3-array#group
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-process
// 这里是依据每个数据点的属性 d.state 即州的名称进行分组
// d3.group() 返回一个 InterMap 对象,当元素绑定数据时会将每个映射关系转换为一个二元数组
// 第一个元素是映射名称(在该示例中就是州的名称 state)
// 第二个元素是映射所对应的值(在该示例中就是由 data 中属于同一个州的所有数据点所构成的数组)
// 具体的返回值可以参考 👇 下一个 📝 cell
.data(d3.group(data, d => d.state))
.join("g")
// 通过设置 CSS 的 transform 属性将宏观分组的容器「移动」到横坐标的相应位置
// 根据容器所绑定的数据(二元数组)的第一个元素 state 州的名称,使用比例尺 fx(state) 得到容器在横坐标的定位
.attr("transform", ([state]) => `translate(${fx(state)},0)`)
// 基于原有的选择集进行「次级选择」,选择集会发生改变
// 详细介绍可以查看这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-binding#次级选择
.selectAll()
// 返回的选择集是由多个分组(各个 <g> 容器中)的虚拟/占位 <rect> 元素构成的
// ⚠️ 使用 select.selectAll() 所创建的新选择集会有多个分组
// 由于新的选择集会创建多个分组,那么原来所绑定数据与(选择集中的)元素的对照关系会发生改变
// 从原来的一对一关系,变成了一对多关系,所以新的选择集中的元素**不会**自动「传递/继承」父节点所绑定的数据
// 所以如果要将原来选择集中所绑定的数据继续「传递」下去,就需要手动调用 selection.data() 方法,以显式声明要继续传递数据
// 在这种场景下,该方法的入参应该是一个返回数组的**函数**
// 每一个分组都会调用该方法,并依次传入三个参数:
// * 当前所遍历的分组的父节点所绑定的数据 datum
// * 当前所遍历的分组的索引 index
// * 选择集的所有父节点 parent nodes
// 详细介绍可以查看这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-binding#绑定数据
// 所以入参是每个宏观分组原本所绑定的数据,即一个二元数组
// 二元数组的第一个元素是映射名称(在该示例中就是州的名称 state),第二个元素是映射所对应的值(在该示例中就是由 data 中属于同一个州的所有数据点所构成的数组)
// 这个函数的作用就是提取并返回第二个元素,即 data 数据中属于当前宏观分组(该州 state)的数据点
// 在该宏观分组区间中,每个条带 <rect> 元素绑定一个数据点
.data(([, d]) => d)
.join("rect") // 将元素绘制到页面上,使用 <rect> 元素绘制条带
// 为每个小矩形分别设置左上角 (x, y) 及其 width 和 height 来确定其定位和形状
// 每个矩形的左上角(相对于所属的宏观分组所在的区间)横轴定位 x 由它所属的年龄段决定
// 可以通过比例尺 x(d.age) 计算得到
.attr("x", d => x(d.age))
// 每个矩形的左上角纵轴定位 y 由该人口数量决定
// 可以通过比例尺 y(d.population) 计算得到
.attr("y", d => y(d.population))
// 条带的宽度
// 通过比例尺的方法 x.bandwidth() 获取(不包含间隙 padding)
.attr("width", x.bandwidth())
// 条带的高度
// ⚠️ 应该特别留意因为在 svg 的坐标体系中向下和向右是正方向
// 所以通过比例尺映射后,在 svg 坐标体系里,柱子底部的 y 值 y(0) 是大于柱子顶部的 y 值 y(d.population)
// 所以条带的高度是 y(0) - y(d.population) 的差值
.attr("height", d => y(0) - y(d.population))
.attr("fill", d => color(d.age)) // 设置颜色,不同年龄段对应不同的颜色
// 最后为每个矩形 <rect> 元素之内添加 <title> 元素
// 以便鼠标 hover 在相应的小矩形之上时,可以显示 tooltip 提示信息
.append("title")
// 设置 tooltip 的文本内容
// 其中 d.state 是所属的州,d.age 是所属的年龄段,d.population 是具体的人口数量
.text(d => `${d.state} ${d.age}\n${formatValue(d.population)}`);

/**
*
* 绘制坐标轴
*
*/
// 绘制横坐标轴
svg.append("g")
// 通过设置 CSS 的 transform 属性将横坐标轴容器「移动」到底部
.attr("transform", `translate(0,${height - marginBottom})`)
// 横轴是一个刻度值朝下的坐标轴
// ⚠️ 注意所使用的比例尺是「宏观」比例尺 fx,因为它才是是负责横坐标轴整体的映射关系的
// 而且将坐标轴的外侧刻度 tickSizeOuter 长度设置为 0(即取消坐标轴首尾两端的刻度)
// 💡 注意这里使用的是方法 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(fx).tickSizeOuter(0))
// 删掉上一步所生成的坐标轴的轴线(它含有 domain 类名)
.call(g => g.selectAll(".domain").remove());

// 绘制纵坐标轴
svg.append("g")
// 通过设置 CSS 的 transform 属性将纵向坐标轴容器「移动」到左侧
.attr("transform", `translate(${marginLeft},0)`)
// 纵轴是一个刻度值朝左的坐标轴
// 并使用坐标轴对象的方法 axis.ticks() 设置坐标轴的刻度数量和刻度值格式
// 具体参考官方文档 https://d3js.org/d3-axis#axis_ticks 或 https://github.com/d3/d3-axis/blob/v3.0.0/README.md#axis_ticks
// 其中第一个参数用于设置刻度数量,这里设置为 `null` 表示采用默认的刻度生成器
// 而第二个参数用于设置刻度值格式,这里设置为 "s" 表示数值采用 SI-prefix 国际单位制词头,例如 k 表示千,M 表示百万
// 具体参考 https://en.wikipedia.org/wiki/Metric_prefix
// 关于 D3 所提供的数值格式具体参考官方文档 https://github.com/d3/d3-format
.call(d3.axisLeft(y).ticks(null, "s"))
// 删掉上一步所生成的坐标轴的轴线(它含有 domain 类名)
.call(g => g.selectAll(".domain").remove());

// Return the chart with the color scale as a property (for the legend).
return Object.assign(svg.node(), {scales: {color}});
}
Insert cell
// 📝 该 cell 只是用于演示效果
d3.group(data, d => d.state)
Insert cell
data = {
// 读取原始数据后得到一个数组
// 其中每一个元素都是一个对象,表示一个州的人口数据,共 52 个州
// 对象中均包含 10 个属性,对应于 csv 表格中的 10 列属性
// 具体可以参考 👇 下一个 📝 cell
let data = await FileAttachment("us-population-state-age.csv").csv({typed: true});
// 对数据进行一些处理,以便后续进行可视化
// 首先通过数组 data 的属性 columns 提取年龄段数组
// 属性 columns 是 D3 在解析 csv 表格时添加到数组上的,该属性值也是一个数组,包含了 csv 表格的列属性
// 具体可以参考 👇 下下一个 📝 cell
// ⚠️ 注意只从第二个元素开始提取 data.columns.slice(1) 因为 csv 的第一列是各州的名称 name ,并不是年龄段的分组
// 可以提取到 9 个年龄段
const ages = data.columns.slice(1);
// 先使用方法 d3.sort() 进行排序
// 第一个参数 data 是需要排序的可迭代对象(数组)
// 第二个参数是 accessor 访问器,入参 d 是当前所遍历的数据点(一个州的数据),该州的总人口数(各年龄段的人口总和)作为返回值
// 默认采用是升序排列,由于返回值前面添加负号,所以变成降序排列,即总人口数较多的州排名较前
// 最后仅截取排好序的数组的前 6 项进行可视化
data = d3.sort(data, d => -d3.sum(ages, age => d[age])).slice(0, 6);
// 然后通过 arr.flatMap(callback) 方法对这些年龄段 ages 进行遍历,依次执行 callback 回调函数
// 💡 arr.flatMap() 相当于 arr.map() 和 arr.flat() 相结合,对数组的每个元素执行一次回调函数,并将返回的数组进行(一级)展开
// 这里的回调函数是 data.map((d) => ({state: d.name, age, population: d[age]}))
// 其作用就是从原始数据(6 个州)中提取出各州的**当前所遍历的年龄段的数据**
// 返回一个对象 {state: d.name, age, population: d[age]}
// 因为有 9 个年龄段所以最后共返回 9 个数组,然后再对它们进行(一级)展开,并整合为一个数组中
// 所以最后返回的数组是由 6 个州 x 9 个年龄段,共 54 个元素构成
return ages.flatMap((age) => data.map((d) => ({state: d.name, age, population: d[age]})));
}
Insert cell
// 📝 该 cell 只是用于演示效果
rawData = await FileAttachment("us-population-state-age.csv").csv({typed: true});
Insert cell
// 📝 该 cell 只是用于演示效果
rawData.columns
Insert cell
import {legend} from "@d3/color-legend"
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more