# 38 | 实战(二):如何使用数据驱动框架绘制常用数据图表? 你好,我是月影。 上一节课,我们使用图表库实现了一些常用的可视化图表。使用图表库的好处是非常简单,基本上我们只需要准备好数据,然后根据图形需要的数据格式创建图形,再添加辅助插件,就可以将图表显示出来了。 图表库虽然使用上简单,但灵活性不高,对数据格式要求也很严格,我们必须按照各个图表的要求来准备数据。而且,图形和插件的可配置性,完全取决于图表库设计者开放的API,给开发者的自由度很少。 今天,我们就来说说,使用数据驱动框架来实现图表的方式。这类框架以D3.js为代表,提供了数据处理能力,以及从数据转换成视图结构的通用API,并且不限制用户处理视图的最终呈现。所以它的特点是更加灵活,不受图表类型对应API的制约。不过,因为图表库只要调用API就能展现内容,而数据驱动框架需要我们自己去完成内容的呈现,所以,它在使用上就没有图表库那么方便了。 使用图表库和使用数据驱动框架的具体过程和差别,我这里准备了一个对比图,你可以看一下。 ![](https://static001.geekbang.org/resource/image/a7/bc/a7ed3169666071df64de4e4bab239fbc.jpg "使用图表库(左)和使用数据驱动框架(右)渲染图表的流程对比") 不过这么讲还是比较抽象,接下来,我们还是通过绘制条形图和力导向图,来体会用数据驱动框架和用图表库构建可视化图表究竟有什么区别。 ## 课前准备 与上一节课差不多,我们还是需要使用SpriteJS,只不过今天我们将QCharts换成D3.js。 ``` ``` 使用上面的代码,我们就能加载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坐标值设置为450,y坐标值设置为从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/#/)