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

visgl / deck.gl / 23208553568

17 Mar 2026 05:51PM UTC coverage: 91.05% (-0.007%) from 91.057%
23208553568

push

github

web-flow
chore: Bump to luma.gl@9.3.0-alpha.6 (#10107)

7005 of 7726 branches covered (90.67%)

Branch coverage included in aggregate %.

5 of 5 new or added lines in 2 files covered. (100.0%)

41 existing lines in 4 files now uncovered.

57764 of 63410 relevant lines covered (91.1%)

14151.8 hits per line

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

93.64
/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} 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

1✔
18
export type MapStateProps = {
1✔
19
  /** Mapbox viewport properties */
1✔
20
  /** The width of the viewport */
1✔
21
  width: number;
1✔
22
  /** The height of the viewport */
1✔
23
  height: number;
1✔
24
  /** The latitude at the center of the viewport */
1✔
25
  latitude: number;
1✔
26
  /** The longitude at the center of the viewport */
1✔
27
  longitude: number;
1✔
28
  /** The tile zoom level of the map. */
1✔
29
  zoom: number;
1✔
30
  /** The bearing of the viewport in degrees */
1✔
31
  bearing?: number;
1✔
32
  /** The pitch of the viewport in degrees */
1✔
33
  pitch?: number;
1✔
34
  /**
1✔
35
   * Specify the altitude of the viewport camera
1✔
36
   * Unit: map heights, default 1.5
1✔
37
   * Non-public API, see https://github.com/mapbox/mapbox-gl-js/issues/1137
1✔
38
   */
1✔
39
  altitude?: number;
1✔
40
  /** Viewport position */
1✔
41
  position?: [number, number, number];
1✔
42

1✔
43
  /** Viewport constraints */
1✔
44
  maxZoom?: number;
1✔
45
  minZoom?: number;
1✔
46
  maxPitch?: number;
1✔
47
  minPitch?: number;
1✔
48

1✔
49
  /** Normalize viewport props to fit map height into viewport. Default `true` */
1✔
50
  normalize?: boolean;
1✔
51

1✔
52
  maxBounds?: ControllerProps['maxBounds'];
1✔
53
};
1✔
54

1✔
55
export type MapStateInternal = {
1✔
56
  /** Interaction states, required to calculate change during transform */
1✔
57
  /* The point on map being grabbed when the operation first started */
1✔
58
  startPanLngLat?: [number, number];
1✔
59
  /* Center of the zoom when the operation first started */
1✔
60
  startZoomLngLat?: [number, number];
1✔
61
  /* Pointer position when rotation started */
1✔
62
  startRotatePos?: [number, number];
1✔
63
  /* The lng/lat/altitude point at the rotation pivot (where rotation started) */
1✔
64
  startRotateLngLat?: [number, number, number];
1✔
65
  /** Bearing when current perspective rotate operation started */
1✔
66
  startBearing?: number;
1✔
67
  /** Pitch when current perspective rotate operation started */
1✔
68
  startPitch?: number;
1✔
69
  /** Zoom when current zoom operation started */
1✔
70
  startZoom?: number;
1✔
71
};
1✔
72

1✔
73
/* Utils */
1✔
74

1✔
75
export class MapState extends ViewState<MapState, MapStateProps, MapStateInternal> {
1✔
76
  makeViewport: (props: Record<string, any>) => Viewport;
1✔
77

1✔
78
  constructor(
1✔
79
    options: MapStateProps &
679✔
80
      MapStateInternal & {
679✔
81
        makeViewport: (props: Record<string, any>) => Viewport;
679✔
82
      }
679✔
83
  ) {
679✔
84
    const {
679✔
85
      /** Mapbox viewport properties */
679✔
86
      /** The width of the viewport */
679✔
87
      width,
679✔
88
      /** The height of the viewport */
679✔
89
      height,
679✔
90
      /** The latitude at the center of the viewport */
679✔
91
      latitude,
679✔
92
      /** The longitude at the center of the viewport */
679✔
93
      longitude,
679✔
94
      /** The tile zoom level of the map. */
679✔
95
      zoom,
679✔
96
      /** The bearing of the viewport in degrees */
679✔
97
      bearing = 0,
679✔
98
      /** The pitch of the viewport in degrees */
679✔
99
      pitch = 0,
679✔
100
      /**
679✔
101
       * Specify the altitude of the viewport camera
679✔
102
       * Unit: map heights, default 1.5
679✔
103
       * Non-public API, see https://github.com/mapbox/mapbox-gl-js/issues/1137
679✔
104
       */
679✔
105
      altitude = 1.5,
679✔
106
      /** Viewport position */
679✔
107
      position = [0, 0, 0],
679✔
108

679✔
109
      /** Viewport constraints */
679✔
110
      maxZoom = 20,
679✔
111
      minZoom = 0,
679✔
112
      maxPitch = 60,
679✔
113
      minPitch = 0,
679✔
114

679✔
115
      /** Interaction states, required to calculate change during transform */
679✔
116
      /* The point on map being grabbed when the operation first started */
679✔
117
      startPanLngLat,
679✔
118
      /* Center of the zoom when the operation first started */
679✔
119
      startZoomLngLat,
679✔
120
      /* Pointer position when rotation started */
679✔
121
      startRotatePos,
679✔
122
      /* The lng/lat point at the rotation pivot (where rotation started) */
679✔
123
      startRotateLngLat,
679✔
124
      /** Bearing when current perspective rotate operation started */
679✔
125
      startBearing,
679✔
126
      /** Pitch when current perspective rotate operation started */
679✔
127
      startPitch,
679✔
128
      /** Zoom when current zoom operation started */
679✔
129
      startZoom,
679✔
130

679✔
131
      /** Normalize viewport props to fit map height into viewport */
679✔
132
      normalize = true,
679✔
133
      maxBounds = null
679✔
134
    } = options;
679✔
135

679✔
136
    assert(Number.isFinite(longitude)); // `longitude` must be supplied
679✔
137
    assert(Number.isFinite(latitude)); // `latitude` must be supplied
679✔
138
    assert(Number.isFinite(zoom)); // `zoom` must be supplied
679✔
139

679✔
140
    super(
679✔
141
      {
679✔
142
        width,
679✔
143
        height,
679✔
144
        latitude,
679✔
145
        longitude,
679✔
146
        zoom,
679✔
147
        bearing,
679✔
148
        pitch,
679✔
149
        altitude,
679✔
150
        maxZoom,
679✔
151
        minZoom,
679✔
152
        maxPitch,
679✔
153
        minPitch,
679✔
154
        normalize,
679✔
155
        position,
679✔
156
        maxBounds
679✔
157
      },
679✔
158
      {
679✔
159
        startPanLngLat,
679✔
160
        startZoomLngLat,
679✔
161
        startRotatePos,
679✔
162
        startRotateLngLat,
679✔
163
        startBearing,
679✔
164
        startPitch,
679✔
165
        startZoom
679✔
166
      }
679✔
167
    );
679✔
168

679✔
169
    this.makeViewport = options.makeViewport;
679✔
170
  }
679✔
171

1✔
172
  /**
1✔
173
   * Start panning
1✔
174
   * @param {[Number, Number]} pos - position on screen where the pointer grabs
1✔
175
   */
1✔
176
  panStart({pos}: {pos: [number, number]}): MapState {
1✔
177
    return this._getUpdatedState({
5✔
178
      startPanLngLat: this._unproject(pos)
5✔
179
    });
5✔
180
  }
5✔
181

1✔
182
  /**
1✔
183
   * Pan
1✔
184
   * @param {[Number, Number]} pos - position on screen where the pointer is
1✔
185
   * @param {[Number, Number], optional} startPos - where the pointer grabbed at
1✔
186
   *   the start of the operation. Must be supplied of `panStart()` was not called
1✔
187
   */
1✔
188
  pan({pos, startPos}: {pos: [number, number]; startPos?: [number, number]}): MapState {
1✔
189
    const startPanLngLat = this.getState().startPanLngLat || this._unproject(startPos);
15✔
190

15✔
191
    if (!startPanLngLat) {
15!
UNCOV
192
      return this;
×
UNCOV
193
    }
×
194

15✔
195
    const viewport = this.makeViewport(this.getViewportProps());
15✔
196
    const newProps = viewport.panByPosition(startPanLngLat, pos);
15✔
197

15✔
198
    return this._getUpdatedState(newProps);
15✔
199
  }
15✔
200

1✔
201
  /**
1✔
202
   * End panning
1✔
203
   * Must call if `panStart()` was called
1✔
204
   */
1✔
205
  panEnd(): MapState {
1✔
206
    return this._getUpdatedState({
5✔
207
      startPanLngLat: null
5✔
208
    });
5✔
209
  }
5✔
210

1✔
211
  /**
1✔
212
   * Start rotating
1✔
213
   * @param {[Number, Number]} pos - position on screen where the center is
1✔
214
   * @param {Number} altitude - optional altitude for rotation pivot
1✔
215
   *   - undefined: rotate around viewport center (no pivot point)
1✔
216
   *   - 0: rotate around pointer position at ground level
1✔
217
   *   - other value: rotate around pointer position at specified altitude
1✔
218
   */
1✔
219
  rotateStart({pos, altitude}: {pos: [number, number]; altitude?: number}): MapState {
1✔
220
    return this._getUpdatedState({
16✔
221
      startRotatePos: pos,
16✔
222
      startRotateLngLat: altitude !== undefined ? this._unproject3D(pos, altitude) : undefined,
16!
223
      startBearing: this.getViewportProps().bearing,
16✔
224
      startPitch: this.getViewportProps().pitch
16✔
225
    });
16✔
226
  }
16✔
227

1✔
228
  /**
1✔
229
   * Rotate
1✔
230
   * @param {[Number, Number]} pos - position on screen where the center is
1✔
231
   */
1✔
232
  rotate({
1✔
233
    pos,
10✔
234
    deltaAngleX = 0,
10✔
235
    deltaAngleY = 0
10✔
236
  }: {
10✔
237
    pos?: [number, number];
10✔
238
    deltaAngleX?: number;
10✔
239
    deltaAngleY?: number;
10✔
240
  }): MapState {
10✔
241
    const {startRotatePos, startRotateLngLat, startBearing, startPitch} = this.getState();
10✔
242

10✔
243
    if (!startRotatePos || startBearing === undefined || startPitch === undefined) {
10!
UNCOV
244
      return this;
×
UNCOV
245
    }
×
246
    let newRotation;
10✔
247
    if (pos) {
10✔
248
      newRotation = this._getNewRotation(pos, startRotatePos, startPitch, startBearing);
8✔
249
    } else {
10✔
250
      newRotation = {
2✔
251
        bearing: startBearing + deltaAngleX,
2✔
252
        pitch: startPitch + deltaAngleY
2✔
253
      };
2✔
254
    }
2✔
255

10✔
256
    // If we have a pivot point, adjust the camera position to keep the pivot point fixed
10✔
257
    if (startRotateLngLat) {
10!
258
      const rotatedViewport = this.makeViewport({
×
259
        ...this.getViewportProps(),
×
260
        ...newRotation
×
261
      });
×
262
      // Use panByPosition3D if available (WebMercatorViewport), otherwise fall back to panByPosition
×
263
      const panMethod = 'panByPosition3D' in rotatedViewport ? 'panByPosition3D' : 'panByPosition';
×
UNCOV
264
      return this._getUpdatedState({
×
UNCOV
265
        ...newRotation,
×
UNCOV
266
        ...rotatedViewport[panMethod](startRotateLngLat, startRotatePos)
×
UNCOV
267
      });
×
UNCOV
268
    }
×
269

10✔
270
    return this._getUpdatedState(newRotation);
10✔
271
  }
10✔
272

1✔
273
  /**
1✔
274
   * End rotating
1✔
275
   * Must call if `rotateStart()` was called
1✔
276
   */
1✔
277
  rotateEnd(): MapState {
1✔
278
    return this._getUpdatedState({
15✔
279
      startRotatePos: null,
15✔
280
      startRotateLngLat: null,
15✔
281
      startBearing: null,
15✔
282
      startPitch: null
15✔
283
    });
15✔
284
  }
15✔
285

1✔
286
  /**
1✔
287
   * Start zooming
1✔
288
   * @param {[Number, Number]} pos - position on screen where the center is
1✔
289
   */
1✔
290
  zoomStart({pos}: {pos: [number, number]}): MapState {
1✔
291
    return this._getUpdatedState({
5✔
292
      startZoomLngLat: this._unproject(pos),
5✔
293
      startZoom: this.getViewportProps().zoom
5✔
294
    });
5✔
295
  }
5✔
296

1✔
297
  /**
1✔
298
   * Zoom
1✔
299
   * @param {[Number, Number]} pos - position on screen where the current center is
1✔
300
   * @param {[Number, Number]} startPos - the center position at
1✔
301
   *   the start of the operation. Must be supplied of `zoomStart()` was not called
1✔
302
   * @param {Number} scale - a number between [0, 1] specifying the accumulated
1✔
303
   *   relative scale.
1✔
304
   */
1✔
305
  zoom({
1✔
306
    pos,
26✔
307
    startPos,
26✔
308
    scale
26✔
309
  }: {
26✔
310
    pos: [number, number];
26✔
311
    startPos?: [number, number];
26✔
312
    scale: number;
26✔
313
  }): MapState {
26✔
314
    // Make sure we zoom around the current mouse position rather than map center
26✔
315
    let {startZoom, startZoomLngLat} = this.getState();
26✔
316

26✔
317
    if (!startZoomLngLat) {
26✔
318
      // We have two modes of zoom:
23✔
319
      // scroll zoom that are discrete events (transform from the current zoom level),
23✔
320
      // and pinch zoom that are continuous events (transform from the zoom level when
23✔
321
      // pinch started).
23✔
322
      // If startZoom state is defined, then use the startZoom state;
23✔
323
      // otherwise assume discrete zooming
23✔
324
      startZoom = this.getViewportProps().zoom;
23✔
325
      startZoomLngLat = this._unproject(startPos) || this._unproject(pos);
23✔
326
    }
23✔
327
    if (!startZoomLngLat) {
26!
UNCOV
328
      return this;
×
UNCOV
329
    }
×
330

26✔
331
    const zoom = this._constrainZoom((startZoom as number) + Math.log2(scale));
26✔
332
    const zoomedViewport = this.makeViewport({...this.getViewportProps(), zoom});
26✔
333

26✔
334
    return this._getUpdatedState({
26✔
335
      zoom,
26✔
336
      ...zoomedViewport.panByPosition(startZoomLngLat, pos)
26✔
337
    });
26✔
338
  }
26✔
339

1✔
340
  /**
1✔
341
   * End zooming
1✔
342
   * Must call if `zoomStart()` was called
1✔
343
   */
1✔
344
  zoomEnd(): MapState {
1✔
345
    return this._getUpdatedState({
5✔
346
      startZoomLngLat: null,
5✔
347
      startZoom: null
5✔
348
    });
5✔
349
  }
5✔
350

1✔
351
  zoomIn(speed: number = 2): MapState {
1✔
352
    return this._zoomFromCenter(speed);
11✔
353
  }
11✔
354

1✔
355
  zoomOut(speed: number = 2): MapState {
1✔
356
    return this._zoomFromCenter(1 / speed);
10✔
357
  }
10✔
358

1✔
359
  moveLeft(speed: number = 100): MapState {
1✔
360
    return this._panFromCenter([speed, 0]);
4✔
361
  }
4✔
362

1✔
363
  moveRight(speed: number = 100): MapState {
1✔
364
    return this._panFromCenter([-speed, 0]);
3✔
365
  }
3✔
366

1✔
367
  moveUp(speed: number = 100): MapState {
1✔
368
    return this._panFromCenter([0, speed]);
4✔
369
  }
4✔
370

1✔
371
  moveDown(speed: number = 100): MapState {
1✔
372
    return this._panFromCenter([0, -speed]);
3✔
373
  }
3✔
374

1✔
375
  rotateLeft(speed: number = 15): MapState {
1✔
376
    return this._getUpdatedState({
4✔
377
      bearing: this.getViewportProps().bearing - speed
4✔
378
    });
4✔
379
  }
4✔
380

1✔
381
  rotateRight(speed: number = 15): MapState {
1✔
382
    return this._getUpdatedState({
3✔
383
      bearing: this.getViewportProps().bearing + speed
3✔
384
    });
3✔
385
  }
3✔
386

1✔
387
  rotateUp(speed: number = 10): MapState {
1✔
388
    return this._getUpdatedState({
4✔
389
      pitch: this.getViewportProps().pitch + speed
4✔
390
    });
4✔
391
  }
4✔
392

1✔
393
  rotateDown(speed: number = 10): MapState {
1✔
394
    return this._getUpdatedState({
3✔
395
      pitch: this.getViewportProps().pitch - speed
3✔
396
    });
3✔
397
  }
3✔
398

1✔
399
  shortestPathFrom(viewState: MapState): MapStateProps {
1✔
400
    // const endViewStateProps = new this.ControllerState(endProps).shortestPathFrom(startViewstate);
53✔
401
    const fromProps = viewState.getViewportProps();
53✔
402
    const props = {...this.getViewportProps()};
53✔
403
    const {bearing, longitude} = props;
53✔
404

53✔
405
    if (Math.abs(bearing - fromProps.bearing) > 180) {
53✔
406
      props.bearing = bearing < 0 ? bearing + 360 : bearing - 360;
1!
407
    }
1✔
408
    if (Math.abs(longitude - fromProps.longitude) > 180) {
53✔
409
      props.longitude = longitude < 0 ? longitude + 360 : longitude - 360;
1!
410
    }
1✔
411
    return props;
53✔
412
  }
53✔
413

1✔
414
  // Apply any constraints (mathematical or defined by _viewportProps) to map state
1✔
415
  applyConstraints(props: Required<MapStateProps>): Required<MapStateProps> {
1✔
416
    // Ensure pitch is within specified range
576✔
417
    const {maxPitch, minPitch, pitch, longitude, bearing, normalize, maxBounds} = props;
576✔
418

576✔
419
    if (normalize) {
576✔
420
      if (longitude < -180 || longitude > 180) {
575✔
421
        props.longitude = mod(longitude + 180, 360) - 180;
1✔
422
      }
1✔
423
      if (bearing < -180 || bearing > 180) {
575✔
424
        props.bearing = mod(bearing + 180, 360) - 180;
1✔
425
      }
1✔
426
    }
575✔
427
    props.pitch = clamp(pitch, minPitch, maxPitch);
576✔
428

576✔
429
    props.zoom = this._constrainZoom(props.zoom, props);
576✔
430

576✔
431
    if (maxBounds !== null || normalize) {
576✔
432
      const bl = maxBounds ? lngLatToWorld(maxBounds[0]) : [-Infinity, 0];
575✔
433
      const tr = maxBounds ? lngLatToWorld(maxBounds[1]) : [Infinity, 512]; // web-mercator tile size
575✔
434
      // calculate center and zoom ranges at pitch=0 and bearing=0
575✔
435
      // to maintain visual stability when rotating
575✔
436
      const scale = 2 ** props.zoom;
575✔
437
      const halfWidth = props.width / 2 / scale;
575✔
438
      const halfHeight = props.height / 2 / scale;
575✔
439
      const [minLng, minLat] = worldToLngLat([bl[0] + halfWidth, bl[1] + halfHeight]);
575✔
440
      const [maxLng, maxLat] = worldToLngLat([tr[0] - halfWidth, tr[1] - halfHeight]);
575✔
441
      props.longitude = clamp(props.longitude, minLng, maxLng);
575✔
442
      props.latitude = clamp(props.latitude, minLat, maxLat);
575✔
443
    }
575✔
444

576✔
445
    return props;
576✔
446
  }
576✔
447

1✔
448
  /* Private methods */
1✔
449

1✔
450
  _constrainZoom(zoom: number, props?: Required<MapStateProps>): number {
1✔
451
    props ||= this.getViewportProps();
602✔
452
    const {maxZoom, normalize, maxBounds} = props;
602✔
453

602✔
454
    const shouldApplyMaxBounds =
602✔
455
      (maxBounds !== null || normalize) && props.width > 0 && props.height > 0;
602✔
456
    let {minZoom} = props;
602✔
457

602✔
458
    if (shouldApplyMaxBounds) {
602✔
459
      const bl = maxBounds ? lngLatToWorld(maxBounds[0]) : [-Infinity, 0];
601✔
460
      const tr = maxBounds ? lngLatToWorld(maxBounds[1]) : [Infinity, 512]; // web-mercator tile size
601✔
461
      const w = tr[0] - bl[0];
601✔
462
      const h = tr[1] - bl[1];
601✔
463
      // ignore bound size of 0 or Infinity
601✔
464
      if (Number.isFinite(w) && w > 0) {
601✔
465
        minZoom = Math.max(minZoom, Math.log2(props.width / w));
1✔
466
      }
1✔
467
      if (Number.isFinite(h) && h > 0) {
601✔
468
        minZoom = Math.max(minZoom, Math.log2(props.height / h));
601✔
469
      }
601✔
470
      if (minZoom > maxZoom) minZoom = maxZoom;
601!
471
    }
601✔
472
    return clamp(zoom, minZoom, maxZoom);
602✔
473
  }
602✔
474

1✔
475
  _zoomFromCenter(scale) {
1✔
476
    const {width, height} = this.getViewportProps();
21✔
477
    return this.zoom({
21✔
478
      pos: [width / 2, height / 2],
21✔
479
      scale
21✔
480
    });
21✔
481
  }
21✔
482

1✔
483
  _panFromCenter(offset) {
1✔
484
    const {width, height} = this.getViewportProps();
14✔
485
    return this.pan({
14✔
486
      startPos: [width / 2, height / 2],
14✔
487
      pos: [width / 2 + offset[0], height / 2 + offset[1]]
14✔
488
    });
14✔
489
  }
14✔
490

1✔
491
  _getUpdatedState(newProps): MapState {
1✔
492
    // @ts-ignore
133✔
493
    return new this.constructor({
133✔
494
      makeViewport: this.makeViewport,
133✔
495
      ...this.getViewportProps(),
133✔
496
      ...this.getState(),
133✔
497
      ...newProps
133✔
498
    });
133✔
499
  }
133✔
500

1✔
501
  _unproject(pos?: [number, number]): [number, number] | undefined {
1✔
502
    const viewport = this.makeViewport(this.getViewportProps());
70✔
503
    // @ts-ignore
70✔
504
    return pos && viewport.unproject(pos);
70✔
505
  }
70✔
506

1✔
507
  _unproject3D(pos: [number, number], altitude: number): [number, number, number] {
1✔
UNCOV
508
    const viewport = this.makeViewport(this.getViewportProps());
×
UNCOV
509
    return viewport.unproject(pos, {targetZ: altitude}) as [number, number, number];
×
UNCOV
510
  }
×
511

1✔
512
  _getNewRotation(
1✔
513
    pos: [number, number],
8✔
514
    startPos: [number, number],
8✔
515
    startPitch: number,
8✔
516
    startBearing: number
8✔
517
  ): {
8✔
518
    pitch: number;
8✔
519
    bearing: number;
8✔
520
  } {
8✔
521
    const deltaX = pos[0] - startPos[0];
8✔
522
    const deltaY = pos[1] - startPos[1];
8✔
523
    const centerY = pos[1];
8✔
524
    const startY = startPos[1];
8✔
525
    const {width, height} = this.getViewportProps();
8✔
526

8✔
527
    const deltaScaleX = deltaX / width;
8✔
528
    let deltaScaleY = 0;
8✔
529

8✔
530
    if (deltaY > 0) {
8✔
531
      if (Math.abs(height - startY) > PITCH_MOUSE_THRESHOLD) {
4✔
532
        // Move from 0 to -1 as we drag upwards
2✔
533
        deltaScaleY = (deltaY / (startY - height)) * PITCH_ACCEL;
2✔
534
      }
2✔
535
    } else if (deltaY < 0) {
4✔
536
      if (startY > PITCH_MOUSE_THRESHOLD) {
4✔
537
        // Move from 0 to 1 as we drag upwards
4✔
538
        deltaScaleY = 1 - centerY / startY;
4✔
539
      }
4✔
540
    }
4✔
541
    // clamp deltaScaleY to [-1, 1] so that rotation is constrained between minPitch and maxPitch.
8✔
542
    // deltaScaleX does not need to be clamped as bearing does not have constraints.
8✔
543
    deltaScaleY = clamp(deltaScaleY, -1, 1);
8✔
544

8✔
545
    const {minPitch, maxPitch} = this.getViewportProps();
8✔
546

8✔
547
    const bearing = startBearing + 180 * deltaScaleX;
8✔
548
    let pitch = startPitch;
8✔
549
    if (deltaScaleY > 0) {
8✔
550
      // Gradually increase pitch
4✔
551
      pitch = startPitch + deltaScaleY * (maxPitch - startPitch);
4✔
552
    } else if (deltaScaleY < 0) {
4✔
553
      // Gradually decrease pitch
2✔
554
      pitch = startPitch - deltaScaleY * (minPitch - startPitch);
2✔
555
    }
2✔
556

8✔
557
    return {
8✔
558
      pitch,
8✔
559
      bearing
8✔
560
    };
8✔
561
  }
8✔
562
}
1✔
563

