Public
Edited
Dec 19, 2023
Insert cell
Insert cell
FileAttachment("02metrics-popup-v3.jpg").image()
Insert cell
Insert cell
chart = {
replay;

// 设置一些关于尺寸的参数
const width = 928; // svg 元素的宽
const height = 720; // svg 元素的高
// margin 为前缀的参数
// 其作用是在 svg 的外周留白,构建一个显示的安全区,以便在四周显示坐标轴
const marginTop = 20;
const marginRight = 30;
const marginBottom = 30;
const marginLeft = 40;

/**
*
* 构建比例尺
*
*/
// 设置横坐标轴的比例尺
// 横坐标轴的数据是连续型的数值(人均每年驾驶英里数),使用 d3.scaleLinear 构建一个线性比例尺
const x = d3.scaleLinear()
// 设置定义域范围
// 从数据集的每个数据点中提取出英里数 d.miles,并用 d3.extent() 计算出这些数据的范围
// 另外还使用 continuous.nice() 方法编辑定义域的范围,通过四舍五入使其两端的值更「整齐」 nice(便于划分刻度)
// 具体参考官方文档 https://d3js.org/d3-scale/linear#linear_nice
.domain(d3.extent(driving, d => d.miles)).nice()
// 设置值域范围(所映射的可视元素)
// svg 元素的宽度(减去留白区域)
.range([marginLeft, width - marginRight]);

// 设置纵坐标轴的比例尺
// 纵坐标轴的数据是连续型的数值(每加仑油价),使用 d3.scaleLinear 构建一个线性比例尺
const y = d3.scaleLinear()
// 设置定义域范围
// 从数据集的每个数据点中提取出油价值 d.gas,并用 d3.extent() 计算出这些数据的范围
// 另外还使用 continuous.nice() 方法编辑定义域的范围,通过四舍五入使其两端的值更「整齐」 nice(便于划分刻度)
.domain(d3.extent(driving, d => d.gas)).nice()
// 设置值域范围(所映射的可视元素)
// svg 元素的高度(减去留白区域)
.range([height - marginBottom, marginTop]);

/**
*
* 创建 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;");
/**
*
* 绘制坐标轴
*
*/
// 绘制横坐标轴
svg.append("g")
// 通过设置 CSS 的 transform 属性将横坐标轴容器「移动」到底部
.attr("transform", `translate(0,${height - marginBottom})`)
// 横轴是一个刻度值朝下的坐标轴
// 通过 axis.ticks(count) 设置刻度数量的参考值(避免刻度过多导致刻度值重叠而影响图表的可读性)
.call(d3.axisBottom(x).ticks(width / 80))
// 删掉上一步所生成的坐标轴的轴线(它含有 domain 类名)
.call(g => g.select(".domain").remove())
// 复制了一份刻度线,用以绘制图中纵向的网格参考线
.call(g => g.selectAll(".tick line").clone()
// 调整复制后的刻度线的终点位置(往上移动)
.attr("y2", -height)
.attr("stroke-opacity", 0.1)) // 调小参考线的透明度
// 为坐标轴添加额外信息名称(一般是刻度值的单位等信息)
.call(g => g.append("text")
// 将该文本移动到坐标轴的一端(即 svg 的右下角)
.attr("x", width - 4)
.attr("y", -4)
.attr("font-weight", "bold") // 设置字体粗细
.attr("text-anchor", "end") // 设置文本的对齐方式
.attr("fill", "currentColor") // 设置文本的颜色
.text("Miles per person per year")); // 设置文本内容
// 💡 注意以上通过方法 selection.call(axis) 的方式来调用坐标轴对象(方法)
// 会将选择集中的元素 <g> 传递给坐标轴对象的方法,作为第一个参数
// 以便将坐标轴在相应容器内部渲染出来
// 具体参考官方文档 https://d3js.org/d3-selection/control-flow#selection_call
// 或这一篇文档 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-binding#其他方法

// 绘制纵坐标轴
svg.append("g")
// 通过设置 CSS 的 transform 属性将纵向坐标轴容器「移动」到左侧
.attr("transform", `translate(${marginLeft},0)`)
// 纵轴是一个刻度值朝左的坐标轴
// 并使用坐标轴对象的方法 axis.ticks() 设置坐标轴的刻度数量和刻度值格式
// 具体参考官方文档 https://d3js.org/d3-axis#axis_ticks
// 其中第一个参数用于设置刻度数量,这里设置为 `null` 表示采用默认的刻度生成器
// 而第二个参数用于设置刻度值格式,这里设置为 "$.2f" 表示将数值保留两位小数,并在前面添加金钱符号 $
// 关于 D3 所提供的数值格式具体参考官方文档 https://d3js.org/d3-format
.call(d3.axisLeft(y).ticks(null, "$.2f"))
// 删掉上一步所生成的坐标轴的轴线(它含有 domain 类名)
.call(g => g.select(".domain").remove())
// 复制了一份刻度线,用以绘制图中横向的网格参考线
.call(g => g.selectAll(".tick line").clone()
.attr("x2", width) // 调整复制后的刻度线的终点位置(往右移动)
.attr("stroke-opacity", 0.1)) // 调小参考线的透明度
// 为坐标轴添加额外信息名称(一般是刻度值的单位等信息)
// 这里并没有添加一个 <text> 元素
// 而是复制坐标轴的最后一个刻度(通过 class 选择器 .tick:last-of-type)里面的 `<text>` 标签
// 再调整其位置,并设置内容
.call(g => g.select(".tick:last-of-type text").clone()
.attr("x", 4) // 将文本向右边设置一点小偏移
.attr("text-anchor", "start") // 设置文本的对齐方式
.attr("font-weight", "bold") // 设置字体粗细
.text("Cost per gallon")); // 设置文本内容

/**
*
* 绘制折线图内的线段
*
*/
// 使用方法 d3.line() 创建一个线段生成器
// 线段生成器会基于给定的坐标点生成线段(或曲线)
// 具体可以参考官方文档 https://d3js.org/d3-shape/line
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-shape#线段生成器-lines
const line = d3.line()
// 设置两点之间的曲线插值器(所以线段生成器除了可以绘制折线,还可以绘制曲线)
// D3 提供了一系列的内置的曲线插值器,它们的区别和具体效果可以查看官方文档 https://d3js.org/d3-shape/curve
// 这里就是使用了其中一个 d3.curveCatmullRom
.curve(d3.curveCatmullRom)
// 设置横坐标读取函数
// 该函数会在调用线段生成器时,为数组中的每一个元素都执行一次,以返回该数据所对应的横坐标
// 这里基于每个数据点的英里数 d.miles 并采用比例尺 x 进行映射,计算出相应的横坐标
.x(d => x(d.miles))
// 设置纵坐标读取函数
.y(d => y(d.gas));

// 调用线段生成器 line(driving) 返回的结果是字符串,该值作为 `<path>` 元素的属性 `d` 的值
// 使用 length() 方法获取该路径的总长度
const l = length(line(driving));

// 将线段路径绘制到页面上
svg.append("path")
// 绑定数据
// 这里采用 selection.datum(value) 为选择集中的每个元素上绑定的数据(该选择集里只有一个 <path> 元素)
// 因为这里只需要使用一个 <path> 元素来绘制一条路径,作为折线/曲线
// ⚠️ 它与 selection.data(value) 不同,该方法不会将数组进行「拆解」
// 即这个方法不会进行数据与元素的一一对应链接计算,并且不影响索引,不影响(不产生)enter 和 exit 选择集
// 而是将数据 value 作为一个整体绑定到选择的各个元素上,因此使用该方法选择集的所有 DOM 元素绑定的数据都一样
// 具体参考官方文档 https://d3js.org/d3-selection/joining#selection_datum
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/module-api/d3-module-selection#绑定数据
.datum(driving)
// 只需要路径的描边作为折线,不需要填充,所以属性 fill 设置为 none
.attr("fill", "none")
// 设置描边颜色
.attr("stroke", "black")
// 设置描边宽度
.attr("stroke-width", 2.5)
.attr("stroke-linejoin", "round") // 设置折线之间的连接样式(圆角让连接更加平滑)
.attr("stroke-linecap", "round") // 设置路径端点的样式
// 通过属性 stroke-dasharray 设置路径(描边)的**点划线**的图案规则,作为路径展开动画的初始状态
// 该属性值由一个或多个(用逗号或者空白隔开)数字构成
// 这些数字组合会依次表示划线和缺口的长度
// 即第一个数字表示划线的长度,第二个数表示缺口的长度,然后下一个数字又是划线的长度,依此类推
// 如果该属性值的数字之和小于路径长度,则重复这个数字来绘制划线和缺口,这样就会出现规律的点划线图案
// 这里首先将属性 stroke-dasharray 设置为 `0,${l}`
// 即路径的划线部分为 0,全部都是缺口
// 所以其效果是在过渡开始时,路径为空,即折线不可见
.attr("stroke-dasharray", `0,${l}`)
// 调用线段生成器,将所绑定的数据 driving 作为参数传递到方法 line() 中
// 返回的结果是字符串,作为 `<path>` 元素的属性 `d` 的值
.attr("d", line)
// 设置过渡动效
// 更改的属性是 stroke-dasharray
.transition()
.duration(5000) // 设置过渡的时间
.ease(d3.easeLinear) // 设置缓动函数
// 设置属性是 stroke-dasharray 过渡的最终状态 `${l},${l}`(其实也可以是 `${l},0` 最终效果一样)
// 即路径的划线的长度和路径总长度相同,缺口也一样
// 所以效果是过渡结束时,路径完全显示
.attr("stroke-dasharray", `${l},${l}`);

/**
*
* 绘制数据点
*
*/
svg.append("g")
.attr("fill", "white") // 填充色为白色
.attr("stroke", "black") // 描边为黑色
.attr("stroke-width", 2) // 描边宽度
// 使用 <circle> 元素(一个个小圆形)来绘制数据点
.selectAll("circle")
.data(driving) // 绑定数据
.join("circle")
// 定位各数据点
.attr("cx", d => x(d.miles))
.attr("cy", d => y(d.gas))
.attr("r", 3); // 设置圆的半径大小

/**
*
* 为数据点添加标注
*
*/
const label = svg.append("g")
// 设置字体样式
.attr("font-family", "sans-serif")
.attr("font-size", 10)
.selectAll()
// 绑定数据
.data(driving)
// 使用 <text> 元素显式标注
.join("text")
// 使用 CSS 的属性 transform 将各个 <text> 元素定位到相应数据点的位置
.attr("transform", d => `translate(${x(d.miles)},${y(d.gas)})`)
.attr("fill-opacity", 0) // 设置透明状态,初始值为 0%,即一开始是隐藏的
.text(d => d.year) // 设置文本内容
// 设置文字的描边颜色为白色
.attr("stroke", "white")
// 设置文本的 fill 填充、stroke 描边、mark 标记的绘制顺序
// 这里是先绘制描边,然后再是填充,避免白色描边遮挡了黑色的字体
// 具体介绍查看 https://developer.mozilla.org/en-US/docs/Web/CSS/paint-order
.attr("paint-order", "stroke")
// 设置文字颜色
.attr("fill", "currentColor")
// 使用方法 selection.each(func) 让选择集中的每个元素都调用一次函数 func 以执行特定的操作
// 这里的作用是根据各个数据点的属性 side 来设置文本的偏移值(避免文本标注遮挡折线)
// d 是当前所遍历的元素所绑定的数据
.each(function(d) {
// this 指向当前所遍历的 DOM 元素
const t = d3.select(this);
// 根据数据点的属性 d.side 的值来设置标注文本的对齐方式和偏离量
switch (d.side) {
case "top": t.attr("text-anchor", "middle").attr("dy", "-0.7em"); break;
case "right": t.attr("dx", "0.5em").attr("dy", "0.32em").attr("text-anchor", "start"); break;
case "bottom": t.attr("text-anchor", "middle").attr("dy", "1.4em"); break;
case "left": t.attr("dx", "-0.5em").attr("dy", "0.32em").attr("text-anchor", "end"); break;
}
});

// 为标注信息设置透明度的过渡动效
label.transition()
// 为各个标注信息设置**不同**的延迟时间
// 以实现标注信息的显式和路径的展开达到同步的效果
// 参数 d 是当前所遍历的元素所绑定的数据,参数 i 是当前所遍历的元素在分组中的索引
// 通过方法 line(driving.slice(0, i + 1)) 获取当前标注文本所对应的数据点,所在的路径
// 然后再通过 length() 计算该路径的长度
// 通过与总长度 l 相除得到相对值,用于计算需要延迟多长时间(路径正好延伸到该数据点)
// duration - 125 做了一些小修正,在路径展开到来前,让标注信息提前一点点时间先显示
.delay((d, i) => length(line(driving.slice(0, i + 1))) / l * (5000 - 125))
.attr("fill-opacity", 1); // 设置透明度在过渡的最终状态为 1,即完全显示

return svg.node();
}
Insert cell
// 读取数据,并将字符串自动解析转换为相应的数据类型
driving = FileAttachment("driving.csv").csv({typed: true})
Insert cell
Insert cell
// 计算曲线的总长度
// 传入的参数是一个字符串(它作为 <path> 元素的属性 `d` 的属性值,由线段生成器所生成)
// 首先使用 d3.create(svg:path) 创建一个选择集,其中包含一个新建的 SVG <path> 元素
// ⚠️ 如果创建的不是 HTML 元素(例如 `<svg>` 元素),而是其他的 SVG 类型的元素(例如 `<g>` 元素),需要显式地指明命名空间 svg,否则创建一个 HTML G 不标准的元素
// 然后通过属性 `d` 设置路径形状
// 再使用 selection.node() 获取该 <path> 元素
// 使用 JavaScript 原生方法 SVGGeometryElement.getTotalLength() 获取路径的总长度(一个浮点数)
length = (path) => d3.create("svg:path").attr("d", path).node().getTotalLength()
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