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 } } /** * Zoom interactions on a canvas whose plot area spans horizontally from * `left` to `width - right`: drag across a range to zoom into it (the range is * shown in the overlay element while dragging), wheel to zoom about the cursor * or, with horizontal scrolling, to pan, and double-click to reset. The * listeners are registered once and read the latest view through a ref. */ export function useZoomPan( ref: RefObject, overlayRef: 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<{ x0: number; x1: number } | null>(null) useEffect(() => { const el = ref.current if (!el) return const plotWidth = () => el.clientWidth - margins.left - margins.right const plotX = (clientX: number) => clientX - el.getBoundingClientRect().left - margins.left const timeAt = (clientX: number) => { const v = viewRef.current return v.start + (plotX(clientX) / plotWidth()) * v.span } const emit = (v: View) => onViewRef.current(clampView(v, duration, minSpan)) const showSelection = () => { const ov = overlayRef.current const d = drag.current if (!ov) return if (!d) { ov.style.display = 'none' return } const a = Math.max(0, Math.min(d.x0, d.x1)) const b = Math.min(plotWidth(), Math.max(d.x0, d.x1)) ov.style.display = 'block' ov.style.left = `${margins.left + a}px` ov.style.width = `${Math.max(1, b - a)}px` } const onWheel = (e: WheelEvent) => { e.preventDefault() const v = viewRef.current if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { emit({ start: v.start + (e.deltaX / plotWidth()) * v.span, span: v.span }) return } 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 const x = plotX(e.clientX) drag.current = { x0: x, x1: x } el.setPointerCapture(e.pointerId) showSelection() } const onMove = (e: PointerEvent) => { const d = drag.current if (!d) return d.x1 = plotX(e.clientX) showSelection() } const onUp = (e: PointerEvent) => { const d = drag.current if (!d) return drag.current = null showSelection() if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId) const a = Math.min(d.x0, d.x1) const b = Math.max(d.x0, d.x1) if (b - a < 4) return const v = viewRef.current const pw = plotWidth() emit({ start: v.start + (a / pw) * v.span, span: ((b - a) / pw) * v.span }) } const onDouble = () => emit({ start: 0, span: duration }) 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, overlayRef, duration, minSpan, margins]) }