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

visgl / deck.gl / 23318212415

19 Mar 2026 09:39PM UTC coverage: 91.021% (-0.04%) from 91.057%
23318212415

Pull #10116

github

web-flow
Merge c7f64856e into 6f16d20c7
Pull Request #10116: feat(core): OrbitController supports maxBounds

7058 of 7803 branches covered (90.45%)

Branch coverage included in aggregate %.

115 of 132 new or added lines in 6 files covered. (87.12%)

2 existing lines in 1 file now uncovered.

58070 of 63750 relevant lines covered (91.09%)

14076.74 hits per line

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

90.5
/modules/core/src/controllers/orbit-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} from './controller';
1✔
7
import ViewState from './view-state';
1✔
8
import {mod} from '../utils/math-utils';
1✔
9

1✔
10
import type Viewport from '../viewports/viewport';
1✔
11
import LinearInterpolator from '../transitions/linear-interpolator';
1✔
12

1✔
13
export type OrbitStateProps = {
1✔
14
  width: number;
1✔
15
  height: number;
1✔
16
  target?: [number, number, number];
1✔
17
  zoom?: number;
1✔
18
  rotationX?: number;
1✔
19
  rotationOrbit?: number;
1✔
20

1✔
21
  /** Viewport constraints */
1✔
22
  maxZoom?: number;
1✔
23
  minZoom?: number;
1✔
24
  minRotationX?: number;
1✔
25
  maxRotationX?: number;
1✔
26

1✔
27
  maxBounds?: ControllerProps['maxBounds'];
1✔
28
};
1✔
29

1✔
30
type OrbitStateInternal = {
1✔
31
  startPanPosition?: number[];
1✔
32
  startRotatePos?: number[];
1✔
33
  startRotationX?: number;
1✔
34
  startRotationOrbit?: number;
1✔
35
  startZoomPosition?: number[];
1✔
36
  startZoom?: number | number[];
1✔
37
};
1✔
38

