@deck.gl/core 9.2.11 → 9.3.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/debug.min.js +1 -1
  2. package/dist/controllers/controller.d.ts +10 -0
  3. package/dist/controllers/controller.d.ts.map +1 -1
  4. package/dist/controllers/controller.js +15 -0
  5. package/dist/controllers/controller.js.map +1 -1
  6. package/dist/controllers/first-person-controller.d.ts +3 -2
  7. package/dist/controllers/first-person-controller.d.ts.map +1 -1
  8. package/dist/controllers/first-person-controller.js +13 -5
  9. package/dist/controllers/first-person-controller.js.map +1 -1
  10. package/dist/controllers/globe-controller.d.ts +1 -0
  11. package/dist/controllers/globe-controller.d.ts.map +1 -1
  12. package/dist/controllers/globe-controller.js +66 -5
  13. package/dist/controllers/globe-controller.js.map +1 -1
  14. package/dist/controllers/map-controller.d.ts +21 -3
  15. package/dist/controllers/map-controller.d.ts.map +1 -1
  16. package/dist/controllers/map-controller.js +139 -25
  17. package/dist/controllers/map-controller.js.map +1 -1
  18. package/dist/controllers/orbit-controller.d.ts +12 -4
  19. package/dist/controllers/orbit-controller.d.ts.map +1 -1
  20. package/dist/controllers/orbit-controller.js +118 -10
  21. package/dist/controllers/orbit-controller.js.map +1 -1
  22. package/dist/controllers/orthographic-controller.d.ts +117 -9
  23. package/dist/controllers/orthographic-controller.d.ts.map +1 -1
  24. package/dist/controllers/orthographic-controller.js +302 -37
  25. package/dist/controllers/orthographic-controller.js.map +1 -1
  26. package/dist/controllers/view-state.d.ts +4 -1
  27. package/dist/controllers/view-state.d.ts.map +1 -1
  28. package/dist/controllers/view-state.js +2 -1
  29. package/dist/controllers/view-state.js.map +1 -1
  30. package/dist/debug/loggers.d.ts.map +1 -1
  31. package/dist/debug/loggers.js +1 -4
  32. package/dist/debug/loggers.js.map +1 -1
  33. package/dist/dist.dev.js +7585 -10714
  34. package/dist/effects/lighting/lighting-effect.d.ts +1 -0
  35. package/dist/effects/lighting/lighting-effect.d.ts.map +1 -1
  36. package/dist/effects/lighting/lighting-effect.js +14 -5
  37. package/dist/effects/lighting/lighting-effect.js.map +1 -1
  38. package/dist/index.cjs +812 -120
  39. package/dist/index.cjs.map +4 -4
  40. package/dist/lib/attribute/attribute-manager.d.ts.map +1 -1
  41. package/dist/lib/attribute/attribute-manager.js +2 -0
  42. package/dist/lib/attribute/attribute-manager.js.map +1 -1
  43. package/dist/lib/attribute/data-column.js +2 -2
  44. package/dist/lib/attribute/data-column.js.map +1 -1
  45. package/dist/lib/attribute/gl-utils.d.ts +1 -1
  46. package/dist/lib/attribute/gl-utils.d.ts.map +1 -1
  47. package/dist/lib/attribute/gl-utils.js +4 -0
  48. package/dist/lib/attribute/gl-utils.js.map +1 -1
  49. package/dist/lib/deck-picker.d.ts +14 -1
  50. package/dist/lib/deck-picker.d.ts.map +1 -1
  51. package/dist/lib/deck-picker.js +43 -11
  52. package/dist/lib/deck-picker.js.map +1 -1
  53. package/dist/lib/deck-renderer.d.ts +6 -1
  54. package/dist/lib/deck-renderer.d.ts.map +1 -1
  55. package/dist/lib/deck-renderer.js +14 -2
  56. package/dist/lib/deck-renderer.js.map +1 -1
  57. package/dist/lib/deck.d.ts +10 -0
  58. package/dist/lib/deck.d.ts.map +1 -1
  59. package/dist/lib/deck.js +29 -11
  60. package/dist/lib/deck.js.map +1 -1
  61. package/dist/lib/init.js +2 -2
  62. package/dist/lib/init.js.map +1 -1
  63. package/dist/lib/layer.d.ts.map +1 -1
  64. package/dist/lib/layer.js +7 -3
  65. package/dist/lib/layer.js.map +1 -1
  66. package/dist/lib/view-manager.d.ts +4 -0
  67. package/dist/lib/view-manager.d.ts.map +1 -1
  68. package/dist/lib/view-manager.js +6 -1
  69. package/dist/lib/view-manager.js.map +1 -1
  70. package/dist/lib/widget.d.ts +4 -0
  71. package/dist/lib/widget.d.ts.map +1 -1
  72. package/dist/lib/widget.js +11 -0
  73. package/dist/lib/widget.js.map +1 -1
  74. package/dist/passes/draw-layers-pass.d.ts +2 -0
  75. package/dist/passes/draw-layers-pass.d.ts.map +1 -1
  76. package/dist/passes/draw-layers-pass.js +3 -0
  77. package/dist/passes/draw-layers-pass.js.map +1 -1
  78. package/dist/passes/layers-pass.d.ts +2 -1
  79. package/dist/passes/layers-pass.d.ts.map +1 -1
  80. package/dist/passes/layers-pass.js +7 -3
  81. package/dist/passes/layers-pass.js.map +1 -1
  82. package/dist/passes/pick-layers-pass.d.ts +6 -3
  83. package/dist/passes/pick-layers-pass.d.ts.map +1 -1
  84. package/dist/passes/pick-layers-pass.js +12 -4
  85. package/dist/passes/pick-layers-pass.js.map +1 -1
  86. package/dist/shaderlib/project/project.glsl.d.ts.map +1 -1
  87. package/dist/shaderlib/project/project.glsl.js +3 -0
  88. package/dist/shaderlib/project/project.glsl.js.map +1 -1
  89. package/dist/utils/deep-merge.d.ts +5 -0
  90. package/dist/utils/deep-merge.d.ts.map +1 -0
  91. package/dist/utils/deep-merge.js +31 -0
  92. package/dist/utils/deep-merge.js.map +1 -0
  93. package/dist/utils/math-utils.d.ts +4 -0
  94. package/dist/utils/math-utils.d.ts.map +1 -1
  95. package/dist/utils/math-utils.js +8 -0
  96. package/dist/utils/math-utils.js.map +1 -1
  97. package/dist/utils/texture.d.ts.map +1 -1
  98. package/dist/utils/texture.js +3 -1
  99. package/dist/utils/texture.js.map +1 -1
  100. package/dist/viewports/globe-viewport.d.ts +1 -0
  101. package/dist/viewports/globe-viewport.d.ts.map +1 -1
  102. package/dist/viewports/globe-viewport.js +1 -1
  103. package/dist/viewports/globe-viewport.js.map +1 -1
  104. package/dist/viewports/orbit-viewport.d.ts.map +1 -1
  105. package/dist/viewports/orbit-viewport.js +7 -2
  106. package/dist/viewports/orbit-viewport.js.map +1 -1
  107. package/dist/viewports/orthographic-viewport.d.ts +8 -2
  108. package/dist/viewports/orthographic-viewport.d.ts.map +1 -1
  109. package/dist/viewports/orthographic-viewport.js.map +1 -1
  110. package/dist/viewports/web-mercator-viewport.d.ts +5 -0
  111. package/dist/viewports/web-mercator-viewport.d.ts.map +1 -1
  112. package/dist/viewports/web-mercator-viewport.js +9 -0
  113. package/dist/viewports/web-mercator-viewport.js.map +1 -1
  114. package/dist/views/orthographic-view.d.ts +38 -4
  115. package/dist/views/orthographic-view.d.ts.map +1 -1
  116. package/dist/views/orthographic-view.js.map +1 -1
  117. package/dist/views/view.d.ts.map +1 -1
  118. package/dist/views/view.js +2 -8
  119. package/dist/views/view.js.map +1 -1
  120. package/dist.min.js +226 -154
  121. package/package.json +9 -9
  122. package/src/controllers/controller.ts +25 -2
  123. package/src/controllers/first-person-controller.ts +18 -8
  124. package/src/controllers/globe-controller.ts +89 -5
  125. package/src/controllers/map-controller.ts +174 -32
  126. package/src/controllers/orbit-controller.ts +147 -13
  127. package/src/controllers/orthographic-controller.ts +417 -41
  128. package/src/controllers/view-state.ts +10 -3
  129. package/src/debug/loggers.ts +1 -5
  130. package/src/effects/lighting/lighting-effect.ts +20 -8
  131. package/src/lib/attribute/attribute-manager.ts +1 -0
  132. package/src/lib/attribute/data-column.ts +3 -3
  133. package/src/lib/attribute/gl-utils.ts +5 -1
  134. package/src/lib/deck-picker.ts +47 -12
  135. package/src/lib/deck-renderer.ts +17 -3
  136. package/src/lib/deck.ts +39 -11
  137. package/src/lib/layer.ts +7 -3
  138. package/src/lib/view-manager.ts +9 -1
  139. package/src/lib/widget.ts +14 -0
  140. package/src/passes/draw-layers-pass.ts +5 -0
  141. package/src/passes/layers-pass.ts +9 -4
  142. package/src/passes/pick-layers-pass.ts +18 -6
  143. package/src/shaderlib/project/project.glsl.ts +3 -0
  144. package/src/utils/deep-merge.ts +33 -0
  145. package/src/utils/math-utils.ts +12 -0
  146. package/src/utils/texture.ts +3 -1
  147. package/src/viewports/globe-viewport.ts +1 -1
  148. package/src/viewports/orbit-viewport.ts +8 -2
  149. package/src/viewports/orthographic-viewport.ts +8 -2
  150. package/src/viewports/web-mercator-viewport.ts +10 -0
  151. package/src/views/orthographic-view.ts +38 -4
  152. package/src/views/view.ts +2 -8
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "deck.gl core library",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
- "version": "9.2.11",
6
+ "version": "9.3.0-alpha.2",
7
7
  "publishConfig": {
8
8
  "access": "public"
9
9
  },
