Public
Edited
Dec 14, 2022
Insert cell
Insert cell
// 在滑块的右侧显示的是年份(手动滑动的步长也是以年为间距单位)
// 但在自动执行动画的过程中,实际输入的数据是 dates 以月为间距单位
viewof date = Scrubber(dates, {format: d => d.getUTCFullYear(), loop: false})
Insert cell
Insert cell
chart = {
// 创建 svg 选择集
// 返回的选择集中仅包含 svg 一个元素
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height]);

// 绘制横坐标轴
svg.append("g")
.call(xAxis); // 调用坐标轴(对象)方法,将坐标轴在相应容器内部渲染出来

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

// 绘制网格参考线
svg.append("g")
.call(grid);

// 绘制数据点(初始状态)
const circle = svg.append("g")
.attr("stroke", "black")
.selectAll("circle")
// 绑定数据
// 并将国家的名称设置为 key,这样在之后不断更新数据时,可以复用页面上这些数据点,从而构成连续的动画
.data(dataAt(1800), d => d.name) // 基于日期来获取数据,初始状态的日期是 1800 年
.join("circle")
// 在(通过设置参数)绘制数据点前,先基于人口值进行降序排序(较大的值排在前面,较小的值排在后面)
// 根据 svg 的绘图原理,先绘制的元素层级较低,后绘制的元素层级较高(叠放在上层)
// 这样就可以保证图中面积较小的数据点(对应于人口较少的国家)叠放在面积较大的数据点之上,避免被遮挡
.sort((a, b) => d3.descending(a.population, b.population))
// 设置数据点的位置、大小、颜色
.attr("cx", d => x(d.income)) // 基于 X 轴比例尺,计算出数据点的横向值
.attr("cy", d => y(d.lifeExpectancy)) // 基于 Y 轴比例尺,计算出数据点的纵向值
.attr("r", d => radius(d.population)) // 数据点的(半径)大小
.attr("fill", d => color(d.region)) // 数据点的颜色
// 为数据点添加描述性字符串 <title> 元素
// 当鼠标 hover 到数据点时,会弹出一个 tooltip 显示这个描述性字符串
.call(circle => circle.append("title")
// 设置文本内容
// 由该数据点所代表的国家和所在的区域(通过换行符来连接)
.text(d => [d.name, d.region].join("\n")));

return Object.assign(svg.node(), {
// 基于日期获取新的数据
// 并更新页面上的数据点的属性(位置和大小)
update(data) {
circle.data(data, d => d.name)
// 每次获取新数据后,先对数据基于人口值进行降序排序
// 以保证页面上面积较小的数据点不会被遮挡
.sort((a, b) => d3.descending(a.population, b.population))
.attr("cx", d => x(d.income))
.attr("cy", d => y(d.lifeExpectancy))
.attr("r", d => radius(d.population));
}
});
}
Insert cell
update = chart.update(currentData)
Insert cell
currentData = dataAt(date) // 当前的数据(基于日期 `date` 来获取,是一个计算属性)
Insert cell
// X 轴比例尺,对数比例尺
// 因为人均收入,各国差距很大,从 200 美元到 1e5=100000美元,采用对数比例尺
x = d3.scaleLog([200, 1e5], [margin.left, width - margin.right])
Insert cell
// Y 轴比例尺,线性比例尺
y = d3.scaleLinear([14, 86], [height - margin.bottom, margin.top])
Insert cell
// 面积比例尺,幂比例尺
// 因为人口数量映射为数据点的面积(线性映射)area = m*population + b
// 而在绘制 svg 元素 <circle> 时是通过指定半径 r 参数来决定面积大小的
// 所以实际需要将人口数量映射为圆形的半径 r = m*population^0.5 + b
// 映射关系是幂关系,幂为 0.5
// 采用 d3.scaleSqrt() 创建比例尺,实际上是幂比例尺 d3.scalePow().exponent(0.5) 的别名,一个更便捷的封装方法
radius = d3.scaleSqrt([0, 5e8], [0, width / 24])
Insert cell
// 排序比例尺
// 将国家所属的地区(离散值)映射为不同的颜色(离散值),以进行区分标注
// 如果国家没有具体所属的地区,则采用黑色标注
color = d3.scaleOrdinal(data.map(d => d.region), d3.schemeCategory10).unknown("black")
Insert cell
// 横坐标轴
xAxis = g => g
.attr("transform", `translate(0,${height - margin.bottom})`) // 将横坐标轴容器定位到底部
// 基于给定的比例尺 x 构建一个坐标轴
// 并设置刻度值的格式,千位以逗号分隔
.call(d3.axisBottom(x).ticks(width / 80, ",")) // 刻度值的数量与页面的宽度相关
.call(g => g.select(".domain").remove()) // 删掉上一步所生成的坐标轴的轴线(它含有 domain 类名)
.call(g => g.append("text") // 为坐标轴添加额外信息名称(一般是刻度值的单位等信息)
// 将该文本移动到坐标轴的右侧(即容器的右下角)
.attr("x", width)
.attr("y", margin.bottom - 4)
.attr("fill", "currentColor")
.attr("text-anchor", "end") // 设置文本的对齐方式
.text("Income per capita (dollars) →")) // 文本内容
Insert cell
// 纵坐标轴
yAxis = g => g
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(y))
.call(g => g.select(".domain").remove())
.call(g => g.append("text")
.attr("x", -margin.left)
.attr("y", 10)
.attr("fill", "currentColor")
.attr("text-anchor", "start")
.text("↑ Life expectancy (years)"))
Insert cell
x.domain() // X 轴比例尺的定义域
Insert cell
// 打印看看比例尺的 x.ticks() 方法所返回的值
// 该方法基于坐标轴的刻度数量,对定义域的范围进行均匀分割 uniformly spaced
// 因为 X 轴比例尺是对数比例尺,所以分割完成返回的元素之间的差值是与对数比例尺相匹配的
// 在百级的范围,两个元素差距是 100;在千级的范围,两个元素的差距是 1000;在万级的范围就依次类推
x.ticks()
Insert cell
// 网格参考线
grid = g => g
.attr("stroke", "currentColor") // 设置参考线的颜色
.attr("stroke-opacity", 0.1) // 调小网格参考线的透明度
// 构建网格参考线的竖线
.call(g => g.append("g")
.selectAll("line")
// x.ticks() 方法返回一个数组,是将比例尺定义域均匀分割所构成的数组
// 数组的元素数量和坐标轴度线的数量相同
// 所以 <link> 元素选择集绑定的数据是一系列取自 X 轴定义域的值
.data(x.ticks())
.join("line")
// 绘制线段
// 由于是竖线,所以线段的起始点和结束点的 x1 和 x2 位置是相同的
// 该位置通过 X 轴比例尺 x(d) 算出(其中加上 0.5 是为了做一点偏移,让分割线和刻度线别重合?)
.attr("x1", d => 0.5 + x(d))
.attr("x2", d => 0.5 + x(d))
// 而线段的起始点和结束点的 y1 和 y2 位置与容器的高度值 height 相关
.attr("y1", margin.top)
.attr("y2", height - margin.bottom))
// 构建网格参考线的横线
.call(g => g.append("g")
.selectAll("line")
.data(y.ticks())
.join("line")
.attr("y1", d => 0.5 + y(d))
.attr("y2", d => 0.5 + y(d))
.attr("x1", margin.left)
.attr("x2", width - margin.right));
Insert cell
// 二元分割
// 使用 d3.bisector(accessor) 创建一个分割器
// 因为入参的数据集(数组)是较为较复杂的,数组的元素是一个二元数组,所以需要设置访问器 accessor,从元素中提取出 date 日期对象,以便进行比对
// 参考 D3 的 d3-array 模块 https://github.com/d3/d3-array 和笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-process#二元分割
bisectDate = d3.bisector(([date]) => date).left // 采用「左分割」
Insert cell
// 基于原始的数据集(推测)计算出相应日期的值
// 使用 bisection 二分查找算法和 linear interpolation 线性插值法
// 基于原始的(以年为间距单位)数据集 values 来(推测)计算出给定月份 date 的数据
function valueAt(values, date) {
// 使用分割器 bisectDate 获取给定月份 date 应该介于哪两个原始数据(年份)之间
// 其实第三、第四个参数可以省略?因为分割器默认就是针对整个数组 values
// 返回的是一个索引值(如果将 date 插入到这个索引值的位置后,依然保持 values 中的年份数据是有序的)
const i = bisectDate(values, date, 0, values.length - 1);
// 因为采用的是「左分割」,所以通过该索引值 i 获取得到的数据点 values[i],是在 date 日期实际所对应的数据点的右侧
const a = values[i]; // 左侧的点
// 判断索引值是否大于 0
if (i > 0) {
const b = values[i - 1]; // 右侧的点
// 通过左右两侧的点,使用 linear interpolation 线性插值法,估算出给定日期 date 所对应的数据
// 关于线性插值法的公式具体解释可以参考 https://datavis-note.benbinbin.com/article/theory/algorithm/linear-interpolation
const t = (date - a[0]) / (b[0] - a[0]);
return a[1] * (1 - t) + b[1] * t; // 估算的值
}
return a[1]; // 因为如果索引值等于 0 则表示 date 日期起始就是和原始数据的起始日期相同,应该直接返回端点的原始数据 a[1]
}
Insert cell
// 基于日期 date 从数据集中获取(或计算出)相应的数据
function dataAt(date) {
// 原数据中的每一个数据点都是一个表示国家的对象
return data.map(d => ({
name: d.name,
region: d.region,
// 主要是需要基于日期 date 对以下三个属性的值进行转换
// 只读取/计算出相应日期的数据值
income: valueAt(d.income, date),
population: valueAt(d.population, date),
lifeExpectancy: valueAt(d.lifeExpectancy, date)
}));
}
Insert cell
dataAt(1800)
Insert cell
// 转换函数
// 主要是将原始数据中的二元数组(具有两个元素的数组)中的第一个元素转换为 Date 日期对象
function parseSeries(series) {
return series.map(([year, value]) => [new Date(Date.UTC(year, 0, 1)), value]);
}
Insert cell
// 加载和处理数据
data = (await FileAttachment("nations.json").json())
.map(({name, region, income, population, lifeExpectancy}) => ({
name,
region,
income: parseSeries(income),
population: parseSeries(population),
lifeExpectancy: parseSeries(lifeExpectancy)
}))
Insert cell
// 时间边距计算器
// 以月为间距,时间格式采用 UTC 世界协调时间
// 参考 D3 的 d3-time 模块 https://github.com/d3/d3-time 和笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-process#时间边距计算器
// 时距器可用于进行时间修约,获取满足不同需求的 Date 日期对象
interval = d3.utcMonth // interval between animation frames
// 采用的时距器是以月为间距(而不是以年为间距)
// 这和原始数据的间隔不同,原始数据集是以年为单位(虽然不一定每个国家都有每一年的数据)
// 由于以年为间距时,每个国家的相关数据变化会比较大
// 则在使用动画展示数据变化时,在画面中每一帧的数据点的位移会很大,这样动画就会显得不流畅
// 因此采用月为间距,再使用 bisection 二分查找算法和 linear interpolation 线性插值基于原始的(以年为间距单位)数据来(推测)计算出每个月的数据
// 采用更小的时间间距,数据的差值会更小,这样每一帧的数据点的位移就不会那么大,可以让动画显得更顺滑
Insert cell
// 构建时间列表
// 使用时距器的方法 interval.range(start, stop, step)
// 基于数据集中的年份范围,和间隔距离(每月采集一个时间点),得出一系列的 Date 日期对象
dates = interval.range(
// 获取时间范围的下限(最早的年份)
// 数据集的结构比较复制,所以第二个参数是转换函数,对数据进行处理以读取其中的日期值
d3.min(data, d => {
// 每一个数据点都是一个表示国家的对象
// 在这个对象里,三个属性 income、population、lifeExpectancy 包含日期数据
// 这三个属性的值都是一个列表,里面的元素都是按照年份进行排序的,所以第一个元素的日期是最早的
return d3.min(
// 提取三个属性的列表里第一个值
[d.income[0], d.population[0], d.lifeExpectancy[0]],
// 因为所提取的第一个值也是一个数组(一个二元数组,即有两个元素)
// 所以也需要设置转换函数,提取其中第一个元素(年份)
([date]) => date
);
}),
// 获取时间范围的上线(最近的年份)
// 这里取所有国家所具有的最大年份的最小值(?避免某些国家在最近年份存在数据缺失)
d3.min(data, d => {
// 也是从三个属性 income、population、lifeExpectancy 中提取日期数据
// 取它们最大值
return d3.max(
// 列表的最后一个元素就包含最近年份的数据
[
d.income[d.income.length - 1],
d.population[d.population.length - 1],
d.lifeExpectancy[d.lifeExpectancy.length - 1]
],
([date]) => date
);
})
)
Insert cell
margin = ({top: 20, right: 20, bottom: 35, left: 40})
Insert cell
height = 560
Insert cell
d3 = require("d3@6.7.0/dist/d3.min.js")
Insert cell
import {Scrubber} from "@mbostock/scrubber"
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