wzb
网易游戏高级开发工程师,现主要负责 CMDB 的前端开发工作随着业务的发展,项目下的各种资源会越来越多,越来越复杂。如何提供一种让用户快捷查看全局资源与模型关联关系的能力呢?资源拓扑图便是一种很好的方式。
本文将尽量简化业务上的内容,重点介绍如何使用 d3.js 来进行前端拓扑图的绘制。
d3.js (data driven ducument)
是一个实现数据可视化的前端 JavaScript 库。那么说到数据可视化,大家可能很快想到诸如 highcharts
,echarts
之类的库。而 highcharrs
和 echarts
比较常用于柱状图,折线图,饼图等统计类相关的图表展示,对于拓扑图可能不太适合。这里想要拿出来与 d3 进行对比的是以下几个库:go.js
和 AntV G6
。这几个库都能较好的满足业务的需求,这里直接放出这些库的一些优缺点:
通过以上的对比,最终我们还是选择了拓展性高,稳定且 免费 的 d3.js。
PS:由于 d3 版本之间差距较大,且不是向后兼容的,本文所用的为最新的 d3 v5
前端可视化的库千千万,但归根结底,底层所用的技术无非就是 canvas 和 svg。d3 主要使用的是 svg。
SVG 是一种用于描述二维矢量图形的,基于 XML 的标记语言。它能和 HTML 及 CSS 一样被浏览器识别,我们可以简单的将其看作一类特殊的 HTML 元素。
这里要注意,在 HTML 中,所有的 SVG 类元素都必须嵌套在一个 中,否则浏览器不会进行渲染,这个 svg 元素相当于一个画布,有自己的尺寸,而其内部的元素默认都是基于其左上角进行定位的。
由于篇幅关系,详细的 SVG 内容这里就不再赘述,只简单介绍一些常用的 svg 元素,待会会用到。
circle
用来绘制一个圆,有三个主要属性:cx
,cy
,r
,分别代表圆心的 x,y 坐标及圆的半径,当然这里的圆心坐标是相对于外层 svg 画布的左上角进行定位的。三个参数都是数字类型,虽然同样是以像素作为单位,但不需要加上 px
。如下所示:
line
用来绘制一条直线。两点确定一条直线,因此通过四个属性可以定位一条 line
:x1
, y1
, x2
, y2
,分别表示两个点的横纵坐标。
text
用来表示文字。它可以设置 x
和 y
属性来进行定位,同时还能设置样式:
svg 中用 stroke
来表示线条颜色,相当于 css 中的 color
,fill
表示填充色,相当于 background-color
。
path
是 svg 中的万金油元素,用它可以模拟任意形状,这主要是通过它的 d
属性来进行。d 属性实际上是一个字符串,包含了一系列路径指令。指令大小写敏感,大写的命令指明它的参数是绝对位置,而小写的命令指明是相对于当前位置:
我们可以用 path 来代替 line 绘制直线:
textPath
可以通过其 xlink:href
属性值引用 元素实现将文字沿路径排列的效果。
以上代码效果如下:
介绍完 svg 的一些基本元素,那么接下来就要使用 d3 将这些元素组合起来,进行资源拓扑图的绘制。
d3 的使用很像 jQuery
,需要将数据和 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 一样链式的调用这些方法,从而更快捷的操作。
下面的代码会在 标签中绘制一个圆:
select(
如果使用的是 d3.selectAll
,则链式调用会作用于每一个选中的元素。
d3
的全称是 Data-Driven Documents
,而 d3 实现数据驱动主要靠以下几个 API:
通过 selection.data
可以将数据和元素进行绑定。这里的 data 是一个数组。那么绑定了数据后,该怎么使用呢?
回到上面的绘制资源节点的例子:
select(
我们只绘制了一个圆,而且使用了魔法数字(https://g.126.fm/03DTeJa)
我们需要对数据(圆心坐标,半径)进行一个集中定义,或者从后端获取这些数据并一次性绘制出来:
const circleData = [
对比一下可以看到有以下几个改动:
我们使用了 d3.selectAll
代替了 d3.select
来选中所有 circle 元素;
使用了 selection.data
来为元素绑定数据,相当于将 selections
做了一次遍历,给每个 selection
增加一个 data
属性;
selection.enter
我们暂且不管,后面再说;
selection.attr
,selection.text
都使用了函数形式来指定设置的值。函数的入参就是单个 selection
元素所绑定的数据。
那么问题来了,如果数据中的数组长度是 3,是否意味着需要在 html 中写 3 个 circle 元素呢?也就是说,d3 是如何保证数据和元素是同步的,当数据和元素个数不匹配时,如何处理?
这个 API 实际上是一个过滤器,它会过滤出数据相对于元素多出来的部分。继续看上面的例子,如果 svg 元素中没有任何的 circle 元素,那么第一次调用上面的代码时,selectAll('circle')
选中的 selection
个数为 0。而此时 data(circleData)
中数组的长度为 2。因此调用 enter()
后返回的内容是一个长度为 2 的空 selection
。
随后我们往这个空的 selection
里添加了 circle
元素,并设置它的属性。
所以,enter()
的用途是:有数据,而没有足够元素的时候,使用此方法可以添加足够的元素。
和 enter()
相反,exit()
会过滤出元素相对于数据多出来的部分,常用于数据减少后,将多余元素进行删除。
比如我们将上述生成的两个圆都删掉:
const circleData = [];
既然新增和删除都有对应的 API,那么元素的更新呢?只要没有调用 enter()
或者 exit()
,默认选中的都是 update
的部分,如下图:
比如,我们需要将之前例子的两个圆的半径缩小到 20:
(item) => {
前面讲的几种情况,都只单一的处理了一种情况:
enter 处理数据多于元素的情况;
update 处理数据个数没有变化的情况;
exit 处理数据少于元素的情况。
而数据个数发生变化的同时,原有数据也有可能发生了变化,那么按照之前的介绍,我们需要这样写:
// update 部分
这时候就非常需要 selection.join
了,它能将几种操作进行合并,减少重复代码:
'svg')
可以看到,enter 和 update 部分还有一些相同的代码,可以进一步简化:
'svg')
上面简单介绍了一下 svg 基础和 d3.js 的一些使用方法,接下来我们进入实战阶段。回到我们开篇的主题,如何使用 d3.js 来绘制资源拓扑图。
资源拓扑图一般是由一些节点和连线组成,表示各个节点之间的关系。以下是在实际业务中的一张效果展示图:
我们可以把上图中的内容简单抽象成 svg 能表现的元素:
节点定义成圆形 circle
。当然,如果你的节点想表现的更加丰富多彩一些,比如加入一些图片或者 css 3 动画等,可以用 html 来进行绘制
节点之间的连线用 path
表示,如果只有直线也可以用 line
节点和连线上的文字 text
。当然,连线上的文字也可以搭配 textPath
来实现一些酷炫效果
根据抽象出的元素类型,我们需要组装出各个元素所需要的数据内容:
绘制 circle
必须传入圆心坐标和半径,同时可以定制圆形的填充色,边框色等
绘制 path
必须传入起点坐标和终点坐标,同时可以定制连线的颜色,粗细,样式等
绘制 text
必须传入文字坐标及文字内容,同时可以定制文字的颜色,粗细,字体等
可以发现,其中最核心的数据就是节点和连线,大致数据结构如下:
// Node
对应到具体的业务中,各个字段可能都有不同的含义和组装规则,这里就不展开了。
那么,问题来了:节点的 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)
力导向图形成静止状态有一个计算过程,默认是自动计算的。这个计算过程的长短受两个参数影响,计算公式为:log(alphaMin) / log(1 - alphaDecay)
,感兴趣的同学可以参考官方文档(https://g.126.fm/00gYsZG) 。其中每一次计算(称作一个 tick)都有一个对应的布局快照,可以通过设置事件监听来对每一个快照进行操作,更新 DOM。但当数据量大时,这样做会有非常大的性能开销。
这里我们只需要使用最终的静止状态来进行绘制即可,所以上述代码使用了 .stop()
停掉布局自动计算的默认行为。然后我们采用手动触发的方式来让布局达到静止状态,此时 nodes 中每个节点都会自动带上 x 和 y 属性了:
// 手动调用 tick 使布局达到稳定状态
利用力导向图解决了节点的坐标之后,我们拿到了完整的数据,现在可以利用这些数据来进行拓扑图的绘制了:
const svg = d3.select(
这里简单用 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
属性来实现这个效果。
// 绘制一个箭头图形
效果如下:
看起来好像没什么区别?
那是因为我们连线的起点和终点都是圆的圆心,导致箭头被文字挡住了。
我们只需要保留连线与两个圆的两个交点之间的那一段就可以了,如下图红色线条:
那么问题来了,如何求线与圆的交点呢?
利用高中知识,我们可以通过圆和直线的方程,代入圆心坐标求得表达式,然后通过解二元方程组得出交点。
得,想想就头疼,我不想努力了。
为了节省大家解方程的时间,还是直接上法宝吧 —— 有大佬已经实现了这种算法(https://g.126.fm/019trY4) 。
我们利用这个算法求出原来的 path 路径和起点终点两个圆的两个交点,并把交点作为新的起点和终点重新绘制 path 即可。
import { Intersection, ShapeInfo }
效果如下:
当然,在真实的业务实现中,可能还会遇到很多其他的问题和需求:
节点太多,布局算法不是很理想。
其实力导向图是一个极其复杂和灵活的算法,真正吃透可能需要了解很多物理学知识,就留给大家自己去消化吧。
给节点增加拖拽。
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)
往期精彩
﹀
﹀
﹀
运维里的人工智能
CI构建环境下的docker build最佳实践
浏览器中执行 C 语言?WebAssembly 实践
校招面试问到Linux CPU不用怕,来看看这份宝典
终于不用为大表添加列而烦恼了!