import { useEffect, useRef, type RefObject } from 'react' export type View = { start: number; span: number } /** Keep the view inside [0, duration] with a span of at least minSpan. */ export function clampView(v: View, duration: number, minSpan: number): View { const span = Math.min(duration, Math.max(minSpan, v.span)) const start = Math.min(Math.max(0, v.start), Math.max(0, duration - span)) return { start, span } } /** * Mouse-wheel zoom about the cursor, drag to pan, double-click to reset, on a * canvas whose plot area spans horizontally from `left` to `width - right`. * The listeners are registered once and read the latest view through a ref, * so a drag survives the re-renders it causes. */ export function useZoomPan(ref: RefObject, view: View, duration: number, minSpan: number, margins: { left: number; right: number }, onView: (v: View) => void): void { const viewRef = useRef(view) viewRef.current = view const onViewRef = useRef(onView) onViewRef.current = onView const drag = useRef<{ x: number; start: number } | null>(null) useEffect(() => { const el = ref.current if (!el) return const plotWidth = () => el.clientWidth - margins.left - margins.right const timeAt = (clientX: number) => { const v = viewRef.current const rect = el.getBoundingClientRect() return v.start + ((clientX - rect.left - margins.left) / plotWidth()) * v.span } const emit = (v: View) => onViewRef.current(clampView(v, duration, minSpan)) const onWheel = (e: WheelEvent) => { e.preventDefault() const v = viewRef.current const factor = Math.exp(e.deltaY * 0.0015) const t = timeAt(e.clientX) const span = v.span * factor emit({ start: t - (t - v.start) * (span / v.span), span }) } const onDown = (e: PointerEvent) => { if (e.button !== 0) return drag.current = { x: e.clientX, start: viewRef.current.start } el.setPointerCapture(e.pointerId) el.style.cursor = 'grabbing' } const onMove = (e: PointerEvent) => { const d = drag.current if (!d) return const v = viewRef.current const dt = ((e.clientX - d.x) / plotWidth()) * v.span emit({ start: d.start - dt, span: v.span }) } const onUp = (e: PointerEvent) => { if (!drag.current) return drag.current = null if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId) el.style.cursor = 'grab' } const onDouble = () => emit({ start: 0, span: duration }) el.style.cursor = 'grab' el.style.touchAction = 'none' el.addEventListener('wheel', onWheel, { passive: false }) el.addEventListener('pointerdown', onDown) el.addEventListener('pointermove', onMove) el.addEventListener('pointerup', onUp) el.addEventListener('pointercancel', onUp) el.addEventListener('dblclick', onDouble) return () => { el.removeEventListener('wheel', onWheel) el.removeEventListener('pointerdown', onDown) el.removeEventListener('pointermove', onMove) el.removeEventListener('pointerup', onUp) el.removeEventListener('pointercancel', onUp) el.removeEventListener('dblclick', onDouble) } }, [ref, duration, minSpan, margins]) }