你好,我是月影。
上一节课,我们使用图表库实现了一些常用的可视化图表。使用图表库的好处是非常简单,基本上我们只需要准备好数据,然后根据图形需要的数据格式创建图形,再添加辅助插件,就可以将图表显示出来了。
图表库虽然使用上简单,但灵活性不高,对数据格式要求也很严格,我们必须按照各个图表的要求来准备数据。而且,图形和插件的可配置性,完全取决于图表库设计者开放的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绘制图表,不同于使用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中提供了模拟斥力的方法。
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的官方文档,花点时间仔细阅读和学习,再通过动手实践和反复练习,最终掌握它们。
关于可视化图表的实战课程就讲到这里了,如果你对于图表绘制,还有什么疑问和困惑,欢迎你在留言区告诉我。我们下节课再见!
课程中完整示例代码详见GitHub仓库
评论