{T}

力导向图

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. 性能优化

节点数策略
< 200SVG 渲染,默认参数即可
200-1000减少 tick 回调中的 DOM 操作,使用 Canvas
1000-5000Canvas 渲染 + 降低 alphaDecay + 减少力
> 5000WebGL(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 次计算布局,再一次性渲染,避免用户看到"抖动"过程。