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

visgl / deck.gl / 26313322008

22 May 2026 09:40PM UTC coverage: 83.381% (-0.05%) from 83.434%
26313322008

Pull #10307

github

web-flow
Merge 0e9285b60 into 48760d53e
Pull Request #10307: feat(core): GlobeView pointer-anchored zoom (wheel + transitions)

7955 of 10016 branches covered (79.42%)

Branch coverage included in aggregate %.

65 of 68 new or added lines in 3 files covered. (95.59%)

108 existing lines in 7 files now uncovered.

14211 of 16568 relevant lines covered (85.77%)

19343.74 hits per line

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

76.47
/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 GlobeViewport, {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 GlobeZoomAround = 'center' | 'pointer';
39

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

49
class GlobeState extends MapState {
50
  constructor(
51
    options: MapStateProps &
52
      GlobeStateInternal & {
53
        makeViewport: (props: Record<string, any>) => any;
54
        zoomAround?: GlobeZoomAround;
55
      }
56
  ) {
57
    const {
58
      startPanPos,
59
      startPanCameraFrame,
60
      startPanAngularRate,
61
      startPanLockBearing,
62
      zoomAround,
63
      ...mapStateOptions
64
    } = options;
150✔
65
    mapStateOptions.normalize = false;
150✔
66
    super(mapStateOptions);
150✔
67

68
    const s = (this as any)._state;
150✔
69
    if (startPanPos !== undefined) s.startPanPos = startPanPos;
150✔
70
    if (startPanCameraFrame !== undefined) s.startPanCameraFrame = startPanCameraFrame;
150✔
71
    if (startPanAngularRate !== undefined) s.startPanAngularRate = startPanAngularRate;
150✔
72
    if (startPanLockBearing !== undefined) s.startPanLockBearing = startPanLockBearing;
150✔
73
    if (zoomAround !== undefined) s.zoomAround = zoomAround;
150✔
74
  }
75

76
  panStart({pos}: {pos: [number, number]}): GlobeState {
77
    const {latitude, longitude, zoom, bearing = 0} = this.getViewportProps();
6✔
78
    const cameraFrame = Globe.cameraFrame(longitude, latitude, bearing);
6✔
79
    const lockBearing = Math.abs(bearing) < 1;
6✔
80

81
    if (lockBearing) {
6!
82
      // Override horizontal axis to polar so north stays up.
83
      // Boost rate by 1/cos(lat) to compensate for smaller longitude
84
      // circles near the poles, capped at 4x.
85
      cameraFrame.axisHorizontal = [0, 0, 1];
6✔
86
    }
87

88
    // Radians of arc per pixel, derived from zoom scale
89
    const scale = Math.pow(2, zoom - zoomAdjust(latitude, true));
6✔
90
    const angularRate = (0.25 / scale) * DEGREES_TO_RADIANS;
6✔
91

92
    return this._getUpdatedState({
6✔
93
      startPanPos: pos,
94
      startPanCameraFrame: cameraFrame,
95
      startPanAngularRate: angularRate,
96
      startPanLockBearing: lockBearing,
97
      startZoom: zoom
98
    }) as GlobeState;
99
  }
100

101
  pan({pos, startPos}: {pos: [number, number]; startPos?: [number, number]}): GlobeState {
102
    const state = this.getState() as GlobeStateInternal;
5✔
103
    const startPanPos = state.startPanPos || startPos;
5!
104
    if (!startPanPos) return this;
5!
105

106
    const frame = state.startPanCameraFrame;
5✔
107
    const rate = state.startPanAngularRate;
5✔
108
    const startZoom = state.startZoom ?? this.getViewportProps().zoom;
5!
109
    if (!frame || !rate) {
5!
110
      return this;
×
111
    }
112

113
    const dx = startPanPos[0] - pos[0];
5✔
114
    const dy = startPanPos[1] - pos[1];
5✔
115

116
    let hAngle = dx * rate;
5✔
117
    let vAngle = -dy * rate;
5✔
118
    const locked = state.startPanLockBearing;
5✔
119

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

130
    const rotated = Globe.rotateFrame(frame, hAngle, vAngle, locked);
5✔
131
    const zoom = startZoom + zoomAdjust(rotated.latitude, true) - zoomAdjust(frame.latitude, true);
5✔
132

133
    return this._getUpdatedState({
5✔
134
      longitude: rotated.longitude,
135
      latitude: rotated.latitude,
136
      bearing: rotated.bearing,
137
      zoom
138
    }) as GlobeState;
139
  }
140

141
  panEnd(): GlobeState {
142
    return this._getUpdatedState({
6✔
143
      startPanPos: null,
144
      startPanCameraFrame: null,
145
      startPanAngularRate: null,
146
      startPanLockBearing: null,
147
      startZoom: null
148
    }) as GlobeState;
149
  }
150

151
  zoomStart({pos}: {pos: [number, number]}): GlobeState {
152
    const startZoomLngLat = this._shouldZoomAroundPointer()
1!
153
      ? this._unprojectOnGlobe(pos)
154
      : undefined;
155

156
    return this._getUpdatedState({
1✔
157
      startZoomLngLat,
158
      startZoom: this.getViewportProps().zoom
159
    }) as GlobeState;
160
  }
161

162
  zoom({
163
    pos,
164
    startPos,
165
    scale
166
  }: {
167
    pos: [number, number];
168
    startPos?: [number, number];
169
    scale: number;
170
  }): MapState {
171
    const state = this.getState();
11✔
172
    const {startZoom} = state;
11✔
173
    let {startZoomLngLat} = state;
11✔
174
    const hasZoomStart = startZoom !== undefined;
11✔
175
    const startZoomValue = (startZoom as number) ?? this.getViewportProps().zoom;
11✔
176
    const zoom = this._constrainZoom(startZoomValue + Math.log2(scale));
11✔
177

178
    if (!this._shouldZoomAroundPointer()) {
11✔
179
      return this._getUpdatedState({zoom});
10✔
180
    }
181

182
    if (!startZoomLngLat && !hasZoomStart) {
1!
183
      startZoomLngLat = this._unprojectOnGlobe(startPos) || this._unprojectOnGlobe(pos);
1✔
184
    }
185

186
    if (!startZoomLngLat) {
1!
NEW
187
      return this._getUpdatedState({zoom});
×
188
    }
189

190
    const zoomedViewport = this.makeViewport({...this.getViewportProps(), zoom}) as GlobeViewport;
1✔
191
    return this._getUpdatedState({
1✔
192
      zoom,
193
      ...zoomedViewport.panByGlobeAnchor(startZoomLngLat, pos)
194
    });
195
  }
196

197
  zoomEnd(): GlobeState {
198
    return this._getUpdatedState({
1✔
199
      startZoomLngLat: null,
200
      startZoom: null
201
    }) as GlobeState;
202
  }
203

204
  _panFromCenter(offset: [number, number]): GlobeState {
205
    const {width, height} = this.getViewportProps();
4✔
206
    const center: [number, number] = [width / 2, height / 2];
4✔
207
    return this.panStart({pos: center})
4✔
208
      .pan({pos: [center[0] + offset[0], center[1] + offset[1]]})
209
      .panEnd();
210
  }
211

212
  applyConstraints(props: Required<MapStateProps>): Required<MapStateProps> {
213
    const {longitude, latitude, maxBounds} = props;
150✔
214

215
    props.zoom = this._constrainZoom(props.zoom, props);
150✔
216

217
    if (longitude < -180 || longitude > 180) {
150✔
218
      props.longitude = mod(longitude + 180, 360) - 180;
1✔
219
    }
220
    props.latitude = clamp(latitude, -90, 90);
150✔
221

222
    if (props.bearing < -180 || props.bearing > 180) {
150!
223
      props.bearing = mod(props.bearing + 180, 360) - 180;
×
224
    }
225
    props.pitch = clamp(props.pitch, props.minPitch, props.maxPitch);
150✔
226

227
    if (maxBounds) {
150✔
228
      props.longitude = clamp(props.longitude, maxBounds[0][0], maxBounds[1][0]);
3✔
229
      props.latitude = clamp(props.latitude, maxBounds[0][1], maxBounds[1][1]);
3✔
230
    }
231

232
    if (maxBounds) {
150✔
233
      const effectiveZoom = props.zoom - zoomAdjust(latitude);
3✔
234
      const lngSpan = maxBounds[1][0] - maxBounds[0][0];
3✔
235
      const latSpan = maxBounds[1][1] - maxBounds[0][1];
3✔
236
      if (latSpan > 0 && latSpan < 180) {
3✔
237
        const halfHeightDegrees =
238
          Math.min(pixelsToDegrees(props.height, effectiveZoom), latSpan) / 2;
2✔
239
        props.latitude = clamp(
2✔
240
          props.latitude,
241
          maxBounds[0][1] + halfHeightDegrees,
242
          maxBounds[1][1] - halfHeightDegrees
243
        );
244
      }
245
      if (lngSpan > 0 && lngSpan < 360) {
3✔
246
        const halfWidthDegrees =
247
          Math.min(
2✔
248
            pixelsToDegrees(
249
              props.width / Math.cos(props.latitude * DEGREES_TO_RADIANS),
250
              effectiveZoom
251
            ),
252
            lngSpan
253
          ) / 2;
254
        props.longitude = clamp(
2✔
255
          props.longitude,
256
          maxBounds[0][0] + halfWidthDegrees,
257
          maxBounds[1][0] - halfWidthDegrees
258
        );
259
      }
260
    }
261
    if (props.latitude !== latitude) {
150✔
262
      props.zoom += zoomAdjust(props.latitude, true) - zoomAdjust(latitude, true);
2✔
263
    }
264

265
    return props;
150✔
266
  }
267

268
  _constrainZoom(zoom: number, props?: Required<MapStateProps>): number {
269
    props ||= this.getViewportProps();
161✔
270
    const {maxZoom, maxBounds} = props;
161✔
271
    let {minZoom} = props;
161✔
272

273
    const shouldApplyMaxBounds = maxBounds !== null && props.width > 0 && props.height > 0;
161✔
274
    if (shouldApplyMaxBounds) {
161✔
275
      const minLatitude = maxBounds[0][1];
3✔
276
      const maxLatitude = maxBounds[1][1];
3✔
277
      const fitLatitude =
278
        Math.sign(minLatitude) === Math.sign(maxLatitude)
3✔
279
          ? Math.min(Math.abs(minLatitude), Math.abs(maxLatitude))
280
          : 0;
281
      const ZOOM0 = zoomAdjust(0);
161✔
282
      const w =
283
        degreesToPixels(maxBounds[1][0] - maxBounds[0][0]) *
3✔
284
        Math.cos(fitLatitude * DEGREES_TO_RADIANS);
285
      const h = degreesToPixels(maxBounds[1][1] - maxBounds[0][1]);
3✔
286
      if (w > 0) {
3!
287
        minZoom = Math.max(minZoom, Math.log2(props.width / w) + ZOOM0);
3✔
288
      }
289
      if (h > 0) {
3!
290
        minZoom = Math.max(minZoom, Math.log2(props.height / h) + ZOOM0);
3✔
291
      }
292
      if (minZoom > maxZoom) minZoom = maxZoom;
3!
293
    }
294

295
    const zoomAdjustment = zoomAdjust(props.latitude, true) - zoomAdjust(0, true);
161✔
296
    return clamp(zoom, minZoom + zoomAdjustment, maxZoom + zoomAdjustment);
161✔
297
  }
298

299
  private _unprojectOnGlobe(pos?: [number, number]): [number, number] | undefined {
300
    if (!pos) {
2✔
301
      return undefined;
1✔
302
    }
303

304
    const viewport = this.makeViewport(this.getViewportProps()) as GlobeViewport;
1✔
305
    if (!viewport.isPointOnGlobe(pos)) {
1!
NEW
306
      return undefined;
×
307
    }
308

309
    const lngLat = viewport.unproject(pos);
1✔
310
    return [lngLat[0], lngLat[1]];
1✔
311
  }
312

313
  private _shouldZoomAroundPointer(): boolean {
314
    return (this.getState() as GlobeStateInternal).zoomAround === 'pointer';
12✔
315
  }
316
}
317

318
export default class GlobeController extends Controller<MapState> {
21✔
319
  ControllerState = GlobeState;
21✔
320

321
  transition = {
21✔
322
    transitionDuration: 300,
323
    transitionInterpolator: new LinearInterpolator({
324
      transitionProps: {
325
        compare: ['longitude', 'latitude', 'zoom', 'bearing', 'pitch'],
326
        required: ['longitude', 'latitude', 'zoom']
327
      }
328
    })
329
  };
330

331
  dragMode: 'pan' | 'rotate' = 'pan';
21✔
332

333
  // Ring buffer tracking globe position during pan for inertia velocity
334
  private _panHistory: Array<{longitude: number; latitude: number; timestamp: number}> = [];
21✔
335

336
  protected _onPanStart(event: MjolnirGestureEvent): boolean {
337
    this._panHistory = [];
5✔
338
    return super._onPanStart(event);
5✔
339
  }
340

341
  protected _onPanMove(event: MjolnirGestureEvent): boolean {
342
    if (!this.dragPan) {
2✔
343
      return false;
1✔
344
    }
345
    const pos = this.getCenter(event);
1✔
346
    const newControllerState = this.controllerState.pan({pos});
1✔
347
    this.updateViewport(
1✔
348
      newControllerState,
349
      {transitionDuration: 0},
350
      {
351
        isDragging: true,
352
        isPanning: true
353
      }
354
    );
355

356
    const {longitude, latitude} = newControllerState.getViewportProps();
1✔
357
    this._panHistory.push({longitude, latitude, timestamp: Date.now()});
1✔
358
    if (this._panHistory.length > 5) {
1!
359
      this._panHistory.shift();
×
360
    }
361

362
    return true;
1✔
363
  }
364

365
  protected _onPanMoveEnd(event: MjolnirGestureEvent): boolean {
366
    const {inertia} = this;
2✔
367
    if (this.dragPan && inertia && this._panHistory.length >= 2) {
2!
368
      const first = this._panHistory[0];
×
369
      const last = this._panHistory[this._panHistory.length - 1];
×
370
      const dt = last.timestamp - first.timestamp;
×
371

372
      if (dt > 0) {
×
373
        const viewportProps = this.controllerState.getViewportProps();
×
374
        const state = this.controllerState.getState() as GlobeStateInternal;
×
375

376
        // Compute velocity from the actual positions the globe was at
377
        const angularDistance = Globe.angularDistance(first, last);
×
378
        const angularVelocity = angularDistance / dt;
×
379

380
        if (angularVelocity > 1e-6) {
×
381
          const totalAngle = (angularVelocity * inertia) / 2;
×
382
          let interpolator: GlobeInertiaInterpolator;
383
          let endLng: number;
384
          let endLat: number;
385

386
          if (state.startPanLockBearing) {
×
387
            // Decompose into lng/lat velocity and extrapolate linearly
388
            let dLng = last.longitude - first.longitude;
×
389
            if (dLng > 180) dLng -= 360;
×
390
            else if (dLng < -180) dLng += 360;
×
391
            const dLat = last.latitude - first.latitude;
×
392
            const vLng = dLng / dt;
×
393
            const vLat = dLat / dt;
×
394
            endLng = viewportProps.longitude + (vLng * inertia) / 2;
×
395
            endLat = clamp(viewportProps.latitude + (vLat * inertia) / 2, -90, 90);
×
396

397
            interpolator = new GlobeInertiaInterpolator({targetLongitude: endLng});
×
398
          } else {
399
            // Free bearing — use single-axis rotation to maintain
400
            // constant spin direction with up vector tracking.
401
            const axis = Globe.greatCircleAxis(first, last);
×
402
            const currentFrame = Globe.cameraFrame(
×
403
              viewportProps.longitude,
404
              viewportProps.latitude,
405
              viewportProps.bearing || 0
×
406
            );
407
            const endFrame = Globe.rotateFrame(
×
408
              {...currentFrame, axisHorizontal: axis},
409
              totalAngle,
410
              0
411
            );
412
            endLng = endFrame.longitude;
×
413
            endLat = clamp(endFrame.latitude, -90, 90);
×
414
            interpolator = new GlobeInertiaInterpolator({axis, totalAngle});
×
415
          }
416

417
          const newControllerState = this.controllerState.panEnd();
×
418
          this.updateViewport(
×
419
            newControllerState,
420
            {
421
              transitionInterpolator: interpolator,
422
              transitionDuration: inertia,
423
              transitionEasing: GLOBE_INERTIA_EASING,
424
              longitude: endLng,
425
              latitude: endLat
426
            },
427
            {
428
              isDragging: false,
429
              isPanning: true
430
            }
431
          );
432
          this._panHistory = [];
×
433
          return true;
×
434
        }
435
      }
436
    }
437

438
    this._panHistory = [];
2✔
439
    const newControllerState = this.controllerState.panEnd();
2✔
440
    this.updateViewport(newControllerState, null, {
2✔
441
      isDragging: false,
442
      isPanning: false
443
    });
444
    return true;
2✔
445
  }
446
}
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