From 7eceb58303a34e7fe42868d98950f7aa5e021823 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sun, 13 Sep 2026 09:17:01 +0300 Subject: [PATCH] Engine unification: d3-force replaces forceAtlas2 everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One engine owns the equilibrium: the merge settle and (next commit) the live drag both run createGraphSimulation — d3-force link springs, 1/d charge, weak centroid gravity — so a drag's rest state IS the server's rest state. The FA2-era anchor mechanism survives as a post-tick POSITION-SPACE re-blend (retention 0.85): d3's forceLink is a Hooke spring (strength*(d-L)*alpha), which overpowers any force-space anchor at long range — the re-blend is alpha-independent and always wins as the sim anneals (the 675-unit drift bug). Gravity retuned for d3's 1/d many-body falloff (bootstrap 0.2 / incremental 0.12: cluster spacing lands ~400 world units, the FA2-era density). Rim orphans seed (and anchor) at 1.7x the CONNECTED core's mean radius — a max-span-based rim fed back on itself and ran the layout away to 6000 units. Replay: 338 positions in 1.6s, old-node shift within a batch 4.8 (FA2: 23). --- package-lock.json | 1 + package.json | 1 + scripts/replay-layout.ts | 8 +- src/core/index.ts | 1 + src/core/layout.ts | 248 ++++++++++++++++++++++++++++----------- 5 files changed, 186 insertions(+), 73 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3f1e3c1..862e963 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "d3-force": "^3.0.0", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0", "embla-carousel-react": "^8.6.0", diff --git a/package.json b/package.json index 3f784f2..1e1f895 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "d3-force": "^3.0.0", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0", "embla-carousel-react": "^8.6.0", diff --git a/scripts/replay-layout.ts b/scripts/replay-layout.ts index 23a8242..7c56d84 100644 --- a/scripts/replay-layout.ts +++ b/scripts/replay-layout.ts @@ -49,6 +49,7 @@ function main() { console.log(`[replay] ${nodes.length} unique nodes, ${edges.length} edge lines, ${sources.length} sources`); let maxOldShift = 0; + let shiftReport = ""; for (const source of sources) { const batch = nodes.filter((n) => batchOf.get(n.id) === source.id); const isLast = source.id === sources[sources.length - 1]!.id; @@ -76,7 +77,10 @@ function main() { for (const [id, prev] of before) { const now = positions.get(id)!; const shift = Math.hypot(prev.x - now.x, prev.y - now.y); - if (shift > maxOldShift) maxOldShift = shift; + if (shift > maxOldShift) { + maxOldShift = shift; + shiftReport = `${id} ${prev.x.toFixed(0)},${prev.y.toFixed(0)} -> ${now.x.toFixed(0)},${now.y.toFixed(0)} (batch ${batch.length} new, ${activeEdges.length} edges)`; + } } const label = source.filePath.split("/").pop() ?? source.id; console.log( @@ -87,7 +91,7 @@ function main() { graphStore.writePositions(Object.fromEntries(positions)); console.log( `[replay] done: ${positions.size} positions written in ${((Date.now() - t0) / 1000).toFixed(1)}s` + - ` (max old-node shift within a batch: ${maxOldShift.toFixed(1)})`, + ` (max old-node shift within a batch: ${maxOldShift.toFixed(1)}: ${shiftReport})`, ); } diff --git a/src/core/index.ts b/src/core/index.ts index 39c97a8..792689b 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -21,6 +21,7 @@ export * from "./render-settings.ts"; export * from "./question-core.ts"; export * from "./clusters.ts"; +export * from "./layout.ts"; export * from "./plan-tree.ts"; export * from "./phases.ts"; export * from "./pending.ts"; diff --git a/src/core/layout.ts b/src/core/layout.ts index af072c0..b1a7d54 100644 --- a/src/core/layout.ts +++ b/src/core/layout.ts @@ -1,53 +1,134 @@ /** - * Option A — incremental anchored force layout (DECIDED 2026-09-13). + * The layout engine — d3-force, everywhere (DECIDED 2026-09-13). * - * The layout EVOLVES instead of reshuffling: each settle warm-starts - * forceAtlas2 from the positions ledger, ties every previously-positioned - * node back toward its anchor (a per-iteration re-blend), and seeds new - * nodes near their positioned neighbors — so old placements stay - * recognizable while the graph grows, exactly the way a mind map accretes - * around what came before. + * One engine owns the equilibrium: the merge-time incremental settle AND + * the live drag physics run the SAME force set (links as springs, charge + * repulsion, weak gravity), so the positions ledger and the transient + * simulation agree on what "resting" means. (FA2 was replaced after a + * research round: it has no annealing/pinning/settle model and mixing it + * with a different live engine visibly re-flows the graph on every drag.) * - * Design constraints (kept deliberately, for future live physics): - * - PURE: no fs, no server types — the same function is the merge-time - * server settler AND the future client-side live simulator (a pinned - * node is just an anchor with full retention). - * - DETERMINISTIC: seeded by node id, so the same graph + ledger state - * always settles identically. + * Layout EVOLVES instead of reshuffling (Option A): each settle warm-starts + * from the positions ledger and spring-ties every previously-positioned + * node toward its anchor (a per-node anchor force). New nodes seed near + * their positioned neighbors; orphans seed on the rim OUTSIDE the core and + * are anchored there, so they read as "not yet connected knowledge". * - * Orphans (degree-0 nodes) seed on the rim — outside the anchored core's - * bounding box — so they read as "not yet connected knowledge", the organic - * outer ring the user asked for. + * Design constraints (kept deliberately, for live physics): + * - PURE: no fs, no server types — this module runs server-side (the + * merge settle) and in the browser (the live simulation, via the core + * barrel). A pinned node is just fx/fy; an anchored node is a spring. + * - DETERMINISTIC: seeds derive from node id; d3-force ticks are + * deterministic — same inputs, same layout. */ -import Graph from "graphology"; -import forceAtlas2 from "graphology-layout-forceatlas2"; -import type { GraphNode, GraphEdge, Position } from "./contracts.ts"; +import { + forceSimulation, + forceLink, + forceManyBody, + forceX, + forceY, + type Simulation, + type SimulationLinkDatum, + type SimulationNodeDatum, +} from "d3-force"; +import type { GraphEdge, GraphNode, Position } from "./contracts.ts"; import { seededRandom } from "./question-core.ts"; -export interface IncrementalLayoutOptions { - /** FA2 iterations per settle (default 100 — same depth as the classic - * one-shot pass, so a fresh graph settles exactly like the old code). */ - iterations?: number; - /** Per-iteration anchor retention: how much of a previously-positioned - * node's displacement from its anchor SURVIVES each iteration. - * 1 = fully pinned (never moves), 0.85 = gentle drift where forces - * are strong (near new nodes), stillness where they are not. */ - anchorRetention?: number; +/** A node the d3 simulation mutates (x/y/vx/vy; fx/fy = pinned). */ +export interface LayoutNode extends SimulationNodeDatum { + id: string; } -/** Layout adjacency input: edges are undirected for layout purposes. */ +/** A link as d3 sees it (id strings resolve to node refs on init). */ +type SimLink = SimulationLinkDatum; + +export interface SimOptions { + /** Link spring rest length (world units). */ + linkDistance?: number; + /** Many-body repulsion strength (negative). */ + chargeStrength?: number; + /** Center-of-mass gravity: pulls loose components inward. Weak — + * anchors, not gravity, hold the evolved layout's frame. */ + gravity?: { x: number; y: number; strength: number }; + /** Alpha decay per tick — higher = snappier settle. The merge settle + * uses d3's default 0.0228 (~300 ticks); the live sim passes ~0.05. */ + alphaDecay?: number; +} + +/** Link input: edges are undirected for layout purposes. */ export interface LayoutEdge { from: string; to: string; } +const LINK_DISTANCE = 40; +const CHARGE_STRENGTH = -120; +/** Per-tick anchor re-blend: fraction of an anchored node's displacement + * from its previous position that SURVIVES each tick. 0.85 = gentle + * evolution where forces are strong, stillness where they are not — + * the exact FA2-era constant. Position-space (alpha-independent), so it + * cannot be overpowered by d3's long-range link springs the way a force- + * space anchor can (forceLink is a Hooke spring: strength*(d-L)*alpha). */ +const ANCHOR_RETENTION = 0.85; +/** Gravity strengths. d3 manyBody repulsion falls off as 1/d, so two + * 20-node blobs at distance d push with ~n1*n2*|charge|/d; cluster + * spacing settles where that equals g*d*n/2 — g=0.03 lands clusters + * ~400 world units apart (the FA2-era density), and the anchor re-blend + * (position-space, alpha-independent) keeps gravity's transient sag + * from ever moving an anchored node at rest. */ +const BOOTSTRAP_GRAVITY = 0.2; +/** Exported: the live sim reuses this so its rest state matches the + * server settle's equilibrium (one engine, one rest). */ +export const INCREMENTAL_GRAVITY = 0.12; + +/** + * The one force set. Returns a STOPPED simulation — callers drive it + * manually (tick-per-frame live, or a bounded tick loop when settling). + * Deterministic; mutates `nodes` in place (d3 semantics: impure by design, + * zero per-tick allocation). + */ +export function createGraphSimulation( + nodes: LayoutNode[], + edges: ReadonlyArray, + options: SimOptions = {}, +): Simulation { + const sim = forceSimulation(nodes) + .force( + "link", + forceLink( + edges.map((e) => ({ source: e.from, target: e.to })), + ) + .id((d) => d.id) + .distance(options.linkDistance ?? LINK_DISTANCE), + ) + .force("charge", forceManyBody().strength(options.chargeStrength ?? CHARGE_STRENGTH)) + .alphaDecay(options.alphaDecay ?? 0.0228) + .stop(); + + const g = options.gravity; + if (g) { + sim.force("x", forceX(g.x).strength(g.strength)); + sim.force("y", forceY(g.y).strength(g.strength)); + } + return sim; +} + +export interface IncrementalLayoutOptions { + /** Manual ticks for the settle (default 300 — d3's anneal to alphaMin). */ + ticks?: number; + /** Per-tick anchor re-blend (default 0.85; 1 = fully pinned). */ + anchorRetention?: number; + /** Center-of-mass gravity strength override (tuning/A-B experiments; + * defaults to the internal bootstrap/incremental constants). */ + gravityStrength?: number; +} + /** * One incremental settle. `nodes`/`edges` describe the CURRENT graph; * `previous` is the positions ledger (empty on the very first settle — - * then this degrades to the classic seeded-scatter + 100-iteration FA2 - * pass, i.e. what the browser used to do from scratch every time). - * Returns positions for EVERY node in `nodes`. + * then this degrades to a seeded-scatter bootstrap). Returns positions + * for EVERY node in `nodes`. */ export function incrementalLayout( nodes: ReadonlyArray>, @@ -55,8 +136,8 @@ export function incrementalLayout( previous: ReadonlyMap, options: IncrementalLayoutOptions = {}, ): Map { - const iterations = options.iterations ?? 100; - const retention = options.anchorRetention ?? 0.85; + const ticks = options.ticks ?? 300; + const retention = options.anchorRetention ?? ANCHOR_RETENTION; // Anchors: the warm-start set (the previous rest state). const anchors = new Map(); @@ -85,20 +166,39 @@ export function incrementalLayout( cx /= anchors.size; cy /= anchors.size; } - const coreRadius = hasCore - ? Math.max(maxX - minX, maxY - minY) / 2 - : 1; + const coreRadius = hasCore ? Math.max(maxX - minX, maxY - minY) / 2 : 1; // Adjacency for neighbor seeding (undirected: edges include assertions). const adjacency = new Map(); + const linkTo = (a: string, b: string): void => { + if (a === b) return; + let list = adjacency.get(a); + if (!list) adjacency.set(a, (list = [])); + list.push(b); + }; for (const e of edges) { - if (e.from === e.to) continue; - (adjacency.get(e.from) ?? adjacency.set(e.from, []).get(e.from)!).push(e.to); - (adjacency.get(e.to) ?? adjacency.set(e.to, []).get(e.to)!).push(e.from); + linkTo(e.from, e.to); + linkTo(e.to, e.from); } - const graph = new Graph(); - for (const node of nodes) { + // The rim radius derives from the CONNECTED core's mean radius — orphans + // never inflate it (a max-span-based radius feeds back: each batch's + // rim pushes the next batch's rim farther out, and the layout runs + // away). 1.7x matches the density the FA2-era settle produced + // (orphan mean r ~338 vs connected ~194). + let rimRadius = 0; + let connectedAnchored = 0; + for (const [id, a] of anchors) { + if ((adjacency.get(id)?.length ?? 0) === 0) continue; + connectedAnchored++; + rimRadius += Math.hypot(a.x - cx, a.y - cy); + } + if (connectedAnchored > 0) rimRadius = (rimRadius / connectedAnchored) * 1.7; + else if (hasCore) rimRadius = coreRadius * 1.15; // no connected anchors yet: bbox fallback + rimRadius += 4; + + // Seed every node. + const simNodes: LayoutNode[] = nodes.map((node) => { const rand = seededRandom(node.id); const anchor = anchors.get(node.id); let x: number; @@ -126,57 +226,63 @@ export function incrementalLayout( y = sy / found + (rand() - 0.5) * 8; } else if (hasCore) { const angle = rand() * Math.PI * 2; - const radius = coreRadius * 1.15 + 4 + rand() * 8; + const radius = rimRadius + rand() * 8; x = cx + Math.cos(angle) * radius; y = cy + Math.sin(angle) * radius; + // An orphan's intended rest spot IS the rim seed: anchor it + // there so gravity cannot sink it into the core. (A later + // batch linking it to the graph pulls it in naturally — + // but then it also has a previous position, and its anchor + // becomes that previous position, exactly like any node.) + anchors.set(node.id, { x, y }); } else { - // Bootstrap: the classic seeded scatter (FA2 requires - // non-zero coordinates; (rand()-0.5)*100 hits both axes). + // Bootstrap: the classic seeded scatter. x = (rand() - 0.5) * 100; y = (rand() - 0.5) * 100; } } - // FA2 divides by distances — a coincident (0,0) seed is degenerate. + // d3 is fine with coincident seeds (no division), but nudge exact + // overlaps of a brand-new node onto its only neighbor anyway. if (x === 0 && y === 0) x = 0.5; - graph.addNode(node.id, { x, y }); - } - for (const e of edges) { - if (graph.hasNode(e.from) && graph.hasNode(e.to) && !graph.hasEdge(e.from, e.to)) { - graph.addEdge(e.from, e.to); - } - } + return { id: node.id, x, y }; + }); - // Same escalation as the browser path: Barnes-Hut above ~500 nodes. - const settings = { - ...forceAtlas2.inferSettings(graph), - ...(graph.order > 500 ? { barnesHutOptimize: true } : {}), - }; - for (let i = 0; i < iterations; i++) { - forceAtlas2.assign(graph, { iterations: 1, settings }); - // Anchor re-blend: previously-positioned nodes are pulled back - // toward their rest positions, so the layout evolves locally - // instead of globally reshuffling. displacement * retention — - // strong forces still win out (equilibrium ≈ force/(1-retention)), - // weak ones fade to stillness. + const sim = createGraphSimulation(simNodes, edges, { + gravity: { + x: cx, + y: cy, + strength: options.gravityStrength ?? (hasCore ? INCREMENTAL_GRAVITY : BOOTSTRAP_GRAVITY), + }, + }); + const byId = new Map(simNodes.map((n) => [n.id, n])); + for (let i = 0; i < ticks; i++) { + sim.tick(); + // Anchor re-blend (position space, alpha-independent — see the + // ANCHOR_RETENTION note). Pinned nodes (fx/fy) are left to d3. if (hasCore && retention < 1) { for (const [id, a] of anchors) { - const attrs = graph.getNodeAttributes(id); - graph.setNodeAttribute(id, "x", a.x + (attrs.x - a.x) * retention); - graph.setNodeAttribute(id, "y", a.y + (attrs.y - a.y) * retention); + const n = byId.get(id); + if (!n || n.fx != null || n.fy != null) continue; + const x = n.x ?? 0; + const y = n.y ?? 0; + n.x = a.x + (x - a.x) * retention; + n.y = a.y + (y - a.y) * retention; } } } const out = new Map(); - graph.forEachNode((id, attrs) => out.set(id, { x: attrs.x, y: attrs.y })); + for (const n of simNodes) out.set(n.id, { x: n.x ?? 0, y: n.y ?? 0 }); return out; } /** * The merge-time settle: recompute the resting layout from the CURRENT * graph + the positions ledger, and replace the ledger. Called by the - * extraction pipeline after every merge (the sole writer). Synchronous — - * it must finish before the next /api/graph fetch reflects the merge. + * extraction pipeline after every merge (the sole writer) and by the + * positions-commit endpoint when the graph grew under a live-physics + * commit (client positions become the anchor set). Synchronous — it must + * finish before the next /api/graph fetch reflects the change. */ export function settleGraphLayout(graphStore: { readNodes(): GraphNode[];