Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/api-reference/core/controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion modules/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
216 changes: 43 additions & 173 deletions modules/core/src/controllers/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -122,24 +114,6 @@ export type ViewStateChangeParameters<ViewStateT = any> = {

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<ControllerState extends IViewState<ControllerState>> {
abstract get ControllerState(): ConstructorOf<ControllerState>;
abstract get transition(): TransitionProps;
Expand All @@ -163,9 +137,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
private _customEvents: string[] = [];
private _eventStartBlocked: any = null;
private _panMove: boolean = false;
private _doubleClickDragTapStart: DoubleClickDragTapState | null = null;
private _lastDoubleClickDragTap: DoubleClickDragTapState | null = null;
private _doubleClickDragZoomState: DoubleClickDragZoomState | null = null;
private _doubleClickDragAnchor: [number, number] | null = null;
private _suppressDoubleClickUntil: number = 0;

protected invertPan: boolean = false;
Expand Down Expand Up @@ -242,19 +214,10 @@ export default abstract class Controller<ControllerState extends IViewState<Cont

switch (event.type) {
case 'panstart':
if (this._doubleClickDragZoomState) {
return false;
}
return eventStartBlocked ? false : this._onPanStart(event);
case 'panmove':
if (this._doubleClickDragZoomState) {
return false;
}
return this._onPan(event);
case 'panend':
if (this._doubleClickDragZoomState) {
return false;
}
return this._onPanEnd(event);
case 'pinchstart':
return eventStartBlocked ? false : this._onPinchStart(event);
Expand All @@ -270,13 +233,13 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
return this._onMultiPanEnd(event);
case 'dblclick':
return this._onDoubleClick(event);
case 'pointerdown':
return this._onDoubleClickDragPointerDown(event);
case 'pointermove':
return this._onDoubleClickDragPointerMove(event);
case 'pointerup':
case 'pointercancel':
return this._onDoubleClickDragPointerUp(event);
case 'dblclickdragstart':
return eventStartBlocked ? false : this._onDoubleClickDragStart(event);
case 'dblclickdragmove':
return this._onDoubleClickDrag(event);
case 'dblclickdragend':
case 'dblclickdragcancel':
return this._onDoubleClickDragEnd(event);
case 'wheel':
return this._onWheel(event as MjolnirWheelEvent);
case 'keydown':
Expand Down Expand Up @@ -364,7 +327,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
dragPan = true,
dragRotate = true,
doubleClickZoom = true,
doubleClickDragZoom = true,
doubleClickDragZoom = false,
touchZoom = true,
touchRotate = false,
keyboard = true
Expand Down Expand Up @@ -693,7 +656,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont

// Default handler for the `pinchstart` event.
protected _onPinchStart(event: MjolnirGestureEvent): boolean {
this._resetDoubleClickDragZoom();
this._doubleClickDragAnchor = null;
const pos = this.getCenter(event);
if (!this.isPointInBounds(pos, event)) {
return false;
Expand Down Expand Up @@ -807,156 +770,63 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
return true;
}

protected _onDoubleClickDragPointerDown(event: MjolnirEvent): boolean {
if (!this.doubleClickDragZoom || !this._isPrimaryPointer(event)) {
this._resetDoubleClickDragZoom();
protected _onDoubleClickDragStart(event: MjolnirGestureEvent): boolean {
if (!this.doubleClickDragZoom) {
this._doubleClickDragAnchor = null;
return false;
}

const pos = this.getCenter(event as MjolnirGestureEvent);
const pos = this.getCenter(event);
if (!this.isPointInBounds(pos, event)) {
this._resetDoubleClickDragZoom();
this._doubleClickDragAnchor = null;
return false;
}

const time = this._getEventTime(event);
const pointerId = (event.srcEvent as PointerEvent).pointerId;
if (
this._lastDoubleClickDragTap &&
time - this._lastDoubleClickDragTap.time <= DOUBLE_CLICK_DRAG_INTERVAL &&
getDistance(pos, this._lastDoubleClickDragTap.pos) <= DOUBLE_CLICK_DRAG_MAX_TAP_DISTANCE
) {
this._doubleClickDragTapStart = null;
this._lastDoubleClickDragTap = null;
this._doubleClickDragZoomState = {startPos: pos, pointerId, active: false};
event.srcEvent.preventDefault();
event.stopPropagation();
return true;
}

this._doubleClickDragTapStart = {pos, time, pointerId};
this._lastDoubleClickDragTap = null;
if ((event.srcEvent as PointerEvent).pointerType === 'touch') {
event.srcEvent.preventDefault();
this._doubleClickDragAnchor = pos;
let newControllerState = this.controllerState.zoomStart({pos});
if (event.scale !== 1) {
newControllerState = newControllerState.zoom({pos, scale: event.scale});
}
return false;
this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {
isDragging: true,
isPanning: true,
isZooming: true
});
return true;
}

protected _onDoubleClickDragPointerMove(event: MjolnirEvent): boolean {
const doubleClickDragZoomState = this._doubleClickDragZoomState;
if (
!doubleClickDragZoomState ||
!this._isSamePointer(event, doubleClickDragZoomState.pointerId)
) {
protected _onDoubleClickDrag(event: MjolnirGestureEvent): boolean {
const pos = this._doubleClickDragAnchor;
if (!pos) {
return false;
}

const pos = this.getCenter(event as MjolnirGestureEvent);
const dy = doubleClickDragZoomState.startPos[1] - pos[1];
if (!doubleClickDragZoomState.active && Math.abs(dy) < DOUBLE_CLICK_DRAG_START_THRESHOLD) {
event.srcEvent.preventDefault();
event.stopPropagation();
return true;
}

const scale = Math.pow(2, dy / DOUBLE_CLICK_DRAG_PIXELS_PER_ZOOM);
const startPos = doubleClickDragZoomState.startPos;
let newControllerState = this.controllerState;
if (!doubleClickDragZoomState.active) {
doubleClickDragZoomState.active = true;
newControllerState = newControllerState.zoomStart({pos: startPos});
}
newControllerState = newControllerState.zoom({pos: startPos, scale});
const newControllerState = this.controllerState.zoom({pos, scale: event.scale});
this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {
isDragging: true,
isPanning: true,
isZooming: true
});

event.srcEvent.preventDefault();
event.stopPropagation();
return true;
}

protected _onDoubleClickDragPointerUp(event: MjolnirEvent): boolean {
const doubleClickDragZoomState = this._doubleClickDragZoomState;
if (
doubleClickDragZoomState &&
this._isSamePointer(event, doubleClickDragZoomState.pointerId)
) {
this._doubleClickDragZoomState = null;
if (doubleClickDragZoomState.active) {
const newControllerState = this.controllerState.zoomEnd();
this.updateViewport(newControllerState, null, {
isDragging: false,
isPanning: false,
isZooming: false
});
this._suppressDoubleClickUntil = Date.now() + 100;
this.blockEvents(100);
event.srcEvent.preventDefault();
event.stopPropagation();
return true;
}
protected _onDoubleClickDragEnd(_event: MjolnirGestureEvent): boolean {
if (!this._doubleClickDragAnchor) {
return false;
}

if (event.type === 'pointercancel') {
this._resetDoubleClickDragZoom();
return false;
}

const doubleClickDragTapStart = this._doubleClickDragTapStart;
if (
!doubleClickDragTapStart ||
!this._isSamePointer(event, doubleClickDragTapStart.pointerId)
) {
return false;
}

const pos = this.getCenter(event as MjolnirGestureEvent);
const time = this._getEventTime(event);
if (
time - doubleClickDragTapStart.time <= DOUBLE_CLICK_DRAG_MAX_TAP_DURATION &&
getDistance(pos, doubleClickDragTapStart.pos) <= DOUBLE_CLICK_DRAG_MAX_TAP_DISTANCE
) {
this._lastDoubleClickDragTap = {pos, time, pointerId: doubleClickDragTapStart.pointerId};
} else {
this._lastDoubleClickDragTap = null;
}
this._doubleClickDragTapStart = null;
if ((event.srcEvent as PointerEvent).pointerType === 'touch') {
event.srcEvent.preventDefault();
}
return false;
}

private _resetDoubleClickDragZoom(): void {
this._doubleClickDragTapStart = null;
this._lastDoubleClickDragTap = null;
this._doubleClickDragZoomState = null;
}

private _getEventTime(event: MjolnirEvent): number {
return (event as any).timeStamp || event.srcEvent.timeStamp || Date.now();
}

private _isPrimaryPointer(event: MjolnirEvent): boolean {
const pointers = (event as any).pointers;
if (pointers && pointers.length > 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) {
Expand Down
9 changes: 6 additions & 3 deletions modules/core/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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 = {
Expand Down
11 changes: 9 additions & 2 deletions modules/core/src/lib/deck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1087,7 +1087,13 @@ export default class Deck<ViewsT extends ViewOrViews = null> {
});

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;
Expand Down Expand Up @@ -1286,7 +1292,8 @@ export default class Deck<ViewsT extends ViewOrViews = null> {
: [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;
}
Expand Down
Loading
Loading