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
+47 -1
View File
@@ -29,7 +29,7 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { LEDGER_FILES, SOURCES_FILE } from "../../config.ts";
import { LEDGER_FILES, POSITIONS_FILE, SOURCES_FILE } from "../../config.ts";
import type {
ClusterLabeling,
GraphEdge,
@@ -39,6 +39,7 @@ import type {
LedgerEventKind,
NotesSource,
NodeMapping,
Position,
ProvenancePoint,
SessionCheckpoint,
} from "../core/contracts.ts";
@@ -207,8 +208,53 @@ export const graphStore: GraphStore = {
: [...(source.extractions ?? []), entry];
writeSourceMap(all);
},
// ── the layout ledger (config.POSITIONS_FILE — a JSON map) ──
readPositions(): Record<string, Position> {
return readPositionMap();
},
writePositions(positions: Record<string, Position>): void {
writePositionMap(positions);
},
};
/** positions.json — { positions, updatedAt }. Read-modify-write, NOT
* append-only: the settle is a full-graph computation, so it replaces the
* whole map. The updatedAt stamp exists for future live-physics conflict
* handling (a browser commit must be able to detect "the graph changed
* underneath me"); reads just return the map. */
function readPositionMap(): Record<string, Position> {
let raw: string;
try {
raw = fs.readFileSync(POSITIONS_FILE, "utf-8");
} catch {
return {}; // no ledger yet — nothing has ever settled
}
try {
const parsed: unknown = JSON.parse(raw);
if (!isRecord(parsed) || !isRecord(parsed.positions)) return {};
const out: Record<string, Position> = {};
for (const [id, value] of Object.entries(parsed.positions)) {
if (isPosition(value)) out[id] = { x: value.x, y: value.y };
}
return out;
} catch {
return {};
}
}
function writePositionMap(positions: Record<string, Position>): void {
fs.mkdirSync(path.dirname(POSITIONS_FILE), { recursive: true });
fs.writeFileSync(
POSITIONS_FILE,
JSON.stringify({ positions, updatedAt: new Date().toISOString() }, null, "\t"),
"utf-8",
);
}
const isPosition = (v: unknown): v is Position =>
isRecord(v) && typeof v.x === "number" && Number.isFinite(v.x) && typeof v.y === "number" && Number.isFinite(v.y);
/** sources.json — { id: NotesSource }. Read-modify-write, NOT append-only:
* the usage log lives inside the record (like a chat's JSON file). */
function readSourceMap(): Record<string, NotesSource> {