From d794a457f71a8632bf6e217a036acd4318e757e4 Mon Sep 17 00:00:00 2001 From: Xiaoji Chen Date: Fri, 3 Jul 2026 10:21:01 -0700 Subject: [PATCH 1/4] upgrade mjolnir.js --- modules/core/package.json | 2 +- modules/core/src/controllers/controller.ts | 210 ++++-------------- modules/core/src/lib/constants.ts | 5 +- .../core/controllers/controllers.spec.ts | 23 +- .../core/controllers/test-controller.ts | 21 ++ yarn.lock | 8 +- 6 files changed, 77 insertions(+), 192 deletions(-) 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..3ec54b3fe1f 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; @@ -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..0af30c076d8 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'; /** @@ -105,7 +105,8 @@ export const RECOGNIZERS = { pinch: [Pinch, {}, null, ['multipan']], pan: [Pan, {threshold: 1}, ['pinch'], ['multipan']], dblclick: [Tap, {event: 'dblclick', taps: 2}], - click: [Tap, {event: 'click'}, null, ['dblclick']] + click: [Tap, {event: 'click'}, null, ['dblclick']], + dblclickdrag: [DoubleClickDrag, {event: 'dblclickdrag'}] } as const; export type RecognizerOptions = { 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" From afeb3d80cd640c196e9b2b5588a777d7ce84e521 Mon Sep 17 00:00:00 2001 From: Charles Richardson Date: Fri, 3 Jul 2026 15:35:03 -0400 Subject: [PATCH 2/4] fix(core): register dblclickdrag recognizer to recognize with click/dblclick (#10418) Co-authored-by: Claude Opus 4.8 --- modules/core/src/lib/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/core/src/lib/constants.ts b/modules/core/src/lib/constants.ts index 0af30c076d8..055ac205367 100644 --- a/modules/core/src/lib/constants.ts +++ b/modules/core/src/lib/constants.ts @@ -106,7 +106,7 @@ export const RECOGNIZERS = { pan: [Pan, {threshold: 1}, ['pinch'], ['multipan']], dblclick: [Tap, {event: 'dblclick', taps: 2}], click: [Tap, {event: 'click'}, null, ['dblclick']], - dblclickdrag: [DoubleClickDrag, {event: 'dblclickdrag'}] + dblclickdrag: [DoubleClickDrag, {event: 'dblclickdrag'}, ['dblclick', 'click'], null] } as const; export type RecognizerOptions = { From 3555e8fcaa9dc600fd0015bbaf33503d246e84f0 Mon Sep 17 00:00:00 2001 From: Chris Gervang Date: Fri, 10 Jul 2026 10:32:37 -0700 Subject: [PATCH 3/4] fix(core): eliminate 300ms click delay and fix runtime controller prop changes (#10422) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(core): disable doubleClickZoom by default to eliminate 300ms click delay The click recognizer's requireFailure dependency on dblclick causes mjolnir.js to defer click events by 300ms. Disabling doubleClickZoom by default removes this delay while keeping doubleClickDragZoom available for zoom-by-drag gestures. Co-Authored-By: Claude Opus 4.6 * fix(core): also disable doubleClickDragZoom by default Same principle — once #10416 lands, dblclickdrag should be in click's requireFailure list to prevent spurious clicks before a drag-zoom. That would reintroduce the 300ms delay, so disable by default too. Co-Authored-By: Claude Opus 4.6 * fix(core): add dblclickdrag to click's requireFailure list Prevents spurious click events from firing before a double-click-drag zoom gesture is recognized. Without this, a click would emit on the first tap before the recognizer determines the user intends to drag. Co-Authored-By: Claude Opus 4.6 * test(core): add click delay unit tests for controller gesture config Tests verify through the full EventManager → recognizer pipeline that: - Click fires immediately when doubleClickZoom and doubleClickDragZoom are both disabled (the new default) - Click is delayed ~300ms when doubleClickZoom is enabled - Click is delayed ~300ms when doubleClickDragZoom is enabled Also reorders RECOGNIZERS so dblclickdrag is registered before click, which is required for click's requireFailure reference to resolve. Co-Authored-By: Claude Opus 4.6 * fix(core): ensure dblclick recognizer is disabled when doubleClickZoom is false The dblclick/dblclickdrag recognizers start with enable:false so they don't cause the 300ms click delay out of the box. Deck's picking system uses watch() (passive) for the dblclick event so it doesn't force-enable the recognizer. Only the controller's doubleClickZoom option enables it. Also adds integration tests that verify click latency at the Deck level using real pointer events dispatched to the canvas. Co-Authored-By: Claude Opus 4.6 * test(core): fix dblclick integration tests using direct pointer dispatch The dblclick tests were broken because page.mouse.dblclick() doesn't properly target the canvas inside vitest's browser iframe. Fixed by dispatching PointerEvents directly on the canvas element, matching how mjolnir's PointerEventInput listener works (pointerdown on element, pointerup on window). Each test uses a fresh Deck instance for isolation. Co-Authored-By: Claude Opus 4.6 * fix(core): propagate runtime controller prop changes to ViewManager Deck._getViews() was mutating the View instance's props.controller in-place, so ViewManager._diffViews short-circuited on identity (same object) and never triggered _rebuildViewports. Controller option changes via setProps had no effect at runtime. Fixed by cloning the view when applying the controller prop. Also adds a gesture-timing test app that measures event handling delays and demonstrates the ~300ms click latency tradeoff with doubleClickZoom. - Restore doubleClickZoom=true default (breaking change avoidance) - Add regression test for runtime controller prop propagation - Add test/apps/gesture-timing for interactive delay measurement Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- docs/api-reference/core/controller.md | 4 +- modules/core/src/controllers/controller.ts | 6 +- modules/core/src/lib/constants.ts | 8 +- modules/core/src/lib/deck.ts | 11 +- test/apps/gesture-timing/README.md | 51 ++++ test/apps/gesture-timing/app.js | 330 +++++++++++++++++++++ test/apps/gesture-timing/index.html | 59 ++++ test/apps/gesture-timing/package.json | 17 ++ test/interaction/map-controller.spec.ts | 195 +++++++++++- 9 files changed, 659 insertions(+), 22 deletions(-) create mode 100644 test/apps/gesture-timing/README.md create mode 100644 test/apps/gesture-timing/app.js create mode 100644 test/apps/gesture-timing/index.html create mode 100644 test/apps/gesture-timing/package.json diff --git a/docs/api-reference/core/controller.md b/docs/api-reference/core/controller.md index 02abfe3cf6b..9a6a41ce6f7 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. +* `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/src/controllers/controller.ts b/modules/core/src/controllers/controller.ts index 3ec54b3fe1f..6eaa533a09f 100644 --- a/modules/core/src/controllers/controller.ts +++ b/modules/core/src/controllers/controller.ts @@ -43,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; @@ -327,7 +327,7 @@ export default abstract class Controller { }); 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(); +}); From 551130d243ae19f0e743724196c5e2a4c28214ab Mon Sep 17 00:00:00 2001 From: Chris Gervang Date: Fri, 10 Jul 2026 10:45:41 -0700 Subject: [PATCH 4/4] docs(core): document onClick(tapCount=2) side-effect of doubleClickZoom Co-Authored-By: Claude Opus 4.6 --- docs/api-reference/core/controller.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-reference/core/controller.md b/docs/api-reference/core/controller.md index 9a6a41ce6f7..2bde20df44c 100644 --- a/docs/api-reference/core/controller.md +++ b/docs/api-reference/core/controller.md @@ -14,7 +14,7 @@ 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`. 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. +* `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`