Node graph: how the physics works
2026-07-29
The note graph on the wiki index isn't a canned layout algorithm — it's a small Newtonian N-body simulation running live in the browser, driving one custom force through force-graph's canvas renderer while every one of d3-force's built-in forces (charge, link, center) is switched off. This note is a walkthrough of that simulation: what force acts on a node, why, and what breaks if you remove a term. The source lives in tools/wiki/graph_script.html; if you want to play with the constants below in real time rather than just read about them, load the wiki page with ?tune appended to the URL and a slider panel appears above the graph.
One node, one snapshot, one force
Every published wiki page is a particle with a mass (its val, which scales with how many notes link to it). Once per animation tick, the simulation:
- snapshots every node's current position and velocity into plain arrays;
- for each node, computes the net force acting on it — using only that snapshot and the node's own link list, never another node's in-progress update;
- converts force to acceleration via \(a = F/m\) and adds it to that node's velocity;
- hands off to d3-force's own integrator, which moves each node by its velocity and applies a fixed per-tick friction.
The snapshot in step 1 matters: without it, a node processed early in the loop would already have moved by the time a later node's force is computed against it, and the whole system would be integrating against a lie — order-dependent artifacts that are subtle but real. Snapshotting is what makes the integration honest: every node in a given tick reacts to the same frozen configuration of the world, exactly like an explicit-timestep N-body solver should.
Three forces are summed into that one net force. In order:
1 — an electrostatic-style pairwise field
Every node pushes and pulls on every other node with a field that is attractive at long range and repulsive at short range:
$$F(r) = \frac{\text{attract}}{r^2} - \frac{\text{repulse}}{r^3}$$with attract = 2200 and repulse = 400000. The \(1/r^2\) term is what keeps the whole corpus from drifting apart — without it, disconnected leaf notes would fly off to infinity, since nothing else pulls them toward the group. The \(1/r^3\) term dominates at short range (it falls off faster) and is what keeps nodes from ever piling up: as \(r \to 0\) the repulsive term wins by construction. The two exponents, not their coefficients, are what make this pattern work — the field name-checks Coulomb's law on purpose.
That raw field is clamped to a maximum magnitude (maxForce = 60) before anything else touches it, so a pathological near-zero separation — two nodes spawned on top of each other, say — can't produce a force large enough to launch a node off-screen in one tick.
2 — progressive, damped springs between linked notes
Only linked pairs get a spring. It has three regimes in the separation \(r\):
$$ F_{\text{spring}}(r) = \begin{cases} 0 & r \le 90 \\ 0.12\,(r - 90) & 90 < r \le 180 \\ 0.12\,(r-90) + 0.4\,(r-180)^2 & r > 180 \end{cases} $$Below 90px (linkDistance) the spring is completely slack — a link between two notes that happen to already be close imparts no pull. This is deliberate: an earlier version used a natural-length spring (force proportional to any deviation from a rest length), and it had a bad side effect — linked clusters squeezed themselves smaller and smaller as every link tried to shrink toward its rest length, even links that were already comfortable. A tension-only spring never does that; it only ever pulls two notes together when they've drifted apart, and does nothing to notes that are already near each other.
Between 90px and 180px (linkLinear) it's an ordinary Hooke spring with \(k = 0.12\). Past 180px the restoring force picks up a quadratic term on top of the linear one, so it gets rapidly harder to stretch a link further — softly bounding how long a link can get without an abrupt hard cap (an earlier version did hard-cap link length, and the cap fought visibly with the repulsion field in dense sub-clusters, producing an oscillation neither term could resolve on its own).
On top of that restoring force there's a damper: whenever a spring is engaged (the pair is past linkDistance), the force also subtracts
— the component of the two endpoints' relative velocity along the link's own axis, read from the same frozen snapshot as everything else. This is a real damper element, not just drag: a spring that overshoots its target and starts swinging back and forth loses energy proportional to how fast it's swinging, so the oscillation dies out in a couple of ticks instead of ringing indefinitely. It only fires along the link direction, so it doesn't fight the node's other, unrelated motion.
3 — centre gravity
A final, small term pulls every node toward the origin, using only that node's own position:
$$F_{\text{gravity}} = -\text{gravity} \cdot \vec{p}, \quad \text{gravity} = 0.006$$Because it depends on nothing but the node's own coordinates, this term never couples two nodes together — it's what keeps the whole cloud anchored near the middle of the frame instead of drifting, without constraining its shape at all.
Keeping circles from actually touching
The smooth \(1/r^3\) repulsion above is a field, not a collision system — nothing stops two large-radius hub nodes from clipping through each other, since the field only cares about center-to-center distance, not drawn radius. So a second, harder term runs alongside it: the instant two drawn circles (their radii plus a 6px gap) intersect, an extra push is added —
$$F_{\text{overlap}} = \text{overlapPush}\cdot p + \text{overlapPush}_2\cdot p^2, \quad p = \text{penetration depth}$$with overlapPush = 5 and overlapPush2 = 10. The quadratic term is what makes this stable and effective at the same time: it's gentle for a shallow, glancing overlap (so it doesn't fight the light, fast-reacting nodes into jitter), but it climbs steeply as the overlap deepens, so even the largest hub nodes — the ones with dozens of incoming links and correspondingly huge drawn radii — get forced fully apart rather than settling into a permanent partial overlap. This term is applied after the maxForce clamp on the field above, so it always wins regardless of how strong the pairwise field currently is.
Starting from clusters, not a pile
The forces above are what keep the graph in equilibrium, but they say nothing about where nodes start. Dropping every node at the origin and letting the physics untangle the resulting pile from scratch works, but takes many seconds and tends to leave stray notes tangled inside clusters they don't belong to.
Instead, before the simulation's first tick, a union-find pass over the link list groups nodes into connected components. Each multi-node component is dropped as a tight blob at a distinct point on a ring around the origin; nodes with no links at all are scattered in a wide annulus further out, so they read immediately as background rather than as members of any cluster. The physics only has to refine this starting layout — separate blobs that are a little too close, tension the odd stretched link — instead of solving the much harder problem of untangling a hairball.
Trying it yourself
Every constant above lives in a single object (P) at the top of tools/wiki/graph_script.html, and the force function reads it fresh every tick — so it's live-tunable, not just edit-and-reload. Append ?tune to the wiki URL and a panel of sliders appears above the graph, one per constant, each retuning the running simulation as you drag it. It's the fastest way to build intuition for which term does what: turn off overlap push and watch hubs clip through each other; turn off link damping and watch a dragged node's neighbours ring for several seconds after you let go.