You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

331 lines
17 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 38 | 实战(二):如何使用数据驱动框架绘制常用数据图表?
你好,我是月影。
上一节课,我们使用图表库实现了一些常用的可视化图表。使用图表库的好处是非常简单,基本上我们只需要准备好数据,然后根据图形需要的数据格式创建图形,再添加辅助插件,就可以将图表显示出来了。
图表库虽然使用上简单但灵活性不高对数据格式要求也很严格我们必须按照各个图表的要求来准备数据。而且图形和插件的可配置性完全取决于图表库设计者开放的API给开发者的自由度很少。
今天我们就来说说使用数据驱动框架来实现图表的方式。这类框架以D3.js为代表提供了数据处理能力以及从数据转换成视图结构的通用API并且不限制用户处理视图的最终呈现。所以它的特点是更加灵活不受图表类型对应API的制约。不过因为图表库只要调用API就能展现内容而数据驱动框架需要我们自己去完成内容的呈现所以它在使用上就没有图表库那么方便了。
使用图表库和使用数据驱动框架的具体过程和差别,我这里准备了一个对比图,你可以看一下。
![](https://static001.geekbang.org/resource/image/a7/bc/a7ed3169666071df64de4e4bab239fbc.jpg "使用图表库(左)和使用数据驱动框架(右)渲染图表的流程对比")
不过这么讲还是比较抽象,接下来,我们还是通过绘制条形图和力导向图,来体会用数据驱动框架和用图表库构建可视化图表究竟有什么区别。
## 课前准备
与上一节课差不多我们还是需要使用SpriteJS只不过今天我们将QCharts换成D3.js。
```
<script src="https://unpkg.com/spritejs/dist/spritejs.min.js"></script>
<script src="https://d3js.org/d3.v6.js"></script>
```
使用上面的代码我们就能加载SpriteJS和D3.js用它们来完成常用图表的绘制了。
## 使用D3.js绘制条形图
我们先来绘制条形图,条形图与柱状图差不多,都是用图形的长度来表示数据的多少。只不过,横向对比的条形图,更容易让我们看到各个数据之间的大小,而纵向的柱状图可以同时比较两个变量之间的数据差别。
用D3.js绘制图表不同于使用Qcharts我们需要创建SpriteJS的容器。通过前面的课程我们已经知道SpriteJS创建场景Scene对象作为其他元素的根元素容器。接下来我们一起看下具体的操作过程。
```
const container = document.getElementById('stage');
const scene = new Scene({
container,
width: 1200,
height: 1200,
});
```
如上面代码所示我们先创建一个Scene对象与QCharts的Chart对象一样它需要一个HTML容器这里我们使用页面上一个id为stage的元素。我们设置了参数width和height为1200也就是把Canvas对象的画布宽高设为1200 \* 1200。
接着我们准备数据。与使用QCharts必须要按照格式给出JSON数据不同使用D3.js的时候数据格式比较自由。这里我们直接用了一个数组
```
const dataset = [125, 121, 127, 193, 309];
```
然后我们使用D3.js的方法对数据进行映射
```
const scale = d3.scaleLinear()
.domain([100, d3.max(dataset)])
.range([0, 500]);
```
D3.js在设计上采用了一些函数式编程思想这里的.scaleLinear、.domain和.range都是高阶函数它们返回一个scale函数这个函数把一组数值线性映射到某个范围这里我们就是将数值映射到500像素区间数值是从100到309。
那么这个scale函数要怎么使用呢别着急我们先往下看。
有了数据dataset和处理数据的scale方法之后我们使用d3-selection这是d3中的一个子模块我们是通过CDN来加载d3的所以已经默认包含了d3-selection来创建并选择layer对象。
在SpriteJS中场景Scene可以由多个Layer构成针对每个Layer对象SpriteJS都会创建一个实际的Canvas画布。
```
const fglayer = scene.layer('fglayer');
const s = d3.select(fglayer);
```
如上面的代码所示我们先创建了一个fglayer它对应一个Canvas画布然后通过d3.select(fglayer)将对应的fglayer元素经过d3包装后返回。
接着我们在fglayer元素上进行迭代操作。你先认真看完代码我再来解释。
```
const colors = ['#fe645b', '#feb050', '#c2af87', '#81b848', '#55abf8'];
const chart = s.selectAll('sprite')
.data(dataset)
.enter()
.append('sprite')
.attr('x', 450)
.attr('y', (d, i) => {
return 200 + i * 95;
})
.attr('width', scale)
.attr('height', 80)
.attr('bgcolor', (d, i) => {
return colors[i];
});
```
我们从第2行代码开始看其中selectAll用来返回fglayer下的sprite子元素对于SpriteJS来说sprite元素是基本元素用来表示一个图形。不过现在fglayer下还没有任何子元素所以selectAll(sprite)本应该返回空的元素但是d3通过data方法迭代数据集也就是之前有5个元素的数组然后通过执行enter()和append(sprite)这样就在fglayer下添加了5个sprite子元素。enter()方法是告诉d3-selection当数据集的数量大于selectAll选中的元素数量时通过append添加元素补齐数量。
从第6行代码开始我们给每个sprite元素迭代设置属性。注意append之后的attr是默认迭代设置每个sprite元素的属性如果是常量就直接设置如果是不同的值就通过迭代算子来设置。迭代算子有两个参数第一个是dataset中对应的数据第二个是迭代次数从0开始因为有五项数据所以会迭代5次。如果你对jQuery比较熟悉你应该能比较容易理解上面这种批量迭代操作的形式。
最后我们根据数据集的每个数据依次设置一个sprite元素将x坐标值设置为450y坐标值设置为从200开始每个元素占据95个像素值然后将width设置为用scale计算后的数据项的值这里我们就用到前面linearScale高阶函数生成的scale函数直接将它作为算子。我们将height值设为固定的80表示元素的高度。这样一来元素之间就会有 95 - 80即15像素的空隙。最后我们给元素设置一组不同的颜色值。
我们最终显示出来的效果如下图:
![](https://static001.geekbang.org/resource/image/8e/c6/8e24ca6f4bfb4byyd71554yydc431bc6.jpg)
这里我们在画布上显示了五个不同颜色的矩形条,它们对应数组的 125、121、127、193、309。但它还不是一个完整的图表我们需要给它增加辅助信息比如坐标轴。添加坐标轴的代码如下所示。
```
const axis = d3.axisBottom(scale).tickValues([100, 200, 300]);
const axisNode = new SpriteSvg({
x: 420,
y: 680,
});
d3.select(axisNode.svg)
.attr('width', 600)
.attr('height', 60)
.append('g')
.attr('transform', 'translate(30, 0)')
.call(axis);
axisNode.svg.children[0].setAttribute('font-size', 20);
fglayer.append(axisNode);
```
如上面代码所示,我们通过 d3.axisBottom 创建一个底部的坐标。我们可以通过tickValues给坐标轴传要显示的刻度值这里我们显示100、200、300三个刻度。同样我们可以用scale函数将这些数值线性映射到500像素区间值从100到309。
axisBottom本身是一个高阶函数它返回axis函数用来绘制坐标轴不过这个函数是使用svg来绘制坐标轴的。好在SpriteJS支持SpriteSvg对象它可以绘制一个SVG图形然后将这个图形以WebGL或者Canvas2D的方式绘制到画布上。
我们先创建SpriteSvg类的对象axisNode然后通过d3.select选中对象的svg属性进行正常的svg属性设置和创建svg元素操作最终将axisNode添加到fglayer上这样就能将坐标轴显示出来了。
![](https://static001.geekbang.org/resource/image/3f/dc/3fbbca34bd9bd53df1d79c64b0ecd3dc.jpg)
这样我们就实现了一个简陋的条形图。简陋是因为和QCharts的柱状图相比它现在只有图形主体部分和一个简单的x坐标轴缺少y坐标轴、图例、提示信息、辅助网格等信息不过这些用D3.js也都能创建我觉得这部分内容你可以自己试着实现我就不多说了如果遇到问题记得在留言区提问。
总的来说在创建简单的图表的时候使用D3.js比直接使用图表库还是要复杂很多的。但比较好的一点是D3.js对数据格式没有太多硬性要求我们可以直接使用一个简单的数组然后在后面绘图的时候再进行迭代。那麻烦一点的是因为没有现成的图表对象所以我们要自己处理数据、显示属性的映射好在D3.js提供了linearScale这样的工具函数来创建数据映射。
处理好数据映射之后我们需要自己通过d3-selection来遍历元素完成属性的设置从而把图形渲染出来。而且对于坐标轴等其他附属信息d3也没有现成的对象我们也需要通过遍历元素进行绘制。
这里顺便提一下虽然我们使用SpriteJS作为图形库来讲解但d3并没有强制限定图形库所以我们无论是采用SVG、原生Canvas2D还是WebGL又或者是采用ThreeJS等其他图形库都可以进行渲染。只不过d3-selection依赖于DOM操作所以SVG和SpriteJS这种与DOM API保持一致的图形系统使用起来会更加方便一些。
## 使用D3.js绘制力导向图
讲完了用D3.js绘制简单条形图的方法接下来我们看看怎么用D3.js绘制更加复杂的图形比如力导向图。
力导向图也是一种比较常见的可视化图表,它非常适合用来描述关系型信息。比如下图就是一个经典的力导向图应用。
![](https://static001.geekbang.org/resource/image/d1/45/d10baf06984e9b356a13e3bd2dc92845.gif)
我们看到,力导向图不仅能够描绘节点和关系链,而且在移动一个节点的时候,图表各个节点的位置会跟随移动,避免节点相互重叠。
那么究竟如何用D3.js实现一个简单的力导向图呢我们来看一个例子。
力导向图顾名思义我们通过模拟节点之间的斥力来保证节点不会相互重叠。在D3.js中提供了模拟斥力的方法。
```
const simulation = d3.forceSimulation()
.force('link', d3.forceLink().id(d => d.id)) //节点连线
.force('charge', d3.forceManyBody()) // 多实体作用
.force('center', d3.forceCenter(400, 300)); // 力中心
```
如上面代码所示我们创建一个d3的力模型对象simulation通过它来模拟示例然后我们设置节点连接、多实体相互作用、力中心点。
接着,我们读取数据。这里我准备了一份[JSON数据](https://s0.ssl.qhres.com/static/f74a79ccf53d8147.json)。我们可以用d3.json来读取数据它返回一个Promise对象。
```
d3.json('https://s0.ssl.qhres.com/static/f74a79ccf53d8147.json').then(graph => {
...
});
```
我们先用力模型来处理数据:
```
simulation
.nodes(graph.nodes)
.on('tick', ticked);
simulation.force('link')
.links(graph.links);
```
接着,我们再绘制节点:
```
d3.select(layer).selectAll('sprite')
.data(graph.nodes)
.enter()
.append('sprite')
.attr('pos', (d) => {
return [d.x, d.y];
})
.attr('size', [10, 10])
.attr('border', [1, 'white'])
.attr('borderRadius', 5)
.attr('anchor', 0.5);
```
然后,我们再绘制连线:
```
d3.select(layer).selectAll('path')
.data(graph.links)
.enter()
.append('path')
.attr('d', (d) => {
const [sx, sy] = [d.source.x, d.source.y];
const [tx, ty] = [d.target.x, d.target.y];
return `M${sx} ${sy} L ${tx} ${ty}`;
})
.attr('name', (d, index) => {
return `path${index}`;
})
.attr('strokeColor', 'white');
```
这里我们依然是用d3-selection的迭代给SpriteJS的sprite和path元素设置了一些属性这些属性有的与我们的数据建立关联有的是单纯的样式。这里面没有特别难的地方我就不一一解释了最好的理解方法是实践所以我建议你亲自研究一下示例代码修改一些属性看看结果有什么变化这样能够加深理解。
将节点和连线绘制完成之后,力导向图的初步结果就呈现出来了。
![](https://static001.geekbang.org/resource/image/a1/cb/a17d7c08d84fd101a8d2a991cb61e4cb.gif)
因为力向导图有一个特点就是在我们移动一个节点的时候其他节点也会跟着移动。所以我们还要实现拖动节点的功能。D3.js支持处理拖拽事件所以我们只要分别实现一下对应的事件回调函数完成时间注册就可以了。首先是三个事件回调函数。
```
function dragstarted(event) {
if(!event.active) simulation.alphaTarget(0.3).restart();
const [x, y] = [event.subject.x, event.subject.y];
event.subject.fx0 = x;
event.subject.fy0 = y;
event.subject.fx = x;
event.subject.fy = y;
const [x0, y0] = layer.toLocalPos(event.x, event.y);
event.subject.x0 = x0;
event.subject.y0 = y0;
}
function dragged(event) {
const [x, y] = layer.toLocalPos(event.x, event.y),
{x0, y0, fx0, fy0} = event.subject;
const [dx, dy] = [x - x0, y - y0];
event.subject.fx = fx0 + dx;
event.subject.fy = fy0 + dy;
}
function dragended(event) {
if(!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = nul
```
其中dragstarted处理开始拖拽的事件这个时候我们通过前面创建的simulation对象启动力模拟记录一下当前各个节点的x、y坐标。因为默认的坐标是DOM事件坐标我们通过layer.toLocalPos方法将它转换成相对于layer的坐标。接着dragged处理拖拽中的事件同样也是转换x、y坐标计算出坐标的差值然后更新fx、fy也就是事件主体的当前坐标。最后我们用dragended处理拖住结束事件清空fx和fy。
接着我们将三个事件处理函数注册到layer的canvas上
```
d3.select(layer.canvas)
.call(d3.drag()
.container(layer.canvas)
.subject(dragsubject)
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended));
```
这样就实现了力导向图拖拽节点的交互d3会自动根据新的节点位置计算布局避免节点的重叠。
![](https://static001.geekbang.org/resource/image/01/f1/016ea964bba39df48d0894e39c45e0f1.gif)
## 要点总结
这节课,我们主要学习了使用数据驱动框架来绘制图表。
与直接使用图表库不同,使用数据驱动框架不要求固定格式的数据格式,而是通过对原始数据的处理和对容器迭代、创建新的子元素,并且根据数据设置属性,来完成从数据到元素结构和属性的映射,然后再用渲染引擎将它最终渲染出来。
那你可能有疑问了我们应该在什么时候选择图表库什么时候选择数据驱动框架呢通常情况下当需求比较明确可以用图表库并且样式通过图表库API设置可以实现的时候我们倾向于使用图表库但是当需求比较复杂或者样式要求灵活多变的时候我们可以考虑使用数据驱动框架。
数据驱动框架可以灵活实现各种复杂的图表效果我们前面举的两个图表例子虽然只是个例但也会在实战项目中经常用到。除此之外使用D3.js和SpriteJS还可以实现其他复杂的图表比如说地图或者一些3D图表以及我们在前面的课程中实现的3Dgithub代码贡献图就是使用D3.js和SpriteJS来实现的。
D3.js和SpriteJS的使用都比较复杂你是不可能用一节课系统掌握的我们只有继续深入学习并动手实践、积累经验才能在可视化项目中得心应手地使用它们来实现各种各样的可视化需求。
## 小试牛刀
最后我给你出了两个实践题。希望你能结合D3.js和SpriteJS的官方文档花点时间仔细阅读和学习再通过动手实践和反复练习最终掌握它们。
1. 请你完善我们课程中讲到的条形图给它实现y轴、图例和提示信息。
2. 你可以将上一节课用QCharts图表库实现的图表改用D3.js实现吗动手试一试体会一下它们使用方式和思路上的不同。
关于可视化图表的实战课程就讲到这里了,如果你对于图表绘制,还有什么疑问和困惑,欢迎你在留言区告诉我。我们下节课再见!
* * *
## 源码
课程中完整示例代码详见[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/d3-spritejs)
## 推荐阅读
[D3.js的官方文档](https://d3js.org/)
[SpriteJS的官方文档](https://spritejs.org/#/)