@@ -40,13 +40,13 @@
40
40
  "prepublishOnly": "npm run build-debugger && npm run build-bundle && npm run build-bundle -- --env=dev"
41
41
  },
42
42
  "dependencies": {
43
- "@loaders.gl/core": "~4.3.4",
44
- "@loaders.gl/images": "~4.3.4",
45
- "@luma.gl/constants": "~9.2.6",
46
- "@luma.gl/core": "~9.2.6",
47
- "@luma.gl/engine": "~9.2.6",
48
- "@luma.gl/shadertools": "~9.2.6",
49
- "@luma.gl/webgl": "~9.2.6",
43
+ "@loaders.gl/core": "^4.4.0-alpha.18",
44
+ "@loaders.gl/images": "^4.4.0-alpha.18",
45
+ "@luma.gl/constants": "^9.3.0-alpha.6",
46
+ "@luma.gl/core": "^9.3.0-alpha.6",
47
+ "@luma.gl/engine": "^9.3.0-alpha.6",
48
+ "@luma.gl/shadertools": "^9.3.0-alpha.6",
49
+ "@luma.gl/webgl": "^9.3.0-alpha.6",
50
50
  "@math.gl/core": "^4.1.0",
51
51
  "@math.gl/sun": "^4.1.0",
52
52
  "@math.gl/types": "^4.1.0",
@@ -58,5 +58,5 @@
58
58
  "gl-matrix": "^3.0.0",
59
59
  "mjolnir.js": "^3.0.0"
60
60
  },
