本文详解如何利用d3.js的力导布局算法绘制资源拓扑图。从基础节点与连线配置入手,深入解析中心力、碰撞力等五种作用力机制。针对实际开发中的文字居中、贝塞尔曲线绘制、箭头定位及标签防重叠等痛点,提供基于SVG DOM操作与几何计算的解决方案,帮助开发者构建清晰、美观且具备交互性的系统资源关系图。
前言与需求分析
前言
近期公司业务服务频繁出现异常,开发人员依靠链路图排查问题效率低下且容易出错。某日,团队负责人提出需求:开发一套资源拓扑图,以便更直观地定位和解决Bug。

(此处展示参考图片,由程序原样保留,不描述看不到的内容)
接下来分析具体需求:界面需呈现一圈资源节点围绕中心应用节点,并通过曲线连接。曲线中段需标注应用与资源间的调用信息。这种结构类似于d3.js中的力导向图(Force-Directed Graph)。
d3.js 力导向图原理
d3.js 力导向图
d3.js是广泛使用的数据可视化工具,具备将数据映射至网页元素的能力,并封装了丰富的数据处理函数与图形算法。力导向图是其经典算法之一,通过在二维空间配置节点和连线,利用多种物理作用力使节点相互碰撞、运动并降低能量,最终达到能量最低的稳定状态,形成稳定的图形结构。
d3.js 力导向图中默认提供了 5 种作用力(以最新的 5.x 为准):
中心力(Centering)
中心力作用于所有节点,可将节点中心统一向指定位置移动,且该移动不修改节点速度,也不影响节点间的相对位置。
碰撞力(Collision)
碰撞力将每个节点视为具有特定半径的圆,防止代表节点的圆相互重叠。可通过设置 strength 参数来调整碰撞力的强度。
弹簧力(Links)
当两个节点通过 link 连接后,可设置弹簧力。该力根据节点间距离将节点拉近或推远,力的强度与距离成正比,类似弹簧效果。
电荷力(Many-Body)
通过设置 strength 模拟节点间的相互作用力。正值表示节点间相互吸引(模拟电荷吸引力),负值表示相互排斥。该力的大小与节点间距离相关。
定位力(Positioning)
该力可将节点沿指定维度推向特定位置。例如,通过设置 forceX 和 forceY 可在 X轴和 Y轴方向推拉所有节点;forceRadial 则可将所有节点推向圆环上的相应位置。
回到需求,可将应用、资源及调用信息均视为节点。资源与调用信息间通过较弱的弹簧力连接;若应用与资源间存在双向调用,则在两个调用信息间设置较强的弹簧力。

基础实现与初步效果
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();
(此处展示效果图片,由程序原样保留,不描述看不到的内容)

基本功能已实现,等待后台接口对接后即可上线。对于日均UV两位数的产品,能展示拓扑图已属不易(手动二哈)。
当然不行,存在一个都市传说:中台产品的优劣与员工离职率呈正相关。本就令人反感的资源拓扑图,若再遇到体验极差的产品,极易引发离职冲动。为助力公司年交易额两万亿的长远目标,我们需审视改进空间。
文字居中与HTML元素替换
至少字给我居中吧
初始版本中,文字均左下角定位至节点中心,这是因为SVG的 text 元素默认 x 和 y 代表基线起始位置。虽然可通过设置 dx 与 dy 偏移量实现居中,但考虑到SVG元素的局限性及未来扩展性,决定将所有圆点与文字替换为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);`;
});
}
(此处展示效果图片,由程序原样保留,不描述看不到的内容)

(此处展示效果图片,由程序原样保留,不描述看不到的内容)
曲线绘制与弧线计算
这个线怎么跟激光似的,一点也不像在遛舔狗
观察连线,初始实现将所有弹簧力线段绘制为直线,视觉效果生硬。实际需求是绘制一条自然曲线连接资源节点和应用节点,并穿过信息节点。问题转化为如何绘制穿过三个点的曲线。
绘制曲线需使用SVG的 path 元素及其 d 绘制指令。MDN文档中有详细教程。在实际项目中,贝塞尔曲线较难把控且效果不佳,因此选用 A 指令绘制弧线。
使用 A 指令画弧线,需知元素有:x轴半径,y轴半径,弧形旋转角度,角度大小flag,弧线方向flag,弧形的终点。在已知三个点坐标的情况下,如何求出这些元素?需复习三角函数。

已知 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;
});
}
(此处展示效果图片,由程序原样保留,不描述看不到的内容)

箭头添加与路径优化
这些线一对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线段去头去尾。已有算法可计算两个path元素间的交点,通过计算原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;
});
...
}

(此处展示效果图片,由程序原样保留,不描述看不到的内容)
标签防重叠与位置调整
字叠到一起啦,臣妾看不清啊
整体效果已接近预期,但追求完美仍需优化。由于调用信息为方盒而非圆形节点,若应用与资源间有来有往,文字易重叠。尝试调整碰撞力(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);`;
});
...
}
(此处展示效果图片,由程序原样保留,不描述看不到的内容)

还差一点
局限性总结
效果已近完善,但仍存在不完美之处:
- 各种力的系数,在数据不同时不能通用,还必须根据数据不同试出来一个相对通用的系数函数。
- 不能保证所有的节点都在方框内且不重叠
这两个问题属于力导布局的固有缺陷,原图实现可能与此无关。但使用力导布局已能取得不错效果,此类边缘情况可逐步解决。
总结
以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,如果有疑问大家可以留言交流,谢谢大家对本站的支持。