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

wzb
网易游戏高级开发工程师,现主要负责 CMDB 的前端开发工作

背景
随着业务的发展,项目下的各种资源会越来越多,越来越复杂。如何提供一种让用户快捷查看全局资源与模型关联关系的能力呢?资源拓扑图便是一种很好的方式。
本文将尽量简化业务上的内容,重点介绍如何使用 d3.js 来进行前端拓扑图的绘制。

为什么选择 d3?
d3.js (data driven ducument) 是一个实现数据可视化的前端 JavaScript 库。那么说到数据可视化,大家可能很快想到诸如 highchartsecharts 之类的库。而 highcharrs 和 echarts 比较常用于柱状图,折线图,饼图等统计类相关的图表展示,对于拓扑图可能不太适合。这里想要拿出来与 d3 进行对比的是以下几个库:go.js 和 AntV G6 。这几个库都能较好的满足业务的需求,这里直接放出这些库的一些优缺点:

jquery 网络拓扑js js 拓扑图_数据

通过以上的对比,最终我们还是选择了拓展性高,稳定且 免费 的 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 用来绘制一个圆,有三个主要属性:cxcyr,分别代表圆心的 x,y 坐标及圆的半径,当然这里的圆心坐标是相对于外层 svg 画布的左上角进行定位的。三个参数都是数字类型,虽然同样是以像素作为单位,但不需要加上 px。如下所示:

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

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

<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 中的 colorfill 表示填充色,相当于 background-color

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

jquery 网络拓扑js js 拓扑图_数据_02

我们可以用 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>

以上代码效果如下:

jquery 网络拓扑js js 拓扑图_数据_03

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 也提供了对应的镜像版本:

jquery 网络拓扑js js 拓扑图_数据_04

同时,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.attrselection.text 都使用了函数形式来指定设置的值。函数的入参就是单个 selection 元素所绑定的数据。

那么问题来了,如果数据中的数组长度是 3,是否意味着需要在 html 中写 3 个 circle 元素呢?也就是说,d3 是如何保证数据和元素是同步的,当数据和元素个数不匹配时,如何处理?

selection.enter
这个 API 实际上是一个过滤器,它会过滤出数据相对于元素多出来的部分。继续看上面的例子,如果 svg 元素中没有任何的 circle 元素,那么第一次调用上面的代码时,selectAll('circle') 选中的 selection 个数为 0。而此时 data(circleData) 中数组的长度为 2。因此调用 enter() 后返回的内容是一个长度为 2 的空 selection

jquery 网络拓扑js js 拓扑图_d3_05

随后我们往这个空的 selection 里添加了 circle 元素,并设置它的属性。
所以,enter() 的用途是:有数据,而没有足够元素的时候,使用此方法可以添加足够的元素。

selection.exit
和 enter() 相反,exit() 会过滤出元素相对于数据多出来的部分,常用于数据减少后,将多余元素进行删除。

jquery 网络拓扑js js 拓扑图_ci_06

比如我们将上述生成的两个圆都删掉:

const circleData = [];
d3.select('svg')
    .selectAll('circle')
    .data(circleData)
    .exit()
    .remove();

update

既然新增和删除都有对应的 API,那么元素的更新呢?只要没有调用 enter() 或者 exit(),默认选中的都是 update 的部分,如下图:

jquery 网络拓扑js js 拓扑图_数据_07

比如,我们需要将之前例子的两个圆的半径缩小到 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 来绘制资源拓扑图。

业务抽象

资源拓扑图一般是由一些节点和连线组成,表示各个节点之间的关系。以下是在实际业务中的一张效果展示图:

jquery 网络拓扑js js 拓扑图_d3_08

我们可以把上图中的内容简单抽象成 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 这两个参数从何而来?
这其实是一个纯前端使用的参数,后端开发肯定不关心你前端把这个节点摆在哪里,这意味着我们需要自己去计算每个节点的位置,即需要一个布局算法。

jquery 网络拓扑js js 拓扑图_ci_09

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

jquery 网络拓扑js js 拓扑图_ci_10

 

  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),感兴趣的同学可以参考官方文档(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 个节点的数据演示一下,效果如下:

jquery 网络拓扑js js 拓扑图_jquery 网络拓扑js_11

优化完善
上面的效果图存在很多“扎眼”的地方,我们来一一优化一下。

字体居中
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 是以左上角为起始的,所以需要用圆心坐标减去对应的半径
效果如下:

jquery 网络拓扑js js 拓扑图_数据_12

连线增加方向
为了表示连线的方向,我们可以给终点加上一个箭头。
我们可以利用 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)');

效果如下:

jquery 网络拓扑js js 拓扑图_数据_13

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

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

jquery 网络拓扑js js 拓扑图_ci_14

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

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

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

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

效果如下:

jquery 网络拓扑js js 拓扑图_数据_15

其他

当然,在真实的业务实现中,可能还会遇到很多其他的问题和需求:

  1. 节点太多,布局算法不是很理想。
    其实力导向图是一个极其复杂和灵活的算法,真正吃透可能需要了解很多物理学知识,就留给大家自己去消化吧。
  2. 给节点增加拖拽。
    d3.js 其实支持拖拽和缩放,限于篇幅,这里就不展开讲了。
  3. 性能
    当数据量太大时,手动触发力导向图进入静止状态也是非常耗时的,此时可能需要用到 web worker 来处理。(https://g.126.fm/04vfPbi)

结语

d3.js 是一个非常强大的可视化库,他能实现很多复杂的场景和需求。而其本质还是数据驱动,最大的难点在于数据的组装和维护。本文只是起到一个抛砖引玉的作用,剩下的还是要靠大家自己去慢慢尝试。希望可以和大家一起学习交流。

参考资料