可视化的经典著作
如上一篇聊ECharts时所说,用ECharts只回答了HOW的问题,并没有回答WHY的问题。要回答WHY的问题,我们就需要有理论来指引了。
可视化的一本经典之作就是:《The Grammar of Graphics》(《图形语法》)这本书了:

此书作为经典,指引了很多图形库的设计。当然对于值得我们尊敬的经典著作,我肯定不期望能在一篇公众号的文章里就能描述清楚,还请有志于深入研究可视化的朋友们自行研读。
本文主要是从更实用的角度,用另一个非常著名的 D3 可视化JavaScript库来让大家体验一下可视化的魅力。
D3
D3,全名是:Data-Driven Documents,项目地址在:https://d3js.org/

项目的历史我就不再赘述了,大家可以自行搜索 D3 (也可以顺便了解一下它的创建者 Mike Bostock https://bost.ocks.org/mike/)
D3的特点:
- 表达能力非常强大
- 非常底层(如果类比编程语言的话, D3可以算是C语言,SVG是汇编语言)
由于其太底层又博大精深,所以比较难在文章中写清。所以,我也犹豫了很久要不要写D3,不过考虑到D3在可视化领域的“泰山北斗”地位,如果写可视化而不写D3,那将会是不完整的。So, 让我们开始D3之旅。
Step 0. 准备工作
本文中的例子是和前文ECharts中同样的例子:”世界人口总量“在2011年和2012年的对比。方便大家来体会EChart和D3的不同。
建立一个 d3.html 文件,内容如下:
<!DOCTYPE html>
<html
lang="en">
<head>
<meta
charset="UTF-8">
<title>D3 Demo</title>
<script
src="https://d3js.org/d3.v5.js"></script>
</head>
<body>
<svg></svg>
</body>
<script>
</script>
</html>
后续的所有D3代码将动态修改 <svg></svg>
中的内容
Step 1. 获取数据
首先,我们把数据(前文ECharts中的例子)存为csv文件,作为样例数据
yearcountrypopulation2011巴西182032012巴西193252011印尼234892012印尼234382011美国290342012美国310002011印度1049702012印度1215942011中国1317442012中国1341412011世界人口6302302012世界人口681807
接下来, 我们通过 d3.csv
函数来获取和解析CSV中的数据,以供后续使用。
由于CSV中,默认每个值都是文本类型,而我们知道 year 和 population 是数值类型,那么我们可以在 d3.csv
的第二个参数(回调函数)中做类型转化:
let dataset = await(d3.csv("d3data.csv",
function(d)
{
return
{
year:
+d.year,
country: d.country,
population:
+d.population
};
}));
获取到如下方便 D3 操作的数据:
[
{"year":
2011,
"country":
"巴西",
"population":
18203},
{"year":
2012,
"country":
"巴西",
"population":
19325},
{"year":
2011,
"country":
"印尼",
"population":
23489},
{"year":
2012,
"country":
"印尼",
"population":
23438},
{"year":
2011,
"country":
"美国",
"population":
29034},
{"year":
2012,
"country":
"美国",
"population":
31000},
{"year":
2011,
"country":
"印度",
"population":
104970},
{"year":
2012,
"country":
"印度",
"population":
121594},
{"year":
2011,
"country":
"中国",
"population":
131744},
{"year":
2012,
"country":
"中国",
"population":
134141},
{"year":
2011,
"country":
"世界人口",
"population":
630230},
{"year":
2012,
"country":
"世界人口",
"population":
681807}
]
Step 2. 数据转化
《图形语法》一书中提到了三种数据转化操作:
- cross:有点类似SQL中的笛卡尔积
- blend:有点类似SQL中的Union
- nest:有点类似SQL中的Group By,但是不一样
本例中,由于本例中数据是单表,我们只需要用 nest 操作,D3中的相关文档:https://github.com/d3/d3-collection/blob/v1.0.7/README.md#nest
我们先来看一下简单的对国家做 nest 操作会得到什么结果?
d3.nest()
.key(d => d.country)
.entries(dataset)
得到结果:
[
{
"key":
"巴西",
"values":
[
{"year":
2011,
"country":
"巴西",
"population":
18203},
{"year":
2012,
"country":
"巴西",
"population":
19325}
]
},
{
"key":
"印尼",
"values":
[
{"year":
2011,
"country":
"印尼",
"population":
23489},
{"year":
2012,
"country":
"印尼",
"population":
23438}
]
},
{
"key":
"美国",
"values":
[
{"year":
2011,
"country":
"美国",
"population":
29034},
{"year":
2012,
"country":
"美国",
"population":
31000}
]
},
{
"key":
"印度",
"values":
[
{"year":
2011,
"country":
"印度",
"population":
104970},
{"year":
2012,
"country":
"印度",
"population":
121594}
]
},
{
"key":
"中国",
"values":
[
{"year":
2011,
"country":
"中国",
"population":
131744},
{"year":
2012,
"country":
"中国",
"population":
134141}
]
},
{
"key":
"世界人口",
"values":
[
{"year":
2011,
"country":
"世界人口",
"population":
630230},
{"year":
2012,
"country":
"世界人口",
"population":
681807}
]
}
]
我们可以看出经过简单的 nest + entries, 我们会把原始数组,根据key拆分为多个子数组。
我们第一个例子先做一个单柱图,所以,我们需要的数据是:各个国家两年内的平均人口数。我们接下来用nest的 rollup 子操作:
let meanDataset = d3.nest()
.key(d => d.country)
.rollup(d => d3.mean(d.map(x => x.population)))
.entries(dataset)
得到如下结果,并存为 meanDataset
[
{"key":
"巴西",
"value":
18764},
{"key":
"印尼",
"value":
23463.5},
{"key":
"美国",
"value":
30017},
{"key":
"印度",
"value":
113282},
{"key":
"中国",
"value":
132942.5},
{"key":
"世界人口",
"value":
656018.5}
]
Step 3. D3操作SVG
数据有了,我们现在要开始考虑怎么在图中画出来了
D3可以操作各种 HTML Element,当然对于图形来说,最简单的是操作 SVG 元素。
SVG是一种矢量图形标准,它的英文全称为Scalable Vector Graphics,是W3C定义的标准。
SVG比较需要注意的是其坐标系,是从左上角为 (0,0) 原点的,

比如:我们现在SVG中画个圆,那么我们可以这样写:
<svg
width="400px"
height="300px">
<circle
cx=50
cy=50
r=50
style='fill:#3EB9BA'>
</circle>
</svg>
将在 (50,50) 这个坐标为圆心,画一个半径为50的圆,并把圆的填充颜色设置为好看的“观远”色
D3 操作 HTML Elements 主要是 d3-selection 子项目,比如:d3.select 和 d3.selectAll, 可以看文档:https://github.com/d3/d3-selection/blob/v1.4.1/README.md#select
我们用D3来实现往之前 d3.html 中预留的 <svg></svg>
节点添加一个圆形的代码如下:
let width =
400
let height =
300
let svg = d3.select("svg")
.attr("width", width)
.attr("height", height)
svg.append("circle")
.attr("cx",
50)
.attr("cy",
50)
.attr("r",
50)
.style("fill",
"#3EB9BA")
d3.select("svg")
是通过 前端的 CSSSelector
来选择对应的HTML Element,结果和手工写SVG是一样的:
Step 4. 使用比例尺 (Scale)来映射实际数据到屏幕坐标
Step3中,我们可以操作简单的SVG元素了,但是毕竟我们要做的图不是随便拼接起来的,而是要和实际数据有关的统计图形,那么我们需要比例尺(Scale)的支持。
在《图形语法》书中,有一章来讲 Scales,对于D3来说,也有很多API是和比例尺相关。
比例尺的作用是什么呢?
比如:我们有了数据meanDataset
[
{"key":
"巴西",
"value":
18764},
{"key":
"印尼",
"value":
23463.5},
{"key":
"美国",
"value":
30017},
{"key":
"印度",
"value":
113282},
{"key":
"中国",
"value":
132942.5},
{"key":
"世界人口",
"value":
656018.5}
]
我们想要做一个“垂直单柱图”,那么我们的X轴是“国家”,Y轴是“人口数”。首先我们遇到的第一个问题是:我们如何把各个数据转化为SVG画布上的坐标。这个就是比例尺(Scale)要解决的问题。
因为数据是有不同类别的,比如:类目(category),数值,时间(time)等,所以,我们也需要不同的比例尺。D3中提供了丰富的比例尺,具体的可以参考D3文档。
- 注:《图形语法》书中把数据类型分为:Nominal,Ordinal,Interval,Ratio。但是解释有点难,所以这里就简单的用类目,数值,时间等来分
Scale都有两个概念:
- domain:就是该维度的真实值的范围
- range:映射到画布展示上时的坐标范围
概念有点抽象,请看下面实际例子中的应用
Step 4.1 X轴比例尺
X轴上是国家名,是离散的值,并且我们需要画的是柱图,那么我们可以用比例尺:d3.scaleBand
, 参考文档:https://github.com/d3/d3-scale/blob/v2.2.2/README.md#scaleBand
let xScale = d3.scaleBand()
.domain(['巴西',
'印尼',
'美国',
'印度',
'中国',
'世界人口'])
.range([0, width])
.padding(0.1)
解读:
- domain 中直接写了6个国家的名字,主要是为了看的更清楚,实际中可以通过:
meanDataset.map(d=>d.key)
来获得该结果 - range: 因为我们的SVG的大小是:宽400像素,高300像素,所以X轴是:[0, 400]
- 额外设置了 10% 的空白间隔(padding),是为了避免柱子都紧挨在一起
我们用如下程序来打印一下各个国家展示在X轴的坐标:
console.log('每根柱子的宽度为:'
+ xScale.bandwidth())
['巴西',
'印尼',
'美国',
'印度',
'中国',
'世界人口'].forEach(
function(country)
{
console.log(''
+ country +
' 的x轴开始位置是: '
+ xScale(country));
}
)
得到如下结果:
每根柱子的宽度为:
59.01639344262295
巴西
的x轴开始位置是:
6.557377049180332
印尼
的x轴开始位置是:
72.1311475409836
美国
的x轴开始位置是:
137.70491803278688
印度
的x轴开始位置是:
203.27868852459017
中国
的x轴开始位置是:
268.8524590163934
世界人口
的x轴开始位置是:
334.4262295081967
Step 4.2 Y轴比例尺
Y轴是各个国家的人口数,是连续的数值类型,我们可以用比例尺:d3.scaleLinear
, 参考文档:https://github.com/d3/d3-scale/blob/v2.2.2/README.md#scaleLinear
let yScale = d3.scaleLinear()
.domain([0, d3.max(meanDataset.map(d => d.value))]).nice()
.range([height,
0])
解读:
- domain中是一个数值范围,因为我们需要从0开始,所以这里是写了 [0, 656018.5]
- domain后面又额外的调用了 nice(), 主要是因为对于实际场景的中数值,往往其最大值并不是一个比较恰好的整数,如果调用nice做处理后,将会由 [0, 656018.5] 变为:[0, 700000] 会看着更好些
- range:因为我们要映射到高度为 300像素的SVG中, 这里需要额外注意的是: 范围是: [300, 0], 而不是 [0, 300], 这个主要是SVG的Y轴坐标是从上往下增加的,而我们的柱图的”坐标轴标记“(Axis)却是从下向上的,如果这里设置为:[0, 300], 后面的"yAxis”展示会是反的。
Step 5. 开始画柱图的柱子
做了这么多的准备后,我们终于可以开始画柱子了!
svg.selectAll("rect")
.data(meanDataset)
.join("rect")
.attr("x", d => xScale(d.key))
.attr("y", d => yScale(d.value))
.attr("height", d => height - yScale(d.value))
.attr("width", xScale.bandwidth())
.style("fill",
"#3EB9BA")
解读:
1). 这里的 data() 是从图中 SVG 父节点选择出所有的 rect(SVG矩形),注意最开始选择时这个是空但是没关系。data()作用就是把数据中数组的每一行和一个 rect图形进行绑定。
2). join 操作是 D3 的最新版本 (版本5.x) 中引入的新操作,主要是为了简化数据绑定,在旧的版本中,D3的数据绑定有3个概念
- enter: 新的数据到来,之前没有绑定到图形上
- update: 新的数据之前已经绑定到图形上
- exit:之前绑定到图形上的数据, 已经不存在了 (需要图形更新去掉之前存在的)
join操作简化了这些操作(当然,仍然能把 enter, update, exit 三个函数当成参数传给 join (所以,自行了解D3的 enter,update,exit 概念还是很重要的)。但是本例子中,只是用静态数据画图,而不涉及更新等操作,所以,就可以简单的 join('rect')
3). 对于 SVG rect 矩形所具有的属性:x,y,height, width 进行赋值,这里,一方面可以直接赋值数值,另一方面也可以传入一些函数。这些函数的声明类似于:
function(d, i)
{
return xScale(some_function(d));
}
这里:
- 传入的第一个参数是对于需要绑定到当前 rect 的数据行。比如:对于我们的例子,我们是绑定 每个 rect 到 meanDataset 的每行,那么,这里的 d 就是
{"key":"巴西","value":18764} - 传入的第二个参数是当前元素在 meanDataset 的数组中的索引, 第一rect对应的就是 0
4). 默认的颜色是黑色,所以别忘了设置矩形的颜色为好看的“观远”色
获得图形:

