使用 d3.js 绘制资源拓扑图
wzb
网易游戏高级开发工程师,现主要负责 CMDB 的前端开发工作
背景
随着业务的发展,项目下的各种资源会越来越多,越来越复杂。如何提供一种让用户快捷查看全局资源与模型关联关系的能力呢?资源拓扑图便是一种很好的方式。
本文将尽量简化业务上的内容,重点介绍如何使用 d3.js 来进行前端拓扑图的绘制。
为什么选择 d3?d3.js (data driven ducument)
是一个实现数据可视化的前端 JavaScript 库。那么说到数据可视化,大家可能很快想到诸如 highcharts
,echarts
之类的库。而 highcharrs
和 echarts
比较常用于柱状图,折线图,饼图等统计类相关的图表展示,对于拓扑图可能不太适合。这里想要拿出来与 d3 进行对比的是以下几个库:go.js
和 AntV G6
。这几个库都能较好的满足业务的需求,这里直接放出这些库的一些优缺点:
通过以上的对比,最终我们还是选择了拓展性高,稳定且 免费 的 d3.js。
PS:由于 d3 版本之间差距较大,且不是向后兼容的,本文所用的为最新的 d3 v5
svg 简介
前端可视化的库千千万,但归根结底,底层所用的技术无非就是 canvas 和 svg。d3 主要使用的是 svg。
SVG 是一种用于描述二维矢量图形的,基于 XML 的标记语言。它能和 HTML 及 CSS 一样被浏览器识别,我们可以简单的将其看作一类特殊的 HTML 元素。
这里要注意,在 HTML 中,所有的 SVG 类元素都必须嵌套在一个 <svg></svg>
中,否则浏览器不会进行渲染,这个 svg 元素相当于一个画布,有自己的尺寸,而其内部的元素默认都是基于其左上角进行定位的。
由于篇幅关系,详细的 SVG 内容这里就不再赘述,只简单介绍一些常用的 svg 元素,待会会用到。
circlecircle
用来绘制一个圆,有三个主要属性:cx
,cy
,r
,分别代表圆心的 x,y 坐标及圆的半径,当然这里的圆心坐标是相对于外层 svg 画布的左上角进行定位的。三个参数都是数字类型,虽然同样是以像素作为单位,但不需要加上 px
。如下所示:
<svg width="500px" height="500px">
<circle cx="60" cy="60" r="30"></circle>
</svg>
lineline
用来绘制一条直线。两点确定一条直线,因此通过四个属性可以定位一条 line
:x1
, y1
, x2
, y2
,分别表示两个点的横纵坐标。
<svg width="500px" height="500px">
<line x1="10" y1="10" x2="200" y2="200"></line>
</svg>
texttext
用来表示文字。它可以设置 x
和 y
属性来进行定位,同时还能设置样式:
<svg width="500px" height="500px">
<text x="10" y="20"
style="font-family: Times New Roman;
font-size : 24;
stroke : #00ff00;
fill : #0000ff;">
SVG text styling
</text>
</svg>
svg 中用 stroke
来表示线条颜色,相当于 css 中的 color
,fill
表示填充色,相当于 background-color
。
pathpath
是 svg 中的万金油元素,用它可以模拟任意形状,这主要是通过它的 d
属性来进行。d 属性实际上是一个字符串,包含了一系列路径指令。指令大小写敏感,大写的命令指明它的参数是绝对位置,而小写的命令指明是相对于当前位置:
我们可以用 path 来代替 line 绘制直线:
<svg width="500px" height="500px">
<path d="M 10, 10 L 200, 200"></path>
</svg>
textPathtextPath
可以通过其 xlink:href
属性值引用 <path>
元素实现将文字沿路径排列的效果。
<svg width="1000px" height="300px">
<path id="MyPath"
d="M 100 200
C 200 100 300 0 400 100
C 500 200 600 300 700 200
C 800 100 900 100 900 100"
stroke="red" fill="none" />
<text font-size="42.5">
<textPath xlink:href="#MyPath">
We go up, then we go down, then up again
</textPath>
</text>
</svg>
以上代码效果如下:
d3 使用初探
介绍完 svg 的一些基本元素,那么接下来就要使用 d3 将这些元素组合起来,进行资源拓扑图的绘制。
d3 的使用很像 jQuery
,需要将数据和 DOM 节点进行绑定,数据变化后,需要手动处理来绘制新的视图。这对用惯了现代前端框架的双向绑定,自动更新视图的开发者来说可能有些不适应。
操作 DOM
d3 提供了 d3.select
和 d3.selectAll
两个 API,根据 CSS 选择器来选取 DOM 节点。但是它们返回的并不是真正的 DOM 节点,而是会对 DOM 做一层封装,我们姑且称之为 selection
。可以通过 selection.nodes()
来获取真正的 DOM 节点。
以下使用 selection
来指代通过 d3.select
或 d3.selectAll
选中的内容
相对应的,对于 DOM 节点上的一些 API,d3 也提供了对应的镜像版本:
同时,d3 也能像 jQuery 一样链式的调用这些方法,从而更快捷的操作。
下面的代码会在 <svg></svg>
标签中绘制一个圆:
d3.select('svg')
.append('circle')
.attr('cx', 60)
.attr('cy', 60)
.attr('r', 30)
.append('text')
.text('资源节点');
如果使用的是 d3.selectAll
,则链式调用会作用于每一个选中的元素。
数据驱动d3
的全称是 Data-Driven Documents
,而 d3 实现数据驱动主要靠以下几个 API:
通过 selection.data
可以将数据和元素进行绑定。这里的 data 是一个数组。那么绑定了数据后,该怎么使用呢?
回到上面的绘制资源节点的例子:
d3.select('svg')
.append('circle')
.attr('cx', 60)
.attr('cy', 60)
.attr('r', 30)
.append('text')
.text('资源节点');
我们只绘制了一个圆,而且使用了魔法数字(https://g.126.fm/03DTeJa)
我们需要对数据(圆心坐标,半径)进行一个集中定义,或者从后端获取这些数据并一次性绘制出来:
const circleData = [
{
cx: 30,
cy: 30,
r: 30,
nodeName: '节点1'
},
{
cx: 30,
cy: 100,
r: 30,
nodeName: '节点2'
}
];
d3.select('svg')
.selectAll('circle')
.data(circleData)
.enter()
.append('circle')
.attr('cx', d => d.cx)
.attr('cy', d => d.cy)
.attr('r', d => d.r)
.append('text')
.text(d => d.nodeName);
对比一下可以看到有以下几个改动:
- 我们使用了
d3.selectAll
代替了d3.select
来选中所有 circle 元素; - 使用了
selection.data
来为元素绑定数据,相当于将selections
做了一次遍历,给每个selection
增加一个data
属性; -
selection.enter
我们暂且不管,后面再说; -
selection.attr
,selection.text
都使用了函数形式来指定设置的值。函数的入参就是单个selection
元素所绑定的数据。
那么问题来了,如果数据中的数组长度是 3,是否意味着需要在 html 中写 3 个 circle 元素呢?也就是说,d3 是如何保证数据和元素是同步的,当数据和元素个数不匹配时,如何处理?
selection.enter
这个 API 实际上是一个过滤器,它会过滤出数据相对于元素多出来的部分。继续看上面的例子,如果 svg 元素中没有任何的 circle 元素,那么第一次调用上面的代码时,selectAll('circle')
选中的 selection
个数为 0。而此时 data(circleData)
中数组的长度为 2。因此调用 enter()
后返回的内容是一个长度为 2 的空 selection
。
随后我们往这个空的 selection
里添加了 circle
元素,并设置它的属性。
所以,enter()
的用途是:有数据,而没有足够元素的时候,使用此方法可以添加足够的元素。
selection.exit
和 enter()
相反,exit()
会过滤出元素相对于数据多出来的部分,常用于数据减少后,将多余元素进行删除。
比如我们将上述生成的两个圆都删掉:
const circleData = [];
d3.select('svg')
.selectAll('circle')
.data(circleData)
.exit()
.remove();
update
既然新增和删除都有对应的 API,那么元素的更新呢?只要没有调用 enter()
或者 exit()
,默认选中的都是 update
的部分,如下图:
比如,我们需要将之前例子的两个圆的半径缩小到 20:
circleData.forEach((item) => {
item.r = 20;
});
d3.select('svg')
.selectAll('circle')
.data(circleData)
.attr('r', d => d.r);
selection.join
前面讲的几种情况,都只单一的处理了一种情况:
- enter 处理数据多于元素的情况;
- update 处理数据个数没有变化的情况;
- exit 处理数据少于元素的情况。
而数据个数发生变化的同时,原有数据也有可能发生了变化,那么按照之前的介绍,我们需要这样写:
// update 部分
d3.select('svg')
.selectAll('circle')
.data(circleData)
.attr('r', d => d.r);
// enter 部分
d3.select('svg')
.selectAll('circle')
.data(circleData)
.enter()
.append('circle')
.attr('cx', d => d.cx)
.attr('cy', d => d.cy)
.attr('r', d => d.r)
.append('text')
.text(d => d.nodeName);
这时候就非常需要 selection.join
了,它能将几种操作进行合并,减少重复代码:
d3.select('svg')
.selectAll('circle')
.data(circleData)
.join(
enter => enter.append('circle')
.attr('cx', d => d.cx)
.attr('cy', d => d.cy)
.attr('r', d => d.r)
.append('text')
.text(d => d.nodeName),
update => update.attr('r', d => d.r),
exit => exit.remove()
);
可以看到,enter 和 update 部分还有一些相同的代码,可以进一步简化:
d3.select('svg')
.selectAll('circle')
.data(circleData)
.join(
enter => enter.append('circle')
.attr('cx', d => d.cx)
.attr('cy', d => d.cy)
.append('text')
.text(d => d.nodeName),
update => update,
exit => exit.remove()
)
.attr('r', d => d.r);
d3.js 实战 —— 绘制资源拓扑图
上面简单介绍了一下 svg 基础和 d3.js 的一些使用方法,接下来我们进入实战阶段。回到我们开篇的主题,如何使用 d3.js 来绘制资源拓扑图。
业务抽象
资源拓扑图一般是由一些节点和连线组成,表示各个节点之间的关系。以下是在实际业务中的一张效果展示图:
我们可以把上图中的内容简单抽象成 svg 能表现的元素:
- 节点定义成圆形
circle
。当然,如果你的节点想表现的更加丰富多彩一些,比如加入一些图片或者 css 3 动画等,可以用 html 来进行绘制 - 节点之间的连线用
path
表示,如果只有直线也可以用line
- 节点和连线上的文字
text
。当然,连线上的文字也可以搭配textPath
来实现一些酷炫效果
数据组装
根据抽象出的元素类型,我们需要组装出各个元素所需要的数据内容:
- 绘制
circle
必须传入圆心坐标和半径,同时可以定制圆形的填充色,边框色等 - 绘制
path
必须传入起点坐标和终点坐标,同时可以定制连线的颜色,粗细,样式等 - 绘制
text
必须传入文字坐标及文字内容,同时可以定制文字的颜色,粗细,字体等
可以发现,其中最核心的数据就是节点和连线,大致数据结构如下:
// Node
{
x: Integer, // 节点横坐标
y: Integer, // 节点纵坐标
radius: Integer, // 节点半径
text: String // 节点文字
strokeColor: String, // 节点边框颜色
fillColor: String, // 节点填充颜色
... // 其他自定义字段
}
// Link
{
source: Node, // 起始节点
target: Node, // 终止节点
strokeColor: String, // 连线颜色
strokeWidth: Number, // 连线宽度
... // 其他自定义字段
}
对应到具体的业务中,各个字段可能都有不同的含义和组装规则,这里就不展开了。
那么,问题来了:节点的 x, y
这两个参数从何而来?
这其实是一个纯前端使用的参数,后端开发肯定不关心你前端把这个节点摆在哪里,这意味着我们需要自己去计算每个节点的位置,即需要一个布局算法。
对于一些简单的多行多列布局场景,我们可以逐个计算每个节点的位置,比如下面这个布局,三层排列,包含一个中心节点,上下两层节点列宽平分:
- 方法一:直接绘制 html,通过 flex 实现自动布局,然后通过 DOM 操作获取各节点坐标;
- 方法二:通过获取容器宽高,算出每一列宽度然后计算出各节点圆心的位置。
力导向图
对于一些复杂场景,我们逐一去计算节点位置似乎不太可行。这时候不要惊慌,d3.js 为大家提供了一种强大的布局算法:力导向图(Force-Directed Graph),它可以模拟物理界的各种作用力,使节点间相互碰撞和运动并最终达到一种静止状态。它会将静止状态时的节点位置作为节点的 x 和 y 坐标。
d3.js 力导向图中提供了 5 种作用力:
- 中心力(Centering)
中心力作用于所有的节点而不是某些单独节点,可以将所有节点的中心一致的向指定的位置移动,而且这种移动不会修改速度也不会影响节点间的相对位置。 - 碰撞力(Collision)
碰撞力将每个节点视为一个具有一定半径的圆,这个力会阻止代表节点的圆相互重叠,即两个节点间会相互碰撞,可以通过 strength 来设置力的强度。 - 弹簧力(Links)
当两个节点通过设置 link 连接到一起后,可以设置弹簧力,这个力将根据两个节点间的距离将两个节点拉近或推远,力的强度和这个距离成比例,就和弹簧一样。 - 电荷力(Many-Body)
模拟所有节点间的相互作用力,如果值为正则节点间就会相互吸引,可以用来模拟电荷吸引力,如果值为负则节点间就会相互排斥。这个力的大小也和节点间的距离有关。 - 定位力(Positioning)
这个力可以将节点沿着指定的维度推向一个指定位置,比如通过设置 forceX 和 forceY 就可以在 X 轴 和 Y 轴方向推或者拉所有的节点,forceRadial 则可以形成一个圆环把所有的节点都往这个圆环上相应的位置推。
回到我们的场景中:
- 节点之间通过连线表示节点之间的关系,类似于弹簧力,通过连线互相牵引;
- 节点是有半径的,需要碰撞力来防止节点之间重合;
- 节点布局的容器大小是固定的,为了防止节点跑出边界,需要增加一个中心力来将所有节点往容器中心推
对应的代码如下:
const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links))
// 使每两个节点之间的距离都至少是节点半径的两倍,避免节点相互覆盖
.force('collision', d3.forceCollide().radius(RADIUS * 2))
// 施加中心力,将整个布局拉向容器中心
.force('center', d3.forceCenter(containerWidth / 2, containerHeight / 2))
.stop();
力导向图形成静止状态有一个计算过程,默认是自动计算的。这个计算过程的长短受两个参数影响,计算公式为:log(alphaMin) / log(1 - alphaDecay)
,感兴趣的同学可以参考官方文档(https://g.126.fm/00gYsZG) 。其中每一次计算(称作一个 tick)都有一个对应的布局快照,可以通过设置事件监听来对每一个快照进行操作,更新 DOM。但当数据量大时,这样做会有非常大的性能开销。这里我们只需要使用最终的静止状态来进行绘制即可,所以上述代码使用了 .stop()
停掉布局自动计算的默认行为。然后我们采用手动触发的方式来让布局达到静止状态,此时 nodes 中每个节点都会自动带上 x 和 y 属性了:
// 手动调用 tick 使布局达到稳定状态
for (let i = 0, n = Math.ceil(Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay())); i < n; i++) {
simulation.tick();
}
绘制拓扑
利用力导向图解决了节点的坐标之后,我们拿到了完整的数据,现在可以利用这些数据来进行拓扑图的绘制了:
const svg = d3.select('#svg');
// 节点
let nodeSelection = svg.selectAll('circle')
.data(nodes)
.join('circle')
.attr('cx', d => d.x)
.attr('cy', d => d.y)
.attr('r', d => d.radius)
.attr('stroke', d => d.strokeColor)
.attr('fill', d => d.fillColor);
// 节点文字
let textSelection = svg.selectAll('text')
.data(nodes)
.join('text')
.attr('x', d => d.x)
.attr('y', d => d.y)
.attr('stroke', d => d.strokeColor)
.attr('fill', d => d.fillColor)
// 连线
let linkSelection = svg.selectAll('path')
.data(links)
.join('path')
.attr('stroke-width', d => d.strokeWidth)
.attr('stroke', d => d.strokeColor)
.attr('d', (d) => {
const { source, target } = d;
return `M ${source.x} ${source.y} L ${target.x} ${target.y}`;
});
这里简单用 4 个节点的数据演示一下,效果如下:
优化完善
上面的效果图存在很多“扎眼”的地方,我们来一一优化一下。
字体居中
svg 中,text 元素的 x 和 y 是基于字体的 baseLine 进行设置的,可以使用 dx 和 dy 来设置偏移量。但我们需要根据文字的长度来动态计算其偏移量,操作起来较为麻烦。因此我们可以换一个思路,把 text 全部换成 html 来做。对于 html 来说,字体居中就非常简单了。
.text {
position: 'absolute';
display: 'flex';
align-items: 'center';
justify-content: 'center';
}
// 节点文字
textSelection = d3.select('#wrapper')
.selectAll('.text')
.data(nodes)
.join('div')
.attr('class', 'text')
.text(d => d.text)
.style('left', d => `${d.x - d.r}px`)
.style('top', d => `${d.y - d.r}px`)
.style('width', d => `${d.r * 2}px`)
.style('height', d => `${d.r * 2}px`);
这里要注意,数据中的 x 和 y 表示的是 circle 的圆心坐标,使用 html 时定位用的 left 和 top 是以左上角为起始的,所以需要用圆心坐标减去对应的半径
效果如下:
连线增加方向
为了表示连线的方向,我们可以给终点加上一个箭头。
我们可以利用 path
元素上的 marker-end
属性来实现这个效果。
// 绘制一个箭头图形
d3.select('body')
.append('svg')
.append('marker')
.attr('id', 'arrow')
.attr('viewBox', '-10 -10 20 20')
.attr('markerWidth', '20')
.attr('markerHeight', '20')
.attr('orient', 'auto')
.append('path')
.attr('d', 'M-4,-3 L 0,0 L -4, 3')
.attr('fill', '#333');
// 在连线上增加 marker-end 并指向上述图形
linkSelection.attr('marker-end', 'url(#arrow)');
效果如下:
看起来好像没什么区别?
那是因为我们连线的起点和终点都是圆的圆心,导致箭头被文字挡住了。
连线被文字遮挡
我们只需要保留连线与两个圆的两个交点之间的那一段就可以了,如下图红色线条:
那么问题来了,如何求线与圆的交点呢?
利用高中知识,我们可以通过圆和直线的方程,代入圆心坐标求得表达式,然后通过解二元方程组得出交点。
得,想想就头疼,我不想努力了。
为了节省大家解方程的时间,还是直接上法宝吧 —— 有大佬已经实现了这种算法(https://g.126.fm/019trY4) 。
我们利用这个算法求出原来的 path 路径和起点终点两个圆的两个交点,并把交点作为新的起点和终点重新绘制 path 即可。
import { Intersection, ShapeInfo } from 'kld-intersections';
linkSelection.attr('d', (d) => {
const { source, target } = d;
// 起点所在的圆
const sourceCircle = ShapeInfo.circle({
center: [source.x, source.y], radius: source.r
});
// 终点所在的圆
const targetCircle = ShapeInfo.circle({
center: [target.x, target.y], radius: target.r
});
// 原先的 path 路径
const path = ShapeInfo.path(`M ${source.x} ${source.y} L ${target.x} ${target.y}`);
// 起点圆与 path 的交点
const sourceIntersection = Intersection.intersect(sourceCircle, path).points[0];
// 终点圆与 path 的交点
const targetIntersection = Intersection.intersect(targetCircle, path).points[0];
// 新的 path 路径
return `
M ${sourceIntersection.x} ${sourceIntersection.y}
L ${targetIntersection.x} ${targetIntersection.y}
`;
});
效果如下:
其他
当然,在真实的业务实现中,可能还会遇到很多其他的问题和需求:
- 节点太多,布局算法不是很理想。
其实力导向图是一个极其复杂和灵活的算法,真正吃透可能需要了解很多物理学知识,就留给大家自己去消化吧。 - 给节点增加拖拽。
d3.js 其实支持拖拽和缩放,限于篇幅,这里就不展开讲了。 - 性能
当数据量太大时,手动触发力导向图进入静止状态也是非常耗时的,此时可能需要用到 web worker 来处理。(https://g.126.fm/04vfPbi)
结语
d3.js 是一个非常强大的可视化库,他能实现很多复杂的场景和需求。而其本质还是数据驱动,最大的难点在于数据的组装和维护。本文只是起到一个抛砖引玉的作用,剩下的还是要靠大家自己去慢慢尝试。希望可以和大家一起学习交流。
参考资料
- SVG 元素参考
(https://g.126.fm/0440kH6) - d3.js API
(https://g.126.fm/0309ow8) - d3.js 力导向图
(https://g.126.fm/01xjKTu) - kld-intersections
(https://g.126.fm/019trY4) - web worker 处理力导向图布局示例
(https://g.126.fm/02cbZCM) - 使用 d3.js 力导布局绘制资源拓扑图
(https://g.126.fm/00SFXeo)