Option A: incremental anchored force layout, persisted server-side

The layout now EVOLVES instead of reshuffling on every extraction. A pure
core module (src/core/layout.ts) warm-starts forceAtlas2 from the positions
ledger (data/graph/positions.json, read-modify-write like sources.json,
updatedAt stamp for future live-physics conflict handling), re-blends every
previously-positioned node toward its anchor each iteration (retention 0.85
- old placements stay recognizable, strong forces still win), seeds new
nodes near their positioned neighbors, and seeds orphans on the rim outside
the core so they read as unconnected knowledge.

- Port/store: GraphStore.readPositions/writePositions; snapshot carries
  positions when the ledger exists (optional - fresh graphs unchanged).
- Extraction pipeline settles synchronously after every merge (chat and
  notes paths), before usage persist - the next /api/graph fetch already
  reflects new nodes.
- View: the ledger IS the layout; the local one-shot FA2 pass survives
  only as a fallback when no ledger exists.
- scripts/replay-layout.ts: simulates organic growth by replaying the
  current 338-node graph one imported file at a time (real import order,
  edges activate when both endpoints exist). Real run: 338 positions in
  0.3s, 90-degree-0 orphans collecting at mean r=338 vs connected r=194.
- Also restores the cluster region fills (hull background colors) that
  6692b19 had removed on a misreading of "no glow".

Live-verified: fit zoom derives from the ledger bbox, pickAt aligns with
ledger positions at every probe, hover/click/detail-card work, 408/408
tests, typecheck/lint/build green.

Future live physics stays cheap by design: the same pure function is the
merge-time settler and the future client simulator; a pinned node is just
an anchor with full retention.
This commit is contained in:
amosblomqvist
2026-09-13 08:35:42 +03:00
parent 11bca055dd
commit 0c14ea425d
12 changed files with 565 additions and 14 deletions
+15 -1
View File
@@ -513,7 +513,21 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
members: community.members,
}));
return { nodes, edges, sessions, fileSources, nodeSessions, nodeObservations, clusters };
// Resting layout (Option A): the positions ledger, when the settle has
// ever run. Absent on a fresh graph — the view falls back to its own
// one-shot pass until the first merge writes the ledger.
const positions = deps.graphStore.readPositions();
return {
nodes,
edges,
sessions,
fileSources,
nodeSessions,
nodeObservations,
clusters,
...(Object.keys(positions).length > 0 ? { positions } : {}),
};
}
// Re-export: the contract owns the shape; legacy importers still reach it.