From 1034a3d6f80fb39488835c5ba152e49c2b6aaca0 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Thu, 18 Jun 2026 00:07:09 -0700 Subject: [PATCH] VIEW-3: re-fit camera on significant viewport resize; ignore stale-size saved camera MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-graph saved camera was restored even after the window was resized/rotated, leaving the graph mis-framed for the new size. Now: - the saved camera is stamped with the viewport size (w/h); on restore, a >20% size mismatch is treated as stale → fresh fit instead of the old transform. - a ResizeObserver on the graph container re-frames on a >20% size change (debounced 250ms), covering window resize / rotate / panel toggle. (Window 'resize' alone didn't fire in headless tests; the observer is reliable and also catches layout-driven size changes.) Test: camera.spec "re-frames the graph after a significant viewport resize" (pan off-screen → shrink window → graph re-framed). 5/5 camera tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 46 +++++++++++++++++-- tests/e2e/camera.spec.ts | 21 +++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 65cb483c..a72a9563 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -3861,7 +3861,13 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i { const gid = currentGraphIdRef.current; if (gid) { - const cam = { x: event.transform.x, y: event.transform.y, k: event.transform.k }; + // Stamp the viewport size with the camera so a later restore can tell + // the window was resized materially (rotate / maximize) and re-fit + // instead of applying a transform framed for a different size. + const cam = { + x: event.transform.x, y: event.transform.y, k: event.transform.k, + w: containerRef.current?.clientWidth || 0, h: containerRef.current?.clientHeight || 0, + }; if (cameraSaveTimerRef.current) clearTimeout(cameraSaveTimerRef.current); cameraSaveTimerRef.current = setTimeout(() => { try { localStorage.setItem(`graphdone:camera:${gid}`, JSON.stringify(cam)); } catch { /* ignore */ } @@ -4153,6 +4159,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i }; }, []); + // Reset layout = the explicit "re-flow everything" escape hatch. It must // override snapshot-authoritative pinning: clear each node's saved-position // intent (in memory) and unpin, suspend re-pinning while physics rearranges, @@ -4260,6 +4267,32 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // hasNodes only and restored one global transform, so it never recentered on // a graph change. We wait briefly for the one-shot layout to settle, then fit. const hasNodes = nodes.length > 0; + + // Re-frame the graph when the container changes size SIGNIFICANTLY (window + // resize, rotate, sidebar/panel toggle) so a camera framed for the old size + // isn't left mis-centred. A ResizeObserver on the container is more reliable + // than the window 'resize' event (which didn't fire in headless tests) and + // also catches layout-driven size changes. Debounced + gated on a >20% size + // delta so it doesn't fight a user nudging the window edge or a normal pan. + useEffect(() => { + const el = containerRef.current; + if (!el || !hasNodes) return undefined; + let last = { w: el.clientWidth || 0, h: el.clientHeight || 0 }; + let t: ReturnType | null = null; + const ro = new ResizeObserver(() => { + if (t) clearTimeout(t); + t = setTimeout(() => { + const w = el.clientWidth || 0, h = el.clientHeight || 0; + if (!w || !h || !last.w || !last.h) { last = { w, h }; return; } + const changed = Math.abs(w - last.w) / last.w > 0.2 || Math.abs(h - last.h) / last.h > 0.2; + last = { w, h }; + if (changed) fitViewRef.current(); + }, 250); + }); + ro.observe(el); + return () => { if (t) clearTimeout(t); ro.disconnect(); }; + }, [hasNodes]); + const isDenseGraph = nodes.length > DENSE_GRAPH_NODE_THRESHOLD; const isSimplified = isDenseGraph && (currentTransform?.scale ?? 1) < SIMPLIFY_SCALE; const isDotMode = isDenseGraph && (currentTransform?.scale ?? 1) < DOT_SCALE; @@ -4310,9 +4343,16 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i if (fittedGraphsRef.current.has(currentGraphId)) return undefined; const gid = currentGraphId; - let saved: { x: number; y: number; k: number } | null = null; + let saved: { x: number; y: number; k: number; w?: number; h?: number } | null = null; try { const r = localStorage.getItem(`graphdone:camera:${gid}`); saved = r ? JSON.parse(r) : null; } catch { /* ignore */ } - const hasSaved = !!(saved && typeof saved.k === 'number'); + // A saved camera framed for a very different viewport (resized window, rotated + // device) would mis-frame the graph now — prefer a fresh fit in that case. + const sizeMatches = (s: { w?: number; h?: number }): boolean => { + const cw = containerRef.current?.clientWidth || 0, ch = containerRef.current?.clientHeight || 0; + if (!s.w || !s.h || !cw || !ch) return true; // legacy save without size → trust it + return Math.abs(cw - s.w) / s.w < 0.2 && Math.abs(ch - s.h) / s.h < 0.2; + }; + const hasSaved = !!(saved && typeof saved.k === 'number' && sizeMatches(saved)); let timer: ReturnType | null = null; let tries = 0; diff --git a/tests/e2e/camera.spec.ts b/tests/e2e/camera.spec.ts index b8d6c107..2582ffaa 100644 --- a/tests/e2e/camera.spec.ts +++ b/tests/e2e/camera.spec.ts @@ -139,4 +139,25 @@ test.describe('camera framing + persistence @camera', () => { expect(total, 'graph has nodes after the delayed load').toBeGreaterThan(0); expect(inView, `${inView}/${total} nodes framed despite slow load`).toBeGreaterThan(total * 0.5); }); + + test('re-frames the graph after a significant viewport resize', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await gotoGraph(page); + // Pan hard so the graph leaves the frame, wait past the camera-save debounce. + 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 - 700, canvas.y + canvas.height / 2 - 500, { steps: 10 }); + await page.mouse.up(); + await page.waitForTimeout(800); + const panned = await nodesInView(page); + expect(panned.inView, `pan pushed nodes off (${panned.inView}/${panned.total})`).toBeLessThan(panned.total); + + // Shrink the window materially (>20%) → the resize handler re-frames. + await page.setViewportSize({ width: 900, height: 700 }); + await page.waitForTimeout(1200); // 250ms debounce + 450ms fit transition + margin + const reframed = await nodesInView(page); + expect(reframed.inView, `resize re-framed ${reframed.inView}/${reframed.total}`).toBeGreaterThan(reframed.total * 0.5); + }); });