Step 6. 增加坐标轴支持
虽然说D3是相对底层的图形库,但是它提供了大量丰富的图形相关的算法支持,比如:坐标轴(Axis)的支持,其中封装了很多操作,比如:
- 根据指定的比例尺(Scale),该划分多少个tick (标度)
- 每个刻度上的显示文本是什么
- 以及一些常见的坐标轴的可配置项等
可以从 https://github.com/d3/d3-axis/tree/v1.0.12 看起:
- d3.axisTop - 顶部坐标轴
- d3.axisRight - 右侧坐标轴
- d3.axisBottom - 底部坐标轴
- d3.axisLeft - 左侧坐标轴
简单调用一下,并绑定到之前的X轴和Y轴的比例尺上,
svg.append("g")
.call(d3.axisBottom(xScale))
svg.append("g")
.call(d3.axisLeft(yScale).ticks(null,
null))
得到图形:

我们发现了如下问题:
- 对于底部坐标轴,默认展示是y轴上放在了 0 的位置(最上面)
- 对于左侧坐标轴,因为刻度tick和文字都是放在左侧的,而我们的坐标轴放在了 X轴为 0 的位置,所以,tick和文字都被隐藏了。
为了解决第一个问题,我们需要使用SVG的 transform 转化, SVG的transform有如下操作:
- translate:平移
- scale:放大/缩小
- rotate:旋转
- skewX & skewY:倾斜
对于问题1,我们可以使用 translate 平移到底部来解决。(但是如果移到图形的底部,就也会有第2个问题,坐标轴的刻度文字将被隐藏)
为了解决第二个问题,一般有两种解法:
- 首先预先定义上下左右的空闲位置大小 (margin),然后在计算各个矩形坐标,坐标轴位置等,考虑到这些margin空白的大小。一般会采用这个方案,但是对于本例子中,就意味着之前的代码很多地方都要改过。
- 另一种比较偷懒一点的方法是使用 SVG 的 viewBox,指定看到的坐标范围,并调大画布SVG大小。
这里用第二种方案来演示一下,比如:之前我们的SVG是宽400,高300的图形,对于:
viewBox ( min-x, min-y, width, height )
我们可以使用 (-50, -15, 430, 345) 作为 viewBox,那么之前的原点 (0, 0) 在新的显示下将不在是在屏幕最左上角,而是向“东偏南”方向移动了一点。另外,我们也可以额外增大 SVG 元素的大小为:宽度 (50 + 430),高度(15 + 345), 变为: 480 * 360,以避免图像被缩小。
修改代码如下:
svg = svg
.attr("width", width +
80)
.attr("height", height +
60)
.attr("viewBox",
`-50
-15 ${width +
30} ${height +
45}`)
svg.append("g")
.attr("transform",
`translate(0,${height})`)
.call(d3.axisBottom(xScale))
svg.append("g")
.call(d3.axisLeft(yScale).ticks(null,
null))
获得图形:

Step 7. 扩展颜色支持
单调颜色的世界是不完美的,我们可以加上颜色支持。D3预制了不少颜色模式,可以参考:Color Schemes (d3-scale-chromatic):https://github.com/d3/d3-scale-chromatic/tree/v1.5.0 对于不同类型的数据,有不同的颜色模式。
我们这里用和 Tableau 10 中预制的颜色模式:d3.schemeTableau10,来对每个矩形标色
svg.selectAll("rect")
.data(meanDataset)
.join("rect")
.attr("x", d => xScale(d.key))
.attr("y", d => yScale(d.value))
.attr("height", d => height - yScale(d.value))
.attr("width", xScale.bandwidth())
// .style("fill", "#3EB9BA")
.style("fill",
(d, i)
=> d3.schemeTableau10[i %
10])
修改的只有最后一行,把观远色改为使用 d3.schemeTableau10,注意,因为这个颜色模板只有10种颜色,所以,我们要调用对10的“取模运算”。
有了颜色,颜值马上上升了:

Step 8. 增加文本标签(Label)展示
因为我们学会了使用比例尺,那么加Label变得非常容易,其位置其实和矩形 Rect 是一致的,只是我们稍微向上偏移一点。我们使用SVG的TEXT元素来画Label
svg.append("g")
.attr("font-size",
10)
.selectAll("text")
.data(meanDataset)
.join("text")
.attr("x", d => xScale(d.key)
+ xScale.bandwidth()
/
2)
.attr("y", d => yScale(d.value)
-
5)
.attr('text-anchor',
'middle')
.text(d => d3.format(",")(d.value))
这里需要注意:
- 通过 text-anchor 设置为middle, 并计算x轴位置时,在对每个矩形的坐标x基础上,额外加了半个Rect的宽度,这样就是中间对齐的了!(So Easy)
- 默认的文本展示为:
656018.5
, 但是我们想要展示为更好看一点的656,018.5
为了转化数值为更好的展示,我们就需要用D3的number format 库了 (对于时间,也有 time format库),文档在:https://github.com/d3/d3-format/tree/v1.4.4, 通过文档,我们可以看到如下格式可以在千分位加上逗号:
d3.format(",")(d.value)
最终图形如下:

