Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/api-reference/core/globe-controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ The `GlobeController` class can be passed to either the `Deck` class's [controll

`GlobeController` is the default controller for [GlobeView](./globe-view.md).

It is **terrain-aware**: when the scene contains a layer with `pickable: '3d'`, the camera follows picked terrain elevation and rotation pivots around the 3D point under the pointer. This is the same terrain behavior as [TerrainController](./terrain-controller.md), composed in via a shared mixin — `GlobeController` does **not** inherit `MapController`, so the Web-Mercator map constraints never apply to the globe.

## Usage

Use with the default view:
Expand Down Expand Up @@ -39,10 +41,12 @@ Supports all [Controller options](./controller.md#options) with the following de

- `dragPan`: default `'pan'` (drag to pan)
- `dragRotate`: shift+drag or right-click drag to change bearing and pitch
- `rotationPivot`: default `'3d'` (rotate around the picked object under the pointer)
- `touchRotate`: multi-touch rotate to change bearing
- `keyboard`: arrow keys to pan, +/- to zoom
- `inertia`: when set to a number (milliseconds), the globe continues spinning after a fling gesture with exponential decay
- `maxBounds` - constrains the viewport to the specified bounding box `[[minLng, minLat], [maxLng, maxLat]]`
- Terrain following requires a layer with `pickable: '3d'`; without one, the controller behaves like a standard `GlobeController`.

## Custom GlobeController

Expand Down
2 changes: 2 additions & 0 deletions docs/api-reference/core/globe-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ By default, `GlobeView` uses the `GlobeController` to handle interactivity. To e
const view = new GlobeView({id: 'globe', controller: true});
```

The default controller is terrain-aware: it adjusts camera elevation to follow picked terrain and uses a 3D rotation pivot when the scene contains a layer with `pickable: '3d'`.

Visit the [GlobeController](./globe-controller.md) documentation for a full list of supported options.


Expand Down
2 changes: 1 addition & 1 deletion docs/whats-new.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Class-specific improvements:
- [MapController](./api-reference/core/map-controller.md) - New `rotationPivot: '3d'` option rotates around the object under the pointer, for more natural interaction with terrain and 3D tiles.
- [OrbitController](./api-reference/core/orbit-controller.md) now uses 3D picking to determine zoom and pan anchors, providing more intuitive navigation around 3D content.
- All controllers - New `maxBounds` option constrains the camera within a (2D or 3D) bounding box, preventing users from navigating outside of the content area.
- [GlobeController](./api-reference/core/globe-controller.md) - Major bug fixes and improved stability.
- [GlobeController](./api-reference/core/globe-controller.md) - Major bug fixes, improved stability, and terrain-aware camera elevation by default.
- [OrthographicView](./api-reference/core/orthographic-view.md) is moving away from 2d-array zoom and adds per-axis `zoom*`, `minZoom*`, `maxZoom*` props.

### Layers
Expand Down
39 changes: 39 additions & 0 deletions modules/core/src/controllers/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ export type ControllerOptions = {
};
/** Drag behavior without pressing function keys, one of `pan` and `rotate`. */
dragMode?: 'pan' | 'rotate';
/** Pivot for rotation: `'center'` (viewport center), `'2d'` (pointer at ground level), or `'3d'` (the picked 3D point under the pointer). Default `'center'`. */
rotationPivot?: 'center' | '2d' | '3d';
/** 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`. */
inertia?: boolean | number;
/** Bounding box of content that the controller is constrained in */
Expand Down Expand Up @@ -170,6 +172,30 @@ export default abstract class Controller<ControllerState extends IViewState<Cont

protected invertPan: boolean = false;
protected dragMode: 'pan' | 'rotate' = 'rotate';

/**
* Rotation pivot behavior:
* - 'center': rotate around viewport center (default)
* - '2d': rotate around the pointer at ground level (z=0)
* - '3d': rotate around the 3D picked point (requires a pickPosition callback)
*/
protected rotationPivot: 'center' | '2d' | '3d' = 'center';

/** Altitude for rotateStart based on rotationPivot mode. Passed to the ControllerState. */
protected _getAltitude = (pos: [number, number]): number | undefined => {
if (this.rotationPivot === '2d') {
return 0;
} else if (this.rotationPivot === '3d') {
if (this.pickPosition) {
const {x, y} = this.props;
const pickResult = this.pickPosition(x + pos[0], y + pos[1]);
if (pickResult && pickResult.coordinate && pickResult.coordinate.length >= 3) {
return pickResult.coordinate[2];
}
}
}
return undefined;
};
protected inertia: number = 0;
protected scrollZoom: boolean | {speed?: number; smooth?: boolean} = true;
protected dragPan: boolean = true;
Expand Down Expand Up @@ -345,6 +371,11 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
if (props.dragMode) {
this.dragMode = props.dragMode;
}
if ('rotationPivot' in props) {
this.rotationPivot = props.rotationPivot || 'center';
}
// Passed to the ControllerState constructor for rotate-around-pivot.
(props as any).getAltitude = this._getAltitude;
const oldProps = this.props;
this.props = props;

Expand Down Expand Up @@ -431,6 +462,14 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
/* Callback util */
// formats map state and invokes callback function
protected updateViewport(newControllerState: ControllerState, extraProps: Record<string, any> | null = null, interactionState: InteractionState = {}) {
// Inject rotation pivot position during rotation for visual feedback
const rotateState = newControllerState.getState() as {startRotateLngLat?: [number, number, number]};
if (interactionState.isDragging && rotateState.startRotateLngLat) {
interactionState = {...interactionState, rotationPivotPosition: rotateState.startRotateLngLat};
} else if (interactionState.isDragging === false) {
interactionState = {...interactionState, rotationPivotPosition: undefined};
}

const viewState = {...newControllerState.getViewportProps(), ...extraProps};

// TODO - to restore diffing, we need to include interactionState
Expand Down
5 changes: 4 additions & 1 deletion modules/core/src/controllers/globe-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import {clamp} from '@math.gl/core';
import Controller from './controller';
import {withTerrain} from './terrain';

import {MapState, MapStateProps} from './map-controller';
import type {MapStateInternal} from './map-controller';
Expand Down Expand Up @@ -244,7 +245,9 @@ class GlobeState extends MapState {
}
}

export default class GlobeController extends Controller<MapState> {
// Terrain-aware by default: the globe camera follows terrain elevation, without
// inheriting MapController (so no Web-Mercator maxBounds leaks onto the globe).
export default class GlobeController extends withTerrain(Controller) {
ControllerState = GlobeState;

transition = {
Expand Down
56 changes: 1 addition & 55 deletions modules/core/src/controllers/map-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -600,66 +600,12 @@ export default class MapController extends Controller<MapState> {

dragMode: 'pan' | 'rotate' = 'pan';

/**
* Rotation pivot behavior:
* - 'center': Rotate around viewport center (default)
* - '2d': Rotate around pointer position at ground level (z=0)
* - '3d': Rotate around 3D picked point (requires pickPosition callback)
*/
protected rotationPivot: 'center' | '2d' | '3d' = 'center';

setProps(
props: ControllerProps &
MapStateProps & {
rotationPivot?: 'center' | '2d' | '3d';
getAltitude?: (pos: [number, number]) => number | undefined;
}
) {
if ('rotationPivot' in props) {
this.rotationPivot = props.rotationPivot || 'center';
}
setProps(props: ControllerProps & MapStateProps) {
// this will be passed to MapState constructor
props.getAltitude = this._getAltitude;
props.position = props.position || [0, 0, 0];
props.maxBounds =
props.maxBounds || (props.normalize === false ? null : WEB_MERCATOR_MAX_BOUNDS);

super.setProps(props);
}

protected updateViewport(
newControllerState: MapState,
extraProps: Record<string, any> | null = null,
interactionState: InteractionState = {}
): void {
// Inject rotation pivot position during rotation for visual feedback
const state = newControllerState.getState();
if (interactionState.isDragging && state.startRotateLngLat) {
interactionState = {
...interactionState,
rotationPivotPosition: state.startRotateLngLat
};
} else if (interactionState.isDragging === false) {
// Clear pivot when drag ends
interactionState = {...interactionState, rotationPivotPosition: undefined};
}

super.updateViewport(newControllerState, extraProps, interactionState);
}

/** Add altitude to rotateStart params based on rotationPivot mode */
protected _getAltitude = (pos: [number, number]): number | undefined => {
if (this.rotationPivot === '2d') {
return 0;
} else if (this.rotationPivot === '3d') {
if (this.pickPosition) {
const {x, y} = this.props;
const pickResult = this.pickPosition(x + pos[0], y + pos[1]);
if (pickResult && pickResult.coordinate && pickResult.coordinate.length >= 3) {
return pickResult.coordinate[2];
}
}
}
return undefined;
};
}
153 changes: 6 additions & 147 deletions modules/core/src/controllers/terrain-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,153 +3,12 @@
// Copyright (c) vis.gl contributors

import MapController from './map-controller';
import {MapState, MapStateProps} from './map-controller';
import type {ControllerProps, InteractionState} from './controller';
import type {MjolnirGestureEvent, MjolnirWheelEvent} from 'mjolnir.js';
import {withTerrain} from './terrain';

/**
* Controller that extends MapController with terrain-aware behavior.
* The camera smoothly follows terrain elevation during pan/zoom.
* MapController with terrain-aware behavior. The camera smoothly follows terrain
* elevation during pan/zoom, and rotation pivots around the 3D point under the
* pointer (`rotationPivot: '3d'`) by default. Requires a layer with
* `pickable: '3d'`; without one it behaves like a standard MapController.
*/
export default class TerrainController extends MapController {
/** Cached terrain altitude from depth picking at viewport center (smoothed) */
private _terrainAltitude?: number = undefined;
/** Raw (unsmoothed) terrain altitude from latest pick */
private _terrainAltitudeTarget?: number = undefined;
/** rAF handle for periodic terrain altitude picking */
private _pickFrameId: number | null = null;
/** Timestamp of last pick */
private _lastPickTime: number = 0;

setProps(
props: ControllerProps &
MapStateProps & {
rotationPivot?: 'center' | '2d' | '3d';
getAltitude?: (pos: [number, number]) => number | undefined;
}
) {
super.setProps({rotationPivot: '3d', ...props});

// Periodically pick terrain altitude at the viewport center using rAF.
// Keeps the altitude cache warm so interactions don't need expensive
// synchronous GPU readbacks. rAF naturally pauses when tab is backgrounded.
if (this._pickFrameId === null) {
const loop = () => {
const now = Date.now();
if (now - this._lastPickTime > 500 && !this.isDragging()) {
this._lastPickTime = now;
this._pickTerrainCenterAltitude();
// On first successful pick, rebase viewport to terrain altitude.
// Runs from rAF (outside React render) so onViewStateChange won't loop.
if (this._terrainAltitude === undefined && this._terrainAltitudeTarget !== undefined) {
this._terrainAltitude = this._terrainAltitudeTarget;
const controllerState = new this.ControllerState({
makeViewport: this.makeViewport,
...this.props,
...this.state
} as any);
const rebaseProps = this._rebaseViewport(this._terrainAltitudeTarget, controllerState);
if (rebaseProps) {
// Build a controllerState that includes the rebase adjustments so
// internal state matches the rebased viewState after React round-trip.
const rebasedState = new this.ControllerState({
makeViewport: this.makeViewport,
...this.props,
...this.state,
...rebaseProps
} as any);
super.updateViewport(rebasedState);
}
}
}
this._pickFrameId = requestAnimationFrame(loop);
};
this._pickFrameId = requestAnimationFrame(loop);
}
}

finalize() {
if (this._pickFrameId !== null) {
cancelAnimationFrame(this._pickFrameId);
this._pickFrameId = null;
}
super.finalize();
}

protected updateViewport(
newControllerState: MapState,
extraProps: Record<string, any> | null = null,
interactionState: InteractionState = {}
): void {
// Not initialized yet — pass through to MapController
if (this._terrainAltitude === undefined) {
super.updateViewport(newControllerState, extraProps, interactionState);
return;
}

// Smoothly blend toward target altitude
const SMOOTHING = 0.05;
this._terrainAltitude += (this._terrainAltitudeTarget! - this._terrainAltitude) * SMOOTHING;

const viewportProps = newControllerState.getViewportProps();
const pos = viewportProps.position || [0, 0, 0];
extraProps = {
...extraProps,
position: [pos[0], pos[1], this._terrainAltitude]
};

super.updateViewport(newControllerState, extraProps, interactionState);
}

private _pickTerrainCenterAltitude(): void {
if (!this.pickPosition) {
return;
}
const {x, y, width, height} = this.props;
const pickResult = this.pickPosition(x + width / 2, y + height / 2);
if (pickResult?.coordinate && pickResult.coordinate.length >= 3) {
this._terrainAltitudeTarget = pickResult.coordinate[2];
}
}

/**
* Compute viewport adjustments to keep the view visually the same
* when shifting position to [0, 0, altitude].
*/
private _rebaseViewport(
altitude: number,
newControllerState: MapState
): Record<string, any> | null {
const viewportProps = newControllerState.getViewportProps();
const oldViewport = this.makeViewport({...viewportProps, position: [0, 0, 0]});
const oldCameraPos = oldViewport.cameraPosition;

const centerZOffset = altitude * oldViewport.distanceScales.unitsPerMeter[2];
const cameraHeightAboveOldCenter = oldCameraPos[2];
const newCameraHeightAboveCenter = cameraHeightAboveOldCenter - centerZOffset;
if (newCameraHeightAboveCenter <= 0) {
return null;
}

const zoomDelta = Math.log2(cameraHeightAboveOldCenter / newCameraHeightAboveCenter);
const newZoom = viewportProps.zoom + zoomDelta;

const newViewport = this.makeViewport({
...viewportProps,
zoom: newZoom,
position: [0, 0, altitude]
});
const {width, height} = viewportProps;
const screenCenter: [number, number] = [width / 2, height / 2];
const worldPoint = oldViewport.unproject(screenCenter, {targetZ: altitude});
if (
worldPoint &&
'panByPosition3D' in newViewport &&
typeof newViewport.panByPosition3D === 'function'
) {
const adjusted = newViewport.panByPosition3D(worldPoint, screenCenter);
return {position: [0, 0, altitude], zoom: newZoom, ...adjusted};
}
return null;
}
}
export default class TerrainController extends withTerrain(MapController) {}
Loading
Loading