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

visgl / deck.gl / 23317415361

19 Mar 2026 09:18PM UTC coverage: 91.038% (-0.005%) from 91.043%
23317415361

push

github

web-flow
feat(core): more intuitive OrbitController (#10117)

7026 of 7752 branches covered (90.63%)

Branch coverage included in aggregate %.

71 of 79 new or added lines in 5 files covered. (89.87%)

57893 of 63558 relevant lines covered (91.09%)

14118.76 hits per line

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

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

1✔
5
import {clamp} from '@math.gl/core';
1✔
6
import Controller, {ControllerProps, InteractionState} from './controller';
1✔
7
import ViewState from './view-state';
1✔
8
import {worldToLngLat, lngLatToWorld as _lngLatToWorld} from '@math.gl/web-mercator';
1✔
9
import assert from '../utils/assert';
1✔
10
import {mod} from '../utils/math-utils';
1✔
11

1✔
12
import LinearInterpolator from '../transitions/linear-interpolator';
1✔
13
import type Viewport from '../viewports/viewport';
1✔
14

1✔
15
const PITCH_MOUSE_THRESHOLD = 5;
1✔
16
const PITCH_ACCEL = 1.2;
1✔
17
const WEB_MERCATOR_TILE_SIZE = 512;
1✔
18
const WEB_MERCATOR_MAX_BOUNDS = [
1✔
19
  [-Infinity, -90],
1✔
20
  [Infinity, 90]
1✔
21
] satisfies ControllerProps['maxBounds'];
1✔
22

1✔
23
/** The web mercator utility `lngLatToWorld` throws if invalid coordinates are provided.
1✔
24
 * This wrapper clamps user input to calculate common positions safely. */
1✔
25
function lngLatToWorld([lng, lat]: number[]): number[] {
2,436✔
26
  if (Math.abs(lat) > 90) {
2,436!
27
    lat = Math.sign(lat) * 90;
×
28
  }
×
29
  if (Number.isFinite(lng)) {
2,436✔
30
    const [x, y] = _lngLatToWorld([lng, lat]);
4✔
31
    return [x, clamp(y, 0, WEB_MERCATOR_TILE_SIZE)];
4✔
32
  }
4✔
33
  const [, y] = _lngLatToWorld([0, lat]);
2,432✔
34
  return [lng, clamp(y, 0, WEB_MERCATOR_TILE_SIZE)];
2,432✔
35
}
2,432✔
36

1✔
37
export type MapStateProps = {
1✔
38
  /** Mapbox viewport properties */
1✔
39
  /** The width of the viewport */
1✔
40
  width: number;
1✔
41
  /** The height of the viewport */
1✔
42
  height: number;
1✔
43
  /** The latitude at the center of the viewport */
1✔
44
  latitude: number;
1✔
45
  /** The longitude at the center of the viewport */
1✔
46
  longitude: number;
1✔
47
  /** The tile zoom level of the map. */
1✔
48
  zoom: number;
1✔
49
  /** The bearing of the viewport in degrees */
1✔
50
  bearing?: number;
1✔
51
  /** The pitch of the viewport in degrees */
1✔
52
  pitch?: number;
1✔
53
  /**
1✔
54
   * Specify the altitude of the viewport camera
1✔
55
   * Unit: map heights, default 1.5
1✔
56
   * Non-public API, see https://github.com/mapbox/mapbox-gl-js/issues/1137
1✔
57
   */
1✔
58
  altitude?: number;
1✔
59
  /** Viewport position */
1✔
60
  position?: [number, number, number];
1✔
61

1✔
62
  /** Viewport constraints */
1✔
63
  maxZoom?: number;
1✔
64
  minZoom?: number;
1✔
65
  maxPitch?: number;
1✔
66
  minPitch?: number;
1✔
67

1✔
68
  /** Normalize viewport props to fit map height into viewport. Default `true` */
1✔
69
  normalize?: boolean;
1✔
70

1✔
71
  maxBounds?: ControllerProps['maxBounds'];
1✔
72
};
1✔
73

1✔
74
export type MapStateInternal = {
1✔
75
  /** Interaction states, required to calculate change during transform */
1✔
76
  /* The point on map being grabbed when the operation first started */
1✔
77
  startPanLngLat?: [number, number];
1✔
78
  /* Center of the zoom when the operation first started */
1✔
79
  startZoomLngLat?: [number, number];
1✔
80
  /* Pointer position when rotation started */
1✔
81
  startRotatePos?: [number, number];
1✔
82
  /* The lng/lat/altitude point at the rotation pivot (where rotation started) */
1✔
83
  startRotateLngLat?: [number, number, number];
1✔
84
  /** Bearing when current perspective rotate operation started */
1✔
85
  startBearing?: number;
1✔
86
  /** Pitch when current perspective rotate operation started */
1✔
87
  startPitch?: number;
1✔
88
  /** Zoom when current zoom operation started */
1✔
89
  startZoom?: number;
1✔
90
};
1✔
91

1✔
92
/* Utils */
1✔
93

1✔
94
export class MapState extends ViewState<MapState, MapStateProps, MapStateInternal> {
1✔
95
  makeViewport: (props: Record<string, any>) => Viewport;
1✔
96
  /* get optional altitude for rotation pivot
1✔
97
   *   - undefined: rotate around viewport center (no pivot point)
1✔
98
   *   - 0: rotate around pointer position at ground level
1✔
99
   *   - other value: rotate around pointer position at specified altitude
1✔
100
   */
1✔
101
  getAltitude?: (pos: [number, number]) => number | undefined;
1✔
102

1✔
103
  constructor(
1✔
104
    options: MapStateProps &
716✔
105
      MapStateInternal & {
716✔
106
        makeViewport: (props: Record<string, any>) => Viewport;
716✔
107
        getAltitude?: (pos: [number, number]) => number | undefined;
716✔
108
      }
716✔
109
  ) {
716✔
110
    const {
716✔
111
      /** Mapbox viewport properties */
716✔
112
      /** The width of the viewport */
716✔
113
      width,
716✔
114
      /** The height of the viewport */
716✔
115
      height,
716✔
116
      /** The latitude at the center of the viewport */
716✔
117
      latitude,
716✔
118
      /** The longitude at the center of the viewport */
716✔
119
      longitude,
716✔
120
      /** The tile zoom level of the map. */
716✔
121
      zoom,
716✔
122
      /** The bearing of the viewport in degrees */
716✔
123
      bearing = 0,
716✔
124
      /** The pitch of the viewport in degrees */
716✔
125
      pitch = 0,
716✔
126
      /**
716✔
127
       * Specify the altitude of the viewport camera
716✔
128
       * Unit: map heights, default 1.5
716✔
129
       * Non-public API, see https://github.com/mapbox/mapbox-gl-js/issues/1137
716✔
130
       */
716✔
131
      altitude = 1.5,
716✔
132
      /** Viewport position */
716✔
133
      position = [0, 0, 0],
716✔
134

716✔
135
      /** Viewport constraints */
716✔
136
      maxZoom = 20,
716✔
137
      minZoom = 0,
716✔
138
      maxPitch = 60,
716✔
139
      minPitch = 0,
716✔
140

716✔
141
      /** Interaction states, required to calculate change during transform */
716✔
142
      /* The point on map being grabbed when the operation first started */
716✔
143
      startPanLngLat,
716✔
144
      /* Center of the zoom when the operation first started */
716✔
145
      startZoomLngLat,
716✔
146
      /* Pointer position when rotation started */
716✔
147
      startRotatePos,
716✔
148
      /* The lng/lat point at the rotation pivot (where rotation started) */
716✔
149
      startRotateLngLat,
716✔
150
      /** Bearing when current perspective rotate operation started */
716✔
151
      startBearing,
716✔
152
      /** Pitch when current perspective rotate operation started */
716✔
153
      startPitch,
716✔
154
      /** Zoom when current zoom operation started */
716✔
155
      startZoom,
716✔
156

716✔
157
      /** Normalize viewport props to fit map height into viewport */
716✔
158
      normalize = true
716✔
159
    } = options;
716✔
160

716✔
161
    assert(Number.isFinite(longitude)); // `longitude` must be supplied
716✔
162
    assert(Number.isFinite(latitude)); // `latitude` must be supplied
716✔
163
    assert(Number.isFinite(zoom)); // `zoom` must be supplied
716✔
164

716✔
165
    const maxBounds = options.maxBounds || (normalize ? WEB_MERCATOR_MAX_BOUNDS : null);
716✔
166

716✔
167
    super(
716✔
168
      {
716✔
169
        width,
716✔
170
        height,
716✔
171
        latitude,
716✔
172
        longitude,
716✔
173
        zoom,
716✔
174
        bearing,
716✔
175
        pitch,
716✔
176
        altitude,
716✔
177
        maxZoom,
716✔
178
        minZoom,
716✔
179
        maxPitch,
716✔
180
        minPitch,
716✔
181
        normalize,
716✔
182
        position,
716✔
183
        maxBounds
716✔
184
      },
716✔
185
      {
716✔
186
        startPanLngLat,
716✔
187
        startZoomLngLat,
716✔
188
        startRotatePos,
716✔
189
        startRotateLngLat,
716✔
190
        startBearing,
716✔
191
        startPitch,
716✔
192
        startZoom
716✔
193
      }
716✔
194
    );
716✔
195

716✔
196
    this.makeViewport = options.makeViewport;
716✔
197
    this.getAltitude = options.getAltitude;
716✔
198
  }
716✔
199

1✔
200
  /**
1✔
201
   * Start panning
1✔
202
   * @param {[Number, Number]} pos - position on screen where the pointer grabs
1✔
203
   */
1✔
204
  panStart({pos}: {pos: [number, number]}): MapState {
1✔
205
    return this._getUpdatedState({
5✔
206
      startPanLngLat: this._unproject(pos)
5✔
207
    });
5✔
208
  }
5✔
209

1✔
210
  /**
1✔
211
   * Pan
1✔
212
   * @param {[Number, Number]} pos - position on screen where the pointer is
1✔
213
   * @param {[Number, Number], optional} startPos - where the pointer grabbed at
1✔
214
   *   the start of the operation. Must be supplied of `panStart()` was not called
1✔
215
   */
1✔
216
  pan({pos, startPos}: {pos: [number, number]; startPos?: [number, number]}): MapState {
1✔
217
    const startPanLngLat = this.getState().startPanLngLat || this._unproject(startPos);
15✔
218

15✔
219
    if (!startPanLngLat) {
15!
220
      return this;
×
221
    }
×
222

15✔
223
    const viewport = this.makeViewport(this.getViewportProps());
15✔
224
    const newProps = viewport.panByPosition(startPanLngLat, pos);
15✔
225

15✔
226
    return this._getUpdatedState(newProps);
15✔
227
  }
15✔
228

1✔
229
  /**
1✔
230
   * End panning
1✔
231
   * Must call if `panStart()` was called
1✔
232
   */
1✔
233
  panEnd(): MapState {
1✔
234
    return this._getUpdatedState({
5✔
235
      startPanLngLat: null
5✔
236
    });
5✔
237
  }
5✔
238

1✔
239
  /**
1✔
240
   * Start rotating
1✔
241
   * @param {[Number, Number]} pos - position on screen where the center is
1✔
242
   */
1✔
243
  rotateStart({pos}: {pos: [number, number]}): MapState {
1✔
244
    const altitude = this.getAltitude?.(pos);
16✔
245

16✔
246
    return this._getUpdatedState({
16✔
247
      startRotatePos: pos,
16✔
248
      startRotateLngLat: altitude !== undefined ? this._unproject3D(pos, altitude) : undefined,
16!
249
      startBearing: this.getViewportProps().bearing,
16✔
250
      startPitch: this.getViewportProps().pitch
16✔
251
    });
16✔
252
  }
16✔
253

1✔
254
  /**
1✔
255
   * Rotate
1✔
256
   * @param {[Number, Number]} pos - position on screen where the center is
1✔
257
   */
1✔
258
  rotate({
1✔
259
    pos,
10✔
260
    deltaAngleX = 0,
10✔
261
    deltaAngleY = 0
10✔
262
  }: {
10✔
263
    pos?: [number, number];
10✔
264
    deltaAngleX?: number;
10✔
265
    deltaAngleY?: number;
10✔
266
  }): MapState {
10✔
267
    const {startRotatePos, startRotateLngLat, startBearing, startPitch} = this.getState();
10✔
268

10✔
269
    if (!startRotatePos || startBearing === undefined || startPitch === undefined) {
10!
270
      return this;
×
271
    }
×
272
    let newRotation;
10✔
273
    if (pos) {
10✔
274
      newRotation = this._getNewRotation(pos, startRotatePos, startPitch, startBearing);
8✔
275
    } else {
10✔
276
      newRotation = {
2✔
277
        bearing: startBearing + deltaAngleX,
2✔
278
        pitch: startPitch + deltaAngleY
2✔
279
      };
2✔
280
    }
2✔
281

10✔
282
    // If we have a pivot point, adjust the camera position to keep the pivot point fixed
10✔
283
    if (startRotateLngLat) {
10!
284
      const rotatedViewport = this.makeViewport({
×
285
        ...this.getViewportProps(),
×
286
        ...newRotation
×
287
      });
×
288
      // Use panByPosition3D if available (WebMercatorViewport), otherwise fall back to panByPosition
×
289
      const panMethod = 'panByPosition3D' in rotatedViewport ? 'panByPosition3D' : 'panByPosition';
×
290
      return this._getUpdatedState({
×
291
        ...newRotation,
×
292
        ...rotatedViewport[panMethod](startRotateLngLat, startRotatePos)
×
293
      });
×
294
    }
×
295

10✔
296
    return this._getUpdatedState(newRotation);
10✔
297
  }
10✔
298

1✔
299
  /**
1✔
300
   * End rotating
1✔
301
   * Must call if `rotateStart()` was called
1✔
302
   */
1✔
303
  rotateEnd(): MapState {
1✔
304
    return this._getUpdatedState({
15✔
305
      startRotatePos: null,
15✔
306
      startRotateLngLat: null,
15✔
307
      startBearing: null,
15✔
308
      startPitch: null
15✔
309
    });
15✔
310
  }
15✔
311

1✔
312
  /**
1✔
313
   * Start zooming
1✔
314
   * @param {[Number, Number]} pos - position on screen where the center is
1✔
315
   */
1✔
316
  zoomStart({pos}: {pos: [number, number]}): MapState {
1✔
317
    return this._getUpdatedState({
5✔
318
      startZoomLngLat: this._unproject(pos),
5✔
319
      startZoom: this.getViewportProps().zoom
5✔
320
    });
5✔
321
  }
5✔
322

1✔
323
  /**
1✔
324
   * Zoom
1✔
325
   * @param {[Number, Number]} pos - position on screen where the current center is
1✔
326
   * @param {[Number, Number]} startPos - the center position at
1✔
327
   *   the start of the operation. Must be supplied of `zoomStart()` was not called
1✔
328
   * @param {Number} scale - a number between [0, 1] specifying the accumulated
1✔
329
   *   relative scale.
1✔
330
   */
1✔
331
  zoom({
1✔
332
    pos,
28✔
333
    startPos,
28✔
334
    scale
28✔
335
  }: {
28✔
336
    pos: [number, number];
28✔
337
    startPos?: [number, number];
28✔
338
    scale: number;
28✔
339
  }): MapState {
28✔
340
    // Make sure we zoom around the current mouse position rather than map center
28✔
341
    let {startZoom, startZoomLngLat} = this.getState();
28✔
342

28✔
343
    if (!startZoomLngLat) {
28✔
344
      // We have two modes of zoom:
25✔
345
      // scroll zoom that are discrete events (transform from the current zoom level),
25✔
346
      // and pinch zoom that are continuous events (transform from the zoom level when
25✔
347
      // pinch started).
25✔
348
      // If startZoom state is defined, then use the startZoom state;
25✔
349
      // otherwise assume discrete zooming
25✔
350
      startZoom = this.getViewportProps().zoom;
25✔
351
      startZoomLngLat = this._unproject(startPos) || this._unproject(pos);
25✔
352
    }
25✔
353
    if (!startZoomLngLat) {
28!
354
      return this;
×
355
    }
×
356

28✔
357
    const zoom = this._constrainZoom((startZoom as number) + Math.log2(scale));
28✔
358
    const zoomedViewport = this.makeViewport({...this.getViewportProps(), zoom});
28✔
359

28✔
360
    return this._getUpdatedState({
28✔
361
      zoom,
28✔
362
      ...zoomedViewport.panByPosition(startZoomLngLat, pos)
28✔
363
    });
28✔
364
  }
28✔
365

1✔
366
  /**
1✔
367
   * End zooming
1✔
368
   * Must call if `zoomStart()` was called
1✔
369
   */
1✔
370
  zoomEnd(): MapState {
1✔
371
    return this._getUpdatedState({
5✔
372
      startZoomLngLat: null,
5✔
373
      startZoom: null
5✔
374
    });
5✔
375
  }
5✔
376

1✔
377
  zoomIn(speed: number = 2): MapState {
1✔
378
    return this._zoomFromCenter(speed);
11✔
379
  }
11✔
380

1✔
381
  zoomOut(speed: number = 2): MapState {
1✔
382
    return this._zoomFromCenter(1 / speed);
13✔
383
  }
13✔
384

1✔
385
  moveLeft(speed: number = 100): MapState {
1✔
386
    return this._panFromCenter([speed, 0]);
4✔
387
  }
4✔
388

1✔
389
  moveRight(speed: number = 100): MapState {
1✔
390
    return this._panFromCenter([-speed, 0]);
3✔
391
  }
3✔
392

1✔
393
  moveUp(speed: number = 100): MapState {
1✔
394
    return this._panFromCenter([0, speed]);
4✔
395
  }
4✔
396

1✔
397
  moveDown(speed: number = 100): MapState {
1✔
398
    return this._panFromCenter([0, -speed]);
3✔
399
  }
3✔
400

1✔
401
  rotateLeft(speed: number = 15): MapState {
1✔
402
    return this._getUpdatedState({
4✔
403
      bearing: this.getViewportProps().bearing - speed
4✔
404
    });
4✔
405
  }
4✔
406

1✔
407
  rotateRight(speed: number = 15): MapState {
1✔
408
    return this._getUpdatedState({
3✔
409
      bearing: this.getViewportProps().bearing + speed
3✔
410
    });
3✔
411
  }
3✔
412

1✔
413
  rotateUp(speed: number = 10): MapState {
1✔
414
    return this._getUpdatedState({
4✔
415
      pitch: this.getViewportProps().pitch + speed
4✔
416
    });
4✔
417
  }
4✔
418

1✔
419
  rotateDown(speed: number = 10): MapState {
1✔
420
    return this._getUpdatedState({
3✔
421
      pitch: this.getViewportProps().pitch - speed
3✔
422
    });
3✔
423
  }
3✔
424

1✔
425
  shortestPathFrom(viewState: MapState): MapStateProps {
1✔
426
    // const endViewStateProps = new this.ControllerState(endProps).shortestPathFrom(startViewstate);
56✔
427
    const fromProps = viewState.getViewportProps();
56✔
428
    const props = {...this.getViewportProps()};
56✔
429
    const {bearing, longitude} = props;
56✔
430

56✔
431
    if (Math.abs(bearing - fromProps.bearing) > 180) {
56✔
432
      props.bearing = bearing < 0 ? bearing + 360 : bearing - 360;
1!
433
    }
1✔
434
    if (Math.abs(longitude - fromProps.longitude) > 180) {
56✔
435
      props.longitude = longitude < 0 ? longitude + 360 : longitude - 360;
1!
436
    }
1✔
437
    return props;
56✔
438
  }
56✔
439

1✔
440
  // Apply any constraints (mathematical or defined by _viewportProps) to map state
1✔
441
  applyConstraints(props: Required<MapStateProps>): Required<MapStateProps> {
1✔
442
    // Ensure pitch is within specified range
596✔
443
    const {maxPitch, minPitch, pitch, longitude, bearing, normalize, maxBounds} = props;
596✔
444

596✔
445
    if (normalize) {
596✔
446
      if (longitude < -180 || longitude > 180) {
595✔
447
        props.longitude = mod(longitude + 180, 360) - 180;
1✔
448
      }
1✔
449
      if (bearing < -180 || bearing > 180) {
595✔
450
        props.bearing = mod(bearing + 180, 360) - 180;
1✔
451
      }
1✔
452
    }
595✔
453
    props.pitch = clamp(pitch, minPitch, maxPitch);
596✔
454

596✔
455
    props.zoom = this._constrainZoom(props.zoom, props);
596✔
456

596✔
457
    if (maxBounds) {
596✔
458
      const bl = lngLatToWorld(maxBounds[0]);
595✔
459
      const tr = lngLatToWorld(maxBounds[1]);
595✔
460
      // calculate center and zoom ranges at pitch=0 and bearing=0
595✔
461
      // to maintain visual stability when rotating
595✔
462
      const scale = 2 ** props.zoom;
595✔
463
      const halfWidth = props.width / 2 / scale;
595✔
464
      const halfHeight = props.height / 2 / scale;
595✔
465
      const [minLng, minLat] = worldToLngLat([bl[0] + halfWidth, bl[1] + halfHeight]);
595✔
466
      const [maxLng, maxLat] = worldToLngLat([tr[0] - halfWidth, tr[1] - halfHeight]);
595✔
467
      props.longitude = clamp(props.longitude, minLng, maxLng);
595✔
468
      props.latitude = clamp(props.latitude, minLat, maxLat);
595✔
469
    }
595✔
470

596✔
471
    return props;
596✔
472
  }
596✔
473

1✔
474
  /* Private methods */
1✔
475

1✔
476
  _constrainZoom(zoom: number, props?: Required<MapStateProps>): number {
1✔
477
    props ||= this.getViewportProps();
624✔
478
    const {maxZoom, maxBounds} = props;
624✔
479

624✔
480
    const shouldApplyMaxBounds = maxBounds !== null && props.width > 0 && props.height > 0;
624✔
481
    let {minZoom} = props;
624✔
482

624✔
483
    if (shouldApplyMaxBounds) {
624✔
484
      const bl = lngLatToWorld(maxBounds[0]);
623✔
485
      const tr = lngLatToWorld(maxBounds[1]);
623✔
486
      const w = tr[0] - bl[0];
623✔
487
      const h = tr[1] - bl[1];
623✔
488
      // ignore bound size of 0 or Infinity
623✔
489
      if (Number.isFinite(w) && w > 0) {
623✔
490
        minZoom = Math.max(minZoom, Math.log2(props.width / w));
1✔
491
      }
1✔
492
      if (Number.isFinite(h) && h > 0) {
623✔
493
        minZoom = Math.max(minZoom, Math.log2(props.height / h));
623✔
494
      }
623✔
495
      if (minZoom > maxZoom) minZoom = maxZoom;
623!
496
    }
623✔
497
    return clamp(zoom, minZoom, maxZoom);
624✔
498
  }
624✔
499

1✔
500
  _zoomFromCenter(scale) {
1✔
501
    const {width, height} = this.getViewportProps();
24✔
502
    return this.zoom({
24✔
503
      pos: [width / 2, height / 2],
24✔
504
      scale
24✔
505
    });
24✔
506
  }
24✔
507

1✔
508
  _panFromCenter(offset) {
1✔
509
    const {width, height} = this.getViewportProps();
14✔
510
    return this.pan({
14✔
511
      startPos: [width / 2, height / 2],
14✔
512
      pos: [width / 2 + offset[0], height / 2 + offset[1]]
14✔
513
    });
14✔
514
  }
14✔
515

1✔
516
  _getUpdatedState(newProps): MapState {
1✔
517
    // @ts-ignore
136✔
518
    return new this.constructor({
136✔
519
      makeViewport: this.makeViewport,
136✔
520
      ...this.getViewportProps(),
136✔
521
      ...this.getState(),
136✔
522
      ...newProps
136✔
523
    });
136✔
524
  }
136✔
525

1✔
526
  _unproject(pos?: [number, number]): [number, number] | undefined {
1✔
527
    const viewport = this.makeViewport(this.getViewportProps());
74✔
528
    // @ts-ignore
74✔
529
    return pos && viewport.unproject(pos);
74✔
530
  }
74✔
531

1✔
532
  _unproject3D(pos: [number, number], altitude: number): [number, number, number] {
1✔
533
    const viewport = this.makeViewport(this.getViewportProps());
×
534
    return viewport.unproject(pos, {targetZ: altitude}) as [number, number, number];
×
535
  }
×
536

1✔
537
  _getNewRotation(
1✔
538
    pos: [number, number],
8✔
539
    startPos: [number, number],
8✔
540
    startPitch: number,
8✔
541
    startBearing: number
8✔
542
  ): {
8✔
543
    pitch: number;
8✔
544
    bearing: number;
8✔
545
  } {
8✔
546
    const deltaX = pos[0] - startPos[0];
8✔
547
    const deltaY = pos[1] - startPos[1];
8✔
548
    const centerY = pos[1];
8✔
549
    const startY = startPos[1];
8✔
550
    const {width, height} = this.getViewportProps();
8✔
551

8✔
552
    const deltaScaleX = deltaX / width;
8✔
553
    let deltaScaleY = 0;
8✔
554

8✔
555
    if (deltaY > 0) {
8✔
556
      if (Math.abs(height - startY) > PITCH_MOUSE_THRESHOLD) {
4✔
557
        // Move from 0 to -1 as we drag upwards
2✔
558
        deltaScaleY = (deltaY / (startY - height)) * PITCH_ACCEL;
2✔
559
      }
2✔
560
    } else if (deltaY < 0) {
4✔
561
      if (startY > PITCH_MOUSE_THRESHOLD) {
4✔
562
        // Move from 0 to 1 as we drag upwards
4✔
563
        deltaScaleY = 1 - centerY / startY;
4✔
564
      }
4✔
565
    }
4✔
566
    // clamp deltaScaleY to [-1, 1] so that rotation is constrained between minPitch and maxPitch.
8✔
567
    // deltaScaleX does not need to be clamped as bearing does not have constraints.
8✔
568
    deltaScaleY = clamp(deltaScaleY, -1, 1);
8✔
569

8✔
570
    const {minPitch, maxPitch} = this.getViewportProps();
8✔
571

8✔
572
    const bearing = startBearing + 180 * deltaScaleX;
8✔
573
    let pitch = startPitch;
8✔
574
    if (deltaScaleY > 0) {
8✔
575
      // Gradually increase pitch
4✔
576
      pitch = startPitch + deltaScaleY * (maxPitch - startPitch);
4✔
577
    } else if (deltaScaleY < 0) {
4✔
578
      // Gradually decrease pitch
2✔
579
      pitch = startPitch - deltaScaleY * (minPitch - startPitch);
2✔
580
    }
2✔
581

8✔
582
    return {
8✔
583
      pitch,
8✔
584
      bearing
8✔
585
    };
8✔
586
  }
8✔
587
}
1✔
588

1✔
589
export default class MapController extends Controller<MapState> {
13✔
590
  ControllerState = MapState;
13✔
591

13✔
592
  transition = {
13✔
593
    transitionDuration: 300,
13✔
594
    transitionInterpolator: new LinearInterpolator({
13✔
595
      transitionProps: {
13✔
596
        compare: ['longitude', 'latitude', 'zoom', 'bearing', 'pitch', 'position'],
13✔
597
        required: ['longitude', 'latitude', 'zoom']
13✔
598
      }
13✔
599
    })
13✔
600
  };
13✔
601

13✔
602
  dragMode: 'pan' | 'rotate' = 'pan';
13✔
603

13✔
604
  /**
13✔
605
   * Rotation pivot behavior:
13✔
606
   * - 'center': Rotate around viewport center (default)
13✔
607
   * - '2d': Rotate around pointer position at ground level (z=0)
13✔
608
   * - '3d': Rotate around 3D picked point (requires pickPosition callback)
13✔
609
   */
13✔
610
  protected rotationPivot: 'center' | '2d' | '3d' = 'center';
13✔
611

13✔
612
  setProps(
13✔
613
    props: ControllerProps &
13✔
614
      MapStateProps & {
13✔
615
        rotationPivot?: 'center' | '2d' | '3d';
13✔
616
        getAltitude?: (pos: [number, number]) => number | undefined;
13✔
617
      }
13✔
618
  ) {
13✔
619
    if ('rotationPivot' in props) {
13!
620
      this.rotationPivot = props.rotationPivot || 'center';
×
621
    }
×
622
    // this will be passed to MapState constructor
13✔
623
    props.getAltitude = this._getAltitude;
13✔
624
    props.position = props.position || [0, 0, 0];
13✔
625
    props.maxBounds =
13✔
626
      props.maxBounds || (props.normalize === false ? null : WEB_MERCATOR_MAX_BOUNDS);
13!
627

13✔
628
    super.setProps(props);
13✔
629
  }
13✔
630

13✔
631
  protected updateViewport(
13✔
632
    newControllerState: MapState,
103✔
633
    extraProps: Record<string, any> | null = null,
103✔
634
    interactionState: InteractionState = {}
103✔
635
  ): void {
103✔
636
    // Inject rotation pivot position during rotation for visual feedback
103✔
637
    const state = newControllerState.getState();
103✔
638
    if (interactionState.isDragging && state.startRotateLngLat) {
103!
639
      interactionState = {
×
640
        ...interactionState,
×
641
        rotationPivotPosition: state.startRotateLngLat
×
642
      };
×
643
    } else if (interactionState.isDragging === false) {
103✔
644
      // Clear pivot when drag ends
18✔
645
      interactionState = {...interactionState, rotationPivotPosition: undefined};
18✔
646
    }
18✔
647

103✔
648
    super.updateViewport(newControllerState, extraProps, interactionState);
103✔
649
  }
103✔
650

13✔
651
  /** Add altitude to rotateStart params based on rotationPivot mode */
13✔
652
  protected _getAltitude = (pos: [number, number]): number | undefined => {
13✔
653
    if (this.rotationPivot === '2d') {
9!
NEW
654
      return 0;
×
655
    } else if (this.rotationPivot === '3d') {
9!
656
      if (this.pickPosition) {
×
657
        const {x, y} = this.props;
×
658
        const pickResult = this.pickPosition(x + pos[0], y + pos[1]);
×
659
        if (pickResult && pickResult.coordinate && pickResult.coordinate.length >= 3) {
×
NEW
660
          return pickResult.coordinate[2];
×
661
        }
×
662
      }
×
663
    }
×
664
    return undefined;
9✔
665
  };
9✔
666
}
1✔
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