会说话的数据—— D3.js 折腾小记

Photo by Isaac Smith on Unsplash

我一个写后端代码运维服务器的,怎么就去搞前端数据可视化了呢?

接触 D3.js 纯属机缘巧合,但既然现在的工作跟数据打交道,数据的可视化总是不可避免的,学了总没什么坏处。由于是前端小白,所以不可避免的会掉入一些看起来很可乐的坑,因此随便写一篇小文章,记录一下自己折腾的过程。

D3 的名字由来从它的官网就能看出来, Data-Driven Documents ,三个 D ,不愿意发那么多音,所以这帮老外就简称 D3 。这套可视化工具还是挺流行的,比如说我最近在搞的 Airflow 就利用 D3 来进行一个 DAG 运行状态的可视化。具体举例来说, Airflow 利用 D3 进行一个 DAG 中每个任务运行时间的表示,代码可以参见这里。我也从官网上找到了一个示例图片,画出来还是很直观很漂亮的。

Task Duration Page

前端零基础,还想速成,一个好办法是从官方示例入手,但 D3 的学习困难也在于此,目前 D3 的最新版本是 v5 ,但给出的示例所用的版本却是五花八门,例如这个 Sequences sunburst 用的是 v3 , NCAA Predictions 用的是 v2 , Bubble Chart 用的又是最新版本。更要命的是,很多图形的代码不同版本之间互不兼容,并且还需要用户对前端知识有一定的了解,比如说 Bubble Chart 那个例子中,作者用这样一句来调用 D3

d3 = require("d3@5")

我作为只会写最基本的 JavaScript 的小白自然要问,这个 require 是什么?所幸 Stack Overflow 上给出了详细的答案。而一个对新手友好的文档/示例是不应该对读者作任何假设的,只需要读者对 JavaScript 有所了解足矣。

本篇文章将会采用最新版本的 D3 库,来逐步说明如何来画一个最简单的直方图,我会用有限的语文水平,从一个新手的视角,尽量根据自己的理解来解释每一块代码在做什么,如有不当之处,欢迎读者指正。

首先自然是“安装” D3 。图省事,我们就直接用 CDN 提供的 js 文件。这样我们就有了这样一个 HTML 文件,我将它命名为 index.html (文件名字可以随便叫,只要保证后缀是 .html 就行)。同时在同一个文件夹下创建一个子文件夹 scripts 来存放我们自己的 js 文件—— main.js

<html>
  <head>
    <title>D3 Play</title>
  </head>
  <body>
    <script src="https://d3js.org/d3.v5.min.js"></script>
    <script src='./scripts/main.js'></script>
  </body>
</html>

谈到用 D3 画图,其实是通过 D3 这个工具去画 SVG (可缩放矢量图),关于 SVG 的具体教程可以看这里。在这篇小文章我们只关心直方图,即 rect 标签。那么问题来了,我们需要有个地方放这个 svg 结构,然后发现上述 HTML 文件中的 body 标签之间是个好地方,所以首先找到 body 标签,然后在其中插入 svg

d3.select('body')
  .append('svg');

我们还需要指定这个 svg 区域的长和宽,继续添加 widthheight 属性

var svg = d3.select('body')
            .append('svg')
            .attr('width', window.innerWidth)
            .attr('height', window.innerHeight);

其中 window.innerWidthwindow.innerHeight 指明图形的大小将会自动适应当前浏览器窗口的大小。到这里我们就得到了一个空白的 svg 结构,如果这时用浏览器打开 index.html 就会发现其内容变成了

<html>
  <head>
    <title>D3 Play</title>
  </head>
  <body>
    <script src="https://d3js.org/d3.v5.min.js"></script>
    <script src="./scripts/main.js"></script>
    <svg width="1440" height="328"></svg>
  </body>
</html>

可以看到多了一行 <svg width="1440" height="328"></svg> ,于是大胆猜想, D3 这套工具实质上就是一个对矢量图强化过的 DOM 操作工具,后续的一系列操作似乎也在逐步验证这个猜想。然后我们需要进行绘图,一般来说没人会把图案贴着框画,那样画出来的图不太好看,所以通常都会制定一个边缘大小,内部才是绘图区。同时趁此机会,设定好 x 轴 y 轴的范围。此时 main.js 文件如下所示

var svg = d3.select('body')
            .append('svg')
            .attr('width', window.innerWidth)
            .attr('height', window.innerHeight);

const margin = 60;

var width = window.innerWidth - margin - margin;
var height = window.innerHeight - margin - margin;

var x = d3.scaleBand().rangeRound([0, width]).padding(0.1);
var y = d3.scaleLinear().rangeRound([height, 0]);

这里的 scaleBand() 函数和 scaleLinear() 函数我没有深入了解,但根据 D3 文档的说法,需要连续值的时候用 scaleLinear() ,需要离散值的时候用 scaleBand(),正好分别符合我们 y 轴和 x 轴的需求。至于 y 轴的范围设定为什么是 height 在前,这是因为按照浏览器的坐标,左上角为原点,向右 x 值逐渐增大,向下 y 值逐渐增大,是比较反直觉的一个情况。

通常 SVG 绘图需要一个 g 标签来表示一组图形,具体的文档说明可以参考这里。代码如下

var g = svg.append('g')
           .attr('transform', `translate(${margin}, ${margin})`);

在这里我们给 g 设定了一个属性 transform="translate(margin, margin)",这表示我们把整个图形组向 x 轴正方向和 y 轴正方向同时移动了 margin 距离,因为我们之前给绘图区设定了一个边缘距离,所以这个平移确保了绘制的图形跟边框之间有这个距离。

绘图环境都设置好了,我们可以画图了。首先,要有数据

const data = [12, 15, 43, 24, 94, 35, 38, 59];

其次,要在 x 轴和 y 轴上找到画图的位置,即直方图每个柱子的位置( x 轴)及高度( y 轴)

x.domain([...Array(data.length).keys()]);
y.domain([0, d3.max(data, (d) => d)]);

题外话,这个 [...Array(data.length).keys()] 是我最近刚学到的一个小技巧,如何生成给定数组长度的从 0 开始的递增数组。运行结果如下

> const data = [12, 15, 43, 24, 94, 35, 38, 59];
undefined
> [...Array(data.length).keys()];
[ 0, 1, 2, 3, 4, 5, 6, 7 ]

还是很方便的。

下面我们需要在 x 轴和 y 轴上写数字,来告诉我们这两个轴分别代表什么。 x 轴比较简单,只需要把每个数字在数组中的位置放上去就好,即 0、1、2、……

g.append("g")
 .attr("transform", `translate(0, ${height})`)
 .call(d3.axisBottom(x));

同样的,transform 属性表示我们标记数字的位置,即从图形的顶部向下 height 距离,也就是我们图形的底部。y 轴稍麻烦些,我们不仅需要放上数字,还要加上说明来表示柱状图的具体含义,什么含义呢?我也不知道,就叫 “Some Secret Value” 好了

g.append("g")
 .call(d3.axisLeft(y))
 .append("text")
 .attr("fill", "#000")
 .attr("transform", "rotate(-90)")
 .attr("y", 6)
 .attr("dy", "0.9em")
 .attr("text-anchor", "end")
 .text("Some Secret Value");

在这里可以看到在 .call(d3.axisLeft(y)) 之后,又继续添加了一个 text 标签来显示 y 轴的含义,即 “Some Secret Value” 。

到此为止,打开 index.html 就可以看到一个漂亮的坐标轴

在添加数据之前,我期望每个柱子有这样一个效果,默认显示为蓝色,当鼠标移上去的时候会变成棕色,采用一点 css 技巧(当然是从别处借鉴来的)

.bar {
  fill: steelblue;
}

.bar:hover {
  fill: brown;
}

然后利用上述 css 代码,开始绘制数据

g.selectAll('.bar')
 .data(data)
 .enter()
 .append('rect')
 .attr('class', 'bar')
 .attr("x", (_, i) => x(i))
 .attr("y", (d) => y(d))
 .attr("width", x.bandwidth())
 .attr("height", (d) => height - y(d));

对这一小段,我的理解是,先选择下面将要出现的所有 class="bar" 的 DOM (对,它们还没有出现,但 D3 已经选定它们),通过 .data(data).enter() 将数据加载给这这 DOM 。 D3 要求这里的数据格式是一个数组,然后对于数据中的每一个元素,添加一个 rect 标签来表示当前这个元素,不消说, class 的值自然是 bar 。后面的 xy 属性代表该柱的位置,这时就能体现 D3 的强大了,它们分别由数字在数组中的位置和数字本身的大小决定的,直接丢给之前定义好的 xy (还记得 [...Array(data.length).keys()] 吗?我们就是用它指定了 x 轴的各个坐标),它们就能自动返回正确的结果。一个可能看起来比较奇怪的地方是高度 height ,为什么要用 height - y(d) ?因为浏览器对 y 轴方向的表示是反直觉的,越往下坐标值越大。 y(d) 本身是给的参数越大,返回值越小(初始化的时候 height 在前),同时我们设定的表示范围是 [0, d3.max(data, (d) => d)] ,所以可以猜到,当传入数组 data 中的最大值94时, y(d) 的返回值是0,所以要用高度减去该值,才是我们想要的高度。

先上结果

到此为止,我们就有了一个看起来还行的直方图,横轴是数字在数组中的坐标,纵轴是数字的值,完整的代码我放在了 JSFiddle 上。通过实现这么一个简单的例子,可以看出要想用好 D3.js ,首先要对 SVG 有一个非常深入的了解,需要熟悉每种图形的画法,组成方式等, D3.js 本身并不画图,而是通过包装对 DOM 的操作大大简化了画图的流程。当然,要想制作出富有表现力的图形,深厚的前端功力也是必不可少的。

2019/03/31 更新

经网友 encro 指出, D3 一般搭配着 C3 使用,粗略看了一眼,似乎 C3 是一个基于 D3 的可复用的图表库,很多图表可以开箱即用,我们给它传数据即可。这样一个库确实十分解放生产力,在学习 D3 的时候就对各种图形、坐标等参数颇感头疼,数据很简单,但大量的时间被浪费在了这些细枝末节的地方,实际生产中,很需要 C3 这样的一个工具。

发表评论

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据