/ proof-of-concept / phase-perception-2
Sign in
proof-of-concept / phase-perception-2
phase-perception-2 / src / hooks / useZoomPan.ts
82 lines · 3.2 KBBlameHistoryRaw
1import { useEffect, useRef, type RefObject } from 'react'
3export type View = { start: number; span: number }
5/** Keep the view inside [0, duration] with a span of at least minSpan. */
6export function clampView(v: View, duration: number, minSpan: number): View {
7 const span = Math.min(duration, Math.max(minSpan, v.span))
8 const start = Math.min(Math.max(0, v.start), Math.max(0, duration - span))
9 return { start, span }
12/**
13 * Mouse-wheel zoom about the cursor, drag to pan, double-click to reset, on a
14 * canvas whose plot area spans horizontally from `left` to `width - right`.
15 * The listeners are registered once and read the latest view through a ref,
16 * so a drag survives the re-renders it causes.
17 */
18export function useZoomPan(ref: RefObject<HTMLCanvasElement | null>, view: View, duration: number, minSpan: number, margins: { left: number; right: number }, onView: (v: View) => void): void {
19 const viewRef = useRef(view)
20 viewRef.current = view
21 const onViewRef = useRef(onView)
22 onViewRef.current = onView
23 const drag = useRef<{ x: number; start: number } | null>(null)
25 useEffect(() => {
26 const el = ref.current
27 if (!el) return
28 const plotWidth = () => el.clientWidth - margins.left - margins.right
29 const timeAt = (clientX: number) => {
30 const v = viewRef.current
31 const rect = el.getBoundingClientRect()
32 return v.start + ((clientX - rect.left - margins.left) / plotWidth()) * v.span
33 }
34 const emit = (v: View) => onViewRef.current(clampView(v, duration, minSpan))
36 const onWheel = (e: WheelEvent) => {
37 e.preventDefault()
38 const v = viewRef.current
39 const factor = Math.exp(e.deltaY * 0.0015)
40 const t = timeAt(e.clientX)
41 const span = v.span * factor
42 emit({ start: t - (t - v.start) * (span / v.span), span })
43 }
44 const onDown = (e: PointerEvent) => {
45 if (e.button !== 0) return
46 drag.current = { x: e.clientX, start: viewRef.current.start }
47 el.setPointerCapture(e.pointerId)
48 el.style.cursor = 'grabbing'
49 }
50 const onMove = (e: PointerEvent) => {
51 const d = drag.current
52 if (!d) return
53 const v = viewRef.current
54 const dt = ((e.clientX - d.x) / plotWidth()) * v.span
55 emit({ start: d.start - dt, span: v.span })
56 }
57 const onUp = (e: PointerEvent) => {
58 if (!drag.current) return
59 drag.current = null
60 if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
61 el.style.cursor = 'grab'
62 }
63 const onDouble = () => emit({ start: 0, span: duration })
65 el.style.cursor = 'grab'
66 el.style.touchAction = 'none'
67 el.addEventListener('wheel', onWheel, { passive: false })
68 el.addEventListener('pointerdown', onDown)
69 el.addEventListener('pointermove', onMove)
70 el.addEventListener('pointerup', onUp)
71 el.addEventListener('pointercancel', onUp)
72 el.addEventListener('dblclick', onDouble)
73 return () => {
74 el.removeEventListener('wheel', onWheel)
75 el.removeEventListener('pointerdown', onDown)
76 el.removeEventListener('pointermove', onMove)
77 el.removeEventListener('pointerup', onUp)
78 el.removeEventListener('pointercancel', onUp)
79 el.removeEventListener('dblclick', onDouble)
80 }
81 }, [ref, duration, minSpan, margins])
↑↓ move↵openescclose