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:
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.
<!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>