61
- "gitHead": "35adca6c8646a5125517cbccb99ce1b733b02235"
61
+ "gitHead": "135d329f4a4b596ae2c16e4eb801eda30252f3bc"
62
62
  }
@@ -7,6 +7,7 @@ import TransitionManager, {TransitionProps} from './transition-manager';
7
7
  import LinearInterpolator from '../transitions/linear-interpolator';
8
8
  import {IViewState} from './view-state';
9
9
  import {ConstructorOf} from '../types/types';
10
+ import {deepEqual} from '../utils/deep-equal';
10
11
 
11
12
  import type Viewport from '../viewports/viewport';
12
13
 
@@ -65,6 +66,8 @@ export type ControllerOptions = {
65
66
  dragMode?: 'pan' | 'rotate';
66
67
  /** Enable inertia after panning/pinching. If a number is provided, indicates the duration of time over which the velocity reduces to zero, in milliseconds. Default `false`. */
67
68
  inertia?: boolean | number;
69
+ /** Bounding box of content that the controller is constrained in */
70
+ maxBounds?: [min: [number, number], max: [number, number]] | [min: [number, number, number], max: [number, number, number]] | null;
68
71
  };
69
72
 
70
73
  export type ControllerProps = {
@@ -92,6 +95,8 @@ export type InteractionState = {
92
95
  isRotating?: boolean;
93
96
  /** If the view is being zoomed, either from user input or transition */
94
97
  isZooming?: boolean;
98
+ /** World coordinate [lng, lat, altitude] of rotation pivot point when rotating */
99
+ rotationPivotPosition?: [number, number, number];
95
100
  }
96
101
 
97
102
  /** Parameters passed to the onViewStateChange callback */
@@ -119,7 +124,8 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
119
124
  protected eventManager: EventManager;
120
125
  protected onViewStateChange: (params: ViewStateChangeParameters) => void;
121
126
  protected onStateChange: (state: InteractionState) => void;
122
- protected makeViewport: (opts: Record<string, any>) => Viewport
127
+ protected makeViewport: (opts: Record<string, any>) => Viewport;
128
+ protected pickPosition?: (x: number, y: number) => {coordinate?: number[]} | null;
123
129
 
124
130
  private _controllerState?: ControllerState;
125
131
  private _events: Record<string, boolean> = {};
@@ -154,6 +160,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
154
160
  makeViewport: (opts: Record<string, any>) => Viewport;
155
161
  onViewStateChange: (params: ViewStateChangeParameters) => void;
156
162
  onStateChange: (state: InteractionState) => void;
163
+ pickPosition?: (x: number, y: number) => {coordinate?: number[]} | null;
157
164
  }) {
158
165
  this.transitionManager = new TransitionManager<ControllerState>({
159
166
  ...opts,
@@ -168,6 +175,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
168
175
  this.onViewStateChange = opts.onViewStateChange || (() => {});
169
176
  this.onStateChange = opts.onStateChange || (() => {});
170
177
  this.makeViewport = opts.makeViewport;
178
+ this.pickPosition = opts.pickPosition;
171
179
  }
172
180
 
173
181
  set events(customEvents) {
@@ -237,7 +245,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
237
245
  ...this.props,
238
246
  ...this.state
239
247
  });
240
- return this._controllerState ;
248
+ return this._controllerState;
241
249
  }
242
250
 
