Force Layout
Green · 2026-07-02 Node + Browser GraphForce-directed graph simulation. Batch-mode d3-force replacement for SSR / precompute workloads.
- Targets
- Node + Browser
- Version
- 0.1.1
Install
pnpm add @amigo-labs/force-layoutBenchmarks
Trend (6 pts)Benchmark
small (20 nodes)
- @amigo-labs/force-layout napi 1.22K hz · 7.18×
- d3-force 170 hz
Benchmark
medium (100 nodes)
- @amigo-labs/force-layout napi 56.6 hz · 3.58×
- d3-force 15.8 hz
README
@amigo-labs/force-layout
Force-directed graph layout — batch-mode simulation (many-body + spring + centre + collision). Replaces
d3-forcefor SSR / precompute workloads where you don’t need per-tick callbacks.
Install
pnpm add @amigo-labs/force-layout
Usage
import { simulate } from '@amigo-labs/force-layout'
const { nodes } = simulate(
[{ id: 'a' }, { id: 'b' }, { id: 'c' }],
[
{ source: 'a', target: 'b', distance: 60 },
{ source: 'b', target: 'c', distance: 60 },
],
{ iterations: 300, charge: -30, centerStrength: 0.1 },
)
// nodes → [{ id, x, y, vx, vy }, ...]
Install for the browser
The same import works in Angular, React, Vite, esbuild, and webpack ≥ 5 — the bundler picks the WASM build via the browser conditional export:
import { simulate } from '@amigo-labs/force-layout'
The _force-layout-core simulation is the same code on both sides, so layouts are identical between Node and the browser.
Options
interface SimulationOptions {
iterations?: number // default 300
charge?: number // many-body strength (negative = repulsion). default -30
collideRadius?: number // collision radius (0 disables). default 0
centerX?: number // default 0
centerY?: number // default 0
centerStrength?: number // default 0.1
alpha?: number // default 1
alphaDecay?: number // default ≈ computed from iterations
velocityDecay?: number // default 0.4
}
interface NodeSpec {
id: string
x?: number // starting x (default phyllotaxis spiral)
y?: number // starting y
fixed?: boolean // pin to (x, y) — skip forces
}
interface EdgeSpec {
source: string
target: string
distance?: number // target link length. default 30
strength?: number // spring strength in [0,1]. default 1 / min(inDeg, outDeg)
}
Scope
- Many-body (repulsion) — O(V²) brute-force.
- Link (Hooke spring with degree-weighted bias).
- Centering.
- Collision (hard-sphere overlap resolution).
- Pinned nodes.
Scope cuts
- No tick callback. One-shot
simulate()returns final positions. Animation loops stay on d3. - No force composition.
simulate()takes a fixed force stack. - No per-node / per-link strength functions. Constants only.
- O(V²) many-body — at >1000 nodes the Barnes-Hut wins. v0.2 will add a quadtree.
See __conformance__/divergences.md.
License
MIT
Perf review
Perf-Review: @amigo-labs/force-layout
Status: 🟢 Green (measured) · Reviewed: 2026-07-02 · Version: 0.1.1
Verdict
7.18× (20 nodes) and 3.58× (100 nodes) vs. d3-force (bench 2026-06-10). The candidate review predicted Yellow leaning Green and feared sub-2× on small graphs; the measurement beats that prediction on both buckets — the small-graph bucket clears the candidate’s ≥1× gate by a wide margin, and the median bucket sits above the ≥2× Green threshold. The structural lever is the batch shape: one simulate() call runs all iterations in Rust, so the per-tick FFI crossings that would dominate a d3-style tick loop never happen.
Evidence
Measured speedup (docs/benchmarks/force-layout.json, 2026-06-10, commit 8c743bf)
| Scenario | @amigo-labs/force-layout | d3-force | Speedup |
|---|---|---|---|
| small graph (20 nodes) | 1 221.01 Hz | 170.16 Hz | 7.18× |
| medium graph (100 nodes) | 56.61 Hz | 15.83 Hz | 3.58× |
docs/packages.jsonspeedup:"3.6–7.2× faster".- Install size: 404 KB vs
d3-force’s 169 KB — we are larger; the win is compute, not footprint.
Benchmark gaps
- Large bucket (500/800 nodes) not benched. Relevant because of the O(V²) many-body caveat below — the crossover point vs. d3’s Barnes–Hut is unmeasured.
What shipped vs. the candidate prediction
- Batch
simulate()only — no per-tick callback API. Layouts that need animated ticks stay on d3-force by design. - O(V²) many-body force instead of Barnes–Hut. Honest caveat: above roughly 1000 nodes d3’s O(V log V) approximation should win; the shipped sweet spot is the ≤ a-few-hundred-nodes dashboard graph.
- Multiplicative alpha decay and a deterministic RNG — same layout for the same input, unlike d3’s
Math.random()jitter.
Divergences
Not coordinate-identical to d3-force (deterministic seeding, decay-schedule differences); topology-level parity (cluster separation, link-length distribution) is what the conformance suite asserts. See crates/force-layout/__conformance__/divergences.md.
Pre-port assessment: d3-force.md
References
- Crate:
crates/force-layout - Bench shard:
docs/benchmarks/force-layout.json docs/packages.jsonspeedup:"3.6–7.2× faster"