diff --git a/docs/api-reference/core/controller.md b/docs/api-reference/core/controller.md index 02abfe3cf6b..2bde20df44c 100644 --- a/docs/api-reference/core/controller.md +++ b/docs/api-reference/core/controller.md @@ -14,8 +14,8 @@ The base Controller class supports the following options: + `smooth` (boolean) - smoothly transition to the new zoom. If enabled, will provide a slightly lagged but smoother experience. Default `false`. * `dragPan` (boolean) - enable panning with pointer drag. Default `true` * `dragRotate` (boolean) - enable rotating with pointer drag. Default `true` -* `doubleClickZoom` (boolean) - enable zooming with double click. Default `true` -* `doubleClickDragZoom` (boolean) - enable zooming by double clicking/tapping and dragging. Default `true` +* `doubleClickZoom` (boolean) - enable zooming with double click. Default `true`. Adds ~300ms latency to click events due to the tap recognizer waiting to distinguish single clicks from double clicks. Set to `false` for immediate click response. Note: disabling also prevents `onClick` from firing with `tapCount: 2` on double-click. +* `doubleClickDragZoom` (boolean) - enable zooming by double clicking/tapping and dragging. Default `false`. Enabling adds ~300ms latency to click events due to the tap recognizer waiting to distinguish single clicks from double-click-drags. * `touchZoom` (boolean) - enable zooming with multi-touch pinch. Default `true` * `touchRotate` (boolean) - enable rotating with multi-touch. Use two-finger rotating gesture for horizontal and three-finger swiping gesture for vertical rotation. Default `false` * `keyboard` (boolean | object) - enable interaction with keyboard. Default `true`. If an object is supplied, it may contain the following fields to customize the keyboard behavior: diff --git a/modules/core/package.json b/modules/core/package.json index 96623960c74..bb5734bae79 100644 --- a/modules/core/package.json +++ b/modules/core/package.json @@ -55,7 +55,7 @@ "@probe.gl/stats": "^4.1.1", "@types/offscreencanvas": "^2019.6.4", "gl-matrix": "^3.0.0", - "mjolnir.js": "^3.0.0" + "mjolnir.js": "^3.0.1" }, "gitHead": "13ace64fc2cee08c133afc882fc307253489a4e4" } diff --git a/modules/core/src/controllers/controller.ts b/modules/core/src/controllers/controller.ts index e4d281f550e..6eaa533a09f 100644 --- a/modules/core/src/controllers/controller.ts +++ b/modules/core/src/controllers/controller.ts @@ -20,21 +20,13 @@ const NO_TRANSITION_PROPS = { const DEFAULT_INERTIA = 300; const INERTIA_EASING = t => 1 - (1 - t) * (1 - t); -// One-finger double-click/tap-drag-to-zoom gesture (matches Google Maps). -// Empirical values chosen to feel snappy but reject accidental triggers. -const DOUBLE_CLICK_DRAG_INTERVAL = 500; -const DOUBLE_CLICK_DRAG_MAX_TAP_DURATION = 350; -const DOUBLE_CLICK_DRAG_MAX_TAP_DISTANCE = 28; -const DOUBLE_CLICK_DRAG_START_THRESHOLD = 1; -const DOUBLE_CLICK_DRAG_PIXELS_PER_ZOOM = 120; - const EVENT_TYPES = { WHEEL: ['wheel'], PAN: ['panstart', 'panmove', 'panend'], PINCH: ['pinchstart', 'pinchmove', 'pinchend'], MULTI_PAN: ['multipanstart', 'multipanmove', 'multipanend'], DOUBLE_CLICK: ['dblclick'], - DOUBLE_CLICK_DRAG: ['pointerdown', 'pointermove', 'pointerup', 'pointercancel'], + DOUBLE_CLICK_DRAG: ['dblclickdragstart', 'dblclickdragmove', 'dblclickdragend', 'dblclickdragcancel'], KEYBOARD: ['keydown'] } as const; @@ -51,9 +43,9 @@ export type ControllerOptions = { dragPan?: boolean; /** Enable rotating with pointer drag. Default `true` */ dragRotate?: boolean; - /** Enable zooming with double click. Default `true` */ + /** Enable zooming with double click. Default `false`. Enabling adds ~300ms latency to click events. */ doubleClickZoom?: boolean; - /** Enable zooming with double click/tap and drag. Default `true` */ + /** Enable zooming with double click/tap and drag. Default `false`. Enabling adds ~300ms latency to click events. */ doubleClickDragZoom?: boolean; /** Enable zooming with multi-touch pinch. Default `true` */ touchZoom?: boolean; @@ -122,24 +114,6 @@ export type ViewStateChangeParameters = { const pinchEventWorkaround: any = {}; -type DoubleClickDragTapState = { - pos: [number, number]; - time: number; - pointerId?: number; -}; - -type DoubleClickDragZoomState = { - startPos: [number, number]; - pointerId?: number; - active: boolean; -}; - -function getDistance(a: [number, number], b: [number, number]): number { - const dx = a[0] - b[0]; - const dy = a[1] - b[1]; - return Math.sqrt(dx * dx + dy * dy); -} - export default abstract class Controller> { abstract get ControllerState(): ConstructorOf; abstract get transition(): TransitionProps; @@ -163,9 +137,7 @@ export default abstract class Controller 1) { - return false; - } - const srcEvent = event.srcEvent as PointerEvent; - if (srcEvent.pointerType === 'mouse') { - return (event as any).leftButton !== false; - } + this._doubleClickDragAnchor = null; + const newControllerState = this.controllerState.zoomEnd(); + this.updateViewport(newControllerState, null, { + isDragging: false, + isPanning: false, + isZooming: false + }); + this._suppressDoubleClickUntil = Date.now() + 100; + this.blockEvents(100); return true; } - private _isSamePointer(event: MjolnirEvent, pointerId?: number): boolean { - return pointerId === undefined || (event.srcEvent as PointerEvent).pointerId === pointerId; - } - // Default handler for the `keydown` event protected _onKeyDown(event: MjolnirKeyEvent): boolean { if (!this.keyboard) { diff --git a/modules/core/src/lib/constants.ts b/modules/core/src/lib/constants.ts index b6661dffa89..a899bc76df6 100644 --- a/modules/core/src/lib/constants.ts +++ b/modules/core/src/lib/constants.ts @@ -3,7 +3,7 @@ // Copyright (c) vis.gl contributors import log from '../utils/log'; -import {Pan, InputDirection, Pinch, Tap} from 'mjolnir.js'; +import {Pan, DoubleClickDrag, InputDirection, Pinch, Tap} from 'mjolnir.js'; import type {PanRecognizerOptions, PinchRecognizerOptions, TapRecognizerOptions} from 'mjolnir.js'; /** @@ -100,12 +100,15 @@ export const EVENT_HANDLERS = { panend: 'onDragEnd' } as const satisfies {[eventName: string]: string}; +// Order matters: recognizeWith/requireFailure resolve by name, so a recognizer +// must be registered before any later entry can reference it. export const RECOGNIZERS = { multipan: [Pan, {threshold: 10, direction: InputDirection.Vertical, pointers: 2}], pinch: [Pinch, {}, null, ['multipan']], pan: [Pan, {threshold: 1}, ['pinch'], ['multipan']], - dblclick: [Tap, {event: 'dblclick', taps: 2}], - click: [Tap, {event: 'click'}, null, ['dblclick']] + dblclick: [Tap, {event: 'dblclick', taps: 2, enable: false}], + dblclickdrag: [DoubleClickDrag, {event: 'dblclickdrag', enable: false}, ['dblclick'], null], + click: [Tap, {event: 'click'}, ['dblclickdrag'], ['dblclick', 'dblclickdrag']] } as const; export type RecognizerOptions = { diff --git a/modules/core/src/lib/deck.ts b/modules/core/src/lib/deck.ts index 9f52a3fe432..9aac92ff376 100644 --- a/modules/core/src/lib/deck.ts +++ b/modules/core/src/lib/deck.ts @@ -1087,7 +1087,13 @@ export default class Deck { }); for (const eventType in EVENT_HANDLERS) { - eventManager.on(eventType, this._onEvent); + if (eventType === 'dblclick') { + // Use watch (passive) so the dblclick recognizer is only enabled by the + // controller's doubleClickZoom option — not by the picking system. + eventManager.watch(eventType, this._onEvent); + } else { + eventManager.on(eventType, this._onEvent); + } } return eventManager; @@ -1286,7 +1292,8 @@ export default class Deck { : [new MapView({id: 'default-view'})]; if (normalizedViews.length && this.props.controller) { // Backward compatibility: support controller prop - normalizedViews[0].props.controller = this.props.controller; + // Clone the view so that ViewManager._diffViews detects the change + normalizedViews[0] = normalizedViews[0].clone({controller: this.props.controller}); } return normalizedViews; } diff --git a/test/apps/gesture-timing/README.md b/test/apps/gesture-timing/README.md new file mode 100644 index 00000000000..7dff270e52f --- /dev/null +++ b/test/apps/gesture-timing/README.md @@ -0,0 +1,51 @@ +# Gesture Timing Test App + +Interactive tool for measuring deck.gl event handling delays, specifically the ~300ms click latency introduced by the `doubleClickZoom` controller option. + +## Run + +```bash +cd test/apps/gesture-timing +npm run start-local +``` + +## Reading the Event Log + +Each row in the event timeline shows: + +| Column | Meaning | +|--------|---------| +| **Event type** | The recognized gesture or picking event | +| **Delta (ms)** | Latency measurement (see below) | +| **Timestamp** | Wall-clock time | + +### Color coding + +- **Green (<50ms)** — Immediate response, no recognizer delay +- **Yellow (50–200ms)** — Noticeable but sub-threshold delay +- **Red (>300ms)** — Significant delay (typically the dblclick `requireFailure` timeout) + +### What delta means per event type + +| Event pattern | Delta measures | +|---------------|----------------| +| `click`, `dblclick`, `pick:click(tap=N)` | **Recognizer delay** — time from `pointerup` to recognized event. This is the ~0ms vs ~300ms this PR addresses. | +| `panstart`, `pinchstart`, `dblclickdragstart` | **Activation delay** — time from `pointerdown` to gesture recognition (usually just the movement threshold). | +| `panmove`, `pinchmove`, `dblclickdragmove` | **Frame interval** — time between consecutive move events (~16ms at 60fps). | +| `panend`, `pinchend`, `dblclickdragend` | **Gesture duration** — total time from start to end of the gesture. | +| `wheel`, `keydown` | **Processing delay** — time from raw DOM event to EventManager dispatch (~0ms, no recognizer). | +| `pick:dragstart`, `pick:dragend` | **Picking callback delay** — time from `pointerdown`/`pointerup` to layer callback. | + +### Key things to observe + +1. **Click with `doubleClickZoom: true` (default)** — click events show ~300ms (red). The Tap recognizer waits to see if a double-click follows. + +2. **Click with `doubleClickZoom: false`** — click events show ~1-5ms (green). No `requireFailure` delay. + +3. **`pick:click(tap=2)` only appears when `doubleClickZoom` is on** — the dblclick recognizer must be active for the picking system to receive double-click events (PR #9629). + +4. **Runtime toggling** — use the checkboxes or preset buttons to toggle `doubleClickZoom` at runtime and see the click delay change immediately. + +## Click Latency Stats + +The stats panel at the top shows running avg/min/max of the last 20 `click` events. Use preset buttons to quickly compare Default (~300ms avg) vs Fast Clicks (~3ms avg). diff --git a/test/apps/gesture-timing/app.js b/test/apps/gesture-timing/app.js new file mode 100644 index 00000000000..8b507b029b7 --- /dev/null +++ b/test/apps/gesture-timing/app.js @@ -0,0 +1,330 @@ +import {Deck, MapView} from '@deck.gl/core'; +import {ScatterplotLayer} from '@deck.gl/layers'; + +// --- State --- + +const controllerOptions = { + doubleClickZoom: true, + doubleClickDragZoom: false, + dragPan: true, + dragRotate: true, + scrollZoom: true, + touchZoom: true, + touchRotate: false, + keyboard: true, + inertia: false +}; + +const INITIAL_VIEW_STATE = { + longitude: -122.45, + latitude: 37.78, + zoom: 11, + pitch: 30, + bearing: 0 +}; + +const timing = { + lastPointerDown: 0, + lastPointerUp: 0, + lastWheel: 0, + lastKeyDown: 0, + lastMoveTime: {}, + gestureStartTime: {}, + log: [], + clickDelays: [] +}; + +// --- Deck Setup --- + +const DATA = []; +for (let i = 0; i < 20; i++) { + for (let j = 0; j < 20; j++) { + DATA.push({ + position: [-122.45 + i * 0.005, 37.78 + j * 0.005], + radius: 50 + (((i * 20 + j) * 7) % 100), + color: [70 + ((i * 13) % 180), 100 + ((j * 17) % 155), 200] + }); + } +} + +let viewState = {...INITIAL_VIEW_STATE}; + +const deck = new Deck({ + parent: document.getElementById('deck-container'), + views: new MapView(), + initialViewState: INITIAL_VIEW_STATE, + controller: {...controllerOptions}, + onViewStateChange: ({viewState: vs}) => { + viewState = vs; + deck.setProps({viewState}); + }, + onClick: (info, event) => { + const tapCount = event.tapCount || 1; + onGesture(`pick:click(tap=${tapCount})`); + }, + onDragStart: () => onGesture('pick:dragstart'), + onDragEnd: () => onGesture('pick:dragend'), + layers: [ + new ScatterplotLayer({ + id: 'scatter', + data: DATA, + pickable: true, + getPosition: d => d.position, + getRadius: d => d.radius, + getFillColor: d => d.color, + radiusMinPixels: 5, + radiusMaxPixels: 30 + }) + ], + onLoad: () => setupTimingListeners() +}); + +// --- Timing Measurement --- + +function setupTimingListeners() { + const canvas = deck.getCanvas(); + const eventManager = deck.getEventManager(); + + // Raw DOM events — capture phase for earliest possible timestamp + canvas.addEventListener( + 'pointerdown', + () => { + timing.lastPointerDown = performance.now(); + }, + {capture: true} + ); + + canvas.addEventListener( + 'pointerup', + () => { + timing.lastPointerUp = performance.now(); + }, + {capture: true} + ); + + canvas.addEventListener( + 'wheel', + () => { + timing.lastWheel = performance.now(); + }, + {capture: true} + ); + + canvas.addEventListener( + 'keydown', + () => { + timing.lastKeyDown = performance.now(); + }, + {capture: true} + ); + + // Recognized gesture events — all passive (watch) to avoid affecting recognizer state + const gestureEvents = [ + 'click', + 'dblclick', + 'panstart', + 'panmove', + 'panend', + 'pinchstart', + 'pinchmove', + 'pinchend', + 'dblclickdragstart', + 'dblclickdragmove', + 'dblclickdragend' + ]; + + for (const eventType of gestureEvents) { + eventManager.watch(eventType, () => onGesture(eventType)); + } + + // Wheel and keyboard go through EventManager too + eventManager.watch('wheel', () => onGesture('wheel')); + eventManager.watch('keydown', () => onGesture('keydown')); +} + +function onGesture(type) { + const now = performance.now(); + let delta = null; + + if (type === 'click' || type === 'dblclick' || type.startsWith('pick:click')) { + // Recognizer delay: time from pointerup to recognized click + delta = timing.lastPointerUp ? now - timing.lastPointerUp : null; + } else if (type === 'wheel') { + delta = timing.lastWheel ? now - timing.lastWheel : null; + } else if (type === 'keydown') { + delta = timing.lastKeyDown ? now - timing.lastKeyDown : null; + } else if (type.endsWith('start')) { + // Start events: delay from pointerdown to gesture recognition + delta = timing.lastPointerDown ? now - timing.lastPointerDown : null; + } else if (type.endsWith('move')) { + // Move events: frame interval (time since last move of same type) + const lastMove = timing.lastMoveTime[type]; + delta = lastMove ? now - lastMove : null; + timing.lastMoveTime[type] = now; + } else if (type.endsWith('end') || type.endsWith('cancel')) { + // End events: total gesture duration from start + const startKey = type.replace(/end$|cancel$/, 'start'); + const startTime = timing.gestureStartTime[startKey]; + delta = startTime ? now - startTime : null; + } + + // Track gesture start times for duration measurement + if (type.endsWith('start')) { + timing.gestureStartTime[type] = now; + } + + const entry = {type, delta, time: now}; + timing.log.unshift(entry); + if (timing.log.length > 80) timing.log.pop(); + + if (type === 'click' && delta !== null) { + timing.clickDelays.unshift(delta); + if (timing.clickDelays.length > 20) timing.clickDelays.pop(); + } + + renderLog(); + renderStats(); +} + +// --- UI Rendering --- + +function renderControls() { + const section = document.getElementById('controls-section'); + const toggles = [ + {key: 'doubleClickZoom', label: 'doubleClickZoom', hint: '+300ms / enables onClick(tap=2)'}, + {key: 'doubleClickDragZoom', label: 'doubleClickDragZoom', hint: '+300ms click delay'}, + {key: 'dragPan', label: 'dragPan'}, + {key: 'dragRotate', label: 'dragRotate'}, + {key: 'scrollZoom', label: 'scrollZoom'}, + {key: 'touchZoom', label: 'touchZoom'}, + {key: 'touchRotate', label: 'touchRotate'}, + {key: 'keyboard', label: 'keyboard'}, + {key: 'inertia', label: 'inertia'} + ]; + + let html = '

Controller Options

'; + for (const {key, label, hint} of toggles) { + const checked = controllerOptions[key] ? 'checked' : ''; + const hintHtml = hint ? `${hint}` : ''; + html += ``; + } + html += `
+ Note: doubleClickZoom controls both zoom-on-dblclick AND whether + onClick fires with tapCount=2 on double-click (PR #9629). Disabling it + gives instant clicks but loses onClick(tap=2). +
`; + section.innerHTML = html; + + section.querySelectorAll('input[type="checkbox"]').forEach(cb => { + cb.addEventListener('change', () => { + const key = cb.dataset.key; + if (key === 'inertia') { + controllerOptions[key] = cb.checked ? 300 : false; + } else { + controllerOptions[key] = cb.checked; + } + deck.setProps({controller: {...controllerOptions}}); + }); + }); +} + +function renderPresets() { + const section = document.getElementById('presets-section'); + section.innerHTML = `

Presets

+
+ + + +
`; + + document.getElementById('preset-default').addEventListener('click', () => { + controllerOptions.doubleClickZoom = true; + controllerOptions.doubleClickDragZoom = false; + deck.setProps({controller: {...controllerOptions}}); + timing.clickDelays.length = 0; + renderControls(); + }); + + document.getElementById('preset-fast').addEventListener('click', () => { + controllerOptions.doubleClickZoom = false; + controllerOptions.doubleClickDragZoom = false; + deck.setProps({controller: {...controllerOptions}}); + timing.clickDelays.length = 0; + renderControls(); + }); + + document.getElementById('preset-clear').addEventListener('click', () => { + timing.log.length = 0; + timing.clickDelays.length = 0; + renderLog(); + renderStats(); + }); +} + +function renderStats() { + const section = document.getElementById('stats-section'); + const delays = timing.clickDelays; + + if (delays.length === 0) { + section.innerHTML = `

Click Latency

+
+
avg
+
min
+
max
+
`; + return; + } + + const avg = delays.reduce((s, d) => s + d, 0) / delays.length; + const min = Math.min(...delays); + const max = Math.max(...delays); + + const colorClass = avg < 50 ? 'color-green' : avg < 200 ? 'color-yellow' : 'color-red'; + + section.innerHTML = `

Click Latency (n=${delays.length})

+
+
${avg.toFixed(0)}ms
avg
+
${min.toFixed(0)}ms
min
+
${max.toFixed(0)}ms
max
+
`; +} + +function renderLog() { + const section = document.getElementById('event-log'); + let html = '

Event Timeline

'; + + for (const entry of timing.log.slice(0, 50)) { + const colorClass = getColorClass(entry.delta); + const deltaStr = entry.delta !== null ? `${entry.delta.toFixed(1)}ms` : '—'; + const timeStr = formatTime(entry.time); + html += `
+ ${entry.type} + ${deltaStr} + ${timeStr} +
`; + } + + section.innerHTML = html; +} + +function getColorClass(delta) { + if (delta === null) return 'color-gray'; + if (delta < 50) return 'color-green'; + if (delta < 200) return 'color-yellow'; + return 'color-red'; +} + +function formatTime(perfNow) { + const d = new Date(performance.timeOrigin + perfNow); + return d.toISOString().slice(11, 23); +} + +// --- Init --- + +renderControls(); +renderPresets(); +renderStats(); +renderLog(); diff --git a/test/apps/gesture-timing/index.html b/test/apps/gesture-timing/index.html new file mode 100644 index 00000000000..c0ee21fea23 --- /dev/null +++ b/test/apps/gesture-timing/index.html @@ -0,0 +1,59 @@ + + + + + deck.gl - Gesture Timing + + + +
+
+
+
+
+
+
+
+
+ + + diff --git a/test/apps/gesture-timing/package.json b/test/apps/gesture-timing/package.json new file mode 100644 index 00000000000..053465fde00 --- /dev/null +++ b/test/apps/gesture-timing/package.json @@ -0,0 +1,17 @@ +{ + "name": "deckgl-test-gesture-timing", + "version": "0.0.0", + "private": true, + "license": "MIT", + "scripts": { + "start": "vite --open", + "start-local": "vite --config ../vite.config.local.mjs" + }, + "dependencies": { + "@deck.gl/core": "^9.0.0", + "@deck.gl/layers": "^9.0.0" + }, + "devDependencies": { + "vite": "^7.3.1" + } +} diff --git a/test/interaction/map-controller.spec.ts b/test/interaction/map-controller.spec.ts index 2d4b45e3444..85dfa084560 100644 --- a/test/interaction/map-controller.spec.ts +++ b/test/interaction/map-controller.spec.ts @@ -19,6 +19,55 @@ async function emulateEvent(event: any): Promise { } } +function getCanvas(): HTMLCanvasElement { + const canvases = Array.from(document.querySelectorAll('canvas')); + const canvas = canvases.reverse().find(el => { + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }); + if (!canvas) throw new Error('No canvas found'); + return canvas; +} + +async function dispatchPointerTap(x: number, y: number, opts?: {shiftKey?: boolean}) { + const canvas = getCanvas(); + const shiftKey = opts?.shiftKey ?? false; + const down = new PointerEvent('pointerdown', { + clientX: x, + clientY: y, + bubbles: true, + cancelable: true, + pointerId: 1, + pointerType: 'mouse', + isPrimary: true, + button: 0, + buttons: 1, + shiftKey + }); + canvas.dispatchEvent(down); + await sleep(10); + const up = new PointerEvent('pointerup', { + clientX: x, + clientY: y, + bubbles: true, + cancelable: true, + pointerId: 1, + pointerType: 'mouse', + isPrimary: true, + button: 0, + buttons: 0, + shiftKey + }); + // PointerEventInput listens for pointerup on window, not on the element + window.dispatchEvent(up); +} + +async function dispatchPointerDoubleTap(x: number, y: number, opts?: {shiftKey?: boolean}) { + await dispatchPointerTap(x, y, opts); + await sleep(50); + await dispatchPointerTap(x, y, opts); +} + // Shared Deck instance and state let deck: Deck | null = null; @@ -122,25 +171,41 @@ test('MapController rotate', async () => { }); test('MapController dblclick zoom in', async () => { - await resetViewState(); - const oldViewport = getViewport(); + const testDeck = new Deck({ + ...deckProps, + id: 'dblclick-zoom-in-deck', + controller: {doubleClickZoom: true} + }); + await new Promise(resolve => { + testDeck.setProps({onLoad: resolve}); + }); - await emulateEvent({type: 'dblclick', x: 200, y: 100}); - await emulateEvent({wait: 300}); + const oldZoom = testDeck.getViewports()[0].zoom; + await dispatchPointerDoubleTap(200, 100); + await sleep(500); - const newViewport = getViewport(); - expect(newViewport.zoom > oldViewport.zoom, 'map zoomed in').toBeTruthy(); + const newZoom = testDeck.getViewports()[0].zoom; + testDeck.finalize(); + expect(newZoom > oldZoom, 'map zoomed in').toBeTruthy(); }); test('MapController shift-dblclick zoom out', async () => { - await resetViewState(); - const oldViewport = getViewport(); + const testDeck = new Deck({ + ...deckProps, + id: 'dblclick-zoom-out-deck', + controller: {doubleClickZoom: true} + }); + await new Promise(resolve => { + testDeck.setProps({onLoad: resolve}); + }); - await emulateEvent({type: 'dblclick', x: 200, y: 100, shiftKey: true}); - await emulateEvent({wait: 300}); + const oldZoom = testDeck.getViewports()[0].zoom; + await dispatchPointerDoubleTap(200, 100, {shiftKey: true}); + await sleep(500); - const newViewport = getViewport(); - expect(newViewport.zoom < oldViewport.zoom, 'map zoomed out').toBeTruthy(); + const newZoom = testDeck.getViewports()[0].zoom; + testDeck.finalize(); + expect(newZoom < oldZoom, 'map zoomed out').toBeTruthy(); }); test('MapController keyboard left', async () => { @@ -208,3 +273,109 @@ test('MapController keyboard shift-plus zoom in', async () => { const newViewport = getViewport(); expect(newViewport.zoom > oldViewport.zoom, 'map zoomed').toBeTruthy(); }); + +test('MapController click fires immediately when doubleClickZoom is disabled', async () => { + const testDeck = new Deck({ + ...deckProps, + id: 'click-test-deck', + controller: {doubleClickZoom: false} + }); + await new Promise(resolve => { + testDeck.setProps({onLoad: resolve}); + }); + + const timestamps: number[] = []; + const eventManager = testDeck.getEventManager()!; + + const handler = () => { + timestamps.push(performance.now()); + }; + eventManager.on('click', handler); + + const beforeClick = performance.now(); + await dispatchPointerTap(400, 200); + await sleep(50); + + eventManager.off('click', handler); + testDeck.finalize(); + + expect(timestamps.length, 'click event fired').toBeGreaterThan(0); + const delay = timestamps[0] - beforeClick; + expect(delay, 'click fired without 300ms recognizer delay').toBeLessThan(100); +}); + +test('MapController click is delayed when doubleClickZoom is enabled', async () => { + const testDeck = new Deck({ + ...deckProps, + id: 'click-delay-test-deck', + controller: {doubleClickZoom: true} + }); + await new Promise(resolve => { + testDeck.setProps({onLoad: resolve}); + }); + + const timestamps: number[] = []; + const eventManager = testDeck.getEventManager()!; + + const handler = () => { + timestamps.push(performance.now()); + }; + eventManager.on('click', handler); + + const beforeClick = performance.now(); + await dispatchPointerTap(400, 200); + await sleep(100); + + expect(timestamps.length, 'click has not fired within 100ms').toBe(0); + + await sleep(250); + + expect(timestamps.length, 'click fired after recognizer delay').toBeGreaterThan(0); + const delay = timestamps[0] - beforeClick; + expect(delay, 'click took at least 250ms (300ms recognizer delay)').toBeGreaterThan(250); + + eventManager.off('click', handler); + testDeck.finalize(); +}); + +test('MapController runtime controller prop change takes effect', async () => { + // Start with doubleClickZoom enabled (click is delayed ~300ms) + const testDeck = new Deck({ + ...deckProps, + id: 'runtime-toggle-deck', + controller: {doubleClickZoom: true} + }); + await new Promise(resolve => { + testDeck.setProps({onLoad: resolve}); + }); + + const timestamps: number[] = []; + const eventManager = testDeck.getEventManager()!; + const handler = () => { + timestamps.push(performance.now()); + }; + eventManager.on('click', handler); + + // Confirm click is delayed with doubleClickZoom on + await dispatchPointerTap(400, 200); + await sleep(100); + expect(timestamps.length, 'click delayed with doubleClickZoom:true').toBe(0); + await sleep(300); + timestamps.length = 0; + + // Toggle doubleClickZoom off at runtime + testDeck.setProps({controller: {doubleClickZoom: false}}); + await sleep(50); + + // Click should now fire immediately + const beforeClick = performance.now(); + await dispatchPointerTap(400, 200); + await sleep(50); + + expect(timestamps.length, 'click fires after runtime toggle').toBeGreaterThan(0); + const delay = timestamps[0] - beforeClick; + expect(delay, 'click is immediate after disabling doubleClickZoom').toBeLessThan(100); + + eventManager.off('click', handler); + testDeck.finalize(); +}); diff --git a/test/modules/core/controllers/controllers.spec.ts b/test/modules/core/controllers/controllers.spec.ts index 01393fa3bc0..c32ca06a5f3 100644 --- a/test/modules/core/controllers/controllers.spec.ts +++ b/test/modules/core/controllers/controllers.spec.ts @@ -14,14 +14,11 @@ import {Timeline} from '@luma.gl/engine'; import testController, {createTestController} from './test-controller'; -const makePointerEvent = (type: string, y: number, timeStamp: number, pointerId: number = 1) => ({ +const makeDoubleClickDragEvent = (type: string, y: number, scale: number = 1) => ({ type, offsetCenter: {x: 50, y}, - timeStamp, + scale, srcEvent: { - pointerId, - pointerType: 'touch', - timeStamp, preventDefault() {} }, stopPropagation() {} @@ -63,13 +60,11 @@ test('MapController supports double-click drag zoom when double click and touch } }); - controller.handleEvent(makePointerEvent('pointerdown', 50, 0) as any); - controller.handleEvent(makePointerEvent('pointerup', 50, 50) as any); - controller.handleEvent(makePointerEvent('pointerdown', 50, 120) as any); - controller.handleEvent(makePointerEvent('pointermove', 20, 150) as any); + controller.handleEvent(makeDoubleClickDragEvent('dblclickdragstart', 50, 1.1) as any); + controller.handleEvent(makeDoubleClickDragEvent('dblclickdragmove', 20, 1.3) as any); const zoomAfterMove = controller.props.zoom; - controller.handleEvent(makePointerEvent('pointerup', 20, 180) as any); + controller.handleEvent(makeDoubleClickDragEvent('dblclickdragend', 20, 1.3) as any); expect(zoomAfterMove, 'dragging up after double click zooms in').toBeGreaterThan(10); expect(controller.props.zoom, 'release should not change zoom').toBeCloseTo(zoomAfterMove); @@ -88,11 +83,9 @@ test('MapController disables double-click drag zoom', () => { } }); - controller.handleEvent(makePointerEvent('pointerdown', 50, 0) as any); - controller.handleEvent(makePointerEvent('pointerup', 50, 50) as any); - controller.handleEvent(makePointerEvent('pointerdown', 50, 120) as any); - controller.handleEvent(makePointerEvent('pointermove', 20, 150) as any); - controller.handleEvent(makePointerEvent('pointerup', 20, 180) as any); + controller.handleEvent(makeDoubleClickDragEvent('dblclickdragstart', 50, 1.1) as any); + controller.handleEvent(makeDoubleClickDragEvent('dblclickdragmove', 20, 1.3) as any); + controller.handleEvent(makeDoubleClickDragEvent('dblclickdragend', 20, 1.3) as any); expect(controller.props.zoom, 'double-click drag zoom stays disabled').toBe(10); }); diff --git a/test/modules/core/controllers/test-controller.ts b/test/modules/core/controllers/test-controller.ts index 4f5d6da1be5..dae1caf0866 100644 --- a/test/modules/core/controllers/test-controller.ts +++ b/test/modules/core/controllers/test-controller.ts @@ -167,6 +167,13 @@ const TEST_CASES = [ viewStateChanges: 1, interactionStates: 2 }, + { + title: 'dblclickdrag', + props: {}, + events: () => makeEvents(['dblclickdragstart', 'dblclickdragmove', 'dblclickdragend']), + viewStateChanges: 3, + interactionStates: 3 + }, { title: 'dblclick#out of bounds', props: {x: 200}, @@ -174,6 +181,13 @@ const TEST_CASES = [ viewStateChanges: 0, interactionStates: 0 }, + { + title: 'dblclickdrag#out of bounds', + props: {x: 200}, + events: () => makeEvents(['dblclickdragstart', 'dblclickdragmove', 'dblclickdragend']), + viewStateChanges: 0, + interactionStates: 0 + }, { title: 'dblclick#disabled', props: {doubleClickZoom: false}, @@ -181,6 +195,13 @@ const TEST_CASES = [ viewStateChanges: 0, interactionStates: 0 }, + { + title: 'dblclickdrag#disabled', + props: {doubleClickDragZoom: false}, + events: () => makeEvents(['dblclickdragstart', 'dblclickdragmove', 'dblclickdragend']), + viewStateChanges: 0, + interactionStates: 0 + }, { title: 'keyboard', diff --git a/yarn.lock b/yarn.lock index 0d36a054b98..b25c96b7d6c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7929,10 +7929,10 @@ mitt@^3.0.1: resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1" integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw== -mjolnir.js@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/mjolnir.js/-/mjolnir.js-3.0.0.tgz#2140362331c421501c43be2190479ea9cdbade4b" - integrity sha512-siX3YCG7N2HnmN1xMH3cK4JkUZJhbkhRFJL+G5N1vH0mh1t5088rJknIoqDFWDIU6NPGvRRgLnYW3ZHjSMEBLA== +mjolnir.js@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mjolnir.js/-/mjolnir.js-3.0.1.tgz#68c87869a277d9b96f41b642734d269273034c4a" + integrity sha512-/RMi8Jm3NKleOkVI8D2ai+1OVwtfRJsSVBVjbTXNm83nfsN4uORYaN3u1/hsg5CqVI+di8enTkvgDNDOywn6cQ== mkdirp@^1.0.3: version "1.0.4"