你好,我是月影。

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

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

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

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

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

课前准备

与上一节课差不多,我们还是需要使用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坐标值设置为450,y坐标值设置为从200开始,每个元素占据95个像素值,然后将width设置为用scale计算后的数据项的值,这里我们就用到前面linearScale高阶函数生成的scale函数,直接将它作为算子。我们将height值设为固定的80,表示元素的高度。这样一来,元素之间就会有 95 - 80,即15像素的空隙。最后我们给元素设置一组不同的颜色值。

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

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

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

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

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

那么究竟如何用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数据。我们可以用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元素设置了一些属性,这些属性有的与我们的数据建立关联,有的是单纯的样式。这里面没有特别难的地方,我就不一一解释了,最好的理解方法是实践,所以我建议你亲自研究一下示例代码,修改一些属性,看看结果有什么变化,这样能够加深理解。

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

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

要点总结

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

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

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

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

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

小试牛刀

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

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

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


源码

课程中完整示例代码详见GitHub仓库

推荐阅读

D3.js的官方文档

SpriteJS的官方文档

评论