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

visgl / luma.gl / 29951130483

22 Jul 2026 07:28PM UTC coverage: 72.248% (-0.04%) from 72.29%
29951130483

push

github

web-flow
feat(experimental) Add OrbitControls (#2748)

11340 of 17665 branches covered (64.19%)

Branch coverage included in aggregate %.

37 of 68 new or added lines in 1 file covered. (54.41%)

21969 of 28439 relevant lines covered (77.25%)

5574.24 hits per line

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

50.0
/modules/experimental/src/controls/orbit-controls.ts
1
// luma.gl
2
// SPDX-License-Identifier: MIT
3
// Copyright (c) vis.gl contributors
4

5
export type OrbitPosition = [number, number, number];
6

7
export type OrbitControlsProps = {
8
  target?: Readonly<OrbitPosition>;
9
  distance?: number;
10
  yaw?: number;
11
  pitch?: number;
12
  minDistance?: number;
13
  maxDistance?: number;
14
  minPitch?: number;
15
  maxPitch?: number;
16
  rotateSpeed?: number;
17
  zoomSpeed?: number;
18
  autoRotate?: boolean;
19
  autoRotateSpeed?: number;
20
};
21

22
type ResolvedOrbitControlsProps = Required<OrbitControlsProps>;
23

24
const DEFAULT_PROPS: ResolvedOrbitControlsProps = {
13✔
25
  target: [0, 0, 0],
26
  distance: 10,
27
  yaw: 0,
28
  pitch: 0.25,
29
  minDistance: 1,
30
  maxDistance: 100,
31
  minPitch: -Math.PI / 2 + 0.01,
32
  maxPitch: Math.PI / 2 - 0.01,
33
  rotateSpeed: 0.006,
34
  zoomSpeed: 0.001,
35
  autoRotate: false,
36
  autoRotateSpeed: 0.1
37
};
38

39
/**
40
 * Pointer orbit, wheel zoom, and optional automatic rotation controls for an HTML canvas.
41
 *
42
 * Call {@link update} once per frame with an animation-loop timestamp in milliseconds before
43
 * reading {@link getEyePosition}. Manual dragging temporarily pauses automatic rotation and
44
 * resumes it from the new angle when the pointer is released.
45
 */
46
export class OrbitControls {
47
  readonly canvas: HTMLCanvasElement;
48
  readonly props: ResolvedOrbitControlsProps;
49
  yaw: number;
50
  pitch: number;
51
  distance: number;
52

53
  private dragging = false;
1✔
54
  private pointerId: number | null = null;
1✔
55
  private lastPointer: [number, number] = [0, 0];
1✔
56
  private previousTimeMilliseconds: number | null = null;
1✔
57
  private readonly previousCursor: string;
58
  private readonly previousTouchAction: string;
59

60
  constructor(canvas: HTMLCanvasElement, props: OrbitControlsProps = {}) {
×
61
    this.canvas = canvas;
1✔
62
    this.props = {...DEFAULT_PROPS, ...props};
1✔
63
    this.props.target = [...this.props.target];
1✔
64
    this.yaw = this.props.yaw;
1✔
65
    this.pitch = clampNumber(this.props.pitch, this.props.minPitch, this.props.maxPitch);
1✔
66
    this.distance = clampNumber(
1✔
67
      this.props.distance,
68
      this.props.minDistance,
69
      this.props.maxDistance
70
    );
71
    this.previousCursor = canvas.style.cursor;
1✔
72
    this.previousTouchAction = canvas.style.touchAction;
1✔
73
    canvas.style.cursor = 'grab';
1✔
74
    canvas.style.touchAction = 'none';
1✔
75
    canvas.addEventListener('pointerdown', this.handlePointerDown);
1✔
76
    canvas.addEventListener('pointermove', this.handlePointerMove);
1✔
77
    canvas.addEventListener('pointerup', this.handlePointerUp);
1✔
78
    canvas.addEventListener('pointercancel', this.handlePointerUp);
1✔
79
    canvas.addEventListener('wheel', this.handleWheel, {passive: false});
1✔
80
  }
81

82
  /** Advances optional auto-rotation using an animation-loop timestamp in milliseconds. */
83
  update(timeMilliseconds: number): void {
84
    if (this.previousTimeMilliseconds !== null && this.props.autoRotate && !this.dragging) {
2✔
85
      const deltaSeconds = Math.min(
1✔
86
        Math.max(timeMilliseconds - this.previousTimeMilliseconds, 0) / 1000,
87
        0.1
88
      );
89
      this.yaw += this.props.autoRotateSpeed * deltaSeconds;
1✔
90
    }
91
    this.previousTimeMilliseconds = timeMilliseconds;
2✔
92
  }
93

94
  /** Returns the current camera eye around the configured target. */
95
  getEyePosition(): OrbitPosition {
NEW
96
    const horizontalDistance = this.distance * Math.cos(this.pitch);
×
NEW
97
    return [
×
98
      this.props.target[0] + horizontalDistance * Math.sin(this.yaw),
99
      this.props.target[1] + this.distance * Math.sin(this.pitch),
100
      this.props.target[2] + horizontalDistance * Math.cos(this.yaw)
101
    ];
102
  }
103

104
  /** Enables or pauses automatic rotation without losing the current manual angle. */
105
  setAutoRotate(autoRotate: boolean): void {
NEW
106
    this.props.autoRotate = autoRotate;
×
107
  }
108

109
  /** Restores the configured starting angle and zoom. */
110
  reset(): void {
NEW
111
    this.yaw = this.props.yaw;
×
NEW
112
    this.pitch = clampNumber(this.props.pitch, this.props.minPitch, this.props.maxPitch);
×
NEW
113
    this.distance = clampNumber(
×
114
      this.props.distance,
115
      this.props.minDistance,
116
      this.props.maxDistance
117
    );
118
  }
119

120
  destroy(): void {
121
    this.canvas.removeEventListener('pointerdown', this.handlePointerDown);
1✔
122
    this.canvas.removeEventListener('pointermove', this.handlePointerMove);
1✔
123
    this.canvas.removeEventListener('pointerup', this.handlePointerUp);
1✔
124
    this.canvas.removeEventListener('pointercancel', this.handlePointerUp);
1✔
125
    this.canvas.removeEventListener('wheel', this.handleWheel);
1✔
126
    if (this.pointerId !== null && this.canvas.hasPointerCapture(this.pointerId)) {
1!
NEW
127
      this.canvas.releasePointerCapture(this.pointerId);
×
128
    }
129
    this.canvas.style.cursor = this.previousCursor;
1✔
130
    this.canvas.style.touchAction = this.previousTouchAction;
1✔
131
  }
132

133
  private readonly handlePointerDown = (event: PointerEvent): void => {
1✔
NEW
134
    if (event.button !== 0) {
×
NEW
135
      return;
×
136
    }
NEW
137
    this.dragging = true;
×
NEW
138
    this.pointerId = event.pointerId;
×
NEW
139
    this.lastPointer = [event.clientX, event.clientY];
×
NEW
140
    this.canvas.setPointerCapture(event.pointerId);
×
NEW
141
    this.canvas.style.cursor = 'grabbing';
×
142
  };
143

144
  private readonly handlePointerMove = (event: PointerEvent): void => {
1✔
NEW
145
    if (!this.dragging || event.pointerId !== this.pointerId) {
×
NEW
146
      return;
×
147
    }
NEW
148
    const deltaX = event.clientX - this.lastPointer[0];
×
NEW
149
    const deltaY = event.clientY - this.lastPointer[1];
×
NEW
150
    this.lastPointer = [event.clientX, event.clientY];
×
NEW
151
    this.yaw -= deltaX * this.props.rotateSpeed;
×
NEW
152
    this.pitch = clampNumber(
×
153
      this.pitch - deltaY * this.props.rotateSpeed,
154
      this.props.minPitch,
155
      this.props.maxPitch
156
    );
157
  };
158

159
  private readonly handlePointerUp = (event: PointerEvent): void => {
1✔
NEW
160
    if (event.pointerId !== this.pointerId) {
×
NEW
161
      return;
×
162
    }
NEW
163
    this.dragging = false;
×
NEW
164
    if (this.canvas.hasPointerCapture(event.pointerId)) {
×
NEW
165
      this.canvas.releasePointerCapture(event.pointerId);
×
166
    }
NEW
167
    this.pointerId = null;
×
NEW
168
    this.canvas.style.cursor = 'grab';
×
169
  };
170

171
  private readonly handleWheel = (event: WheelEvent): void => {
1✔
NEW
172
    event.preventDefault();
×
NEW
173
    const deltaY = clampNumber(event.deltaY, -240, 240);
×
NEW
174
    this.distance = clampNumber(
×
175
      this.distance * Math.exp(deltaY * this.props.zoomSpeed),
176
      this.props.minDistance,
177
      this.props.maxDistance
178
    );
179
  };
180
}
181

182
function clampNumber(value: number, minimum: number, maximum: number): number {
183
  return Math.min(Math.max(value, minimum), maximum);
2✔
184
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc