D3.js 力导向模拟完整指南:d3-force simulation 的数值原理、参数体系与自定义力实战
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 未指定,默认为空数组。
两点重要行为:
- 该函数是不纯的(impure):它会直接修改传入的节点对象。节点属性的详细赋值行为见下文 simulation.nodes 一节。
- 模拟自动启动:创建后内部计时器立即开始运行,可通过
simulation.on("tick", ...)监听 tick 事件进行渲染。如果希望手动控制模拟(例如离线计算静态布局),应先调用simulation.stop(),然后按需要反复调用simulation.tick()。
三、节点管理:simulation.nodes(nodes)
nodes 既是创建时的入参,也可以后续整体替换:
- 若指定 nodes:将模拟的节点设置为给定数组,必要时初始化它们的位置和速度,并重新初始化(re-initialize)所有已绑定的力,然后返回模拟;
- 若未指定:返回创建时传入的节点数组。
同样需要注意:此函数会直接修改传入的节点对象,为每个节点赋 index、x/y、vx/vy,并在后续每次 tick 中持续更新位置与速度。
每个 node 必须是对象。模拟会为它分配以下属性:
| 属性 | 含义 |
|---|---|
index |
节点在 nodes 中的零基索引 |
x |
节点当前 x 位置 |
y |
节点当前 y 位置 |
vx |
节点当前 x 方向速度 |
vy |
节点当前 y 方向速度 |
位置 ⟨x,y⟩ 和速度 ⟨vx,vy⟩ 之后既可能被自定义力修改,也会被模拟自身更新。初始化规则如下:
- 若 vx 或 vy 为 NaN,速度被初始化为 ⟨0, 0⟩;
- 若 x 或 y 为 NaN,位置按**旋花状排列(phyllotaxis arrangement)**初始化——这是一种确保分布确定且均匀的策略,避免了“所有节点堆在原点”的退化初始状态。
3.1 固定节点:fx 与 fy
可以通过两个额外属性把节点钉死在给定位置:
fx—— 节点固定的 x 位置;fy—— 节点固定的 y 位置。
执行机制:在每次 tick 的末尾(所有力作用之后),若节点定义了 node.fx,则 node.x 被重置为该值且 node.vx 置零;定义了 node.fy 则 node.y 被重置且 node.vy 置零。要解除固定,将 node.fx、node.fy 设为 null 或删除这两个属性即可。
3.2 节点数组变更后的重新通知
如果后续对节点数组做了增删改(例如向模拟中新增节点、删除节点),必须再次调用 simulation.nodes(newNodes) 传入新数组,以通知模拟和所有已绑定的力。模拟不会为指定数组做防御性拷贝。
四、计时器控制:restart() 与 stop()
4.1 simulation.restart()
重启模拟的内部计时器并返回模拟本身。配合 alphaTarget 或 alpha,此方法可以在交互过程中(如拖拽节点时)“重新加热”模拟,或在用 stop() 临时暂停后恢复模拟。
4.2 simulation.stop()
若模拟的内部计时器正在运行则停止它,并返回模拟。若计时器本就处于停止状态,此方法不做任何事。配合 tick() 手动驱动模拟时常用。
五、手动步进:simulation.tick(iterations)
手动按指定 iterations 次数步进模拟(默认 1 次,即单步),并返回模拟。每一迭代的内部执行顺序严格固定:
- 将当前 alpha 递增 (alphaTarget - alpha) × alphaDecay;
- 调用每一个已注册的力(force),并把新的 alpha 作为参数传入;
- 将每个节点的速度衰减:velocity × (1 - velocityDecay);
- 将每个节点的位置按速度递增。
两个关键注意事项:
- 手动 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 设置事件监听器并返回模拟。若同类型同名监听器已存在,旧监听器会先被移除。若 listener 为
null,则移除指定 typenames 的当前监听器; - 未指定 listener 时:返回与 typenames 匹配的第一个当前已赋监听器(若有)。
事件触发时,每个 listener 以模拟自身作为 this 上下文被调用。
typenames 是由空格分隔的一个或多个 typename 组成的字符串。每个 typename 由 type 组成,可后跟一个点(.)和 name,如 tick.foo 与 tick.bar——名字允许对同一类型注册多个监听器。合法的事件 type 只有两种:
tick—— 模拟内部计时器每次 tick 之后;end—— 模拟计时器在 alpha < alphaMin 时停止之后。
两个使用要点:
- 手动调用
simulation.tick()不会派发 tick 事件——事件只由内部计时器派发,且专为模拟的交互渲染设计; - 若要影响模拟,应注册力,而不是在 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 源提供给力。此方法在两个时机被调用:
- 力通过
simulation.force绑定到模拟时; - 模拟的节点通过
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;
}
流程拆解:
- 拖拽开始:把
alphaTarget抬到 0.3 并restart()。由于此时 alpha 会持续向 0.3 插值而不会跌破alphaMin,模拟进入“恒温”活跃状态,其他节点被扰动后重新找平衡; - 拖拽中:持续更新被拖节点的
fx/fy。每个 tick 末尾,该节点位置被强制重置到fx/fy且对应速度清零,实现“跟手”效果; - 拖拽结束:
alphaTarget归 0,alpha 开始自然衰减,模拟经过约 300 次迭代内的冷却后自动停止;同时fx/fy置null解除固定。
组件在 onUnmounted 中调用 simulation.stop() 释放计时器,这也是页面级模拟应有的清理动作。
十三、源码结构与验证依据
从本仓库的源码结构可以确认 d3-force 是 d3 主包的直接依赖并被完整再导出:
- src/index.js 中
export * from "d3-force";表明所有模拟 API(forceSimulation、forceLink、forceManyBody等)都以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 下的各分篇文档。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00