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

visgl / deck.gl / 23258419524

18 Mar 2026 05:35PM UTC coverage: 91.043% (-0.02%) from 91.061%
23258419524

push

github

web-flow
feat(core): controller normalizes viewport on dimension change (#10109)

7025 of 7749 branches covered (90.66%)

Branch coverage included in aggregate %.

49 of 55 new or added lines in 4 files covered. (89.09%)

7 existing lines in 1 file now uncovered.

57856 of 63515 relevant lines covered (91.09%)

14128.66 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,432✔
26
  if (Math.abs(lat) > 90) {
2,432!
NEW
27
    lat = Math.sign(lat) * 90;
×
NEW
28
  }
×
29
  if (Number.isFinite(lng)) {
2,432✔
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,428✔
34
  return [lng, clamp(y, 0, WEB_MERCATOR_TILE_SIZE)];
2,428✔
35
}
2,428✔
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

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

715✔
128
      /** Viewport constraints */
715✔
129
      maxZoom = 20,
715✔
130
      minZoom = 0,
715✔
131
      maxPitch = 60,
715✔
132
      minPitch = 0,
715✔
133

715✔
134
      /** Interaction states, required to calculate change during transform */
715✔
135
      /* The point on map being grabbed when the operation first started */
715✔
136
      startPanLngLat,
715✔
137
      /* Center of the zoom when the operation first started */
715✔
138
      startZoomLngLat,
715✔
139
      /* Pointer position when rotation started */
715✔
140
      startRotatePos,
715✔
141
      /* The lng/lat point at the rotation pivot (where rotation started) */
715✔
142
      startRotateLngLat,
715✔
143
      /** Bearing when current perspective rotate operation started */
715✔
144
      startBearing,
715✔
145
      /** Pitch when current perspective rotate operation started */
715✔
146
      startPitch,
715✔
147
      /** Zoom when current zoom operation started */
715✔
148
      startZoom,
715✔
149

715✔
150
      /** Normalize viewport props to fit map height into viewport */
715✔
151
      normalize = true
715✔
152
    } = options;
715✔
153

715✔
154
    assert(Number.isFinite(longitude)); // `longitude` must be supplied
715✔
155
    assert(Number.isFinite(latitude)); // `latitude` must be supplied
715✔
156
    assert(Number.isFinite(zoom)); // `zoom` must be supplied
715✔
157

715✔
158
    const maxBounds = options.maxBounds || (normalize ? WEB_MERCATOR_MAX_BOUNDS : null);
715✔
159

715✔
160
    super(
715✔
161
      {
715✔
162
        width,
715✔
163
        height,
715✔
164
        latitude,
715✔
165
        longitude,
715✔
166
        zoom,
715✔
167
        bearing,
715✔
168
        pitch,
715✔
169
        altitude,
715✔
170
        maxZoom,
715✔
171
        minZoom,
715✔
172
        maxPitch,
715✔
173
        minPitch,
715✔
174
        normalize,
715✔
175
        position,
715✔
176
        maxBounds
715✔
177
      },
715✔
178
      {
715✔
179
        startPanLngLat,
715✔
180
        startZoomLngLat,
715✔
181
        startRotatePos,
715✔
182
        startRotateLngLat,
715✔
183
        startBearing,
715✔
184
        startPitch,
715✔
185
        startZoom
715✔
186
      }
715✔
187
    );
715✔
188

715✔
189
    this.makeViewport = options.makeViewport;
715✔
190
  }
715✔
191

1✔
192
  /**
1✔
193
   * Start panning
1✔
194
   * @param {[Number, Number]} pos - position on screen where the pointer grabs
1✔
195
   */
1✔
196
  panStart({pos}: {pos: [number, number]}): MapState {
1✔
197
    return this._getUpdatedState({
5✔
198
      startPanLngLat: this._unproject(pos)
5✔
199
    });
5✔
200
  }
5✔
201

1✔
202
  /**
1✔
203
   * Pan
1✔
204
   * @param {[Number, Number]} pos - position on screen where the pointer is
1✔
205
   * @param {[Number, Number], optional} startPos - where the pointer grabbed at
1✔
206
   *   the start of the operation. Must be supplied of `panStart()` was not called
1✔
207
   */
1✔
208
  pan({pos, startPos}: {pos: [number, number]; startPos?: [number, number]}): MapState {
1✔
209
    const startPanLngLat = this.getState().startPanLngLat || this._unproject(startPos);
15✔
210

15✔
211
    if (!startPanLngLat) {
15!
212
      return this;
×
213
    }
×
214

15✔
215
    const viewport = this.makeViewport(this.getViewportProps());
15✔
216
    const newProps = viewport.panByPosition(startPanLngLat, pos);
15✔
217

15✔
218
    return this._getUpdatedState(newProps);
15✔
219
  }
15✔
220

1✔
221
  /**
1✔
222
   * End panning
1✔
223
   * Must call if `panStart()` was called
1✔
224
   */
1✔
225
  panEnd(): MapState {
1✔
226
    return this._getUpdatedState({
5✔
227
      startPanLngLat: null
5✔
228
    });
5✔
229
  }
5✔
230

1✔
231
  /**
1✔
232
   * Start rotating
1✔
233
   * @param {[Number, Number]} pos - position on screen where the center is
1✔
234
   * @param {Number} altitude - optional altitude for rotation pivot
1✔
235
   *   - undefined: rotate around viewport center (no pivot point)
1✔
236
   *   - 0: rotate around pointer position at ground level
1✔
237
   *   - other value: rotate around pointer position at specified altitude
1✔
238
   */
1✔
239
  rotateStart({pos, altitude}: {pos: [number, number]; altitude?: number}): MapState {
1✔
240
    return this._getUpdatedState({
16✔
241
      startRotatePos: pos,
16✔
242
      startRotateLngLat: altitude !== undefined ? this._unproject3D(pos, altitude) : undefined,
16!
243
      startBearing: this.getViewportProps().bearing,
16✔
244
      startPitch: this.getViewportProps().pitch
16✔
245
    });
16✔
246
  }
16✔
247

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

10✔
263
    if (!startRotatePos || startBearing === undefined || startPitch === undefined) {
10!
264
      return this;
×
265
    }
×
266
    let newRotation;
10✔
267
    if (pos) {
10✔
268
      newRotation = this._getNewRotation(pos, startRotatePos, startPitch, startBearing);
8✔
269
    } else {
10✔
270
      newRotation = {
2✔
271
        bearing: startBearing + deltaAngleX,
2✔
272
        pitch: startPitch + deltaAngleY
2✔
273
      };
2✔
274
    }
2✔
275

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

10✔
290
    return this._getUpdatedState(newRotation);
10✔
291
  }
10✔
292

1✔
293
  /**
1✔
294
   * End rotating
1✔
295
   * Must call if `rotateStart()` was called
1✔
296
   */
1✔
297
  rotateEnd(): MapState {
1✔
298
    return this._getUpdatedState({
15✔
299
      startRotatePos: null,
15✔
300
      startRotateLngLat: null,
15✔
301
      startBearing: null,
15✔
302
      startPitch: null
15✔
303
    });
15✔
304
  }
15✔
305

1✔
306
  /**
1✔
307
   * Start zooming
1✔
308
   * @param {[Number, Number]} pos - position on screen where the center is
1✔
309
   */
1✔
310
  zoomStart({pos}: {pos: [number, number]}): MapState {
1✔
311
    return this._getUpdatedState({
5✔
312
      startZoomLngLat: this._unproject(pos),
5✔
313
      startZoom: this.getViewportProps().zoom
5✔
314
    });
5✔
315
  }
5✔
316

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

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

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

28✔
354
    return this._getUpdatedState({
28✔
355
      zoom,
28✔
356
      ...zoomedViewport.panByPosition(startZoomLngLat, pos)
28✔
357
    });
28✔
358
  }
28✔
359

1✔
360
  /**
1✔
361
   * End zooming
1✔
362
   * Must call if `zoomStart()` was called
1✔
363
   */
1✔
364
  zoomEnd(): MapState {
1✔
365
    return this._getUpdatedState({
5✔
366
      startZoomLngLat: null,
5✔
367
      startZoom: null
5✔
368
    });
5✔
369
  }
5✔
370

1✔
371
  zoomIn(speed: number = 2): MapState {
1✔
372
    return this._zoomFromCenter(speed);
11✔
373
  }
11✔
374

1✔
375
  zoomOut(speed: number = 2): MapState {
1✔
376
    return this._zoomFromCenter(1 / speed);
13✔
377
  }
13✔
378

1✔
379
  moveLeft(speed: number = 100): MapState {
1✔
380
    return this._panFromCenter([speed, 0]);
4✔
381
  }
4✔
382

1✔
383
  moveRight(speed: number = 100): MapState {
1✔
384
    return this._panFromCenter([-speed, 0]);
3✔
385
  }
3✔
386

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

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

1✔
395
  rotateLeft(speed: number = 15): MapState {
1✔
396
    return this._getUpdatedState({
4✔
397
      bearing: this.getViewportProps().bearing - speed
4✔
398
    });
4✔
399
  }
4✔
400

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

1✔
407
  rotateUp(speed: number = 10): MapState {
1✔
408
    return this._getUpdatedState({
4✔
409
      pitch: this.getViewportProps().pitch + speed
4✔
410
    });
4✔
411
  }
4✔
412

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

1✔
419
  shortestPathFrom(viewState: MapState): MapStateProps {
1✔
420
    // const endViewStateProps = new this.ControllerState(endProps).shortestPathFrom(startViewstate);
56✔
421
    const fromProps = viewState.getViewportProps();
56✔
422
    const props = {...this.getViewportProps()};
56✔
423
    const {bearing, longitude} = props;
56✔
424

56✔
425
    if (Math.abs(bearing - fromProps.bearing) > 180) {
56✔
426
      props.bearing = bearing < 0 ? bearing + 360 : bearing - 360;
1!
427
    }
1✔
428
    if (Math.abs(longitude - fromProps.longitude) > 180) {
56✔
429
      props.longitude = longitude < 0 ? longitude + 360 : longitude - 360;
1!
430
    }
1✔
431
    return props;
56✔
432
  }
56✔
433

1✔
434
  // Apply any constraints (mathematical or defined by _viewportProps) to map state
1✔
435
  applyConstraints(props: Required<MapStateProps>): Required<MapStateProps> {
1✔
436
    // Ensure pitch is within specified range
595✔
437
    const {maxPitch, minPitch, pitch, longitude, bearing, normalize, maxBounds} = props;
595✔
438

595✔
439
    if (normalize) {
595✔
440
      if (longitude < -180 || longitude > 180) {
594✔
441
        props.longitude = mod(longitude + 180, 360) - 180;
1✔
442
      }
1✔
443
      if (bearing < -180 || bearing > 180) {
594✔
444
        props.bearing = mod(bearing + 180, 360) - 180;
1✔
445
      }
1✔
446
    }
594✔
447
    props.pitch = clamp(pitch, minPitch, maxPitch);
595✔
448

595✔
449
    props.zoom = this._constrainZoom(props.zoom, props);
595✔
450

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

595✔
465
    return props;
595✔
466
  }
595✔
467

1✔
468
  /* Private methods */
1✔
469

1✔
470
  _constrainZoom(zoom: number, props?: Required<MapStateProps>): number {
1✔
471
    props ||= this.getViewportProps();
623✔
472
    const {maxZoom, maxBounds} = props;
623✔
473

623✔
474
    const shouldApplyMaxBounds = maxBounds !== null && props.width > 0 && props.height > 0;
623✔
475
    let {minZoom} = props;
623✔
476

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

1✔
494
  _zoomFromCenter(scale) {
1✔
495
    const {width, height} = this.getViewportProps();
24✔
496
    return this.zoom({
24✔
497
      pos: [width / 2, height / 2],
24✔
498
      scale
24✔
499
    });
24✔
500
  }
24✔
501

1✔
502
  _panFromCenter(offset) {
1✔
503
    const {width, height} = this.getViewportProps();
14✔
504
    return this.pan({
14✔
505
      startPos: [width / 2, height / 2],
14✔
506
      pos: [width / 2 + offset[0], height / 2 + offset[1]]
14✔
507
    });
14✔
508
  }
14✔
509

1✔
510
  _getUpdatedState(newProps): MapState {
1✔
511
    // @ts-ignore
136✔
512
    return new this.constructor({
136✔
513
      makeViewport: this.makeViewport,
136✔
514
      ...this.getViewportProps(),
136✔
515
      ...this.getState(),
136✔
516
      ...newProps
136✔
517
    });
136✔
518
  }
136✔
519

1✔
520
  _unproject(pos?: [number, number]): [number, number] | undefined {
1✔
521
    const viewport = this.makeViewport(this.getViewportProps());
74✔
522
    // @ts-ignore
74✔
523
    return pos && viewport.unproject(pos);
74✔
524
  }
74✔
525

1✔
526
  _unproject3D(pos: [number, number], altitude: number): [number, number, number] {
1✔
527
    const viewport = this.makeViewport(this.getViewportProps());
×
528
    return viewport.unproject(pos, {targetZ: altitude}) as [number, number, number];
×
529
  }
×
530

1✔
531
  _getNewRotation(
1✔
532
    pos: [number, number],
8✔
533
    startPos: [number, number],
8✔
534
    startPitch: number,
8✔
535
    startBearing: number
8✔
536
  ): {
8✔
537
    pitch: number;
8✔
538
    bearing: number;
8✔
539
  } {
8✔
540
    const deltaX = pos[0] - startPos[0];
8✔
541
    const deltaY = pos[1] - startPos[1];
8✔
542
    const centerY = pos[1];
8✔
543
    const startY = startPos[1];
8✔
544
    const {width, height} = this.getViewportProps();
8✔
545

8✔
546
    const deltaScaleX = deltaX / width;
8✔
547
    let deltaScaleY = 0;
8✔
548

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

8✔
564
    const {minPitch, maxPitch} = this.getViewportProps();
8✔
565

8✔
566
    const bearing = startBearing + 180 * deltaScaleX;
8✔
567
    let pitch = startPitch;
8✔
568
    if (deltaScaleY > 0) {
8✔
569
      // Gradually increase pitch
4✔
570
      pitch = startPitch + deltaScaleY * (maxPitch - startPitch);
4✔
571
    } else if (deltaScaleY < 0) {
4✔
572
      // Gradually decrease pitch
2✔
573
      pitch = startPitch - deltaScaleY * (minPitch - startPitch);
2✔
574
    }
2✔
575

8✔
576
    return {
8✔
577
      pitch,
8✔
578
      bearing
8✔
579
    };
8✔
580
  }
8✔
581
}
1✔
582

1✔
583
export default class MapController extends Controller<MapState> {
1✔
584
  ControllerState = MapState;
1✔
585

1✔
586
  transition = {
1✔
587
    transitionDuration: 300,
1✔
588
    transitionInterpolator: new LinearInterpolator({
1✔
589
      transitionProps: {
1✔
590
        compare: ['longitude', 'latitude', 'zoom', 'bearing', 'pitch', 'position'],
1✔
591
        required: ['longitude', 'latitude', 'zoom']
1✔
592
      }
1✔
593
    })
1✔
594
  };
1✔
595

1✔
596
  dragMode: 'pan' | 'rotate' = 'pan';
1✔
597

1✔
598
  /**
1✔
599
   * Rotation pivot behavior:
1✔
600
   * - 'center': Rotate around viewport center (default)
1✔
601
   * - '2d': Rotate around pointer position at ground level (z=0)
1✔
602
   * - '3d': Rotate around 3D picked point (requires pickPosition callback)
1✔
603
   */
1✔
604
  protected rotationPivot: 'center' | '2d' | '3d' = 'center';
1✔
605

1✔
606
  /**
1✔
607
   * Internal callback to access deck picking engine. Populated by ViewManager
1✔
608
   */
1✔
609
  protected pickPosition?: (x: number, y: number) => {coordinate?: number[]} | null;
1✔
610

1✔
611
  constructor(opts: ConstructorParameters<typeof Controller>[0]) {
1✔
612
    super(opts);
13✔
613
    this.pickPosition = opts.pickPosition;
13✔
614
  }
13✔
615

1✔
616
  setProps(props: ControllerProps & MapStateProps & {rotationPivot?: 'center' | '2d' | '3d'}) {
1✔
617
    if ('rotationPivot' in props) {
413!
618
      this.rotationPivot = props.rotationPivot || 'center';
×
619
    }
×
620
    props.position = props.position || [0, 0, 0];
413✔
621
    props.maxBounds =
413✔
622
      props.maxBounds || (props.normalize === false ? null : WEB_MERCATOR_MAX_BOUNDS);
413!
623

413✔
624
    super.setProps(props);
413✔
625
  }
413✔
626

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

103✔
644
    super.updateViewport(newControllerState, extraProps, interactionState);
103✔
645
  }
103✔
646

1✔
647
  /** Add altitude to rotateStart params based on rotationPivot mode */
1✔
648
  protected _getRotateStartParams(pos: [number, number]): {
1✔
649
    pos: [number, number];
5✔
650
    altitude?: number;
5✔
651
  } {
5✔
652
    let altitude: number | undefined;
5✔
653
    if (this.rotationPivot === '2d') {
5!
654
      altitude = 0;
×
655
    } else if (this.rotationPivot === '3d') {
5!
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) {
×
660
          altitude = pickResult.coordinate[2];
×
661
        }
×
662
      }
×
663
    }
×
664
    return {pos, altitude};
5✔
665
  }
5✔
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