diff --git a/packages/web/src/components/FlowCanvas.tsx b/packages/web/src/components/FlowCanvas.tsx index 978a209..e06310b 100644 --- a/packages/web/src/components/FlowCanvas.tsx +++ b/packages/web/src/components/FlowCanvas.tsx @@ -123,6 +123,28 @@ function FlowCanvasInner({ const { nodes: baseNodes, edges: baseEdges } = useFlowGraphLayout(flow) const reactFlow = useReactFlow() + // Per-step auto-zoom level applied during playback. The bbox-fit + // calc in moveTo() routinely zoomed further out than felt right for + // tightly-laid-out flows; a fixed override at 1.0 (= native sprite + // size, no upscale/downscale) reads much better. Overview (paused) + // still uses the natural fit. + // + // Live-tunable via `window.__setMaxZoom(n)` for browser-console + // tuning — clearing lastFocusKeyRef makes the new value land on the + // current step immediately. + const [autoZoomOverride, setAutoZoomOverride] = useState(1.0) + useEffect(() => { + window.__setMaxZoom = (n: number) => { + setAutoZoomOverride(n) + lastFocusKeyRef.current = '' + // eslint-disable-next-line no-console + console.log('[openhop] auto-zoom =', n) + } + return () => { + delete window.__setMaxZoom + } + }, []) + // Playback speed multiplier. The animation hook reads the live value // off `window.__flowSpeed` each tick, so updating the global is what // actually affects pacing — local state just drives the button label. @@ -367,19 +389,29 @@ function FlowCanvasInner({ } } - const moveTo = (nodes: typeof baseNodes, pad: number, maxZoom: number) => { + const moveTo = ( + nodes: typeof baseNodes, + pad: number, + maxZoom: number, + override: number | null + ) => { if (nodes.length === 0) return const { minX, minY, maxX, maxY } = computeBbox(nodes) const contentW = maxX - minX const contentH = maxY - minY - const zoom = Math.min(paneW / (contentW * (1 + pad)), paneH / (contentH * (1 + pad)), maxZoom) + const naturalZoom = Math.min( + paneW / (contentW * (1 + pad)), + paneH / (contentH * (1 + pad)), + maxZoom + ) + const zoom = override != null ? Math.max(0.1, Math.min(6, override)) : naturalZoom reactFlow.setCenter((minX + maxX) / 2, (minY + maxY) / 2, { zoom, duration: 500 }) } if (!playing) { if (lastFocusKeyRef.current === '__overview__') return lastFocusKeyRef.current = '__overview__' - moveTo(baseNodes, 0.3, 1.5) + moveTo(baseNodes, 0.3, 1.5, null) return } @@ -394,7 +426,7 @@ function FlowCanvasInner({ ) if (focusNodes.length === 0) return lastFocusKeyRef.current = focusKey - moveTo(focusNodes, 0.5, 1.8) + moveTo(focusNodes, 0.5, 1.8, autoZoomOverride) }, [ playing, animState.currentStepIndex, @@ -402,6 +434,7 @@ function FlowCanvasInner({ animState.activeToIds, baseNodes, reactFlow, + autoZoomOverride, // paneResizeTick: re-runs this effect after a sidebar/inspector // toggle so the camera re-locks onto the active step instead of // staying at the overview fitToPane() applied. diff --git a/packages/web/src/globals.d.ts b/packages/web/src/globals.d.ts index 3c1809f..c532fb7 100644 --- a/packages/web/src/globals.d.ts +++ b/packages/web/src/globals.d.ts @@ -11,6 +11,8 @@ declare global { interface Window { /** Test/debug hook: scales animation speed (default 1, e.g. 4 for 4× faster). */ __flowSpeed?: number + /** Test/debug hook: set the canvas's max zoom from the browser console. */ + __setMaxZoom?: (n: number) => void } } diff --git a/packages/web/src/hooks/useFlowAnimation.ts b/packages/web/src/hooks/useFlowAnimation.ts index 2da9b5c..7e41d59 100644 --- a/packages/web/src/hooks/useFlowAnimation.ts +++ b/packages/web/src/hooks/useFlowAnimation.ts @@ -63,7 +63,7 @@ export interface StepEdgeMapping { // Speed can be overridden via window.__flowSpeed (for testing) const getSpeed = () => window.__flowSpeed ?? 1 -const STEP_DURATION_BASE = 2500 +const STEP_DURATION_BASE = 2900 const PIXEL_DURATION_BASE = 1800 export function useFlowAnimation(