import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; export interface UseZoomOptions { /** Minimum zoom level (default: 1 = 100%) */ minZoom?: number; /** Maximum zoom level (default: 5 = 500%) */ maxZoom?: number; /** Zoom step for button controls (default: 0.25 = 25%) */ zoomStep?: number; /** Enable zoom with mouse wheel (default: true) */ wheelZoom?: boolean; /** ViewBox dimensions for coordinate mapping */ viewBox?: { width: number; height: number }; } export interface UseZoomReturn { /** Current zoom level (1 = 100%) */ zoom: number; /** Pan offset in pixels */ pan: { x: number; y: number }; /** Increase zoom level */ zoomIn: () => void; /** Decrease zoom level */ zoomOut: () => void; /** Reset zoom and pan to default values */ resetZoom: () => void; /** Handle mouse down for drag start */ handleMouseDown: (e: React.MouseEvent) => void; /** Handle mouse move for dragging */ handleMouseMove: (e: React.MouseEvent) => void; /** Handle mouse up to end dragging */ handleMouseUp: () => void; /** Whether currently dragging */ isDragging: boolean; /** Whether zoom and pan are at default values */ isDefaultZoom: boolean; /** Ref to attach to the container element for wheel event handling */ containerRef: RefObject; } const ZOOM_ANIMATION_MS = 180; const easeOutCubic = (t: number) => 1 - (1 - t) ** 3; /** * Custom hook for zoom and pan functionality */ export function useZoom(options: UseZoomOptions = {}): UseZoomReturn { const { minZoom = 1, maxZoom = 5, zoomStep = 0.5, wheelZoom = true, viewBox } = options; const [zoom, setZoom] = useState(1); const [pan, setPan] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); const dragStartRef = useRef({ x: 0, y: 0 }); const panStartRef = useRef({ x: 0, y: 0 }); const containerRef = useRef(null); const zoomRef = useRef(1); const panRef = useRef({ x: 0, y: 0 }); const animationRef = useRef(null); const clampZoom = useCallback( (value: number) => Math.max(minZoom, Math.min(maxZoom, value)), [minZoom, maxZoom], ); const stopAnimation = useCallback(() => { if (animationRef.current !== null) { cancelAnimationFrame(animationRef.current); animationRef.current = null; } }, []); const animateZoom = useCallback( ( targetZoom: number, options?: { targetPan?: { x: number; y: number }; durationMs?: number }, ) => { const { targetPan, durationMs = ZOOM_ANIMATION_MS } = options ?? {}; stopAnimation(); const startZoom = zoomRef.current; const clampedTargetZoom = clampZoom(targetZoom); const startPan = panRef.current; const hasPanTarget = targetPan !== undefined; const finalPan = targetPan ?? startPan; if ( durationMs <= 0 || (clampedTargetZoom === startZoom && (!hasPanTarget || (finalPan.x === startPan.x && finalPan.y === startPan.y))) ) { setZoom(clampedTargetZoom); zoomRef.current = clampedTargetZoom; if (hasPanTarget) { setPan(finalPan); panRef.current = finalPan; } return; } const startTime = performance.now(); const step = (now: number) => { const elapsed = now - startTime; const t = Math.min(elapsed / durationMs, 1); const eased = easeOutCubic(t); const nextZoom = startZoom + (clampedTargetZoom - startZoom) * eased; zoomRef.current = nextZoom; setZoom(nextZoom); if (hasPanTarget) { const nextPan = { x: startPan.x + (finalPan.x - startPan.x) * eased, y: startPan.y + (finalPan.y - startPan.y) * eased, }; panRef.current = nextPan; setPan(nextPan); } if (t < 1) { animationRef.current = requestAnimationFrame(step); } else { animationRef.current = null; setZoom(clampedTargetZoom); zoomRef.current = clampedTargetZoom; if (hasPanTarget) { setPan(finalPan); panRef.current = finalPan; } } }; animationRef.current = requestAnimationFrame(step); }, [clampZoom, stopAnimation], ); const zoomIn = useCallback(() => { const nextZoom = clampZoom(zoomRef.current + zoomStep); animateZoom(nextZoom); }, [animateZoom, clampZoom, zoomStep]); const zoomOut = useCallback(() => { const nextZoom = clampZoom(zoomRef.current - zoomStep); animateZoom(nextZoom); }, [animateZoom, clampZoom, zoomStep]); const resetZoom = useCallback(() => { animateZoom(1, { targetPan: { x: 0, y: 0 } }); }, [animateZoom]); const getViewBoxSize = useCallback( (container: SVGSVGElement) => { if (viewBox?.width && viewBox?.height) { return { width: viewBox.width, height: viewBox.height }; } const rect = container.getBoundingClientRect(); return { width: rect.width || 1, height: rect.height || 1 }; }, [viewBox?.width, viewBox?.height], ); const getViewBoxMetrics = useCallback( (container: SVGSVGElement) => { const rect = container.getBoundingClientRect(); const { width: viewBoxWidth, height: viewBoxHeight } = getViewBoxSize(container); const safeWidth = viewBoxWidth || 1; const safeHeight = viewBoxHeight || 1; const scale = rect.width > 0 && rect.height > 0 ? Math.min(rect.width / safeWidth, rect.height / safeHeight) : 0; const contentWidth = safeWidth * scale; const contentHeight = safeHeight * scale; const offsetX = (rect.width - contentWidth) / 2; const offsetY = (rect.height - contentHeight) / 2; return { rect, viewBoxWidth: safeWidth, viewBoxHeight: safeHeight, scale, offsetX, offsetY }; }, [getViewBoxSize], ); const getPointerPosition = useCallback( (container: SVGSVGElement, clientX: number, clientY: number) => { const ctm = container.getScreenCTM(); if (ctm) { if (typeof DOMPoint !== "undefined") { const point = new DOMPoint(clientX, clientY); const { x, y } = point.matrixTransform(ctm.inverse()); return { x, y }; } if ("createSVGPoint" in container) { const point = container.createSVGPoint(); point.x = clientX; point.y = clientY; const { x, y } = point.matrixTransform(ctm.inverse()); return { x, y }; } } const { rect, scale, offsetX, offsetY } = getViewBoxMetrics(container); if (scale <= 0) return null; return { x: (clientX - rect.left - offsetX) / scale, y: (clientY - rect.top - offsetY) / scale, }; }, [getViewBoxMetrics], ); const clampPan = useCallback( ( newPan: { x: number; y: number }, currentZoom: number, viewBoxWidth: number, viewBoxHeight: number, ) => { if (currentZoom <= 1) { return { x: 0, y: 0 }; } const maxPanX = (viewBoxWidth * (currentZoom - 1)) / 2; const maxPanY = (viewBoxHeight * (currentZoom - 1)) / 2; return { x: Math.max(-maxPanX, Math.min(maxPanX, newPan.x)), y: Math.max(-maxPanY, Math.min(maxPanY, newPan.y)), }; }, [], ); // Use native event listener with passive: false to allow preventDefault // Use smaller step for wheel (0.1) than buttons (zoomStep) for smoother zooming // Zoom is centered on mouse pointer position useEffect(() => { zoomRef.current = zoom; }, [zoom]); useEffect(() => { panRef.current = pan; }, [pan]); useEffect(() => { return () => stopAnimation(); }, [stopAnimation]); useEffect(() => { const container = containerRef.current; if (!container || !wheelZoom) return; const wheelZoomStep = 0.1; const handleWheel = (e: WheelEvent) => { e.preventDefault(); stopAnimation(); const { viewBoxWidth, viewBoxHeight } = getViewBoxMetrics(container); const pointer = getPointerPosition(container, e.clientX, e.clientY); if (!pointer) return; const { x: mouseX, y: mouseY } = pointer; const centerX = viewBoxWidth / 2; const centerY = viewBoxHeight / 2; const delta = e.deltaY > 0 ? -wheelZoomStep : wheelZoomStep; const currentZoom = zoomRef.current; const nextZoom = clampZoom(currentZoom + delta); if (nextZoom === currentZoom) return; const zoomRatio = nextZoom / currentZoom; const nextPan = { x: (mouseX - centerX) * (1 - zoomRatio) + panRef.current.x * zoomRatio, y: (mouseY - centerY) * (1 - zoomRatio) + panRef.current.y * zoomRatio, }; const clampedPan = clampPan(nextPan, nextZoom, viewBoxWidth, viewBoxHeight); zoomRef.current = nextZoom; panRef.current = clampedPan; setZoom(nextZoom); setPan(clampedPan); }; container.addEventListener("wheel", handleWheel, { passive: false }); return () => { container.removeEventListener("wheel", handleWheel); }; }, [clampZoom, wheelZoom, getViewBoxMetrics, getPointerPosition, clampPan, stopAnimation]); // Recalculate pan bounds when zoom changes useEffect(() => { const container = containerRef.current; if (!container) return; const { viewBoxWidth, viewBoxHeight } = getViewBoxMetrics(container); setPan((currentPan) => clampPan(currentPan, zoom, viewBoxWidth, viewBoxHeight)); }, [zoom, clampPan, getViewBoxMetrics]); const handleMouseDown = useCallback( (e: React.MouseEvent) => { // Only start drag with left mouse button, and only when zoomed in if (e.button !== 0 || zoom <= 1) return; stopAnimation(); setIsDragging(true); dragStartRef.current = { x: e.clientX, y: e.clientY }; panStartRef.current = { ...pan }; e.preventDefault(); }, [pan, zoom, stopAnimation], ); const handleMouseMove = useCallback( (e: React.MouseEvent) => { if (!isDragging || zoom <= 1) return; const container = containerRef.current; if (!container) return; const { scale, viewBoxWidth, viewBoxHeight } = getViewBoxMetrics(container); if (scale <= 0) return; const dx = e.clientX - dragStartRef.current.x; const dy = e.clientY - dragStartRef.current.y; const newPan = { x: panStartRef.current.x + dx / scale, y: panStartRef.current.y + dy / scale, }; setPan(clampPan(newPan, zoom, viewBoxWidth, viewBoxHeight)); }, [isDragging, zoom, clampPan, getViewBoxMetrics], ); const handleMouseUp = useCallback(() => { setIsDragging(false); }, []); const isDefaultZoom = zoom === 1 && pan.x === 0 && pan.y === 0; return { zoom, pan, zoomIn, zoomOut, resetZoom, handleMouseDown, handleMouseMove, handleMouseUp, isDragging, isDefaultZoom, containerRef, }; }