chart = {
const width = 928;
const height = 600;
const marginTop = 40;
const marginRight = 50;
const marginBottom = 10;
const marginLeft = 50;
const padding = 3;
const steps = [...new Set(data.map(d => d.year))];
const x = d3.scalePoint()
.domain(steps)
.range([marginLeft, width - marginRight])
// 传入的数值是百分比,范围在 0 至 1 之间(包含端点),是以分割所得的等距宽度作为基准,假如设置为 1 则表示两侧预留空白的宽度,和分割所得的等距段的宽度一致
.padding(0.5);
// 设置纵坐标轴的比例尺
// 纵坐标轴的数据是连续型的数值(生存率),使用 d3.scaleLinear 构建一个线性比例尺
// 具体参考官方文档 https://d3js.org/d3-scale/linear
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-scale#线性比例尺-linear-scales
const y = d3.scaleLinear()
// 设置定义域范围 [ymin, ymax]
// 使用 d3.extent() 计算出数据集 data 中的生存率 d.survival 范围
.domain(d3.extent(data.map(d => d.survival)))
// 设置值域范围
// svg 元素的高度(减去留白区域)
.range([height - marginBottom, marginTop]);
/**
*
* 创建 svg 容器
*
*/
// 返回的是一个包含 svg 元素的选择集
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto; font: 10px sans-serif;");
/**
*
* 绘制坐标轴
*
*/
// 绘制横坐标轴
// 实际上这里并没有使用坐标轴相关的模块,只是添加了一些文本标签,以标记不同的生存时长(类别)
// 💡 可以将其理解为没有轴线的横坐标轴
svg.append("g")
// 设置文字的对齐方式
.attr("text-anchor", "middle")
.selectAll("g") // 为不同的文本标签创建容器 <g>
// 绑定数据,数组 steps 包含了不同的生存时长
.data(steps)
.join("g") // 将元素绘制到页面上
// 通过设置 CSS 的 transform 属性将不同的容器「移动」到相应的位置
// 基于绑定数据 d 再通过横坐标轴比例尺 x(d) 进行映射,得到相应的横坐标值;而纵坐标值都是 20px
.attr("transform", (d) => `translate(${x(d)},20)`)
// 在每个容器中都添加一个 `<text>` 元素,以设置文本内容
// 文本内容是所绑定的数据 d 生存时长
.call(g => g.append("text").text((d) => d))
// 在每个容器中都添加一个 `<line>` 元素(以绘制一小段直线),作为坐标轴的刻度线
// 直线的起始点的纵坐标值 y1 都是 3,终止点的纵坐标值 y2 都是 9(所以这一段小直线长度为 6px)
// 由于直线的方向是垂直向下的,即起始点和终止点的横坐标值是相同的,所以可以忽略不设置(采用默认值,所以横坐标和容器的位置一样,☝️ 在前面用 CSS 的 transform 设置的)
// 最后设置直线的描边颜色,继承父元素的颜色(黑色)
.call(g => g.append("line").attr("y1", 3).attr("y2", 9).attr("stroke", "currentColor"));
/**
*
* 绘制折线图内的线段
*
*/
// 使用方法 d3.line() 创建一个线段生成器
// 线段生成器会基于给定的坐标点生成线段(或曲线)
// 具体可以参考官方文档 https://d3js.org/d3-shape/line 或 https://github.com/d3/d3-shape/tree/main#lines
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-shape#线段生成器-lines
const line = d3.line()
// 该函数会在调用线段生成器时,为数组中的每一个元素都执行一次横坐标读取函数和纵坐标读取函数,以返回该数据所对应的横纵坐标值
// 设置横坐标读取函数
// 这里基于每个数据点的生存时长 d.year 并采用比例尺 x 进行映射,计算出相应的横坐标
.x(d => x(d.year))
// 设置纵坐标读取函数
// 这里基于每个数据点的生存率 d.survival 并采用比例尺 y 进行映射,计算出相应的纵坐标
.y(d => y(d.survival));
// 绘制(多条)折线
// 为这些线段创建一个容器 <g>
svg.append("g")
// 只需要路径的描边作为折线,不需要填充,所以属性 fill 设置为 none
.attr("fill", "none")
// 设置描边颜色
.attr("stroke", "currentColor")
// 使用路径 <path> 元素为每个国家创建一条折线(包括多个步骤,分成多条线段)
.selectAll("path")
// 绑定数据 data
// 先使用 D3 的内置方法 d3.group(iterable, ...keys) 对可迭代对象的元素进行分组转换
// 第一参数 iterable 是需要分组的可迭代对象
// 第二个参数 ...keys 是一系列返回分组依据的函数,数据集中的每个元素都会调用该函数,入参就是当前遍历的元素 d
// 并返回一个 InterMap 对象(映射,键名是分组依据,相应的值是在原始数组中属于该分组的元素)
// 具体可以参考官方文档 https://d3js.org/d3-array/group#group
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-process#转换
// 在这里是基于癌症的名称 d => d.name 对数据集 data 的元素进行分组
// 具体返回的 InterMap 对象可以查看 👇 下一个 📝 cell
// 共有 24 个癌症类别,它们分别绑定一个 <path> 元素
// InterMap 对象的每个映射绑定到相应的元素上时,会变成一个二元数组,第一个元素是键名(即癌症的名称),第二个元素是一个数组,由原数据集中属于该癌症类别的数据点组成
.data(d3.group(data, d => d.name))
.join("path") // 将这些线段绘制到页面上
// 解构出所绑定数据(一个二元数组)的第二个元素 values,它是一个数组,包含该癌症类别的一系列数据点,用它们绘制折线
// 调用线段生成器 line(values) 返回的结果是字符串
// 该值作为 `<path>` 元素的属性 `d` 的值
.attr("d", ([, values]) => line(values));
/**
*
* 为每条线段添加标注
*
*/
// 通过 scale.tickFormat(count) 构建一个数值格式器
// 根据所设置的刻度线数量 count(这里设置为 100)来自动确定数据的精度,更适用于阅读
// 该数值格式器在 👇 后面用于对标签文本进行格式化
const formatNumber = y.tickFormat(100);
// 为这些标注添加一个容器
svg.append("g")
// 为每个生存时长(类别)的标注分别设置一个容器(以便在沿横轴方向上分别进行定位)
.selectAll("g")
// 绑定数据
// 先使用 D3 的内置方法 d3.group(iterable, ...keys) 对可迭代对象的元素进行分组转换
// 这里是基于生存时长 d => d.year 对数据集 data 的元素进行分组
// 具体返回的 InterMap 对象可以查看 👇 后面的第二个 📝 cell
// 共有 4 个生存时长类别,它们分别绑定一个 <g> 容器
// InterMap 对象的每个映射绑定到相应的元素上时,会变成一个二元数组,第一个元素是键名(即生存时长),第二个元素是一个数组,由原数据集中属于该生存时长的数据点组成
.data(d3.group(data, d => d.year))
.join("g") // 将容器添加到页面上
// 通过设置 CSS 的 transform 属性将不同的容器「移动」到相应的位置
// 解构出所绑定数据(一个二元数组)的第一个元素 step 生存时长,再通过横坐标轴比例尺 x(step) 进行映射,得到相应的横坐标值
// 如果生存时长是最短的 `5 Year` 或最长的 `20 Year` 还要设置一小段间距
// 这两类标签是定位到折线的左右两端,要与折线有一定的距离,以避免重叠(而其他生存时长的标签就直接与折线重叠)
.attr("transform", ([step]) => `translate(${x(step) + (
step === "20 Year" ? padding // 位于右端的标签,则设置间距值为 padding(正数),即容器再向右移动一小段距离
: step === "5 Year" ? -padding // 位于左端的标签,则设置间距值为 -padding(负数),即容器再向左移动一小段距离
: 0 // 其他标签,不设置偏移
)},0)`)
// 设置标签文本的对齐方式
.attr("text-anchor", ([step]) =>
step === "5 Year" ? "end" // 位于左端的标签,文本的结束对齐
: step === "20 Year" ? "start" // 位于右侧的标签,文本的开头对齐
: "middle") // 其他标签,居中对齐
// 进行二次选择,在各个容器内添加一系列的 <text> 元素,以添加文本标注
.selectAll("text")
// ⚠️ 使用 select.selectAll() 所创建的新选择集会有多个分组
// 返回的选择集是由多个分组(各个 <g> 容器中)的虚拟/占位 <text> 元素构成的
// 由于新的选择集会创建多个分组,那么原来所绑定数据与(选择集中的)元素的对照关系会发生改变
// 从原来的一对一关系,变成了一对多关系,所以新的选择集中的元素**不会**自动「传递/继承」父节点所绑定的数据
// 所以如果要将原来选择集中所绑定的数据继续「传递」下去,就需要手动调用 selection.data() 方法,以显式声明要继续传递数据
// 在这种场景下,该方法的入参应该是一个返回数组的**函数**
// 每一个分组都会调用该方法,并依次传入三个参数:
// * 当前所遍历的分组的父节点所绑定的数据 datum
// * 当前所遍历的分组的索引 index
// * 选择集的所有父节点 parent nodes
// 详细介绍可以查看笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-binding#绑定数据
// 这里入参是一个二元数组,并解构出其中的元素 [step, values]
// 第一个元素 step 是生存时长;第二个元素 values 是一个数组,由原数据集 data 中属于该生存时长类别的数据点组成
// 使用方法 d3.zip(arr1️⃣, arr2️⃣, ...) 对输入一系列的数组 arr1️⃣、arr2️⃣ ... 实现类似矩阵转置的功能
// 即依次提取各数组的第 i 个元素,将它们构成新数组,然后再将这些数组作为元素,组合成一个新的数组(一个嵌套数组)
// 具体可以参考官方文档 https://d3js.org/d3-array/transform#zip
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-process
// 1️⃣ 这里传入的第一个参数是基于数组 values 生成的一个数组
// values.map(
// step === "20 Year" ? (d) => `${formatNumber(d.survival)} ${d.name}`
// : step === "5 Year" ? (d) => `${d.name} ${formatNumber(d.survival)}`
// : (d) => `${formatNumber(d.survival)}`),
// 方法 arr.map(func) 让数组中的每个元素依次调用处理函数 func,并将函数的返回值构成一个新的数组
// 这里会基于当前(选择集)分组所对应的 step 生存时长类型,对数组 values 采用不同的转换方式,将数组 values 中的每个元素(对象)变成字符串,作为标签的文本内容
// 当 step === "20 Year" 时,字符串的内容是用数值格式器 formatNumber() 调整精度的生存率 d.survial,然后加上当前所遍历的元素所属的癌症名称 d.name
// 当 step === "5 Year" 时,字符串的内容是当前所遍历的元素所属的癌症名称 d.name,再加上用数值格式器 formatNumber() 调整精度的生存率 d.survial
// 当 step 是其他值时,字符串的内容是用数值格式器 formatNumber() 调整精度的生存率 d.survial
// 2️⃣ 这里传入的第二个参数也是基于数组 values 生成的另一个数组
// dodge(values.map(d => y(d.survival)))
// 首先使用方法 values.map(func) 对数据集 data 进行转换,在每个元素执行处理函数中,调用了纵坐标轴比例尺 y(d.survival) 将当前所遍历的元素(生存率)映射为纵坐标轴的值,作为标签在纵轴方向的定位
// ⚠️ 因为不同癌症在特定的生存时长的概率可能相同或相近,如果将比例尺 y(d.survival) 映射所得的值直接作为这些标签的纵轴坐标值,那么它们就可能重叠
// 所以这里还需要使用方法 dodge() 对布局定位进一步优化,以提高标签的视觉可视性
// 方法 dodge() 的具体代码看 👇 最后一个 cell
// 最后经过 d3.zip() 将数组 1️⃣ 和数组 2️⃣ 进行「整合」
// 所以每个 <text> 元素所绑定的数据都是一个二元数组,第一个元素是字符串(从数组 1️⃣ 提取出来的),作为标注内容;第二个元素是数值(从数组 2️⃣ 提取出来的),用于在纵坐标轴上定位
.data(([step, values]) => d3.zip(
values.map(
step === "20 Year" ? (d) => `${formatNumber(d.survival)} ${d.name}`
: step === "5 Year" ? (d) => `${d.name} ${formatNumber(d.survival)}`
: (d) => `${formatNumber(d.survival)}`),
dodge(values.map(d => y(d.survival)))))
.join("text") // 将 <text> 元素添加到页面上
// 设置各个标注的定位
// 只需要设置纵坐标值 y 即可(横坐标值都相同,在前面 ☝️由(标签所属的)容器进行统一定位)
.attr("y", ([, y]) => y)
// 在纵轴方向上为文本设置一点小偏移
.attr("dy", "0.35em")
// 设置标注的文本内容
.text(([text]) => text)
// 设置文字颜色(
.attr("fill", "currentColor")
// 设置文字的描边颜色为白色
.attr("stroke", "white")
// 设置文字描边的宽度为 5
.attr("stroke-width", 5)
// 设置文本的 fill 填充、stroke 描边、mark 标记的绘制顺序
// 这里是先绘制描边,然后再是填充,避免白色描边遮挡了黑色的字体
// 具体介绍查看 https://developer.mozilla.org/en-US/docs/Web/CSS/paint-order
.attr("paint-order", "stroke");
return svg.node();
}