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

visgl / deck.gl / 23381039318

21 Mar 2026 01:47PM UTC coverage: 91.064% (+0.03%) from 91.038%
23381039318

Pull #10118

github

web-flow
Merge 57b8634f8 into b2c8c4048
Pull Request #10118: feat(layers): add clipping to TextLayer

7072 of 7822 branches covered (90.41%)

Branch coverage included in aggregate %.

330 of 332 new or added lines in 7 files covered. (99.4%)

65 existing lines in 5 files now uncovered.

58466 of 64147 relevant lines covered (91.14%)

14006.92 hits per line

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

93.14
/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
  /* get optional altitude for rotation pivot
1✔
96
   *   - undefined: rotate around viewport center (no pivot point)
1✔
97
   *   - 0: rotate around pointer position at ground level
1✔
98
   *   - other value: rotate around pointer position at specified altitude
1✔
99
   */
1✔
100
  getAltitude?: (pos: [number, number]) => number | undefined;
1✔
101

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

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

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

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

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

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

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

711✔
196
    this.getAltitude = options.getAltitude;
711✔
197
  }
711✔
198

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1✔
473
  /* Private methods */
1✔
474

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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