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

visgl / deck.gl / 25968417998

16 May 2026 05:31PM UTC coverage: 83.399% (-0.04%) from 83.434%
25968417998

Pull #10303

github

web-flow
Merge 335b614c1 into 2ff156c73
Pull Request #10303: feat(core): improve touch zoom gestures

7846 of 9865 branches covered (79.53%)

Branch coverage included in aggregate %.

97 of 116 new or added lines in 1 file covered. (83.62%)

14047 of 16386 relevant lines covered (85.73%)

19644.31 hits per line

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

91.68
/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);
12✔
23
const MAX_PINCH_ZOOM_DELTA_PER_EVENT = 0.18;
4✔
24
const DOUBLE_TAP_DRAG_INTERVAL = 500;
4✔
25
const DOUBLE_TAP_DRAG_MAX_TAP_DURATION = 350;
4✔
26
const DOUBLE_TAP_DRAG_MAX_TAP_DISTANCE = 28;
4✔
27
const DOUBLE_TAP_DRAG_START_THRESHOLD = 1;
4✔
28
const DOUBLE_TAP_DRAG_PIXELS_PER_ZOOM = 120;
4✔
29

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

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

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

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

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

120
const pinchEventWorkaround: any = {};
4✔
121

122
type OneFingerTapState = {
123
  pos: [number, number];
124
  time: number;
125
  pointerId?: number;
126
};
127

128
type OneFingerZoomState = {
129
  startPos: [number, number];
130
  pointerId?: number;
131
  active: boolean;
132
};
133

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

140
function clampPinchZoomDelta(delta: number): number {
141
  return Math.max(-MAX_PINCH_ZOOM_DELTA_PER_EVENT, Math.min(MAX_PINCH_ZOOM_DELTA_PER_EVENT, delta));
8✔
142
}
143

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

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

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

159
  private _controllerState?: ControllerState;
160
  private _events: Record<string, boolean> = {};
159✔
161
  private _interactionState: InteractionState = {
159✔
162
    isDragging: false
163
  };
164
  private _customEvents: string[] = [];
159✔
165
  private _eventStartBlocked: any = null;
159✔
166
  private _panMove: boolean = false;
159✔
167
  private _tapStart: OneFingerTapState | null = null;
159✔
168
  private _lastTap: OneFingerTapState | null = null;
159✔
169
  private _oneFingerZoom: OneFingerZoomState | null = null;
159✔
170
  private _suppressDoubleClickUntil: number = 0;
159✔
171

172
  protected invertPan: boolean = false;
159✔
173
  protected dragMode: 'pan' | 'rotate' = 'rotate';
159✔
174
  protected inertia: number = 0;
159✔
175
  protected scrollZoom: boolean | {speed?: number; smooth?: boolean} = true;
159✔
176
  protected dragPan: boolean = true;
159✔
177
  protected dragRotate: boolean = true;
159✔
178
  protected doubleClickZoom: boolean = true;
159✔
179
  protected touchZoom: boolean = true;
159✔
180
  protected touchRotate: boolean = false;
159✔
181
  protected keyboard:
159✔
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>({
159✔
199
      ...opts,
200
      getControllerState: props => new this.ControllerState(props),
540✔
201
      onViewStateChange: this._onTransition.bind(this),
202
      onStateChange: this._setInteractionState.bind(this)
203
    });
204

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

207
    this.eventManager = opts.eventManager;
159✔
208
    this.onViewStateChange = opts.onViewStateChange || (() => {});
159✔
209
    this.onStateChange = opts.onStateChange || (() => {});
159✔
210
    this.makeViewport = opts.makeViewport;
159✔
211
    this.pickPosition = opts.pickPosition;
159✔
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) {
146✔
226
      if (this._events[eventName]) {
112✔
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);
91✔
230
      }
231
    }
232
    this.transitionManager.finalize();
146✔
233
  }
234

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

243
    switch (event.type) {
360!
244
      case 'panstart':
245
        if (this._oneFingerZoom) {
35!
NEW
246
          return false;
×
247
        }
248
        return eventStartBlocked ? false : this._onPanStart(event);
35!
249
      case 'panmove':
250
        if (this._oneFingerZoom) {
35!
NEW
251
          return false;
×
252
        }
253
        return this._onPan(event);
35✔
254
      case 'panend':
255
        if (this._oneFingerZoom) {
35!
NEW
256
          return false;
×
257
        }
258
        return this._onPanEnd(event);
35✔
259
      case 'pinchstart':
260
        return eventStartBlocked ? false : this._onPinchStart(event);
22!
261
      case 'pinchmove':
262
        return this._onPinch(event);
22✔
263
      case 'pinchend':
264
        return this._onPinchEnd(event);
22✔
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._onPointerDown(event);
2✔
275
      case 'pointermove':
276
        return this._onPointerMove(event);
1✔
277
      case 'pointerup':
278
      case 'pointercancel':
279
        return this._onPointerUp(event);
2✔
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,041✔
293
      makeViewport: this.makeViewport,
294
      ...this.props,
295
      ...this.state
296
    });
297
    return this._controllerState;
1,041✔
298
  }
299

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

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

312
    const inside = pos[0] >= 0 && pos[0] <= width && pos[1] >= 0 && pos[1] <= height;
100✔
313
    if (inside && event) {
107✔
314
      event.stopPropagation();
65✔
315
    }
316
    return inside;
100✔
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;
134✔
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(() => {
9✔
334
      if (this._eventStartBlocked === timer) {
9!
335
        this._eventStartBlocked = null;
9✔
336
      }
337
    }, timeout);
338
    this._eventStartBlocked = timer;
9✔
339
  }
340

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

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

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

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

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

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

383
    // Interaction toggles
384
    this.scrollZoom = scrollZoom;
916✔
385
    this.dragPan = dragPan;
916✔
386
    this.dragRotate = dragRotate;
916✔
387
    this.doubleClickZoom = doubleClickZoom;
916✔
388
    this.touchZoom = touchZoom;
916✔
389
    this.touchRotate = touchRotate;
916✔
390
    this.keyboard = keyboard;
916✔
391

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

406
  updateTransition() {
407
    this.transitionManager.updateTransition();
406✔
408
  }
409

410
  toggleEvents(eventNames, enabled) {
411
    if (this.eventManager) {
6,414✔
412
      eventNames.forEach(eventName => {
100✔
413
        if (this._events[eventName] !== enabled) {
226✔
414
          this._events[eventName] = enabled;
130✔
415
          if (enabled) {
130✔
416
            // eslint-disable-next-line @typescript-eslint/unbound-method
417
            this.eventManager.on(eventName, this.handleEvent);
105✔
418
          } else {
419
            // eslint-disable-next-line @typescript-eslint/unbound-method
420
            this.eventManager.off(eventName, this.handleEvent);
25✔
421
          }
422
        }
423
      });
424
    }
425
  }
426

427
  // Private Methods
428

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

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

439
    this.state = newControllerState.getState();
273✔
440
    this._setInteractionState(interactionState);
273✔
441

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

717
    let newControllerState = this.controllerState;
8✔
718
    if (this.touchZoom) {
8!
719
      const {scale} = event;
8✔
720
      const pos = this.getCenter(event);
8✔
721
      const rawScaleLog = Math.log2(scale);
8✔
722
      const previousScaleLog = pinchEventWorkaround._smoothedPinchScaleLog ?? 0;
8!
723
      const smoothedScaleLog =
724
        previousScaleLog + clampPinchZoomDelta(rawScaleLog - previousScaleLog);
8✔
725
      pinchEventWorkaround._smoothedPinchScaleLog = smoothedScaleLog;
8✔
726
      newControllerState = newControllerState.zoom({pos, scale: Math.pow(2, smoothedScaleLog)});
8✔
727
    }
728
    if (this.touchRotate) {
8✔
729
      const {rotation} = event;
6✔
730
      newControllerState = newControllerState.rotate({
6✔
731
        deltaAngleX: pinchEventWorkaround._startPinchRotation - rotation
732
      });
733
    }
