All files / src/hooks usePanZoom.ts

61.72% Statements 50/81
50% Branches 11/22
52.94% Functions 9/17
64.38% Lines 47/73

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150              29x 29x 29x 29x   29x 12x 12x                       29x 29x 29x 29x     29x   135x                     29x   1x 1x 1x 1x 1x 1x 1x 1x                   29x 25x 25x   22x 1x 1x 1x 1x 1x 1x 1x 1x 1x   1x       22x 22x 22x         29x         29x 29x   29x                   29x                       29x           29x         29x                                    
// File: src/hooks/usePanZoom.ts
import { useCallback, useEffect, useRef, useState } from 'react';
import type { Pt } from '../utils/geomap';
 
export function usePanZoom(
  bounds: { minX: number; minY: number; maxX: number; maxY: number } | null
) {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const svgRef = useRef<SVGSVGElement | null>(null);
  const [size, setSize] = useState({ w: 0, h: 0 });
  const initialTransform = useRef({ k: 1.3, t: { x: 0, y: 0 } });
 
  useEffect(() => {
    const el = containerRef.current;
    Eif (!el) return;
    const ro = new ResizeObserver((entries) => {
      for (const e of entries) {
        const cr = e.contentRect;
        setSize({ w: Math.max(1, cr.width), h: Math.max(1, cr.height) });
      }
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, []);
 
  // pan / zoom state
  const [k, setK] = useState(initialTransform.current.k);
  const [t, setT] = useState(initialTransform.current.t);
  const MIN_ZOOM = 0.3;
  const MAX_ZOOM = 10;
 
  // Convert pixels → viewBox units for constant-pixel elements
  const pxToVb = useCallback(
    (px: number) => {
      Eif (!bounds || size.w <= 0 || size.h <= 0) return px;
      const vbW = bounds.maxX - bounds.minX,
        vbH = bounds.maxY - bounds.minY;
      const sx = vbW / size.w,
        sy = vbH / size.h;
      return px * Math.max(sx, sy);
    },
    [bounds, size.w, size.h]
  );
 
  // Utility: map client (px) → viewBox coordinates
  const clientToVb = useCallback(
    (clientX: number, clientY: number): Pt => {
      const svg = svgRef.current;
      Iif (!svg || !bounds) return { x: 0, y: 0 };
      const r = svg.getBoundingClientRect();
      const rx = (clientX - r.left) / r.width;
      const ry = (clientY - r.top) / r.height;
      const x = bounds.minX + rx * (bounds.maxX - bounds.minX);
      const y = bounds.minY + ry * (bounds.maxY - bounds.minY);
      return { x, y };
    },
    [bounds]
  );
 
  /**
   * Wheel zoom:
   * Attach a native 'wheel' listener with { passive: false } so preventDefault() is allowed.
   * This avoids the "Unable to preventDefault inside passive event listener" warning.
   */
  useEffect(() => {
    const svg = svgRef.current;
    if (!svg || !bounds) return;
 
    const onWheelNative = (ev: WheelEvent) => {
      ev.preventDefault(); // now valid because listener is non-passive
      const f = Math.exp(-ev.deltaY * 0.001); // smooth zoom factor
      setK((prevK) => {
        const target = clientToVb(ev.clientX, ev.clientY);
        const nk = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, prevK * f));
        setT((prevT) => {
          const worldX = (target.x - prevT.x) / prevK;
          const worldY = (target.y - prevT.y) / prevK;
          return { x: target.x - worldX * nk, y: target.y - worldY * nk };
        });
        return nk;
      });
    };
 
    svg.addEventListener('wheel', onWheelNative, { passive: false });
    return () => {
      svg.removeEventListener('wheel', onWheelNative as EventListener);
    };
  }, [bounds, clientToVb]);
 
  // React onWheel handler kept as NO-OP to preserve existing props usage without causing the warning.
  const onWheel = useCallback((_e: React.WheelEvent<SVGSVGElement>) => {
    // no-op: handled by native non-passive listener above
  }, []);
 
  // Panning
  const [panning, setPanning] = useState(false);
  const [lastPt, setLastPt] = useState<Pt | null>(null);
 
  const onPointerDown = useCallback(
    (e: React.PointerEvent<SVGSVGElement>) => {
      if (!bounds) return;
      (e.currentTarget as Element).setPointerCapture(e.pointerId);
      setPanning(true);
      setLastPt(clientToVb(e.clientX, e.clientY));
    },
    [bounds, clientToVb]
  );
 
  const onPointerMove = useCallback(
    (e: React.PointerEvent<SVGSVGElement>) => {
      if (!panning || !bounds || !lastPt) return;
      const pt = clientToVb(e.clientX, e.clientY);
      const dx = pt.x - lastPt.x;
      const dy = pt.y - lastPt.y;
      setT((prev) => ({ x: prev.x + dx, y: prev.y + dy }));
      setLastPt(pt);
    },
    [panning, bounds, lastPt, clientToVb]
  );
 
  const onPointerUp = useCallback((e: React.PointerEvent<SVGSVGElement>) => {
    (e.currentTarget as Element).releasePointerCapture(e.pointerId);
    setPanning(false);
    setLastPt(null);
  }, []);
 
  const reset = useCallback(() => {
    setK(initialTransform.current.k);
    setT(initialTransform.current.t);
  }, []);
 
  return {
    containerRef,
    svgRef,
    size,
    k,
    t,
    setK,
    setT,
    pxToVb,
    clientToVb,
    onWheel,
    onPointerDown,
    onPointerMove,
    onPointerUp,
    panning,
    reset
  };
}