利用d3.js力导布局绘制资源拓扑图实例教程


Posted in Javascript onJanuary 08, 2019

前言

最近公司业务服务老出bug,各路大佬盯着链路图找问题找的头昏眼花。某天大佬丢了一张图过来“我们做一个资源拓扑图吧,方便大家找bug”。

利用d3.js力导布局绘制资源拓扑图实例教程

就是这个图,应该是马爸爸家的

好吧,来仔细瞧瞧这个需求咋整呢。一圈资源围着一个中心的一个应用,用曲线连接起来,曲线中段记有应用与资源间的调用信息。emmm 这个看起来很像女神在遛一群舔狗... 啊不,是 d3.js 力导向图!

d3.js 力导向图

d3.js 是著名的数据可视化基础工具,他提供了基本的将数据映射至网页元素的能力,同时封装了大量实用的数据操作函数与图形算法。其中力导向图(Force-Directed Graph)是 d3.js 提供的一种十分经典的绘图算法。通过在二维空间里配置节点和连线,在各种各样力的作用下,节点间相互碰撞和运动并在这个过程中不断地降低能量,最终达到一种能量很低的安定状态,形成一种稳定的力导向图。

d3.js 力导向图中默认提供了 5 种作用力(以最新的 5.x 为准):

中心力(Centering)

中心力作用于所有的节点而不是某些单独节点,可以将所有的节点的中心一致的向指定的位置移动,而且这种移动不会修改速度也不会影响节点间的相对位置。

碰撞力(Collision)

碰撞力将每个节点视为一个具有一定半径的圆,这个力会阻止代表节点的这个圆相互重叠,即两个节点间会相互碰撞,可以通过设置 strength 设置这个碰撞力的强度。

弹簧力(Links)

当两个节点通过设置 link 连接到一起后,可以设置弹簧力,这个力将根据两个节点间的距离将两个节点拉近或推远,力的强度和这个距离成比例就和弹簧一样。

电荷力(Many-Body)

通过设置 strength 来模拟所有节点间的相互作用力,如果为正节点间就会相互吸引,可以用来模拟电荷吸引力,如果为负节点间就会相互排斥。这个力的大小也和节点间的距离有关。

定位力(Positioning)

这个力可以将节点沿着指定的维度推向一个指定位置,比如通过设置 forceX 和 forceY 就可以在 X轴 和 Y轴 方向推或者拉所有的节点,forceRadial 则可以形成一个圆环把所有的节点都往这个圆环上相应的位置推。

回到这个需求上,其实可以把应用、所有的资源与调用信息都看成节点,资源之间通过一个较弱的弹簧力与调用信息连接起来,同时如果应用与资源间的调用有来有往,则在这两个调用信息之间加上一个较强的弹簧力。

利用d3.js力导布局绘制资源拓扑图实例教程

ok说干就干

// 所有代码基于 typescript,省略部分代码

type INode = d3.SimulationNodeDatum & { 
 id: string
 label: string;
 isAppNode?: boolean;
};

type ILink = d3.SimulationLinkDatum<INode> & { 
 strength: number;
};

const nodes: INode[] = [...]; 
const links: ILink[] = [...];

const container = d3.select('container');

const svg = container.select('svg') 
 .attr('width', width)
 .attr('height', height);

const html = container.append('div') 
 .attr('class', styles.HtmlContainer);

// 创建一个弹簧力,根据 link 的 strength 值决定强度
const linkForce = d3.forceLink<INode, ILink>(links) 
 .id(node => node.id)
 // 资源节点与信息节点间的 strength 小一点,信息节点间的 strength 大一点
 .strength(link => link.strength);

const simulation = d3.forceSimulation<INode, ILink>(nodes) 
 .force('link', linkForce)
 // 在 y轴 方向上施加一个力把整个图形压扁一点
 .force('yt', d3.forceY().strength(() => 0.025)) 
 .force('yb', d3.forceY(height).strength(() => 0.025))
 // 节点间相互排斥的电磁力
 .force('charge', d3.forceManyBody<INode>().strength(-400))
 // 避免节点相互覆盖
 .force('collision', d3.forceCollide().radius(d => 4))
 .force('center', d3.forceCenter(width / 2, height / 2))
 .stop();

// 手动调用 tick 使布局达到稳定状态
for (let i = 0, n = Math.ceil(Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay())); i < n; ++i) { 
 simulation.tick();
}

