d3-force Physics

The Physics Behind d3-force's Simulation Loop

Each tick() cools, alpha += (alphaTarget - alpha) * alphaDecay; calls every force, which only adds to node velocities; applies friction, multiplying velocity by 1 - velocityDecay; and moves each node by its velocity unless fx/fy pin it: a velocity Verlet step of size 1. This script repeats one step by hand:

one-tick.mjs: one simulation step by hand, compared with d3's (Node.js)JavaScript
import { forceSimulation, forceManyBody } from 'd3';
const make = () => [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 0, y: 10 }];
const a = make(), sim = forceSimulation(a).force('charge', forceManyBody()).stop();
sim.tick();                                                    // d3's step
const b = make().map(n => ({ ...n, vx: 0, vy: 0 }));
const alpha = 1 + (0 - 1) * sim.alphaDecay();                  // 1. cool
const charge = forceManyBody();
charge.initialize(b, Math.random); charge(alpha);              // 2. forces add velocity
for (const n of b) {
  n.vx *= 1 - 0.4; n.vy *= 1 - 0.4;                            // 3. friction
  n.x += n.vx; n.y += n.vy;                                    // 4. move
}
const fmt = ns => ns.map(n => `(${n.x.toFixed(3)}, ${n.y.toFixed(3)})`).join(' ');
console.log(`d3:   ${fmt(a)}\nhand: ${fmt(b)}`);
Output
d3:   (-1.759, -1.759) (12.639, -0.880) (-0.880, 12.639)
hand: (-1.759, -1.759) (12.639, -0.880) (-0.880, 12.639)

The positions match. Alpha scales every force and decays geometrically, so motion dies out smoothly; a custom force is just a function of alpha that adjusts velocities.

One tick of d3-force done by hand: cool alpha, add force velocities, apply friction, moveHTMLLive
<!doctype html>
<style>
  body { margin: 0; padding: 8px; background: #fafaf7; font: 12px system-ui, sans-serif; color: #263238; }
  svg { width: 100%; max-width: 600px; display: block; background: #fff; }
  pre { margin: 4px 0; font-size: 11px; }
</style>
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
<p><button id="step">Step both</button> <button id="reset">Reset</button> <label>velocityDecay <input type="range" id="vd" min="0.1" max="0.9" step="0.05" value="0.4"></label></p>
<svg viewBox="0 0 600 220" font-size="11"></svg>
<pre id="out"></pre>
<script>
  const make = () => [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 0, y: 10 }, { x: 8, y: 9 }];
  let a, b, sim, charge, ticks, trails;
  function reset() {
    a = make();  b = make().map(n => ({ ...n, vx: 0, vy: 0 }));
    const vd = +d3.select('#vd').property('value');
    sim = d3.forceSimulation(a).force('charge', d3.forceManyBody()).velocityDecay(vd).stop();   // d3's own step
    charge = d3.forceManyBody();
    charge.initialize(b, () => 0.5);                   // the same force, driven by hand
    ticks = 0;  trails = [a.map(n => [n.x, n.y])];
    draw();
  }
  function step() {
    sim.tick();                                         // d3's step
    const alpha = sim.alpha();                          // 1. cool: alpha += (target - alpha) * decay
    charge(alpha);                                      // 2. forces only add to velocities
    const keep = 1 - sim.velocityDecay();               // friction keeps 1 - velocityDecay of the speed
    for (const n of b) {
      n.vx *= keep;  n.vy *= keep;                      // 3. friction
      n.x += n.vx;  n.y += n.vy;                        // 4. move: a velocity Verlet step of size 1
    }
    ticks++;  trails.push(a.map(n => [n.x, n.y]));
    draw();
  }
  const x = d3.scaleLinear([-60, 70], [20, 400]), y = d3.scaleLinear([-50, 60], [210, 10]);
  const colors = ['#1f5f8b', '#e09a10', '#b5452f', '#3f7d3a'];
  function draw() {
    const svg = d3.select('svg');
    svg.selectAll('path.trail').data(colors).join('path').attr('class', 'trail').attr('fill', 'none')
      .attr('stroke', c => c).attr('stroke-opacity', 0.4).attr('d', (c, k) => d3.line()(trails.map(t => [x(t[k][0]), y(t[k][1])])));
    svg.selectAll('circle.d3').data(a).join('circle').attr('class', 'd3').attr('r', 7)
      .attr('cx', n => x(n.x)).attr('cy', n => y(n.y)).attr('fill', (n, k) => colors[k]);
    svg.selectAll('circle.hand').data(b).join('circle').attr('class', 'hand').attr('r', 11).attr('fill', 'none')
      .attr('stroke', '#263238').attr('stroke-dasharray', '3 2').attr('cx', n => x(n.x)).attr('cy', n => y(n.y));
    // Alpha decays geometrically: 300 ticks from 1 to alphaMin (0.001) by default
    const ax = d3.scaleLinear([0, 300], [420, 590]), ay = d3.scaleLinear([0, 1], [200, 20]);
    svg.selectAll('path.alpha').data([0]).join('path').attr('class', 'alpha').attr('fill', 'none').attr('stroke', '#78909c')
      .attr('d', d3.line()(d3.range(301).map(t => [ax(t), ay((1 - sim.alphaDecay()) ** t)])));
    svg.selectAll('circle.now').data([ticks]).join('circle').attr('class', 'now').attr('r', 4).attr('fill', '#e09a10')
      .attr('cx', ax(ticks)).attr('cy', ay(sim.alpha()));
    svg.selectAll('text.lab').data(['alpha over 300 ticks']).join('text').attr('class', 'lab').attr('x', 420).attr('y', 14).text(t => t);
    const fmt = ns => ns.map(n => `(${n.x.toFixed(3)}, ${n.y.toFixed(3)})`).join(' ');
    d3.select('#out').text(`tick ${ticks}, alpha ${sim.alpha().toFixed(4)}\nd3:   ${fmt(a)}\nhand: ${fmt(b)}  (dashed rings)`);
  }
  d3.select('#step').on('click', step);
  d3.select('#reset').on('click', reset);
  d3.select('#vd').on('input', reset);
  reset();
  for (let k = 0; k < 12; k++) step();
</script>