243
251
  getCenter(event: MjolnirGestureEvent | MjolnirWheelEvent) : [number, number] {
@@ -288,6 +296,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
288
296
  if (props.dragMode) {
289
297
  this.dragMode = props.dragMode;
290
298
  }
299
+ const oldProps = this.props;
291
300
  this.props = props;
292
301
 
293
302
  if (!('transitionInterpolator' in props)) {
@@ -329,6 +338,19 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
329
338
  this.touchZoom = touchZoom;
330
339
  this.touchRotate = touchRotate;
331
340
  this.keyboard = keyboard;
341
+
342
+ // Normalize view state if maxBounds is defined
343
+ const dimensionChanged = !oldProps || oldProps.height !== props.height || oldProps.width !== props.width || oldProps.maxBounds !== props.maxBounds;
344
+ if (dimensionChanged && props.maxBounds) {
345
+ // Dimensions changed, try re-normalize the props
346
+ const controllerState = new this.ControllerState({...props, makeViewport: this.makeViewport});
347
+ const normalizedProps = controllerState.getViewportProps();
348
+ const changed = Object.keys(normalizedProps).some(key => !deepEqual(normalizedProps[key], props[key], 1));
349
+ if (changed) {
350
+ // some props are updated after normalization
351
+ this.updateViewport(controllerState);
352
+ }
353
+ }
332
354
  }
333
355
 
334
356
  updateTransition() {
@@ -396,6 +418,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
396
418
  // invertPan is replaced by props.dragMode, keeping for backward compatibility
397
419
  alternateMode = !alternateMode;
398
420
  }
421
+
399
422
  const newControllerState = this.controllerState[alternateMode ? 'panStart' : 'rotateStart']({
400
423
  pos
401
424
  });
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
  // Copyright (c) vis.gl contributors
4
4
 
5
- import Controller from './controller';
5
+ import Controller, {ControllerProps} from './controller';
6
6
  import ViewState from './view-state';
7
7
  import {mod} from '../utils/math-utils';
8
8
  import type Viewport from '../viewports/viewport';
@@ -27,6 +27,8 @@ type FirstPersonStateProps = {
27
27
 
28
28
  maxPitch?: number;
29
29
  minPitch?: number;
30
+
31
+ maxBounds?: ControllerProps['maxBounds'];
30
32
  };
31
33
 
32
34
  type FirstPersonStateInternal = {
@@ -43,8 +45,6 @@ class FirstPersonState extends ViewState<
43
45
  FirstPersonStateProps,
44
46
  FirstPersonStateInternal
45
47
  > {
46
- makeViewport: (props: Record<string, any>) => Viewport;
47
-
48
48
  constructor(
49
49
  options: FirstPersonStateProps &
50
50
  FirstPersonStateInternal & {
@@ -69,6 +69,8 @@ class FirstPersonState extends ViewState<
69
69
  maxPitch = 90,
70
70
  minPitch = -90,
71
71
 
72
+ maxBounds = null,
73
+
72
74
  // Model state when the rotate operation first started
73
75
  startRotatePos,
74
76
  startBearing,
@@ -88,7 +90,8 @@ class FirstPersonState extends ViewState<
88
90
  longitude,
89
91
  latitude,
90
92
  maxPitch,
91
- minPitch
93
+ minPitch,
94
+ maxBounds
92
95
  },
93
96
  {
94
97
  startRotatePos,
@@ -97,10 +100,9 @@ class FirstPersonState extends ViewState<
97
100
  startZoomPosition,
98
101
  startPanPos,
99
102
  startPanPosition
100
- }
103
+ },
104
+ options.makeViewport
101
105
  );
102
-
103
- this.makeViewport = options.makeViewport;
104
106
  }
105
107
 
106
108
  /* Public API */
@@ -366,7 +368,7 @@ class FirstPersonState extends ViewState<
366
368
  // Apply any constraints (mathematical or defined by _viewportProps) to map state
367
369
  applyConstraints(props: Required<FirstPersonStateProps>): Required<FirstPersonStateProps> {
368
370
  // Ensure pitch and zoom are within specified range
369
- const {pitch, maxPitch, minPitch, longitude, bearing} = props;
371
+ const {pitch, maxPitch, minPitch, longitude, position, bearing, maxBounds} = props;
370
372
  props.pitch = clamp(pitch, minPitch, maxPitch);
371
373
 
372
374
  // Normalize degrees
@@ -376,6 +378,14 @@ class FirstPersonState extends ViewState<
376
378
  if (bearing < -180 || bearing > 180) {
377
379
  props.bearing = mod(bearing + 180, 360) - 180;
378
380
  }
381
+ if (maxBounds) {
382
+ const x = clamp(position[0], maxBounds[0][0], maxBounds[1][0]);
383
+ const y = clamp(position[1], maxBounds[0][1], maxBounds[1][1]);
384
+ const z = clamp(position[2] ?? 0, maxBounds[0][2] ?? 0, maxBounds[1][2] ?? 0);
385
+ if (x !== position[0] || y !== position[1] || z !== position[2]) {
386
+ props.position = [x, y, z];
387
+ }
388
+ }
379
389
 
380
390
  return props;
381
391
  }
@@ -9,10 +9,24 @@ import {MapState, MapStateProps} from './map-controller';
9
9
  import type {MapStateInternal} from './map-controller';
10
10
  import {mod} from '../utils/math-utils';
11
11
  import LinearInterpolator from '../transitions/linear-interpolator';
12
- import {zoomAdjust} from '../viewports/globe-viewport';
12
+ import {zoomAdjust, GLOBE_RADIUS} from '../viewports/globe-viewport';
13
13
 
14
14
  import {MAX_LATITUDE} from '@math.gl/web-mercator';
15
15
 
16
+ const DEGREES_TO_RADIANS = Math.PI / 180;
17
+ const RADIANS_TO_DEGREES = 180 / Math.PI;
18
+
19
+ function degreesToPixels(angle: number, zoom: number = 0): number {
20
+ const radians = Math.min(180, angle) * DEGREES_TO_RADIANS;
21
+ const size = GLOBE_RADIUS * 2 * Math.sin(radians / 2);
22
+ return size * Math.pow(2, zoom);
23
+ }
24
+ function pixelsToDegrees(pixels: number, zoom: number = 0): number {
25
+ const size = pixels / Math.pow(2, zoom);
26
+ const radians = Math.asin(Math.min(1, size / GLOBE_RADIUS / 2)) * 2;
27
+ return radians * RADIANS_TO_DEGREES;
28
+ }
29
+
16
30
  type GlobeStateInternal = MapStateInternal & {
17
31
  startPanPos?: [number, number];
18
32
  };
@@ -25,6 +39,7 @@ class GlobeState extends MapState {
25
39
  }
26
40
  ) {
27
41
  const {startPanPos, ...mapStateOptions} = options;
42
+ mapStateOptions.normalize = false; // disable MapState default normalization
28
43
  super(mapStateOptions);
29
44
 
30
45
  if (startPanPos !== undefined) {
@@ -71,19 +86,88 @@ class GlobeState extends MapState {
71
86
 
72
87
  applyConstraints(props: Required<MapStateProps>): Required<MapStateProps> {
73
88
  // Ensure zoom is within specified range
74
- const {longitude, latitude, maxZoom, minZoom, zoom} = props;
89
+ const {longitude, latitude, maxBounds} = props;
75
90
 
76
- const ZOOM0 = zoomAdjust(0);
77
- const zoomAdjustment = zoomAdjust(latitude) - ZOOM0;
78
- props.zoom = clamp(zoom, minZoom + zoomAdjustment, maxZoom + zoomAdjustment);
91
+ props.zoom = this._constrainZoom(props.zoom, props);
79
92
 
80
93
  if (longitude < -180 || longitude > 180) {
81
94
  props.longitude = mod(longitude + 180, 360) - 180;
82
95
  }
83
96
  props.latitude = clamp(latitude, -MAX_LATITUDE, MAX_LATITUDE);
97
+ if (maxBounds) {
98
+ props.longitude = clamp(props.longitude, maxBounds[0][0], maxBounds[1][0]);
99
+ props.latitude = clamp(props.latitude, maxBounds[0][1], maxBounds[1][1]);
100
+ }
101
+
102
+ if (maxBounds) {
103
+ // calculate center and zoom ranges at pitch=0 and bearing=0
104
+ // to maintain visual stability when rotating
105
+ const effectiveZoom = props.zoom - zoomAdjust(latitude);
106
+ const lngSpan = maxBounds[1][0] - maxBounds[0][0];
107
+ const latSpan = maxBounds[1][1] - maxBounds[0][1];
108
+ if (latSpan > 0 && latSpan < MAX_LATITUDE * 2) {
109
+ const halfHeightDegrees =
110
+ Math.min(pixelsToDegrees(props.height, effectiveZoom), latSpan) / 2;
111
+ props.latitude = clamp(
112
+ props.latitude,
113
+ maxBounds[0][1] + halfHeightDegrees,
114
+ maxBounds[1][1] - halfHeightDegrees
115
+ );
116
+ }
117
+ if (lngSpan > 0 && lngSpan < 360) {
118
+ const halfWidthDegrees =
119
+ Math.min(
120
+ pixelsToDegrees(
121
+ props.width / Math.cos(props.latitude * DEGREES_TO_RADIANS),
122
+ effectiveZoom
123
+ ),
124
+ lngSpan
125
+ ) / 2;
126
+ props.longitude = clamp(
127
+ props.longitude,
128
+ maxBounds[0][0] + halfWidthDegrees,
129
+ maxBounds[1][0] - halfWidthDegrees
130
+ );
131
+ }
132
+ }
133
+ if (props.latitude !== latitude) {
134
+ props.zoom += zoomAdjust(props.latitude) - zoomAdjust(latitude);
135
+ }
84
136
 
85
137
  return props;
86
138
  }
139
+
140
+ _constrainZoom(zoom: number, props?: Required<MapStateProps>): number {
141
+ props ||= this.getViewportProps();
142
+ const {latitude, maxZoom, maxBounds} = props;
143
+ let {minZoom} = props;
144
+ const ZOOM0 = zoomAdjust(0);
145
+ const zoomAdjustment = zoomAdjust(latitude) - ZOOM0;
146
+
147
+ const shouldApplyMaxBounds = maxBounds !== null && props.width > 0 && props.height > 0;
148
+ if (shouldApplyMaxBounds) {
149
+ const minLatitude = maxBounds[0][1];
150
+ const maxLatitude = maxBounds[1][1];
151
+ // latitude at which the bounding box is the widest
152
+ const fitLatitude =
153
+ Math.sign(minLatitude) === Math.sign(maxLatitude)
154
+ ? Math.min(Math.abs(minLatitude), Math.abs(maxLatitude))
155
+ : 0;
156
+ const w =
157
+ degreesToPixels(maxBounds[1][0] - maxBounds[0][0]) *
158
+ Math.cos(fitLatitude * DEGREES_TO_RADIANS);
159
+ const h = degreesToPixels(maxBounds[1][1] - maxBounds[0][1]);
160
+ if (w > 0) {
161
+ minZoom = Math.max(minZoom, Math.log2(props.width / w) + ZOOM0);
162
+ }
163
+ if (h > 0) {
164
+ minZoom = Math.max(minZoom, Math.log2(props.height / h) + ZOOM0);
165
+ }
166
+ if (minZoom > maxZoom) minZoom = maxZoom;
167
+ }
168
+
169
+ return clamp(zoom, minZoom + zoomAdjustment, maxZoom + zoomAdjustment);
170
+ }
87
171
  }
88
172
 
89
173
  export default class GlobeController extends Controller<MapState> {
@@ -3,16 +3,36 @@
3
3
  // Copyright (c) vis.gl contributors
4
4
 
5
5
  import {clamp} from '@math.gl/core';
6
- import Controller, {ControllerProps} from './controller';
6
+ import Controller, {ControllerProps, InteractionState} from './controller';
7
7
  import ViewState from './view-state';
8
- import {normalizeViewportProps} from '@math.gl/web-mercator';
8
+ import {worldToLngLat, lngLatToWorld as _lngLatToWorld} from '@math.gl/web-mercator';
9
9
  import assert from '../utils/assert';
10
+ import {mod} from '../utils/math-utils';
10
11
 
11
12
  import LinearInterpolator from '../transitions/linear-interpolator';
12
13
  import type Viewport from '../viewports/viewport';
13
14
 
14
15
  const PITCH_MOUSE_THRESHOLD = 5;
15
16
  const PITCH_ACCEL = 1.2;
17
+ const WEB_MERCATOR_TILE_SIZE = 512;
18
+ const WEB_MERCATOR_MAX_BOUNDS = [
19
+ [-Infinity, -90],
20
+ [Infinity, 90]
21
+ ] satisfies ControllerProps['maxBounds'];
22
+
23
+ /** The web mercator utility `lngLatToWorld` throws if invalid coordinates are provided.
24
+ * This wrapper clamps user input to calculate common positions safely. */
25
+ function lngLatToWorld([lng, lat]: number[]): number[] {
26
+ if (Math.abs(lat) > 90) {
27
+ lat = Math.sign(lat) * 90;
28
+ }
29
+ if (Number.isFinite(lng)) {
30
+ const [x, y] = _lngLatToWorld([lng, lat]);
31
+ return [x, clamp(y, 0, WEB_MERCATOR_TILE_SIZE)];
32
+ }
33
+ const [, y] = _lngLatToWorld([0, lat]);
34
+ return [lng, clamp(y, 0, WEB_MERCATOR_TILE_SIZE)];
35
+ }
16
36
 
17
37
  export type MapStateProps = {
18
38
  /** Mapbox viewport properties */
@@ -47,6 +67,8 @@ export type MapStateProps = {
47
67
 
48
68
  /** Normalize viewport props to fit map height into viewport. Default `true` */
49
69
  normalize?: boolean;
70
+
71
+ maxBounds?: ControllerProps['maxBounds'];
50
72
  };
51
73
 
52
74
  export type MapStateInternal = {
@@ -57,6 +79,8 @@ export type MapStateInternal = {
57
79
  startZoomLngLat?: [number, number];
58
80
  /* Pointer position when rotation started */
59
81
  startRotatePos?: [number, number];
82
+ /* The lng/lat/altitude point at the rotation pivot (where rotation started) */
83
+ startRotateLngLat?: [number, number, number];
60
84
  /** Bearing when current perspective rotate operation started */
61
85
  startBearing?: number;
62
86
  /** Pitch when current perspective rotate operation started */
@@ -68,12 +92,18 @@ export type MapStateInternal = {
68
92
  /* Utils */
69
93
 
70
94
  export class MapState extends ViewState<MapState, MapStateProps, MapStateInternal> {
71
- makeViewport: (props: Record<string, any>) => Viewport;
95
+ /* get optional altitude for rotation pivot
96
+ * - undefined: rotate around viewport center (no pivot point)
97
+ * - 0: rotate around pointer position at ground level
98
+ * - other value: rotate around pointer position at specified altitude
99
+ */
100
+ getAltitude?: (pos: [number, number]) => number | undefined;
72
101
 
73
102
  constructor(
74
103
  options: MapStateProps &
75
104
  MapStateInternal & {
76
105
  makeViewport: (props: Record<string, any>) => Viewport;
106
+ getAltitude?: (pos: [number, number]) => number | undefined;
77
107
  }
78
108
  ) {
79
109
  const {
@@ -114,6 +144,8 @@ export class MapState extends ViewState<MapState, MapStateProps, MapStateInterna
114
144
  startZoomLngLat,
115
145
  /* Pointer position when rotation started */
116
146
  startRotatePos,
147
+ /* The lng/lat point at the rotation pivot (where rotation started) */
148
+ startRotateLngLat,
117
149
  /** Bearing when current perspective rotate operation started */
118
150
  startBearing,
119
151
  /** Pitch when current perspective rotate operation started */
@@ -129,6 +161,8 @@ export class MapState extends ViewState<MapState, MapStateProps, MapStateInterna
129
161
  assert(Number.isFinite(latitude)); // `latitude` must be supplied
130
162
  assert(Number.isFinite(zoom)); // `zoom` must be supplied
131
163
 
164
+ const maxBounds = options.maxBounds || (normalize ? WEB_MERCATOR_MAX_BOUNDS : null);
165
+
132
166
  super(
133
167
  {
134
168
  width,
@@ -144,19 +178,22 @@ export class MapState extends ViewState<MapState, MapStateProps, MapStateInterna
144
178
  maxPitch,
145
179
  minPitch,
146
180
  normalize,
147
- position
181
+ position,
182
+ maxBounds
148
183
  },
149
184
  {
150
185
  startPanLngLat,
151
186
  startZoomLngLat,
152
187
  startRotatePos,
188
+ startRotateLngLat,
153
189
  startBearing,
154
190
  startPitch,
155
191
  startZoom
156
- }
192
+ },
193
+ options.makeViewport
157
194
  );
158
195
 
159
- this.makeViewport = options.makeViewport;
196
+ this.getAltitude = options.getAltitude;
160
197
  }
161
198
 
162
199
  /**
@@ -203,8 +240,11 @@ export class MapState extends ViewState<MapState, MapStateProps, MapStateInterna
203
240
  * @param {[Number, Number]} pos - position on screen where the center is
204
241
  */
205
242
  rotateStart({pos}: {pos: [number, number]}): MapState {
243
+ const altitude = this.getAltitude?.(pos);
244
+
206
245
  return this._getUpdatedState({
207
246
  startRotatePos: pos,
247
+ startRotateLngLat: altitude !== undefined ? this._unproject3D(pos, altitude) : undefined,
208
248
  startBearing: this.getViewportProps().bearing,
209
249
  startPitch: this.getViewportProps().pitch
210
250
  });
@@ -223,7 +263,7 @@ export class MapState extends ViewState<MapState, MapStateProps, MapStateInterna
223
263
  deltaAngleX?: number;
224
264
  deltaAngleY?: number;
225
265
  }): MapState {
226
- const {startRotatePos, startBearing, startPitch} = this.getState();
266
+ const {startRotatePos, startRotateLngLat, startBearing, startPitch} = this.getState();
227
267
 
228
268
  if (!startRotatePos || startBearing === undefined || startPitch === undefined) {
229
269
  return this;
@@ -237,6 +277,21 @@ export class MapState extends ViewState<MapState, MapStateProps, MapStateInterna
237
277
  pitch: startPitch + deltaAngleY
238
278
  };
239
279
  }
280
+
281
+ // If we have a pivot point, adjust the camera position to keep the pivot point fixed
282
+ if (startRotateLngLat) {
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
+
240
295
  return this._getUpdatedState(newRotation);
241
296
  }
242
297
 
@@ -246,6 +301,8 @@ export class MapState extends ViewState<MapState, MapStateProps, MapStateInterna
246
301
  */
247
302
  rotateEnd(): MapState {
248
303
  return this._getUpdatedState({
304
+ startRotatePos: null,
305
+ startRotateLngLat: null,
249
306
  startBearing: null,
250
307
  startPitch: null
251
308
  });
@@ -296,10 +353,7 @@ export class MapState extends ViewState<MapState, MapStateProps, MapStateInterna
296
353
  return this;
297
354
  }
298
355
 
299
- const {maxZoom, minZoom} = this.getViewportProps();
300
- let zoom = (startZoom as number) + Math.log2(scale);
301
- zoom = clamp(zoom, minZoom, maxZoom);
302
-
356
+ const zoom = this._constrainZoom((startZoom as number) + Math.log2(scale));
303
357
  const zoomedViewport = this.makeViewport({...this.getViewportProps(), zoom});
304
358
 
305
359
  return this._getUpdatedState({
@@ -384,18 +438,33 @@ export class MapState extends ViewState<MapState, MapStateProps, MapStateInterna
384
438
 
385
439
  // Apply any constraints (mathematical or defined by _viewportProps) to map state
386
440
  applyConstraints(props: Required<MapStateProps>): Required<MapStateProps> {
387
- // Ensure zoom is within specified range
388
- const {maxZoom, minZoom, zoom} = props;
389
- props.zoom = clamp(zoom, minZoom, maxZoom);
390
-
391
441
  // Ensure pitch is within specified range
392
- const {maxPitch, minPitch, pitch} = props;
393
- props.pitch = clamp(pitch, minPitch, maxPitch);
442
+ const {maxPitch, minPitch, pitch, longitude, bearing, normalize, maxBounds} = props;
394
443
 
395
- // Normalize viewport props to fit map height into viewport
396
- const {normalize = true} = props;
397
444
  if (normalize) {
398
- Object.assign(props, normalizeViewportProps(props));
445
+ if (longitude < -180 || longitude > 180) {
446
+ props.longitude = mod(longitude + 180, 360) - 180;
447
+ }
448
+ if (bearing < -180 || bearing > 180) {
449
+ props.bearing = mod(bearing + 180, 360) - 180;
450
+ }
451
+ }
452
+ props.pitch = clamp(pitch, minPitch, maxPitch);
453
+
454
+ props.zoom = this._constrainZoom(props.zoom, props);
455
+
456
+ if (maxBounds) {
457
+ const bl = lngLatToWorld(maxBounds[0]);
458
+ const tr = lngLatToWorld(maxBounds[1]);
459
+ // calculate center and zoom ranges at pitch=0 and bearing=0
460
+ // to maintain visual stability when rotating
461
+ const scale = 2 ** props.zoom;
462
+ const halfWidth = props.width / 2 / scale;
463
+ const halfHeight = props.height / 2 / scale;
464
+ const [minLng, minLat] = worldToLngLat([bl[0] + halfWidth, bl[1] + halfHeight]);
465
+ const [maxLng, maxLat] = worldToLngLat([tr[0] - halfWidth, tr[1] - halfHeight]);
466
+ props.longitude = clamp(props.longitude, minLng, maxLng);
467
+ props.latitude = clamp(props.latitude, minLat, maxLat);
399
468
  }
400
469
 
401
470
  return props;
@@ -403,6 +472,30 @@ export class MapState extends ViewState<MapState, MapStateProps, MapStateInterna
403
472
 
404
473
  /* Private methods */
405
474
 
475
+ _constrainZoom(zoom: number, props?: Required<MapStateProps>): number {
476
+ props ||= this.getViewportProps();
477
+ const {maxZoom, maxBounds} = props;
478
+
479
+ const shouldApplyMaxBounds = maxBounds !== null && props.width > 0 && props.height > 0;
480
+ let {minZoom} = props;
481
+
482
+ if (shouldApplyMaxBounds) {
483
+ const bl = lngLatToWorld(maxBounds[0]);
484
+ const tr = lngLatToWorld(maxBounds[1]);
485
+ const w = tr[0] - bl[0];
486
+ const h = tr[1] - bl[1];
487
+ // ignore bound size of 0 or Infinity
488
+ if (Number.isFinite(w) && w > 0) {
489
+ minZoom = Math.max(minZoom, Math.log2(props.width / w));
490
+ }
491
+ if (Number.isFinite(h) && h > 0) {
492
+ minZoom = Math.max(minZoom, Math.log2(props.height / h));
493
+ }
494
+ if (minZoom > maxZoom) minZoom = maxZoom;
495
+ }
496
+ return clamp(zoom, minZoom, maxZoom);
497
+ }
498
+
406
499
  _zoomFromCenter(scale) {
407
500
  const {width, height} = this.getViewportProps();
408
501
  return this.zoom({
@@ -435,6 +528,11 @@ export class MapState extends ViewState<MapState, MapStateProps, MapStateInterna
435
528
  return pos && viewport.unproject(pos);
436
529
  }
437
530
 
531
+ _unproject3D(pos: [number, number], altitude: number): [number, number, number] {
532
+ const viewport = this.makeViewport(this.getViewportProps());
533
+ return viewport.unproject(pos, {targetZ: altitude}) as [number, number, number];
534
+ }
535
+
438
536
  _getNewRotation(
439
537
  pos: [number, number],
440
538
  startPos: [number, number],
@@ -502,22 +600,66 @@ export default class MapController extends Controller<MapState> {
502
600
 
503
601
  dragMode: 'pan' | 'rotate' = 'pan';
504
602
 
505
- setProps(props: ControllerProps & MapStateProps) {
603
+ /**
604
+ * Rotation pivot behavior:
605
+ * - 'center': Rotate around viewport center (default)
606
+ * - '2d': Rotate around pointer position at ground level (z=0)
607
+ * - '3d': Rotate around 3D picked point (requires pickPosition callback)
608
+ */
609
+ protected rotationPivot: 'center' | '2d' | '3d' = 'center';
610
+
611
+ setProps(
612
+ props: ControllerProps &
613
+ MapStateProps & {
614
+ rotationPivot?: 'center' | '2d' | '3d';
615
+ getAltitude?: (pos: [number, number]) => number | undefined;
616
+ }
617
+ ) {
618
+ if ('rotationPivot' in props) {
619
+ this.rotationPivot = props.rotationPivot || 'center';
620
+ }
621
+ // this will be passed to MapState constructor
622
+ props.getAltitude = this._getAltitude;
506
623
  props.position = props.position || [0, 0, 0];
507
- const oldProps = this.props;
624
+ props.maxBounds =
625
+ props.maxBounds || (props.normalize === false ? null : WEB_MERCATOR_MAX_BOUNDS);
508
626
 
509
627
  super.setProps(props);
628
+ }
510
629
 
511
- const dimensionChanged = !oldProps || oldProps.height !== props.height;
512
- if (dimensionChanged) {
513
- // Dimensions changed, normalize the props
514
- this.updateViewport(
515
- new this.ControllerState({
516
- makeViewport: this.makeViewport,
517
- ...props,
518
- ...this.state
519
- })
520
- );
630
+ protected updateViewport(
631
+ newControllerState: MapState,
632
+ extraProps: Record<string, any> | null = null,
633
+ interactionState: InteractionState = {}
634
+ ): void {
635
+ // Inject rotation pivot position during rotation for visual feedback
636
+ const state = newControllerState.getState();
637
+ if (interactionState.isDragging && state.startRotateLngLat) {
638
+ interactionState = {
639
+ ...interactionState,
640
+ rotationPivotPosition: state.startRotateLngLat
641
+ };
642
+ } else if (interactionState.isDragging === false) {
643
+ // Clear pivot when drag ends
644
+ interactionState = {...interactionState, rotationPivotPosition: undefined};
521
645
  }
646
+
647
+ super.updateViewport(newControllerState, extraProps, interactionState);
522
648
  }
649
+
650
+ /** Add altitude to rotateStart params based on rotationPivot mode */
651
+ protected _getAltitude = (pos: [number, number]): number | undefined => {
652
+ if (this.rotationPivot === '2d') {
653
+ return 0;
654
+ } else if (this.rotationPivot === '3d') {
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;
664
+ };
523
665
  }