Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 43 additions & 3 deletions packages/web/src/components/InteractiveGraphVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */ }
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<typeof setTimeout> | 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;
Expand Down Expand Up @@ -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<typeof setTimeout> | null = null;
let tries = 0;
Expand Down
21 changes: 21 additions & 0 deletions tests/e2e/camera.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading