fix(memory-graph): render large graphs in the SVG fallback without freezing (#3291)

Co-authored-by: Cyrus Gray <cyrus@tinyhumans.ai>
This commit is contained in:
sanil-23
2026-06-03 16:38:24 +05:30
committed by GitHub
co-authored by Cyrus Gray
parent 68d0da5311
commit ad7511d089
7 changed files with 1008 additions and 41 deletions
+216 -41
View File
@@ -30,6 +30,7 @@ import {
type PointerEvent as ReactPointerEvent,
type WheelEvent as ReactWheelEvent,
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
@@ -53,6 +54,8 @@ import {
} from './memoryGraphLayout';
import { summaryWorkspacePath } from './memoryWorkspacePaths';
import { PixiGraph } from './PixiGraph';
import { seedSvgLayout } from './seedSvgLayout';
import { useSvgForceLayout, WORKER_SUPPORTED } from './useSvgForceLayout';
/** Use WebGL (Pixi) in production; fall back to SVG in test (jsdom). */
const HAS_WEBGL =
@@ -74,6 +77,21 @@ interface SimNode extends GraphNode {
vy: number;
}
interface SimState {
sim: SimNode[];
edges: Array<[number, number]>;
radii: number[];
alpha: number;
}
// Stable empties so the worker-layout effect's deps don't change every render
// when there's no graph yet.
const NO_NODES: SimNode[] = [];
const NO_RADII: number[] = [];
const NO_EDGES: Array<[number, number]> = [];
// Stable centre the SVG worker layout settles around (matches the viewBox).
const SVG_CENTER: readonly [number, number] = [VIEWPORT_W / 2, VIEWPORT_H / 2];
interface MemoryGraphProps {
/** Pre-fetched summary / chunk / contact nodes. */
nodes: GraphNode[];
@@ -196,6 +214,18 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint }: MemoryGraphProps)
// True once the pointer moved during the current gesture — guards the
// node click so a drag doesn't also open the summary file.
const movedRef = useRef(false);
// Halts the SVG layout worker once the user grabs a node/background, so its
// streamed positions stop fighting the manual drag. Set after the hook below.
const stopLayoutRef = useRef<() => void>(() => {});
// Set once the user grabs the camera, so the settle-time auto-fit doesn't
// yank the view out from under them.
const userInteractedRef = useRef(false);
// Re-frame the SVG graph from "Reset view" (set after fitToView below).
const fitRef = useRef<() => void>(() => {});
// Holds the current sim across renders; during the next build it still points
// at the OUTGOING sim, whose nodes carry the latest live coordinates (the
// worker / a drag mutate them in place) — read for position carry-over.
const liveSimRef = useRef<SimState | null>(null);
const clientToGraph = useCallback(
(clientX: number, clientY: number) => {
@@ -236,6 +266,12 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint }: MemoryGraphProps)
(e: ReactPointerEvent) => {
const d = dragRef.current;
if (!d) return;
// On the first real movement (not a plain click), hand the camera to the
// user: freeze the worker layout and suppress the settle-time auto-fit.
if (!movedRef.current) {
stopLayoutRef.current();
userInteractedRef.current = true;
}
if (d.kind === 'node') {
const g = clientToGraph(e.clientX, e.clientY);
if (!g) return;
@@ -261,6 +297,7 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint }: MemoryGraphProps)
const onWheelZoom = useCallback((e: ReactWheelEvent) => {
const vb = clientToViewBox(svgRef.current, e.clientX, e.clientY);
if (!vb) return;
userInteractedRef.current = true;
setView(v => {
const factor = Math.exp(-e.deltaY * 0.0015);
const scale = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, v.scale * factor));
@@ -272,9 +309,10 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint }: MemoryGraphProps)
}, []);
const resetView = useCallback(() => {
// SVG fallback resets its transform; the Pixi canvas listens on the
// reset signal. Both are bumped so the button works in either path.
setView({ tx: 0, ty: 0, scale: 1 });
// SVG re-frames the whole graph; the Pixi canvas listens on the reset
// signal. Both are triggered so the button works in either path.
userInteractedRef.current = false;
fitRef.current();
bumpReset();
}, []);
@@ -309,45 +347,166 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint }: MemoryGraphProps)
}
}, []);
// Build edges and (for the SVG fallback) seed positions + relax. The
// O(n²) relax only runs when WebGL is unavailable; the Pixi path runs
// its own d3-force simulation instead.
const sim = useMemo(() => {
// Build edges + seed positions, carrying over the last positions of any
// surviving node so a live data update doesn't reshuffle the whole graph
// (seedSvgLayout). The O(n²) relax only runs as the no-worker fallback (test
// env); the worker settles otherwise; the Pixi path runs its own sim.
const sim = useMemo<SimState | null>(() => {
if (!nodes || nodes.length === 0) return null;
const idIndex = new Map<string, number>();
nodes.forEach((n, i) => idIndex.set(n.id, i));
const sim: SimNode[] = nodes.map((n, i) => {
const angle = (i / nodes.length) * Math.PI * 2;
const r = 200 + (i % 7) * 12;
return {
...n,
x: VIEWPORT_W / 2 + Math.cos(angle) * r,
y: VIEWPORT_H / 2 + Math.sin(angle) * r,
vx: 0,
vy: 0,
};
});
const edgeIndices: Array<[number, number]> = [];
if (mode === 'tree') {
// Tree mode: each summary's parent_id is the edge.
for (const n of nodes) {
if (!n.parent_id) continue;
const childIdx = idIndex.get(n.id);
const parentIdx = idIndex.get(n.parent_id);
if (childIdx == null || parentIdx == null) continue;
edgeIndices.push([childIdx, parentIdx]);
}
} else {
for (const e of edges) {
const a = idIndex.get(e.from);
const b = idIndex.get(e.to);
if (a == null || b == null) continue;
edgeIndices.push([a, b]);
// Snapshot the OUTGOING graph's live coordinates so survivors carry over
// from where they actually are now — not a stale init/settle snapshot —
// even mid-settle or after a drag.
const prev = liveSimRef.current;
const prevPos = new Map<string, { x: number; y: number }>();
if (prev) for (const n of prev.sim) prevPos.set(n.id, { x: n.x, y: n.y });
const seed = seedSvgLayout(nodes, edges, mode, prevPos);
const sim: SimNode[] = nodes.map((n, i) => ({
...n,
x: seed.positions[i].x,
y: seed.positions[i].y,
vx: 0,
vy: 0,
}));
const radii = sim.map(n => nodeRadius(n));
if (!useWebGL && !WORKER_SUPPORTED) relaxLayout(sim, seed.edges);
return { sim, edges: seed.edges, radii, alpha: seed.reheatAlpha };
}, [nodes, edges, mode, useWebGL]);
// Becomes the "previous" sim on the next build (above).
liveSimRef.current = sim;
// Element refs for imperative position updates: while the worker streams
// positions we write cx/cy (and line endpoints) straight to the DOM instead
// of re-rendering up to 10k elements through React every frame.
const circleEls = useRef<(SVGCircleElement | null)[]>([]);
const lineEls = useRef<(SVGLineElement | null)[]>([]);
// Progressive DOM mount for the SVG path: reveal nodes in per-frame batches
// so a large graph never blocks building thousands of elements in one commit.
// WebGL draws to one canvas, so it shows everything at once.
const FIRST_BATCH = 800;
const [svgVisible, setSvgVisible] = useState(() =>
sim ? Math.min(sim.sim.length, FIRST_BATCH) : 0
);
// Reset the reveal window + element refs during render when the graph data
// changes (the recommended alternative to setState-in-effect).
const simIdRef = useRef(sim);
if (simIdRef.current !== sim) {
simIdRef.current = sim;
circleEls.current = [];
lineEls.current = [];
setSvgVisible(sim ? Math.min(sim.sim.length, FIRST_BATCH) : 0);
}
// Latest visible count read by the stable imperative applier without
// re-subscribing the worker every render.
const latestVisibleRef = useRef(svgVisible);
latestVisibleRef.current = svgVisible;
// Write current positions straight to the mounted SVG elements.
const applyPositions = useCallback(() => {
const s = liveSimRef.current;
if (!s) return;
const vis = latestVisibleRef.current;
const ns = s.sim;
for (let i = 0; i < vis && i < ns.length; i++) {
const el = circleEls.current[i];
if (el) {
el.setAttribute('cx', String(ns[i].x));
el.setAttribute('cy', String(ns[i].y));
}
}
if (!useWebGL) relaxLayout(sim, edgeIndices);
return { sim, edges: edgeIndices };
}, [nodes, edges, mode, useWebGL]);
for (let e = 0; e < s.edges.length; e++) {
const [ai, bi] = s.edges[e];
if (ai >= vis || bi >= vis) continue;
const el = lineEls.current[e];
if (el) {
el.setAttribute('x1', String(ns[ai].x));
el.setAttribute('y1', String(ns[ai].y));
el.setAttribute('x2', String(ns[bi].x));
el.setAttribute('y2', String(ns[bi].y));
}
}
}, []);
// Frame the whole cloud in the viewport. d3-force spreads a large graph far
// past the viewBox, so without this most nodes sit off-screen. Committed to
// `view` state (single source) so pan/zoom keep working; called once the
// worker settles, unless the user already grabbed the camera.
const fitToView = useCallback(() => {
const s = liveSimRef.current;
if (!s || userInteractedRef.current) return;
const ns = s.sim;
if (ns.length === 0) return;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const n of ns) {
if (n.x < minX) minX = n.x;
if (n.y < minY) minY = n.y;
if (n.x > maxX) maxX = n.x;
if (n.y > maxY) maxY = n.y;
}
if (!Number.isFinite(minX)) return;
const pad = 48;
const w = Math.max(1, maxX - minX);
const h = Math.max(1, maxY - minY);
const scale = Math.min(
ZOOM_MAX,
Math.max(ZOOM_MIN, Math.min((VIEWPORT_W - pad) / w, (VIEWPORT_H - pad) / h))
);
setView({
scale,
tx: VIEWPORT_W / 2 - ((minX + maxX) / 2) * scale,
ty: VIEWPORT_H / 2 - ((minY + maxY) / 2) * scale,
});
}, []);
fitRef.current = fitToView;
// Coalesce worker ticks to one DOM write per frame.
const applyPendingRef = useRef(false);
const scheduleApply = useCallback(() => {
if (applyPendingRef.current) return;
applyPendingRef.current = true;
const run = () => {
applyPendingRef.current = false;
applyPositions();
};
if (typeof window.requestAnimationFrame === 'function') window.requestAnimationFrame(run);
else run();
}, [applyPositions]);
// SVG fallback layout runs in a worker (off the main thread); positions
// stream back and are applied imperatively. No-op on WebGL and where workers
// are unavailable (the synchronous relaxLayout above covers that case).
const svgLayout = useSvgForceLayout(
!useWebGL && !!sim,
sim?.sim ?? NO_NODES,
sim?.radii ?? NO_RADII,
sim?.edges ?? NO_EDGES,
SVG_CENTER,
sim?.alpha ?? 1,
scheduleApply,
fitToView
);
stopLayoutRef.current = svgLayout.stop;
// Ramp the rest in per-frame batches (setState only inside the rAF callback).
useEffect(() => {
if (useWebGL || !sim) return;
const total = sim.sim.length;
if (total <= FIRST_BATCH || typeof window.requestAnimationFrame !== 'function') return;
let raf = 0;
const step = () => {
setSvgVisible(c => {
const next = Math.min(total, c + 1200);
if (next < total) raf = window.requestAnimationFrame(step);
return next;
});
};
raf = window.requestAnimationFrame(step);
return () => window.cancelAnimationFrame(raf);
}, [useWebGL, sim]);
if (nodes.length === 0) {
return (
@@ -454,13 +613,26 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint }: MemoryGraphProps)
<g transform={`translate(${view.tx} ${view.ty}) scale(${view.scale})`}>
<g stroke="#cbd5e1" strokeWidth={0.6} opacity={0.7}>
{sim.edges.map(([ai, bi], idx) => {
// Only draw edges whose endpoints are both mounted yet.
if (ai >= svgVisible || bi >= svgVisible) return null;
const a = sim.sim[ai];
const b = sim.sim[bi];
return <line key={idx} x1={a.x} y1={a.y} x2={b.x} y2={b.y} />;
return (
<line
key={idx}
ref={el => {
lineEls.current[idx] = el;
}}
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
/>
);
})}
</g>
<g>
{sim.sim.map(n => {
{sim.sim.slice(0, svgVisible).map((n, i) => {
const r = nodeRadius(n);
const fill = nodeColor(n);
const isHover = hovered?.id === n.id;
@@ -471,6 +643,9 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint }: MemoryGraphProps)
return (
<circle
key={n.id}
ref={el => {
circleEls.current[i] = el;
}}
cx={n.x}
cy={n.y}
r={isHover ? r + 2 : r}
@@ -0,0 +1,172 @@
/**
* Exercises the worker-backed SVG path of MemoryGraph, which can't run under
* the default test env (no real Worker). A mocked Worker lets us drive the
* streamed-position apply, settle-time fit, drag-freeze, and progressive mount.
*
* `WORKER_SUPPORTED` is captured at module load, so Worker must be stubbed
* before MemoryGraph is imported — hence the dynamic import in beforeAll.
*/
import { act, fireEvent, render } from '@testing-library/react';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GraphNode } from '../../utils/tauriCommands';
vi.mock('../../utils/openUrl', () => ({ openUrl: vi.fn() }));
vi.mock('../../utils/tauriCommands/workspacePaths', () => ({
openWorkspacePath: vi.fn(),
previewWorkspaceText: vi.fn(),
}));
class MockWorker {
static instances: MockWorker[] = [];
onmessage: ((e: { data: unknown }) => void) | null = null;
posted: Array<Record<string, unknown>> = [];
terminated = false;
constructor() {
MockWorker.instances.push(this);
}
postMessage(m: Record<string, unknown>) {
this.posted.push(m);
}
terminate() {
this.terminated = true;
}
emit(data: unknown) {
this.onmessage?.({ data });
}
}
const last = () => MockWorker.instances[MockWorker.instances.length - 1];
let MemoryGraph: typeof import('./MemoryGraph').MemoryGraph;
beforeAll(async () => {
vi.stubGlobal('Worker', MockWorker as unknown as typeof Worker);
// Run rAF synchronously so imperative position writes + the mount ramp are
// deterministic within a test tick.
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 1;
});
vi.stubGlobal('cancelAnimationFrame', () => {});
({ MemoryGraph } = await import('./MemoryGraph'));
});
function treeNodes(leaves: number): GraphNode[] {
const nodes: GraphNode[] = [{ kind: 'source', id: 's', label: 's', parent_id: null }];
for (let i = 0; i < leaves; i++) {
nodes.push({ kind: 'chunk', id: `c${i}`, label: `c${i}`, parent_id: 's' });
}
return nodes;
}
/** Give the SVG a usable CTM so pan/drag math doesn't no-op under jsdom. */
function withCTM(svg: Element) {
(svg as unknown as { getScreenCTM: () => unknown }).getScreenCTM = () => ({
inverse: () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
});
}
describe('<MemoryGraph /> worker-backed SVG path', () => {
beforeEach(() => {
MockWorker.instances = [];
});
it('spins up a worker and applies streamed positions imperatively', () => {
const { container } = render(<MemoryGraph nodes={treeNodes(3)} edges={[]} mode="tree" />);
const w = last();
expect(w).toBeTruthy();
expect(w.posted[0].type).toBe('init');
expect(w.posted[0].alpha).toBe(1); // fresh graph → full reheat
act(() =>
w.emit({
type: 'tick',
positions: new Float32Array([11, 22, 33, 44, 55, 66, 77, 88]),
alpha: 0.5,
})
);
const circles = container.querySelectorAll('circle');
expect(circles.length).toBeGreaterThan(0);
expect(circles[0].getAttribute('cx')).toBe('11');
expect(circles[0].getAttribute('cy')).toBe('22');
});
it('frames the graph on settle (end → fit transform)', () => {
const { container } = render(<MemoryGraph nodes={treeNodes(3)} edges={[]} mode="tree" />);
const w = last();
act(() =>
w.emit({
type: 'tick',
positions: new Float32Array([0, 0, 200, 0, 0, 200, 200, 200]),
alpha: 0.1,
})
);
act(() => w.emit({ type: 'end' }));
const g = container.querySelector('[data-testid="memory-graph-svg"] g');
expect(g?.getAttribute('transform')).toMatch(/translate\(.*\) scale\(/);
});
it('freezes the worker on a drag, but NOT on a plain click', () => {
const { container } = render(<MemoryGraph nodes={treeNodes(3)} edges={[]} mode="tree" />);
const svg = container.querySelector('[data-testid="memory-graph-svg"]') as Element;
withCTM(svg);
const w = last();
// Plain click (down + up, no move) must not stop layout.
fireEvent.pointerDown(svg, { clientX: 10, clientY: 10 });
fireEvent.pointerUp(svg, { clientX: 10, clientY: 10 });
expect(w.terminated).toBe(false);
// Drag (down + move) hands the camera over → stop().
fireEvent.pointerDown(svg, { clientX: 10, clientY: 10 });
fireEvent.pointerMove(svg, { clientX: 80, clientY: 80 });
expect(w.terminated).toBe(true);
});
it('reheats gently and carries positions over on an incremental update', () => {
const { rerender } = render(<MemoryGraph nodes={treeNodes(3)} edges={[]} mode="tree" />);
const first = last();
act(() =>
first.emit({
type: 'tick',
positions: new Float32Array([1, 1, 2, 2, 3, 3, 4, 4]),
alpha: 0.5,
})
);
// Add a node → same instance, new props → new worker session.
rerender(<MemoryGraph nodes={treeNodes(4)} edges={[]} mode="tree" />);
const second = last();
expect(second).not.toBe(first);
expect(second.posted[0].type).toBe('init');
expect(second.posted[0].alpha).toBe(0.3); // survivors present → gentle reheat
});
it('mounts a large graph progressively without throwing', () => {
expect(() =>
render(<MemoryGraph nodes={treeNodes(900)} edges={[]} mode="tree" />)
).not.toThrow();
});
it('handles node drag, wheel zoom, and reset view', () => {
const { container, getByTestId } = render(
<MemoryGraph nodes={treeNodes(3)} edges={[]} mode="tree" />
);
const svg = container.querySelector('[data-testid="memory-graph-svg"]') as Element;
withCTM(svg);
const w = last();
// Drag a node → freezes layout and repositions the node.
const node = container.querySelector('[data-testid="memory-graph-node-c0"]') as Element;
fireEvent.pointerDown(node, { clientX: 20, clientY: 20 });
fireEvent.pointerMove(svg, { clientX: 90, clientY: 90 });
fireEvent.pointerUp(svg, { clientX: 90, clientY: 90 });
expect(w.terminated).toBe(true);
// Wheel zoom is a real interaction (no throw).
expect(() => fireEvent.wheel(svg, { deltaY: -120, clientX: 50, clientY: 50 })).not.toThrow();
// Reset view re-frames.
expect(() => fireEvent.click(getByTestId('memory-graph-reset-view'))).not.toThrow();
});
});
@@ -0,0 +1,172 @@
import { describe, expect, it } from 'vitest';
import { type GraphEdge, type GraphNode } from '../../utils/tauriCommands';
import { seedSvgLayout } from './seedSvgLayout';
const src = (id: string): GraphNode => ({ kind: 'source', id, label: id, parent_id: null });
const sum = (id: string, parent: string): GraphNode => ({
kind: 'summary',
id,
label: id,
level: 1,
parent_id: parent,
});
const chunk = (id: string, parent: string): GraphNode => ({
kind: 'chunk',
id,
label: id,
parent_id: parent,
});
const contact = (id: string): GraphNode => ({
kind: 'contact',
id,
label: id,
entity_kind: 'person',
});
const NO_PREV = new Map<string, { x: number; y: number }>();
const finite = (p: { x: number; y: number }) => Number.isFinite(p.x) && Number.isFinite(p.y);
describe('seedSvgLayout — first load (no previous positions)', () => {
it('marks every node new, reheats fully, and produces finite positions', () => {
const nodes = [src('s'), sum('a', 's'), chunk('c1', 'a'), chunk('c2', 'a')];
const r = seedSvgLayout(nodes, [], 'tree', NO_PREV);
expect(r.positions).toHaveLength(4);
expect(r.positions.every(p => p.isNew)).toBe(true);
expect(r.newCount).toBe(4);
expect(r.reheatAlpha).toBe(1);
expect(r.positions.every(finite)).toBe(true);
});
it('handles an empty graph', () => {
const r = seedSvgLayout([], [], 'tree', NO_PREV);
expect(r.positions).toEqual([]);
expect(r.edges).toEqual([]);
expect(r.newCount).toBe(0);
expect(r.reheatAlpha).toBe(1);
});
});
describe('seedSvgLayout — carry-over', () => {
it('preserves the exact previous position of surviving nodes', () => {
const nodes = [src('s'), sum('a', 's')];
const prev = new Map([
['s', { x: 100, y: 120 }],
['a', { x: 300, y: 340 }],
]);
const r = seedSvgLayout(nodes, [], 'tree', prev);
expect(r.positions[0]).toEqual({ x: 100, y: 120, isNew: false });
expect(r.positions[1]).toEqual({ x: 300, y: 340, isNew: false });
expect(r.newCount).toBe(0);
expect(r.reheatAlpha).toBe(0.3); // all survived → gentle reheat
});
it('drops removed nodes — result length tracks the current node set', () => {
const nodes = [src('s')];
const prev = new Map([
['s', { x: 10, y: 10 }],
['gone-1', { x: 1, y: 1 }],
['gone-2', { x: 2, y: 2 }],
]);
const r = seedSvgLayout(nodes, [], 'tree', prev);
expect(r.positions).toHaveLength(1);
expect(r.positions[0]).toEqual({ x: 10, y: 10, isNew: false });
});
});
describe('seedSvgLayout — new nodes', () => {
it('seeds a new child near its parents previous position', () => {
const nodes = [sum('a', 's'), chunk('c', 'a')];
const prev = new Map([['a', { x: 500, y: 250 }]]); // parent survived, child is new
const r = seedSvgLayout(nodes, [], 'tree', prev);
const childPos = r.positions[1];
expect(childPos.isNew).toBe(true);
const dist = Math.hypot(childPos.x - 500, childPos.y - 250);
expect(dist).toBeGreaterThan(0);
expect(dist).toBeLessThanOrEqual(25); // within the small offset of the parent
});
it('falls back to the ring when a new nodes parent is also new (not in prev)', () => {
const nodes = [sum('a', 's'), chunk('c', 'a')]; // neither in prev
const r = seedSvgLayout(nodes, [], 'tree', NO_PREV);
expect(r.positions[1].isNew).toBe(true);
expect(finite(r.positions[1])).toBe(true);
// not clustered at the parent (parent has no known position)
expect(r.positions[1]).not.toEqual(r.positions[0]);
});
it('falls back to the ring for a parent-less new node', () => {
const nodes = [src('s')];
const r = seedSvgLayout(nodes, [], 'tree', NO_PREV);
expect(r.positions[0].isNew).toBe(true);
expect(finite(r.positions[0])).toBe(true);
});
it('reheats fully when the graph is entirely replaced (no survivors)', () => {
const nodes = [src('x'), sum('y', 'x')];
const prev = new Map([['old', { x: 1, y: 2 }]]); // stale ids, none match
const r = seedSvgLayout(nodes, [], 'tree', prev);
expect(r.newCount).toBe(2);
expect(r.reheatAlpha).toBe(1);
});
it('reheats gently when at least one node survives a mixed update', () => {
const nodes = [sum('a', 's'), chunk('c1', 'a'), chunk('c2', 'a')];
const prev = new Map([['a', { x: 400, y: 300 }]]); // a survives, c1/c2 new
const r = seedSvgLayout(nodes, [], 'tree', prev);
expect(r.newCount).toBe(2);
expect(r.positions[0].isNew).toBe(false);
expect(r.reheatAlpha).toBe(0.3);
});
});
describe('seedSvgLayout — edges', () => {
it('derives tree edges from parent_id', () => {
const nodes = [src('s'), sum('a', 's'), chunk('c', 'a')];
const r = seedSvgLayout(nodes, [], 'tree', NO_PREV);
// [child, parent] index pairs: a→s (1,0), c→a (2,1)
expect(r.edges).toEqual([
[1, 0],
[2, 1],
]);
});
it('skips tree edges whose parent_id is missing from the node set', () => {
const nodes = [sum('a', 'ghost-parent'), chunk('c', 'a')];
const r = seedSvgLayout(nodes, [], 'tree', NO_PREV);
expect(r.edges).toEqual([[1, 0]]); // a→ghost dropped; c→a kept
expect(r.edges.every(([x, y]) => x < nodes.length && y < nodes.length)).toBe(true);
});
it('uses explicit edges in contacts mode and skips dangling endpoints', () => {
const nodes = [chunk('c', 'whatever'), contact('p')];
const edges: GraphEdge[] = [
{ from: 'c', to: 'p' },
{ from: 'c', to: 'missing' }, // dangling → skipped
];
const r = seedSvgLayout(nodes, edges, 'contacts', NO_PREV);
expect(r.edges).toEqual([[0, 1]]);
expect(r.edges.every(([x, y]) => x < nodes.length && y < nodes.length)).toBe(true);
});
it('seeds new contact-mode nodes on the ring (no parent_id seeding)', () => {
const nodes = [chunk('c', 'x'), contact('p')];
const r = seedSvgLayout(nodes, [{ from: 'c', to: 'p' }], 'contacts', NO_PREV);
expect(r.positions.every(p => p.isNew && finite(p))).toBe(true);
});
});
describe('seedSvgLayout — invariants', () => {
it('is deterministic for identical inputs', () => {
const nodes = [src('s'), sum('a', 's'), chunk('c1', 'a'), chunk('c2', 'a')];
const a = seedSvgLayout(nodes, [], 'tree', NO_PREV);
const b = seedSvgLayout(nodes, [], 'tree', NO_PREV);
expect(a).toEqual(b);
});
it('keeps positions index-aligned with the node array', () => {
const nodes = [src('s'), sum('a', 's'), chunk('c', 'a')];
const r = seedSvgLayout(nodes, [], 'tree', new Map([['a', { x: 9, y: 9 }]]));
expect(r.positions).toHaveLength(nodes.length);
});
});
@@ -0,0 +1,103 @@
/**
* Position seeding for the SVG force layout, with incremental carry-over.
*
* When the graph data changes (a background sync completes, a rebuild, a mode
* toggle) the component re-derives its node array. Re-seeding every node from
* scratch makes the whole graph reshuffle and re-settle — jarring on a live
* update. Instead this:
* - keeps each surviving node (same `id`) at its previous position,
* - seeds a genuinely-new node near its parent's previous position (tree
* mode) so it eases in next to where it belongs, falling back to a
* deterministic ring around the viewport centre,
* - reports a `reheatAlpha`: a gentle 0.3 when anything carried over (so the
* sim nudges rather than explodes), or a full 1 for a first / fully-new
* graph.
*
* Pure and deterministic (no RNG / clock) so it is straightforward to test.
*/
import { type GraphEdge, type GraphMode, type GraphNode } from '../../utils/tauriCommands';
import { VIEWPORT_H, VIEWPORT_W } from './memoryGraphLayout';
export interface SeededPosition {
x: number;
y: number;
/** True when the node had no carried-over position (newly arrived). */
isNew: boolean;
}
export interface SeedResult {
/** Index-aligned with the input `nodes`. */
positions: SeededPosition[];
/** Edge index pairs [childIdx, parentIdx] (tree) or [fromIdx, toIdx] (contacts). */
edges: Array<[number, number]>;
/** How many nodes had no previous position. */
newCount: number;
/** Initial simulation alpha: gentle (0.3) on an incremental update, full (1) otherwise. */
reheatAlpha: number;
}
const CX = VIEWPORT_W / 2;
const CY = VIEWPORT_H / 2;
const NEW_NODE_OFFSET = 24;
/** Deterministic ring position around the viewport centre for index `i`. */
function ringPosition(i: number, total: number): { x: number; y: number } {
const angle = (i / Math.max(1, total)) * Math.PI * 2;
const r = 200 + (i % 7) * 12;
return { x: CX + Math.cos(angle) * r, y: CY + Math.sin(angle) * r };
}
export function seedSvgLayout(
nodes: GraphNode[],
edges: GraphEdge[],
mode: GraphMode,
prev: ReadonlyMap<string, { x: number; y: number }>
): SeedResult {
const idIndex = new Map<string, number>();
nodes.forEach((n, i) => idIndex.set(n.id, i));
const positions: SeededPosition[] = nodes.map((n, i) => {
const carried = prev.get(n.id);
if (carried) return { x: carried.x, y: carried.y, isNew: false };
// New node — seed near its parent's previous position if we know it.
if (mode === 'tree' && n.parent_id) {
const parent = prev.get(n.parent_id);
if (parent) {
const a = (i % 12) * (Math.PI / 6);
return {
x: parent.x + Math.cos(a) * NEW_NODE_OFFSET,
y: parent.y + Math.sin(a) * NEW_NODE_OFFSET,
isNew: true,
};
}
}
const ring = ringPosition(i, nodes.length);
return { x: ring.x, y: ring.y, isNew: true };
});
const edgeIndices: Array<[number, number]> = [];
if (mode === 'tree') {
for (const n of nodes) {
if (!n.parent_id) continue;
const child = idIndex.get(n.id);
const parent = idIndex.get(n.parent_id);
if (child == null || parent == null) continue;
edgeIndices.push([child, parent]);
}
} else {
for (const e of edges) {
const a = idIndex.get(e.from);
const b = idIndex.get(e.to);
if (a == null || b == null) continue;
edgeIndices.push([a, b]);
}
}
const newCount = positions.reduce((acc, p) => acc + (p.isNew ? 1 : 0), 0);
// Gentle reheat only when at least one node survived (an incremental update);
// a first load or a fully-replaced graph gets a full settle.
const reheatAlpha = nodes.length > 0 && newCount < nodes.length ? 0.3 : 1;
return { positions, edges: edgeIndices, newCount, reheatAlpha };
}
@@ -0,0 +1,90 @@
/**
* Drives the worker module directly with a stubbed `self`, since a real
* Worker context can't run under the test env. Verifies it streams tick frames,
* cools to an end, pins on drag, and halts on stop.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
interface FakeSelf {
onmessage: ((e: { data: unknown }) => void) | null;
postMessage: ReturnType<typeof vi.fn>;
}
let workerSelf: FakeSelf;
async function loadWorker() {
workerSelf = { onmessage: null, postMessage: vi.fn() };
vi.stubGlobal('self', workerSelf);
vi.resetModules();
await import('./svgForceLayout.worker');
}
function send(msg: unknown) {
workerSelf.onmessage?.({ data: msg });
}
function initMsg(n: number) {
return {
type: 'init',
nodes: Array.from({ length: n }, (_, i) => ({ x: i * 10, y: i * 10, r: 6 })),
links: n > 1 ? [{ source: 1, target: 0 }] : [],
cx: 550,
cy: 320,
alpha: 1,
};
}
const ticks = () => workerSelf.postMessage.mock.calls.filter(c => c[0]?.type === 'tick');
const ended = () => workerSelf.postMessage.mock.calls.some(c => c[0]?.type === 'end');
describe('svgForceLayout.worker', () => {
beforeEach(async () => {
vi.useFakeTimers();
await loadWorker();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('streams a tick frame with a transferable positions buffer on init', () => {
send(initMsg(3));
const tick = ticks()[0];
expect(tick).toBeTruthy();
expect(tick[0].positions).toBeInstanceOf(Float32Array);
expect(tick[0].positions.length).toBe(6); // 3 nodes × (x,y)
expect(Array.isArray(tick[1])).toBe(true); // transfer list
});
it('cools to an end message and then stops ticking', () => {
send(initMsg(2));
vi.advanceTimersByTime(20000); // run the tick loop down to alphaMin
expect(ended()).toBe(true);
const afterEnd = workerSelf.postMessage.mock.calls.length;
vi.advanceTimersByTime(5000);
expect(workerSelf.postMessage.mock.calls.length).toBe(afterEnd); // no more ticks
});
it('halts the loop on stop (no further frames)', () => {
send(initMsg(2));
send({ type: 'stop' });
const before = workerSelf.postMessage.mock.calls.length;
vi.advanceTimersByTime(5000);
expect(workerSelf.postMessage.mock.calls.length).toBe(before);
});
it('pins/unpins a node on drag and resumes ticking after it settles', () => {
send(initMsg(2));
vi.advanceTimersByTime(20000); // settle (loop idle)
const before = workerSelf.postMessage.mock.calls.length;
send({ type: 'drag', index: 0, x: 100, y: 100, fixed: true });
vi.advanceTimersByTime(64); // a few frames
expect(workerSelf.postMessage.mock.calls.length).toBeGreaterThan(before);
send({ type: 'drag', index: 0, x: 0, y: 0, fixed: false }); // unpin — no throw
});
it('ignores a drag for an out-of-range index without throwing', () => {
send(initMsg(2));
expect(() => send({ type: 'drag', index: 99, x: 0, y: 0, fixed: true })).not.toThrow();
});
});
@@ -0,0 +1,146 @@
/**
* Off-main-thread force layout for the SVG fallback renderer.
*
* The SVG path used to settle its graph with a synchronous O(n²) all-pairs
* relaxation (`relaxLayout` in MemoryGraph.tsx) that ran 220 iterations in one
* blocking call — fine at a few hundred nodes, a multi-second freeze at ~1k,
* and a renderer-killing hang at ~10k. This worker moves that work off the UI
* thread: it runs the same d3-force simulation the WebGL path uses (BarnesHut
* charge, link springs, centring, collision) and streams node positions back
* as a transferable Float32Array each tick. The main thread only applies
* positions and repaints, so it never blocks regardless of graph size.
*
* Protocol (main → worker):
* { type: 'init', nodes: {x,y,r}[], links: {source,target}[] } // source/target = node indices
* { type: 'drag', index, x, y, fixed } // pin/unpin a node during drag
* { type: 'stop' } // halt + tear down the sim
* Worker → main:
* { type: 'tick', positions: Float32Array([x0,y0,x1,y1,…]), alpha }
* { type: 'end' } // sim cooled below alphaMin
*/
import {
forceCenter,
forceCollide,
forceLink,
forceManyBody,
forceSimulation,
type Simulation,
type SimulationNodeDatum,
} from 'd3-force';
interface WNode extends SimulationNodeDatum {
r: number;
}
interface WLink {
source: number;
target: number;
}
// Local worker-global type so we don't pull the `webworker` lib (which would
// clash with the DOM lib across the whole tsc program). `self` is the worker's
// global scope at runtime.
const ctx = self as unknown as {
onmessage: ((e: MessageEvent) => void) | null;
postMessage: (message: unknown, transfer?: Transferable[]) => void;
};
let sim: Simulation<WNode, WLink> | null = null;
let nodes: WNode[] = [];
let timer: ReturnType<typeof setTimeout> | 0 = 0;
// d3 charge/link/collide params mirror createSimulation() in
// memoryGraphLayout.ts so the worker layout matches the WebGL path. `cx`/`cy`
// centre the cloud on the SVG viewport (the WebGL path recentres its own
// camera, but the SVG path renders in fixed viewBox coordinates).
function build(
initNodes: { x: number; y: number; r: number }[],
links: WLink[],
cx: number,
cy: number,
alpha: number
) {
nodes = initNodes.map(n => ({ x: n.x, y: n.y, r: n.r }));
sim = forceSimulation<WNode, WLink>(nodes)
.force('charge', forceManyBody<WNode>().strength(-140).distanceMax(420))
.force(
'link',
forceLink<WNode, WLink>(links)
.id((_d, i) => i)
.distance(58)
.strength(0.35)
)
.force('center', forceCenter(cx, cy).strength(0.04))
.force(
'collide',
forceCollide<WNode>().radius(d => d.r + 2)
)
.stop();
// 1 on a fresh graph (full settle); ~0.3 on an incremental update so carried
// positions barely move and only new nodes ease into place.
sim.alpha(alpha > 0 ? alpha : 1);
}
function postPositions() {
const buf = new Float32Array(nodes.length * 2);
for (let i = 0; i < nodes.length; i++) {
buf[i * 2] = nodes[i].x ?? 0;
buf[i * 2 + 1] = nodes[i].y ?? 0;
}
ctx.postMessage({ type: 'tick', positions: buf, alpha: sim?.alpha() ?? 0 }, [buf.buffer]);
}
function loop() {
if (!sim) return;
sim.tick(); // one integration step per frame
postPositions();
if (sim.alpha() > sim.alphaMin()) {
timer = setTimeout(loop, 16); // ~60fps; workers have no requestAnimationFrame
} else {
timer = 0;
ctx.postMessage({ type: 'end' });
}
}
function stop() {
if (timer) {
clearTimeout(timer);
timer = 0;
}
sim?.stop();
sim = null;
}
ctx.onmessage = (e: MessageEvent) => {
const msg = e.data as
| {
type: 'init';
nodes: { x: number; y: number; r: number }[];
links: WLink[];
cx: number;
cy: number;
alpha: number;
}
| { type: 'drag'; index: number; x: number; y: number; fixed: boolean }
| { type: 'stop' };
if (msg.type === 'init') {
stop();
build(msg.nodes, msg.links, msg.cx, msg.cy, msg.alpha);
loop();
} else if (msg.type === 'drag') {
const n = nodes[msg.index];
if (n) {
if (msg.fixed) {
n.fx = msg.x;
n.fy = msg.y;
} else {
n.fx = null;
n.fy = null;
}
}
if (sim) sim.alpha(Math.max(sim.alpha(), 0.2));
if (!timer) loop();
} else if (msg.type === 'stop') {
stop();
}
};
@@ -0,0 +1,109 @@
/**
* React hook that drives the SVG fallback's layout from a Web Worker.
*
* When enabled, it spins up {@link svgForceLayout.worker} with the seeded node
* positions + edges, applies each streamed position frame back onto the same
* `nodes` array (mutated in place, by index), and calls `onTick` so the SVG
* repaints — the graph visibly settles instead of freezing. The worker is torn
* down on data change / unmount / `stop()`.
*
* If `Worker` is unavailable (jsdom under test, or a runtime without workers),
* the hook is a no-op and the caller's synchronous `relaxLayout` fallback runs
* instead — so behaviour and tests are unchanged off the worker path.
*/
import { useCallback, useEffect, useRef } from 'react';
export interface SvgLayoutNode {
x: number;
y: number;
}
interface WorkerTickMessage {
type: 'tick';
positions: Float32Array;
alpha: number;
}
export const WORKER_SUPPORTED = typeof Worker !== 'undefined';
/**
* @param enabled only true on the SVG path with worker support
* @param nodes the live SimNode array (mutated in place with streamed x/y)
* @param radii per-node collision radius, index-aligned with `nodes`
* @param edges index pairs [childIdx, parentIdx]
* @param center [cx, cy] viewport centre the layout settles around
* @param alpha initial sim energy: 1 fresh graph, ~0.3 incremental update
* @param onTick stable callback to trigger a repaint (e.g. an imperative apply)
* @param onSettled stable callback fired once the sim cools (good time to fit)
*/
export function useSvgForceLayout(
enabled: boolean,
nodes: SvgLayoutNode[],
radii: number[],
edges: Array<[number, number]>,
center: readonly [number, number],
alpha: number,
onTick: () => void,
onSettled: () => void
): { drag: (index: number, x: number, y: number, fixed: boolean) => void; stop: () => void } {
const workerRef = useRef<Worker | null>(null);
// Session-alive guard, shared by the message handler, the effect cleanup, and
// stop(); flipping it false makes any in-flight tick/end a no-op.
const aliveRef = useRef(false);
useEffect(() => {
if (!enabled || !WORKER_SUPPORTED || nodes.length === 0) return;
const worker = new Worker(new URL('./svgForceLayout.worker.ts', import.meta.url), {
type: 'module',
});
workerRef.current = worker;
aliveRef.current = true;
worker.onmessage = (e: MessageEvent) => {
// aliveRef is flipped false by the cleanup AND by stop(), so a late
// tick/end from a just-stopped worker can't mutate nodes or fit.
if (!aliveRef.current) return;
const msg = e.data as WorkerTickMessage | { type: 'end' };
if (msg.type === 'tick') {
const pos = msg.positions;
const n = Math.min(nodes.length, pos.length >> 1);
for (let i = 0; i < n; i++) {
nodes[i].x = pos[i * 2];
nodes[i].y = pos[i * 2 + 1];
}
onTick();
} else if (msg.type === 'end') {
onSettled();
}
};
worker.postMessage({
type: 'init',
nodes: nodes.map((node, i) => ({ x: node.x, y: node.y, r: radii[i] ?? 6 })),
links: edges.map(([a, b]) => ({ source: a, target: b })),
cx: center[0],
cy: center[1],
alpha,
});
return () => {
aliveRef.current = false;
worker.postMessage({ type: 'stop' });
worker.terminate();
workerRef.current = null;
};
}, [enabled, nodes, radii, edges, center, alpha, onTick, onSettled]);
const drag = useCallback((index: number, x: number, y: number, fixed: boolean) => {
workerRef.current?.postMessage({ type: 'drag', index, x, y, fixed });
}, []);
const stop = useCallback(() => {
aliveRef.current = false; // ignore any in-flight tick/end from this worker
workerRef.current?.postMessage({ type: 'stop' });
workerRef.current?.terminate();
workerRef.current = null;
}, []);
return { drag, stop };
}