力导向图
D3 的力导向图(Force-Directed Graph)基于物理模拟引擎 d3-force,通过多种力的组合实现节点自动布局,是可视化关系网络的标准方案。
1. 力模拟引擎
1.1 核心概念
图表渲染中…
1.2 基本结构
javascript
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide } from 'd3'
const nodes = [
{ id: 'A', group: 1 },
{ id: 'B', group: 1 },
{ id: 'C', group: 2 },
{ id: 'D', group: 2 },
{ id: 'E', group: 3 }
]
const links = [
{ source: 'A', target: 'B', value: 3 },
{ source: 'A', target: 'C', value: 1 },
{ source: 'B', target: 'D', value: 2 },
{ source: 'C', target: 'E', value: 1 }
]
const simulation = forceSimulation(nodes)
.force('link', forceLink(links).id(d => d.id).distance(80))
.force('charge', forceManyBody().strength(-200))
.force('center', forceCenter(width / 2, height / 2))
.force('collide', forceCollide().radius(20))
.on('tick', ticked)2. 力的类型
2.1 forceLink(连接力)
将相连节点拉向目标距离。
javascript
forceLink(links)
.id(d => d.id) // 节点标识访问器
.distance(100) // 目标距离
.strength(d => 1 / Math.min(count(d.source), count(d.target))) // 强度2.2 forceManyBody(电荷力)
所有节点之间的引力/斥力(类似万有引力)。
javascript
forceManyBody()
.strength(-200) // 负值=斥力,正值=引力
.distanceMin(10) // 最小作用距离(防止重叠时力过大)
.distanceMax(500) // 最大作用距离(性能优化)
.theta(0.9) // Barnes-Hut 近似精度2.3 forceCenter(居中力)
将节点群体的质心拉向指定点。
javascript
forceCenter(width / 2, height / 2)
.strength(0.1) // 居中强度(默认 1)2.4 forceCollide(碰撞力)
防止节点重叠。
javascript
forceCollide()
.radius(d => nodeRadius(d) + 5) // 碰撞半径
.strength(0.8) // 碰撞强度
.iterations(2) // 迭代次数(精度)2.5 forceX / forceY(定位力)
将节点拉向指定 x/y 坐标。
javascript
forceX(width / 2).strength(0.05)
forceY(height / 2).strength(0.05)
// 按分组定位到不同区域
forceX(d => groupPositions[d.group].x).strength(0.1)3. 渲染力导向图
3.1 SVG 渲染
javascript
// 连线
const link = svg.append('g')
.selectAll('line')
.data(links)
.join('line')
.attr('stroke', '#999')
.attr('stroke-opacity', 0.6)
.attr('stroke-width', d => Math.sqrt(d.value))
// 节点
const node = svg.append('g')
.selectAll('circle')
.data(nodes)
.join('circle')
.attr('r', 8)
.attr('fill', d => colorScale(d.group))
.attr('stroke', '#fff')
.attr('stroke-width', 1.5)
// 标签
const label = svg.append('g')
.selectAll('text')
.data(nodes)
.join('text')
.text(d => d.id)
.attr('font-size', 10)
.attr('dx', 12)
.attr('dy', 4)
// tick 回调:每帧更新位置
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)
label
.attr('x', d => d.x)
.attr('y', d => d.y)
}3.2 添加拖拽交互
javascript
import { drag } from 'd3'
node.call(drag()
.on('start', (event, d) => {
if (!event.active) simulation.alphaTarget(0.3).restart()
d.fx = d.x // 固定节点
d.fy = d.y
})
.on('drag', (event, d) => {
d.fx = event.x
d.fy = event.y
})
.on('end', (event, d) => {
if (!event.active) simulation.alphaTarget(0)
d.fx = null // 释放节点
d.fy = null
})
)4. 模拟控制
4.1 Alpha 衰减
javascript
simulation
.alpha(1) // 初始能量(默认 1)
.alphaMin(0.001) // 停止阈值
.alphaDecay(0.0228) // 衰减率(默认,约 300 次 tick 后停止)
.alphaTarget(0) // 目标 alpha
.velocityDecay(0.4) // 速度衰减(摩擦力)4.2 动态控制
javascript
// 重新加热(数据变化后)
simulation.alpha(0.5).restart()
// 停止模拟
simulation.stop()
// 手动 tick(用于服务端渲染/截图)
simulation.stop()
for (let i = 0; i < 300; i++) simulation.tick()
// 此时节点位置已稳定,可直接渲染5. 性能优化
| 节点数 | 策略 |
|---|---|
| < 200 | SVG 渲染,默认参数即可 |
| 200-1000 | 减少 tick 回调中的 DOM 操作,使用 Canvas |
| 1000-5000 | Canvas 渲染 + 降低 alphaDecay + 减少力 |
| > 5000 | WebGL(pixi.js)或预计算布局 |
5.1 Canvas 渲染
javascript
const canvas = document.querySelector('canvas')
const ctx = canvas.getContext('2d')
simulation.on('tick', () => {
ctx.clearRect(0, 0, width, height)
// 画连线
ctx.beginPath()
links.forEach(d => {
ctx.moveTo(d.source.x, d.source.y)
ctx.lineTo(d.target.x, d.target.y)
})
ctx.strokeStyle = '#999'
ctx.stroke()
// 画节点
nodes.forEach(d => {
ctx.beginPath()
ctx.arc(d.x, d.y, 5, 0, 2 * Math.PI)
ctx.fillStyle = colorScale(d.group)
ctx.fill()
})
})6. 最佳实践
始终设置 forceCollide
没有碰撞力时节点会重叠。forceCollide().radius(d => r + padding) 是必备配置。
使用 id 访问器
forceLink(links).id(d => d.id) 让 links 中可以用字符串 ID 引用节点,而非对象索引。
不要在 tick 中创建/删除 DOM
tick 每秒触发约 60 次。只在 tick 中更新位置属性,DOM 的创建/删除放在数据更新时。
大数据集预计算
超过 1000 节点时,先 simulation.stop() + 手动 tick 300 次计算布局,再一次性渲染,避免用户看到"抖动"过程。