力导向图(Force-Directed Graph) 是可视化领域中用于布局节点(nodes)和连线(links)的经典算法。其核心目标是通过模拟物理力(引力、斥力、弹力),让图布局达到视觉上的平衡(节点不重叠、连线长度适中、整体居中)。
一、核心计算逻辑
代码分三部分计算节点的受力:
- 中心引力:所有节点被微弱拉向画布中心,防止布局偏移;
- 节点间斥力:所有节点互相排斥,避免重叠;
- 连线上的弹力:有连线的节点互相吸引(模拟弹簧),让连线长度趋于稳定值。
最终将计算出的力转化为节点的速度(vx/vy),为后续节点位置更新提供依据。
1. 获取节点和基础配置
const nodeList = Array.from(this.nodes.values()); // 取出所有节点转为数组
const width = this.options.width; // 画布宽度
const height = this.options.height; // 画布高度
const centerX = width / 2; // 画布中心X坐标
const centerY = height / 2; // 画布中心Y坐标
- 从实例的
nodes(大概率是Map结构)中提取所有节点,转为数组方便遍历; - 计算画布中心坐标,作为 “中心引力” 的目标点。
2. 遍历所有节点,计算斥力 + 中心引力
for (let i = 0; i < nodeList.length; i++) {
const node = nodeList[i];
if (node === this.draggedNode) continue; // 拖拽中的节点跳过(不被力影响)
let fx = 0; // X方向总受力
let fy = 0; // Y方向总受力
// 2.1 中心引力:微弱拉向画布中心
fx += (centerX - node.x) * this.CENTER_PULL;
fy += (centerY - node.y) * this.CENTER_PULL;
// 2.2 节点间斥力:所有节点互相排斥
for (let j = 0; j < nodeList.length; j++) {
if (i === j) continue; // 跳过自身
const other = nodeList[j];
const dx = node.x - other.x; // 两节点X方向距离
const dy = node.y - other.y; // 两节点Y方向距离
let distSq = dx * dx + dy * dy; // 距离的平方(避免开方,优化性能)
// 防止距离为0(除以0),给极小值+随机力避免节点卡死
if (distSq === 0) {
distSq = 0.1;
fx += Math.random();
fy += Math.random();
}
const dist = Math.sqrt(distSq); // 实际距离
// 斥力公式:与两节点权重的平均值成正比,与距离平方成反比(符合库仑斥力)
const force = this.REPULSION * (node.weight + other.weight) * 0.5 / distSq;
// 将斥力分解到X/Y方向(单位向量 × 力的大小)
fx += (dx / dist) * force;
fy += (dy / dist) * force;
}
// 受力转化为速度:力越大,节点在对应方向的速度增量越大
node.vx += fx;
node.vy += fy;
}
-
拖拽节点跳过:
this.draggedNode是用户正在拖拽的节点,跳过计算避免拖拽时被力 “拉走”; -
中心引力逻辑:
(centerX - node.x)是 “节点到中心的偏移量”,乘以系数CENTER_PULL(中心引力强度),节点越偏离中心,引力越大; -
斥力逻辑:模拟物理中的 “库仑斥力”(同种电荷互相排斥),核心规则:
- 节点权重(
weight)越大,斥力越强(大节点排斥力更大); - 距离越近,斥力呈平方级增大(避免节点重叠);
- 距离为 0 时做容错处理(加随机力让节点分开);
- 节点权重(
-
速度更新:受力直接叠加到节点的速度(
vx/vy),后续会通过x += vx、y += vy更新节点位置。
3. 遍历所有连线,计算弹力(吸引力)
this.links.forEach(link => {
const source = this.nodes.get(link.source); // 连线起点节点
const target = this.nodes.get(link.target); // 连线终点节点
if (!source || !target) return; // 节点不存在则跳过
const dx = target.x - source.x; // 两节点X方向距离
const dy = target.y - source.y; // 两节点Y方向距离
const dist = Math.sqrt(dx * dx + dy * dy);
// 距离为0时跳过(避免除以0)
if (dist === 0) return;
// 胡克定律(弹簧弹力):F = k × (当前长度 - 静止长度)
const force = (dist - this.SPRING_LENGTH) * this.SPRING_STRENGTH;
// 弹力分解到X/Y方向
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
// 拖拽中的节点不被弹力影响
if (source !== this.draggedNode) {
source.vx += fx;
source.vy += fy;
}
if (target !== this.draggedNode) {
target.vx -= fx;
target.vy -= fy;
}
});
-
弹力逻辑:模拟 “胡克定律”(弹簧受力),核心规则:
SPRING_LENGTH是弹簧的 “静止长度”(理想连线长度);- 如果当前连线长度 > 静止长度 → 弹力为正(拉回,节点互相吸引);
- 如果当前连线长度 < 静止长度 → 弹力为负(推开,避免节点过近);
-
力的方向:起点节点(source)受力向终点(target),终点节点(target)受力向起点(source),因此
target.vx -= fx、target.vy -= fy;
二、碰撞检测与修正
力导向图的 “斥力” 虽然能减少节点重叠,但极端情况下(如节点初始位置重叠、斥力系数不足)仍可能出现重叠。这段代码是最后一道防线:通过 “硬修正” 直接调整节点位置,确保节点之间的距离不小于「两节点半径之和 + 额外间距」,彻底避免重叠。
private resolveCollisions() {
// Hard collision resolution: Nodes must not overlap
const nodes = Array.from(this.nodes.values()); // 所有节点转为数组
const iterations = 2; // 迭代次数(多次执行提升稳定性,避免一次修正不彻底)
// 多次迭代修正(2次足够平衡“修正精度”和“性能”)
for (let k = 0; k < iterations; k++) {
// 双层循环:遍历所有“节点对”(i<j 避免重复计算)
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const n1 = nodes[i];
const n2 = nodes[j];
// 计算两节点的位置差和距离平方
const dx = n2.x - n1.x;
const dy = n2.y - n1.y;
const distSq = dx*dx + dy*dy;
// 最小安全距离:两节点半径之和 + 2px 额外间距(避免贴太紧)
const minDist = n1.radius + n2.radius + 2;
// 检测碰撞:当前距离 < 最小安全距离 → 发生重叠
if (distSq < minDist * minDist) {
const dist = Math.sqrt(distSq);
// 处理完全重叠的极端情况:随机生成单位向量(避免除以0)
const tx = dist === 0 ? Math.random() : dx / dist;
const ty = dist === 0 ? Math.random() : dy / dist;
// 计算重叠量:需要分开的距离
const overlap = minDist - dist;
// 分离系数:0.5 表示“两个节点各承担一半的移动距离”(更公平)
const separationFactor = 0.5;
// 修正节点位置:向相反方向移动,抵消重叠
if (n1 !== this.draggedNode) { // 拖拽节点不被修正
n1.x -= tx * overlap * separationFactor;
n1.y -= ty * overlap * separationFactor;
}
if (n2 !== this.draggedNode) {
n2.x += tx * overlap * separationFactor;
n2.y += ty * overlap * separationFactor;
}
}
}
}
}
}
三、计算整合与状态更新
private integrate() {
// 1. 第一步:计算所有力(引力、斥力、弹力)→ 给节点赋予vx/vy(速度)
this.calculateForces();
// 2. 第二步:根据速度更新节点位置,同时做约束
this.nodes.forEach(node => {
if (node === this.draggedNode) return; // 拖拽节点跳过(位置由用户控制)
// 2.1 速度限制:防止节点因受力过大“飞出去”
const vMag = Math.sqrt(node.vx * node.vx + node.vy * node.vy); // 速度的大小
if (vMag > this.MAX_VELOCITY) { // 超过最大速度则按比例缩放
node.vx = (node.vx / vMag) * this.MAX_VELOCITY;
node.vy = (node.vy / vMag) * this.MAX_VELOCITY;
}
// 2.2 更新位置:速度 → 位移(核心:位置 = 原位置 + 速度)
node.x += node.vx;
node.y += node.vy;
// 2.3 阻尼(Damping):模拟空气阻力,让速度逐渐衰减 → 布局最终稳定
node.vx *= this.DAMPING; // DAMPING通常是0.8~0.95的小数
node.vy *= this.DAMPING;
// 2.4 边界约束:软反弹(节点不超出画布,且碰撞边界时减速反弹)
const padding = node.radius; // 边界内边距(避免节点一半出画布)
// 左边界:超出则拉回,速度反向并减半(软反弹,不是硬停)
if (node.x < padding) { node.x = padding; node.vx *= -0.5; }
// 右边界
if (node.x > this.options.width - padding) { node.x = this.options.width - padding; node.vx *= -0.5; }
// 上边界
if (node.y < padding) { node.y = padding; node.vy *= -0.5; }
// 下边界
if (node.y > this.options.height - padding) { node.y = this.options.height - padding; node.vy *= -0.5; }
});
// 3. 第三步:修正碰撞(最后一步确保无重叠)
this.resolveCollisions();
}
-
执行顺序(核心) :
calculateForces()→ 计算速度 → 速度限制 → 更新位置 → 阻尼 → 边界约束 →resolveCollisions()这个顺序确保:先按物理规律更新位置,再修正极端情况(碰撞、越界),逻辑更合理。 -
速度限制(MAX_VELOCITY) :若没有速度限制,节点可能因 “斥力过大” 瞬间飞出画布,限制后布局更平稳。
-
阻尼(DAMPING) :力导向图需要 “最终稳定”(节点不再移动),阻尼让速度每次更新后衰减,最终趋近于 0。比如 DAMPING=0.9,速度每次乘以 0.9,逐渐变小,布局慢慢静止。