diff --git a/app/src/components/intelligence/MemoryGraph.tsx b/app/src/components/intelligence/MemoryGraph.tsx index 46e7406d7..2db7028b7 100644 --- a/app/src/components/intelligence/MemoryGraph.tsx +++ b/app/src/components/intelligence/MemoryGraph.tsx @@ -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(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(() => { if (!nodes || nodes.length === 0) return null; - const idIndex = new Map(); - 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(); + 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) {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 ; + return ( + { + lineEls.current[idx] = el; + }} + x1={a.x} + y1={a.y} + x2={b.x} + y2={b.y} + /> + ); })} - {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 ( { + circleEls.current[i] = el; + }} cx={n.x} cy={n.y} r={isHover ? r + 2 : r} diff --git a/app/src/components/intelligence/MemoryGraph.worker.test.tsx b/app/src/components/intelligence/MemoryGraph.worker.test.tsx new file mode 100644 index 000000000..24d1088ea --- /dev/null +++ b/app/src/components/intelligence/MemoryGraph.worker.test.tsx @@ -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> = []; + terminated = false; + constructor() { + MockWorker.instances.push(this); + } + postMessage(m: Record) { + 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(' worker-backed SVG path', () => { + beforeEach(() => { + MockWorker.instances = []; + }); + + it('spins up a worker and applies streamed positions imperatively', () => { + const { container } = render(); + 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(); + 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(); + 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(); + 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(); + 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() + ).not.toThrow(); + }); + + it('handles node drag, wheel zoom, and reset view', () => { + const { container, getByTestId } = render( + + ); + 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(); + }); +}); diff --git a/app/src/components/intelligence/seedSvgLayout.test.ts b/app/src/components/intelligence/seedSvgLayout.test.ts new file mode 100644 index 000000000..4098244df --- /dev/null +++ b/app/src/components/intelligence/seedSvgLayout.test.ts @@ -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(); +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 parent’s 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 node’s 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); + }); +}); diff --git a/app/src/components/intelligence/seedSvgLayout.ts b/app/src/components/intelligence/seedSvgLayout.ts new file mode 100644 index 000000000..6bdc81dcc --- /dev/null +++ b/app/src/components/intelligence/seedSvgLayout.ts @@ -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 +): SeedResult { + const idIndex = new Map(); + 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 }; +} diff --git a/app/src/components/intelligence/svgForceLayout.worker.test.ts b/app/src/components/intelligence/svgForceLayout.worker.test.ts new file mode 100644 index 000000000..8c26b1ee1 --- /dev/null +++ b/app/src/components/intelligence/svgForceLayout.worker.test.ts @@ -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; +} + +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(); + }); +}); diff --git a/app/src/components/intelligence/svgForceLayout.worker.ts b/app/src/components/intelligence/svgForceLayout.worker.ts new file mode 100644 index 000000000..15c6e7840 --- /dev/null +++ b/app/src/components/intelligence/svgForceLayout.worker.ts @@ -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 (Barnes–Hut + * 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 | null = null; +let nodes: WNode[] = []; +let timer: ReturnType | 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(nodes) + .force('charge', forceManyBody().strength(-140).distanceMax(420)) + .force( + 'link', + forceLink(links) + .id((_d, i) => i) + .distance(58) + .strength(0.35) + ) + .force('center', forceCenter(cx, cy).strength(0.04)) + .force( + 'collide', + forceCollide().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(); + } +}; diff --git a/app/src/components/intelligence/useSvgForceLayout.ts b/app/src/components/intelligence/useSvgForceLayout.ts new file mode 100644 index 000000000..3311fb602 --- /dev/null +++ b/app/src/components/intelligence/useSvgForceLayout.ts @@ -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(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 }; +}