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
55 changes: 52 additions & 3 deletions packages/web/src/components/InteractiveGraphVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined>(currentGraph?.id);
currentGraphIdRef.current = currentGraph?.id;
const fittedGraphsRef = useRef<Set<string>>(new Set());
const cameraSaveTimerRef = useRef<ReturnType<typeof setTimeout> | 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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -3995,6 +4012,11 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i
svg.call(d3.zoom<SVGSVGElement, unknown>().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(() => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down
14 changes: 13 additions & 1 deletion packages/web/src/pages/Workspace.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -438,6 +438,18 @@ export function Workspace() {
{graphLocked ? <Lock className="h-4 w-4" /> : <Unlock className="h-4 w-4" />}
{graphLocked ? 'Locked' : 'Editing'}
</button>
{/* 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. */}
<button
data-testid="graph-zoom-extents"
onClick={() => (window as any).triggerZoomToFit?.()}
className="absolute top-16 right-3 md:top-3 md:left-3 md:right-auto z-40 flex items-center justify-center w-10 h-10 rounded-full shadow-lg backdrop-blur-sm border border-gray-600 bg-gray-900/90 text-gray-200 hover:text-white hover:border-green-400 transition-colors"
title="Zoom to fit — frame all nodes"
aria-label="Zoom to fit — frame all nodes"
>
<Maximize2 className="h-4 w-4" />
</button>
{inspectorNode && (
<div className="absolute z-40 inset-x-3 bottom-3 md:inset-x-auto md:bottom-auto md:top-3 md:right-3">
<NodeInspector node={inspectorNode} onClose={() => setInspectorNode(null)} />
Expand Down
26 changes: 16 additions & 10 deletions tests/diagnostics/graph-balance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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);
});
}
});
113 changes: 113 additions & 0 deletions tests/e2e/camera.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading