KAIROS.WORKSPACE
技术宇宙 / ONLINE
返回文章列表
前端工程advanced11 分钟

力导向图最小实现:在原生 Canvas 上写一个物理引擎

不引库做出可拖拽的知识图谱星图:斥力、弹簧、向心力三股力量如何配平,缩放平移的坐标换算,以及 reduced-motion 下的静态降级。

#Canvas#数据可视化#JavaScript#动效
排版
字号
行宽
目录 · 8 节

本文为通用技术教程,全部代码可在浏览器控制台或单页项目中复现。

为什么“自己写”反而更轻

提到力导向图,第一反应是引 D3-force。但处理几十到几百个节点的规模时,核心物理只有三股力,O(n²) 的两两斥力在这个量级毫无压力。自研的好处是:能精确控制何时停帧(省电)、如何降级(reduced-motion)、以及坐标系统(与缩放平移共用一套换算)。

三股力的配平

每个 tick 依次施加:

// 1) 斥力:任意两节点之间,距离越近推得越狠
for (let i = 0; i < nodes.length; i += 1) {
  for (let j = i + 1; j < nodes.length; j += 1) {
    const dx = a.x - b.x,
      dy = a.y - b.y;
    const distSq = Math.max(1, dx * dx + dy * dy);
    if (distSq > 90000) continue; // 远处的力忽略,性能与稳定双赢
    const force = REPULSION / distSq;
    const dist = Math.sqrt(distSq);
    a.vx += (dx / dist) * force;
    b.vx -= (dx / dist) * force;
  }
}

// 2) 弹簧力:有边的节点被拉向理想长度
const force = (dist - SPRING_LENGTH) * STIFFNESS;

// 3) 向心力:防止整张图飘走
node.vx -= node.x * GRAVITY;

然后是积化和衰减:

node.vx *= DAMPING; // 阻尼,0.86 左右
node.x += node.vx * alpha;

参数的量级感:斥力 2000–5000,弹簧长度 80–120,刚度 0.01–0.02,重力 0.001–0.002。斥力与弹簧的比值决定“图是抱团还是散开”,重力决定“能不能被弹簧拉出凸包”。

alpha:让图自己停下来

物理循环最大的工程问题是 CPU。解法是引入衰减因子 alpha:

alpha = Math.max(0.02, alpha * 0.996);

每帧位移乘以 alpha,运动幅度指数衰减;当所有节点速度足够小(或 alpha 触底)就 cancelAnimationFrame循环彻底停止。用户拖拽、缩放或改筛选时再把 alpha 抬回 0.55 重新加热——绝大多数时间 CPU 占用为零。

const frame = () => {
  tick();
  draw();
  if (alpha <= 0.03 && !dragging) {
    running = false;
    return;
  }
  requestAnimationFrame(frame);
};

拖拽、缩放、平移的坐标换算

Canvas 的世界坐标与屏幕坐标通过一个仿射变换关联:

// 世界 → 屏幕:先平移到画布中心,再缩放
screenX = worldX * scale + width / 2 + panX;

// 屏幕 → 世界(命中测试用)
worldX = (screenX - width / 2 - panX) / scale;

三个要点:

  1. 命中测试必须用世界坐标。把鼠标位置反算回世界系,再找半径 +6/scale 范围内最近的节点,才能保证任何缩放下手感一致。
  2. 滚轮缩放要锚定光标。缩放前后,光标下的世界点必须静止不动:
    const factor = Math.exp(-event.deltaY * 0.0012);
    panX = cursorX - width / 2 - worldX * nextScale;
    scale = nextScale;
  3. 拖拽节点时把该节点标记为 pinned,物理循环跳过它的位置积分,松手解除。节点被“拎”起来时,弹簧会自然把邻居拖过来——这是力导向图最有生命感的瞬间。

绘制层的两件事

  • DPR 适配canvas.width = cssWidth * devicePixelRatio,再 setTransform(dpr, 0, 0, dpr, 0, 0),否则视网膜屏上全是锯齿。
  • 视觉编码:半径 ∝ √degree,高连接节点画径向渐变光晕;标签只在“选中、悬停、大节点、放大”四种情况绘制,避免几百个文字糊成一团。

reduced-motion 与节能降级

prefers-reduced-motion 不是“不画”,而是“不逐帧画”:

if (reducedMotion.matches) {
  for (let i = 0; i < 240; i += 1) tick(); // 同步跑完物理
  draw(); // 画一帧静态终态
}

拖拽、缩放、平移是直接操作,应当保留——只是没有惯性动画。另外两个节能开关:IntersectionObserver 监听画布离开视口即停帧;document.hidden 时暂停。

停止条件清单

发布前逐条自检:

  • alpha 触底或最大速度 < 阈值时,rAF 停止;
  • 拖拽/平移期间循环保持运转,松手后重新衰减;
  • 画布离开视口、标签页隐藏时停帧,回来时恢复;
  • ResizeObserver 而非 window resize 适配容器尺寸;
  • astro:before-swap(或路由卸载)时取消帧、断开所有 Observer。

小结

力导向图的“高级感”不来自物理公式的复杂度,而来自三件事:力量配平的参数手感、彻底停帧的工程纪律、以及直接操作(拖拽/缩放)的坐标换算。三件事都做对,一个 200 行的自研引擎就能在几十节点规模上取代整个图库。

Conversation

评论与互动

正在加载评论…

提交后需审核,不会立即公开。

Keep exploring

继续探索