• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

visgl / deck.gl / 27321282016

11 Jun 2026 03:12AM UTC coverage: 83.386%. Remained the same
27321282016

push

github

web-flow
feat(core): doubleClickDragZoom gesture (#10327)

8006 of 10086 branches covered (79.38%)

Branch coverage included in aggregate %.

92 of 107 new or added lines in 1 file covered. (85.98%)

14288 of 16650 relevant lines covered (85.81%)

19239.43 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.27
/modules/core/src/controllers/controller.ts
1
// deck.gl
2
// SPDX-License-Identifier: MIT
3
// Copyright (c) vis.gl contributors
4

5
/* eslint-disable max-statements, complexity */
6
import TransitionManager, {TransitionProps} from './transition-manager';
7
import LinearInterpolator from '../transitions/linear-interpolator';
8
import {IViewState} from './view-state';
9
import {ConstructorOf} from '../types/types';
10
import {deepEqual} from '../utils/deep-equal';
11

12
import type Viewport from '../viewports/viewport';
13

14
import type {EventManager, MjolnirEvent, MjolnirGestureEvent, MjolnirWheelEvent, MjolnirKeyEvent} from 'mjolnir.js';
15
import type {Timeline} from '@luma.gl/engine';
16

17
const NO_TRANSITION_PROPS = {
4✔
18
  transitionDuration: 0
19
} as const;
20

21
const DEFAULT_INERTIA = 300;
4✔
22
const INERTIA_EASING = t => 1 - (1 - t) * (1 - t);
16✔
23
// One-finger double-click/tap-drag-to-zoom gesture (matches Google Maps).
24
// Empirical values chosen to feel snappy but reject accidental triggers.
25
const DOUBLE_CLICK_DRAG_INTERVAL = 500;
4✔
26
const DOUBLE_CLICK_DRAG_MAX_TAP_DURATION = 350;
4✔
27
const DOUBLE_CLICK_DRAG_MAX_TAP_DISTANCE = 28;
4✔
28
const DOUBLE_CLICK_DRAG_START_THRESHOLD = 1;
4✔
29
const DOUBLE_CLICK_DRAG_PIXELS_PER_ZOOM = 120;
4✔
30

31
const EVENT_TYPES = {
4✔
32
  WHEEL: ['wheel'],
33
  PAN: ['panstart', 'panmove', 'panend'],
34
  PINCH: ['pinchstart', 'pinchmove', 'pinchend'],
35
  MULTI_PAN: ['multipanstart', 'multipanmove', 'multipanend'],
36
  DOUBLE_CLICK: ['dblclick'],
37
  DOUBLE_CLICK_DRAG: ['pointerdown', 'pointermove', 'pointerup', 'pointercancel'],
38
  KEYBOARD: ['keydown']
39
} as const;
40

41
/** Configuration of how user input is handled */
42
export type ControllerOptions = {
43
  /** Enable zooming with mouse wheel. Default `true`. */
44
  scrollZoom?: boolean | {
45
    /** Scaler that translates wheel delta to the change of viewport scale. Default `0.01`. */
46
    speed?: number;
47
    /** Smoothly transition to the new zoom. If enabled, will provide a slightly lagged but smoother experience. Default `false`. */
48
    smooth?: boolean
49
  };
50
  /** Enable panning with pointer drag. Default `true` */
51
  dragPan?: boolean;
52
  /** Enable rotating with pointer drag. Default `true` */
53
  dragRotate?: boolean;
54
  /** Enable zooming with double click. Default `true` */
55
  doubleClickZoom?: boolean;
56
  /** Enable zooming with double click/tap and drag. Default `true` */
57
  doubleClickDragZoom?: boolean;
58
  /** Enable zooming with multi-touch pinch. Default `true` */
59
  touchZoom?: boolean;
60
  /** Enable rotating with multi-touch. Use two-finger rotating gesture for horizontal and three-finger swiping gesture for vertical rotation. Default `false` */
61
  touchRotate?: boolean;
62
  /** Enable interaction with keyboard. Default `true`. */
63
  keyboard?:
64
    | boolean
65
    | {
66
        /** Speed of zoom using +/- keys. Default `2` */
67
        zoomSpeed?: number;
68
        /** Speed of movement using arrow keys, in pixels. */
69
        moveSpeed?: number;
70
        /** Speed of rotation using shift + left/right arrow keys, in degrees. Default 15. */
71
        rotateSpeedX?: number;
72
        /** Speed of rotation using shift + up/down arrow keys, in degrees. Default 10. */
73
        rotateSpeedY?: number;
74
      };
75
  /** Drag behavior without pressing function keys, one of `pan` and `rotate`. */
76
  dragMode?: 'pan' | 'rotate';
77
  /** Enable inertia after panning/pinching. If a number is provided, indicates the duration of time over which the velocity reduces to zero, in milliseconds. Default `false`. */
78
  inertia?: boolean | number;
79
  /** Bounding box of content that the controller is constrained in */
80
  maxBounds?: [min: [number, number], max: [number, number]] | [min: [number, number, number], max: [number, number, number]] | null;
81
};
82

83
export type ControllerProps = {
84
  /** Identifier of the controller */
85
  id: string;
86
  /** Viewport x position */
87
  x: number;
88
  /** Viewport y position */
89
  y: number;
90
  /** Viewport width */
91
  width: number;
92
  /** Viewport height */
93
  height: number;
94
} & ControllerOptions & TransitionProps;
95

96
/** The state of a controller */
97
export type InteractionState = {
98
  /** If the view state is in transition */
99
  inTransition?: boolean;
100
  /** If the user is dragging */
101
  isDragging?: boolean;
102
  /** If the view is being panned, either from user input or transition */
103
  isPanning?: boolean;
104
  /** If the view is being rotated, either from user input or transition */
105
  isRotating?: boolean;
106
  /** If the view is being zoomed, either from user input or transition */
107
  isZooming?: boolean;
108
  /** World coordinate [lng, lat, altitude] of rotation pivot point when rotating */
109
  rotationPivotPosition?: [number, number, number];
110
}
111

112
/** Parameters passed to the onViewStateChange callback */
113
export type ViewStateChangeParameters<ViewStateT = any> = {
114
  viewId: string;
115
  /** The next view state, either from user input or transition */
116
  viewState: ViewStateT;
117
  /** Object describing the nature of the view state change */
118
  interactionState: InteractionState;
119
  /** The current view state */
120
  oldViewState?: ViewStateT;
121
}
122

123
const pinchEventWorkaround: any = {};
4✔
124

125
type DoubleClickDragTapState = {
126
  pos: [number, number];
127
  time: number;
128
  pointerId?: number;
129
};
130

131
type DoubleClickDragZoomState = {
132
  startPos: [number, number];
133
  pointerId?: number;
134
  active: boolean;
135
};
136

137
function getDistance(a: [number, number], b: [number, number]): number {
138
  const dx = a[0] - b[0];
2✔
139
  const dy = a[1] - b[1];
2✔
140
  return Math.sqrt(dx * dx + dy * dy);
2✔
141
}
142

143
export default abstract class Controller<ControllerState extends IViewState<ControllerState>> {
144
  abstract get ControllerState(): ConstructorOf<ControllerState>;
145
  abstract get transition(): TransitionProps;
146

147
  // @ts-expect-error (2564) - not assigned in the constructor
148
  protected props: ControllerProps;
149
  protected state: Record<string, any> = {};
162✔
150

151
  protected transitionManager: TransitionManager<ControllerState>;
152
  protected eventManager: EventManager;
153
  protected onViewStateChange: (params: ViewStateChangeParameters) => void;
154
  protected onStateChange: (state: InteractionState) => void;
155
  protected makeViewport: (opts: Record<string, any>) => Viewport;
156
  protected pickPosition?: (x: number, y: number) => {coordinate?: number[]} | null;
157

158
  private _controllerState?: ControllerState;
159
  private _events: Record<string, boolean> = {};
162✔
160
  private _interactionState: InteractionState = {
162✔
161
    isDragging: false
162
  };
163
  private _customEvents: string[] = [];
162✔
164
  private _eventStartBlocked: any = null;
162✔
165
  private _panMove: boolean = false;
162✔
166
  private _doubleClickDragTapStart: DoubleClickDragTapState | null = null;
162✔
167
  private _lastDoubleClickDragTap: DoubleClickDragTapState | null = null;
162✔
168
  private _doubleClickDragZoomState: DoubleClickDragZoomState | null = null;
162✔
169
  private _suppressDoubleClickUntil: number = 0;
162✔
170

171
  protected invertPan: boolean = false;
162✔
172
  protected dragMode: 'pan' | 'rotate' = 'rotate';
162✔
173
  protected inertia: number = 0;
162✔
174
  protected scrollZoom: boolean | {speed?: number; smooth?: boolean} = true;
162✔
175
  protected dragPan: boolean = true;
162✔
176
  protected dragRotate: boolean = true;
162✔
177
  protected doubleClickZoom: boolean = true;
162✔
178
  protected doubleClickDragZoom: boolean = true;
162✔
179
  protected touchZoom: boolean = true;
162✔
180
  protected touchRotate: boolean = false;
162✔
181
  protected keyboard:
162✔
182
    | boolean
183
    | {
184
        zoomSpeed?: number; //  speed of zoom using +/- keys. Default 2.
185
        moveSpeed?: number; //  speed of movement using arrow keys, in pixels.
186
        rotateSpeedX?: number; //  speed of rotation using shift + left/right arrow keys, in degrees. Default 15.
187
        rotateSpeedY?: number; //  speed of rotation using shift + up/down arrow keys, in degrees. Default 10.
188
      } = true;
189

190
  constructor(opts: {
191
    timeline: Timeline,
192
    eventManager: EventManager;
193
    makeViewport: (opts: Record<string, any>) => Viewport;
194
    onViewStateChange: (params: ViewStateChangeParameters) => void;
195
    onStateChange: (state: InteractionState) => void;
196
    pickPosition?: (x: number, y: number) => {coordinate?: number[]} | null;
197
  }) {
198
    this.transitionManager = new TransitionManager<ControllerState>({
162✔
199
      ...opts,
200
      getControllerState: props => new this.ControllerState(props),
546✔
201
      onViewStateChange: this._onTransition.bind(this),
202
      onStateChange: this._setInteractionState.bind(this)
203
    });
204

205
    this.handleEvent = this.handleEvent.bind(this);
162✔
206

207
    this.eventManager = opts.eventManager;
162✔
208
    this.onViewStateChange = opts.onViewStateChange || (() => {});
162✔
209
    this.onStateChange = opts.onStateChange || (() => {});
162✔
210
    this.makeViewport = opts.makeViewport;
162✔
211
    this.pickPosition = opts.pickPosition;
162✔
212
  }
213

214
  set events(customEvents) {
215
    this.toggleEvents(this._customEvents, false);
1✔
216
    this.toggleEvents(customEvents, true);
1✔
217
    this._customEvents = customEvents;
1✔
218
    // Make sure default events are not overwritten
219
    if (this.props) {
1!
220
      this.setProps(this.props);
×
221
    }
222
  }
223

224
  finalize() {
225
    for (const eventName in this._events) {
150✔
226
      if (this._events[eventName]) {
176✔
227
        // @ts-ignore (2345) event type string cannot be assifned to enum
228
        // eslint-disable-next-line @typescript-eslint/unbound-method
229
        this.eventManager?.off(eventName, this.handleEvent);
143✔
230
      }
231
    }
232
    this.transitionManager.finalize();
150✔
233
  }
234

235
  /**
236
   * Callback for events
237
   */
238
  handleEvent(event: MjolnirEvent) {
239
    // Force recalculate controller state
240
    this._controllerState = undefined;
359✔
241
    const eventStartBlocked = this._eventStartBlocked;
359✔
242

243
    switch (event.type) {
359!
244
      case 'panstart':
245
        if (this._doubleClickDragZoomState) {
35!
NEW
246
          return false;
×
247
        }
248
        return eventStartBlocked ? false : this._onPanStart(event);
35!
249
      case 'panmove':
250
        if (this._doubleClickDragZoomState) {
35!
NEW
251
          return false;
×
252
        }
253
        return this._onPan(event);
35✔
254
      case 'panend':
255
        if (this._doubleClickDragZoomState) {
35!
NEW
256
          return false;
×
257
        }
258
        return this._onPanEnd(event);
35✔
259
      case 'pinchstart':
260
        return eventStartBlocked ? false : this._onPinchStart(event);
20!
261
      case 'pinchmove':
262
        return this._onPinch(event);
20✔
263
      case 'pinchend':
264
        return this._onPinchEnd(event);
20✔
265
      case 'multipanstart':
266
        return eventStartBlocked ? false : this._onMultiPanStart(event);
16!
267
      case 'multipanmove':
268
        return this._onMultiPan(event);
16✔
269
      case 'multipanend':
270
        return this._onMultiPanEnd(event);
16✔
271
      case 'dblclick':
272
        return this._onDoubleClick(event);
21✔
273
      case 'pointerdown':
274
        return this._onDoubleClickDragPointerDown(event);
4✔
275
      case 'pointermove':
276
        return this._onDoubleClickDragPointerMove(event);
2✔
277
      case 'pointerup':
278
      case 'pointercancel':
279
        return this._onDoubleClickDragPointerUp(event);
4✔
280
      case 'wheel':
281
        return this._onWheel(event as MjolnirWheelEvent);
25✔
282
      case 'keydown':
283
        return this._onKeyDown(event as MjolnirKeyEvent);
90✔
284
      default:
285
        return false;
×
286
    }
287
  }
288

289
  /* Event utils */
290
  // Event object: http://hammerjs.github.io/api/#event-object
291
  get controllerState(): ControllerState {
292
    this._controllerState = this._controllerState || new this.ControllerState({
1,018✔
293
      makeViewport: this.makeViewport,
294
      ...this.props,
295
      ...this.state
296
    });
297
    return this._controllerState;
1,018✔
298
  }
299

300
  getCenter(event: MjolnirGestureEvent | MjolnirWheelEvent) : [number, number] {
301
    const {x, y} = this.props;
131✔
302
    const {offsetCenter} = event;
131✔
303
    return [offsetCenter.x - x, offsetCenter.y - y];
131✔
304
  }
305

306
  isPointInBounds(pos: [number, number], event: MjolnirEvent): boolean {
307
    const {width, height} = this.props;
105✔
308
    if (event && event.handled) {
105✔
309
      return false;
7✔
310
    }
311

312
    const inside = pos[0] >= 0 && pos[0] <= width && pos[1] >= 0 && pos[1] <= height;
98✔
313
    if (inside && event) {
105✔
314
      event.stopPropagation();
63✔
315
    }
316
    return inside;
98✔
317
  }
318

319
  isFunctionKeyPressed(event: MjolnirEvent): boolean {
320
    const {srcEvent} = event;
111✔
321
    return Boolean(srcEvent.metaKey || srcEvent.altKey || srcEvent.ctrlKey || srcEvent.shiftKey);
111✔
322
  }
323

324
  isDragging(): boolean {
325
    return this._interactionState.isDragging || false;
130✔
326
  }
327

328
  // When a multi-touch event ends, e.g. pinch, not all pointers are lifted at the same time.
329
  // This triggers a brief `pan` event.
330
  // Calling this method will temporarily disable *start events to avoid conflicting transitions.
331
  blockEvents(timeout: number): void {
332
    /* global setTimeout */
333
    const timer = setTimeout(() => {
10✔
334
      if (this._eventStartBlocked === timer) {
10!
335
        this._eventStartBlocked = null;
10✔
336
      }
337
    }, timeout);
338
    this._eventStartBlocked = timer;
10✔
339
  }
340

341
  /**
342
   * Extract interactivity options
343
   */
344
  setProps(props: ControllerProps) {
345
    if (props.dragMode) {
926✔
346
      this.dragMode = props.dragMode;
6✔
347
    }
348
    const oldProps = this.props;
926✔
349
    this.props = props;
926✔
350

351
    if (!('transitionInterpolator' in props)) {
926✔
352
      // Add default transition interpolator
353
      props.transitionInterpolator = this._getTransitionProps().transitionInterpolator;
310✔
354
    }
355

356
    this.transitionManager.processViewStateChange(props);
926✔
357

358
    const {inertia} = props;
926✔
359
    this.inertia = Number.isFinite(inertia) ? (inertia as number) : (inertia === true ? DEFAULT_INERTIA : 0);
926✔
360

361
    // TODO - make sure these are not reset on every setProps
362
    const {
363
      scrollZoom = true,
926✔
364
      dragPan = true,
926✔
365
      dragRotate = true,
926✔
366
      doubleClickZoom = true,
926✔
367
      doubleClickDragZoom = true,
926✔
368
      touchZoom = true,
926✔
369
      touchRotate = false,
926✔
370
      keyboard = true
926✔
371
    } = props;
926✔
372

373
    // Register/unregister events
374
    const isInteractive = Boolean(this.onViewStateChange);
926✔
375
    this.toggleEvents(EVENT_TYPES.WHEEL, isInteractive && scrollZoom);
926✔
376
    // We always need the pan events to set the correct isDragging state, even if dragPan & dragRotate are both false
377
    this.toggleEvents(EVENT_TYPES.PAN, isInteractive);
926✔
378
    this.toggleEvents(EVENT_TYPES.PINCH, isInteractive && (touchZoom || touchRotate));
926✔
379
    this.toggleEvents(EVENT_TYPES.MULTI_PAN, isInteractive && touchRotate);
926✔
380
    this.toggleEvents(EVENT_TYPES.DOUBLE_CLICK, isInteractive && doubleClickZoom);
926✔
381
    this.toggleEvents(EVENT_TYPES.DOUBLE_CLICK_DRAG, isInteractive && doubleClickDragZoom);
926✔
382
    this.toggleEvents(EVENT_TYPES.KEYBOARD, isInteractive && keyboard);
926✔
383

384
    // Interaction toggles
385
    this.scrollZoom = scrollZoom;
926✔
386
    this.dragPan = dragPan;
926✔
387
    this.dragRotate = dragRotate;
926✔
388
    this.doubleClickZoom = doubleClickZoom;
926✔
389
    this.doubleClickDragZoom = doubleClickDragZoom;
926✔
390
    this.touchZoom = touchZoom;
926✔
391
    this.touchRotate = touchRotate;
926✔
392
    this.keyboard = keyboard;
926✔
393

394
    // Normalize view state if maxBounds is defined
395
    const dimensionChanged = !oldProps || oldProps.height !== props.height || oldProps.width !== props.width || oldProps.maxBounds !== props.maxBounds;
926✔
396
    if (dimensionChanged && props.maxBounds) {
926✔
397
      // Dimensions changed, try re-normalize the props
398
      const controllerState = new this.ControllerState({...props, makeViewport: this.makeViewport});
59✔
399
      const normalizedProps = controllerState.getViewportProps();
59✔
400
      const changed = Object.keys(normalizedProps).some(key => !deepEqual(normalizedProps[key], props[key], 1));
451✔
401
      if (changed) {
59✔
402
        // some props are updated after normalization
403
        this.updateViewport(controllerState);
58✔
404
      }
405
    }
406
  }
407

408
  updateTransition() {
409
    this.transitionManager.updateTransition();
460✔
410
  }
411

412
  toggleEvents(eventNames, enabled) {
413
    if (this.eventManager) {
6,351✔
414
      eventNames.forEach(eventName => {
65✔
415
        if (this._events[eventName] !== enabled) {
450✔
416
          this._events[eventName] = enabled;
194✔
417
          if (enabled) {
194✔
418
            // eslint-disable-next-line @typescript-eslint/unbound-method
419
            this.eventManager.on(eventName, this.handleEvent);
157✔
420
          } else {
421
            // eslint-disable-next-line @typescript-eslint/unbound-method
422
            this.eventManager.off(eventName, this.handleEvent);
37✔
423
          }
424
        }
425
      });
426
    }
427
  }
428

429
  // Private Methods
430

431
  /* Callback util */
432
  // formats map state and invokes callback function
433
  protected updateViewport(newControllerState: ControllerState, extraProps: Record<string, any> | null = null, interactionState: InteractionState = {}) {
534✔
434
    const viewState = {...newControllerState.getViewportProps(), ...extraProps};
267✔
435

436
    // TODO - to restore diffing, we need to include interactionState
437
    const changed = this.controllerState !== newControllerState;
267✔
438
    // const oldViewState = this.controllerState.getViewportProps();
439
    // const changed = Object.keys(viewState).some(key => oldViewState[key] !== viewState[key]);
440

441
    this.state = newControllerState.getState();
267✔
442
    this._setInteractionState(interactionState);
267✔
443

444
    if (changed) {
267!
445
      const oldViewState = this.controllerState && this.controllerState.getViewportProps();
267✔
446
      if (this.onViewStateChange) {
267!
447
        this.onViewStateChange({viewState, interactionState: this._interactionState, oldViewState, viewId: this.props.id});
267✔
448
      }
449
    }
450
  }
451

452
  private _onTransition(params: {viewState: Record<string, any>, oldViewState: Record<string, any>}) {
453
    this.onViewStateChange({...params, interactionState: this._interactionState, viewId: this.props.id});
364✔
454
  }
455

456
  private _setInteractionState(newStates: InteractionState) {
457
    Object.assign(this._interactionState, newStates);
460✔
458
    this.onStateChange(this._interactionState);
460✔
459
  }
460

461
  /* Event handlers */
462
  // Default handler for the `panstart` event.
463
  protected _onPanStart(event: MjolnirGestureEvent): boolean {
464
    const pos = this.getCenter(event);
35✔
465
    if (!this.isPointInBounds(pos, event)) {
35✔
466
      return false;
14✔
467
    }
468
    let alternateMode = this.isFunctionKeyPressed(event) || event.rightButton || false;
21✔
469
    if (this.invertPan || this.dragMode === 'pan') {
35✔
470
      // invertPan is replaced by props.dragMode, keeping for backward compatibility
471
      alternateMode = !alternateMode;
15✔
472
    }
473

474
    const newControllerState = this.controllerState[alternateMode ? 'panStart' : 'rotateStart']({
21✔
475
      pos
476
    });
477
    this._panMove = alternateMode;
35✔
478
    this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {isDragging: true});
35✔
479
    return true;
35✔
480
  }
481

482
  // Default handler for the `panmove` and `panend` event.
483
  protected _onPan(event: MjolnirGestureEvent): boolean {
484
    if (!this.isDragging()) {
35✔
485
      return false;
14✔
486
    }
487
    return this._panMove ? this._onPanMove(event) : this._onPanRotate(event);
21✔
488
  }
489

490
  protected _onPanEnd(event: MjolnirGestureEvent): boolean {
491
    if (!this.isDragging()) {
35✔
492
      return false;
14✔
493
    }
494
    return this._panMove ? this._onPanMoveEnd(event) : this._onPanRotateEnd(event);
21✔
495
  }
496

497
  // Default handler for panning to move.
498
  // Called by `_onPan` when panning without function key pressed.
499
  protected _onPanMove(event: MjolnirGestureEvent): boolean {
500
    if (!this.dragPan) {
10✔
501
      return false;
5✔
502
    }
503
    const pos = this.getCenter(event);
5✔
504
    const newControllerState = this.controllerState.pan({pos});
5✔
505
    this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {
5✔
506
      isDragging: true,
507
      isPanning: true
508
    });
509
    return true;
5✔
510
  }
511

512
  protected _onPanMoveEnd(event: MjolnirGestureEvent): boolean {
513
    const {inertia} = this;
10✔
514
    if (this.dragPan && inertia && event.velocity) {
10✔
515
      const pos = this.getCenter(event);
1✔
516
      const endPos: [number, number] = [
1✔
517
        pos[0] + (event.velocityX * inertia) / 2,
518
        pos[1] + (event.velocityY * inertia) / 2
519
      ];
520
      const newControllerState = this.controllerState.pan({pos: endPos}).panEnd();
1✔
521
      this.updateViewport(
1✔
522
        newControllerState,
523
        {
524
          ...this._getTransitionProps(),
525
          transitionDuration: inertia,
526
          transitionEasing: INERTIA_EASING
527
        },
528
        {
529
          isDragging: false,
530
          isPanning: true
531
        }
532
      );
533
    } else {
534
      const newControllerState = this.controllerState.panEnd();
9✔
535
      this.updateViewport(newControllerState, null, {
9✔
536
        isDragging: false,
537
        isPanning: false
538
      });
539
    }
540
    return true;
10✔
541
  }
542

543
  // Default handler for panning to rotate.
544
  // Called by `_onPan` when panning with function key pressed.
545
  protected _onPanRotate(event: MjolnirGestureEvent): boolean {
546
    if (!this.dragRotate) {
9✔
547
      return false;
5✔
548
    }
549

550
    const pos = this.getCenter(event);
4✔
551
    const newControllerState = this.controllerState.rotate({pos});
4✔
552
    this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {
4✔
553
      isDragging: true,
554
      isRotating: true
555
    });
556
    return true;
4✔
557
  }
558

559
  protected _onPanRotateEnd(event): boolean {
560
    const {inertia} = this;
9✔
561
    if (this.dragRotate && inertia && event.velocity) {
9✔
562
      const pos = this.getCenter(event);
1✔
563
      const endPos: [number, number] = [
1✔
564
        pos[0] + (event.velocityX * inertia) / 2,
565
        pos[1] + (event.velocityY * inertia) / 2
566
      ];
567
      const newControllerState = this.controllerState.rotate({pos: endPos}).rotateEnd();
1✔
568
      this.updateViewport(
1✔
569
        newControllerState,
570
        {
571
          ...this._getTransitionProps(),
572
          transitionDuration: inertia,
573
          transitionEasing: INERTIA_EASING
574
        },
575
        {
576
          isDragging: false,
577
          isRotating: true
578
        }
579
      );
580
    } else {
581
      const newControllerState = this.controllerState.rotateEnd();
8✔
582
      this.updateViewport(newControllerState, null, {
8✔
583
        isDragging: false,
584
        isRotating: false
585
      });
586
    }
587
    return true;
9✔
588
  }
589

590
  // Default handler for the `wheel` event.
591
  protected _onWheel(event: MjolnirWheelEvent): boolean {
592
    if (!this.scrollZoom) {
25✔
593
      return false;
7✔
594
    }
595

596
    const pos = this.getCenter(event);
18✔
597
    if (!this.isPointInBounds(pos, event)) {
18✔
598
      return false;
7✔
599
    }
600
    event.srcEvent.preventDefault();
11✔
601

602
    const {speed = 0.01, smooth = false} = this.scrollZoom === true ? {} : this.scrollZoom;
11!
603
    const {delta} = event;
25✔
604

605
    // Map wheel delta to relative scale
606
    let scale = 2 / (1 + Math.exp(-Math.abs(delta * speed)));
25✔
607
    if (delta < 0 && scale !== 0) {
25✔
608
      scale = 1 / scale;
9✔
609
    }
610

611
    const transitionProps = smooth
11!
612
      ? {...this._getTransitionProps({around: pos}), transitionDuration: 250}
613
      : NO_TRANSITION_PROPS;
614

615
    const newControllerState = this.controllerState.zoom({pos, scale});
25✔
616
    this.updateViewport(
25✔
617
      newControllerState,
618
      transitionProps,
619
      {
620
        isZooming: true,
621
        isPanning: true
622
      }
623
    );
624

625
    // When there's no transition (duration = 0), immediately reset interaction state
626
    // since _onTransitionEnd callback won't fire
627
    if (!smooth) {
25✔
628
      this._setInteractionState({isZooming: false, isPanning: false});
11✔
629
    }
630
    return true;
11✔
631
  }
632

633
  protected _onMultiPanStart(event: MjolnirGestureEvent): boolean {
634
    const pos = this.getCenter(event);
16✔
635
    if (!this.isPointInBounds(pos, event)) {
16✔
636
      return false;
7✔
637
    }
638
    const newControllerState = this.controllerState.rotateStart({pos});
9✔
639
    this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {isDragging: true});
9✔
640
    return true;
9✔
641
  }
642

643
  protected _onMultiPan(event: MjolnirGestureEvent): boolean {
644
    if (!this.touchRotate) {
16✔
645
      return false;
5✔
646
    }
647
    if (!this.isDragging()) {
11✔
648
      return false;
7✔
649
    }
650

651
    const pos = this.getCenter(event);
4✔
652
    pos[0] -= event.deltaX;
4✔
653

654
    const newControllerState = this.controllerState.rotate({pos});
4✔
655
    this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {
4✔
656
      isDragging: true,
657
      isRotating: true
658
    });
659
    return true;
4✔
660
  }
661

662
  protected _onMultiPanEnd(event: MjolnirGestureEvent): boolean {
663
    if (!this.isDragging()) {
16✔
664
      return false;
7✔
665
    }
666
    const {inertia} = this;
9✔
667
    if (this.touchRotate && inertia && event.velocityY) {
9✔
668
      const pos = this.getCenter(event);
1✔
669
      const endPos: [number, number] = [pos[0], (pos[1] += (event.velocityY * inertia) / 2)];
1✔
670
      const newControllerState = this.controllerState.rotate({pos: endPos});
1✔
671
      this.updateViewport(
1✔
672
        newControllerState,
673
        {
674
          ...this._getTransitionProps(),
675
          transitionDuration: inertia,
676
          transitionEasing: INERTIA_EASING
677
        },
678
        {
679
          isDragging: false,
680
          isRotating: true
681
        }
682
      );
683
      this.blockEvents(inertia);
1✔
684
    } else {
685
      const newControllerState = this.controllerState.rotateEnd();
8✔
686
      this.updateViewport(newControllerState, null, {
8✔
687
        isDragging: false,
688
        isRotating: false
689
      });
690
    }
691
    return true;
9✔
692
  }
693

694
  // Default handler for the `pinchstart` event.
695
  protected _onPinchStart(event: MjolnirGestureEvent): boolean {
696
    this._resetDoubleClickDragZoom();
20✔
697
    const pos = this.getCenter(event);
20✔
698
    if (!this.isPointInBounds(pos, event)) {
20✔
699
      return false;
7✔
700
    }
701

702
    const newControllerState = this.controllerState.zoomStart({pos}).rotateStart({pos});
13✔
703
    // hack - hammer's `rotation` field doesn't seem to produce the correct angle
704
    pinchEventWorkaround._startPinchRotation = event.rotation;
13✔
705
    pinchEventWorkaround._lastPinchEvent = event;
13✔
706
    this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {isDragging: true});
13✔
707
    return true;
13✔
708
  }
709

710
  // Default handler for the `pinchmove` and `pinchend` events.
711
  protected _onPinch(event: MjolnirGestureEvent): boolean {
712
    if (!this.touchZoom && !this.touchRotate) {
20✔
713
      return false;
7✔
714
    }
715
    if (!this.isDragging()) {
13✔
716
      return false;
7✔
717
    }
718

719
    let newControllerState = this.controllerState;
6✔
720
    if (this.touchZoom) {
6!
721
      const {scale} = event;
6✔
722
      const pos = this.getCenter(event);
6✔
723
      newControllerState = newControllerState.zoom({pos, scale});
6✔
724
    }
725
    if (this.touchRotate) {
6!
726
      const {rotation} = event;
6✔
727
      newControllerState = newControllerState.rotate({
6✔
728
        deltaAngleX: pinchEventWorkaround._startPinchRotation - rotation
729
      });
730
    }
731

732
    this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {
6✔
733
      isDragging: true,
734
      isPanning: this.touchZoom,
735
      isZooming: this.touchZoom,
736
      isRotating: this.touchRotate
737
    });
738
    pinchEventWorkaround._lastPinchEvent = event;
6✔
739
    return true;
6✔
740
  }
741

742
  protected _onPinchEnd(event: MjolnirGestureEvent): boolean {
743
    if (!this.isDragging()) {
20✔
744
      return false;
7✔
745
    }
746
    const {inertia} = this;
13✔
747
    const {_lastPinchEvent} = pinchEventWorkaround;
13✔
748
    if (this.touchZoom && inertia && _lastPinchEvent && event.scale !== _lastPinchEvent.scale) {
13✔
749
      const pos = this.getCenter(event);
1✔
750
      let newControllerState = this.controllerState.rotateEnd();
1✔
751
      const z = Math.log2(event.scale);
1✔
752
      const velocityZ =
1✔
753
        (z - Math.log2(_lastPinchEvent.scale)) / (event.deltaTime - _lastPinchEvent.deltaTime);
754
      const endScale = Math.pow(2, z + (velocityZ * inertia) / 2);
1✔
755
      newControllerState = newControllerState.zoom({pos, scale: endScale}).zoomEnd();
1✔
756

757
      this.updateViewport(
1✔
758
        newControllerState,
759
        {
760
          ...this._getTransitionProps({around: pos}),
761
          transitionDuration: inertia,
762
          transitionEasing: INERTIA_EASING
763
        },
764
        {
765
          isDragging: false,
766
          isPanning: this.touchZoom,
767
          isZooming: this.touchZoom,
768
          isRotating: false
769
        }
770
      );
771
      this.blockEvents(inertia);
1✔
772
    } else {
773
      const newControllerState = this.controllerState.zoomEnd().rotateEnd();
12✔
774
      this.updateViewport(newControllerState, null, {
12✔
775
        isDragging: false,
776
        isPanning: false,
777
        isZooming: false,
778
        isRotating: false
779
      });
780
    }
781
    pinchEventWorkaround._startPinchRotation = null;
13✔
782
    pinchEventWorkaround._lastPinchEvent = null;
13✔
783
    return true;
13✔
784
  }
785

786
  // Default handler for the `dblclick` event.
787
  protected _onDoubleClick(event: MjolnirGestureEvent): boolean {
788
    if (!this.doubleClickZoom) {
21✔
789
      return false;
7✔
790
    }
791
    if (Date.now() < this._suppressDoubleClickUntil) {
14!
NEW
792
      return false;
×
793
    }
794
    const pos = this.getCenter(event);
14✔
795
    if (!this.isPointInBounds(pos, event)) {
14✔
796
      return false;
7✔
797
    }
798

799
    const isZoomOut = this.isFunctionKeyPressed(event);
7✔
800

801
    const newControllerState = this.controllerState.zoom({pos, scale: isZoomOut ? 0.5 : 2});
7!
802
    this.updateViewport(newControllerState, this._getTransitionProps({around: pos}), {
21✔
803
      isZooming: true,
804
      isPanning: true
805
    });
806
    this.blockEvents(100);
21✔
807
    return true;
21✔
808
  }
809

810
  protected _onDoubleClickDragPointerDown(event: MjolnirEvent): boolean {
811
    if (!this.doubleClickDragZoom || !this._isPrimaryPointer(event)) {
4✔
812
      this._resetDoubleClickDragZoom();
2✔
813
      return false;
2✔
814
    }
815

816
    const pos = this.getCenter(event as MjolnirGestureEvent);
2✔
817
    if (!this.isPointInBounds(pos, event)) {
2!
NEW
818
      this._resetDoubleClickDragZoom();
×
NEW
819
      return false;
×
820
    }
821

822
    const time = this._getEventTime(event);
2✔
823
    const pointerId = (event.srcEvent as PointerEvent).pointerId;
2✔
824
    if (
2✔
825
      this._lastDoubleClickDragTap &&
4✔
826
      time - this._lastDoubleClickDragTap.time <= DOUBLE_CLICK_DRAG_INTERVAL &&
827
      getDistance(pos, this._lastDoubleClickDragTap.pos) <= DOUBLE_CLICK_DRAG_MAX_TAP_DISTANCE
828
    ) {
829
      this._doubleClickDragTapStart = null;
1✔
830
      this._lastDoubleClickDragTap = null;
1✔
831
      this._doubleClickDragZoomState = {startPos: pos, pointerId, active: false};
1✔
832
      event.srcEvent.preventDefault();
1✔
833
      event.stopPropagation();
1✔
834
      return true;
1✔
835
    }
836

837
    this._doubleClickDragTapStart = {pos, time, pointerId};
1✔
838
    this._lastDoubleClickDragTap = null;
1✔
839
    if ((event.srcEvent as PointerEvent).pointerType === 'touch') {
1!
840
      event.srcEvent.preventDefault();
1✔
841
    }
842
    return false;
1✔
843
  }
844

845
  protected _onDoubleClickDragPointerMove(event: MjolnirEvent): boolean {
846
    const doubleClickDragZoomState = this._doubleClickDragZoomState;
2✔
847
    if (
2✔
848
      !doubleClickDragZoomState ||
3✔
849
      !this._isSamePointer(event, doubleClickDragZoomState.pointerId)
850
    ) {
851
      return false;
1✔
852
    }
853

854
    const pos = this.getCenter(event as MjolnirGestureEvent);
1✔
855
    const dy = doubleClickDragZoomState.startPos[1] - pos[1];
1✔
856
    if (!doubleClickDragZoomState.active && Math.abs(dy) < DOUBLE_CLICK_DRAG_START_THRESHOLD) {
1!
NEW
857
      event.srcEvent.preventDefault();
×
NEW
858
      event.stopPropagation();
×
NEW
859
      return true;
×
860
    }
861

862
    const scale = Math.pow(2, dy / DOUBLE_CLICK_DRAG_PIXELS_PER_ZOOM);
1✔
863
    const startPos = doubleClickDragZoomState.startPos;
1✔
864
    let newControllerState = this.controllerState;
1✔
865
    if (!doubleClickDragZoomState.active) {
1!
866
      doubleClickDragZoomState.active = true;
1✔
867
      newControllerState = newControllerState.zoomStart({pos: startPos});
1✔
868
    }
869
    newControllerState = newControllerState.zoom({pos: startPos, scale});
1✔
870
    this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {
1✔
871
      isDragging: true,
872
      isPanning: true,
873
      isZooming: true
874
    });
875

876
    event.srcEvent.preventDefault();
1✔
877
    event.stopPropagation();
1✔
878
    return true;
1✔
879
  }
880

881
  protected _onDoubleClickDragPointerUp(event: MjolnirEvent): boolean {
882
    const doubleClickDragZoomState = this._doubleClickDragZoomState;
4✔
883
    if (
4✔
884
      doubleClickDragZoomState &&
5✔
885
      this._isSamePointer(event, doubleClickDragZoomState.pointerId)
886
    ) {
887
      this._doubleClickDragZoomState = null;
1✔
888
      if (doubleClickDragZoomState.active) {
1!
889
        const newControllerState = this.controllerState.zoomEnd();
1✔
890
        this.updateViewport(newControllerState, null, {
1✔
891
          isDragging: false,
892
          isPanning: false,
893
          isZooming: false
894
        });
895
        this._suppressDoubleClickUntil = Date.now() + 100;
1✔
896
        this.blockEvents(100);
1✔
897
        event.srcEvent.preventDefault();
1✔
898
        event.stopPropagation();
1✔
899
        return true;
1✔
900
      }
NEW
901
      return false;
×
902
    }
903

904
    if (event.type === 'pointercancel') {
3!
NEW
905
      this._resetDoubleClickDragZoom();
×
NEW
906
      return false;
×
907
    }
908

909
    const doubleClickDragTapStart = this._doubleClickDragTapStart;
3✔
910
    if (
3✔
911
      !doubleClickDragTapStart ||
4✔
912
      !this._isSamePointer(event, doubleClickDragTapStart.pointerId)
913
    ) {
914
      return false;
2✔
915
    }
916

917
    const pos = this.getCenter(event as MjolnirGestureEvent);
1✔
918
    const time = this._getEventTime(event);
1✔
919
    if (
1!
920
      time - doubleClickDragTapStart.time <= DOUBLE_CLICK_DRAG_MAX_TAP_DURATION &&
2✔
921
      getDistance(pos, doubleClickDragTapStart.pos) <= DOUBLE_CLICK_DRAG_MAX_TAP_DISTANCE
922
    ) {
923
      this._lastDoubleClickDragTap = {pos, time, pointerId: doubleClickDragTapStart.pointerId};
1✔
924
    } else {
NEW
925
      this._lastDoubleClickDragTap = null;
×
926
    }
927
    this._doubleClickDragTapStart = null;
1✔
928
    if ((event.srcEvent as PointerEvent).pointerType === 'touch') {
1!
929
      event.srcEvent.preventDefault();
1✔
930
    }
931
    return false;
1✔
932
  }
933

934
  private _resetDoubleClickDragZoom(): void {
935
    this._doubleClickDragTapStart = null;
22✔
936
    this._lastDoubleClickDragTap = null;
22✔
937
    this._doubleClickDragZoomState = null;
22✔
938
  }
939

940
  private _getEventTime(event: MjolnirEvent): number {
941
    return (event as any).timeStamp || event.srcEvent.timeStamp || Date.now();
3✔
942
  }
943

944
  private _isPrimaryPointer(event: MjolnirEvent): boolean {
945
    const pointers = (event as any).pointers;
2✔
946
    if (pointers && pointers.length > 1) {
2!
NEW
947
      return false;
×
948
    }
949
    const srcEvent = event.srcEvent as PointerEvent;
2✔
950
    if (srcEvent.pointerType === 'mouse') {
2!
NEW
951
      return (event as any).leftButton !== false;
×
952
    }
953
    return true;
2✔
954
  }
955

956
  private _isSamePointer(event: MjolnirEvent, pointerId?: number): boolean {
957
    return pointerId === undefined || (event.srcEvent as PointerEvent).pointerId === pointerId;
3✔
958
  }
959

960
  // Default handler for the `keydown` event
961
  protected _onKeyDown(event: MjolnirKeyEvent): boolean {
962
    if (!this.keyboard) {
90✔
963
      return false;
7✔
964
    }
965
    const funcKey = this.isFunctionKeyPressed(event);
83✔
966
    // @ts-ignore
967
    const {zoomSpeed, moveSpeed, rotateSpeedX, rotateSpeedY} = this.keyboard === true ? {} : this.keyboard;
83✔
968
    const {controllerState} = this;
90✔
969
    let newControllerState;
970
    const interactionState: InteractionState = {};
90✔
971

972
    switch (event.srcEvent.code) {
90!
973
      case 'Minus':
974
        newControllerState = funcKey
19✔
975
          ? controllerState.zoomOut(zoomSpeed).zoomOut(zoomSpeed)
976
          : controllerState.zoomOut(zoomSpeed);
977
        interactionState.isZooming = true;
19✔
978
        break;
19✔
979
      case 'Equal':
980
        newControllerState = funcKey
14✔
981
          ? controllerState.zoomIn(zoomSpeed).zoomIn(zoomSpeed)
982
          : controllerState.zoomIn(zoomSpeed);
983
        interactionState.isZooming = true;
14✔
984
        break;
14✔
985
      case 'ArrowLeft':
986
        if (funcKey) {
13✔
987
          newControllerState = controllerState.rotateLeft(rotateSpeedX);
5✔
988
          interactionState.isRotating = true;
5✔
989
        } else {
990
          newControllerState = controllerState.moveLeft(moveSpeed);
8✔
991
          interactionState.isPanning = true;
8✔
992
        }
993
        break;
13✔
994
      case 'ArrowRight':
995
        if (funcKey) {
12✔
996
          newControllerState = controllerState.rotateRight(rotateSpeedX);
5✔
997
          interactionState.isRotating = true;
5✔
998
        } else {
999
          newControllerState = controllerState.moveRight(moveSpeed);
7✔
1000
          interactionState.isPanning = true;
7✔
1001
        }
1002
        break;
12✔
1003
      case 'ArrowUp':
1004
        if (funcKey) {
13✔
1005
          newControllerState = controllerState.rotateUp(rotateSpeedY);
5✔
1006
          interactionState.isRotating = true;
5✔
1007
        } else {
1008
          newControllerState = controllerState.moveUp(moveSpeed);
8✔
1009
          interactionState.isPanning = true;
8✔
1010
        }
1011
        break;
13✔
1012
      case 'ArrowDown':
1013
        if (funcKey) {
12✔
1014
          newControllerState = controllerState.rotateDown(rotateSpeedY);
5✔
1015
          interactionState.isRotating = true;
5✔
1016
        } else {
1017
          newControllerState = controllerState.moveDown(moveSpeed);
7✔
1018
          interactionState.isPanning = true;
7✔
1019
        }
1020
        break;
12✔
1021
      default:
1022
        return false;
×
1023
    }
1024
    this.updateViewport(newControllerState, this._getTransitionProps(), interactionState);
83✔
1025
    return true;
83✔
1026
  }
1027

1028
  protected _getTransitionProps(opts?: any): TransitionProps {
1029
    const {transition} = this;
404✔
1030

1031
    if (!transition || !transition.transitionInterpolator) {
404✔
1032
      return NO_TRANSITION_PROPS;
1✔
1033
    }
1034

1035
    // Enables Transitions on double-click/tap and key-down events.
1036
    return opts
403✔
1037
      ? {
1038
        ...transition,
1039
        transitionInterpolator: new LinearInterpolator({
1040
          ...opts,
1041
          ...(transition.transitionInterpolator as LinearInterpolator).opts,
1042
          makeViewport: this.controllerState.makeViewport
1043
        })
1044
      }
1045
      : transition;
1046
  }
1047
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc