Skip to content
Closed
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
18 changes: 14 additions & 4 deletions docs/api-reference/core/globe-controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,20 @@ new Deck({

Supports all [Controller options](./controller.md#options) with the following default behavior:

- `dragPan`: default `'pan'` (drag to pan)
- `dragRotate`: not effective, this view does not currently support rotation
- `touchRotate`: not effective, this view does not currently support rotation
- `keyboard`: arrow keys to pan, +/- to zoom
- `dragPan`: default `true` (drag to pan)
- `dragRotate`: default `true` (drag with function key to rotate/change bearing and pitch)
- `touchZoom`: default `true` (pinch to zoom, zooms toward cursor position)
- `touchRotate`: default `false` (two-finger rotate gesture to change bearing and pitch)
- `keyboard`: arrow keys to pan, +/- to zoom, shift+left/right to rotate bearing, shift+up/down to change pitch

## Interactions

The GlobeController supports Google Earth-like interactions:

- **Pan**: Drag to rotate the globe (change longitude/latitude)
- **Rotate**: Shift+drag (or right-click drag) to change bearing and pitch
- **Zoom**: Scroll wheel or pinch gesture - zooms toward cursor position
- **Keyboard**: Arrow keys for panning, +/- for zoom, Shift+arrows for rotation

## Custom GlobeController

Expand Down
7 changes: 6 additions & 1 deletion docs/api-reference/core/globe-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ It's recommended that you read the [Views and Projections guide](../../developer
The goal of `GlobeView` is to provide a generic solution to rendering and navigating data in the 3D space.
In the initial release, this class mainly addresses the need to render an overview of the entire globe. The following limitations apply, as features are still under development:

- No support for rotation (`pitch` or `bearing`). The camera always points towards the center of the earth, with north up.
- `bearing` is supported for rotation around the view axis (like compass rotation).
- `pitch` is supported to tilt the camera angle (0-85 degrees), similar to Google Earth.
- No high-precision rendering at high zoom levels (> 12). Features at the city-block scale may not be rendered accurately.
- Only supports `COORDINATE_SYSTEM.LNGLAT` (default of the `coordinateSystem` prop).
- Known rendering issues when using multiple views mixing `GlobeView` and `MapView`, or switching between the two.
Expand Down Expand Up @@ -62,8 +63,12 @@ To render, `GlobeView` needs to be used together with a `viewState` with the fol
- `longitude` (number) - longitude at the viewport center
- `latitude` (number) - latitude at the viewport center
- `zoom` (number) - zoom level
- `bearing` (number, optional) - bearing angle in degrees. Default `0` (north up). Positive values rotate clockwise.
- `pitch` (number, optional) - pitch angle in degrees. Default `0` (looking straight at globe center). Range: 0-85.
- `maxZoom` (number, optional) - max zoom level. Default `20`.
- `minZoom` (number, optional) - min zoom level. Default `0`.
- `minPitch` (number, optional) - min pitch in degrees. Default `0`.
- `maxPitch` (number, optional) - max pitch in degrees. Default `85`.


## Controller
Expand Down
195 changes: 185 additions & 10 deletions modules/core/src/controllers/globe-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {MapState, MapStateProps} from './map-controller';
import type {MapStateInternal} from './map-controller';
import {mod} from '../utils/math-utils';
import LinearInterpolator from '../transitions/linear-interpolator';
import {zoomAdjust} from '../viewports/globe-viewport';
import {zoomAdjust, DEFAULT_MIN_PITCH, DEFAULT_MAX_PITCH} from '../viewports/globe-viewport';

import {MAX_LATITUDE} from '@math.gl/web-mercator';

Expand Down Expand Up @@ -62,11 +62,148 @@ class GlobeState extends MapState {
}) as GlobeState;
}

zoom({scale}: {scale: number}): MapState {
// In Globe view zoom does not take into account the mouse position
const startZoom = this.getState().startZoom || this.getViewportProps().zoom;
const zoom = startZoom + Math.log2(scale);
return this._getUpdatedState({zoom});
zoom({
pos,
startPos,
scale
}: {
pos: [number, number];
startPos?: [number, number];
scale: number;
}): GlobeState {
// Make sure we zoom around the current mouse position rather than map center
let {startZoom, startZoomLngLat} = this.getState();

if (!startZoomLngLat) {
// We have two modes of zoom:
// scroll zoom that are discrete events (transform from the current zoom level),
// and pinch zoom that are continuous events (transform from the zoom level when
// pinch started).
// If startZoom state is defined, then use the startZoom state;
// otherwise assume discrete zooming
startZoom = this.getViewportProps().zoom;
startZoomLngLat = this._unproject(startPos) || this._unproject(pos);
}
if (!startZoomLngLat) {
// Fallback: zoom without following cursor
const currentZoom = this.getState().startZoom || this.getViewportProps().zoom;
const newZoom = currentZoom + Math.log2(scale);
return this._getUpdatedState({zoom: newZoom}) as GlobeState;
}

const {maxZoom, minZoom} = this.getViewportProps();
let zoom = (startZoom as number) + Math.log2(scale);
zoom = clamp(zoom, minZoom, maxZoom);

const zoomedViewport = this.makeViewport({...this.getViewportProps(), zoom});

return this._getUpdatedState({
zoom,
...zoomedViewport.panByPosition(startZoomLngLat, pos)
}) as GlobeState;
}

/**
* Start rotating
* @param {[Number, Number]} pos - position on screen where the center is
*/
rotateStart({pos}: {pos: [number, number]}): GlobeState {
return this._getUpdatedState({
startRotatePos: pos,
startBearing: this.getViewportProps().bearing || 0,
startPitch: this.getViewportProps().pitch || 0
}) as GlobeState;
}

/**
* Rotate the globe (change bearing and pitch)
* For GlobeView, rotation includes:
* - Bearing: camera rotation around its view axis (compass direction)
* - Pitch: camera tilt angle (looking at globe from above vs from the side)
*/
rotate({
pos,
deltaAngleX = 0,
deltaAngleY = 0
}: {
pos?: [number, number];
deltaAngleX?: number;
deltaAngleY?: number;
}): GlobeState {
const {startRotatePos, startBearing, startPitch} = this.getState();

let newBearing: number;
let newPitch: number;

if (pos && startRotatePos && startBearing !== undefined && startPitch !== undefined) {
// Calculate bearing and pitch change based on mouse movement
const {width, height} = this.getViewportProps();
const deltaX = pos[0] - startRotatePos[0];
const deltaY = pos[1] - startRotatePos[1];
const deltaScaleX = deltaX / width;
const deltaScaleY = deltaY / height;
newBearing = startBearing + 180 * deltaScaleX;
// Invert deltaY for natural feel (drag down = look up)
newPitch = startPitch - 90 * deltaScaleY;
} else if (deltaAngleX !== 0 || deltaAngleY !== 0) {
// Handle deltaAngleX/Y from pinch rotation or direct call
const currentBearing = startBearing ?? this.getViewportProps().bearing ?? 0;
const currentPitch = startPitch ?? this.getViewportProps().pitch ?? 0;
newBearing = currentBearing + deltaAngleX;
newPitch = currentPitch + deltaAngleY;
} else if (startBearing !== undefined) {
// Maintain current values
newBearing = startBearing;
newPitch = startPitch ?? this.getViewportProps().pitch ?? 0;
} else {
// No rotation data available
return this;
}

return this._getUpdatedState({bearing: newBearing, pitch: newPitch}) as GlobeState;
}

/**
* End rotating
*/
rotateEnd(): GlobeState {
return this._getUpdatedState({
startRotatePos: null,
startBearing: null,
startPitch: null
}) as GlobeState;
}

/**
* Rotate left (decrease bearing)
*/
rotateLeft(speed: number = 15): GlobeState {
const bearing = (this.getViewportProps().bearing || 0) - speed;
return this._getUpdatedState({bearing}) as GlobeState;
}

/**
* Rotate right (increase bearing)
*/
rotateRight(speed: number = 15): GlobeState {
const bearing = (this.getViewportProps().bearing || 0) + speed;
return this._getUpdatedState({bearing}) as GlobeState;
}

/**
* Rotate up (decrease pitch - look more from above)
*/
rotateUp(speed: number = 10): GlobeState {
const pitch = (this.getViewportProps().pitch || 0) - speed;
return this._getUpdatedState({pitch}) as GlobeState;
}

/**
* Rotate down (increase pitch - look more from the side)
*/
rotateDown(speed: number = 10): GlobeState {
const pitch = (this.getViewportProps().pitch || 0) + speed;
return this._getUpdatedState({pitch}) as GlobeState;
}

applyConstraints(props: Required<MapStateProps>): Required<MapStateProps> {
Expand All @@ -82,6 +219,38 @@ class GlobeState extends MapState {
}
props.latitude = clamp(latitude, -MAX_LATITUDE, MAX_LATITUDE);

// Normalize bearing to [-180, 180]
if (props.bearing !== undefined) {
if (props.bearing < -180 || props.bearing > 180) {
props.bearing = mod(props.bearing + 180, 360) - 180;
}
}

// Clamp pitch to valid range
if (props.pitch !== undefined) {
const minPitch = props.minPitch ?? DEFAULT_MIN_PITCH;
const maxPitch = props.maxPitch ?? DEFAULT_MAX_PITCH;
props.pitch = clamp(props.pitch, minPitch, maxPitch);
}

return props;
}

shortestPathFrom(viewState: MapState): MapStateProps {
const fromProps = viewState.getViewportProps();
const props = {...this.getViewportProps()};
const {bearing, longitude} = props;

// Normalize bearing for shortest path interpolation
if (bearing !== undefined && fromProps.bearing !== undefined) {
if (Math.abs(bearing - fromProps.bearing) > 180) {
props.bearing = bearing < 0 ? bearing + 360 : bearing - 360;
}
}

if (Math.abs(longitude - fromProps.longitude) > 180) {
props.longitude = longitude < 0 ? longitude + 360 : longitude - 360;
}
return props;
}
}
Expand All @@ -91,16 +260,22 @@ export default class GlobeController extends Controller<MapState> {

transition = {
transitionDuration: 300,
transitionInterpolator: new LinearInterpolator(['longitude', 'latitude', 'zoom'])
transitionInterpolator: new LinearInterpolator([
'longitude',
'latitude',
'zoom',
'bearing',
'pitch'
])
};

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

setProps(props: ControllerProps) {
super.setProps(props);

// TODO - support pitching?
this.dragRotate = false;
this.touchRotate = false;
// GlobeView now supports bearing and pitch rotation
// Note: dragRotate/touchRotate are enabled by default in the base Controller
// Users can still disable them via props if desired
}
}
26 changes: 26 additions & 0 deletions modules/core/src/viewports/globe-viewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ const RADIANS_TO_DEGREES = 180 / Math.PI;
const EARTH_RADIUS = 6370972;
const GLOBE_RADIUS = 256;

// Pitch constraints for GlobeView
export const DEFAULT_MIN_PITCH = 0;
export const DEFAULT_MAX_PITCH = 85;

function getDistanceScales() {
const unitsPerMeter = GLOBE_RADIUS / EARTH_RADIUS;
const unitsPerDegree = (Math.PI / 180) * GLOBE_RADIUS;
Expand Down Expand Up @@ -44,6 +48,10 @@ export type GlobeViewportOptions = {
longitude?: number;
/** Latitude in degrees */
latitude?: number;
/** Bearing angle in degrees. Default `0` (north up). */
bearing?: number;
/** Pitch angle in degrees. Default `0` (looking straight down at globe center). */
pitch?: number;
/** Camera altitude relative to the viewport height, used to control the FOV. Default `1.5` */
altitude?: number;
/* Meter offsets of the viewport center from lng, lat, elevation */
Expand Down Expand Up @@ -71,12 +79,16 @@ export default class GlobeViewport extends Viewport {

longitude: number;
latitude: number;
bearing: number;
pitch: number;
fovy: number;
resolution: number;

constructor(opts: GlobeViewportOptions = {}) {
const {
longitude = 0,
bearing = 0,
pitch = 0,
zoom = 0,
// Matches Maplibre defaults
// https://github.com/maplibre/maplibre-gl-js/blob/f8ab4b48d59ab8fe7b068b102538793bbdd4c848/src/geo/projection/globe_transform.ts#L632-L633
Expand All @@ -89,6 +101,8 @@ export default class GlobeViewport extends Viewport {

// Clamp to web mercator limit to prevent bad inputs
latitude = Math.max(Math.min(latitude, MAX_LATITUDE), -MAX_LATITUDE);
// Clamp pitch to valid range
const clampedPitch = Math.max(DEFAULT_MIN_PITCH, Math.min(DEFAULT_MAX_PITCH, pitch));

height = height || 1;
if (fovy) {
Expand All @@ -104,7 +118,17 @@ export default class GlobeViewport extends Viewport {
const farZ = opts.farZ ?? (altitude + (GLOBE_RADIUS * 2 * scale) / height) * farZMultiplier;

// Calculate view matrix
// The view matrix rotates the globe to show the correct location and orientation
// 1. Start with camera looking at origin from -Y axis (north up = +Z)
// 2. Apply pitch rotation (tilt camera up/down from looking at center)
// 3. Rotate around Y axis by bearing (camera rotation around its view axis)
// 4. Rotate around X axis by latitude (tilt to show correct latitude)
// 5. Rotate around Z axis by -longitude (spin globe to show correct longitude)
const viewMatrix = new Matrix4().lookAt({eye: [0, -altitude, 0], up: [0, 0, 1]});
// Apply pitch rotation (rotate camera around its local X axis to tilt view)
viewMatrix.rotateX(-clampedPitch * DEGREES_TO_RADIANS);
// Apply bearing rotation (rotate camera around its view axis)
viewMatrix.rotateY(bearing * DEGREES_TO_RADIANS);
viewMatrix.rotateX(latitude * DEGREES_TO_RADIANS);
viewMatrix.rotateZ(-longitude * DEGREES_TO_RADIANS);
viewMatrix.scale(scale / height);
Expand All @@ -131,6 +155,8 @@ export default class GlobeViewport extends Viewport {
this.scale = scale;
this.latitude = latitude;
this.longitude = longitude;
this.bearing = bearing;
this.pitch = clampedPitch;
this.fovy = fovy;
this.resolution = resolution;
}
Expand Down
8 changes: 8 additions & 0 deletions modules/core/src/views/globe-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,18 @@ export type GlobeViewState = {
latitude: number;
/** Zoom level */
zoom: number;
/** Bearing angle in degrees (rotation around the view axis). Default `0` (north up). */
bearing?: number;
/** Pitch angle in degrees (tilt of the camera). Default `0` (looking straight at globe center). Range: 0-85. */
pitch?: number;
/** Min zoom, default `0` */
minZoom?: number;
/** Max zoom, default `20` */
maxZoom?: number;
/** Min pitch in degrees. Default `0`. */
minPitch?: number;
/** Max pitch in degrees. Default `85`. */
maxPitch?: number;
/** The near plane position */
nearZ?: number;
/** The far plane position */
Expand Down
8 changes: 4 additions & 4 deletions test/modules/core/controllers/controllers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@ test('GlobeController', async t => {
{
longitude: -122.45,
latitude: 37.78,
zoom: 0
},
// GlobeView cannot be rotated
['pan#function key', 'pinch', 'multipan']
zoom: 0,
bearing: 0
}
// GlobeView now supports bearing rotation
);

t.end();
Expand Down
Loading