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

原文载于 https://old-panda.com/2019/02/05/d3js-histogram/

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

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

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

image.png

<figcaption style="box-sizing: border-box; display: block; margin-top: 0.5em; margin-bottom: 1em; color: rgb(85, 93, 102); text-align: center; font-size: 13px;">Task Duration Page</figcaption>

前端零基础,还想速成,一个好办法是从官方示例入手,但 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 就可以看到一个漂亮的坐标轴

image.png

在添加数据之前,我期望每个柱子有这样一个效果,默认显示为蓝色,当鼠标移上去的时候会变成棕色,采用一点 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,所以要用高度减去该值,才是我们想要的高度。

先上结果

image.png

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

2019/03/31 更新

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,271评论 5 466
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,725评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,252评论 0 328
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,634评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,549评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,985评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,471评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,128评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,257评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,233评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,235评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,940评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,528评论 3 302
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,623评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,858评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,245评论 2 344
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,790评论 2 339

推荐阅读更多精彩内容

  • Data Visualization with D3 D3: SVG中的jQurey 1. Add Documen...
    王策北阅读 749评论 0 2
  • d3 (核心部分)选择集d3.select - 从当前文档中选择一系列元素。d3.selectAll - 从当前文...
    谢大见阅读 3,411评论 1 4
  • 一、简介 1. D3是什么? D3(或D3.js) 是一个用来使用Web标准做数据可视化的JavaScript库。...
    朝朝_c53e阅读 811评论 0 2
  • 近期在做线路图实时刷新的功能,用到的技术主要有d3、svg、websocket。整体思路就是解析线路图json,使...
    淼一___阅读 1,041评论 0 1
  • 第六章:基因种族 本章的主要内容是,一个基因有可能帮助存在于其 他一些个体之内的其自身的复制品。如果是这样,这种情...
    淡淡的绿茶阅读 724评论 0 0