function Drawer({
marginTop = 20,
marginRight = 30,
marginBottom = 30,
marginLeft = 40,
width = 640,
height = 400,
xSamples = 30,
xType = d3.scaleUtc,
xDomain = [new Date("2022-01-01"), new Date("2023-01-01")],
xRange = [marginLeft, width - marginRight],
yType = d3.scaleLinear,
yDomain = [0, 1],
yRange = [height - marginBottom, marginTop],
yFormat,
yLabel,
curve = d3.curveLinear,
} = {}) {
// 用一个数组来容纳数据点
let data = [];
/**
*
* 构建比例尺
*
*/
// 设置横坐标轴的比例尺
// 横坐标轴的数据是月份(时间),使用 d3.scaleUtc 构建一个时间比例尺(连续型比例尺的一种)
// 该时间比例尺采用协调世界时 UTC,处于不同时区的用户也会显示同样的时间
// 具体可以参考官方文档 https://d3js.org/d3-scale/time 或 https://github.com/d3/d3-scale#time-scales
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-scale#时间比例尺-time-scales
const xScale = xType(xDomain, xRange);
// 设置纵坐标轴的比例尺
// 纵坐标轴的数据是 [0, 1] 的连续型的数值,,使用 d3.scaleLinear 构建一个线性比例尺
const yScale = yType(yDomain, yRange);
/**
*
* 创建 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; height: intrinsic;")
.property("value", data);
/**
*
* 绘制坐标轴
*
*/
// 横轴是一个刻度值朝下的坐标轴
// 通过 axis.ticks(count) 设置刻度数量的参考值(避免刻度过多导致刻度值重叠而影响图表的可读性)
// 具体参考官方文档 https://d3js.org/d3-axis#axis_ticks
// 而且将坐标轴的外侧刻度 tickSizeOuter 长度设置为 0(即取消坐标轴首尾两端的刻度)
const xAxis = d3.axisBottom(xScale).ticks(width / 80).tickSizeOuter(0);
// 纵轴是一个刻度值朝左的坐标轴
// 通过 axis.ticks(count, format) 设置刻度数量(参考值)和刻度值格式
// 第二个参数 format 应该是一个字符串,称为 specifier 格式化说明符,用于设置刻度值格式(由于纵坐标轴采用线性比例尺,所以应该采用数值格式说明符)
// 而在这个实例中 yFormat 的值是 undefined,则采用比例尺自动生成的默认格式
// 关于 D3 所提供的数值格式具体参考官方文档 https://github.com/d3/d3-format
const yAxis = d3.axisLeft(yScale).ticks(height / 40, yFormat);
// 绘制横坐标轴
svg.append("g")
// 通过设置 CSS 的 transform 属性将横坐标轴容器「移动」到底部
.attr("transform", `translate(0,${height - marginBottom})`)
.call(xAxis);
// 💡 注意这里使用的是方法 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)`)
.call(yAxis)
// 删掉上一步所生成的坐标轴的轴线(它含有 domain 类名)
.call(g => g.select(".domain").remove())
// 复制了一份刻度线,用以绘制图中横向的网格参考线
.call(g => g.selectAll(".tick line").clone()
// 调整复制后的刻度线的终点位置(往右移动)
.attr("x2", width - marginLeft - marginRight)
.attr("stroke-opacity", 0.1)) // 调小参考线的透明度
// 为坐标轴添加额外注释信息(一般是坐标轴名称或刻度值的单位等信息)
.call(g => g.append("text")
// 将该文本移动到坐标轴的顶部(即容器的左上角)
.attr("x", -marginLeft)
.attr("y", 10)
.attr("fill", "currentColor") // 设置文本的颜色
.attr("text-anchor", "start") // 设置文本的对齐方式
.text(yLabel)); // 设置文本内容
/**
*
* 绘制折线图内的线段
*
*/
// 使用方法 d3.line() 创建一个线段生成器
// 线段生成器会基于给定的坐标点生成线段(或曲线)
// 调用线段生成器时返回的结果,会基于生成器是否设置了画布上下文 context 而不同。如果设置了画布上下文 context,则生成一系列在画布上绘制路径的方法,通过调用它们可以将路径绘制到画布上;如果没有设置画布上下文 context,则生成字符串,可以作为 `<path>` 元素的属性 `d` 的值
// 具体可以参考官方文档 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.curveLinear` 它会在两个相邻的离散点之间以直连的方式生成线段
// 所以其效果和普通的折线图一样
.curve(curve)
// 调用线段生成器方法 line.defined() 设置数据完整性检验函数
// 该函数会在调用线段生成器时,为数组中的每一个元素都执行一次,返回布尔值,以判断该元素的数据是否完整
// 该函数也是有三个入参,当前的元素 `d`,该元素在数组中的索引 `i`,整个数组 `data`
// 当函数返回 true 时,线段线段生成器就会执行下一步(调用坐标读取函数),最后生成该元素相应的坐标数据
// 当函数返回 false 时,该元素就会就会跳过,当前线段就会截止,并在下一个有定义的元素再开始绘制,反映在图上就是一段段分离的线段
// 这里通过判断数据点(一个二元数组)的第二个元素 y 是否为 null 来判定该数据是否缺失
.defined(([, y]) => y != null)
// 设置横坐标读取函数
// 该函数会在调用线段生成器时,为数组中的每一个元素都执行一次,以返回该数据所对应的横坐标
// 这里解构出数据点(一个二元数组)的第一个元素 x,基于它并采用比例尺 x 进行映射,计算出相应的横坐标
.x(([x]) => xScale(x))
// 设置纵坐标读取函数
// 这里解构出数据点(一个二元数组)的第二个元素 y,基于它并采用比例尺 y 进行映射,计算出相应的纵坐标
.y(([, y]) => yScale(y));
// 设置折线图路径的样式
const path = svg.append("path") // 使用路径 <path> 元素绘制折线
// 只需要路径的描边作为折线,不需要填充,所以属性 fill 设置为 none
.attr("fill", "none")
.attr("stroke", "black") // 设置描边颜色
.attr("stroke-width", 1.5); // 设置描边宽度
/**
*
* 交互
*
*/
// 创建一个 <rect> 矩形,它的大小与 svg 元素一样大
// 可以将它理解为一个覆盖在折线图上面的透明蒙版,在它上面监控用户的拖拽行为,以实现交互操作
svg.append("rect")
.attr("width", width)
.attr("height", height)
// 这里在折线图上面再添加一个「不可见」的矩形
// 所以这里将填充 fill 设置为 none
.attr("fill", "none")
// ⚠️ 由于属性 fill 设置为 none 的 SVG 元素无法成为鼠标事件的目标
// 需要将 pointer-events 设置为 all 进行「校正」,则该元素在任何情况下(无论属性 fill 设置为任何值)都可以响应指针事件
.attr("pointer-events", "all")
// 通过 d3.drag() 创建一个拖拽器(以下称为 `drag`)
// D3 提供了一个模块 d3-drag 实现拖拽相关的交互,具体参考官方文档 https://d3js.org/d3-drag
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-interact#拖拽
// 拖拽器是一个方法,它可以接收选择集作为参数 `drag(selection)`,为选择集中的元素添加相应的拖拽事件监听器
// 所以一般通过 selection.call(d3.drag()) 方法调用拖拽器创建函数
// 这样 selection 选择集就会作为参数传递给拖拽器创建函数
.call(d3.drag()
// 使用方法 drag.on(typenames, [listener]) 监听拖拽事件 typenames,并执行回调函数 listener
// 第一个参数 typenames 可以是 D3 提供了 3 种拖拽相关的自定义事件类型之一:start(拖拽开始时分发)、drag(拖拽进行中不断分发)、end(拖拽结束时分发)
// 第二个参数 listener 是事件处理函数,它依次接收 2 个参数:event 拖拽事件对象,d 当前被拖拽的元素所绑定的数据 datum
// 在事件处理函数内的 this 指向当前的元素,即 `<rect>` 矩形元素
// 这里分别监听 `start` 和 `drag` 事件
// 在拖拽开始时执行函数 dragstarted,而在拖拽期间不断执行函数 dragged 这些函数的具体实现和解读 👇 看下面
.on("start", dragstarted)
.on("drag", dragged));
// 在拖拽开始时执行函数 dragstarted
// 初始化 <path> 路径元素所绑定的数据
// 并触发函数 dragged(创建第一个数据点)
function dragstarted() {
// 使用比例尺的方法 xScale.ticks(count) 获取它对横坐标定义域范围的采样结果
// 返回结果是一个数组(作为刻度),其中元素的数量(刻度线的数量)是基于参数 count 进行调整的,以保证刻度的可读性
// 具体介绍可以参考官方文档 https://d3js.org/d3-scale/linear#linear_ticks
// 然后对数组进行遍历,将它的每个元素 x 转换为 `[x, null]` 二元数组的形式
// 💡 进行如此转换是因为二元数组的格式和数据点的横纵坐标值相兼容(数据点的 x 值来源/约束在于横坐标轴的刻度值,它是已知的;而 y 值由用户绘制生成,它是未知的,所以用 null 来替代/占位)
// 所以 path 所绑定的数据
data = xScale.ticks(xSamples).map(x => [x, null]);
// 使用方法 selection.property(name, value) 为选择集中的元素添加值为 value 名字为 name 的属性
// 这里为 <svg> 元素(svg 选择集中只有它一个元素)添加名为 value 的属性,其值为前面基于横坐标轴刻度生成的数据(一个数组)data
// 这是 Observable 平台的功能 ❓ 为了将数据点展示在 ☝️ 前面的 cell 里
svg.property("value", data);
// 为 <path> 元素(path 选择集中只有它一个元素)绑定数据
// 这里采用 selection.datum(value) 为选择集中的每个元素上绑定的数据(该选择集里只有一个 <path> 元素)
// ⚠️ 它与 selection.data(data) 不同,该方法不会将数组进行「拆解」,即这个方法不会进行数据链接计算并且不影响索引,不影响(不产生)enter 和 exit 选择集,而是将数据 value 作为一个整体绑定到选择的各个元素上,因此使用该方法选择集的所有 DOM 元素绑定的数据都一样
// 具体参考官方文档 https://d3js.org/d3-selection/joining#selection_datum 或 https://github.com/d3/d3-selection/tree/main#selection_datum
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/module-api/d3-module-selection#绑定数据
path.datum(data);
// 这里手动触发函数 dragged 这样就可以在按下鼠标时就创建一个数据点(作为折线的起始点)
// 因为如果等到(拖拽)鼠标移动时才开始创建数据点,折线的起始点可能就不在按下鼠标的地方,看起来就像是鼠标定位飘了 ❓
// 通过 func.call(context) 的形式来调用,以修改/设定它的上下文
// 将函数 dragged 的 this 指向当前函数的 this(当前回调函数的 this 是指向触发 `start` 事件的触发元素 `<rect>` 矩形)
dragged.call(this);
}
// 使用方法 d3.bisector(accessor) 创建一个数组分割器 bisector,它会基于特定值,将(有序)的数组里的元素一分为二
// 参数 accessor 是访问函数,在调用分割器对数组进行分割时,数组的每个元素都调用该访问函数,将返回值用于分割/比较时该元素的代表值
// bisector.center 将分割的标准设置为「临近分割」
// 关于分割器的介绍参考官方文档 https://d3js.org/d3-array/bisect#bisector
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-process#二元分割
// 这里对遍历的元素(一个二元数组)进行解构,基于它的第一个元素(它是横坐标值,时间)来比较
const bisectX = d3.bisector(([x]) => x).center;
// 在拖拽期间不断执行函数 dragged
// 该函数的参数是拖拽事件对象 event,这里对其进行解构 {x, y} 以得到拖拽目标(相对于其父容器,即 <svg> 元素)的横纵坐标
// 在这个实例中从视觉上没有看到拖拽的元素(因为拖拽的是隐形的 <rect> 元素),就可以理解为鼠标的横纵坐标值
function dragged({x, y}) {
// 通过比例尺的方法 `scale.invert()` 基于坐标值(值域的值),反过来得到定义域的相应值
const dx = xScale.invert(x);
const dy = yScale.invert(y);
// ⚠️ 但是并不会将每次捕获所得的鼠标坐标点都直接拿来绘制折线图(否则会让折线图看起来就和鼠标轨迹一样,小的曲曲折折特别多,而且波动/抖动频率很高 ❓ ),需要先对横坐标进行处理,以确保创建/修改的数据点都约束在横坐标轴的刻度线上
// 基于鼠标所对应的横坐标值 dx 对数据 data(一个有序的数组,包含了横坐标轴的刻度值)进行二元分割
// 返回值 i 是 data 数组的索引值,表示如果 dx 插入到该位置,依然保持数组有序
// 也可以理解为数组的第 i 个元素 data[i] 与 dx 值最接近
let i = bisectX(data, dx);
// 修改 data 的第 i 个元素,它是一个二元数组,其第二个元素表示当前数据点的 y 值,将其设置为鼠标的纵坐标值 dy
data[i][1] = dy;
// 假如用户的鼠标从横坐标的开头快速拖拽到结尾以绘制折线,但是速度过快而 drag 事件分发的速度可能跟不上,则会出现折线断连的情况(由于 data 数组中间的一些元素的 y 值没有被修改,依然是 null)
// 修补正好出现在鼠标所在位置(第 i 个元素)前面的一段折线「缺口」 (从左往右快速拖拽绘制折线时)
// 从索引值 i-1 开始向前遍历 data 的元素
for (let k = i - 1; k >= 0; --k) {
// 当遇到第一个 data[k][1] 不为 null(即这个第 k 个元素具有 y 值),进入「修补」程序
// 即从第 k+1 到第 i-1 个元素都是缺少 y 值的
if (data[k][1] != null) {
// 循环遍历第 k+1 个元素到第 i-1 个元素,将它们的 y 值都设置为 dy(假定与当前第 i 个元素的纵坐标一致)
// 其效果就是直接用一条水平直线来填补缺口
while (++k < i) data[k][1] = dy;
// 修补完成后跳出循环
break;
}
}
// 修补正好出现在鼠标所在位置(第 i 个元素)后面的一段折线「缺口」 (从右往左快速拖拽绘制折线时)
for (let k = i + 1; k < data.length; ++k) {
if (data[k][1] != null) {
while (--k > i) data[k][1] = dy;
break;
}
}
// 最后(用更新后的数据)重新绘制折线
// 调用线段生成器 line
// 将其返回的结果(字符串)作为 `<path>` 元素的属性 `d` 的值
path.attr("d", line);
svg.dispatch("input"); // 这里是 Observable 平台的功能 ❓ 将数据点展示在 ☝️ 前面的 cell 里
}
return svg.node();
}