/ proof-of-concept / phase-perception-2
Sign in
proof-of-concept / phase-perception-2
phase-perception-2 / src / hooks / useZoomPan.ts
115 lines · 4.1 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 * Zoom interactions on a canvas whose plot area spans horizontally from
14 * `left` to `width - right`: drag across a range to zoom into it (the range is
15 * shown in the overlay element while dragging), wheel to zoom about the cursor
16 * or, with horizontal scrolling, to pan, and double-click to reset. The
17 * listeners are registered once and read the latest view through a ref.
18 */
19export function useZoomPan(
20 ref: RefObject<HTMLCanvasElement | null>,
21 overlayRef: RefObject<HTMLDivElement | null>,
22 view: View,
23 duration: number,
24 minSpan: number,
25 margins: { left: number; right: number },
26 onView: (v: View) => void,
27): void {
28 const viewRef = useRef(view)
29 viewRef.current = view
30 const onViewRef = useRef(onView)
31 onViewRef.current = onView
32 const drag = useRef<{ x0: number; x1: number } | null>(null)
34 useEffect(() => {
35 const el = ref.current
36 if (!el) return
37 const plotWidth = () => el.clientWidth - margins.left - margins.right
38 const plotX = (clientX: number) => clientX - el.getBoundingClientRect().left - margins.left
39 const timeAt = (clientX: number) => {
40 const v = viewRef.current
41 return v.start + (plotX(clientX) / plotWidth()) * v.span
42 }
43 const emit = (v: View) => onViewRef.current(clampView(v, duration, minSpan))
44 const showSelection = () => {
45 const ov = overlayRef.current
46 const d = drag.current
47 if (!ov) return
48 if (!d) {
49 ov.style.display = 'none'
50 return
51 }
52 const a = Math.max(0, Math.min(d.x0, d.x1))
53 const b = Math.min(plotWidth(), Math.max(d.x0, d.x1))
54 ov.style.display = 'block'
55 ov.style.left = `${margins.left + a}px`
56 ov.style.width = `${Math.max(1, b - a)}px`
57 }
59 const onWheel = (e: WheelEvent) => {
60 e.preventDefault()
61 const v = viewRef.current
62 if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
63 emit({ start: v.start + (e.deltaX / plotWidth()) * v.span, span: v.span })
64 return
65 }
66 const factor = Math.exp(e.deltaY * 0.0015)
67 const t = timeAt(e.clientX)
68 const span = v.span * factor
69 emit({ start: t - (t - v.start) * (span / v.span), span })
70 }
71 const onDown = (e: PointerEvent) => {
72 if (e.button !== 0) return
73 const x = plotX(e.clientX)
74 drag.current = { x0: x, x1: x }
75 el.setPointerCapture(e.pointerId)
76 showSelection()
77 }
78 const onMove = (e: PointerEvent) => {
79 const d = drag.current
80 if (!d) return
81 d.x1 = plotX(e.clientX)
82 showSelection()
83 }
84 const onUp = (e: PointerEvent) => {
85 const d = drag.current
86 if (!d) return
87 drag.current = null
88 showSelection()
89 if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
90 const a = Math.min(d.x0, d.x1)
91 const b = Math.max(d.x0, d.x1)
92 if (b - a < 4) return
93 const v = viewRef.current
94 const pw = plotWidth()
95 emit({ start: v.start + (a / pw) * v.span, span: ((b - a) / pw) * v.span })
96 }
97 const onDouble = () => emit({ start: 0, span: duration })
99 el.style.touchAction = 'none'
100 el.addEventListener('wheel', onWheel, { passive: false })
101 el.addEventListener('pointerdown', onDown)
102 el.addEventListener('pointermove', onMove)
103 el.addEventListener('pointerup', onUp)
104 el.addEventListener('pointercancel', onUp)
105 el.addEventListener('dblclick', onDouble)
106 return () => {
107 el.removeEventListener('wheel', onWheel)
108 el.removeEventListener('pointerdown', onDown)
109 el.removeEventListener('pointermove', onMove)
110 el.removeEventListener('pointerup', onUp)
111 el.removeEventListener('pointercancel', onUp)
112 el.removeEventListener('dblclick', onDouble)
113 }
114 }, [ref, overlayRef, duration, minSpan, margins])
↑↓ move↵openescclose