力导向网络 — 让布局自己长出来
力导向网络 — 让布局自己长出来
这一章讲什么: 之前的图,位置都由公式直接算出;力导向图不同—— 位置是模拟出来的。节点像带电粒子,边像弹簧,再加碰撞、向心等约束, 一切从中心散开、冷却、落定。你要做的是选力、调参数、在 tick 里把新坐标画出来。 这是 D3 里最「物理」的一章,也是交互技巧(reheating、钉节点)最密集的一章。
1. 顶层全景:模拟的三要素
① 节点数组(任意对象)──► d3.forceSimulation(nodes)
│ 立即给每个节点加属性:x, y, vx, vy, index
│ 初始位置:phyllotaxis(螺旋)排列在中心附近
▼
② 加力:sim.force("名字", d3.forceXxx()… ) ← 每种力是一个小算法,改 vx/vy
│
▼
③ on("tick", 重画) ← 每次迭代(默认 ~300 次)回调,把 x/y 画进 DOM/Canvas
│
▼
alpha 从 1 冷到 alphaMin(0.001) → 模拟结束('end' 事件)
图说:没有力,节点不动(只初始排布);有力没 tick 监听,模拟跑了你也看不见。2. 核心原理
2.1 最小模拟与 tick
const nodes = [ {node:'A', value:79}, {node:'B', value:15}, … ]; // 8 个节点
const sim = d3.forceSimulation(nodes);
// 此刻 nodes 每项多了 index/x/y/vx/vy;初始位置是中心附近的螺旋(phyllotaxis)
sim.on("tick", () => chart.selectAll("circle")
.attr("cx", d => d.x).attr("cy", d => d.y));
sim.force("manybody", d3.forceManyBody());
// 斥力一上,圆点炸开、减速、落定——默认 300 tick 后结束想要静态结果(不要动画):sim.stop() 停掉自动迭代,手动
for(let i=0;i<50;i++) sim.tick(); 跑 50 次后按最终坐标画1。
2.2 七种力算法
| 力 | 干什么 | 关键配置 |
|---|---|---|
forceManyBody | 全员互斥(负 strength)或互吸(正) | strength(默认 −30)、distanceMin/Max、theta(精度换速度) |
forceCollide | 防重叠:间距 ≥ 半径之和 | radius(可逐节点)、iterations |
forceLink | 沿边拉(默认吸引);揭示聚类与度中心性 | links(数组)、distance(理想边长)、strength、id(用名字不用下标) |
forceCenter | 把整体质心拉回指定点(不变形) | x, y |
forceX / forceY | 把节点吸向竖线/横线 | x/y、strength(0–1) |
forceRadial | 吸向某圆周 | radius、x, y、strength |
配置规律:多数方法既能传一个普通数值(标量)也能传访问器——
forceManyBody().strength(d => -d.value) 让大节点斥力更强;
forceX/Y 甚至能吃序数比例尺,把不同类节点吸到不同带上2。
组合心法(原书反复强调):单力实验 → 再组合;碰撞力通常先注册; 参数微调(strength/distance/iterations)甚至注册顺序都会改结果,但多数情况下跑完后的终局差别不大——差别在动画过程。五力组合示例3:
d3.forceSimulation(nodes)
.force("center", d3.forceCenter())
.force("link", d3.forceLink(links).iterations(5).distance(1))
.force("collide",d3.forceCollide(15))
.force("radial", d3.forceRadial(200).strength(.1))
.force("y", d3.forceY().strength(.05))
.on("tick", redraw);自定义力就是「initialize(nodes) + 返回一个按 alpha 比例改 vx/vy 的回调」的函数, 十几行能写一个随机漂移力;x/y 只读,只动速度4。
2.3 alpha:冷却曲线与 reheating
模拟的一切动力来自 alpha(1 → 0 的冷却值):每 tick 按 alphaDecay(默认约 0.023,
正好 300 tick 降到 alphaMin 0.001)衰减;velocityDecay(默认 0.4)给速度阻尼。
alphaTarget 默认 0——把 target 抬高到 min 之上,模拟就永远跑不完。
这条曲线催生本章最重要的交互技巧——reheating(再加热): 拓扑变了(拖节点、增删节点)就把 alpha 抬回去,让模拟重新布局5:
function reheatSimulation() {
if (sim.alpha() < .05) { sim.alpha(.05); sim.restart(); }
}3. 交互四件套(全部围绕「模拟还在跑」这个事实)
3.1 高亮:与模拟无关,纯 classed
悬停节点 → 自己加 highlight、邻居与相连边之外的全加 faded;
判断邻居要在 links 数组里查双向(source/target 两个方向都查)6。
3.2 缩放:transform 叠加初始偏移
svg.call(d3.zoom().on('zoom', evt =>
chart.attr("transform", evt.transform.translate(offsetX, offsetY))));3.3 拖拽:与模拟协作,reheating 登场
关键矛盾:drag 改了 d.x/d.y,但模拟下一 tick 又会重算。 拖拽期间要把模拟「吊住」不结束7:
const nodeDragged = d3.drag()
.on("start", function() {
if (sim.alpha() <= sim.alphaMin()) sim.restart();
sim.alphaTarget(sim.alphaMin() + .1); // 目标高于 min → 暂时永动
})
.on("drag", function(evt, d) { [d.x, d.y] = [evt.x, evt.y]; })
.on("end", () => sim.alphaTarget(0)); // 松手,冷却收工钉住:把 d.fx/d.fy(固定坐标)代替 x/y 写,模拟每 tick 会把 fx/fy 拷进 x/y
而无视速度——节点永久停在你放的地方;恢复自由就 d.fx = d.fy = null8。
3.4 增删节点:更新三处
删:nodes.splice(idx,1); 相连的 links 逆序 splice;
sim.nodes(nodes); sim.force("link").links(links).initialize(nodes);
draw(); reheatSimulation();
加:d3.pointer(evt) → 用 zoomTransform.invert 把屏幕坐标换算回图表坐标 →
sim.find(x, y) 找最近节点(第三参数可给半径上限)→
push 新节点与连边 → 同样更新 sim/force/draw/reheat。
坑:删节点的 click 会冒泡到 svg 的「点击空白加节点」→ evt.stopPropagation()。sim.find(x, y, radius?) 是「最近节点查询」,Canvas 交互与点击定位都靠它9。
3.5 Canvas 版
千级节点 SVG 会卡死:Canvas 每 tick 全量重绘(clearRect → 线 → 圆 → 文字,
每步 save/restore)。模拟代码一行不改——变的只有 draw10。
4. 作者的判断与证据
| 判断 | 性质 | 依据 |
|---|---|---|
| 「先单力实验再组合」 | 学习方法 | 原书明说「best way to understand」2 |
| 「力注册顺序有讲究,但终局差别小」 | nuanced 经验 | 动画过程敏感、最终结果鲁棒3 |
| 「拖拽要 reheating,钉住用 fx/fy」 | 标准手法 | 三个 drag 事件的完整配方7 |
5. 边界与局限
- 力导向的终局不保证最优:同样的参数重跑可能陷在不同局部布局,书里没提这一点; (补充,不在书里:需要确定性结果就设随机种子——用自定义 source 喂模拟。)
- 大规模(万级节点)要做 Barnes-Hut 近似参数(theta)调优,书里只给了参数含义2。
- 有向图的「方向」在力导向里不编码(边无箭头),书里未展开。
判断(我们的,不是书里的): 力导向是「交互即布局」的极致: 拖拽、增删不是图表的附属功能,而是布局算法本身的一部分—— 用户每动一下节点,就是给模拟注入新的物理现实,图自己重新长好。 这个心智模型(人是模拟的参与者而非观察者)是力导向图区别于其他所有图的地方。 如果错,会错在: 把「重新长好」理解成「长得一样」——reheating 后的终局 常和原来不同(局部最优各不相同);需要精确复现布局的场合(印刷出版)应避免 交互式编辑,改用静态迭代 + 固定坐标。
6. 可带走的
- 模拟三要素:
forceSimulation(nodes)+ 若干force()+on("tick", 重画); - 节点属性:x/y(位置)、vx/vy(速度)、fx/fy(钉住);初始螺旋排列;
- 七种力:斥力 manyBody、碰撞 collide、弹簧 link、向心 center、定位 x/y/radial;
- 配置多能传访问器:
strength(d => -d.value)逐节点生效; - 碰撞力先注册;终局对顺序不敏感、过程敏感;
- alpha 冷却:默认 300 tick;
alphaTarget > alphaMin= 永动; - reheating = 拓扑变化后
alpha(.05) + restart(); - 拖拽三事件配方:start 抬 target → drag 写 x/y → end 归 0;钉住用 fx/fy,释放置 null;
- 增删节点:数据数组 +
sim.nodes()+force("link").links().initialize()+ reheating; sim.find(x,y,r)最近节点查询;Canvas 版只换 draw,模拟不动。
7. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 模拟创建与节点属性 | Force-Directed Networks | text/26-ch18-chapter-18-force-directed-networks.txt:58(搜「d3.forceSimulation()」) · text/26-ch18-chapter-18-force-directed-networks.txt:101(搜「index」) · text/26-ch18-chapter-18-force-directed-networks.txt:132(搜「phyllotaxis」) |
| tick 与静态布局 | Force-Directed Networks | text/26-ch18-chapter-18-force-directed-networks.txt:139(搜「sim.on("tick"」) · text/26-ch18-chapter-18-force-directed-networks.txt:170(搜「.stop()」) |
| 七种力算法表 | Force-Directed Networks | text/26-ch18-chapter-18-force-directed-networks.txt:208(搜「d3.forceCenter(x,y)」) · text/26-ch18-chapter-18-force-directed-networks.txt:250(搜「d3.forceManyBody()」) |
| 逐力详解 | Force-Directed Networks | text/26-ch18-chapter-18-force-directed-networks.txt:270(搜「Gravitational force」) · text/26-ch18-chapter-18-force-directed-networks.txt:293(搜「Collision force」) · text/26-ch18-chapter-18-force-directed-networks.txt:308(搜「Position forces」) · text/26-ch18-chapter-18-force-directed-networks.txt:342(搜「Link forces」) · text/26-ch18-chapter-18-force-directed-networks.txt:376(搜「Center force」) |
| 组合与自定义力 | Force-Directed Networks | text/26-ch18-chapter-18-force-directed-networks.txt:393(搜「force("center"」) · text/26-ch18-chapter-18-force-directed-networks.txt:413(搜「acts upon the previous state」) · text/26-ch18-chapter-18-force-directed-networks.txt:422(搜「forceRandom」) |
| alpha 与 reheating | Force-Directed Networks | text/26-ch18-chapter-18-force-directed-networks.txt:460(搜「alpha — a value between 0 and 1」) · text/26-ch18-chapter-18-force-directed-networks.txt:480(搜「0.023」) · text/26-ch18-chapter-18-force-directed-networks.txt:495(搜「reheating」) |
| 高亮/缩放/拖拽 | Force-Directed Networks | text/26-ch18-chapter-18-force-directed-networks.txt:590(搜「highlightNode」) · text/26-ch18-chapter-18-force-directed-networks.txt:630(搜「evt.transform.translate(offsetX」) · text/26-ch18-chapter-18-force-directed-networks.txt:651(搜「nodeDragged」) · text/26-ch18-chapter-18-force-directed-networks.txt:676(搜「d.fx = evt.x」) |
| 增删节点与 find | Force-Directed Networks | text/26-ch18-chapter-18-force-directed-networks.txt:730(搜「sim.nodes(nodes)」) · text/26-ch18-chapter-18-force-directed-networks.txt:739(搜「reheatSimulation()」) · text/26-ch18-chapter-18-force-directed-networks.txt:787(搜「findNearestNode」) · text/26-ch18-chapter-18-force-directed-networks.txt:803(搜「stopPropagation」) |
| Canvas 版 | Force-Directed Networks | text/26-ch18-chapter-18-force-directed-networks.txt:832(搜「Canvas rendering」) · text/26-ch18-chapter-18-force-directed-networks.txt:866(搜「drawLinks()」) |
| 章小结 | Force-Directed Networks | text/26-ch18-chapter-18-force-directed-networks.txt:904(搜「Summary」) |
8. 练习题里的两个完整场景(加厚:从题面到解法)
原书三道大练习值得单独走一遍,它们把本章技术放进了真实数据。
8.1 城市气泡图(练习 18.1–18.3)
数据:data/cities15000.csv(GeoNames,一万五千座以上城市),过滤人口 ≥ 400 万,得到约四十个大城市。
三连问层层递进:
- 只加斥力:manyBody 的 strength 调参——人口越大的城市斥力越强
(
strength(d => -Math.sqrt(d.population))),让小城能挤在大城边上; - 静态版:
.stop()后手动tick()50 次,把终局坐标画出来—— 适合出图,不适合交互; - 加碰撞与定位:collide 半径 = 圆的半径 + 1 像素间距,forceX 略强于 forceY, 让集群横向铺开——验收标准是「不重叠、间距 ≥ 1px、横向约为纵向两倍宽」。 这正是 forceX/forceY 的经典用法:它们不负责精确位置,只负责「拉向一条线」, 精确布局交给斥力与碰撞自己去协商——记住这个分工,比记参数有用。
8.2 巴黎地铁(练习 18.4)
data/paris-sample.json 三个数组:车站、连接、线路(含官方色)。
目标:一张按真实线路着色的网络图,换乘站(连接不同线路的车站)之间的连线画黑加粗三倍。
- link force 的
distance与strength按「是否换乘连线」分别配置:distance(d => d.interchange ? 30 : 60),strength同理; - 连边的可视化属性(颜色、粗细)是数据驱动的另一个例子——
stroke和stroke-width直接从 link 对象取,与力模拟完全解耦; - 加固定:练习 18.6 要求拖过的车站钉住(
fx/fy),再点一下释放—— 这是fx = null语义最顺手的一个交互。
判断(我们的,不是书里的): 力导向图调参有一条经验次序:先 link + manyBody + center 保底, 再加 collide 防重叠,最后才用 x/y/radial 塑形。 反过来(先塑形后补斥力)几乎必然反复返工, 因为定位力会把节点压进重叠区,碰撞力再往外推,两力拉锯到模拟结束都不收敛。 如果错,会错在: 对「已经有清晰几何归宿」的图(地铁线路本质上是一维顺序), 力导向本身可能就是错工具——按线路分 forceX 带、站点按序 forceY 排列,会更稳。 力导向的价值在「拓扑未知」的网络;拓扑已知时,它的随机性反而添乱。
Footnotes
-
出处:「Force-Directed Networks」第 55–193 段(
text/26-ch18-chapter-18-force-directed-networks.txt:93,搜「d3.forceSimulation(nodes)」;:132,搜「phyllotaxis」;:139,搜「sim.on("tick"」;:168,搜「.stop()」)。 ↩ -
出处:「Force-Directed Networks」第 195–341 段(
text/26-ch18-chapter-18-force-directed-networks.txt:208,搜「d3.forceCenter(x,y)」;:250,搜「d3.forceManyBody()」;:288,搜「strength(d => -d.value)」;:266,搜「best way to understand」)。力算法表照原书 Table 18.3 转写。 ↩ ↩2 ↩3 -
出处:「Force-Directed Networks」第 387–415 段(
text/26-ch18-chapter-18-force-directed-networks.txt:393,搜「force("center"」;:413,搜「acts upon the previous state」;:415,搜「might not notice many differences」)。 ↩ ↩2 -
出处:「Force-Directed Networks」第 417–439 段(
text/26-ch18-chapter-18-force-directed-networks.txt:422,搜「forceRandom」;:437,搜「read-only」)。 ↩ -
出处:「Force-Directed Networks」第 459–508 段(
text/26-ch18-chapter-18-force-directed-networks.txt:460,搜「alpha — a value between 0 and 1」;:480,搜「0.023」;:485,搜「alphaTarget()」;:495,搜「reheating」;:739,搜「reheatSimulation()」)。 ↩ -
出处:「Force-Directed Networks」第 575–619 段(
text/26-ch18-chapter-18-force-directed-networks.txt:590,搜「highlightNode」;:592,搜「classed("faded"」)。 ↩ -
出处:「Force-Directed Networks」第 642–682 段(
text/26-ch18-chapter-18-force-directed-networks.txt:651,搜「nodeDragged」;:656,搜「restarts the simulation」;:659,搜「alphaTarget(sim.alphaMin() + .1)」)。 ↩ ↩2 -
出处:「Force-Directed Networks」第 671–682 段(
text/26-ch18-chapter-18-force-directed-networks.txt:676,搜「d.fx = evt.x」;:680,搜「copies them to x and y」)。 ↩ -
出处:「Force-Directed Networks」第 64–77 段(
text/26-ch18-chapter-18-force-directed-networks.txt:74,搜「find(x,y,radius)」)与第 783–805 段(:787,搜「findNearestNode」;:789,搜「t.invert」;:803,搜「stopPropagation」)。 ↩ -
出处:「Force-Directed Networks」第 832–902 段(
text/26-ch18-chapter-18-force-directed-networks.txt:832,搜「Canvas rendering」;:847,搜「There is no change in the simulation code」;:866,搜「drawLinks()」)。 ↩