Skip to content

Commit

Permalink
add zoomable area chart
Browse files Browse the repository at this point in the history
  • Loading branch information
Benbinbin committed Jul 2, 2024
1 parent 9edbb32 commit 44eb884
Show file tree
Hide file tree
Showing 4 changed files with 300 additions and 1 deletion.
248 changes: 248 additions & 0 deletions areachart/zoomable-areachart/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
// 参考自 https://observablehq.com/@d3/zoomable-area-chart

/**
*
* 构建 svg
*
*/
const container = document.getElementById("container"); // 图像的容器

// 获取尺寸大小
const width = container.clientWidth; // 宽度
const height = container.clientHeight; // 高度
// margin 为前缀的参数
// 其作用是在 svg 的外周留白,构建一个显示的安全区,以便在四周显示坐标轴
const marginTop = 20;
const marginRight = 30;
const marginBottom = 30;
const marginLeft = 40;

// 创建 svg
// 在容器 <div id="container"> 元素内创建一个 SVG 元素
// 返回一个选择集,只有 svg 一个元素
const svg = d3
.select("#container")
.append("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height]);

/**
*
* 异步获取数据
* 再在回调函数中执行绘制操作
*
*/
// 数据来源网页 https://observablehq.com/@d3/zoomable-area-chart 的文件附件
const dataURL =
"https://gist.githubusercontent.com/Benbinbin/67028502b64dcdc17739d44154c883cb/raw/992ab81ed14e96ece84da56574b08297c77f3540/flights.csv";

d3.csv(dataURL, d3.autoType).then((data) => {
// 需要检查一下数据解析的结果,可能并不正确,需要在后面的步骤里再进行相应的处理
console.log(data);

/**
*
* 构建比例尺
*
*/
// 设置横坐标轴的比例尺
// 横坐标轴的数据是日期(时间),使用 d3.scaleUtc 构建一个时间比例尺(连续型比例尺的一种)
// 该时间比例尺采用协调世界时 UTC,处于不同时区的用户也会显示同样的时间
// 具体可以参考官方文档 https://d3js.org/d3-scale/time
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-scale#时间比例尺-time-scales
const x = d3.scaleUtc()
// 设置定义域范围
// 从数据集的每个数据点中提取出日期(时间),并用 d3.extent() 计算出它的范围
.domain(d3.extent(data, d => d.date))
// 设置值域范围(所映射的可视元素)
// svg 元素的宽度(减去留白区域)
.range([marginLeft, width - marginRight]);

// 设置纵坐标轴的比例尺
// 纵坐标轴的数据是连续型的数值(航班次数),使用 d3.scaleLinear 构建一个线性比例尺
const y = d3.scaleLinear()
// 设置定义域范围
// [0, ymax] 其中 ymax 是航班次数的最大值
// 通过 d3.max(data, d => d.value) 从数据集中获取航班次数的最大值
// 并使用 continuous.nice() 方法编辑定义域的范围,通过四舍五入使其两端的值更「整齐」nice
// 具体参考官方文档 https://github.com/d3/d3-scale#continuous_nice
.domain([0, d3.max(data, d => d.value)]).nice()
// 设置值域范围
// svg 元素的高度(减去留白区域)
.range([height - marginBottom, marginTop]);

/**
*
* 绘制坐标轴
*
*/
// 变量 xAxis 是一个函数,它接受两个参数
// * g 是容器元素(横坐标轴在该元素里渲染)
// * x 是横坐标比例尺
// 💡 该函数最终返回一个横坐标轴对象
// 💡 在初始化时(或缩放时),调用该函数以绘制(或更新)横坐标轴
const xAxis = (g, x) => g
// 横轴是一个刻度值朝下的坐标轴
// 通过 axis.ticks(count) 设置刻度数量的参考值(避免刻度过多导致刻度值重叠而影响图表的可读性)
// 而且将坐标轴的外侧刻度 tickSizeOuter 长度设置为 0(即取消坐标轴首尾两端的刻度)
.call(d3.axisBottom(x).ticks(width / 80).tickSizeOuter(0))

// 绘制横坐标轴
// 变量 gx 是一个选择集,里面包含一个元素 <g>,它是横坐标轴的容器
const gx = svg.append("g")
// 通过设置 CSS 的 transform 属性将横坐标轴容器「移动」到底部
.attr("transform", `translate(0,${height - marginBottom})`)
// 通过 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#其他方法
.call(xAxis, x);

// 绘制纵坐标轴
svg.append("g")
// 通过设置 CSS 的 transform 属性将纵向坐标轴容器「移动」到左侧
.attr("transform", `translate(${marginLeft},0)`)
// 纵轴是一个刻度值朝左的坐标轴
// 并使用坐标轴对象的方法 axis.ticks() 设置坐标轴的刻度数量和刻度值格式
// 具体参考官方文档 https://d3js.org/d3-axis#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.select(".domain").remove())
// 为纵坐标轴添加标注信息
// 并选中最后一个刻度值,即 <text> 元素,进行复制
.call(g => g.select(".tick:last-of-type text").clone()
.attr("x", 3) // 设置元素的偏移量
.attr("text-anchor", "start") // 设置文字的对齐方式
.attr("font-weight", "bold") // 设置字体大小
.text("Flights")); // 设置文本内容

/**
*
* 绘制面积图内的面积形状
*
*/
// 变量 area 是一个函数,它接受两个参数
// * data 数据集(用于绘制面积图)
// * x 横坐标比例尺
// 💡 该函数会使用更新后的横坐标比例尺 x 构建面积生成器
// 💡 然后调用该面积生成器(传递数据集 data)最终返回的结果是字符串,可作为 `<path>` 元素的属性 `d` 的值
// 💡 在初始化时(或缩放时),调用该函数以绘制(或更新)面积形状
// 使用 d3.area() 创建一个面积生成器
// 面积生成器会基于给定的数据生成面积形状
// 调用面积生成器时返回的结果,会基于生成器是否设置了画布上下文 context 而不同。如果设置了画布上下文 context,则生成一系列在画布上绘制路径的方法,通过调用它们可以将路径绘制到画布上;如果没有设置画布上下文 context,则生成字符串,可以作为 `<path>` 元素的属性 `d` 的值
// 具体可以参考官方文档 https://d3js.org/d3-shape/area
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-shape#面积生成器-areas
const area = (data, x) => d3.area()
// 设置两点之间的曲线插值器,这里使用 D3 所提供的一种内置曲线插值器 d3.curveStepAfter
// 该插值效果是在两个数据点之间,生成阶梯形状的线段(作为面积图的边界)
// 具体效果参考 https://d3js.org/d3-shape/curve#curveStepAfter
.curve(d3.curveStepAfter)
// 设置下边界线横坐标读取函数
// 该函数会在调用面积生成器时,为数组中的每一个元素都执行一次,以返回该数据所对应的横坐标
// 这里基于每个数据点的日期(时间)d.date 并采用比例尺 x 进行映射,计算出相应的横坐标
.x(d => x(d.date))
// 设置下边界线的纵坐标的读取函数
// 这里的面积图的下边界线是横坐标轴,所以它的 y 值始终是 0,并采用比例尺 y 进行映射,得到纵坐标轴在 svg 中的坐标位置
.y0(y(0))
// 设置上边界线的纵坐标的读取函数
.y1(d => y(d.value))
// 调用面积生成器(传递数据集 data)
// 由于最之前面积生成器并没有调用方法 area.context(parentDOM) 设置画布上下文
// 所以调用面积生成器 area(aapl) 返回的结果是字符串
// 该值作为 `<path>` 元素的属性 `d` 的值
(data);

// 创建一个 identifier 唯一标识符(字符串)
// 它会作为一些 <clipPath> 元素的 id 属性值(方便其他元素基于 id 来使用),以避免与其他元素发生冲突
// 💡 在参考的 Observable Notebook 使用了平台的标准库所提供的方法 DOM.uid(namespace) 创建一个唯一 ID 号
// 💡 具体参考官方文档 https://observablehq.com/documentation/misc/standard-library#dom-uid-name
// 💡 方法 DOM.uid() 的具体实现可参考源码 https://github.com/observablehq/stdlib/blob/main/src/dom/uid.js
// const clip = DOM.uid("clip");
// 这里使用硬编码(手动指定)id 值
const clipId = "clipId";

// 创建一个元素 <clipPath> (一般具有属性 id 以便被其他元素引用)路径剪裁遮罩,其作用充当一层剪贴蒙版,具体形状由其包含的元素决定
// 💡 它不会直接在页面渲染出图形,而是被其他元素(通过设置属性 clip-path)引用的方式来起作用,为其他元素自定义了视口
// 这里在 <clipPath> 内部添加了一个 <rect> 设置剪裁路径的形状,以约束面积图容器的可视区域
// 则放大面积图时,超出其容器的部分就不会显示(避免遮挡坐标轴)
svg.append("clipPath")
// 为 <clipPath> 设置属性 id
.attr("id", clipId)
// 在其中添加 <rect> 子元素,以设置剪切路径的形状
.append("rect")
// 设置矩形的定位和尺寸
.attr("x", marginLeft) // 设置该元素的左上角的横坐标值(距离 svg 左侧 marginLeft 个像素大小)
.attr("y", marginTop) // 设置该元素的左上角的纵坐标值(距离 svg 顶部 marginTop 个像素大小)
.attr("width", width - marginLeft - marginRight) // 设置宽度(采用 svg 的宽度,并减去左右留白区域)
.attr("height", height - marginTop - marginBottom); // 设置宽度(采用 svg 的高度,并减去上下留白区域)

// 将面积形状绘制到页面上
// 变量 path 是一个选择集,里面包含一个元素 <path>,它是绘制面积形状的元素
const path = svg.append("path") // 使用路径 <path> 元素绘制面积形状
// 设置属性 clip-path 以采用前面预设的 <clipPath> 元素对图形进行裁剪/约束
.attr("clip-path", clipId)
.attr("fill", "steelblue") // 将面积的填充颜色设置为蓝色
// 调用函数 area(data, x) 返回的结果是字符串,作为 `<path>` 元素的属性 `d` 的值
.attr("d", area(data, x));

/**
*
* 缩放交互
*
*/
// 缩放事件的回调函数
// 当缩放时,需要更新横坐标轴比例尺,并重绘面积图
// 其中参数 event 是 D3 的缩放事件对象
// 该缩放事件对象的属性 transform 包含当前的缩放变换值,还提供一些方法用于操作缩放
function zoomed(event) {
// 调用方法 transform.rescaleX(x) 更新横轴轴比例尺
// 返回一个定义域经过缩放变换的比例尺(这样映射关系就会相应的改变,会考虑上缩放变换对象 transform 的缩放比例)
// 💡 关于方法 transform.rescaleX(x) 的介绍可以参考这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-interact#缩放变换对象的方法
const xz = event.transform.rescaleX(x);
// 调用函数 area(data, xz) 返回的结果是字符串,更新变量 path(它是一个选择集,里面包含一个 <path> 元素)的属性 d
// 其中参数 xz 是更新后的的横坐标比例尺
path.attr("d", area(data, xz));
// 使用新的比例尺重新绘制横坐标轴
gx.call(xAxis, xz);
}

// 创建缩放器
const zoom = d3.zoom()
// 约束缩放比例的范围,默认值是 [0, ∞]
// 入参是一个数组 [1, 32] 表示最小的缩放比例是 1 倍,最大的缩放比例是 8 倍
.scaleExtent([1, 32])
// 缩放器除了可以缩放,还可以进行平移,以下两个方法分别设置与平移相关参数
// 设置视图范围 viewport extent
// 入参是一个嵌套数组,第一个元素是面积图的矩形区域的左上角,第二个元素是右下角
// 如果缩放器绑定的是 svg,则视图范围 viewport extent 默认是 viewBox
// 这里「校正」为用于绘制面积图的区域大小(不包含 margin 的区域)
.extent([[marginLeft, 0], [width - marginRight, height]])
// 约束平移的范围 translate extent,默认值是 [[-∞, -∞], [+∞, +∞]]
// 这里设置平移的范围:最左侧为面积图的左边;最右侧为面积图的右边(最上方和最下方的范围虽然是无限的,但是这里只会进行水平缩放,所以也只可能进行水平移动,并不能进行上下移动)
// 所以即使放大后,画布也只能在面积图的最左边和最右边之间来回移动
.translateExtent([[marginLeft, -Infinity], [width - marginRight, Infinity]])
.on("zoom", zoomed); // 缩放事件的回调函数
// 🔎 以上提及的视图范围 viewport extent 和平移范围 translate extent 这两个概念,具体可以查看 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-interact

// 为 svg 添加缩放事件监听器
svg.call(zoom)
// 通过 selection.transition() 创建过渡管理器
// 💡 这样(从无缩放状态)切换到初始缩放状态时,就可以有过渡动效
.transition()
.duration(750) // 设置过渡持续时间
// 设置初始缩放状态
// 💡 transition.call(function[, arguments…]) 执行一次函数 function 它其实和ff selection.call() 方法类似
// 💡 而且将过渡管理器作为第一个入参传递给 function,而其他传入的参数 arguments... 同样传给 function
// 💡 最后返回当前过渡管理器,这样是为了便于后续进行链式调用
// 具体参考官方文档 https://d3js.org/d3-transition/control-flow#transition_call
// 或这一篇文档 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-transition#过渡参数配置
// 使用方法 zoom.scaleTo(selection, k[, p]) 对选择集的元素进行缩放操作,并将缩放比例设置为 k
// 第三个参数 p 是构建平滑的缩放过渡的参照点,默认为视图的中点,该参考点在缩放过程中不会发生移动
// 这里将初始状态设置为放大 4 倍,过渡参考点是设置为横坐标轴上的一个点(日期 Date.UTC(2001, 8, 1) 所对应的位置),也是靠近中间的位置
.call(zoom.scaleTo, 4, [x(Date.UTC(2001, 8, 1)), 0]);
});
42 changes: 42 additions & 0 deletions areachart/zoomable-areachart/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zoomable Area Chart</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
svg {
border: 2px solid orange;
}

#container {
width: 100%;
height: 70vh;
}
</style>
</head>

<body>
<div class="mx-auto my-6 text-center">
<h1 class="py-8 text-2xl font-bold">可缩放的面积图</h1>
<p class="py-2">时间序列面积图展示了每天航班数量(从 1988-01-01 至 2008-12-31)</p>
<p class="py-2">观察可视化图形可知 2001 年 9 月 11 日的袭击事件对于航空旅行的(负面)影响十分明显</p>
<div class="py-2 text-sm flex flex-wrap justify-center items-center gap-4">
<a href="../../index.html"
class="text-blue-500 hover:text-blue-600 underline transition-colors duration-300">首页</a>
<a href="https://observablehq.com/@benbinbin/zoomable-area-chart" target="_blank"
class="text-blue-500 hover:text-blue-600 underline transition-colors duration-300">参考</a>
<a href="https://github.com/Benbinbin/d3-learning/tree/main/areachart/zoomable-areachart/" target="_blank"
class="text-blue-500 hover:text-blue-600 underline transition-colors duration-300">代码</a>
<a href="https://datavis-note.benbinbin.com/article/d3/chart-example/d3-chart-example-area-chart#可缩放的面积图"
target="_blank" class="text-blue-500 hover:text-blue-600 underline transition-colors duration-300">笔记</a>
</div>
</div>
<div id="container" class="flex justify-center items-center"></div>
<script src="app.js"></script>
</body>

</html>
5 changes: 4 additions & 1 deletion barchart/zoomable-barchart/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ function zoom(svg) {
// 传入的是原始的横坐标 d 通过缩放变换对象处理,返回变换后的坐标
// 所以 [xmin, xmax].map(d => event.transform.applyX(d)) 是基于原来的横轴值域,求出缩放变换后的新值域
// 然后修改横坐标轴的比例尺的值域 x.range([newXmin, newXmax])
// 💡 缩放时,值域与定义域的映射关系就改变了(页面上原来的某个位置对应于某个数据量的关系不成立了),需要更新比例尺,可以考虑改变值域(这里就是手动改变值域),也可以考虑改变定义域
// 💡 其实 D3 提供了更简单的方法 transform.rescaleX(x) 或 transform.rescaleY(y)(通过改变定义域)更新比例尺
// 💡 关于方法 transform.rescaleX(x) 或 transform.rescaleY(y) 的介绍可以参考这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-interact#缩放变换对象的方法
x.range(
[margin.left, width - margin.right].map((d) => event.transform.applyX(d))
);
// s使用新的比例尺调整条形图的柱子的定位(通过改变柱子的左上角的 x 值)
// 使用新的比例尺调整条形图的柱子的定位(通过改变柱子的左上角的 x 值)
// 以及调整条形图的柱子的宽度,通过新的比例尺 x.bandwidth() 获取
svg
.selectAll(".bars rect")
Expand Down
6 changes: 6 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,12 @@ <h3 class="pb-4 text-xl font-bold text-gray-700">{{ example.name }}</h3>
folder: 'horizon-chart',
note: 'https://datavis-note.benbinbin.com/article/d3/chart-example/d3-chart-example-area-chart#地平线图'
},
{
name: '可缩放的面积图',
reference: 'https://observablehq.com/@benbinbin/zoomable-area-chart',
folder: 'zoomable-areachart',
note: 'https://datavis-note.benbinbin.com/article/d3/chart-example/d3-chart-example-area-chart#可缩放的面积图'
},
]
},
{
Expand Down

0 comments on commit 44eb884

Please sign in to comment.