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
4 changes: 2 additions & 2 deletions docs/api-reference/core/globe-controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ 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
- `dragRotate`: default `true` (right-click drag to rotate pitch/bearing)
- `touchRotate`: default `true` (two-finger rotate to change pitch/bearing)
- `keyboard`: arrow keys to pan, +/- to zoom
- `maxBounds` - constrains the viewport to the specified bounding box `[[minLng, minLat], [maxLng, maxLat]]`

Expand Down
5 changes: 4 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,6 @@ 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.
- No high-precision rendering at high zoom levels (> 12). Features at the city-block scale may not be rendered accurately.
- Only supports `'lnglat'` (the default value 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 +61,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).
- `pitch` (number, optional) - pitch angle in degrees. `0` is looking straight down. Default `0`.
- `maxZoom` (number, optional) - max zoom level. Default `20`.
- `minZoom` (number, optional) - min zoom level. Default `0`.
- `maxPitch` (number, optional) - max pitch angle. Default `60`.
- `minPitch` (number, optional) - min pitch angle. Default `0`.


## Controller
Expand Down
109 changes: 51 additions & 58 deletions modules/core/src/controllers/globe-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@
// Copyright (c) vis.gl contributors

import {clamp} from '@math.gl/core';
import Controller, {ControllerProps} from './controller';
import Controller from './controller';

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, GLOBE_RADIUS} from '../viewports/globe-viewport';
Expand All @@ -27,73 +26,69 @@ function pixelsToDegrees(pixels: number, zoom: number = 0): number {
return radians * RADIANS_TO_DEGREES;
}

type GlobeStateInternal = MapStateInternal & {
startPanPos?: [number, number];
};

class GlobeState extends MapState {
constructor(
options: MapStateProps &
GlobeStateInternal & {
makeViewport: (props: Record<string, any>) => any;
}
options: MapStateProps & {
makeViewport: (props: Record<string, any>) => any;
}
) {
const {startPanPos, ...mapStateOptions} = options;
mapStateOptions.normalize = false; // disable MapState default normalization
super(mapStateOptions);
// Disable MapState's default web-mercator bounds; globe covers the whole earth.
// Pan (panStart/pan/panEnd) is inherited from MapState so the grabbed lng/lat
// stays under the cursor — matching MapView. The old globe-specific delta-pan
// produced the wrong on-screen speed and yanked the center at zoom > 12 (where
// WebMercatorViewport takes over rendering but ignored the delta third-arg).
super({...options, normalize: false});
}

if (startPanPos !== undefined) {
(this as any)._state.startPanPos = startPanPos;
zoom({
pos,
startPos,
scale
}: {
pos: [number, number];
startPos?: [number, number];
scale: number;
}): MapState {
let {startZoom, startZoomLngLat} = this.getState();

if (!startZoomLngLat) {
startZoom = this.getViewportProps().zoom;
startZoomLngLat = this._unproject(startPos) || this._unproject(pos);
}
}

panStart({pos}: {pos: [number, number]}): GlobeState {
const {latitude, longitude, zoom} = this.getViewportProps();
return this._getUpdatedState({
startPanLngLat: [longitude, latitude],
startPanPos: pos,
startZoom: zoom
}) as GlobeState;
}
const zoom = this._constrainZoom((startZoom as number) + Math.log2(scale));

pan({pos, startPos}: {pos: [number, number]; startPos?: [number, number]}): GlobeState {
const state = this.getState() as GlobeStateInternal;
const startPanLngLat = state.startPanLngLat || this._unproject(startPos);
if (!startPanLngLat) return this;
const startZoom = state.startZoom ?? this.getViewportProps().zoom;
const startPanPos = state.startPanPos || startPos;

const coords = [startPanLngLat[0], startPanLngLat[1], startZoom];
const viewport = this.makeViewport(this.getViewportProps());
const newProps = viewport.panByPosition(coords, pos, startPanPos);
return this._getUpdatedState(newProps) as GlobeState;
}
if (!startZoomLngLat) {
// Cursor is off the globe — fall back to center zoom
return this._getUpdatedState({zoom});
}

panEnd(): GlobeState {
const zoomedViewport = this.makeViewport({...this.getViewportProps(), zoom});
return this._getUpdatedState({
startPanLngLat: null,
startPanPos: null,
startZoom: null
}) 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,
...zoomedViewport.panByPosition(startZoomLngLat, pos)
});
}

applyConstraints(props: Required<MapStateProps>): Required<MapStateProps> {
// Ensure zoom is within specified range
const {longitude, latitude, maxBounds} = props;
const {longitude, latitude, maxBounds, maxPitch, minPitch} = props;

props.zoom = this._constrainZoom(props.zoom, props);

if (longitude < -180 || longitude > 180) {
props.longitude = mod(longitude + 180, 360) - 180;
}
props.latitude = clamp(latitude, -MAX_LATITUDE, MAX_LATITUDE);

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

// Clamp pitch to [minPitch, maxPitch]
props.pitch = clamp(props.pitch, minPitch, maxPitch);

if (maxBounds) {
props.longitude = clamp(props.longitude, maxBounds[0][0], maxBounds[1][0]);
props.latitude = clamp(props.latitude, maxBounds[0][1], maxBounds[1][1]);
Expand Down Expand Up @@ -175,16 +170,14 @@ 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;
}
}
90 changes: 61 additions & 29 deletions modules/core/src/viewports/globe-viewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export type GlobeViewportOptions = {
longitude?: number;
/** Latitude in degrees */
latitude?: number;
/** Bearing angle in degrees. Default `0` (north up). */
bearing?: number;
/** Pitch (tilt) angle in degrees. `0` looks straight down at the earth. Default `0`. */
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 +75,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 @@ -100,14 +108,33 @@ export default class GlobeViewport extends Viewport {
// The goal is that globe and web mercator projection results converge at high zoom
// https://github.com/maplibre/maplibre-gl-js/blob/f8ab4b48d59ab8fe7b068b102538793bbdd4c848/src/geo/projection/globe_transform.ts#L575-L577
const scale = Math.pow(2, zoom - zoomAdjust(latitude));

// Adjust far plane for pitch — tilted camera can see further across the globe
const pitchRadians = pitch * DEGREES_TO_RADIANS;
const nearZ = opts.nearZ ?? nearZMultiplier;
const farZ = opts.farZ ?? (altitude + (GLOBE_RADIUS * 2 * scale) / height) * farZMultiplier;
const farZ =
opts.farZ ??
(altitude + (GLOBE_RADIUS * 2 * scale) / height / Math.max(Math.cos(pitchRadians), 0.1)) *
farZMultiplier;

// Calculate view matrix
const viewMatrix = new Matrix4().lookAt({eye: [0, -altitude, 0], up: [0, 0, 1]});
viewMatrix.rotateX(latitude * DEGREES_TO_RADIANS);
viewMatrix.rotateZ(-longitude * DEGREES_TO_RADIANS);
viewMatrix.scale(scale / height);
// The Viewport base class subtracts `center` (the target's common-space position)
// before applying this matrix, placing the target at the origin. Since rotations
// preserve the origin, the target always projects to screen center regardless of
// pitch/bearing. The lookAt places the camera along -Y looking toward origin.
// After the globe rotation (Rx(lat) * Rz(-lng)), the surface normal at the target
// aligns with -Y, East with +X, and North with +Z.
const viewMatrix = new Matrix4()
.lookAt({eye: [0, -altitude, 0], up: [0, 0, 1]})
// Pitch: tilt the camera away from straight-down
.rotateX(-pitchRadians)
// Bearing: rotate around the surface normal (-Y axis).
// Negative sign matches the WebMercator convention (bearing > 0 = clockwise from North).
.rotateY(-bearing * DEGREES_TO_RADIANS)
// Globe orientation: position the target's surface at the top
.rotateX(latitude * DEGREES_TO_RADIANS)
.rotateZ(-longitude * DEGREES_TO_RADIANS)
.scale(scale / height);

super({
...opts,
Expand All @@ -131,6 +158,8 @@ export default class GlobeViewport extends Viewport {
this.scale = scale;
this.latitude = latitude;
this.longitude = longitude;
this.bearing = bearing;
this.pitch = pitch;
this.fovy = fovy;
this.resolution = resolution;
}
Expand Down Expand Up @@ -188,10 +217,19 @@ export default class GlobeViewport extends Viewport {
const sSqr = (4 * l0Sqr * l1Sqr - (lSqr - l0Sqr - l1Sqr) ** 2) / 16;
const dSqr = (4 * sSqr) / lSqr;
const r0 = Math.sqrt(l0Sqr - dSqr);
const dr = Math.sqrt(Math.max(0, lt * lt - dSqr));
const t = (r0 - dr) / Math.sqrt(lSqr);

coord = vec3.lerp([], coord0, coord1, t);
const discriminant = lt * lt - dSqr;

if (discriminant < 0) {
// Ray misses the sphere — project the closest-approach point onto the sphere surface
const tClosest = r0 / Math.sqrt(lSqr);
const closest = vec3.lerp([], coord0, coord1, tClosest);
const len = vec3.len(closest);
coord = len > 0 ? vec3.scale([], closest, lt / len) : [0, 0, lt];
} else {
const dr = Math.sqrt(discriminant);
const t = (r0 - dr) / Math.sqrt(lSqr);
coord = vec3.lerp([], coord0, coord1, t);
}
}
const [X, Y, Z] = this.unprojectPosition(coord);

Expand Down Expand Up @@ -232,27 +270,21 @@ export default class GlobeViewport extends Viewport {
}

/**
* Pan the globe using delta-based movement
* @param coords - the geographic coordinates where the pan started
* @param pixel - the current screen position
* @param startPixel - the screen position where the pan started
* @returns updated viewport options with new longitude/latitude
* Pan the globe so that a geographic position appears at a given screen pixel.
* Shifts center by (coords - unproject(pixel)) — i.e. keeps the grabbed lng/lat
* under the cursor. Used for drag-pan and zoom-toward-cursor.
*/
panByPosition(
[startLng, startLat, startZoom]: number[],
pixel: number[],
startPixel: number[]
): GlobeViewportOptions {
// Scale rotation speed inversely with zoom, to approximate constant panning speed
const scale = Math.pow(2, this.zoom - zoomAdjust(this.latitude));
const rotationSpeed = 0.25 / scale;

const longitude = startLng + rotationSpeed * (startPixel[0] - pixel[0]);
let latitude = startLat - rotationSpeed * (startPixel[1] - pixel[1]);
latitude = Math.max(Math.min(latitude, MAX_LATITUDE), -MAX_LATITUDE);
const out = {longitude, latitude, zoom: startZoom - zoomAdjust(startLat)};
out.zoom += zoomAdjust(out.latitude);
return out;
panByPosition(coords: number[], pixel: number[]): GlobeViewportOptions {
const currentAtPixel = this.unproject(pixel);
if (!currentAtPixel) {
return {longitude: this.longitude, latitude: this.latitude};
}
const longitude = this.longitude + (coords[0] - currentAtPixel[0]);
const latitude = Math.max(
Math.min(this.latitude + (coords[1] - currentAtPixel[1]), MAX_LATITUDE),
-MAX_LATITUDE
);
return {longitude, latitude};
}
}

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. Default `0` (north up). */
bearing?: number;
/** Pitch (tilt) angle in degrees. `0` looks straight down at the earth. Default `0`. */
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 `60` */
maxPitch?: number;
/** The near plane position */
nearZ?: number;
/** The far plane position */
Expand Down
Loading
Loading