1✔
39
export class OrbitState extends ViewState<OrbitState, OrbitStateProps, OrbitStateInternal> {
1✔
40
  unproject3D?: (pos: number[]) => number[] | null;
1✔
41

1✔
42
  constructor(
1✔
43
    options: OrbitStateProps &
165✔
44
      OrbitStateInternal & {
165✔
45
        makeViewport: (props: Record<string, any>) => Viewport;
165✔
46
        unproject3D?: (pos: number[]) => number[] | null;
165✔
47
      }
165✔
48
  ) {
165✔
49
    const {
165✔
50
      /* Viewport arguments */
165✔
51
      width, // Width of viewport
165✔
52
      height, // Height of viewport
165✔
53
      rotationX = 0, // Rotation around x axis
165✔
54
      rotationOrbit = 0, // Rotation around orbit axis
165✔
55
      target = [0, 0, 0],
165✔
56
      zoom = 0,
165✔
57

165✔
58
      /* Viewport constraints */
165✔
59
      minRotationX = -90,
165✔
60
      maxRotationX = 90,
165✔
61
      minZoom = -Infinity,
165✔
62
      maxZoom = Infinity,
165✔
63

165✔
64
      maxBounds = null,
165✔
65

165✔
66
      /** Interaction states, required to calculate change during transform */
165✔
67
      // Model state when the pan operation first started
165✔
68
      startPanPosition,
165✔
69
      // Model state when the rotate operation first started
165✔
70
      startRotatePos,
165✔
71
      startRotationX,
165✔
72
      startRotationOrbit,
165✔
73
      // Model state when the zoom operation first started
165✔
74
      startZoomPosition,
165✔
75
      startZoom
165✔
76
    } = options;
165✔
77

165✔
78
    super(
165✔
79
      {
165✔
80
        width,
165✔
81
        height,
165✔
82
        rotationX,
165✔
83
        rotationOrbit,
165✔
84
        target,
165✔
85
        zoom,
165✔
86
        minRotationX,
165✔
87
        maxRotationX,
165✔
88
        minZoom,
165✔
89
        maxZoom,
165✔
90
        maxBounds
165✔
91
      },
165✔
92
      {
165✔
93
        startPanPosition,
165✔
94
        startRotatePos,
165✔
95
        startRotationX,
165✔
96
        startRotationOrbit,
165✔
97
        startZoomPosition,
165✔
98
        startZoom
165✔
99
      },
165✔
100
      options.makeViewport
165✔
101
    );
165✔
102

165✔
103
    this.unproject3D = options.unproject3D;
165✔
104
  }
165✔
105

1✔
106
  /**
1✔
107
   * Start panning
1✔
108
   * @param {[Number, Number]} pos - position on screen where the pointer grabs
1✔
109
   */
1✔
110
  panStart({pos}: {pos: [number, number]}): OrbitState {
1✔
111
    return this._getUpdatedState({
2✔
112
      startPanPosition: this._unproject(pos)
2✔
113
    });
2✔
114
  }
2✔
115

1✔
116
  /**
1✔
117
   * Pan
1✔
118
   * @param {[Number, Number]} pos - position on screen where the pointer is
1✔
119
   */
1✔
120
  pan({pos, startPosition}: {pos: [number, number]; startPosition?: number[]}): OrbitState {
1✔
121
    const startPanPosition = this.getState().startPanPosition || startPosition;
5✔
122

5✔
123
    if (!startPanPosition) {
5!
124
      return this;
×
125
    }
×
126

5✔
127
    const viewport = this.makeViewport(this.getViewportProps());
5✔
128
    const newProps = viewport.panByPosition(startPanPosition, pos);
5✔
129

5✔
130
    return this._getUpdatedState(newProps);
5✔
131
  }
5✔
132

1✔
133
  /**
1✔
134
   * End panning
1✔
135
   * Must call if `panStart()` was called
1✔
136
   */
1✔
137
  panEnd(): OrbitState {
1✔
138
    return this._getUpdatedState({
2✔
139
      startPanPosition: null
2✔
140
    });
2✔
141
  }
2✔
142

1✔
143
  /**
1✔
144
   * Start rotating
1✔
145
   * @param {[Number, Number]} pos - position on screen where the pointer grabs
1✔
146
   */
1✔
147
  rotateStart({pos}: {pos: [number, number]}): OrbitState {
1✔
148
    return this._getUpdatedState({
6✔
149
      startRotatePos: pos,
6✔
150
      startRotationX: this.getViewportProps().rotationX,
6✔
151
      startRotationOrbit: this.getViewportProps().rotationOrbit
6✔
152
    });
6✔
153
  }
6✔
154

1✔
155
  /**
1✔
156
   * Rotate
1✔
157
   * @param {[Number, Number]} pos - position on screen where the pointer is
1✔
158
   */
1✔
159
  rotate({
1✔
160
    pos,
3✔
161
    deltaAngleX = 0,
3✔
162
    deltaAngleY = 0
3✔
163
  }: {
3✔
164
    pos?: [number, number];
3✔
165
    deltaAngleX?: number;
3✔
166
    deltaAngleY?: number;
3✔
167
  }): OrbitState {
3✔
168
    const {startRotatePos, startRotationX, startRotationOrbit} = this.getState();
3✔
169
    const {width, height} = this.getViewportProps();
3✔
170

3✔
171
    if (!startRotatePos || startRotationX === undefined || startRotationOrbit === undefined) {
3!
172
      return this;
×
173
    }
×
174

3✔
175
    let newRotation;
3✔
176
    if (pos) {
3✔
177
      let deltaScaleX = (pos[0] - startRotatePos[0]) / width;
2✔
178
      const deltaScaleY = (pos[1] - startRotatePos[1]) / height;
2✔
179

2✔
180
      if (startRotationX < -90 || startRotationX > 90) {
2!
181
        // When looking at the "back" side of the scene, invert horizontal drag
×
182
        // so that the camera movement follows user input
×
183
        deltaScaleX *= -1;
×
184
      }
×
185
      newRotation = {
2✔
186
        rotationX: startRotationX + deltaScaleY * 180,
2✔
187
        rotationOrbit: startRotationOrbit + deltaScaleX * 180
2✔
188
      };
2✔
189
    } else {
3✔
190
      newRotation = {
1✔
191
        rotationX: startRotationX + deltaAngleY,
1✔
192
        rotationOrbit: startRotationOrbit + deltaAngleX
1✔
193
      };
1✔
194
    }
1✔
195

3✔
196
    return this._getUpdatedState(newRotation);
3✔
197
  }
3✔
198

1✔
199
  /**
1✔
200
   * End rotating
1✔
201
   * Must call if `rotateStart()` was called
1✔
202
   */
1✔
203
  rotateEnd(): OrbitState {
1✔
204
    return this._getUpdatedState({
6✔
205
      startRotationX: null,
6✔
206
      startRotationOrbit: null
6✔
207
    });
6✔
208
  }
6✔
209

1✔
210
  // shortest path between two view states
1✔
211
  shortestPathFrom(viewState: OrbitState): OrbitStateProps {
1✔
212
    const fromProps = viewState.getViewportProps();
15✔
213
    const props = {...this.getViewportProps()};
15✔
214
    const {rotationOrbit} = props;
15✔
215

15✔
216
    if (Math.abs(rotationOrbit - fromProps.rotationOrbit) > 180) {
15✔
217
      props.rotationOrbit = rotationOrbit < 0 ? rotationOrbit + 360 : rotationOrbit - 360;
1!
218
    }
1✔
219

15✔
220
    return props;
15✔
221
  }
15✔
222

1✔
223
  /**
1✔
224
   * Start zooming
1✔
225
   * @param {[Number, Number]} pos - position on screen where the pointer grabs
1✔
226
   */
1✔
227
  zoomStart({pos}: {pos: [number, number]}): OrbitState {
1✔
228
    return this._getUpdatedState({
2✔
229
      startZoomPosition: this._unproject(pos),
2✔
230
      startZoom: this.getViewportProps().zoom
2✔
231
    });
2✔
232
  }
2✔
233

1✔
234
  /**
1✔
235
   * Zoom
1✔
236
   * @param {[Number, Number]} pos - position on screen where the current target is
1✔
237
   * @param {[Number, Number]} startPos - the target position at
1✔
238
   *   the start of the operation. Must be supplied of `zoomStart()` was not called
1✔
239
   * @param {Number} scale - a number between [0, 1] specifying the accumulated
1✔
240
   *   relative scale.
1✔
241
   */
1✔
242
  zoom({
1✔
243
    pos,
3✔
244
    startPos,
3✔
245
    scale
3✔
246
  }: {
3✔
247
    pos: [number, number];
3✔
248
    startPos?: [number, number];
3✔
249
    scale: number;
3✔
250
  }): OrbitState {
3✔
251
    let {startZoom, startZoomPosition} = this.getState();
3✔
252
    if (!startZoomPosition) {
3✔
253
      // We have two modes of zoom:
2✔
254
      // scroll zoom that are discrete events (transform from the current zoom level),
2✔
255
      // and pinch zoom that are continuous events (transform from the zoom level when
2✔
256
      // pinch started).
2✔
257
      // If startZoom state is defined, then use the startZoom state;
2✔
258
      // otherwise assume discrete zooming
2✔
259
      startZoom = this.getViewportProps().zoom;
2✔
260
      startZoomPosition = this._unproject(startPos || pos);
2✔
261
    }
2✔
262
    if (!startZoomPosition) {
3!
263
      return this;
×
264
    }
×
265
    const newZoom = this._calculateNewZoom({scale, startZoom});
3✔
266
    const zoomedViewport = this.makeViewport({...this.getViewportProps(), zoom: newZoom});
3✔
267

3✔
268
    return this._getUpdatedState({
3✔
269
      zoom: newZoom,
3✔
270
      ...zoomedViewport.panByPosition(startZoomPosition, pos)
3✔
271
    });
3✔
272
  }
3✔
273

1✔
274
  /**
1✔
275
   * End zooming
1✔
276
   * Must call if `zoomStart()` was called
1✔
277
   */
1✔
278
  zoomEnd(): OrbitState {
1✔
279
    return this._getUpdatedState({
2✔
280
      startZoomPosition: null,
2✔
281
      startZoom: null
2✔
282
    });
2✔
283
  }
2✔
284

1✔
285
  zoomIn(speed: number = 2): OrbitState {
1✔
286
    return this._getUpdatedState({
3✔
287
      zoom: this._calculateNewZoom({scale: speed})
3✔
288
    });
3✔
289
  }
3✔
290

1✔
291
  zoomOut(speed: number = 2): OrbitState {
1✔
292
    return this._getUpdatedState({
4✔
293
      zoom: this._calculateNewZoom({scale: 1 / speed})
4✔
294
    });
4✔
295
  }
4✔
296

1✔
297
  moveLeft(speed: number = 50): OrbitState {
1✔
298
    return this._panFromCenter([-speed, 0]);
1✔
299
  }
1✔
300

1✔
301
  moveRight(speed: number = 50): OrbitState {
1✔
302
    return this._panFromCenter([speed, 0]);
1✔
303
  }
1✔
304

1✔
305
  moveUp(speed: number = 50): OrbitState {
1✔
306
    return this._panFromCenter([0, -speed]);
1✔
307
  }
1✔
308

1✔
309
  moveDown(speed: number = 50): OrbitState {
1✔
310
    return this._panFromCenter([0, speed]);
1✔
311
  }
1✔
312

1✔
313
  rotateLeft(speed: number = 15): OrbitState {
1✔
314
    return this._getUpdatedState({
1✔
315
      rotationOrbit: this.getViewportProps().rotationOrbit - speed
1✔
316
    });
1✔
317
  }
1✔
318

1✔
319
  rotateRight(speed: number = 15): OrbitState {
1✔
320
    return this._getUpdatedState({
1✔
321
      rotationOrbit: this.getViewportProps().rotationOrbit + speed
1✔
322
    });
1✔
323
  }
1✔
324

1✔
325
  rotateUp(speed: number = 10): OrbitState {
1✔
326
    return this._getUpdatedState({
1✔
327
      rotationX: this.getViewportProps().rotationX - speed
1✔
328
    });
1✔
329
  }
1✔
330

1✔
331
  rotateDown(speed: number = 10): OrbitState {
1✔
332
    return this._getUpdatedState({
1✔
333
      rotationX: this.getViewportProps().rotationX + speed
1✔
334
    });
1✔
335
  }
1✔
336

1✔
337
  /* Private methods */
1✔
338

1✔
339
  _project(pos: number[]): number[] {
1✔
340
    const viewport = this.makeViewport(this.getViewportProps());
4✔
341
    return viewport.project(pos);
4✔
342
  }
4✔
343
  _unproject(pos: number[]): number[] {
1✔
344
    const p = this.unproject3D?.(pos);
6✔
345
    if (p) return p;
6!
346
    const viewport = this.makeViewport(this.getViewportProps());
6✔
347
    return viewport.unproject(pos);
6✔
348
  }
6✔
349

1✔
350
  // Calculates new zoom
1✔
351
  _calculateNewZoom({
1✔
352
    scale,
10✔
353
    startZoom
10✔
354
  }: {
10✔
355
    scale: number;
10✔
356
    startZoom?: number | number[];
10✔
357
  }): number | number[] {
10✔
358
    if (startZoom === undefined) {
10✔
359
      startZoom = this.getViewportProps().zoom;
7✔
360
    }
7✔
361
    const zoom = (startZoom as number) + Math.log2(scale);
10✔
362
    return this._constrainZoom(zoom);
10✔
363
  }
10✔
364

1✔
365
  _panFromCenter(offset) {
1✔
366
    const {target} = this.getViewportProps();
4✔
367
    const center = this._project(target);
4✔
368
    return this.pan({
4✔
369
      startPosition: target,
4✔
370
      pos: [center[0] + offset[0], center[1] + offset[1]]
4✔
371
    });
4✔
372
  }
4✔
373

1✔
374
  _getUpdatedState(newProps): OrbitState {
1✔
375
    // @ts-ignore
42✔
376
    return new this.constructor({
42✔
377
      makeViewport: this.makeViewport,
42✔
378
      ...this.getViewportProps(),
42✔
379
      ...this.getState(),
42✔
380
      ...newProps
42✔
381
    });
42✔
382
  }
42✔
383

1✔
384
  // Apply any constraints (mathematical or defined by _viewportProps) to map state
1✔
385
  applyConstraints(props: Required<OrbitStateProps>): Required<OrbitStateProps> {
1✔
386
    // Ensure zoom is within specified range
165✔
387
    const {maxRotationX, minRotationX, rotationOrbit} = props;
165✔
388

165✔
389
    props.zoom = this._constrainZoom(props.zoom, props);
165✔
390

165✔
391
    props.rotationX = clamp(props.rotationX, minRotationX, maxRotationX);
165✔
392
    if (rotationOrbit < -180 || rotationOrbit > 180) {
165✔
393
      props.rotationOrbit = mod(rotationOrbit + 180, 360) - 180;
1✔
394
    }
1✔
395

165✔
396
    props.target = this._constrainTarget(props);
165✔
397

165✔
398
    return props;
165✔
399
  }
165✔
400

1✔
401
  _constrainZoom(zoom: number, props?: Required<OrbitStateProps>) {
1✔
402
    props ||= this.getViewportProps();
175✔
403
    const {maxZoom, maxBounds} = props;
175✔
404
    let {minZoom} = props;
175✔
405

175✔
406
    if (maxBounds && props.width > 0 && props.height > 0) {
175✔
407
      const dx = maxBounds[1][0] - maxBounds[0][0];
2✔
408
      const dy = maxBounds[1][1] - maxBounds[0][1];
2✔
409
      const dz = (maxBounds[1][2] ?? 0) - (maxBounds[0][2] ?? 0);
2!
410
      const maxDiameter = Math.sqrt(dx * dx + dy * dy + dz * dz);
2✔
411
      if (maxDiameter > 0) {
2✔
412
        minZoom = Math.max(minZoom, Math.log2(Math.min(props.width, props.height) / maxDiameter));
2✔
413
        if (minZoom > maxZoom) minZoom = maxZoom;
2!
414
      }
2✔
415
    }
2✔
416

175✔
417
    return clamp(zoom, minZoom, maxZoom);
175✔
418
  }
175✔
419

1✔
420
  _constrainTarget(props: Required<OrbitStateProps>): [number, number, number] {
1✔
421
    const {target, maxBounds} = props;
165✔
422
    if (!maxBounds) return target;
165✔
423
    const [[minX, minY, minZ = 0], [maxX, maxY, maxZ = 0]] = maxBounds;
2✔
424
    if (
2✔
425
      target[0] >= minX &&
2!
NEW
426
      target[0] <= maxX &&
×
NEW
427
      target[1] >= minY &&
×
NEW
428
      target[1] <= maxY &&
×
NEW
429
      target[2] >= minZ &&
×
NEW
430
      target[2] <= maxZ
×
431
    ) {
165!
NEW
432
      return target;
×
NEW
433
    }
✔
434

2✔
435
    const vp = this.makeViewport?.(props);
2✔
436
    if (vp) {
165✔
437
      // Given the bounding box and the target plane (defined by target position and distance to near plane)
2✔
438
      // Move target to the closest point on the plane that is also inside the bounding box
2✔
439
      const {cameraPosition} = vp;
2✔
440
      const nx = cameraPosition[0] - target[0];
2✔
441
      const ny = cameraPosition[1] - target[1];
2✔
442
      const nz = cameraPosition[2] - target[2];
2✔
443
      const c = nx * target[0] + ny * target[1] + nz * target[2];
2✔
444
      const minDot =
2✔
445
        nx * (nx >= 0 ? minX : maxX) + ny * (ny >= 0 ? minY : maxY) + nz * (nz >= 0 ? minZ : maxZ);
2!
446
      const maxDot =
2✔
447
        nx * (nx >= 0 ? maxX : minX) + ny * (ny >= 0 ? maxY : minY) + nz * (nz >= 0 ? maxZ : minZ);
2!
448

2✔
449
      if ((nx || ny || nz) && c >= minDot && c <= maxDot) {
2!
450
        // Target plane intersects the bounding box
1✔
451
        const clampX = (value: number) => clamp(value, minX, maxX);
1✔
452
        const clampY = (value: number) => clamp(value, minY, maxY);
1✔
453
        const clampZ = (value: number) => clamp(value, minZ, maxZ);
1✔
454
        const f = (lambda: number) =>
1✔
455
          nx * clampX(target[0] - lambda * nx) +
32✔
456
          ny * clampY(target[1] - lambda * ny) +
32✔
457
          nz * clampZ(target[2] - lambda * nz) -
32✔
458
          c;
32✔
459

1✔
460
        let lo = -1;
1✔
461
        let hi = 1;
1✔
462
        let flo = f(lo);
1✔
463
        let fhi = f(hi);
1✔
464

1✔
465
        while (flo < 0) {
1!
NEW
466
          hi = lo;
×
NEW
467
          fhi = flo;
×
NEW
468
          lo *= 2;
×
NEW
469
          flo = f(lo);
×
NEW
470
        }
×
471
        while (fhi > 0) {
1!
NEW
472
          lo = hi;
×
NEW
473
          flo = fhi;
×
NEW
474
          hi *= 2;
×
NEW
475
          fhi = f(hi);
×
NEW
476
        }
×
477

1✔
478
        for (let i = 0; i < 30; i++) {
1✔
479
          const mid = (lo + hi) / 2;
30✔
480
          const fm = f(mid);
30✔
481
          if (fm > 0) {
30✔
482
            lo = mid;
15✔
483
          } else {
15✔
484
            hi = mid;
15✔
485
          }
15✔
486
        }
30✔
487

1✔
488
        const lambda = (lo + hi) / 2;
1✔
489
        return [
1✔
490
          clampX(target[0] - lambda * nx),
1✔
491
          clampY(target[1] - lambda * ny),
1✔
492
          clampZ(target[2] - lambda * nz)
1✔
493
        ];
1✔
494
      }
1✔
495
    }
2✔
496
    // Fallback if the camera vector degenerates or the plane misses the box.
1✔
497
    return [
1✔
498
      clamp(target[0], minX, maxX),
1✔
499
      clamp(target[1], minY, maxY),
1✔
500
      clamp(target[2], minZ, maxZ)
1✔
501
    ];
1✔
502
  }
165✔
503
}
1✔
504

1✔
505
export default class OrbitController extends Controller<OrbitState> {
3✔
506
  ControllerState = OrbitState;
3✔
507

3✔
508
  transition = {
3✔
509
    transitionDuration: 300,
3✔
510
    transitionInterpolator: new LinearInterpolator({
3✔
511
      transitionProps: {
3✔
512
        compare: ['target', 'zoom', 'rotationX', 'rotationOrbit'],
3✔
513
        required: ['target', 'zoom']
3✔
514
      }
3✔
515
    })
3✔
516
  };
3✔
517

3✔
518
  setProps(
3✔
519
    props: ControllerProps &
3✔
520
      OrbitStateProps & {
3✔
521
        unproject3D?: (pos: number[]) => number[] | null;
3✔
522
      }
3✔
523
  ) {
3✔
524
    // this will be passed to OrbitState constructor
3✔
525
    props.unproject3D = this._unproject3D;
3✔
526

3✔
527
    super.setProps(props);
3✔
528
  }
3✔
529

3✔
530
  protected _unproject3D = (pos: number[]): number[] | null => {
3✔
531
    if (this.pickPosition) {
6!
532
      const {x, y} = this.props;
×
533
      const pickResult = this.pickPosition(x + pos[0], y + pos[1]);
×
534
      if (pickResult && pickResult.coordinate) {
×
535
        return pickResult.coordinate;
×
536
      }
×
537
    }
×
538
    return null;
6✔
539
  };
6✔
540
}
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