用 d3-force 把一个字打散

774 字 · 2 分钟#可视化

在 S2 里写了两年 canvas 表格,组件库把底层封装得太好,一直没机会碰可视化的地基。最近把 d3 过了一遍,发现最有意思的不是画图表,是 d3-force——一个通用的粒子物理模拟器。官方例子全是节点连线的关系图,但它能干的事远不止这个。

先看结果。下面这个「墨」字由大约三千个粒子组成,拿指针从字上扫过去:

拿指针从字上扫过去,再等它聚回来

粒子被指针推开,又被一股看不见的力拉回原位。整个 demo 没有一行手写的动画代码,全部行为都来自力的叠加。

d3-force 在模拟什么

它把每个粒子当质点,每个 tick 给所有粒子施加若干个力、更新速度、再用速度更新位置(velocity Verlet 积分)。内置的力——forceLinkforceManyBodyforceCenter——组合起来就是常见的力导向布局。

模拟的「热度」由 alpha 控制:从 1 开始每 tick 衰减,衰减到 alphaMin(默认 0.001)就停机。默认衰减率约 0.0228,这个数是算出来的:1 − 0.001^(1/300),正好 300 个 tick 冷却完毕。做布局希望它收敛,但上面的 demo 相反——我把 alphaDecay 设成 0,模拟永不冷却,粒子随时待命。

顺带一提性能:内置的 forceManyBody 是所有节点两两作用,朴素实现是 O(n²),d3 用 Barnes-Hut 四叉树把远处的一簇节点合并成质心来算,降到 O(n log n)。三千个粒子每帧模拟毫无压力,瓶颈从来不在物理,在渲染。

目标点从哪来

把字打散的前提是知道每个粒子该待在哪。做法是把字画到一个离屏 canvas 上,按 4px 网格扫一遍像素,alpha 超过阈值的格点就是一个目标位置:

octx.font = '300px serif'
octx.fillText('', width / 2, height / 2)
const img = octx.getImageData(0, 0, width, height).data

for (let y = 0; y < height; y += 4) {
  for (let x = 0; x < width; x += 4) {
    if (img[(y * width + x) * 4 + 3] > 128) {
      targets.push([x, y])
    }
  }
}

这个采样思路是通用的:换成 logo、图标、任何形状都行,甚至可以在两组目标点之间做变形。

自定义力才是正戏

d3 里的「力」就是一个函数:每 tick 被调用一次,拿到 alpha,直接改粒子的速度。归位力五行就写完了:

const forceHome = (k = 0.06) => {
  let nodes
  const force = (alpha) => {
    for (const d of nodes) {
      d.vx += (d.tx - d.x) * k * alpha
      d.vy += (d.ty - d.y) * k * alpha
    }
  }
  force.initialize = (n) => (nodes = n)
  return force
}

指针斥力同理:粒子到指针的距离小于半径时,沿反方向推一把,推力随距离线性衰减:

const push = (1 - dist / radius) * strength
d.vx += (dx / dist) * push
d.vy += (dy / dist) * push

两个力挂上去就完了。粒子被推开时归位力一直在拉,指针一走,拉力独大,字自己愈合。「散开再聚拢」这个看起来很复杂的行为,其实是两个五行函数的合力,谁也没写过一帧动画。

弹性手感主要靠 velocityDecay 调:它是每 tick 的速度摩擦,调小了粒子回弹会过冲、晃几下才停,像果冻;调大了就变成温顺的直线归位。

渲染这边

三千个粒子用 SVG 就是三千个 DOM 节点,每帧改三千次属性,想都不用想。tick 回调里直接画 canvas:

simulation.on('tick', () => {
  ctx.clearRect(0, 0, width, height)
  for (const d of particles) {
    ctx.fillRect(d.x, d.y, 1.7, 1.7)
  }
})

模拟和渲染本来就是两层,d3-force 根本不关心你怎么画。写表格攒下的 canvas 手艺,在这里刚好接上。