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
13 changes: 13 additions & 0 deletions packages/web/src/components/InteractiveGraphVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3888,8 +3888,21 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap
const t = d3.zoomIdentity.translate(w / 2 - graphX * k, h / 2 - graphY * k).scale(k);
svg.transition().duration(350).call(zoomBehaviorRef.current.transform as any, t);
};
// Mini-map wheel/pinch → zoom the main view to a target scale, centered on
// the gesture's graph point. Clamped to the same scaleExtent as the main
// zoom; applied via the shared zoom behavior so state + handlers stay in sync.
(window as any).miniMapZoom = (graphX: number, graphY: number, targetK: number) => {
if (!svgRef.current || !containerRef.current || !zoomBehaviorRef.current) return;
const svg = d3.select(svgRef.current);
const k = Math.max(0.1, Math.min(4, targetK));
const w = containerRef.current.clientWidth;
const h = containerRef.current.clientHeight;
const t = d3.zoomIdentity.translate(w / 2 - graphX * k, h / 2 - graphY * k).scale(k);
svg.transition().duration(120).call(zoomBehaviorRef.current.transform as any, t);
};
return () => {
delete (window as any).miniMapNavigate;
delete (window as any).miniMapZoom;
};
}, []);

Expand Down
57 changes: 57 additions & 0 deletions packages/web/src/components/MiniMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,61 @@ export function MiniMap({ width = 192, height = 128 }: { width?: number; height?
const [nodes, setNodes] = useState<Record<string, MiniMapNode>>({});
const [viewport, setViewport] = useState<Viewport | null>(null);
const nodeTypesRef = useRef<Record<string, string>>({});
// Live minimap-px <-> graph-coord conversion params, updated each render so
// the native (non-passive) wheel/touch listeners can map a gesture point to
// a graph point and drive the main view's zoom.
const geomRef = useRef({ minX: 0, minY: 0, offsetX: 0, offsetY: 0, scale: 1, k: 1 });
const svgElRef = useRef<SVGSVGElement>(null);

// Wheel + pinch on the minimap zoom the MAIN view (centered on the gesture
// point). Attached natively with passive:false so we can preventDefault and
// stop the page from scrolling while zooming the map.
useEffect(() => {
const el = svgElRef.current;
if (!el) return undefined;
const toGraph = (clientX: number, clientY: number) => {
const r = el.getBoundingClientRect();
const g = geomRef.current;
return {
x: g.minX + (clientX - r.left - g.offsetX) / g.scale,
y: g.minY + (clientY - r.top - g.offsetY) / g.scale,
};
};
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const p = toGraph(e.clientX, e.clientY);
const factor = e.deltaY < 0 ? 1.18 : 1 / 1.18;
(window as any).miniMapZoom?.(p.x, p.y, geomRef.current.k * factor);
};
let pinchDist0 = 0;
let pinchK0 = 1;
const dist = (t: TouchList) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
const mid = (t: TouchList) => ({ x: (t[0].clientX + t[1].clientX) / 2, y: (t[0].clientY + t[1].clientY) / 2 });
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length === 2) { pinchDist0 = dist(e.touches); pinchK0 = geomRef.current.k; }
};
const onTouchMove = (e: TouchEvent) => {
if (e.touches.length === 2 && pinchDist0 > 0) {
e.preventDefault();
const m = mid(e.touches);
const p = toGraph(m.x, m.y);
(window as any).miniMapZoom?.(p.x, p.y, pinchK0 * (dist(e.touches) / pinchDist0));
}
};
const onTouchEnd = () => { pinchDist0 = 0; };
el.addEventListener('wheel', onWheel, { passive: false });
el.addEventListener('touchstart', onTouchStart, { passive: false });
el.addEventListener('touchmove', onTouchMove, { passive: false });
el.addEventListener('touchend', onTouchEnd);
return () => {
el.removeEventListener('wheel', onWheel);
el.removeEventListener('touchstart', onTouchStart);
el.removeEventListener('touchmove', onTouchMove);
el.removeEventListener('touchend', onTouchEnd);
};
// Re-run when the svg appears: the minimap renders a "No nodes yet" div
// first (svg ref null), then the <svg> once positions arrive — attach then.
}, [Object.keys(nodes).length > 0]);

useEffect(() => {
(window as any).updateMiniMapPositions = (positions: Record<string, { x: number; y: number; type?: string }>) => {
Expand Down Expand Up @@ -75,6 +130,7 @@ export function MiniMap({ width = 192, height = 128 }: { width?: number; height?
const scale = Math.min(width / spanX, height / spanY);
const offsetX = (width - spanX * scale) / 2;
const offsetY = (height - spanY * scale) / 2;
geomRef.current = { minX, minY, offsetX, offsetY, scale, k: viewport?.k || 1 };

const toMini = useCallback(
(gx: number, gy: number) => ({
Expand Down Expand Up @@ -117,6 +173,7 @@ export function MiniMap({ width = 192, height = 128 }: { width?: number; height?

return (
<svg
ref={svgElRef}
width="100%"
height="100%"
viewBox={`0 0 ${width} ${height}`}
Expand Down
58 changes: 58 additions & 0 deletions tests/diagnostics/minimap-zoom.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { test, expect, Page } from '@playwright/test';
import { login, TEST_USERS } from '../helpers/auth';

/**
* Minimap wheel/pinch zoom: a wheel over the minimap zooms the MAIN view
* (centered on the gesture point). Asserts the main view's zoom scale changes.
*/
const GRAPH_ID = 'subgraph-power-shared';

async function mainScale(page: Page): Promise<number> {
return page.evaluate(() => {
const g = document.querySelector('.graph-container svg .main-graph-group') as SVGGElement | null;
const t = g?.getAttribute('transform') || '';
const m = /scale\(([-0-9.]+)/.exec(t);
return m ? parseFloat(m[1]) : 1;
});
}

test.describe('minimap zoom diagnostic @geometry', () => {
test.describe.configure({ timeout: 120_000 });

test('wheel on the minimap changes the main view zoom', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await login(page, TEST_USERS.ADMIN);
await page.waitForTimeout(1500);
await page.evaluate((gid) => {
localStorage.setItem('currentGraphId', gid);
localStorage.setItem('graphdone.quality.override', 'HIGH');
}, GRAPH_ID);
await page.reload();
await page.waitForTimeout(6000);

const mini = page.locator('[data-testid="mini-map"]');
await mini.waitFor({ timeout: 15000 });
const box = await mini.boundingBox();
expect(box, 'minimap is visible').not.toBeNull();

const cx = box!.x + box!.width / 2;
const cy = box!.y + box!.height / 2;
await page.mouse.move(cx, cy);
const before = await mainScale(page);

// Zoom IN: real (trusted) wheel events at the minimap centre.
for (let i = 0; i < 3; i++) { await page.mouse.wheel(0, -120); await page.waitForTimeout(250); }
await page.waitForTimeout(400);
const afterIn = await mainScale(page);

// Zoom OUT.
for (let i = 0; i < 4; i++) { await page.mouse.wheel(0, 120); await page.waitForTimeout(250); }
await page.waitForTimeout(400);
const afterOut = await mainScale(page);

// eslint-disable-next-line no-console
console.log(`[minimap-zoom] before=${before} afterIn=${afterIn} afterOut=${afterOut}`);
expect(afterIn, 'wheel-in increases main zoom').toBeGreaterThan(before);
expect(afterOut, 'wheel-out decreases zoom below the zoomed-in level').toBeLessThan(afterIn);
});
});
Loading