Chapter 2. The Enter Selection(输入选择)(上)
示例1:创建地铁线路状态公告栏
闲言少叙,来看本书的第一个坑,还是大坑:重要概念 enter selection(输入选择)。
示例的场景很简单:根据清洗好的 JSON
数据源,罗列出纽约各个地铁线的状态信息,比如是否正常运行、是否计划运营等等;然后对不同的状态设置相应的 CSS 样式,效果图如下:
代码也挺简单的,顺带提到了一个 D3 惯用的链式调用风格(小瀑布式写法?(cascade)):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon">
<title>Ch2 - Example 1 (new) | Getting Started with D3</title>
</head>
<body>
<h2>Ch2 - Example 1 (new) | Building a Simple Subway Train Status Board</h2>
<script src="/demos/js/d3.js"></script>
<script>
function draw(data) {
"use strict";
// badass visualization code goes here
d3.select("body")
.append("ul")
.selectAll("li")
.data(data)
.enter()
.append("li")
.text(function (d) {
return d.name + ": " + d.status;
})
.style('font-weight', function(d) {
if(d.status == 'GOOD SERVICE') {
return 'normal';
} else {
return 'bold';
}
});
}
d3.json("/demos/data/service_status.json", draw);
</script>
</body>
</html>
更新到续写时的最新版(v6.7.0 + ES6)似乎更能满足我的强迫症:
const draw = data => {
'use strict';
// badass visualization code goes here
d3.select('body')
.append('ul')
.selectAll('li')
.data(data)
.join(enter => enter
.append('li')
.text(d => `${d.name}: ${d.status}`)
.style('font-weight', d =>
d.status[0] === 'GOOD SERVICE' ? 'normal' : 'bold'))
}
d3.json('/demos/data/service_status.json').then(draw).catch(console.error);
重读这一章,发现还漏掉一个非常实用的小技巧:将 D3 读取到的数据赋值给一个全局变量(如 d),这样就能在控制台查看诸如 selectAll('li')
、data(data)
、enter()
每一步的返回值,非常适合初学者理解 D3.js
的核心概念:
let d = null;
const draw = data => {
'use strict';
// badass visualization code goes here
d = data;
// ...
}
d3.json('/demos/data/service_status.json')
.then(draw).catch(console.error);
像这样逐步验证返回结果:(论熟读小字部分的重要性)
真正的坑,出现在作者对这段代码的讲解上。原谅我只凭书中的描述,难以领会作者的深意。后来才发现,必须访问书中提示栏(如下图所示)给出的 链接文章,并且再结合那篇文章末尾的补遗位置给出的 示例链接,才能完全搞明白 Mike 大神说的啥。
感兴趣的亲们可以去看看原文。要彻底搞懂本章标题中的 Enter Selection 为何物,必须结合一个重要配图:
链接文章说,D3 在绘制页面元素时,只需要告知其数据元素(状态信息)与作图元素(li
)间的对应关系即可,这在 D3 中称为【数据连接】(data join)。结合第 L12-15 行代码:
.selectAll('li')
.data(data)
.enter()
.append('li')
接下来是真正的关键信息:
.selectAll('li')
返回一个【空选中】(empty selection),这个【空选中】会与一个新的数据数组 连接 在一起,得到三个新 选中,对应上图那三个状态:enter(输入)、update(更新)、exit(退出)。鉴于刚才的【空选中】没有任何页面元素,update 和 exit 选中也是一个【空选中】;而左边那个 enter 选中,则包含了一组 占位符,每个 占位符 与后续即将渲染的各个数据元素一一对应。根据小册子中的解释,此时这些占位符都是空的,没有绑定任何数据;虽然是空的,但可以通过【空选中】的.data()
方法接收数据,即后面的第二行代码;.data(data)
返回上图中的 update 选中,此时 enter 与 exit 选中阻断了 update 选中的后续操作;.enter()
是 update 选中的方法,返回一个 enter 状态的选中(即本章标题中的 输入选择)。此时,各占位上已经有了带渲染的数据;.append('li')
是在 enter 选中上的方法,作用是将所有待渲染的作图元素li
,按数据源中的元素顺序,逐一追加渲染到页面的ul
容器元素内。
再配合后面两句设置,才有了最终的页面效果。
数据连接的设计初衷
代码倒是解释完了,新的问题又出来了:
为什么要绕这么大一圈呢?直接在
.data()
方法中搞定一切、或者直接给一个封装好的方法不好吗?
这篇由 D3 创始人亲自执笔的文章给出的解释是:
**The beauty of the data join is that it generalizes. **
翻译过来就是:数据连接的终极奥义,在于它是泛化的、通用的、普适的。(说了当没说)
结合后面的几个例子,才知道这样的设计不仅适合类似示例这样只考虑 enter 选中的小儿科型应用场景,也适合动态的、实时的数据可视化展示——
例1:动态增删元素
var circle = svg.selectAll("circle")
.data(data);
circle.exit().remove();
circle.enter().append("circle")
.attr("r", 2.5)
.merge(circle)
.attr("cx", d => d.x)
.attr("cy", d => d.y);
这段代码会重新选中页面 svg
元素内的所有 circle
元素。当新的数据集少于当前数据集,多出的部分会进入 exit 状态,进而被最终删除;反之,如果新数据集大于当前数据集,则新多出的部分会进入 enter 状态,进而渲染到页面;如果前后两个数据集一样大,则只执行数据对应位置的更新,不存在页面元素的增删环节。
例2:提升页面渲染性能
此外,从数据连接的角度考虑问题,还能使编码风格将更趋于声明式:只需要对 enter、update、exit 这三个状态实现交互、过渡、动画等各类可视化效果即可,省去了具体的 if
分支或 for
循环细节。比如,可以在 enter 阶段而非 update 阶段给 circle
元素的半径赋一个常量值,达到最小化 DOM 操作的效果,极大地提升页面渲染的性能。
circle.enter().append("circle")
.attr("r", 0)
例3:针对状态设置过渡
// Expand-in:
circle.enter()
.append("circle")
.attr("r", 0)
.transition()
.attr("r", 2.5);
// Shrink-out:
circle.exit()
.transition()
.attr("r", 0)
.remove();
例子倒是给出了不少,但对我这样的重度强迫症已弃疗者来说,百闻不如一见——没有见到实际的效果,总有点小遗憾。谁曾想,在文末的附录补遗的位置,看到了大神八年前留下的文末彩蛋(要不要这么心有灵犀啊😂😂😂)
不看不知道,一看吓一跳,这是一个带实时编辑预览功能的 D3 可视化实验室,用 GitHub 帐号就可以登录,叉取自己感兴趣的示例文章。8 年前的这篇 通用更新模式 已经在 2019 年 1 月 23 日被 Mike Bostock 大神更新到了 新文章 上,看得我那叫个过瘾啊。这不就是 D3.js
版的 字符跳动效果 的手把手教程吗。代码的简洁、工整、高效让人折服!不知道国内是否有这样的模拟环境,母语为英语的崽儿们简直太幸福了。。。
静态效果:
{
const svg = d3.create("svg")
.attr("width", width)
.attr("height", 33)
.attr("viewBox", `0 -20 ${width} 33`);
svg.selectAll("text")
.data(randomLetters())
.join("text")
.attr("x", (d, i) => i * 16)
.text(d => d);
return svg.node();
}
每 2.5 秒动态更新一次:
{
const svg = d3.create("svg")
.attr("width", width)
.attr("height", 33)
.attr("viewBox", `0 -20 ${width} 33`);
while (true) {
svg.selectAll("text")
.data(randomLetters())
.join("text")
.attr("x", (d, i) => i * 16)
.text(d => d);
yield svg.node();
await Promises.tick(2500);
}
}
.join()
还可以接收三个回调函数,分别对应 enter、update、exit 三个选中状态,例如:
动态字符跳动效果:
{
function randomLetters() {
return d3.shuffle("abcdefghijklmnopqrstuvwxyz".split(""))
.slice(0, Math.floor(6 + Math.random() * 20))
.sort();
}
const svg = d3.create("svg")
.attr("width", width)
.attr("height", 33)
.attr("viewBox", `0 -20 ${width} 33`);
while (true) {
const t = svg.transition()
.duration(750);
svg.selectAll("text")
.data(randomLetters(), d => d)
.join(
enter => enter.append("text")
.attr("fill", "green")
.attr("x", (d, i) => i * 16)
.attr("y", -30)
.text(d => d)
.call(enter => enter.transition(t)
.attr("y", 0)),
update => update
.attr("fill", "black")
.attr("y", 0)
.call(update => update.transition(t)
.attr("x", (d, i) => i * 16)),
exit => exit
.attr("fill", "brown")
.call(exit => exit.transition(t)
.attr("y", 30)
.remove())
);
yield svg.node();
await Promises.tick(2500);
}
}
最后一个比较有意思的地方,是随机英文字母序列的实现,用到了 d3.shuffle(array)
:
function randomLetters() {
return d3.shuffle("abcdefghijklmnopqrstuvwxyz".split(""))
.slice(0, Math.floor(6 + Math.random() * 20))
.sort();
}
也可以用 ES6 的 Array.from()
实现:
const randomLetters = () => d3.shuffle(Array.from({length: 26}, (e, i) =>
String.fromCharCode(i + 97)))
.slice(0, Math.floor(6 + Math.random() * 20))
.sort();
示例2:绘制露天广场的日平均流量
(未完待续)