734

735
    this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {
8✔
736
      isDragging: true,
737
      isPanning: this.touchZoom,
738
      isZooming: this.touchZoom,
739
      isRotating: this.touchRotate
740
    });
741
    return true;
8✔
742
  }
743

744
  protected _onPinchEnd(event: MjolnirGestureEvent): boolean {
745
    if (!this.isDragging()) {
22✔
746
      return false;
7✔
747
    }
748
    const newControllerState = this.controllerState.zoomEnd().rotateEnd();
15✔
749
    this.updateViewport(newControllerState, null, {
15✔
750
      isDragging: false,
751
      isPanning: false,
752
      isZooming: false,
753
      isRotating: false
754
    });
755
    pinchEventWorkaround._startPinchRotation = null;
15✔
756
    pinchEventWorkaround._smoothedPinchScaleLog = null;
15✔
757
    return true;
15✔
758
  }
759

760
  // Default handler for the `dblclick` event.
761
  protected _onDoubleClick(event: MjolnirGestureEvent): boolean {
762
    if (!this.doubleClickZoom) {
21✔
763
      return false;
7✔
764
    }
765
    if (Date.now() < this._suppressDoubleClickUntil) {
14!
NEW
766
      return false;
×
767
    }
768
    const pos = this.getCenter(event);
14✔
769
    if (!this.isPointInBounds(pos, event)) {
14✔
770
      return false;
7✔
771
    }
772

773
    const isZoomOut = this.isFunctionKeyPressed(event);
7✔
774

775
    const newControllerState = this.controllerState.zoom({pos, scale: isZoomOut ? 0.5 : 2});
7!
776
    this.updateViewport(newControllerState, this._getTransitionProps({around: pos}), {
21✔
777
      isZooming: true,
778
      isPanning: true
779
    });
780
    this.blockEvents(100);
21✔
781
    return true;
21✔
782
  }
783

784
  protected _onPointerDown(event: MjolnirEvent): boolean {
785
    if (!this.touchZoom || !this._isPrimaryPointer(event)) {
2!
NEW
786
      this._resetOneFingerZoom();
×
NEW
787
      return false;
×
788
    }
789

790
    const pos = this.getCenter(event as MjolnirGestureEvent);
2✔
791
    if (!this.isPointInBounds(pos, event)) {
2!
NEW
792
      this._resetOneFingerZoom();
×
NEW
793
      return false;
×
794
    }
795

796
    const time = this._getEventTime(event);
2✔
797
    const pointerId = (event.srcEvent as PointerEvent).pointerId;
2✔
798
    if (
2✔
799
      this._lastTap &&
4✔
800
      time - this._lastTap.time <= DOUBLE_TAP_DRAG_INTERVAL &&
801
      getDistance(pos, this._lastTap.pos) <= DOUBLE_TAP_DRAG_MAX_TAP_DISTANCE
802
    ) {
803
      this._tapStart = null;
1✔
804
      this._lastTap = null;
1✔
805
      this._oneFingerZoom = {startPos: pos, pointerId, active: false};
1✔
806
      event.srcEvent.preventDefault();
1✔
807
      event.stopPropagation();
1✔
808
      return true;
1✔
809
    }
810

811
    this._tapStart = {pos, time, pointerId};
1✔
812
    this._lastTap = null;
1✔
813
    if ((event.srcEvent as PointerEvent).pointerType === 'touch') {
1!
814
      event.srcEvent.preventDefault();
1✔
815
    }
816
    return false;
1✔
817
  }
818

