Spaces:
Sleeping
Sleeping
| // Copyright (c) 2025-2026, RTE (https://www.rte-france.com) | |
| // This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. | |
| // If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, | |
| // you can obtain one at http://mozilla.org/MPL/2.0/. | |
| // SPDX-License-Identifier: MPL-2.0 | |
| // This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study. | |
| import { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef, type RefObject } from 'react'; | |
| import type { ViewBox } from '../types'; | |
| import { getSmoothPanZoomMode, type PanZoomMode } from '../utils/smoothPanZoom'; | |
| import { | |
| collectHighlightCss, | |
| serializeStrippedSvg, | |
| composeSnapshotMarkup, | |
| rasterizeMarkupToCanvas, | |
| } from '../utils/svg/bitmapSnapshot'; | |
| /** | |
| * Custom Hook for SVG Pan/Zoom via viewBox manipulation. | |
| * Performance: viewBox updates go directly to the DOM via refs, | |
| * bypassing React's render cycle during active interaction. | |
| * React state is only synced on interaction end / pause. | |
| * | |
| * Optimizations over the baseline (PR #5): | |
| * - Wheel zoom batched through rAF (was applying every event) | |
| * - getScreenCTM() cached and reused within a zoom burst | |
| * - Pointer-events disabled on SVG children during interaction | |
| * (eliminates expensive hit-testing on thousands of elements) | |
| * - Pointer-events disabled on SVG children during interaction | |
| * (eliminates expensive hit-testing on thousands of elements) | |
| */ | |
| export type ZoomTier = 'overview' | 'region' | 'detail'; | |
| const computeZoomTier = (current: ViewBox, original: ViewBox): ZoomTier => { | |
| const ratio = current.w / original.w; | |
| if (ratio > 0.5) return 'overview'; | |
| if (ratio > 0.15) return 'region'; | |
| return 'detail'; | |
| }; | |
| // Screen-space px width for the overload / action / contingency line halos, | |
| // driven into the `--nad-halo-w` CSS var (see App.css). The halos are thin | |
| // (HALO_MIN_PX — a clean trace of the branch) across the whole zoomed-in range | |
| // and grow toward HALO_MAX_PX (a prominent marker) only once past the overview | |
| // boundary, so a contingency stays visible on the full-extent view. Continuous | |
| // at ratio = 0.5 — no jarring snap at a tier boundary. | |
| const HALO_MIN_PX = 24; | |
| const HALO_MAX_PX = 50; | |
| const computeHaloWidthPx = (current: ViewBox, original: ViewBox): number => { | |
| const ratio = current.w / original.w; | |
| if (ratio <= 0.5) return HALO_MIN_PX; | |
| const t = Math.min(1, (ratio - 0.5) / 0.5); | |
| return Math.round(HALO_MIN_PX + (HALO_MAX_PX - HALO_MIN_PX) * t); | |
| }; | |
| /** | |
| * Element-local affine for a viewBox under `preserveAspectRatio="xMidYMid | |
| * meet"` (what pypowsybl emits). Returns the uniform scale `a` and offset | |
| * `(cx, cy)` such that a user-space point `u` renders at element-local | |
| * pixel `(a*u.x + cx, a*u.y + cy)`. Used by the opt-in smooth-pan/zoom path. | |
| */ | |
| const meetLocal = (vb: ViewBox, W: number, H: number) => { | |
| const a = Math.min(W / vb.w, H / vb.h); | |
| return { a, cx: (W - a * vb.w) / 2 - a * vb.x, cy: (H - a * vb.h) / 2 - a * vb.y }; | |
| }; | |
| /** | |
| * CSS transform (with `transform-origin: 0 0`) for the <svg> element that | |
| * makes a diagram baked at viewBox `base` render pixel-identically to | |
| * viewBox `target`. The opt-in "Smooth pan/zoom (GPU)" mode applies this | |
| * during a gesture so the compositor translates/scales the already- | |
| * rasterised SVG layer instead of repainting ~100k DOM nodes per frame, | |
| * then bakes `target` into the viewBox attribute on settle. Returns null on | |
| * degenerate input so the caller falls back to a direct viewBox write. | |
| * | |
| * Derivation: with both states on the same element + meet rule, the | |
| * element-local maps are similarities `L(u) = a·u + c`. The transform | |
| * `T(p) = s·p + t` must satisfy `T(L_base(u)) = L_target(u)` for all u, | |
| * giving `s = a_t / a_b` and `t = c_t − s·c_b`. | |
| */ | |
| const interactionTransform = (base: ViewBox, target: ViewBox, W: number, H: number): string | null => { | |
| if (!(W > 0 && H > 0 && base.w > 0 && base.h > 0 && target.w > 0 && target.h > 0)) return null; | |
| const lb = meetLocal(base, W, H); | |
| const lt = meetLocal(target, W, H); | |
| const s = lt.a / lb.a; | |
| const tx = lt.cx - s * lb.cx; | |
| const ty = lt.cy - s * lb.cy; | |
| if (!Number.isFinite(s) || !Number.isFinite(tx) || !Number.isFinite(ty)) return null; | |
| return `translate(${tx}px, ${ty}px) scale(${s})`; | |
| }; | |
| /** | |
| * Analytic inverse of the `xMidYMid meet` mapping: the user-space point under | |
| * a client pixel for a given viewBox. Used by the BITMAP mode's wheel zoom, | |
| * where the live <svg> is `visibility:hidden` at its base viewBox so | |
| * `getScreenCTM()` would report the base mapping, not the live (transformed) | |
| * one — anchoring the zoom to the wrong point. Computing it from the *current* | |
| * viewBox + the element box keeps the point under the cursor fixed. | |
| */ | |
| const cursorToUserSpace = ( | |
| svg: SVGSVGElement, size: { w: number; h: number } | null, vb: ViewBox, clientX: number, clientY: number, | |
| ): { x: number; y: number } => { | |
| const rect = svg.getBoundingClientRect(); | |
| const W = (size && size.w) || rect.width; | |
| const H = (size && size.h) || rect.height; | |
| const a = Math.min(W / vb.w, H / vb.h); | |
| const offX = (W - a * vb.w) / 2; | |
| const offY = (H - a * vb.h) / 2; | |
| return { x: vb.x + ((clientX - rect.left) - offX) / a, y: vb.y + ((clientY - rect.top) - offY) / a }; | |
| }; | |
| export const usePanZoom = ( | |
| svgRef: RefObject<HTMLDivElement | null>, | |
| initialViewBox: ViewBox | null | undefined, | |
| active: boolean, | |
| ) => { | |
| // React state: "settled" viewBox for downstream consumers | |
| const [viewBox, setViewBox] = useState<ViewBox | null>(null); | |
| // Mutable ref for the hot path — updated every frame, no React render | |
| const viewBoxRef = useRef<ViewBox | null>(null); | |
| const isDragging = useRef(false); | |
| const startPoint = useRef({ x: 0, y: 0, pendingX: 0, pendingY: 0 }); | |
| const wheelTimerId = useRef<ReturnType<typeof setTimeout> | null>(null); | |
| const rafId = useRef<number | null>(null); | |
| // Cached SVG element ref — avoids querySelector on every event | |
| const svgElRef = useRef<SVGSVGElement | null>(null); | |
| const activeRef = useRef(active); | |
| activeRef.current = active; | |
| // Cached getScreenCTM() — invalidated after each rAF viewBox apply | |
| const ctmCacheRef = useRef<DOMMatrix | null>(null); | |
| // Wheel zoom rAF batching: accumulate scale factor + last cursor position | |
| const wheelRafId = useRef<number | null>(null); | |
| const pendingWheelScale = useRef(1); | |
| const pendingWheelCursor = useRef({ x: 0, y: 0 }); | |
| // Zoom tier: tracked in ref to avoid DOM writes when tier hasn't changed | |
| const currentTierRef = useRef<ZoomTier | null>(null); | |
| // Last `--nad-halo-w` value written, to skip redundant style writes per frame | |
| const currentHaloWRef = useRef<number | null>(null); | |
| const originalVbRef = useRef<ViewBox | null>(null); | |
| // Opt-in "Smooth pan/zoom (GPU)" state. `smoothRef` snapshots the | |
| // preference at gesture start so the whole gesture is consistent; | |
| // `baseVbRef` is the viewBox baked on the DOM when the gesture began | |
| // and `baseSizeRef` the element's untransformed CSS size — both feed | |
| // the per-frame CSS transform. `interactingRef` marks that a transform | |
| // is live and must be baked back into the viewBox on settle. | |
| const smoothRef = useRef(false); | |
| const interactingRef = useRef(false); | |
| const baseVbRef = useRef<ViewBox | null>(null); | |
| const baseSizeRef = useRef<{ w: number; h: number } | null>(null); | |
| // Which smooth mode this gesture uses (snapshotted at gesture start). | |
| const modeRef = useRef<PanZoomMode>('off'); | |
| // Bitmap mode: the dpr-scaled <canvas> overlay transformed during the | |
| // gesture, and whether the (async) snapshot has been drawn + mounted yet. | |
| const bitmapCanvasRef = useRef<HTMLCanvasElement | null>(null); | |
| const bitmapReadyRef = useRef(false); | |
| // Generation token: bumped on every begin/teardown so a slow async raster | |
| // from a settled gesture can't mount a stale bitmap into a later gesture. | |
| const bitmapTokenRef = useRef(0); | |
| // Cached serialisation of the FO-stripped live SVG (the ~250ms expensive, | |
| // viewBox-INDEPENDENT part of a bitmap snapshot). Pre-warmed on idle and | |
| // invalidated when the SVG DOM changes, so a gesture's snapshot becomes a | |
| // cheap header re-compose + an off-thread decode instead of the 250ms | |
| // main-thread serialize that froze the pan start. | |
| const snapshotSerializedRef = useRef<string | null>(null); | |
| // Toggle interaction class on container to disable pointer-events on SVG children | |
| const setInteracting = (interacting: boolean) => { | |
| const container = svgRef.current; | |
| if (container) { | |
| container.classList.toggle('svg-interacting', interacting); | |
| } | |
| }; | |
| // Direct DOM update — no React involved | |
| const applyViewBox = useCallback((vb: ViewBox | null) => { | |
| const svg = svgElRef.current; | |
| // Safety net: a NaN/Infinity in any field would yield e.g. | |
| // `viewBox="NaN -2245943 NaN NaN"`, which SVG rejects and | |
| // renders as a blank diagram. The upstream callers should | |
| // never produce non-finite values, but a missing/malformed | |
| // coordinate in grid_layout.json can leak through the | |
| // pypowsybl metadata; refuse rather than corrupt the DOM. | |
| // `svg == null` is benign (tab sync writes a valid viewBox | |
| // before the target tab's SVG is mounted) — don't warn on it, | |
| // only warn on the real bug (a non-finite field). | |
| const vbIsFinite = !!vb && | |
| Number.isFinite(vb.x) && Number.isFinite(vb.y) && | |
| Number.isFinite(vb.w) && Number.isFinite(vb.h); | |
| if (svg && vbIsFinite) { | |
| svg.setAttribute('viewBox', `${vb!.x} ${vb!.y} ${vb!.w} ${vb!.h}`); | |
| // A concrete viewBox is the settled source of truth — drop any | |
| // leftover smooth-mode interaction transform so the bake is | |
| // exact and programmatic writes (zoom buttons, tab sync, load, | |
| // tied tabs) reset it. Inert in the default path (transform is | |
| // never set), so it costs nothing there. `style?.` guards the | |
| // jsdom case where DOMParser'd SVG nodes lack a style object. | |
| if (svg.style?.transform) { | |
| svg.style.transform = ''; | |
| svg.style.willChange = ''; | |
| } | |
| } else if (vb && !vbIsFinite) { | |
| // Trace the upstream caller so we can pinpoint which path | |
| // (handleZoomToElement, useTabSync, useTiedTabsSync, an | |
| // overflow-iframe postMessage handler, …) leaked a NaN / | |
| // Infinity field. Stack-trace via Error() — works in every | |
| // browser that supports SVG. | |
| console.warn('[usePanZoom] rejected non-finite viewBox', vb, new Error().stack); | |
| } | |
| // Invalidate CTM cache after viewBox change | |
| ctmCacheRef.current = null; | |
| // Update zoom tier attribute (only writes DOM when tier changes) | |
| const container = svgRef.current; | |
| const orig = originalVbRef.current; | |
| if (container && vb && orig) { | |
| const tier = computeZoomTier(vb, orig); | |
| if (tier !== currentTierRef.current) { | |
| currentTierRef.current = tier; | |
| container.setAttribute('data-zoom-tier', tier); | |
| } | |
| // Zoom-adaptive halo width: thin when zoomed in, growing toward a | |
| // prominent marker when zoomed out (smooth, no tier snap). Cheap — | |
| // only writes the var when the rounded px value actually changes. | |
| const haloW = computeHaloWidthPx(vb, orig); | |
| if (haloW !== currentHaloWRef.current && container.style) { | |
| currentHaloWRef.current = haloW; | |
| container.style.setProperty('--nad-halo-w', `${haloW}px`); | |
| } | |
| } | |
| }, [svgRef]); | |
| // Bitmap-mode teardown: remove the canvas overlay and un-hide the live | |
| // SVG. Hook-level (not inside the event effect) so the diagram-load and | |
| // tab-switch lifecycle effects can cancel an in-flight bitmap gesture too. | |
| // No-op in the default / GPU paths (no canvas, SVG never hidden). | |
| const teardownBitmap = useCallback(() => { | |
| bitmapTokenRef.current++; // invalidate any in-flight async raster | |
| const canvas = bitmapCanvasRef.current; | |
| if (canvas) { | |
| canvas.style.transform = ''; | |
| canvas.style.willChange = ''; | |
| canvas.remove(); | |
| bitmapCanvasRef.current = null; | |
| } | |
| bitmapReadyRef.current = false; | |
| const svg = svgElRef.current; | |
| if (svg?.style && svg.style.visibility === 'hidden') svg.style.visibility = ''; | |
| }, []); | |
| // Pre-warm the serialisation cache (bitmap mode only) so the NEXT gesture's | |
| // snapshot skips the heavy serialize. Runs on idle — off any gesture. | |
| const prewarmSnapshot = useCallback(() => { | |
| const svg = svgElRef.current; | |
| // Never serialise mid-gesture (it would freeze a frame); a later settle / | |
| // observer schedule warms it. Skip if already warm or not in bitmap mode. | |
| if (!svg || interactingRef.current || snapshotSerializedRef.current != null | |
| || getSmoothPanZoomMode() !== 'bitmap') return; | |
| try { snapshotSerializedRef.current = serializeStrippedSvg(svg); } catch { /* ignore */ } | |
| }, []); | |
| const scheduleSnapshotPrewarm = useCallback(() => { | |
| if (getSmoothPanZoomMode() !== 'bitmap') return; | |
| if (typeof requestIdleCallback === 'function') { | |
| requestIdleCallback(() => prewarmSnapshot(), { timeout: 1500 }); | |
| } else { | |
| setTimeout(() => prewarmSnapshot(), 200); | |
| } | |
| }, [prewarmSnapshot]); | |
| const invalidateSnapshotCache = useCallback(() => { | |
| snapshotSerializedRef.current = null; | |
| scheduleSnapshotPrewarm(); | |
| }, [scheduleSnapshotPrewarm]); | |
| // Flush ref -> React state for downstream consumers. Skip the state | |
| // update (and the React re-render it triggers) when the settled viewBox | |
| // is byte-identical to the last committed one — e.g. a drag that returns | |
| // to its origin or a wheel burst that nets back to the same frame. The DOM | |
| // is settled independently by applyViewBox, so the guard is purely a | |
| // redundant-render saver. Mirrors the equality guard in setViewBoxPublic. | |
| const commitViewBox = () => { | |
| const vb = viewBoxRef.current; | |
| if (!vb) return; | |
| setViewBox(prev => { | |
| if (prev && | |
| prev.x === vb.x && prev.y === vb.y && | |
| prev.w === vb.w && prev.h === vb.h) { | |
| return prev; | |
| } | |
| return { ...vb }; | |
| }); | |
| }; | |
| // Cache SVG element when container content changes. | |
| // Also hide text immediately on large grids to prevent a flash | |
| // of unreadable text before the first applyViewBox call. | |
| // Runs only when a new diagram loads (initialViewBox changes), | |
| // NOT on every render — otherwise it blocks paint on tab switch. | |
| useLayoutEffect(() => { | |
| if (svgRef.current) { | |
| svgElRef.current = svgRef.current.querySelector('svg'); | |
| } else { | |
| svgElRef.current = null; | |
| } | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [initialViewBox]); | |
| // Sync from initialViewBox (diagram load or programmatic reset) | |
| useEffect(() => { | |
| if (initialViewBox) { | |
| // Store the full-extent viewBox for zoom tier calculation. | |
| // Only update on diagram load (when initialViewBox object identity changes), | |
| // not on programmatic zoom which uses setViewBoxPublic instead. | |
| originalVbRef.current = initialViewBox; | |
| currentTierRef.current = null; // force re-evaluation | |
| currentHaloWRef.current = null; // re-apply --nad-halo-w for the new diagram | |
| interactingRef.current = false; // a new diagram cancels any pending bake | |
| teardownBitmap(); // drop any in-flight bitmap overlay for the old diagram | |
| invalidateSnapshotCache(); // the SVG content changed — re-warm later | |
| viewBoxRef.current = initialViewBox; | |
| applyViewBox(initialViewBox); | |
| setViewBox(initialViewBox); | |
| } | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [initialViewBox]); | |
| // Sync DOM viewBox BEFORE paint when tab becomes active — prevents | |
| // one frame of stale viewBox on tab switch. | |
| useLayoutEffect(() => { | |
| // A tab switch ends any in-flight smooth-mode gesture; applyViewBox | |
| // re-bakes the settled viewBox and clears the transform. | |
| interactingRef.current = false; | |
| teardownBitmap(); | |
| invalidateSnapshotCache(); | |
| if (!active || !viewBoxRef.current) return; | |
| applyViewBox(viewBoxRef.current); | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [active]); | |
| // Invalidate the bitmap serialisation cache when the live SVG's CONTENT | |
| // changes (highlight halos / flow-delta classes + the clones they add), | |
| // so a snapshot never bakes stale visuals. viewBox attribute writes are NOT | |
| // observed (attributeFilter excludes them), so per-frame pans don't churn | |
| // the cache. Cheap when idle; only fires on real highlight mutations. | |
| useEffect(() => { | |
| const svg = svgElRef.current; | |
| if (!svg || typeof MutationObserver === 'undefined') return; | |
| scheduleSnapshotPrewarm(); | |
| const obs = new MutationObserver(() => invalidateSnapshotCache()); | |
| obs.observe(svg, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] }); | |
| return () => obs.disconnect(); | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [active, initialViewBox]); | |
| // Stable event registration — re-registers when active tab changes | |
| // OR when the diagram loads (initialViewBox changes). | |
| useEffect(() => { | |
| const el = svgRef.current; | |
| if (!el || !active) return; | |
| // Get (or cache) the screen CTM | |
| const getCTM = (): DOMMatrix | null => { | |
| if (ctmCacheRef.current) return ctmCacheRef.current; | |
| const svg = svgElRef.current; | |
| if (!svg) return null; | |
| const ctm = svg.getScreenCTM(); | |
| if (ctm) ctmCacheRef.current = ctm; | |
| return ctm; | |
| }; | |
| // --- Opt-in smooth-pan/zoom (GPU compositing) helpers --- | |
| // Snapshot the baked viewBox + element size so each gesture frame | |
| // can be expressed as a CSS transform relative to it. | |
| const beginInteraction = () => { | |
| if (interactingRef.current) return; | |
| const svg = svgElRef.current; | |
| if (!svg || !viewBoxRef.current) return; | |
| const rect = svg.getBoundingClientRect(); | |
| baseVbRef.current = { ...viewBoxRef.current }; | |
| baseSizeRef.current = { w: svg.clientWidth || rect.width, h: svg.clientHeight || rect.height }; | |
| if (svg.style) { | |
| svg.style.transformOrigin = '0 0'; | |
| svg.style.willChange = 'transform'; | |
| } | |
| interactingRef.current = true; | |
| }; | |
| // Render `vb` via a compositor-only CSS transform — no viewBox | |
| // rewrite, so no full vector repaint. Keeps viewBoxRef in lockstep | |
| // so the wheel/drag math and the final bake stay exact, and | |
| // invalidates the CTM cache so the next getScreenCTM reflects | |
| // base+transform = the live mapping. Falls back to a viewBox write | |
| // if anything is missing or the transform is degenerate. | |
| const applyInteractionFrame = (vb: ViewBox) => { | |
| const svg = svgElRef.current; | |
| const base = baseVbRef.current; | |
| const size = baseSizeRef.current; | |
| const t = (svg && base && size) ? interactionTransform(base, vb, size.w, size.h) : null; | |
| if (!svg || !t) { | |
| viewBoxRef.current = vb; | |
| applyViewBox(vb); | |
| return; | |
| } | |
| viewBoxRef.current = vb; | |
| svg.style.transform = t; | |
| ctmCacheRef.current = null; | |
| }; | |
| // --- Opt-in BITMAP mode helpers (rasterise once, transform the bitmap) --- | |
| // Capture the base viewBox + element size, then build the bitmap OFF the | |
| // gesture handler. The gesture pans via the viewBox fallback from frame 1 | |
| // (responsive); the bitmap only mounts when ready. If the serialisation | |
| // cache is COLD (the ~250ms expensive part), this gesture stays on the | |
| // fallback and we warm the cache on idle — so the START is NEVER frozen; | |
| // the bitmap engages from the next gesture once warm. | |
| const beginBitmapInteraction = () => { | |
| if (interactingRef.current) return; | |
| // Clean a canvas left mounted by a previous gesture whose deferred | |
| // reveal hasn't fired yet (token bump cancels that pending reveal). | |
| teardownBitmap(); | |
| const svg = svgElRef.current; | |
| const container = svgRef.current; | |
| if (!svg || !container || !viewBoxRef.current) return; | |
| const rect = svg.getBoundingClientRect(); | |
| const w = svg.clientWidth || rect.width; | |
| const h = svg.clientHeight || rect.height; | |
| baseVbRef.current = { ...viewBoxRef.current }; | |
| baseSizeRef.current = { w, h }; | |
| interactingRef.current = true; | |
| bitmapReadyRef.current = false; | |
| const token = ++bitmapTokenRef.current; | |
| const baseVb = baseVbRef.current; | |
| const view = container.ownerDocument.defaultView ?? window; | |
| const dpr = view.devicePixelRatio || 1; | |
| const zoomTier = container.getAttribute('data-zoom-tier'); | |
| const build = () => { | |
| if (token !== bitmapTokenRef.current || !interactingRef.current || modeRef.current !== 'bitmap') return; | |
| const live = svgElRef.current; | |
| if (!live) return; | |
| const serialized = snapshotSerializedRef.current; | |
| if (serialized == null) { | |
| // Cold cache: don't freeze this gesture serialising 9MB on the | |
| // main thread — stay on the viewBox fallback and warm for next. | |
| scheduleSnapshotPrewarm(); | |
| return; | |
| } | |
| let css = ''; | |
| try { css = collectHighlightCss(container.ownerDocument); } catch { /* ignore */ } | |
| const markup = composeSnapshotMarkup(serialized, { | |
| baseVb, width: w, height: h, zoomTier, css, | |
| haloWidthPx: currentHaloWRef.current, | |
| }); | |
| rasterizeMarkupToCanvas(markup, w, h, dpr) | |
| .then((canvas) => { | |
| // Gesture may have settled / a new diagram may have loaded / | |
| // the mode may have changed while the (off-thread) decode ran. | |
| if (token !== bitmapTokenRef.current || !interactingRef.current | |
| || modeRef.current !== 'bitmap' || bitmapCanvasRef.current) return; | |
| canvas.style.position = 'absolute'; | |
| canvas.style.left = '0'; | |
| canvas.style.top = '0'; | |
| canvas.style.width = `${w}px`; | |
| canvas.style.height = `${h}px`; | |
| canvas.style.transformOrigin = '0 0'; | |
| canvas.style.willChange = 'transform'; | |
| canvas.style.pointerEvents = 'none'; | |
| container.appendChild(canvas); | |
| bitmapCanvasRef.current = canvas; | |
| live.style.visibility = 'hidden'; | |
| bitmapReadyRef.current = true; | |
| // Jump straight to the current frame (no flash of base view). | |
| const cur = viewBoxRef.current; | |
| if (cur) { | |
| const t = interactionTransform(baseVb, cur, w, h); | |
| if (t) canvas.style.transform = t; | |
| } | |
| }) | |
| .catch(() => { /* raster failed → stay on the viewBox fallback */ }); | |
| }; | |
| // Defer off the mousedown/wheel handler so the first frame paints. | |
| setTimeout(build, 0); | |
| }; | |
| // Per-frame: keep viewBoxRef in lockstep (the wheel/drag math + the | |
| // settle bake all read it), then transform the canvas if it's mounted, | |
| // else fall back to a viewBox write (pre-raster frames). | |
| const applyBitmapFrame = (vb: ViewBox) => { | |
| viewBoxRef.current = vb; | |
| const canvas = bitmapCanvasRef.current; | |
| const base = baseVbRef.current; | |
| const size = baseSizeRef.current; | |
| if (bitmapReadyRef.current && canvas && base && size) { | |
| const t = interactionTransform(base, vb, size.w, size.h); | |
| if (t) { canvas.style.transform = t; ctmCacheRef.current = null; return; } | |
| } | |
| applyViewBox(vb); | |
| }; | |
| // Bake the live viewBox back onto the SVG (single repaint at the | |
| // settled position; applyViewBox clears the transform) and sync | |
| // React state. Safe to call in the default path — it just commits. | |
| const endInteraction = () => { | |
| if (interactingRef.current) { | |
| interactingRef.current = false; | |
| if (bitmapCanvasRef.current) { | |
| // BITMAP settle. Bake the target viewBox onto the STILL-HIDDEN | |
| // live SVG first (it repaints behind the bitmap), then keep the | |
| // bitmap on top for ~2 frames before revealing the SVG + dropping | |
| // the canvas. Revealing immediately would expose the SVG layer's | |
| // STALE base-zoom paint for the ~50ms vector repaint — the | |
| // "ghost halo" the operator sees on a fast zoom. The token guard | |
| // cancels this deferred reveal if a new gesture starts first | |
| // (beginBitmapInteraction tears the canvas down up-front). | |
| if (viewBoxRef.current) applyViewBox(viewBoxRef.current); | |
| const token = ++bitmapTokenRef.current; | |
| const reveal = () => { | |
| if (token !== bitmapTokenRef.current) return; // superseded | |
| const sv = svgElRef.current; | |
| if (sv?.style) sv.style.visibility = ''; | |
| const c = bitmapCanvasRef.current; | |
| if (c) { c.style.transform = ''; c.style.willChange = ''; c.remove(); } | |
| bitmapCanvasRef.current = null; | |
| bitmapReadyRef.current = false; | |
| }; | |
| requestAnimationFrame(() => requestAnimationFrame(reveal)); | |
| } else if (viewBoxRef.current) { | |
| // default / GPU: single bake at the settled position. | |
| applyViewBox(viewBoxRef.current); | |
| } | |
| // The gesture is over — drop the `will-change: transform` | |
| // compositor-layer hint now so we don't hold a promoted | |
| // multi-MB layer through the wheel-commit debounce gap or | |
| // between gestures. applyViewBox already clears it when a | |
| // transform was live on settle, but clear unconditionally to | |
| // also cover the frame(s) where applyInteractionFrame fell back | |
| // to a direct viewBox write (no transform set). This does NOT | |
| // shorten the 150ms cull/debounce window — only the hint. | |
| const svg = svgElRef.current; | |
| if (svg?.style) svg.style.willChange = ''; | |
| // Re-warm the serialisation cache off the gesture (idle) so the | |
| // next bitmap gesture is fast. No-op if already warm / not bitmap. | |
| scheduleSnapshotPrewarm(); | |
| } | |
| commitViewBox(); | |
| }; | |
| const handleWheel = (e: WheelEvent) => { | |
| if (!activeRef.current || !viewBoxRef.current) return; | |
| e.preventDefault(); | |
| // Accumulate scale factor (multiplicative) and record latest cursor | |
| const scaleFactor = e.deltaY > 0 ? 1.1 : 0.9; | |
| pendingWheelScale.current *= scaleFactor; | |
| pendingWheelCursor.current = { x: e.clientX, y: e.clientY }; | |
| // Mark as interacting. Snapshot the pan/zoom mode for the whole | |
| // gesture and, when smooth, capture the transform base / snapshot. | |
| modeRef.current = getSmoothPanZoomMode(); | |
| smoothRef.current = modeRef.current !== 'off'; | |
| setInteracting(true); | |
| if (modeRef.current === 'gpu') beginInteraction(); | |
| else if (modeRef.current === 'bitmap') beginBitmapInteraction(); | |
| // Schedule rAF if not already queued | |
| if (!wheelRafId.current) { | |
| // Snapshot the CTM before the rAF (while it's still valid) | |
| const ctm = getCTM(); | |
| wheelRafId.current = requestAnimationFrame(() => { | |
| wheelRafId.current = null; | |
| const vb = viewBoxRef.current; | |
| const svg = svgElRef.current; | |
| if (!vb || !svg) return; | |
| const accumulatedScale = pendingWheelScale.current; | |
| pendingWheelScale.current = 1; // reset accumulator | |
| const cursor = pendingWheelCursor.current; | |
| let svgP: { x: number; y: number }; | |
| if (modeRef.current === 'bitmap') { | |
| // Live SVG is hidden at its base viewBox, so getScreenCTM | |
| // is stale — derive the cursor's user point analytically. | |
| svgP = cursorToUserSpace(svg, baseSizeRef.current, vb, cursor.x, cursor.y); | |
| } else { | |
| if (!ctm) return; | |
| const pt = svg.createSVGPoint(); | |
| pt.x = cursor.x; | |
| pt.y = cursor.y; | |
| svgP = pt.matrixTransform(ctm.inverse()); | |
| } | |
| const newVb: ViewBox = { | |
| x: vb.x + (svgP.x - vb.x) * (1 - accumulatedScale), | |
| y: vb.y + (svgP.y - vb.y) * (1 - accumulatedScale), | |
| w: vb.w * accumulatedScale, | |
| h: vb.h * accumulatedScale, | |
| }; | |
| if (modeRef.current === 'bitmap') { | |
| applyBitmapFrame(newVb); | |
| } else if (modeRef.current === 'gpu') { | |
| applyInteractionFrame(newVb); | |
| } else { | |
| viewBoxRef.current = newVb; | |
| applyViewBox(newVb); | |
| } | |
| }); | |
| } | |
| // Debounced commit: sync to React after scrolling stops | |
| if (wheelTimerId.current) clearTimeout(wheelTimerId.current); | |
| wheelTimerId.current = setTimeout(() => { | |
| if (smoothRef.current) endInteraction(); | |
| else commitViewBox(); | |
| setInteracting(false); | |
| }, 150); | |
| }; | |
| // rAF-throttled drag: at most one DOM update per display frame | |
| const handleMouseMove = (e: MouseEvent) => { | |
| if (!activeRef.current || !viewBoxRef.current || !isDragging.current) return; | |
| e.preventDefault(); | |
| startPoint.current.pendingX = e.clientX; | |
| startPoint.current.pendingY = e.clientY; | |
| if (rafId.current) return; // frame already queued | |
| rafId.current = requestAnimationFrame(() => { | |
| rafId.current = null; | |
| const sp = startPoint.current; | |
| const dx = sp.pendingX - sp.x; | |
| const dy = sp.pendingY - sp.y; | |
| sp.x = sp.pendingX; | |
| sp.y = sp.pendingY; | |
| const svg = svgElRef.current; | |
| if (!svg) return; | |
| // In smooth mode the SVG carries a CSS transform, so use the | |
| // transform-independent layout width (clientWidth) for the | |
| // user-per-pixel scale. The default path keeps the original | |
| // getBoundingClientRect().width for byte-identical behaviour. | |
| const screenW = smoothRef.current | |
| ? (svg.clientWidth || svg.getBoundingClientRect().width) | |
| : svg.getBoundingClientRect().width; | |
| const vb = viewBoxRef.current!; | |
| const scale = vb.w / screenW; | |
| const newVb: ViewBox = { | |
| ...vb, | |
| x: vb.x - dx * scale, | |
| y: vb.y - dy * scale, | |
| }; | |
| if (modeRef.current === 'bitmap') { | |
| applyBitmapFrame(newVb); | |
| } else if (modeRef.current === 'gpu') { | |
| applyInteractionFrame(newVb); | |
| } else { | |
| viewBoxRef.current = newVb; | |
| applyViewBox(newVb); | |
| } | |
| }); | |
| }; | |
| // Track the window where mousemove/mouseup are currently bound for | |
| // an active drag, so handleMouseUp knows where to unbind. Using a | |
| // closure-level variable keeps the API minimal. | |
| let activeDragWindow: Window | null = null; | |
| const handleMouseUp = () => { | |
| isDragging.current = false; | |
| if (rafId.current) { | |
| cancelAnimationFrame(rafId.current); | |
| rafId.current = null; | |
| } | |
| if (activeDragWindow) { | |
| activeDragWindow.removeEventListener('mousemove', handleMouseMove); | |
| activeDragWindow.removeEventListener('mouseup', handleMouseUp); | |
| activeDragWindow = null; | |
| } | |
| // Bake the smooth-mode transform back to viewBox (and commit); | |
| // the default path just commits to React state on drag end. | |
| if (smoothRef.current) endInteraction(); | |
| else commitViewBox(); | |
| setInteracting(false); | |
| }; | |
| const handleMouseDown = (e: MouseEvent) => { | |
| if (!activeRef.current || !viewBoxRef.current) return; | |
| isDragging.current = true; | |
| startPoint.current = { x: e.clientX, y: e.clientY, pendingX: e.clientX, pendingY: e.clientY }; | |
| modeRef.current = getSmoothPanZoomMode(); | |
| smoothRef.current = modeRef.current !== 'off'; | |
| setInteracting(true); | |
| if (modeRef.current === 'gpu') beginInteraction(); | |
| else if (modeRef.current === 'bitmap') beginBitmapInteraction(); | |
| // Resolve the element's CURRENT owner window per-drag rather | |
| // than capturing it at effect-bind time. This is critical for | |
| // detachable tabs: when the tab's DOM is relocated to a popup | |
| // window via imperative move, `el.ownerDocument.defaultView` | |
| // points at the popup, so drag-pan keeps working there — | |
| // whereas a captured `window` reference would still listen | |
| // to the main window and ignore events in the popup. | |
| const dragWindow = el.ownerDocument.defaultView ?? window; | |
| activeDragWindow = dragWindow; | |
| dragWindow.addEventListener('mousemove', handleMouseMove); | |
| dragWindow.addEventListener('mouseup', handleMouseUp); | |
| }; | |
| el.addEventListener('wheel', handleWheel, { passive: false }); | |
| el.addEventListener('mousedown', handleMouseDown); | |
| return () => { | |
| el.removeEventListener('wheel', handleWheel); | |
| el.removeEventListener('mousedown', handleMouseDown); | |
| // Clean up any in-flight drag listeners. | |
| if (activeDragWindow) { | |
| activeDragWindow.removeEventListener('mousemove', handleMouseMove); | |
| activeDragWindow.removeEventListener('mouseup', handleMouseUp); | |
| activeDragWindow = null; | |
| } | |
| if (wheelTimerId.current) clearTimeout(wheelTimerId.current); | |
| if (rafId.current) cancelAnimationFrame(rafId.current); | |
| if (wheelRafId.current) cancelAnimationFrame(wheelRafId.current); | |
| // Drop any in-flight smooth-mode transform so a re-bind / tab | |
| // teardown never leaves a stale transform on the SVG. | |
| interactingRef.current = false; | |
| teardownBitmap(); | |
| const svgEl = svgElRef.current; | |
| if (svgEl && svgEl.style?.transform) { | |
| svgEl.style.transform = ''; | |
| svgEl.style.willChange = ''; | |
| } | |
| }; | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [active, initialViewBox]); | |
| // Public API: updates ref + DOM + React state immediately | |
| const setViewBoxPublic = useCallback((vb: ViewBox) => { | |
| // Reject non-finite viewBoxes at the public boundary too — | |
| // otherwise viewBoxRef.current gets polluted with NaN and the | |
| // tab-active layout effect re-emits the bad value at the next | |
| // tab switch (which would slip past applyViewBox's DOM guard | |
| // but pollute downstream React state). applyViewBox itself | |
| // will log the stack trace. | |
| if ( | |
| !vb || | |
| !Number.isFinite(vb.x) || !Number.isFinite(vb.y) || | |
| !Number.isFinite(vb.w) || !Number.isFinite(vb.h) | |
| ) { | |
| applyViewBox(vb); | |
| return; | |
| } | |
| viewBoxRef.current = vb; | |
| applyViewBox(vb); | |
| setViewBox(prev => { | |
| if (prev && vb && | |
| prev.x === vb.x && prev.y === vb.y && | |
| prev.w === vb.w && prev.h === vb.h) { | |
| return prev; | |
| } | |
| return vb; | |
| }); | |
| }, [applyViewBox]); | |
| return useMemo(() => ({ viewBox, setViewBox: setViewBoxPublic }), [viewBox, setViewBoxPublic]); | |
| }; | |