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:
@@ -129,10 +129,16 @@ const PICKING_PAD = 10;
|
||||
/** Click vs drag threshold: pointer travel under this many px is a click. */
|
||||
const CLICK_SLOP = 4;
|
||||
|
||||
/** Cluster hull styling: a SOLID viewer-relative border only. The layered
|
||||
* low-alpha fills (the "glow") were removed on user request — they read
|
||||
* as jagged up close (straight-line hull approximation) and are better
|
||||
* revisited once the layout settles. */
|
||||
/** Cluster hull styling: layered low-alpha fills tint each region, plus
|
||||
* a SOLID viewer-relative border. (The fills were briefly removed on a
|
||||
* misreading of "no glow": they are the REGION BACKGROUNDS, not the
|
||||
* glow. The jagged-when-zoomed look is the straight-line hull
|
||||
* approximation, polishable later once the layout settles.) */
|
||||
const HULL_FILL_LAYERS = [
|
||||
[24, 0.025],
|
||||
[19, 0.04],
|
||||
[14, 0.06],
|
||||
] as const;
|
||||
const HULL_STROKE_PAD = 14;
|
||||
const HULL_CORNER_RADIUS = 9;
|
||||
const REGION_LABEL_SIZE = 14;
|
||||
@@ -356,6 +362,7 @@ export class GraphCanvasRenderer {
|
||||
private shaftsPath: Path2D = new Path2D();
|
||||
private headsPath: Path2D = new Path2D();
|
||||
private hullShapes: {
|
||||
fill: Path2D[];
|
||||
stroke: Path2D;
|
||||
label: string | null;
|
||||
cx: number; // world centroid
|
||||
@@ -704,7 +711,8 @@ export class GraphCanvasRenderer {
|
||||
this.headsPath = heads;
|
||||
|
||||
// Cluster hulls: convex hull of member positions (world = raw data
|
||||
// coordinates — no projection at all) and a SOLID boundary path.
|
||||
// coordinates, no projection at all), three offset fill layers,
|
||||
// and a SOLID boundary path.
|
||||
this.hullShapes = [];
|
||||
for (const cluster of data.clusters) {
|
||||
const pts: Pt[] = [];
|
||||
@@ -715,11 +723,16 @@ export class GraphCanvasRenderer {
|
||||
}
|
||||
const hull = convexHull(pts);
|
||||
if (hull.length < 3) continue;
|
||||
const fills = HULL_FILL_LAYERS.map(([pad]) => {
|
||||
const path = new Path2D();
|
||||
traceRoundedPath(path, offsetPolygon(hull, pad * s), HULL_CORNER_RADIUS * s);
|
||||
return path;
|
||||
});
|
||||
const stroke = new Path2D();
|
||||
traceRoundedPath(stroke, offsetPolygon(hull, HULL_STROKE_PAD * s), HULL_CORNER_RADIUS * s);
|
||||
const cx = pts.reduce((sum, p) => sum + p.x, 0) / pts.length;
|
||||
const cy = pts.reduce((sum, p) => sum + p.y, 0) / pts.length;
|
||||
this.hullShapes.push({ stroke, label: cluster.label || null, cx, cy, hue: clusterHue(cluster.id) });
|
||||
this.hullShapes.push({ fill: fills, stroke, label: cluster.label || null, cx, cy, hue: clusterHue(cluster.id) });
|
||||
}
|
||||
|
||||
// Label wrapping: measureText once per node per geometry build,
|
||||
@@ -764,9 +777,13 @@ export class GraphCanvasRenderer {
|
||||
const regionAlpha = 1 - nodeAlpha;
|
||||
const { nodeR, glowOuter } = this.world;
|
||||
|
||||
// 1. Cluster hulls: cached world paths — SOLID viewer-relative
|
||||
// hairline border only (no fill glow, removed for now).
|
||||
// 1-2. Cluster hulls: cached world paths — soft layered region
|
||||
// fills, then a SOLID viewer-relative hairline border.
|
||||
for (const hs of this.hullShapes) {
|
||||
for (let i = 0; i < hs.fill.length; i++) {
|
||||
ctx.fillStyle = `hsla(${hs.hue}, 65%, 60%, ${HULL_FILL_LAYERS[i]![1]})`;
|
||||
ctx.fill(hs.fill[i]!);
|
||||
}
|
||||
ctx.strokeStyle = `hsla(${hs.hue}, 65%, 62%, 0.5)`;
|
||||
ctx.lineWidth = 1.5 / k; // constant screen px
|
||||
ctx.stroke(hs.stroke);
|
||||
|
||||
@@ -327,12 +327,30 @@ export function GraphView() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Build the graphology graph and lay it out ONCE per snapshot: seeded
|
||||
// start positions, one synchronous forceAtlas2 pass, then freeze. The
|
||||
// renderer only ever receives the frozen positions as data — layout is
|
||||
// never recomputed for a settings tweak or a refetch of identical data.
|
||||
// Layout (Option A): the positions ledger (written by the server's
|
||||
// incremental settle after every merge) IS the layout — the renderer
|
||||
// receives it as frozen data. The local one-shot forceAtlas2 pass runs
|
||||
// ONLY as a fallback when the ledger is absent (a graph never merged
|
||||
// through the pipeline, e.g. tests or a fresh install).
|
||||
useEffect(() => {
|
||||
if (!snapshot || snapshot.nodes.length === 0) return;
|
||||
// The ledger path: every positioned node uses its resting position;
|
||||
// nodes the ledger doesn't know yet (raced mid-settle) fall through
|
||||
// to seeded scatter below.
|
||||
const ledger = snapshot.positions;
|
||||
if (ledger) {
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
for (const node of snapshot.nodes) {
|
||||
const p = ledger[node.id];
|
||||
if (p) positions.set(node.id, { x: p.x, y: p.y });
|
||||
}
|
||||
if (positions.size > 0) {
|
||||
posRef.current = positions;
|
||||
return () => {
|
||||
posRef.current = null;
|
||||
};
|
||||
}
|
||||
}
|
||||
const graph = new Graph();
|
||||
for (const node of snapshot.nodes) {
|
||||
// Seeded start positions (FA2 requires non-zero x/y) keep the
|
||||
|
||||
Reference in New Issue
Block a user