|
| 1 | +import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react' |
| 2 | + |
| 3 | +/** |
| 4 | + * Two-finger pitch gesture for deck.gl on mobile. |
| 5 | + * |
| 6 | + * deck.gl's built-in `multipan` recognizer (via mjolnir.js) fails on real |
| 7 | + * touchscreens because Pinch and Pan(pointers:2, direction:Vertical) compete |
| 8 | + * for the same two-finger input — pinch almost always wins. |
| 9 | + * See https://github.com/visgl/deck.gl/issues/4853 |
| 10 | + * |
| 11 | + * This hook detects two-finger vertical drag via document-level touch events |
| 12 | + * and converts it to pitch changes on a controlled `viewState`. A simple |
| 13 | + * state machine classifies gestures after a 15px dead zone: |
| 14 | + * |
| 15 | + * idle → pending (2 fingers down) → pitching | passthrough |
| 16 | + * |
| 17 | + * Classification as "pitch" requires both fingers moving in the same vertical |
| 18 | + * direction with low spread change (not pinch) and low rotation. |
| 19 | + * |
| 20 | + * Convention: drag down = increase pitch (tilt toward horizon), matching |
| 21 | + * deck.gl's own `_onMultiPan` → `rotate` code path. |
| 22 | + * |
| 23 | + * Usage: |
| 24 | + * ```tsx |
| 25 | + * const isPitchingRef = useTouchPitch({ setViewState, maxPitch: 85 }) |
| 26 | + * |
| 27 | + * <DeckGL |
| 28 | + * viewState={viewState} |
| 29 | + * onViewStateChange={({ viewState: vs }) => { |
| 30 | + * if (isPitchingRef.current) return // ignore deck.gl during pitch |
| 31 | + * setViewState(vs) |
| 32 | + * }} |
| 33 | + * controller={{ touchRotate: true }} |
| 34 | + * /> |
| 35 | + * ``` |
| 36 | + */ |
| 37 | +export function useTouchPitch<V extends { pitch: number }>({ |
| 38 | + setViewState, |
| 39 | + maxPitch = 85, |
| 40 | + sensitivity = 0.3, |
| 41 | +}: { |
| 42 | + /** React-style setState for the deck.gl viewState (controlled mode). */ |
| 43 | + setViewState: Dispatch<SetStateAction<V>> |
| 44 | + /** Upper bound for pitch in degrees (default 85). */ |
| 45 | + maxPitch?: number |
| 46 | + /** Degrees of pitch change per pixel of finger movement (default 0.3). */ |
| 47 | + sensitivity?: number |
| 48 | +}) { |
| 49 | + const isPitchingRef = useRef(false) |
| 50 | + // Refs for latest values so the effect never re-subscribes |
| 51 | + const cfgRef = useRef({ maxPitch, sensitivity }) |
| 52 | + cfgRef.current = { maxPitch, sensitivity } |
| 53 | + const setterRef = useRef(setViewState) |
| 54 | + setterRef.current = setViewState |
| 55 | + |
| 56 | + useEffect(() => { |
| 57 | + type State = 'idle' | 'pending' | 'pitching' | 'passthrough' |
| 58 | + // Gesture state: start positions (sx/sy), last-frame positions (ly) |
| 59 | + const g = { state: 'idle' as State, sx0: 0, sy0: 0, sx1: 0, sy1: 0, ly0: 0, ly1: 0 } |
| 60 | + |
| 61 | + const onTouchStart = (e: TouchEvent) => { |
| 62 | + if (e.touches.length === 2) { |
| 63 | + g.state = 'pending' |
| 64 | + g.sx0 = e.touches[0].clientX; g.sy0 = e.touches[0].clientY |
| 65 | + g.sx1 = e.touches[1].clientX; g.sy1 = e.touches[1].clientY |
| 66 | + g.ly0 = g.sy0; g.ly1 = g.sy1 |
| 67 | + } else { |
| 68 | + g.state = 'idle' |
| 69 | + isPitchingRef.current = false |
| 70 | + } |
| 71 | + } |
| 72 | + |
| 73 | + const onTouchMove = (e: TouchEvent) => { |
| 74 | + if (e.touches.length !== 2 || g.state === 'idle' || g.state === 'passthrough') return |
| 75 | + const x0 = e.touches[0].clientX, y0 = e.touches[0].clientY |
| 76 | + const x1 = e.touches[1].clientX, y1 = e.touches[1].clientY |
| 77 | + |
| 78 | + if (g.state === 'pending') { |
| 79 | + const dy0 = y0 - g.sy0, dy1 = y1 - g.sy1 |
| 80 | + const dx0 = x0 - g.sx0, dx1 = x1 - g.sx1 |
| 81 | + const avgDy = (dy0 + dy1) / 2, avgDx = (dx0 + dx1) / 2 |
| 82 | + // Spread change (pinch/zoom signal) |
| 83 | + const startSpread = Math.hypot(g.sx1 - g.sx0, g.sy1 - g.sy0) |
| 84 | + const curSpread = Math.hypot(x1 - x0, y1 - y0) |
| 85 | + const spreadDelta = Math.abs(curSpread - startSpread) |
| 86 | + // Rotation signal |
| 87 | + const startAngle = Math.atan2(g.sy1 - g.sy0, g.sx1 - g.sx0) |
| 88 | + const curAngle = Math.atan2(y1 - y0, x1 - x0) |
| 89 | + let rotDelta = curAngle - startAngle |
| 90 | + if (rotDelta > Math.PI) rotDelta -= 2 * Math.PI |
| 91 | + if (rotDelta < -Math.PI) rotDelta += 2 * Math.PI |
| 92 | + const vert = Math.abs(avgDy) |
| 93 | + // Dead zone: wait for 15px of movement before classifying |
| 94 | + if (Math.max(vert, Math.abs(avgDx), spreadDelta) < 15) return |
| 95 | + // Pitch: both fingers same vertical direction, vertical dominates, |
| 96 | + // low spread change (not pinch), low rotation |
| 97 | + const sameDir = (dy0 > 0) === (dy1 > 0) |
| 98 | + if (sameDir && vert > Math.abs(avgDx) * 1.5 && spreadDelta < vert * 0.5 && Math.abs(rotDelta) < 0.2) { |
| 99 | + g.state = 'pitching' |
| 100 | + isPitchingRef.current = true |
| 101 | + } else { |
| 102 | + g.state = 'passthrough' |
| 103 | + return |
| 104 | + } |
| 105 | + } |
| 106 | + |
| 107 | + // Prevent browser default (scroll/zoom) while pitching |
| 108 | + e.preventDefault() |
| 109 | + const { maxPitch: mp, sensitivity: s } = cfgRef.current |
| 110 | + const frameDy = ((y0 - g.ly0) + (y1 - g.ly1)) / 2 |
| 111 | + setterRef.current(v => ({ ...v, pitch: Math.max(0, Math.min(mp, v.pitch + frameDy * s)) } as V)) |
| 112 | + g.ly0 = y0; g.ly1 = y1 |
| 113 | + } |
| 114 | + |
| 115 | + const onTouchEnd = () => { |
| 116 | + g.state = 'idle' |
| 117 | + isPitchingRef.current = false |
| 118 | + } |
| 119 | + |
| 120 | + document.addEventListener('touchstart', onTouchStart, { passive: true }) |
| 121 | + document.addEventListener('touchmove', onTouchMove, { passive: false }) |
| 122 | + document.addEventListener('touchend', onTouchEnd, { passive: true }) |
| 123 | + document.addEventListener('touchcancel', onTouchEnd, { passive: true }) |
| 124 | + return () => { |
| 125 | + document.removeEventListener('touchstart', onTouchStart) |
| 126 | + document.removeEventListener('touchmove', onTouchMove) |
| 127 | + document.removeEventListener('touchend', onTouchEnd) |
| 128 | + document.removeEventListener('touchcancel', onTouchEnd) |
| 129 | + } |
| 130 | + }, []) |
| 131 | + |
| 132 | + return isPitchingRef |
| 133 | +} |
0 commit comments