简单力向导图绘制引擎

65 阅读6分钟

力导向图(Force-Directed Graph)  是可视化领域中用于布局节点(nodes)和连线(links)的经典算法。其核心目标是通过模拟物理力(引力、斥力、弹力),让图布局达到视觉上的平衡(节点不重叠、连线长度适中、整体居中)。

image.png

一、核心计算逻辑

代码分三部分计算节点的受力:

  1. 中心引力:所有节点被微弱拉向画布中心,防止布局偏移;
  2. 节点间斥力:所有节点互相排斥,避免重叠;
  3. 连线上的弹力:有连线的节点互相吸引(模拟弹簧),让连线长度趋于稳定值。

最终将计算出的力转化为节点的速度(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 += vxy += 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 -= fxtarget.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,逐渐变小,布局慢慢静止。