diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 040a7cbc..70bfe946 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -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; }; }, []); diff --git a/packages/web/src/components/MiniMap.tsx b/packages/web/src/components/MiniMap.tsx index bd78d0cf..9307efd4 100644 --- a/packages/web/src/components/MiniMap.tsx +++ b/packages/web/src/components/MiniMap.tsx @@ -25,6 +25,61 @@ export function MiniMap({ width = 192, height = 128 }: { width?: number; height? const [nodes, setNodes] = useState>({}); const [viewport, setViewport] = useState(null); const nodeTypesRef = useRef>({}); + // 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(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 once positions arrive — attach then. + }, [Object.keys(nodes).length > 0]); useEffect(() => { (window as any).updateMiniMapPositions = (positions: Record) => { @@ -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) => ({ @@ -117,6 +173,7 @@ export function MiniMap({ width = 192, height = 128 }: { width?: number; height? return ( { + 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); + }); +});