819
  protected _onPointerMove(event: MjolnirEvent): boolean {
820
    const oneFingerZoom = this._oneFingerZoom;
1✔
821
    if (!oneFingerZoom || !this._isSamePointer(event, oneFingerZoom.pointerId)) {
1!
NEW
822
      return false;
×
823
    }
824

825
    const pos = this.getCenter(event as MjolnirGestureEvent);
1✔
826
    const dy = oneFingerZoom.startPos[1] - pos[1];
1✔
827
    if (!oneFingerZoom.active && Math.abs(dy) < DOUBLE_TAP_DRAG_START_THRESHOLD) {
1!
NEW
828
      event.srcEvent.preventDefault();
×
NEW
829
      event.stopPropagation();
×
NEW
830
      return true;
×
831
    }
832

833
    const scale = Math.pow(2, dy / DOUBLE_TAP_DRAG_PIXELS_PER_ZOOM);
1✔
834
    const startPos = oneFingerZoom.startPos;
1✔
835
    let newControllerState = this.controllerState;
1✔
836
    if (!oneFingerZoom.active) {
1!
837
      oneFingerZoom.active = true;
1✔
838
      newControllerState = newControllerState.zoomStart({pos: startPos});
1✔
839
    }
840
    newControllerState = newControllerState.zoom({pos: startPos, scale});
1✔
841
    this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {
1✔
842
      isDragging: true,
843
      isPanning: true,
844
      isZooming: true
845
    });
846

847
    event.srcEvent.preventDefault();
1✔
848
    event.stopPropagation();
1✔
849
    return true;
1✔
850
  }
851

852
  protected _onPointerUp(event: MjolnirEvent): boolean {
853
    const oneFingerZoom = this._oneFingerZoom;
2✔
854
    if (oneFingerZoom && this._isSamePointer(event, oneFingerZoom.pointerId)) {
2✔
855
      this._oneFingerZoom = null;
1✔
856
      if (oneFingerZoom.active) {
1!
857
        const newControllerState = this.controllerState.zoomEnd();
1✔
858
        this.updateViewport(newControllerState, null, {
1✔
859
          isDragging: false,
860
          isPanning: false,
861
          isZooming: false
862
        });
863
        this._suppressDoubleClickUntil = Date.now() + 100;
1✔
864
        this.blockEvents(100);
1✔
865
        event.srcEvent.preventDefault();
1✔
866
        event.stopPropagation();
1✔
867
        return true;
1✔
868
      }
NEW
869
      return false;
×
870
    }
871

872
    if (event.type === 'pointercancel') {
1!
NEW
873
      this._resetOneFingerZoom();
×
NEW
874
      return false;
×
875
    }
876

877
    const tapStart = this._tapStart;
1✔
878
    if (!tapStart || !this._isSamePointer(event, tapStart.pointerId)) {
1!
NEW
879
      return false;
×
880
    }
881

882
    const pos = this.getCenter(event as MjolnirGestureEvent);
1✔
883
    const time = this._getEventTime(event);
1✔
884
    if (
1!
885
      time - tapStart.time <= DOUBLE_TAP_DRAG_MAX_TAP_DURATION &&
2✔
886
      getDistance(pos, tapStart.pos) <= DOUBLE_TAP_DRAG_MAX_TAP_DISTANCE
887
    ) {
888
      this._lastTap = {pos, time, pointerId: tapStart.pointerId};
1✔
889
    } else {
NEW
890
      this._lastTap = null;
×
891
    }
892
    this._tapStart = null;
1✔
893
    if ((event.srcEvent as PointerEvent).pointerType === 'touch') {
1!
894
      event.srcEvent.preventDefault();
1✔
895
    }
896
    return false;
1✔
897
  }
898

899
  private _resetOneFingerZoom(): void {
900
    this._tapStart = null;
22✔
901
    this._lastTap = null;
22✔
902
    this._oneFingerZoom = null;
22✔
903
  }
904

905
  private _getEventTime(event: MjolnirEvent): number {
906
    return (event as any).timeStamp || event.srcEvent.timeStamp || Date.now();
3✔
907
  }
908

909
  private _isPrimaryPointer(event: MjolnirEvent): boolean {
910
    const pointers = (event as any).pointers;
2✔
911
    if (pointers && pointers.length > 1) {
2!
NEW
912
      return false;
×
913
    }
914
    const srcEvent = event.srcEvent as PointerEvent;
2✔
915
    if (srcEvent.pointerType === 'mouse') {
2!
NEW
916
      return (event as any).leftButton !== false;
×
917
    }
918
    return true;
2✔
919
  }
920

921
  private _isSamePointer(event: MjolnirEvent, pointerId?: number): boolean {
922
    return pointerId === undefined || (event.srcEvent as PointerEvent).pointerId === pointerId;
3✔
923
  }
924

925
  // Default handler for the `keydown` event
926
  protected _onKeyDown(event: MjolnirKeyEvent): boolean {
927
    if (!this.keyboard) {
90✔
928
      return false;
7✔
929
    }
930
    const funcKey = this.isFunctionKeyPressed(event);
83✔
931
    // @ts-ignore
932
    const {zoomSpeed, moveSpeed, rotateSpeedX, rotateSpeedY} = this.keyboard === true ? {} : this.keyboard;
83✔
933
    const {controllerState} = this;
90✔
934
    let newControllerState;
935
    const interactionState: InteractionState = {};
90✔
936

937
    switch (event.srcEvent.code) {
90!
938
      case 'Minus':
939
        newControllerState = funcKey
19✔
940
          ? controllerState.zoomOut(zoomSpeed).zoomOut(zoomSpeed)
941
          : controllerState.zoomOut(zoomSpeed);
942
        interactionState.isZooming = true;
19✔
943
        break;
19✔
944
      case 'Equal':
945
        newControllerState = funcKey
14✔
946
          ? controllerState.zoomIn(zoomSpeed).zoomIn(zoomSpeed)
947
          : controllerState.zoomIn(zoomSpeed);
948
        interactionState.isZooming = true;
14✔
949
        break;
14✔
950
      case 'ArrowLeft':
951
        if (funcKey) {
13✔
952
          newControllerState = controllerState.rotateLeft(rotateSpeedX);
5✔
953
          interactionState.isRotating = true;
5✔
954
        } else {
955
          newControllerState = controllerState.moveLeft(moveSpeed);
8✔
956
          interactionState.isPanning = true;
8✔
957
        }
958
        break;
13✔
959
      case 'ArrowRight':
960
        if (funcKey) {
12✔
961
          newControllerState = controllerState.rotateRight(rotateSpeedX);
5✔
962
          interactionState.isRotating = true;
5✔
963
        } else {
964
          newControllerState = controllerState.moveRight(moveSpeed);
7✔
965
          interactionState.isPanning = true;
7✔
966
        }
967
        break;
12✔
968
      case 'ArrowUp':
969
        if (funcKey) {
13✔
970
          newControllerState = controllerState.rotateUp(rotateSpeedY);
5✔
971
          interactionState.isRotating = true;
5✔
972
        } else {
973
          newControllerState = controllerState.moveUp(moveSpeed);
8✔
974
          interactionState.isPanning = true;
8✔
975
        }
976
        break;
13✔
977
      case 'ArrowDown':
978
        if (funcKey) {
12✔
979
          newControllerState = controllerState.rotateDown(rotateSpeedY);
5✔
980
          interactionState.isRotating = true;
5✔
981
        } else {
982
          newControllerState = controllerState.moveDown(moveSpeed);
7✔
983
          interactionState.isPanning = true;
7✔
984
        }
985
        break;
12✔
986
      default:
987
        return false;
×
988
    }
989
    this.updateViewport(newControllerState, this._getTransitionProps(), interactionState);
83✔
990
    return true;
83✔
991
  }
992

993
  protected _getTransitionProps(opts?: any): TransitionProps {
994
    const {transition} = this;
390✔
995

996
    if (!transition || !transition.transitionInterpolator) {
390✔
997
      return NO_TRANSITION_PROPS;
1✔
998
    }
999

1000
    // Enables Transitions on double-tap and key-down events.
1001
    return opts
389✔
1002
      ? {
1003
        ...transition,
1004
        transitionInterpolator: new LinearInterpolator({
1005
          ...opts,
1006
          ...(transition.transitionInterpolator as LinearInterpolator).opts,
1007
          makeViewport: this.controllerState.makeViewport
1008
        })
1009
      }
1010
      : transition;
1011
  }
1012
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc