Skip to content

Commit 55dc926

Browse files
ryan-williamsclaude
andcommitted
useTouchPitch hook: two-finger pitch gesture for deck.gl on mobile
deck.gl's built-in `multipan` recognizer (via mjolnir.js) is broken on real touchscreens: `Pinch` and `Pan(pointers:2, direction:Vertical)` compete for the same input, and pinch almost always wins. This is a known, long-standing issue (visgl/deck.gl#4853). Custom touch handler with gesture classification state machine: - idle → pending (2 fingers down) → pitching | passthrough - After 15px dead zone, classifies based on: same-direction vertical movement, low spread change (not pinch), low rotation - In pitching mode, ignores deck.gl's `onViewStateChange` and applies pitch deltas directly to controlled `viewState` Extracted as a standalone `useTouchPitch` hook for reuse across deck.gl projects. Also adds `onError` handler + visible error overlay on DeckGL for WebGL initialization failure debugging (iPad). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 04d37a1 commit 55dc926

2 files changed

Lines changed: 154 additions & 0 deletions

File tree

www/src/App.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { KbdModal, KbdOmnibar, useHotkeysContext } from 'use-kbd'
99
import { resolve as dvcResolve } from 'virtual:dvc-data'
1010
import 'maplibre-gl/dist/maplibre-gl.css'
1111
import { useKeyboardShortcuts, type ViewState } from './useKeyboardShortcuts'
12+
import { useTouchPitch } from './useTouchPitch'
1213
import { useParcelSearch } from './useParcelSearch'
1314
import { useTheme } from './ThemeContext'
1415
import GradientEditor, {
@@ -118,6 +119,7 @@ export default function App() {
118119
const [hoveredId, setHoveredId] = useState<string | null>(null)
119120
const [hovered, setHovered] = useState<ParcelProperties | null>(null)
120121
const suppressHoverRef = useRef(false)
122+
const [webglError, setWebglError] = useState<string | null>(null)
121123
const [selectedId, setSelectedId] = useUrlState('sel', stringParam())
122124
const selectedIdRef = useRef(selectedId)
123125
selectedIdRef.current = selectedId
@@ -159,6 +161,9 @@ export default function App() {
159161
toggleTheme,
160162
})
161163

164+
// Two-finger pitch gesture for mobile (deck.gl's built-in multipan is broken)
165+
const isPitchingRef = useTouchPitch({ setViewState, maxPitch: 85 })
166+
162167
// Omnibar search over parcels
163168
const onParcelSelect = useCallback((f: ParcelFeature) => {
164169
setSelectedId(getFeatureId(f))
@@ -273,6 +278,7 @@ export default function App() {
273278
<DeckGL
274279
viewState={viewState}
275280
onViewStateChange={({ viewState: vs }) => {
281+
if (isPitchingRef.current) return
276282
const { latitude, longitude, zoom, pitch, bearing } = vs as ViewState
277283
setViewState({ latitude, longitude, zoom, pitch, bearing })
278284
}}
@@ -286,6 +292,10 @@ export default function App() {
286292
}
287293
if (window.innerWidth <= 768) setSettingsOpen(false)
288294
}}
295+
onError={(error: Error) => {
296+
console.error('DeckGL error:', error)
297+
setWebglError(error.message)
298+
}}
289299
controller={{ maxPitch: 85, touchRotate: true }}
290300
layers={layers}
291301
deviceProps={{ type: 'webgl' }}
@@ -430,6 +440,17 @@ export default function App() {
430440
)
431441
})()}
432442

443+
{webglError && (
444+
<div style={{
445+
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
446+
background: 'rgba(0,0,0,0.9)', color: '#ff6b6b', padding: '20px 30px',
447+
borderRadius: 8, fontSize: 16, maxWidth: '80vw', zIndex: 9999, textAlign: 'center',
448+
}}>
449+
<div style={{ fontWeight: 'bold', marginBottom: 8 }}>WebGL Error</div>
450+
<div style={{ fontSize: 14, color: '#ccc' }}>{webglError}</div>
451+
</div>
452+
)}
453+
433454
{/* Status bar */}
434455
<div
435456
style={{

www/src/useTouchPitch.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
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

Comments
 (0)