用 d3-force 把一个字打散
774 字 · 2 分钟#可视化
在 S2 里写了两年 canvas 表格,组件库把底层封装得太好,一直没机会碰可视化的地基。最近把 d3 过了一遍,发现最有意思的不是画图表,是 d3-force——一个通用的粒子物理模拟器。官方例子全是节点连线的关系图,但它能干的事远不止这个。
先看结果。下面这个「墨」字由大约三千个粒子组成,拿指针从字上扫过去:
粒子被指针推开,又被一股看不见的力拉回原位。整个 demo 没有一行手写的动画代码,全部行为都来自力的叠加。
d3-force 在模拟什么
它把每个粒子当质点,每个 tick 给所有粒子施加若干个力、更新速度、再用速度更新位置(velocity Verlet 积分)。内置的力——forceLink、forceManyBody、forceCenter——组合起来就是常见的力导向布局。
模拟的「热度」由 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 手艺,在这里刚好接上。