<audio id="audio" title="38 | 实战（二）：如何使用数据驱动框架绘制常用数据图表？" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/27/f0/2711de9972222c1ea5b9504809df6cf0.mp3"></audio>

你好，我是月影。

上一节课，我们使用图表库实现了一些常用的可视化图表。使用图表库的好处是非常简单，基本上我们只需要准备好数据，然后根据图形需要的数据格式创建图形，再添加辅助插件，就可以将图表显示出来了。

图表库虽然使用上简单，但灵活性不高，对数据格式要求也很严格，我们必须按照各个图表的要求来准备数据。而且，图形和插件的可配置性，完全取决于图表库设计者开放的API，给开发者的自由度很少。

今天，我们就来说说，使用数据驱动框架来实现图表的方式。这类框架以D3.js为代表，提供了数据处理能力，以及从数据转换成视图结构的通用API，并且不限制用户处理视图的最终呈现。所以它的特点是更加灵活，不受图表类型对应API的制约。不过，因为图表库只要调用API就能展现内容，而数据驱动框架需要我们自己去完成内容的呈现，所以，它在使用上就没有图表库那么方便了。

使用图表库和使用数据驱动框架的具体过程和差别，我这里准备了一个对比图，你可以看一下。

<img src="https://static001.geekbang.org/resource/image/a7/bc/a7ed3169666071df64de4e4bab239fbc.jpg" alt="" title="使用图表库（左）和使用数据驱动框架（右）渲染图表的流程对比">

不过这么讲还是比较抽象，接下来，我们还是通过绘制条形图和力导向图，来体会用数据驱动框架和用图表库构建可视化图表究竟有什么区别。

## 课前准备

与上一节课差不多，我们还是需要使用SpriteJS，只不过今天我们将QCharts换成D3.js。

```
&lt;script src=&quot;https://unpkg.com/spritejs/dist/spritejs.min.js&quot;&gt;&lt;/script&gt;
&lt;script src=&quot;https://d3js.org/d3.v6.js&quot;&gt;&lt;/script&gt;


```

使用上面的代码，我们就能加载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) =&gt; {
    return 200 + i * 95;
  })
  .attr('width', scale)
  .attr('height', 80)
  .attr('bgcolor', (d, i) =&gt; {
    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像素的空隙。最后我们给元素设置一组不同的颜色值。

我们最终显示出来的效果如下图：

<img src="https://static001.geekbang.org/resource/image/8e/c6/8e24ca6f4bfb4byyd71554yydc431bc6.jpg" alt="">

这里我们在画布上显示了五个不同颜色的矩形条，它们对应数组的 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上，这样就能将坐标轴显示出来了。

<img src="https://static001.geekbang.org/resource/image/3f/dc/3fbbca34bd9bd53df1d79c64b0ecd3dc.jpg" alt="">

这样，我们就实现了一个简陋的条形图。简陋是因为和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绘制更加复杂的图形，比如力导向图。

力导向图也是一种比较常见的可视化图表，它非常适合用来描述关系型信息。比如下图就是一个经典的力导向图应用。

<img src="https://static001.geekbang.org/resource/image/d1/45/d10baf06984e9b356a13e3bd2dc92845.gif" alt="">

我们看到，力导向图不仅能够描绘节点和关系链，而且在移动一个节点的时候，图表各个节点的位置会跟随移动，避免节点相互重叠。

那么究竟如何用D3.js实现一个简单的力导向图呢？我们来看一个例子。

力导向图，顾名思义，我们通过模拟节点之间的斥力，来保证节点不会相互重叠。在D3.js中提供了模拟斥力的方法。

```
const simulation = d3.forceSimulation()
  .force('link', d3.forceLink().id(d =&gt; 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 =&gt; {
  ...
});

```

我们先用力模型来处理数据：

```
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) =&gt; {
    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) =&gt; {
    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) =&gt; {
    return `path${index}`;
  })
  .attr('strokeColor', 'white');


```

这里我们依然是用d3-selection的迭代，给SpriteJS的sprite和path元素设置了一些属性，这些属性有的与我们的数据建立关联，有的是单纯的样式。这里面没有特别难的地方，我就不一一解释了，最好的理解方法是实践，所以我建议你亲自研究一下示例代码，修改一些属性，看看结果有什么变化，这样能够加深理解。

将节点和连线绘制完成之后，力导向图的初步结果就呈现出来了。

<img src="https://static001.geekbang.org/resource/image/a1/cb/a17d7c08d84fd101a8d2a991cb61e4cb.gif" alt="">

因为力向导图有一个特点就是，在我们移动一个节点的时候，其他节点也会跟着移动。所以，我们还要实现拖动节点的功能。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会自动根据新的节点位置计算布局，避免节点的重叠。

<img src="https://static001.geekbang.org/resource/image/01/f1/016ea964bba39df48d0894e39c45e0f1.gif" alt="">

## 要点总结

这节课，我们主要学习了使用数据驱动框架来绘制图表。

与直接使用图表库不同，使用数据驱动框架不要求固定格式的数据格式，而是通过对原始数据的处理和对容器迭代、创建新的子元素，并且根据数据设置属性，来完成从数据到元素结构和属性的映射，然后再用渲染引擎将它最终渲染出来。

那你可能有疑问了，我们应该在什么时候选择图表库，什么时候选择数据驱动框架呢？通常情况下，当需求比较明确可以用图表库，并且样式通过图表库API设置可以实现的时候，我们倾向于使用图表库，但是当需求比较复杂，或者样式要求灵活多变的时候，我们可以考虑使用数据驱动框架。

数据驱动框架可以灵活实现各种复杂的图表效果，我们前面举的两个图表例子虽然只是个例，但也会在实战项目中经常用到。除此之外，使用D3.js和SpriteJS还可以实现其他复杂的图表，比如说，地图或者一些3D图表，以及我们在前面的课程中实现的3Dgithub代码贡献图，就是使用D3.js和SpriteJS来实现的。

D3.js和SpriteJS的使用都比较复杂，你是不可能用一节课系统掌握的，我们只有继续深入学习，并动手实践、积累经验，才能在可视化项目中得心应手地使用它们，来实现各种各样的可视化需求。

## 小试牛刀

最后，我给你出了两个实践题。希望你能结合D3.js和SpriteJS的官方文档，花点时间仔细阅读和学习，再通过动手实践和反复练习，最终掌握它们。

1. 请你完善我们课程中讲到的条形图，给它实现y轴、图例和提示信息。
1. 你可以将上一节课用QCharts图表库实现的图表改用D3.js实现吗？动手试一试，体会一下它们使用方式和思路上的不同。

关于可视化图表的实战课程就讲到这里了，如果你对于图表绘制，还有什么疑问和困惑，欢迎你在留言区告诉我。我们下节课再见！

## 源码

课程中完整示例代码详见[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/d3-spritejs)

## 推荐阅读

[D3.js的官方文档](https://d3js.org/)

[SpriteJS的官方文档](https://spritejs.org/#/)