1✔
564
export default class MapController extends Controller<MapState> {
1✔
565
  ControllerState = MapState;
1✔
566

1✔
567
  transition = {
1✔
568
    transitionDuration: 300,
1✔
569
    transitionInterpolator: new LinearInterpolator({
1✔
570
      transitionProps: {
1✔
571
        compare: ['longitude', 'latitude', 'zoom', 'bearing', 'pitch', 'position'],
1✔
572
        required: ['longitude', 'latitude', 'zoom']
1✔
573
      }
1✔
574
    })
1✔
575
  };
1✔
576

1✔
577
  dragMode: 'pan' | 'rotate' = 'pan';
1✔
578

1✔
579
  /**
1✔
580
   * Rotation pivot behavior:
1✔
581
   * - 'center': Rotate around viewport center (default)
1✔
582
   * - '2d': Rotate around pointer position at ground level (z=0)
1✔
583
   * - '3d': Rotate around 3D picked point (requires pickPosition callback)
1✔
584
   */
1✔
585
  protected rotationPivot: 'center' | '2d' | '3d' = 'center';
1✔
586

1✔
587
  /**
1✔
588
   * Internal callback to access deck picking engine. Populated by ViewManager
1✔
589
   */
1✔
590
  protected pickPosition?: (x: number, y: number) => {coordinate?: number[]} | null;
1✔
591

1✔
592
  constructor(opts: ConstructorParameters<typeof Controller>[0]) {
1✔
593
    super(opts);
13✔
594
    this.pickPosition = opts.pickPosition;
13✔
595
  }
13✔
596

1✔
597
  setProps(props: ControllerProps & MapStateProps & {rotationPivot?: 'center' | '2d' | '3d'}) {
1✔
598
    if ('rotationPivot' in props) {
401!
UNCOV
599
      this.rotationPivot = props.rotationPivot || 'center';
×
UNCOV
600
    }
×
601
    props.position = props.position || [0, 0, 0];
401✔
602
    const oldProps = this.props;
401✔
603

401✔
604
    super.setProps(props);
401✔
605

401✔
606
    const dimensionChanged = !oldProps || oldProps.height !== props.height;
401✔
607
    if (dimensionChanged) {
401✔
608
      // Dimensions changed, normalize the props
12✔
609
      this.updateViewport(
12✔
610
        new this.ControllerState({
12✔
611
          makeViewport: this.makeViewport,
12✔
612
          ...props,
12✔
613
          ...this.state
12✔
614
        })
12✔
615
      );
12✔
616
    }
12✔
617
  }
401✔
618

1✔
619
  protected updateViewport(
1✔
620
    newControllerState: MapState,
98✔
621
    extraProps: Record<string, any> | null = null,
98✔
622
    interactionState: InteractionState = {}
98✔
623
  ): void {
98✔
624
    // Inject rotation pivot position during rotation for visual feedback
98✔
625
    const state = newControllerState.getState();
98✔
626
    if (interactionState.isDragging && state.startRotateLngLat) {
98!
UNCOV
627
      interactionState = {
×
UNCOV
628
        ...interactionState,
×
UNCOV
629
        rotationPivotPosition: state.startRotateLngLat
×
UNCOV
630
      };
×
631
    } else if (interactionState.isDragging === false) {
98✔
632
      // Clear pivot when drag ends
18✔
633
      interactionState = {...interactionState, rotationPivotPosition: undefined};
18✔
634
    }
18✔
635

98✔
636
    super.updateViewport(newControllerState, extraProps, interactionState);
98✔
637
  }
98✔
638

1✔
639
  /** Add altitude to rotateStart params based on rotationPivot mode */
1✔
640
  protected _getRotateStartParams(pos: [number, number]): {
1✔
641
    pos: [number, number];
5✔
642
    altitude?: number;
5✔
643
  } {
5✔
644
    let altitude: number | undefined;
5✔
645
    if (this.rotationPivot === '2d') {
5!
UNCOV
646
      altitude = 0;
×
647
    } else if (this.rotationPivot === '3d') {
5!
UNCOV
648
      if (this.pickPosition) {
×
UNCOV
649
        const {x, y} = this.props;
×
UNCOV
650
        const pickResult = this.pickPosition(x + pos[0], y + pos[1]);
×
UNCOV
651
        if (pickResult && pickResult.coordinate && pickResult.coordinate.length >= 3) {
×
UNCOV
652
          altitude = pickResult.coordinate[2];
×
UNCOV
653
        }
×
UNCOV
654
      }
×
UNCOV
655
    }
×
656
    return {pos, altitude};
5✔
657
  }
5✔
658
}
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