编程语言研究书库

力导向网络 — 让布局自己长出来

力导向网络 — 让布局自己长出来

这一章讲什么: 之前的图,位置都由公式直接算出;力导向图不同—— 位置是模拟出来的。节点像带电粒子,边像弹簧,再加碰撞、向心等约束, 一切从中心散开、冷却、落定。你要做的是选力、调参数、在 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. 可带走的

  1. 模拟三要素:forceSimulation(nodes) + 若干 force() + on("tick", 重画);
  2. 节点属性:x/y(位置)、vx/vy(速度)、fx/fy(钉住);初始螺旋排列;
  3. 七种力:斥力 manyBody、碰撞 collide、弹簧 link、向心 center、定位 x/y/radial;
  4. 配置多能传访问器:strength(d => -d.value) 逐节点生效;
  5. 碰撞力先注册;终局对顺序不敏感、过程敏感;
  6. alpha 冷却:默认 300 tick;alphaTarget > alphaMin = 永动;
  7. reheating = 拓扑变化后 alpha(.05) + restart();
  8. 拖拽三事件配方:start 抬 target → drag 写 x/y → end 归 0;钉住用 fx/fy,释放置 null;
  9. 增删节点:数据数组 + sim.nodes() + force("link").links().initialize() + reheating;
  10. sim.find(x,y,r) 最近节点查询;Canvas 版只换 draw,模拟不动。

7. 原文地图

主题原书章原文位置
模拟创建与节点属性Force-Directed Networkstext/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 Networkstext/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 Networkstext/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 Networkstext/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 Networkstext/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 与 reheatingForce-Directed Networkstext/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 Networkstext/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」)
增删节点与 findForce-Directed Networkstext/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 Networkstext/26-ch18-chapter-18-force-directed-networks.txt:832(搜「Canvas rendering」) · text/26-ch18-chapter-18-force-directed-networks.txt:866(搜「drawLinks()」)
章小结Force-Directed Networkstext/26-ch18-chapter-18-force-directed-networks.txt:904(搜「Summary」)

8. 练习题里的两个完整场景(加厚:从题面到解法)

原书三道大练习值得单独走一遍,它们把本章技术放进了真实数据。

8.1 城市气泡图(练习 18.1–18.3)

数据:data/cities15000.csv(GeoNames,一万五千座以上城市),过滤人口 ≥ 400 万,得到约四十个大城市。 三连问层层递进:

  1. 只加斥力:manyBody 的 strength 调参——人口越大的城市斥力越强 (strength(d => -Math.sqrt(d.population))),让小城能挤在大城边上;
  2. 静态版:.stop() 后手动 tick() 50 次,把终局坐标画出来—— 适合出图,不适合交互;
  3. 加碰撞与定位: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

  1. 出处:「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()」)。 ↩

  2. 出处:「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

  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

  4. 出处:「Force-Directed Networks」第 417–439 段(text/26-ch18-chapter-18-force-directed-networks.txt:422,搜「forceRandom」;:437,搜「read-only」)。 ↩

  5. 出处:「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()」)。 ↩

  6. 出处:「Force-Directed Networks」第 575–619 段(text/26-ch18-chapter-18-force-directed-networks.txt:590,搜「highlightNode」;:592,搜「classed("faded"」)。 ↩

  7. 出处:「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

  8. 出处:「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」)。 ↩

  9. 出处:「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」)。 ↩

  10. 出处:「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()」)。 ↩

On this page