const nodeElements = svg.append('g') 
 .selectAll('circle')
 .data(nodes)
 .enter().append('circle')
 .attr('r', 10)
 .attr('fill', getNodeColor);

const labelElements = svg.append('g') 
 .selectAll('text')
 .data(nodes)
 .enter().append('text')
 .text(node => node.label)
 .attr('font-size', 15);

const pathElements = svg.append('g') 
 .selectAll('line')
 .data(links)
 .enter().append('line')
 .attr('stroke-width', 1)
 .attr('stroke', '#E5E5E5');

const render = () => { 
 nodeElements
 .attr('cx', node => node.x!)
 .attr('cy', node => node.y!);
 labelElements
 .attr('x', node => node.x!)
 .attr('y', node => node.y!);
 pathElements
 .attr('x1', link => link.source.x)
 .attr('y1', link => link.source.y)
 .attr('x2', link => link.target.x)
 .attr('y2', link => link.target.y);
}

render();

效果如下:

利用d3.js力导布局绘制资源拓扑图实例教程

ok 已经基本实现啦,那就这样啦,等后台同学实现一下接口就可以上线啦,日均UV两位数的产品要啥自行车,有的看就不错了(手动二哈)。

当然不行了,有这么一个都市传说,中台产品的好用与否与离职率高低成相关关系。本来需要打开资源拓扑图就是一件很?的事了,再看到这么一款体验极差的产品,感觉分分钟就要离职了。为了给我司年交易额两万亿的长远目标添砖加瓦,我们来看看有啥需要改进的地方。

至少字给我居中吧

注意到我们的字都是左下角定位到节点中心的,这是因为我们使用的是 svg 的 text 元素,默认情况下给 text 元素设置的 x 和 y 代表了 text 元素 baseLine 的起始位置。当然我们可以通过直接设置 dx 与 dy 设置一个偏移量来完成居中的问题,但考虑到 svg 元素相比普通的 html 元素毕竟还是有所限制,并不方便将来的扩展啥的,所以我们索性把所有的圆点与文字都换成 html 元素。

...

const nodeElements = html.append('div') 
 .selectAll('div')
 .data(nodes.filter(node => node.isAppNode))
 .enter().append('div')
 // css modules
 .attr('class', styles.NodeItem)
 .html((node: INode) => {
 return `<p>${node.id}</p>`;
 });

const labelElements = html.append('div') 
 .selectAll('div')
 .data(nodes.filter(node => !node.isAppNode))
 .enter().append('div')
 // css modules
 .attr('class', styles.LabelItem)
 .html(node => `
 <p>${node.label}</p>
 <p>Avada Kedavra!</p>
 `);

...

const render = () => { 
 nodeElements
 .attr('style', (node) => {
 return `transform: translate3d(calc(${node.x}px - 50%), calc(${node.y}px - 50%), 0);`;
 });

 labelElements
 .attr('style', (node) => {
 return `transform: translate3d(calc(${node.x}px - 50%), calc(${node.y}px - 50%), 0);`;
 });
}

效果如下:

利用d3.js力导布局绘制资源拓扑图实例教程

字都居中了!

这个线怎么跟激光似的,一点也不像在遛舔狗

再来看看这个线,我们一开始是把所有代表弹簧力的线段当成直线就画上去了,但这样看起来很生硬效果很差。实际上我们需要的是一条自然的曲线把资源节点和应用节点连接起来,同时穿过信息节点,所以问题就变成了如何穿过三个点画一条曲线。

要画曲线自然要用到 svg 的 path 元素和他的 d 绘制指令,关于怎么用 path 画曲线,这里和MDN上都有很详细的教程。在具体实际项目应用中,一般来说贝塞尔曲线会比较难把控也比较难获得较好的效果,所以我们使用 A 指令来画这个弧线。

使用 A 指令画弧线,需要知道的元素有:x轴半径,y轴半径,弧形旋转角度,角度大小flag,弧线方向flag,弧形的终点。那在已知三个点坐标的情况下,怎么求出这些元素呢?是时候复习一波三角函数了。

利用d3.js力导布局绘制资源拓扑图实例教程

