diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 01916271..af8048c5 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -197,6 +197,11 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // descendInto from context isn't memoized; hold the latest in a ref so the // D3-bound node click handler can call it without re-binding every render. const descendIntoRef = useRef(descendInto); + // Camera persistence: remember the user's view per-graph + auto-fit once. + const currentGraphIdRef = useRef(currentGraph?.id); + currentGraphIdRef.current = currentGraph?.id; + const fittedGraphsRef = useRef>(new Set()); + const cameraSaveTimerRef = useRef | null>(null); // Mirrors isSimplified for the d3 tick closure (which captures stale render // values otherwise). Lets updateEdgePositions skip hidden arrow/label work. const simplifiedRef = useRef(false); @@ -3833,6 +3838,18 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i y: event.transform.y, scale: event.transform.k }); + // Persist the user's camera per-graph (debounced) so their view survives a + // reload and isn't reset on the next visit. localStorage only — no D1 chatter. + { + const gid = currentGraphIdRef.current; + if (gid) { + const cam = { x: event.transform.x, y: event.transform.y, k: event.transform.k }; + if (cameraSaveTimerRef.current) clearTimeout(cameraSaveTimerRef.current); + cameraSaveTimerRef.current = setTimeout(() => { + try { localStorage.setItem(`graphdone:camera:${gid}`, JSON.stringify(cam)); } catch { /* ignore */ } + }, 600); + } + } // Re-cull on pan/zoom. The one-shot sim is usually stopped during pan, so // this is the only thing that reveals nodes panned back into view (and @@ -3995,6 +4012,11 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i svg.call(d3.zoom().transform as any, transform); } }, [getNodeDimensions]); + // Latest fit fn in a ref so the once-per-graph camera effect can call it WITHOUT + // listing it as a dep — its identity churns during settle, which would otherwise + // cancel the fit timer before it fires (the bug that left graphs off-screen). + const fitViewRef = useRef(fitViewToNodes); + fitViewRef.current = fitViewToNodes; // Mini-map click → pan the main view to that graph point at current zoom useEffect(() => { @@ -4243,11 +4265,28 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i return () => cancelAnimationFrame(raf); }, [inlineEditNodeId]); const currentGraphId = currentGraph?.id; + // On graph load: restore the user's saved camera for THIS graph (respect their + // in-session view) or auto-fit ONCE so it never loads off-screen, then jumps in. + // Keyed on the graph id; never re-fits a graph already framed (don't fight the + // user). Uses fitViewRef (not fitViewToNodes) so the timer isn't cancelled by + // the callback's identity churning during physics settle. useEffect(() => { - if (!hasNodes || !svgRef.current) return undefined; - const timer = setTimeout(() => fitViewToNodes(), 1500); + if (!hasNodes || !svgRef.current || !currentGraphId) return undefined; + if (fittedGraphsRef.current.has(currentGraphId)) return undefined; + const gid = currentGraphId; + const timer = setTimeout(() => { + if (fittedGraphsRef.current.has(gid)) return; + fittedGraphsRef.current.add(gid); + let saved: { x: number; y: number; k: number } | null = null; + try { const r = localStorage.getItem(`graphdone:camera:${gid}`); saved = r ? JSON.parse(r) : null; } catch { /* ignore */ } + if (saved && typeof saved.k === 'number' && zoomBehaviorRef.current && svgRef.current) { + d3.select(svgRef.current).call(zoomBehaviorRef.current.transform as any, d3.zoomIdentity.translate(saved.x, saved.y).scale(saved.k)); + } else { + fitViewRef.current(); + } + }, 1200); return () => clearTimeout(timer); - }, [hasNodes, currentGraphId, fitViewToNodes]); + }, [hasNodes, currentGraphId]); // PR-3: keep the expand-in-place panel glued to its node through drags, ticks // and pan/zoom (same rAF technique as the rename box). The panel sits beside @@ -4309,6 +4348,16 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i }; }, [resetLayout, onResetLayout]); + // Expose "zoom to fit" (zoom extents) so the Workspace toolbar button can frame + // every node without touching the camera-restore state. Calls through the ref so + // the latest fit fn is used regardless of when the button mounts. + useEffect(() => { + (window as any).triggerZoomToFit = () => fitViewRef.current(); + return () => { + delete (window as any).triggerZoomToFit; + }; + }, []); + // Force reinitialization trigger - incremented when view needs refresh const [reinitTrigger, setReinitTrigger] = useState(0); diff --git a/packages/web/src/pages/Workspace.tsx b/packages/web/src/pages/Workspace.tsx index 6c5d6a83..61f4792a 100644 --- a/packages/web/src/pages/Workspace.tsx +++ b/packages/web/src/pages/Workspace.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { Plus, Share2, Users, Table, Activity, Network, CreditCard, Columns, CalendarDays, GanttChartSquare, LayoutDashboard, Database, AlertTriangle, Map, X, Minimize2, Edit3, Trash2, FolderPlus, ChevronLeft, ChevronRight, Lock, Unlock } from 'lucide-react'; +import { Plus, Share2, Users, Table, Activity, Network, CreditCard, Columns, CalendarDays, GanttChartSquare, LayoutDashboard, Database, AlertTriangle, Map, X, Minimize2, Maximize2, Edit3, Trash2, FolderPlus, ChevronLeft, ChevronRight, Lock, Unlock } from 'lucide-react'; import { createPortal } from 'react-dom'; import { useQuery } from '@apollo/client'; import { SafeGraphVisualization } from '../components/SafeGraphVisualization'; @@ -438,6 +438,18 @@ export function Workspace() { {graphLocked ? : } {graphLocked ? 'Locked' : 'Editing'} + {/* Zoom-extents: frame every node, independent of the camera-restore + state. Sits under the lock toggle on phones; top-left on desktop, + where the right corner is taken by the docked inspector. */} + {inspectorNode && (
setInspectorNode(null)} /> diff --git a/tests/diagnostics/graph-balance.spec.ts b/tests/diagnostics/graph-balance.spec.ts index 2d549878..fb0c65d3 100644 --- a/tests/diagnostics/graph-balance.spec.ts +++ b/tests/diagnostics/graph-balance.spec.ts @@ -13,12 +13,20 @@ import * as path from 'node:path'; * graph is placed: centroid offset from centre, bbox coverage, content usage, * margin balance, quadrant mass distribution, and an informational balanceScore. * - * PHASE 1 = measurement, not a verdict. It records numbers + an annotated - * overlay into the report and only asserts that content was detected — so we get - * objective baselines first. Centering/usage THRESHOLDS (pass/fail) come once the - * camera-centering work lands and we know what "good" looks like numerically. + * PHASE 2 = a gate. The camera-centering work landed (graph loads framed + + * once-per-graph fit), so we now know what "good" looks like numerically: + * offMag ~0.05 and bbox coverage ~0.93 across desktop/laptop/tablet. The + * thresholds below have a wide margin over those values and would have FAILED + * the pre-fix state (offMag ~0.80, ~6% coverage — graph off in a corner), so a + * regression that pushes the graph off-screen again is caught. The annotated + * overlay + raw metrics are still attached to the report for every run. */ +// Pass/fail thresholds (see header). Wide margins over the measured good state +// so they catch a real regression (off-screen / cornered graph) without flaking. +const MAX_OFF_MAG = 0.30; // centroid offset from frame centre (0 = dead centre) +const MIN_BBOX_COVERAGE = 0.35; // graph bbox vs canvas (off-screen graph ~= 0.06) + const PY = path.join(process.cwd(), 'tests/helpers/balance_metrics.py'); const OUT = path.join(process.cwd(), 'test-artifacts/balance'); mkdirSync(OUT, { recursive: true }); @@ -65,13 +73,11 @@ test.describe('graph balance metrics (OpenCV) @balance', () => { await info.attach(`balance-${r.name}`, { path: ann, contentType: 'image/jpeg' }); await info.attach(`metrics-${r.name}`, { body: JSON.stringify(m, null, 2), contentType: 'application/json' }); - // Phase 1 is measurement, not a gate: record even a near-empty canvas - // (itself a signal the graph rendered off-screen) instead of failing. - // Centering/usage THRESHOLDS become pass/fail once the camera work lands. + // Gate: the graph must actually be on-screen, framed, and centred. expect(m, 'metrics computed').toBeTruthy(); - if ((m.contentPixels ?? 0) < 200) { - console.warn(`[balance ${r.name}] near-empty canvas (${m.contentPixels}px) — graph likely rendered off-screen`); - } + expect(m.contentPixels ?? 0, `content detected on canvas (${m.contentPixels}px) — a near-empty canvas means the graph rendered off-screen`).toBeGreaterThan(200); + expect(b.coverage ?? 0, `graph bbox fills the canvas (coverage ${b.coverage}); a cornered/off-screen graph reads ~0.06`).toBeGreaterThan(MIN_BBOX_COVERAGE); + expect(c.offMag ?? 1, `graph is centred (offMag ${c.offMag}, dx=${c.offX} dy=${c.offY}); off-centre pre-fix read ~0.80`).toBeLessThan(MAX_OFF_MAG); }); } }); diff --git a/tests/e2e/camera.spec.ts b/tests/e2e/camera.spec.ts new file mode 100644 index 00000000..14782da5 --- /dev/null +++ b/tests/e2e/camera.spec.ts @@ -0,0 +1,113 @@ +import { test, expect, Page } from '@playwright/test'; +import { login, TEST_USERS, getBaseURL } from '../helpers/auth'; + +/** + * Camera framing + persistence (@camera). + * + * Covers two user-facing promises from the camera work: + * 1. The graph loads framed (never off-screen, then jumps in) and a "zoom to + * fit" (zoom-extents) button re-frames every node on demand. + * 2. The user's camera is respected within/across a session — panning is saved + * to localStorage per-graph and restored on reload, instead of being reset. + */ + +async function gotoGraph(page: Page) { + await page.addInitScript(() => localStorage.setItem('graphdone:viewMode', 'graph')); + await login(page, TEST_USERS.ADMIN); + await page.goto(`${getBaseURL()}/`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.graph-container svg .node', { timeout: 15_000 }); + await page.waitForTimeout(3500); // physics settle + the once-per-graph framing +} + +// How many nodes have any part inside the canvas viewport. +async function nodesInView(page: Page): Promise<{ inView: number; total: number }> { + const canvas = await page.locator('.graph-container').first().boundingBox(); + if (!canvas) return { inView: 0, total: 0 }; + const boxes = await page.locator('.graph-container svg .node').evaluateAll((els) => + els.map((el) => (el as SVGGraphicsElement).getBoundingClientRect()).map((r) => ({ x: r.x, y: r.y, w: r.width, h: r.height })) + ); + const inView = boxes.filter( + (b) => b.x + b.w > canvas.x && b.x < canvas.x + canvas.width && b.y + b.h > canvas.y && b.y < canvas.y + canvas.height + ).length; + return { inView, total: boxes.length }; +} + +test.describe('camera framing + persistence @camera', () => { + test.describe.configure({ timeout: 90_000 }); + + test('graph loads framed (nodes on-screen, not off in a corner)', async ({ page }) => { + await gotoGraph(page); + const { inView, total } = await nodesInView(page); + expect(total, 'graph has nodes').toBeGreaterThan(0); + // The framing fit centres the bbox; the bulk of nodes must be on-screen. + expect(inView, `${inView}/${total} nodes in view on load`).toBeGreaterThan(total * 0.5); + }); + + test('zoom-extents button re-frames nodes after a hard zoom-in', async ({ page }) => { + await gotoGraph(page); + const btn = page.locator('[data-testid="graph-zoom-extents"]'); + await expect(btn, 'zoom-extents button is present on graph view').toBeVisible(); + + // Zoom IN hard over the canvas centre (wheel, not drag) so peripheral nodes + // leave the viewport. Wheel avoids the minimap (bottom-right) and never grabs + // a node the way a drag-pan can. Custom wheelDelta: +deltaY == zoom in. + const canvas = await page.locator('.graph-container').first().boundingBox(); + if (!canvas) throw new Error('no canvas'); + const cx = canvas.x + canvas.width / 2; + const cy = canvas.y + canvas.height / 2; + await page.mouse.move(cx, cy); + for (let i = 0; i < 7; i++) { + await page.mouse.wheel(0, 320); + await page.waitForTimeout(90); + } + await page.waitForTimeout(500); + const zoomed = await nodesInView(page); + expect(zoomed.total, 'graph has nodes').toBeGreaterThan(0); + expect(zoomed.inView, `zoom-in pushed some nodes off-screen (${zoomed.inView}/${zoomed.total})`).toBeLessThan(zoomed.total); + + // Zoom-extents brings them all back into frame. + await btn.click(); + await page.waitForTimeout(900); // the 450ms fit transition + margin + const reframed = await nodesInView(page); + expect( + reframed.inView, + `zoom-extents reframed ${reframed.inView}/${reframed.total}` + ).toBeGreaterThanOrEqual(Math.ceil(reframed.total * 0.8)); + }); + + test('camera pan is saved per-graph and restored on reload', async ({ page }) => { + await gotoGraph(page); + const gid = await page.evaluate(() => { + const keys = Object.keys(localStorage); + return keys.find((k) => k.startsWith('graphdone:camera:'))?.replace('graphdone:camera:', '') ?? null; + }); + + // Pan, then wait past the 600ms debounce so it persists. + const canvas = await page.locator('.graph-container').first().boundingBox(); + if (!canvas) throw new Error('no canvas'); + await page.mouse.move(canvas.x + canvas.width / 2, canvas.y + canvas.height / 2); + await page.mouse.down(); + await page.mouse.move(canvas.x + canvas.width / 2 - 220, canvas.y + canvas.height / 2 - 160, { steps: 8 }); + await page.mouse.up(); + await page.waitForTimeout(900); + + const saved = await page.evaluate(() => { + const key = Object.keys(localStorage).find((k) => k.startsWith('graphdone:camera:')); + return key ? localStorage.getItem(key) : null; + }); + expect(saved, 'camera persisted to localStorage after pan').toBeTruthy(); + const cam = JSON.parse(saved!); + expect(typeof cam.x === 'number' && typeof cam.y === 'number' && typeof cam.k === 'number').toBeTruthy(); + + // Reload: the saved camera should be restored, not reset to a fresh fit. + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.graph-container svg .node', { timeout: 15_000 }); + await page.waitForTimeout(3000); + const after = await page.evaluate((k) => localStorage.getItem(k), `graphdone:camera:${gid}`); + expect(after, 'saved camera survives reload').toBeTruthy(); + const restored = JSON.parse(after!); + // Restore applies the saved transform; allow drift only from later auto-saves + // of the same view (no fresh fit, which would change k substantially). + expect(Math.abs(restored.k - cam.k), 'zoom level preserved across reload').toBeLessThan(0.05); + }); +});