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

visgl / deck.gl / 25852760607

14 May 2026 09:31AM UTC coverage: 83.519% (-0.3%) from 83.791%
25852760607

push

github

web-flow
feat(core): GlobeController with inertia, tilt & pan (#10298)

7762 of 9747 branches covered (79.63%)

Branch coverage included in aggregate %.

111 of 175 new or added lines in 3 files covered. (63.43%)

7 existing lines in 1 file now uncovered.

13938 of 16235 relevant lines covered (85.85%)

29170.4 hits per line

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

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

5
import {clamp} from '@math.gl/core';
6
import Controller from './controller';
7

8
import {MapState, MapStateProps} from './map-controller';
9
import type {MapStateInternal} from './map-controller';
10
import {mod} from '../utils/math-utils';
11
import LinearInterpolator from '../transitions/linear-interpolator';
12
import {zoomAdjust, GLOBE_RADIUS} from '../viewports/globe-viewport';
13
import {
14
  Globe,
15
  type CameraFrame,
16
  GLOBE_INERTIA_EASING,
17
  GlobeInertiaInterpolator
18
} from '../viewports/globe-utils';
19
import {MAX_LATITUDE} from '@math.gl/web-mercator';
20

21
import type {MjolnirGestureEvent} from 'mjolnir.js';
22

23
const DEGREES_TO_RADIANS = Math.PI / 180;
4✔
24
const RADIANS_TO_DEGREES = 180 / Math.PI;
4✔
25

26
function degreesToPixels(angle: number, zoom: number = 0): number {
6✔
27
  const radians = Math.min(180, angle) * DEGREES_TO_RADIANS;
6✔
28
  const size = GLOBE_RADIUS * 2 * Math.sin(radians / 2);
6✔
29
  return size * Math.pow(2, zoom);
6✔
30
}
31

32
function pixelsToDegrees(pixels: number, zoom: number = 0): number {
4✔
33
  const size = pixels / Math.pow(2, zoom);
4✔
34
  const radians = Math.asin(Math.min(1, size / GLOBE_RADIUS / 2)) * 2;
4✔
35
  return radians * RADIANS_TO_DEGREES;
4✔
36
}
37

38
type GlobeStateInternal = MapStateInternal & {
39
  startPanPos?: [number, number];
40
  startPanCameraFrame?: CameraFrame;
41
  startPanAngularRate?: number;
42
  /** When true, bearing is held fixed during pan (north stays up) */
43
  startPanLockBearing?: boolean;
44
};
45

46
class GlobeState extends MapState {
47
  constructor(
48
    options: MapStateProps &
49
      GlobeStateInternal & {
50
        makeViewport: (props: Record<string, any>) => any;
51
      }
52
  ) {
53
    const {
54
      startPanPos,
55
      startPanCameraFrame,
56
      startPanAngularRate,
57
      startPanLockBearing,
58
      ...mapStateOptions
59
    } = options;
146✔
60
    mapStateOptions.normalize = false;
146✔
61
    super(mapStateOptions);
146✔
62

63
    const s = (this as any)._state;
146✔
64
    if (startPanPos !== undefined) s.startPanPos = startPanPos;
146✔
65
    if (startPanCameraFrame !== undefined) s.startPanCameraFrame = startPanCameraFrame;
146✔
66
    if (startPanAngularRate !== undefined) s.startPanAngularRate = startPanAngularRate;
146✔
67
    if (startPanLockBearing !== undefined) s.startPanLockBearing = startPanLockBearing;
146✔
68
  }
69

70
  panStart({pos}: {pos: [number, number]}): GlobeState {
71
    const {latitude, longitude, zoom, bearing = 0} = this.getViewportProps();
6✔
72
    const cameraFrame = Globe.cameraFrame(longitude, latitude, bearing);
6✔
73
    const lockBearing = Math.abs(bearing) < 1;
6✔
74

75
    if (lockBearing) {
6!
76
      // Override horizontal axis to polar so north stays up.
77
      // Boost rate by 1/cos(lat) to compensate for smaller longitude
78
      // circles near the poles, capped at 4x.
79
      cameraFrame.axisHorizontal = [0, 0, 1];
6✔
80
    }
81

82
    // Radians of arc per pixel, derived from zoom scale
83
    const scale = Math.pow(2, zoom - zoomAdjust(latitude, true));
6✔
84
    const angularRate = (0.25 / scale) * DEGREES_TO_RADIANS;
6✔
85

86
    return this._getUpdatedState({
6✔
87
      startPanPos: pos,
88
      startPanCameraFrame: cameraFrame,
89
      startPanAngularRate: angularRate,
90
      startPanLockBearing: lockBearing,
91
      startZoom: zoom
92
    }) as GlobeState;
93
  }
94

95
  pan({pos, startPos}: {pos: [number, number]; startPos?: [number, number]}): GlobeState {
96
    const state = this.getState() as GlobeStateInternal;
5✔
97
    const startPanPos = state.startPanPos || startPos;
5!
98
    if (!startPanPos) return this;
5!
99

100
    const frame = state.startPanCameraFrame;
5✔
101
    const rate = state.startPanAngularRate;
5✔
102
    const startZoom = state.startZoom ?? this.getViewportProps().zoom;
5!
103
    if (!frame || !rate) {
5!
NEW
104
      return this;
×
105
    }
106

107
    const dx = startPanPos[0] - pos[0];
5✔
108
    const dy = startPanPos[1] - pos[1];
5✔
109

110
    let hAngle = dx * rate;
5✔
111
    let vAngle = -dy * rate;
5✔
112
    const locked = state.startPanLockBearing;
5✔
113

114
    if (locked) {
5!
115
      // Boost horizontal rate by 1/cos(lat) for the polar axis, capped at 4x
116
      const cosLat = Math.cos(frame.latitude * DEGREES_TO_RADIANS);
5✔
117
      hAngle = (dx * rate) / Math.max(cosLat, 0.25);
5✔
118
      // Clamp vertical angle to prevent crossing the poles
119
      const maxUp = (MAX_LATITUDE - frame.latitude) * DEGREES_TO_RADIANS;
5✔
120
      const maxDown = -(MAX_LATITUDE + frame.latitude) * DEGREES_TO_RADIANS;
5✔
121
      vAngle = clamp(vAngle, maxDown, maxUp);
5✔
122
    }
123

124
    const rotated = Globe.rotateFrame(frame, hAngle, vAngle, locked);
5✔
125
    const zoom = startZoom + zoomAdjust(rotated.latitude, true) - zoomAdjust(frame.latitude, true);
5✔
126

127
    return this._getUpdatedState({
5✔
128
      longitude: rotated.longitude,
129
      latitude: rotated.latitude,
130
      bearing: rotated.bearing,
131
      zoom
132
    }) as GlobeState;
133
  }
134

135
  panEnd(): GlobeState {
136
    return this._getUpdatedState({
6✔
137
      startPanPos: null,
138
      startPanCameraFrame: null,
139
      startPanAngularRate: null,
140
      startPanLockBearing: null,
141
      startZoom: null
142
    }) as GlobeState;
143
  }
144

145
  zoom({scale}: {scale: number}): MapState {
146
    const startZoom = this.getState().startZoom || this.getViewportProps().zoom;
9✔
147
    const zoom = startZoom + Math.log2(scale);
9✔
148
    return this._getUpdatedState({zoom});
9✔
149
  }
150

151
  _panFromCenter(offset: [number, number]): GlobeState {
152
    const {width, height} = this.getViewportProps();
4✔
153
    const center: [number, number] = [width / 2, height / 2];
4✔
154
    return this.panStart({pos: center})
4✔
155
      .pan({pos: [center[0] + offset[0], center[1] + offset[1]]})
156
      .panEnd();
157
  }
158

159
  applyConstraints(props: Required<MapStateProps>): Required<MapStateProps> {
160
    const {longitude, latitude, maxBounds} = props;
146✔
161

162
    props.zoom = this._constrainZoom(props.zoom, props);
146✔
163

164
    if (longitude < -180 || longitude > 180) {
146✔
165
      props.longitude = mod(longitude + 180, 360) - 180;
1✔
166
    }
167
    props.latitude = clamp(latitude, -90, 90);
146✔
168

169
    if (props.bearing < -180 || props.bearing > 180) {
146!
NEW
170
      props.bearing = mod(props.bearing + 180, 360) - 180;
×
171
    }
172
    props.pitch = clamp(props.pitch, props.minPitch, props.maxPitch);
146✔
173

174
    if (maxBounds) {
146✔
175
      props.longitude = clamp(props.longitude, maxBounds[0][0], maxBounds[1][0]);
3✔
176
      props.latitude = clamp(props.latitude, maxBounds[0][1], maxBounds[1][1]);
3✔
177
    }
178

179
    if (maxBounds) {
146✔
180
      const effectiveZoom = props.zoom - zoomAdjust(latitude);
3✔
181
      const lngSpan = maxBounds[1][0] - maxBounds[0][0];
3✔
182
      const latSpan = maxBounds[1][1] - maxBounds[0][1];
3✔
183
      if (latSpan > 0 && latSpan < 180) {
3✔
184
        const halfHeightDegrees =
185
          Math.min(pixelsToDegrees(props.height, effectiveZoom), latSpan) / 2;
2✔
186
        props.latitude = clamp(
2✔
187
          props.latitude,
188
          maxBounds[0][1] + halfHeightDegrees,
189
          maxBounds[1][1] - halfHeightDegrees
190
        );
191
      }
192
      if (lngSpan > 0 && lngSpan < 360) {
3✔
193
        const halfWidthDegrees =
194
          Math.min(
2✔
195
            pixelsToDegrees(
196
              props.width / Math.cos(props.latitude * DEGREES_TO_RADIANS),
197
              effectiveZoom
198
            ),
199
            lngSpan
200
          ) / 2;
201
        props.longitude = clamp(
2✔
202
          props.longitude,
203
          maxBounds[0][0] + halfWidthDegrees,
204
          maxBounds[1][0] - halfWidthDegrees
205
        );
206
      }
207
    }
208
    if (props.latitude !== latitude) {
146✔
209
      props.zoom += zoomAdjust(props.latitude, true) - zoomAdjust(latitude, true);
2✔
210
    }
211

212
    return props;
146✔
213
  }
214

215
  _constrainZoom(zoom: number, props?: Required<MapStateProps>): number {
216
    props ||= this.getViewportProps();
146✔
217
    const {maxZoom, maxBounds} = props;
146✔
218
    let {minZoom} = props;
146✔
219

220
    const shouldApplyMaxBounds = maxBounds !== null && props.width > 0 && props.height > 0;
146✔
221
    if (shouldApplyMaxBounds) {
146✔
222
      const minLatitude = maxBounds[0][1];
3✔
223
      const maxLatitude = maxBounds[1][1];
3✔
224
      const fitLatitude =
225
        Math.sign(minLatitude) === Math.sign(maxLatitude)
3✔
226
          ? Math.min(Math.abs(minLatitude), Math.abs(maxLatitude))
227
          : 0;
228
      const ZOOM0 = zoomAdjust(0);
146✔
229
      const w =
230
        degreesToPixels(maxBounds[1][0] - maxBounds[0][0]) *
3✔
231
        Math.cos(fitLatitude * DEGREES_TO_RADIANS);
232
      const h = degreesToPixels(maxBounds[1][1] - maxBounds[0][1]);
3✔
233
      if (w > 0) {
3!
234
        minZoom = Math.max(minZoom, Math.log2(props.width / w) + ZOOM0);
3✔
235
      }
236
      if (h > 0) {
3!
237
        minZoom = Math.max(minZoom, Math.log2(props.height / h) + ZOOM0);
3✔
238
      }
239
      if (minZoom > maxZoom) minZoom = maxZoom;
3!
240
    }
241

242
    const zoomAdjustment = zoomAdjust(props.latitude, true) - zoomAdjust(0, true);
146✔
243
    return clamp(zoom, minZoom + zoomAdjustment, maxZoom + zoomAdjustment);
146✔
244
  }
245
}
246

247
export default class GlobeController extends Controller<MapState> {
19✔
248
  ControllerState = GlobeState;
19✔
249

250
  transition = {
19✔
251
    transitionDuration: 300,
252
    transitionInterpolator: new LinearInterpolator({
253
      transitionProps: {
254
        compare: ['longitude', 'latitude', 'zoom', 'bearing', 'pitch'],
255
        required: ['longitude', 'latitude', 'zoom']
256
      }
257
    })
258
  };
259

260
  dragMode: 'pan' | 'rotate' = 'pan';
19✔
261

262
  // Ring buffer tracking globe position during pan for inertia velocity
263
  private _panHistory: Array<{longitude: number; latitude: number; timestamp: number}> = [];
19✔
264

265
  protected _onPanStart(event: MjolnirGestureEvent): boolean {
266
    this._panHistory = [];
5✔
267
    return super._onPanStart(event);
5✔
268
  }
269

270
  protected _onPanMove(event: MjolnirGestureEvent): boolean {
271
    if (!this.dragPan) {
2✔
272
      return false;
1✔
273
    }
274
    const pos = this.getCenter(event);
1✔
275
    const newControllerState = this.controllerState.pan({pos});
1✔
276
    this.updateViewport(
1✔
277
      newControllerState,
278
      {transitionDuration: 0},
279
      {
280
        isDragging: true,
281
        isPanning: true
282
      }
283
    );
284

285
    const {longitude, latitude} = newControllerState.getViewportProps();
1✔
286
    this._panHistory.push({longitude, latitude, timestamp: Date.now()});
1✔
287
    if (this._panHistory.length > 5) {
1!
NEW
288
      this._panHistory.shift();
×
289
    }
290

291
    return true;
1✔
292
  }
293

294
  protected _onPanMoveEnd(event: MjolnirGestureEvent): boolean {
295
    const {inertia} = this;
2✔
296
    if (this.dragPan && inertia && this._panHistory.length >= 2) {
2!
NEW
297
      const first = this._panHistory[0];
×
NEW
298
      const last = this._panHistory[this._panHistory.length - 1];
×
NEW
299
      const dt = last.timestamp - first.timestamp;
×
300

NEW
301
      if (dt > 0) {
×
NEW
302
        const viewportProps = this.controllerState.getViewportProps();
×
NEW
303
        const state = this.controllerState.getState() as GlobeStateInternal;
×
304

305
        // Compute velocity from the actual positions the globe was at
NEW
306
        const angularDistance = Globe.angularDistance(first, last);
×
NEW
307
        const angularVelocity = angularDistance / dt;
×
308

NEW
309
        if (angularVelocity > 1e-6) {
×
NEW
310
          const totalAngle = (angularVelocity * inertia) / 2;
×
311
          let interpolator: GlobeInertiaInterpolator;
312
          let endLng: number;
313
          let endLat: number;
314

NEW
315
          if (state.startPanLockBearing) {
×
316
            // Decompose into lng/lat velocity and extrapolate linearly
NEW
317
            let dLng = last.longitude - first.longitude;
×
NEW
318
            if (dLng > 180) dLng -= 360;
×
NEW
319
            else if (dLng < -180) dLng += 360;
×
NEW
320
            const dLat = last.latitude - first.latitude;
×
NEW
321
            const vLng = dLng / dt;
×
NEW
322
            const vLat = dLat / dt;
×
NEW
323
            endLng = viewportProps.longitude + (vLng * inertia) / 2;
×
NEW
324
            endLat = clamp(viewportProps.latitude + (vLat * inertia) / 2, -90, 90);
×
325

NEW
326
            interpolator = new GlobeInertiaInterpolator({targetLongitude: endLng});
×
327
          } else {
328
            // Free bearing — use single-axis rotation to maintain
329
            // constant spin direction with up vector tracking.
NEW
330
            const axis = Globe.greatCircleAxis(first, last);
×
NEW
331
            const currentFrame = Globe.cameraFrame(
×
332
              viewportProps.longitude,
333
              viewportProps.latitude,
334
              viewportProps.bearing || 0
×
335
            );
NEW
336
            const endFrame = Globe.rotateFrame(
×
337
              {...currentFrame, axisHorizontal: axis},
338
              totalAngle,
339
              0
340
            );
NEW
341
            endLng = endFrame.longitude;
×
NEW
342
            endLat = clamp(endFrame.latitude, -90, 90);
×
NEW
343
            interpolator = new GlobeInertiaInterpolator({axis, totalAngle});
×
344
          }
345

NEW
346
          const newControllerState = this.controllerState.panEnd();
×
NEW
347
          this.updateViewport(
×
348
            newControllerState,
349
            {
350
              transitionInterpolator: interpolator,
351
              transitionDuration: inertia,
352
              transitionEasing: GLOBE_INERTIA_EASING,
353
              longitude: endLng,
354
              latitude: endLat
355
            },
356
            {
357
              isDragging: false,
358
              isPanning: true
359
            }
360
          );
NEW
361
          this._panHistory = [];
×
NEW
362
          return true;
×
363
        }
364
      }
365
    }
366

367
    this._panHistory = [];
2✔
368
    const newControllerState = this.controllerState.panEnd();
2✔
369
    this.updateViewport(newControllerState, null, {
2✔
370
      isDragging: false,
371
      isPanning: false
372
    });
373
    return true;
2✔
374
  }
375
}
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