使用 d3.js 绘制资源拓扑图

使用 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 元素,待会会用到。

circle
circle 用来绘制一个圆,有三个主要属性: cx cy r ,分别代表圆心的 x,y 坐标及圆的半径,当然这里的圆心坐标是相对于外层 svg 画布的左上角进行定位的。三个参数都是数字类型,虽然同样是以像素作为单位,但不需要加上 px 。如下所示:

<svg width="500px" height="500px">
 <circle cx="60" cy="60" r="30"></circle> 
</svg>

line
line 用来绘制一条直线。两点确定一条直线,因此通过四个属性可以定位一条 line x1 , y1 , x2 , y2 ,分别表示两个点的横纵坐标。

<svg width="500px" height="500px">
 <line x1="10" y1="10" x2="200" y2="200"></line>
</svg>

text
text 用来表示文字。它可以设置 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

path
path 是 svg 中的万金油元素,用它可以模拟任意形状,这主要是通过它的 d 属性来进行。d 属性实际上是一个字符串,包含了一系列路径指令。指令大小写敏感,大写的命令指明它的参数是绝对位置,而小写的命令指明是相对于当前位置:


我们可以用 path 来代替 line 绘制直线:

<svg width="500px" height="500px">
 <path d="M 10, 10 L 200, 200"></path>
</svg>

textPath
textPath 可以通过其 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

通过 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);

对比一下可以看到有以下几个改动:

  1. 我们使用了 d3.selectAll 代替了 d3.select 来选中所有 circle 元素;
  2. 使用了 selection.data 来为元素绑定数据,相当于将 selections 做了一次遍历,给每个 selection 增加一个 data 属性;
  3. selection.enter 我们暂且不管,后面再说;
  4. 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 这两个参数从何而来?
这其实是一个纯前端使用的参数,后端开发肯定不关心你前端把这个节点摆在哪里,这意味着我们需要自己去计算每个节点的位置,即需要一个布局算法。


对于一些简单的多行多列布局场景,我们可以逐个计算每个节点的位置,比如下面这个布局,三层排列,包含一个中心节点,上下两层节点列宽平分:


  1. 方法一:直接绘制 html,通过 flex 实现自动布局,然后通过 DOM 操作获取各节点坐标;
  2. 方法二:通过获取容器宽高,算出每一列宽度然后计算出各节点圆心的位置。

力导向图
对于一些复杂场景,我们逐一去计算节点位置似乎不太可行。这时候不要惊慌,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) ,感兴趣的同学可以参考官方文档( 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)');

效果如下:


看起来好像没什么区别?
那是因为我们连线的起点和终点都是圆的圆心,导致箭头被文字挡住了。

连线被文字遮挡
我们只需要保留连线与两个圆的两个交点之间的那一段就可以了,如下图红色线条:



那么问题来了,如何求线与圆的交点呢?

利用高中知识,我们可以通过圆和直线的方程,代入圆心坐标求得表达式,然后通过解二元方程组得出交点。

得,想想就头疼,我不想努力了。

为了节省大家解方程的时间,还是直接上法宝吧 —— 有大佬已经实现了这种算法( 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];