首页
/ D3.js 力导向模拟完整指南:d3-force simulation 的数值原理、参数体系与自定义力实战

D3.js 力导向模拟完整指南:d3-force simulation 的数值原理、参数体系与自定义力实战

2026-09-04 20:55:46作者:宗隆裙

D3.js(当前仓库版本 7.9.0,见 package.json)通过 d3-force 模块(依赖声明为 d3-force@^3.0.0)提供了一套基于速度 Verlet 积分器(velocity Verlet)的力导向模拟系统,是绘制网络图、层次结构图、解决碰撞(如气泡图)的底层引擎。本文完整覆盖模拟的创建、计时器控制、alpha 温度参数体系、节点管理与事件监听的全部 API,并结合仓库中官方文档站的真实演示组件源码,展示“拖拽 + 重新加热(reheat)”的完整交互模式,读完后可独立实现交互式力导向图与静态布局计算。

一、模拟的数值原理:速度 Verlet 积分

力模拟(force simulation)实现了一个速度 Verlet 数值积分器,用于模拟作用在粒子(节点)上的物理力。模拟做了两个关键简化假设:

  • 每一步采用恒定单位时间步 Δt = 1;
  • 所有粒子具有恒定单位质量 m = 1。

由此,作用在粒子上的力 F 等价于时间间隔 Δt 内恒定的加速度 a,模拟可以非常简单地实现:先把力累加到粒子速度上,再把速度累加到粒子位置上。这正是每个 tick 中“先改 vx/vy,再改 x/y”两步更新顺序的由来。

该模块的典型应用场景包括:可视化网络图层次结构、以及解决碰撞检测(如纽约时报气泡图的同类效果),详见 docs/d3-force.md

二、创建模拟:forceSimulation(nodes)

const simulation = d3.forceSimulation(nodes);

forceSimulation 接收一个节点数组,创建一个不带任何力的新模拟。若 nodes 未指定,默认为空数组。

两点重要行为:

  1. 该函数是不纯的(impure):它会直接修改传入的节点对象。节点属性的详细赋值行为见下文 simulation.nodes 一节。
  2. 模拟自动启动:创建后内部计时器立即开始运行,可通过 simulation.on("tick", ...) 监听 tick 事件进行渲染。如果希望手动控制模拟(例如离线计算静态布局),应先调用 simulation.stop(),然后按需要反复调用 simulation.tick()

三、节点管理:simulation.nodes(nodes)

nodes 既是创建时的入参,也可以后续整体替换:

  • 若指定 nodes:将模拟的节点设置为给定数组,必要时初始化它们的位置和速度,并重新初始化(re-initialize)所有已绑定的力,然后返回模拟;
  • 若未指定:返回创建时传入的节点数组。

同样需要注意:此函数会直接修改传入的节点对象,为每个节点赋 indexx/yvx/vy,并在后续每次 tick 中持续更新位置与速度。

每个 node 必须是对象。模拟会为它分配以下属性:

属性 含义
index 节点在 nodes 中的零基索引
x 节点当前 x 位置
y 节点当前 y 位置
vx 节点当前 x 方向速度
vy 节点当前 y 方向速度

位置 ⟨x,y⟩ 和速度 ⟨vx,vy⟩ 之后既可能被自定义力修改,也会被模拟自身更新。初始化规则如下:

  • vxvy 为 NaN,速度被初始化为 ⟨0, 0⟩;
  • xy 为 NaN,位置按**旋花状排列(phyllotaxis arrangement)**初始化——这是一种确保分布确定且均匀的策略,避免了“所有节点堆在原点”的退化初始状态。

3.1 固定节点:fx 与 fy

可以通过两个额外属性把节点钉死在给定位置:

  • fx —— 节点固定的 x 位置;
  • fy —— 节点固定的 y 位置。

执行机制:在每次 tick 的末尾(所有力作用之后),若节点定义了 node.fx,则 node.x 被重置为该值且 node.vx 置零;定义了 node.fynode.y 被重置且 node.vy 置零。要解除固定,将 node.fxnode.fy 设为 null 或删除这两个属性即可。

3.2 节点数组变更后的重新通知

如果后续对节点数组做了增删改(例如向模拟中新增节点、删除节点),必须再次调用 simulation.nodes(newNodes) 传入新数组,以通知模拟和所有已绑定的力。模拟不会为指定数组做防御性拷贝。

四、计时器控制:restart() 与 stop()

4.1 simulation.restart()

重启模拟的内部计时器并返回模拟本身。配合 alphaTargetalpha,此方法可以在交互过程中(如拖拽节点时)“重新加热”模拟,或在用 stop() 临时暂停后恢复模拟。

4.2 simulation.stop()

若模拟的内部计时器正在运行则停止它,并返回模拟。若计时器本就处于停止状态,此方法不做任何事。配合 tick() 手动驱动模拟时常用。

五、手动步进:simulation.tick(iterations)

手动按指定 iterations 次数步进模拟(默认 1 次,即单步),并返回模拟。每一迭代的内部执行顺序严格固定:

  1. 将当前 alpha 递增 (alphaTarget - alpha) × alphaDecay;
  2. 调用每一个已注册的力(force),并把新的 alpha 作为参数传入;
  3. 将每个节点的速度衰减:velocity × (1 - velocityDecay);
  4. 将每个节点的位置按速度递增。

两个关键注意事项:

  • 手动 tick 不派发任何事件。事件只由内部计时器派发——即模拟创建时自动启动、或调用 restart() 时。因此手动 tick 后必须自己负责渲染。
  • 模拟自然运行到停止的 tick 数为 ⌈log(alphaMin) / log(1 - alphaDecay)⌉;按默认参数计算,这正是 300 次。

静态布局的实战用法tick() 配合 stop() 可以在浏览器主线程外预先算好一个“静态力导向布局”——创建模拟、stop() 后循环 tick() 足够次数,然后一次性渲染最终坐标。对于大图,官方文档建议把这种计算放到 Web Worker 中执行,以避免冻结用户界面(原文档给出的两个 Observable 参考实现分别为“静态力导向图”与“力导向 Web Worker”方案)。

六、温度参数体系:alpha / alphaMin / alphaDecay / alphaTarget

alpha 大致类比模拟退火中的“温度”:它随时间推移逐渐下降,模拟随之“冷却”。当 alpha 低于 alphaMin 时,模拟的内部计时器停止。

6.1 simulation.alpha(alpha)

  • 指定时:把当前 alpha 设置为 [0, 1] 范围内的给定数值,返回模拟;
  • 未指定时:返回当前 alpha 值,默认 1

6.2 simulation.alphaMin(min)

  • 指定时:设置最小 alpha 为 [0, 1] 范围内数值,返回模拟;
  • 未指定时:返回当前最小 alpha 值,默认 0.001

模拟的内部计时器在当前 alpha 低于该最小值时停止。默认 alpha 衰减率 ~0.0228 恰好对应 300 次迭代。

6.3 simulation.alphaDecay(decay)

  • 指定时:设置 alpha 衰减率为 [0, 1] 范围内数值,返回模拟;
  • 未指定时:返回当前衰减率,默认 0.0228…,其精确值为 1 - Math.pow(0.001, 1 / 300),其中 0.001 即默认最小 alpha。

衰减率决定当前 alpha 向目标 alpha 插值的快慢。由于默认目标 alpha 为零,它实际上控制模拟的冷却速度。权衡关系:

  • 更高的衰减率:模拟更快稳定,但有风险陷入局部最小值(布局质量较差);
  • 更低的衰减率:运行时间更长,但通常能收敛到更好的布局;
  • 让模拟在当前 alpha 下永远运行:把衰减率设为 0,或者把目标 alpha 设为大于最小 alpha 的值。

6.4 simulation.alphaTarget(target)

  • 指定时:设置当前目标 alpha 为 [0, 1] 范围内数值,返回模拟;
  • 未指定时:返回当前目标 alpha 值,默认 0

这是实现“重新加热”的核心参数:拖拽开始时把 alphaTarget 抬到 0.3,松手后归零(见第八节完整示例)。

七、速度衰减:simulation.velocityDecay(decay)

  • 指定时:设置速度衰减因子为 [0, 1] 范围内数值,返回模拟;
  • 未指定时:返回当前速度衰减因子,默认 0.4

衰减因子类似大气摩擦:每次 tick 中所有力作用完毕后,每个节点的速度乘以 1 - decay。与调低 alpha 衰减率同理,更小的速度衰减可能收敛到更优解,但有风险引发数值不稳定和振荡。

八、注册与移除力:simulation.force(name, force)

  • 指定 force 时:为给定 name 绑定力并返回模拟;
  • 未指定时:返回该名称的力,若不存在则返回 undefined(新建模拟默认不带任何力)。

创建图布局模拟的典型写法:

const simulation = d3.forceSimulation(nodes)
    .force("charge", d3.forceManyBody())
    .force("link", d3.forceLink(links))
    .force("center", d3.forceCenter());

移除某个力:传入 null 作为 force

simulation.force("charge", null);

九、空间查询与随机源

9.1 simulation.find(x, y, radius)

返回距位置 ⟨x,y⟩ 最近、且位于给定搜索 radius 范围内的节点。radius 未指定时默认为无穷大。若搜索区域内无节点,返回 undefined。这是命中检测(如决定拖拽拾取哪个节点)的基础设施。

9.2 simulation.randomSource(source)

  • 指定时:设置生成随机数的函数;该函数应返回 [0, 1) 区间内的数值;
  • 未指定时:返回当前随机源,默认为一个固定种子的线性同余发生器(LCG)

固定种子意味着相同输入数据会得到可复现的初始布局——这也是节点 NaN 位置采用确定性旋花状排列的原因。随机源的相关 API 可参考 docs/d3-random.md

十、事件监听:simulation.on(typenames, listener)

  • 指定 listener 时:为指定 typenames 设置事件监听器并返回模拟。若同类型同名监听器已存在,旧监听器会先被移除。若 listenernull,则移除指定 typenames 的当前监听器;
  • 未指定 listener 时:返回与 typenames 匹配的第一个当前已赋监听器(若有)。

事件触发时,每个 listener 以模拟自身作为 this 上下文被调用。

typenames 是由空格分隔的一个或多个 typename 组成的字符串。每个 typenametype 组成,可后跟一个点(.)和 name,如 tick.footick.bar——名字允许对同一类型注册多个监听器。合法的事件 type 只有两种:

  • tick —— 模拟内部计时器每次 tick 之后;
  • end —— 模拟计时器在 alpha < alphaMin 时停止之后。

两个使用要点:

  1. 手动调用 simulation.tick() 不会派发 tick 事件——事件只由内部计时器派发,且专为模拟的交互渲染设计;
  2. 若要影响模拟,应注册,而不是在 tick 监听器里直接改节点的位置或速度(后者会与积分器“抢方向盘”)。

监听器机制基于 d3-dispatch 实现,细节见 docs/d3-dispatch.md

十一、自定义力(Custom forces)

一个*力(force)*就是修改节点位置或速度的函数。它可以模拟物理力(如电荷、引力),也可以解决几何约束(如把节点限制在边界框内、让相连节点保持固定距离)。

一个把节点向原点拉近的完整自定义力示例(原文档给出的最小实现):

function force(alpha) {
  for (let i = 0, n = nodes.length, node, k = alpha * 0.1; i < n; ++i) {
    node = nodes[i];
    node.vx -= node.x * k;
    node.vy -= node.y * k;
  }
}

编写自定义力时的约定与技巧:

  • 典型模式:力读取节点当前位置 ⟨x,y⟩,然后修改速度 ⟨vx,vy⟩;
  • 前瞻(peek ahead):力可以“偷看”节点的预期下一位置 ⟨x + vx, y + vy⟩。对通过迭代松弛(iterative relaxation)解决几何约束的力而言,这是必须的;
  • 直接改位置:力有时也可以直接修改位置,这能避免向模拟注入能量——例如在视口中重新居中模拟时。

11.1 force(alpha) 约定

该函数应用此力,可选地接受当前 alpha。通常力作用于先前传给 force.initialize 的节点数组,但也有些力只作用于节点子集,或表现不同。例如 forceLink 只作用于每条边的 source 与 target。

11.2 force.initialize(nodes) 约定

nodes 数组和 random 源提供给力。此方法在两个时机被调用:

  1. 力通过 simulation.force 绑定到模拟时;
  2. 模拟的节点通过 simulation.nodes 变化时。

力可以利用初始化阶段完成必要的一次性工作(例如求值每个节点的参数),从而避免在力每次应用时重复计算。

十二、仓库中的真实示例:交互式力导向图 + 拖拽重加热

以上 API 的组合方式在文档站的 docs/d3-force.md 页面可以直接看到——该页嵌入了 Vue 演示组件 docs/components/ExampleDisjointForce.vue,它实现了一个可拖拽、分组的力导向图。核心代码与上文 API 一一对应:

simulation = d3.forceSimulation(nodes)
    .force("link", d3.forceLink(links).id((d) => d.id))
    .force("charge", d3.forceManyBody())
    .force("x", d3.forceX())
    .force("y", d3.forceY())
    .on("tick", ticked);

function ticked() {
  link.attr("x1", d => d.source.x)
      .attr("y1", d => d.source.y)
      .attr("x2", d => d.target.x)
      .attr("y2", d => d.target.y);
  node.attr("cx", d => d.x)
      .attr("cy", d => d.y);
}

注意 forceLink 使用 .id((d) => d.id) 后,tick 回调里可以直接读 d.source.x / d.target.y——边对象已经被初始化过程解析成了节点对象引用,而不是 ID 字符串。

拖拽交互则完整演示了 fx/fy 固定机制 + alphaTarget 重新加热的标准范式:

function dragstarted(event, d) {
  if (!event.active) simulation.alphaTarget(0.3).restart();
  d.fx = d.x;
  d.fy = d.y;
}

function dragged(event, d) {
  d.fx = event.x;
  d.fy = event.y;
}

function dragended(event, d) {
  if (!event.active) simulation.alphaTarget(0);
  d.fx = null;
  d.fy = null;
}

流程拆解:

  1. 拖拽开始:把 alphaTarget 抬到 0.3 并 restart()。由于此时 alpha 会持续向 0.3 插值而不会跌破 alphaMin,模拟进入“恒温”活跃状态,其他节点被扰动后重新找平衡;
  2. 拖拽中:持续更新被拖节点的 fx/fy。每个 tick 末尾,该节点位置被强制重置到 fx/fy 且对应速度清零,实现“跟手”效果;
  3. 拖拽结束alphaTarget 归 0,alpha 开始自然衰减,模拟经过约 300 次迭代内的冷却后自动停止;同时 fx/fynull 解除固定。

组件在 onUnmounted 中调用 simulation.stop() 释放计时器,这也是页面级模拟应有的清理动作。

十三、源码结构与验证依据

从本仓库的源码结构可以确认 d3-forced3 主包的直接依赖并被完整再导出:

  • src/index.jsexport * from "d3-force"; 表明所有模拟 API(forceSimulationforceLinkforceManyBody 等)都以 d3. 命名空间直接可用;
  • package.json 声明 "d3-force": "^3.0.0",且 test 脚本为 mocha 'test/**/*-test.js' && eslint src test
  • test/d3-test.js 逐模块遍历 package.json 的 dependencies,断言每个子模块(包括 d3-force)的所有导出都存在于 d3 命名空间中——即“d3 exports everything from d3-force”;
  • test/docs-test.js 会爬取 docs 下全部 Markdown 文件,校验所有内部文档链接(含锚点)均指向真实存在的标题,本文引用的 docs/d3-force/simulation.md 原始文档亦在此校验范围内;
  • 文档站可通过 package.json 中的 docs:dev 脚本(vitepress)本地运行查看交互式示例。

十四、参数速查表

API 默认值 取值范围 作用
simulation.alpha() 1 [0, 1] 当前“温度”
simulation.alphaMin() 0.001 [0, 1] 低于该值计时器停止
simulation.alphaDecay() 0.0228…(= 1 - 0.001^(1/300)) [0, 1] alpha 向目标插值的速率;0 表示永不冷却
simulation.alphaTarget() 0 [0, 1] alpha 的收敛目标;> alphaMin 时模拟持续运行
simulation.velocityDecay() 0.4 [0, 1] 每 tick 速度乘以 (1 - decay),类似摩擦
simulation.find(x, y, radius) radius = ∞ ≥ 0 搜索半径内最近节点
simulation.force(name, force) 绑定/移除按名称索引的力
节点属性 index/x/y/vx/vy 由模拟维护;fx/fy 固定位置

十五、小结

d3-force 的 forceSimulation 用极小的 API 面封装了一个完整的数值积分系统:alpha 体系alpha/alphaMin/alphaDecay/alphaTarget)控制模拟的“热力学”生命周期,velocityDecay 控制数值稳定性,force 注册表提供可插拔的力机制,tick/restart/stop 提供自动与手动两种驱动模式。掌握“创建模拟 → 绑定力 → 监听 tick 渲染 → 拖拽时 alphaTarget(0.3).restart() 重加热 → 松手归零冷却”这条主线,再结合 fx/fy 固定节点与 Web Worker 静态布局两个进阶技巧,即可覆盖从实时交互图到大规模离线布局的绝大多数需求。更细粒度的力实现(center、collide、link、many-body、position)可继续参考 docs/d3-force.md 下的各分篇文档。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
982
503
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384