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:
+47
-1
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user