已知 A、B、C 坐标(xaya、xbyb、xcyc),则可求得 a、b、c 长度(Math.sqrt((x1-x2)2 - (y1-y2)2),再根据余弦定理可求得∠C,再根据正弦定理可得r,具体参看代码:

type IVisualLink = { 
 id: string;
 start: number[];
 middle: number[];
 end: number[];
 arcPath: string;
 hasReverseVisualLink: boolean;
};

const visualLinks: IVisualLink[] = [...];

function dist(a: number[], b: number[]) { 
 return Math.sqrt(
 Math.pow(a[0] - b[0], 2) +
 Math.pow(a[1] - b[1], 2));
}

...

const pathElements = svg.append('g') 
 .selectAll('path')
 .data(visualLinks)
 .enter().append('path')
 .attr('fill', 'none')
 .attr('stroke-width', 1)
 .attr('stroke', '#E5E5E5');

...

const render = () => { 
 ...

 nodes
 // 过滤出所有的信息节点
 .filter(node => !node.isAppNode)
 .forEach((node) => {
 ...
 // 根据信息节点的信息得到对应的 visualLink 对象 index
 const idx = findVisualLinkIndex(node)
 visualLinks[idx].start = [source.x!, source.y!];
 visualLinks[idx].middle = [node.x!, node.y!];
 visualLinks[idx].end = [target.x!, target.y!];

 const A = visualLinks[idx].start;
 const B = visualLinks[idx].end;
 const C = visualLinks[idx].middle;

 const a = dist(B, C);
 const b = dist(C, A);
 const c = dist(A, B);

 // 余弦定理求得∠C
 const angle = Math.acos((a * a + b * b - c * c) / (2 * a * b));
 // 正弦定理求得外接圆半径
 const r = _.round(c / Math.sin(angle) / 2, 4);

 // 角度大小flag,因为我们要的是条弧线而不是一个残缺的圆,所以恒为0
 const laf = 0;

 // 弧线方向flag,根据AB的斜率判断C在AB线的那一边,再确定弧线方向
 const saf = +((B[0] - A[0]) * (C[1] - A[1]) - (B[1] - A[1]) * (C[0] - A[0]) < 0);

 const arcPath = ['M', A, 'A', r, r, 0, laf, saf, B].join(' ');

 visualLinks[idx].arcPath = arcPath;
 });

 pathElements
 .attr('d', (link) => {
 return link.arcPath;
 });
}

效果如下:

利用d3.js力导布局绘制资源拓扑图实例教程

这些线一对A都没有,分不清正反啊

应用与资源间的关系,是有方向的,大部分情况下是应用调用资源,也有情况会有双向的调用,除了文字意外,我们还需要加上箭头来表明是谁在调用谁。怎么加这个箭头呢?svg 的 path 元素有一个 marker-end 属性,通过设置这个属性可以可以将一个 svg 元素绘制到 path 元素最后的向量上。

// 在 svg 元素中添加一个 marker 元素
<svg> 
 <marker
 id="arrow"
 viewBox="-10 -10 20 20"
 markerWidth="20"
 markerHeight="20"
 orient="auto"
 >
 <path
 d="M-6.75,-6.75 L 0,0 L -6.75,6.75"
 fill="none"
 stroke="#E5E5E5"
 />
 </marker>
</svg>

...

const pathElements = svg.append('g') 
 .selectAll('path')
 .data(visualLinks)
 .enter().append('path')
 .attr('fill', 'none')
 // 设置 marker-end 属性
 .attr('marker-end', 'url(#arrow)')
 .attr('id', link => link.id)
 .attr('stroke-width', 1)
 .attr('stroke', '#E5E5E5');

...

但直接这样写的话,效果会很差,为啥呢?因为我们 path 元素的起点与终点是节点的中心点,直接这样的话箭头都在节点上面,如图:

利用d3.js力导布局绘制资源拓扑图实例教程

看到中间那朵菊花没

所以我们没法直接通过加这个属性来加上箭头,我们需要对 path 做一些处理,对 path 线段去头去尾。那怎么做呢?还好有巨佬已经实现了一种算法,算出两个 path 元素之间的交点,因此我们可以在算出原 arcPath 后,再算出这条弧线与节点外一个大一点的圆的交点,再把原 arcPath 的起点与终点移到这两个点上。

import intersect from 'path-intersection';

const render = () => { 
 ...

 nodes
 // 过滤出所有的信息节点
 .filter(node => !node.isAppNode)
 .forEach((node) => {
 ...
 // 根据信息节点的信息得到对应的 visualLink 对象 index
 const idx = findVisualLinkIndex(node)
 visualLinks[idx].start = [source.x!, source.y!];
 visualLinks[idx].middle = [node.x!, node.y!];
 visualLinks[idx].end = [target.x!, target.y!];

 const A = visualLinks[idx].start;
 const B = visualLinks[idx].end;
 const C = visualLinks[idx].middle;

 const a = dist(B, C);
 const b = dist(C, A);
 const c = dist(A, B);

 // 余弦定理求得∠C
 const angle = Math.acos((a * a + b * b - c * c) / (2 * a * b));
 // 正弦定理求得外接圆半径
 const r = _.round(c / Math.sin(angle) / 2, 4);

 // 角度大小flag,因为我们要的是条弧线而不是一个残缺的圆,所以恒为0
 const laf = 0;

 // 弧线方向flag,根据AB的斜率判断C在AB线的那一边,再确定弧线方向
 const saf = +((B[0] - A[0]) * (C[1] - A[1]) - (B[1] - A[1]) * (C[0] - A[0]) < 0);

 const origArcPath = ['M', A, 'A', r, r, 0, laf, saf, B].join(' ');

 const raidus = NODE_RADIUS;
 const startCirclePath = [
 'M', A,
 'm', [-raidus, 0],
 'a', raidus, raidus, 0, 1, 0, [raidus * 2, 0],
 'a', raidus, raidus, 0, 1, 0, [-raidus * 2, 0],
 ].join(' ');
 const endCirclePath = [
 'M', B,
 'm', [-raidus, 0],
 'a', raidus, raidus, 0, 1, 0, [raidus * 2, 0],
 'a', raidus, raidus, 0, 1, 0, [-raidus * 2, 0],
 ].join(' ');

 const startIntersection = intersect(origArcPath, startCirclePath)[0];
 const endIntersection = intersect(origArcPath, endCirclePath)[0];

 const arcPath = [
 'M', [startIntersection.x, startIntersection.y],
 'A', r, r, 0, laf, saf, [endIntersection.x, endIntersection.y],
 ].join(' ');

 visualLinks[idx].arcPath = arcPath;
 });

 pathElements
 .attr('d', (link) => {
 return link.arcPath;
 });

 ...
}

利用d3.js力导布局绘制资源拓扑图实例教程

效果已经很接近了!

字叠到一起啦,臣妾看不清啊

到这一步整体效果其实已经差不多了,但追求完美的我们怎么可能到此为止呢?仔细看看这个图,因为调用信息是一个方盒而不是原型的节点,如果应用和资源间有来有往,那这个字很容易叠到一起。可以尝试调整碰撞力(Collision)和弹簧力(Links)来让他们别叠到一起,不过试下来发现调整这两个系数很容易把整个图弄得乱七八糟的。那咋办呢?我们就要到此为止了吗?不妨换个思路,如果应用与资源间有来有往,则这个连接信息就不放到中间点,而是放到开始三分之一处。

说的挺好,我咋知道开始三分之一处在哪?

还好这种「复杂」的数学问题,前人已经帮我们探索的差不多了。svg 标准里定义了 SVGGeometryElement.getTotalLength 与 SVGGeometryElement.getPointAtLength 两个方法,通过这两个方法我们可以获得 path 路径的全长,和某一长度时点的位置。不过这两个方法都是附在 DOM 元素上的,直接调用有点麻烦,还好有PureJS 的实现:

import { svgPathProperties } from 'svg-path-properties';

...

render = () => { 
 ...

 labelElements
 .attr('style', (link) => {
 const properties = svgPathProperties(link.arcPath);
 const totalLength = properties.getTotalLength();
 const point = properties.getPointAtLength(
 link.hasReverseVisualLink ? totalLength / 3 : totalLength / 2,
 );

 return `transform: translate3d(calc(${point.x}px - 50%), calc(${point.y}px - 50%), 0);`;
 });

 ...
}

最终效果:

利用d3.js力导布局绘制资源拓扑图实例教程

还差一点

效果做到这已经差不多了,不过还有一些不完美的地方

  • 各种力的系数,在数据不同时不能通用,还必须根据数据不同试出来一个相对通用的系数函数。
  • 不能保证所有的节点都在方框内且不重叠

感觉这两个问题都算是力导布局的固有缺陷,可能那张图的实现根本和力导布局没啥关系呢?。不过我们使用力导布局也可以实现不错的效果,这种 edge case 可以慢慢来解决了就。

总结

以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,如果有疑问大家可以留言交流,谢谢大家对三水点靠木的支持。

Javascript 相关文章推荐
jquery批量控制form禁用的代码
Aug 06 Javascript
jquery插件lazyload.js延迟加载图片的使用方法
Feb 19 Javascript
js 判断控件获得焦点的示例代码
Mar 04 Javascript
Javascript 完美运动框架(逐行分析代码,让你轻松了运动的原理)
Jan 23 Javascript
js实现进度条的方法
Feb 13 Javascript
详解jQuery Mobile自定义标签
Jan 06 Javascript
Node.js的Express框架使用上手指南
Mar 12 Javascript
JavaScript中String对象的方法介绍
Jan 04 Javascript
移动端web滚动分页的实现方法
May 05 Javascript
echarts学习笔记之图表自适应问题详解
Nov 22 Javascript
JavaScript实现单例模式实例分享
Dec 22 Javascript
vue基于element-ui的三级CheckBox复选框功能的实现代码
Oct 15 Javascript
vuejs简单验证码功能完整示例
Jan 08 #Javascript
详解React中合并单元格的正确写法
Jan 08 #Javascript
JS简单判断是否在微信浏览器打开的方法示例
Jan 08 #Javascript
JQuery搜索框自动补全(模糊匹配)功能实现示例
Jan 08 #jQuery
Angular6 发送手机验证码按钮倒计时效果实现方法
Jan 08 #Javascript
Angular6 用户自定义标签开发的实现方法
Jan 08 #Javascript
JS实现的获取银行卡号归属地及银行卡类型操作示例
Jan 08 #Javascript
You might like
如何冲泡挂耳包咖啡?技巧是什么
2021/03/04 冲泡冲煮
php 中的str_replace 函数总结
2007/04/27 PHP
linux下 C语言对 php 扩展
2008/12/14 PHP
PHP命名空间与自动加载机制的基础介绍
2019/08/25 PHP
js实现幻灯片效果(基于jquery插件)
2013/11/05 Javascript
JavaScript代码编写中各种各样的坑和填坑方法
2014/06/06 Javascript
js实现字符串转日期格式的方法
2015/05/20 Javascript
jQuery实现可编辑的表格实例讲解(2)
2015/09/17 Javascript
需灵活掌握的Bootstrap预定义排版类 你精通吗?
2016/06/20 Javascript
Javascript blur与click冲突解决办法
2017/01/09 Javascript
js判断手机号是否正确并返回的实现代码
2017/01/17 Javascript
基于javascript的异步编程实例详解
2017/04/10 Javascript
解决npm管理员身份install时出现权限的问题
2018/03/16 Javascript
微信小程序图表插件wx-charts用法实例详解
2019/05/20 Javascript
微信小程序在线客服自动回复功能(基于node)
2019/07/03 Javascript
python实现猜数字游戏(无重复数字)示例分享
2014/03/29 Python
基于wxpython实现的windows GUI程序实例
2015/05/30 Python
python实现在图片上画特定大小角度矩形框
2018/10/24 Python
使用PyCharm创建Django项目及基本配置详解
2018/10/24 Python
使用Django简单编写一个XSS平台的方法步骤
2019/03/25 Python
解决python文件双击运行秒退的问题
2019/06/24 Python
Python坐标线性插值应用实现
2019/11/13 Python
python with (as)语句实例详解
2020/02/04 Python
深度学习入门之Pytorch 数据增强的实现
2020/02/26 Python
解决pytorch 交叉熵损失输出为负数的问题
2020/07/07 Python
python切割图片的示例
2020/11/12 Python
CSS3弹性伸缩布局之box布局
2016/07/12 HTML / CSS
委托公证书范本
2014/04/03 职场文书
竞选体育委员演讲稿
2014/04/26 职场文书
关于教师节的演讲稿
2014/09/04 职场文书
总经理2015中秋节致辞
2015/07/29 职场文书
团干部培训班心得体会
2016/01/06 职场文书
HTML5页面音频自动播放的实现方式
2021/06/21 HTML / CSS
mysql left join快速转inner join的过程
2021/06/30 MySQL
sass 常用备忘案例详解
2021/09/15 HTML / CSS
win10以太网连接不上怎么办?Win10连接以太网详细教程
2022/04/08 数码科技