程序附录
看到这里,估计你也想直接试试D3了,我把完整的程序也列一下,可以把它保存为本地的 .html 文件,然后chrome浏览器打开
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>D3 Demo</title>
<script src="https://d3js.org/d3.v5.js"></script>
</head>
<body>
<svg></svg>
</body>
<script>
let dataset =
[
{"year":
2011,
"country":
"巴西",
"population":
18203},
{"year":
2012,
"country":
"巴西",
"population":
19325},
{"year":
2011,
"country":
"印尼",
"population":
23489},
{"year":
2012,
"country":
"印尼",
"population":
23438},
{"year":
2011,
"country":
"美国",
"population":
29034},
{"year":
2012,
"country":
"美国",
"population":
31000},
{"year":
2011,
"country":
"印度",
"population":
104970},
{"year":
2012,
"country":
"印度",
"population":
121594},
{"year":
2011,
"country":
"中国",
"population":
131744},
{"year":
2012,
"country":
"中国",
"population":
134141},
{"year":
2011,
"country":
"世界人口",
"population":
630230},
{"year":
2012,
"country":
"世界人口",
"population":
681807}
]
let meanDataset = d3.nest()
.key(d => d.country)
.rollup(d => d3.mean(d.map(x => x.population)))
.entries(dataset)
let width =
400
let height =
300
let svg = d3.select("svg")
.attr("width", width)
.attr("height", height);
let xScale = d3.scaleBand()
.domain(['巴西',
'印尼',
'美国',
'印度',
'中国',
'世界人口'])
.range([0, width])
.padding(0.1)
let yScale = d3.scaleLinear()
.domain([0, d3.max(meanDataset.map(d => d.value))]).nice()
.range([height,
0])
svg = svg
.attr("width", width +
80)
.attr("height", height +
60)
.attr("viewBox",
`-50
-15 ${width +
30} ${height +
45}`)
svg.selectAll("rect")
.data(meanDataset)
.join("rect")
.attr("x", d => xScale(d.key))
.attr("y", d => yScale(d.value))
.attr("height", d => height - yScale(d.value))
.attr("width", xScale.bandwidth())
// .style("fill", "#3EB9BA")
.style("fill",
(d, i)
=> d3.schemeTableau10[i %
10])
svg.append("g")
.attr("transform",
`translate(0,${height})`)
.call(d3.axisBottom(xScale))
svg.append("g")
.call(d3.axisLeft(yScale).ticks(null,
null))
svg.append("g")
.attr("font-size",
10)
.selectAll("text")
.data(meanDataset)
.join("text")
.attr("x", d => xScale(d.key)
+ xScale.bandwidth()
/
2)
.attr("y", d => yScale(d.value)
-
5)
.attr('text-anchor',
'middle')
.text(d => d3.format(",")(d.value))
</script>
</html>
总结
本文只是D3的起步,主要是简单介绍了一些基本概念:比例尺,数据绑定,SVG操作等,D3还有很多高级功能值得探索,比如:动画,tooltip,拖拽等。
不要被“底层”所吓到,D3虽然看着复杂,但是由于有理论支撑,虽然D3有1000+个不同的API,但是其被很好的组织在了不同的类别和目的下,使用还是相对方便的。正如“底层”的C语言也能实现 Windows/Linux 操作系统,基于D3,也能构造出非常易用的高级图形库,比如:plotly: https://plotly.com/javascript/
另外,我们不能把D3局限在一些常见的统计图形上(比如:柱图,饼图等),而其由于最强大的表达能力,以及丰富的预制图形算法库,可以广泛被用于各种创意图形的绘制,比如分析新冠的致命程度:https://observablehq.com/@yy/covid-19-fatality-rate

所以,本文称D3为艺术家是有依据的。
D3很好的回答了WHY的问题,从此我们做可视化也算是有了理论的支持了!
本系列的下一篇:《技术人员眼中的BI之可视化 —— 标准家:Vega & Vega-Lite》将会比本文更轻松一些, Stay Tuned!
注:本文来自于观远数据吴宝琪原创,转载或更多交流请关注WeChat:架构587