diff --git a/docs/api-reference/core/globe-controller.md b/docs/api-reference/core/globe-controller.md index 4cb2ad72ed5..5147033b054 100644 --- a/docs/api-reference/core/globe-controller.md +++ b/docs/api-reference/core/globe-controller.md @@ -38,9 +38,10 @@ 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`: shift+drag or right-click drag to change bearing and pitch +- `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]]` ## Custom GlobeController diff --git a/docs/api-reference/core/globe-view.md b/docs/api-reference/core/globe-view.md index 24a41fdf67a..b4ebf172a14 100644 --- a/docs/api-reference/core/globe-view.md +++ b/docs/api-reference/core/globe-view.md @@ -18,9 +18,7 @@ It's recommended that you read the [Views and Projections guide](../../developer ## Limitations 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. @@ -72,8 +70,14 @@ 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` looks straight down at the earth. 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`. + +When `bearing` is `0` (the default), north is always kept pointing up and the globe behaves like a traditional desk globe — horizontal drag changes longitude, vertical drag changes latitude, and the polar axis stays fixed. When the user changes the bearing (via shift+drag or right-click drag), the globe enters free rotation mode where bearing evolves naturally to avoid orientation discontinuities near the poles. ## Controller diff --git a/docs/api-reference/core/globe-viewport.md b/docs/api-reference/core/globe-viewport.md index 69f29e1f4ec..7facaed184e 100644 --- a/docs/api-reference/core/globe-viewport.md +++ b/docs/api-reference/core/globe-viewport.md @@ -1,6 +1,6 @@ # GlobeViewport (Experimental) -The `GlobeViewport` class takes globe view states (`latitude`, `longitude`, and `zoom`), and performs projections between world and screen coordinates. It is a helper class for visualizing the earth as a 3D globe. +The `GlobeViewport` class takes globe view states (`latitude`, `longitude`, `zoom`, `bearing`, and `pitch`), and performs projections between world and screen coordinates. It is a helper class for visualizing the earth as a 3D globe. ## Usage @@ -25,7 +25,7 @@ viewport.project([-122.45, 37.78]); ## Constructor ```js -new GlobeViewport({width, height, longitude, latitude, zoom}); +new GlobeViewport({width, height, longitude, latitude, zoom, bearing, pitch}); ``` Parameters: @@ -40,11 +40,13 @@ Parameters: + `latitude` (number, optional) - Latitude of the viewport center on map. Default to `0`. + `longitude` (number, optional) - Longitude of the viewport center on map. Default to `0`. + `zoom` (number, optional) - Map zoom (scale is calculated as `2^zoom`). Default to `11`. + + `bearing` (number, optional) - Bearing angle in degrees. Default to `0`. + + `pitch` (number, optional) - Pitch angle in degrees. Default to `0`. + `altitude` (number, optional) - Altitude of camera, 1 unit equals to the height of the viewport. Default to `1.5`. projection matrix arguments: - + `nearZMultiplier` (number, optional) - Scaler for the near plane, 1 unit equals to the height of the viewport. Default to `0.1`. + + `nearZMultiplier` (number, optional) - Scaler for the near plane, 1 unit equals to the height of the viewport. Default to `0.01`. + `farZMultiplier` (number, optional) - Scaler for the far plane, 1 unit equals to the distance from the camera to the top edge of the screen. Default to `1`. Remarks: diff --git a/modules/core/src/controllers/globe-controller.ts b/modules/core/src/controllers/globe-controller.ts index 60532e9ce3b..8993b2ffee7 100644 --- a/modules/core/src/controllers/globe-controller.ts +++ b/modules/core/src/controllers/globe-controller.ts @@ -3,16 +3,23 @@ // 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'; - +import { + Globe, + type CameraFrame, + GLOBE_INERTIA_EASING, + GlobeInertiaInterpolator +} from '../viewports/globe-utils'; import {MAX_LATITUDE} from '@math.gl/web-mercator'; +import type {MjolnirGestureEvent} from 'mjolnir.js'; + const DEGREES_TO_RADIANS = Math.PI / 180; const RADIANS_TO_DEGREES = 180 / Math.PI; @@ -21,6 +28,7 @@ function degreesToPixels(angle: number, zoom: number = 0): number { const size = GLOBE_RADIUS * 2 * Math.sin(radians / 2); return size * Math.pow(2, zoom); } + function pixelsToDegrees(pixels: number, zoom: number = 0): number { const size = pixels / Math.pow(2, zoom); const radians = Math.asin(Math.min(1, size / GLOBE_RADIUS / 2)) * 2; @@ -29,6 +37,10 @@ function pixelsToDegrees(pixels: number, zoom: number = 0): number { type GlobeStateInternal = MapStateInternal & { startPanPos?: [number, number]; + startPanCameraFrame?: CameraFrame; + startPanAngularRate?: number; + /** When true, bearing is held fixed during pan (north stays up) */ + startPanLockBearing?: boolean; }; class GlobeState extends MapState { @@ -38,54 +50,113 @@ class GlobeState extends MapState { makeViewport: (props: Record) => any; } ) { - const {startPanPos, ...mapStateOptions} = options; - mapStateOptions.normalize = false; // disable MapState default normalization + const { + startPanPos, + startPanCameraFrame, + startPanAngularRate, + startPanLockBearing, + ...mapStateOptions + } = options; + mapStateOptions.normalize = false; super(mapStateOptions); - if (startPanPos !== undefined) { - (this as any)._state.startPanPos = startPanPos; - } + const s = (this as any)._state; + if (startPanPos !== undefined) s.startPanPos = startPanPos; + if (startPanCameraFrame !== undefined) s.startPanCameraFrame = startPanCameraFrame; + if (startPanAngularRate !== undefined) s.startPanAngularRate = startPanAngularRate; + if (startPanLockBearing !== undefined) s.startPanLockBearing = startPanLockBearing; } panStart({pos}: {pos: [number, number]}): GlobeState { - const {latitude, longitude, zoom} = this.getViewportProps(); + const {latitude, longitude, zoom, bearing = 0} = this.getViewportProps(); + const cameraFrame = Globe.cameraFrame(longitude, latitude, bearing); + const lockBearing = Math.abs(bearing) < 1; + + if (lockBearing) { + // Override horizontal axis to polar so north stays up. + // Boost rate by 1/cos(lat) to compensate for smaller longitude + // circles near the poles, capped at 4x. + cameraFrame.axisHorizontal = [0, 0, 1]; + } + + // Radians of arc per pixel, derived from zoom scale + const scale = Math.pow(2, zoom - zoomAdjust(latitude, true)); + const angularRate = (0.25 / scale) * DEGREES_TO_RADIANS; + return this._getUpdatedState({ - startPanLngLat: [longitude, latitude], startPanPos: pos, + startPanCameraFrame: cameraFrame, + startPanAngularRate: angularRate, + startPanLockBearing: lockBearing, startZoom: zoom }) as GlobeState; } 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; + if (!startPanPos) return this; + + const frame = state.startPanCameraFrame; + const rate = state.startPanAngularRate; + const startZoom = state.startZoom ?? this.getViewportProps().zoom; + if (!frame || !rate) { + return this; + } + + const dx = startPanPos[0] - pos[0]; + const dy = startPanPos[1] - pos[1]; + + let hAngle = dx * rate; + let vAngle = -dy * rate; + const locked = state.startPanLockBearing; + + if (locked) { + // Boost horizontal rate by 1/cos(lat) for the polar axis, capped at 4x + const cosLat = Math.cos(frame.latitude * DEGREES_TO_RADIANS); + hAngle = (dx * rate) / Math.max(cosLat, 0.25); + // Clamp vertical angle to prevent crossing the poles + const maxUp = (MAX_LATITUDE - frame.latitude) * DEGREES_TO_RADIANS; + const maxDown = -(MAX_LATITUDE + frame.latitude) * DEGREES_TO_RADIANS; + vAngle = clamp(vAngle, maxDown, maxUp); + } + + const rotated = Globe.rotateFrame(frame, hAngle, vAngle, locked); + const zoom = startZoom + zoomAdjust(rotated.latitude, true) - zoomAdjust(frame.latitude, true); - 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; + return this._getUpdatedState({ + longitude: rotated.longitude, + latitude: rotated.latitude, + bearing: rotated.bearing, + zoom + }) as GlobeState; } panEnd(): GlobeState { return this._getUpdatedState({ - startPanLngLat: null, startPanPos: null, + startPanCameraFrame: null, + startPanAngularRate: null, + startPanLockBearing: 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}); } + _panFromCenter(offset: [number, number]): GlobeState { + const {width, height} = this.getViewportProps(); + const center: [number, number] = [width / 2, height / 2]; + return this.panStart({pos: center}) + .pan({pos: [center[0] + offset[0], center[1] + offset[1]]}) + .panEnd(); + } + applyConstraints(props: Required): Required { - // Ensure zoom is within specified range const {longitude, latitude, maxBounds} = props; props.zoom = this._constrainZoom(props.zoom, props); @@ -93,19 +164,23 @@ class GlobeState extends MapState { if (longitude < -180 || longitude > 180) { props.longitude = mod(longitude + 180, 360) - 180; } - props.latitude = clamp(latitude, -MAX_LATITUDE, MAX_LATITUDE); + props.latitude = clamp(latitude, -90, 90); + + if (props.bearing < -180 || props.bearing > 180) { + props.bearing = mod(props.bearing + 180, 360) - 180; + } + props.pitch = clamp(props.pitch, props.minPitch, props.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]); } if (maxBounds) { - // calculate center and zoom ranges at pitch=0 and bearing=0 - // to maintain visual stability when rotating const effectiveZoom = props.zoom - zoomAdjust(latitude); const lngSpan = maxBounds[1][0] - maxBounds[0][0]; const latSpan = maxBounds[1][1] - maxBounds[0][1]; - if (latSpan > 0 && latSpan < MAX_LATITUDE * 2) { + if (latSpan > 0 && latSpan < 180) { const halfHeightDegrees = Math.min(pixelsToDegrees(props.height, effectiveZoom), latSpan) / 2; props.latitude = clamp( @@ -131,7 +206,7 @@ class GlobeState extends MapState { } } if (props.latitude !== latitude) { - props.zoom += zoomAdjust(props.latitude) - zoomAdjust(latitude); + props.zoom += zoomAdjust(props.latitude, true) - zoomAdjust(latitude, true); } return props; @@ -139,20 +214,18 @@ class GlobeState extends MapState { _constrainZoom(zoom: number, props?: Required): number { props ||= this.getViewportProps(); - const {latitude, maxZoom, maxBounds} = props; + const {maxZoom, maxBounds} = props; let {minZoom} = props; - const ZOOM0 = zoomAdjust(0); - const zoomAdjustment = zoomAdjust(latitude) - ZOOM0; const shouldApplyMaxBounds = maxBounds !== null && props.width > 0 && props.height > 0; if (shouldApplyMaxBounds) { const minLatitude = maxBounds[0][1]; const maxLatitude = maxBounds[1][1]; - // latitude at which the bounding box is the widest const fitLatitude = Math.sign(minLatitude) === Math.sign(maxLatitude) ? Math.min(Math.abs(minLatitude), Math.abs(maxLatitude)) : 0; + const ZOOM0 = zoomAdjust(0); const w = degreesToPixels(maxBounds[1][0] - maxBounds[0][0]) * Math.cos(fitLatitude * DEGREES_TO_RADIANS); @@ -166,6 +239,7 @@ class GlobeState extends MapState { if (minZoom > maxZoom) minZoom = maxZoom; } + const zoomAdjustment = zoomAdjust(props.latitude, true) - zoomAdjust(0, true); return clamp(zoom, minZoom + zoomAdjustment, maxZoom + zoomAdjustment); } } @@ -175,16 +249,127 @@ export default class GlobeController extends Controller { transition = { transitionDuration: 300, - transitionInterpolator: new LinearInterpolator(['longitude', 'latitude', 'zoom']) + transitionInterpolator: new LinearInterpolator({ + transitionProps: { + compare: ['longitude', 'latitude', 'zoom', 'bearing', 'pitch'], + required: ['longitude', 'latitude', 'zoom'] + } + }) }; dragMode: 'pan' | 'rotate' = 'pan'; - setProps(props: ControllerProps) { - super.setProps(props); + // Ring buffer tracking globe position during pan for inertia velocity + private _panHistory: Array<{longitude: number; latitude: number; timestamp: number}> = []; + + protected _onPanStart(event: MjolnirGestureEvent): boolean { + this._panHistory = []; + return super._onPanStart(event); + } + + protected _onPanMove(event: MjolnirGestureEvent): boolean { + if (!this.dragPan) { + return false; + } + const pos = this.getCenter(event); + const newControllerState = this.controllerState.pan({pos}); + this.updateViewport( + newControllerState, + {transitionDuration: 0}, + { + isDragging: true, + isPanning: true + } + ); + + const {longitude, latitude} = newControllerState.getViewportProps(); + this._panHistory.push({longitude, latitude, timestamp: Date.now()}); + if (this._panHistory.length > 5) { + this._panHistory.shift(); + } + + return true; + } + + protected _onPanMoveEnd(event: MjolnirGestureEvent): boolean { + const {inertia} = this; + if (this.dragPan && inertia && this._panHistory.length >= 2) { + const first = this._panHistory[0]; + const last = this._panHistory[this._panHistory.length - 1]; + const dt = last.timestamp - first.timestamp; + + if (dt > 0) { + const viewportProps = this.controllerState.getViewportProps(); + const state = this.controllerState.getState() as GlobeStateInternal; + + // Compute velocity from the actual positions the globe was at + const angularDistance = Globe.angularDistance(first, last); + const angularVelocity = angularDistance / dt; + + if (angularVelocity > 1e-6) { + const totalAngle = (angularVelocity * inertia) / 2; + let interpolator: GlobeInertiaInterpolator; + let endLng: number; + let endLat: number; + + if (state.startPanLockBearing) { + // Decompose into lng/lat velocity and extrapolate linearly + let dLng = last.longitude - first.longitude; + if (dLng > 180) dLng -= 360; + else if (dLng < -180) dLng += 360; + const dLat = last.latitude - first.latitude; + const vLng = dLng / dt; + const vLat = dLat / dt; + endLng = viewportProps.longitude + (vLng * inertia) / 2; + endLat = clamp(viewportProps.latitude + (vLat * inertia) / 2, -90, 90); + + interpolator = new GlobeInertiaInterpolator({targetLongitude: endLng}); + } else { + // Free bearing — use single-axis rotation to maintain + // constant spin direction with up vector tracking. + const axis = Globe.greatCircleAxis(first, last); + const currentFrame = Globe.cameraFrame( + viewportProps.longitude, + viewportProps.latitude, + viewportProps.bearing || 0 + ); + const endFrame = Globe.rotateFrame( + {...currentFrame, axisHorizontal: axis}, + totalAngle, + 0 + ); + endLng = endFrame.longitude; + endLat = clamp(endFrame.latitude, -90, 90); + interpolator = new GlobeInertiaInterpolator({axis, totalAngle}); + } + + const newControllerState = this.controllerState.panEnd(); + this.updateViewport( + newControllerState, + { + transitionInterpolator: interpolator, + transitionDuration: inertia, + transitionEasing: GLOBE_INERTIA_EASING, + longitude: endLng, + latitude: endLat + }, + { + isDragging: false, + isPanning: true + } + ); + this._panHistory = []; + return true; + } + } + } - // TODO - support pitching? - this.dragRotate = false; - this.touchRotate = false; + this._panHistory = []; + const newControllerState = this.controllerState.panEnd(); + this.updateViewport(newControllerState, null, { + isDragging: false, + isPanning: false + }); + return true; } } diff --git a/modules/core/src/viewports/globe-utils.ts b/modules/core/src/viewports/globe-utils.ts new file mode 100644 index 00000000000..9c4bc42ba07 --- /dev/null +++ b/modules/core/src/viewports/globe-utils.ts @@ -0,0 +1,235 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {clamp, vec3, Quaternion} from '@math.gl/core'; +import TransitionInterpolator from '../transitions/transition-interpolator'; +import {zoomAdjust} from './globe-viewport'; + +const DEGREES_TO_RADIANS = Math.PI / 180; +const RADIANS_TO_DEGREES = 180 / Math.PI; + +type Vec3 = number[]; + +export type CameraFrame = { + /** Unit-sphere position */ + position: Vec3; + /** Camera up direction (tangent to sphere) */ + up: Vec3; + /** Rotation axis for horizontal drag */ + axisHorizontal: Vec3; + /** Rotation axis for vertical drag */ + axisVertical: Vec3; + /** Longitude in degrees */ + longitude: number; + /** Latitude in degrees */ + latitude: number; + /** Bearing in degrees */ + bearing: number; +}; + +/** + * Static utility methods for sphere geometry on the unit globe. + * Used by GlobeState and the globe inertia interpolators. + */ +export class Globe { + /** Convert (lng, lat) in degrees to a unit-sphere position */ + static toPosition(lng: number, lat: number): Vec3 { + const phi = lat * DEGREES_TO_RADIANS; + const lam = lng * DEGREES_TO_RADIANS; + const cp = Math.cos(phi); + return [cp * Math.cos(lam), cp * Math.sin(lam), Math.sin(phi)]; + } + + /** Convert a unit-sphere position to [lng, lat] in degrees */ + static toLngLat(v: Vec3): [number, number] { + return [ + Math.atan2(v[1], v[0]) * RADIANS_TO_DEGREES, + Math.asin(clamp(v[2], -1, 1)) * RADIANS_TO_DEGREES + ]; + } + + /** North and East tangent vectors at a given (lng, lat) */ + static tangentBasis(lng: number, lat: number): {N: Vec3; E: Vec3} { + const phi = lat * DEGREES_TO_RADIANS; + const lam = lng * DEGREES_TO_RADIANS; + const sp = Math.sin(phi); + const cp = Math.cos(phi); + const sl = Math.sin(lam); + const cl = Math.cos(lam); + return { + N: [-sp * cl, -sp * sl, cp], + E: [-sl, cl, 0] + }; + } + + /** Camera "up" direction on the unit sphere for a given bearing */ + static upVector(lng: number, lat: number, bearing: number): Vec3 { + const {N, E} = Globe.tangentBasis(lng, lat); + const b = bearing * DEGREES_TO_RADIANS; + const cb = Math.cos(b); + const sb = Math.sin(b); + return [N[0] * cb + E[0] * sb, N[1] * cb + E[1] * sb, N[2] * cb + E[2] * sb]; + } + + /** Bearing (degrees) from a camera up vector at a given lng/lat */ + static bearing(upVector: Vec3, lng: number, lat: number): number { + const {N, E} = Globe.tangentBasis(lng, lat); + return Math.atan2(vec3.dot(upVector, E), vec3.dot(upVector, N)) * RADIANS_TO_DEGREES; + } + + /** Camera frame for panning at a given position/bearing */ + static cameraFrame(lng: number, lat: number, bearing: number): CameraFrame { + const position = Globe.toPosition(lng, lat); + const up = Globe.upVector(lng, lat, bearing); + const {N, E} = Globe.tangentBasis(lng, lat); + const b = bearing * DEGREES_TO_RADIANS; + const cb = Math.cos(b); + const sb = Math.sin(b); + const right: Vec3 = [E[0] * cb - N[0] * sb, E[1] * cb - N[1] * sb, E[2] * cb - N[2] * sb]; + return { + position, + up, + axisHorizontal: vec3.cross([], position, right), + axisVertical: vec3.cross([], position, up), + longitude: lng, + latitude: lat, + bearing + }; + } + + /** Angular distance in radians between two lng/lat points (great circle arc) */ + static angularDistance( + a: {longitude: number; latitude: number}, + b: {longitude: number; latitude: number} + ): number { + const pa = Globe.toPosition(a.longitude, a.latitude); + const pb = Globe.toPosition(b.longitude, b.latitude); + return Math.acos(clamp(vec3.dot(pa, pb), -1, 1)); + } + + /** Normalized rotation axis of the great circle between two lng/lat points */ + static greatCircleAxis( + a: {longitude: number; latitude: number}, + b: {longitude: number; latitude: number} + ): Vec3 { + const pa = Globe.toPosition(a.longitude, a.latitude); + const pb = Globe.toPosition(b.longitude, b.latitude); + return vec3.normalize([], vec3.cross([], pa, pb)); + } + + /** Rotate a vector around a unit axis by an angle (radians) using quaternions */ + static rotate(v: Vec3, axis: Vec3, angle: number): Vec3 { + const q = new Quaternion().fromAxisRotation(axis, angle); + return vec3.transformQuat([], v, q) as Vec3; + } + + /** + * Rotate a camera frame by horizontal/vertical angles (radians). + * Returns a new frame with updated position, up, longitude, latitude, + * and bearing. If lockBearing is true, bearing is forced to 0. + */ + static rotateFrame( + frame: CameraFrame, + horizontalAngle: number, + verticalAngle: number, + lockBearing?: boolean + ): CameraFrame { + let position = Globe.rotate(frame.position, frame.axisHorizontal, horizontalAngle); + position = Globe.rotate(position, frame.axisVertical, verticalAngle); + let up = Globe.rotate(frame.up, frame.axisHorizontal, horizontalAngle); + up = Globe.rotate(up, frame.axisVertical, verticalAngle); + + const [longitude, latitude] = Globe.toLngLat(position); + const b = lockBearing ? 0 : Globe.bearing(up, longitude, latitude); + + return { + ...frame, // preserve axes + position, + up, + longitude, + latitude, + bearing: b + }; + } +} + +// Exponential decay easing — models viscous friction on a spinning sphere. +const INERTIA_DECAY = 5; +const INERTIA_NORM = 1 / (1 - Math.exp(-INERTIA_DECAY)); +export const GLOBE_INERTIA_EASING = (t: number) => + (1 - Math.exp(-INERTIA_DECAY * t)) * INERTIA_NORM; + +/** + * Inertia interpolator for the globe. Two modes: + * - Linear (bearing locked): lerps lng/lat, preserves raw target longitude + * to avoid antimeridian reversal. + * - Rotation (free bearing): rigid body spin around a fixed axis with + * bearing tracked via the up vector. + */ +export class GlobeInertiaInterpolator extends TransitionInterpolator { + private _mode: 'linear' | 'rotation'; + private _targetLongitude?: number; + private _axis?: number[]; + private _totalAngle?: number; + private _startFrame!: CameraFrame; + private _startZoom!: number; + + constructor(opts: {targetLongitude: number} | {axis: number[]; totalAngle: number}) { + const isRotation = 'axis' in opts; + super({ + compare: ['longitude', 'latitude'], + extract: isRotation + ? ['longitude', 'latitude', 'zoom', 'bearing'] + : ['longitude', 'latitude', 'zoom'], + required: ['longitude', 'latitude'] + }); + if (isRotation) { + this._mode = 'rotation'; + this._axis = opts.axis; + this._totalAngle = opts.totalAngle; + } else { + this._mode = 'linear'; + this._targetLongitude = opts.targetLongitude; + } + } + + initializeProps( + startProps: Record, + endProps: Record + ): {start: Record; end: Record} { + const result = super.initializeProps(startProps, endProps); + this._startZoom = startProps.zoom; + if (this._mode === 'rotation') { + this._startFrame = { + ...Globe.cameraFrame(startProps.longitude, startProps.latitude, startProps.bearing || 0), + axisHorizontal: this._axis! + }; + } else { + result.end.longitude = this._targetLongitude; + } + return result; + } + + interpolateProps( + startProps: Record, + endProps: Record, + t: number + ): Record { + if (this._mode === 'rotation') { + const {longitude, latitude, bearing} = Globe.rotateFrame( + this._startFrame, + this._totalAngle! * t, + 0 + ); + const zoom = + this._startZoom + zoomAdjust(latitude, true) - zoomAdjust(this._startFrame.latitude, true); + return {bearing, longitude, latitude, zoom}; + } + const longitude = startProps.longitude + (endProps.longitude - startProps.longitude) * t; + const latitude = startProps.latitude + (endProps.latitude - startProps.latitude) * t; + const zoom = + this._startZoom + zoomAdjust(latitude, true) - zoomAdjust(startProps.latitude, true); + return {longitude, latitude, zoom}; + } +} diff --git a/modules/core/src/viewports/globe-viewport.ts b/modules/core/src/viewports/globe-viewport.ts index 578d49530ce..55e37122bbb 100644 --- a/modules/core/src/viewports/globe-viewport.ts +++ b/modules/core/src/viewports/globe-viewport.ts @@ -6,7 +6,6 @@ import {Matrix4} from '@math.gl/core'; import Viewport from './viewport'; import {PROJECTION_MODE} from '../lib/constants'; import {altitudeToFovy, fovyToAltitude} from '@math.gl/web-mercator'; -import {MAX_LATITUDE} from '@math.gl/web-mercator'; import {vec3, vec4} from '@math.gl/core'; @@ -14,6 +13,7 @@ const DEGREES_TO_RADIANS = Math.PI / 180; const RADIANS_TO_DEGREES = 180 / Math.PI; const EARTH_RADIUS = 6370972; export const GLOBE_RADIUS = 256; +import {MAX_LATITUDE} from '@math.gl/web-mercator'; function getDistanceScales() { const unitsPerMeter = GLOBE_RADIUS / EARTH_RADIUS; @@ -44,6 +44,10 @@ export type GlobeViewportOptions = { longitude?: number; /** Latitude in degrees */ latitude?: number; + /** Bearing in degrees. Default `0` */ + bearing?: number; + /** Pitch in degrees. 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 */ @@ -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 @@ -87,8 +95,8 @@ export default class GlobeViewport extends Viewport { let {latitude = 0, height, altitude = 1.5, fovy} = opts; - // Clamp to web mercator limit to prevent bad inputs - latitude = Math.max(Math.min(latitude, MAX_LATITUDE), -MAX_LATITUDE); + // Clamp to valid range + latitude = Math.max(Math.min(latitude, 90), -90); height = height || 1; if (fovy) { @@ -99,15 +107,34 @@ export default class GlobeViewport extends Viewport { // Exagerate distance by latitude to match the Web Mercator distortion // 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)); + // Cap latitude for scale calculation to avoid the singularity at the poles + // where cos(90°)=0 → scale→∞. GlobeController applies the same cap when + // compensating zoom during pan (MAX_LATITUDE). + const scaleLatitude = Math.max(Math.min(latitude, MAX_LATITUDE), -MAX_LATITUDE); + const scale = Math.pow(2, zoom - zoomAdjust(scaleLatitude)); + // 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 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. + // 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, @@ -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; } @@ -249,14 +278,17 @@ export default class GlobeViewport extends Viewport { 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); + latitude = Math.max(Math.min(latitude, 90), -90); const out = {longitude, latitude, zoom: startZoom - zoomAdjust(startLat)}; out.zoom += zoomAdjust(out.latitude); return out; } } -export function zoomAdjust(latitude: number): number { +export function zoomAdjust(latitude: number, clampToPoles?: boolean): number { + if (clampToPoles) { + latitude = Math.max(Math.min(latitude, MAX_LATITUDE), -MAX_LATITUDE); + } const scaleAdjust = Math.PI * Math.cos((latitude * Math.PI) / 180); return Math.log2(scaleAdjust); } diff --git a/test/apps/globe/app.js b/test/apps/globe/app.js index 6361407fa72..f9295b9a336 100644 --- a/test/apps/globe/app.js +++ b/test/apps/globe/app.js @@ -4,6 +4,8 @@ import {Deck, _GlobeView as GlobeView} from '@deck.gl/core'; import {GeoJsonLayer, ArcLayer, ColumnLayer, BitmapLayer, PathLayer} from '@deck.gl/layers'; +import {ResetViewWidget as _ResetViewWidget} from '@deck.gl/widgets'; +import '@deck.gl/widgets/stylesheet.css'; // source: Natural Earth http://www.naturalearthdata.com/ via geojson.xyz const COUNTRIES = @@ -16,7 +18,8 @@ const WORLD_MAP = './map.jpg'; const INITIAL_VIEW_STATE = { latitude: 51.47, longitude: 0.45, - zoom: 0 + minZoom: 1, + zoom: 1 }; const GRATICULES = getGraticules(30); @@ -24,7 +27,7 @@ const GRATICULES = getGraticules(30); export const deck = new Deck({ views: new GlobeView(), initialViewState: INITIAL_VIEW_STATE, - controller: {minZoom: -2}, + controller: {inertia: 500}, parameters: { cull: true }, @@ -118,3 +121,42 @@ function getGraticules(resolution) { // For automated test cases /* global document */ document.body.style.margin = '0px'; + +// Debug overlay +const overlay = document.createElement('div'); +Object.assign(overlay.style, { + position: 'fixed', + top: '10px', + left: '10px', + background: 'rgba(0,0,0,0.7)', + color: '#fff', + padding: '8px 12px', + fontFamily: 'monospace', + fontSize: '13px', + borderRadius: '4px', + zIndex: '1000', + pointerEvents: 'none', + lineHeight: '1.6', + whiteSpace: 'pre' +}); +document.body.appendChild(overlay); + +function updateOverlay(vs) { + const {longitude = 0, latitude = 0, zoom = 0, bearing = 0, pitch = 0} = vs; + overlay.textContent = + `lat: ${latitude.toFixed(2)} lng: ${longitude.toFixed(2)}\n` + + `zoom: ${zoom.toFixed(2)} bearing: ${bearing.toFixed(2)} pitch: ${pitch.toFixed(2)}`; +} +updateOverlay(INITIAL_VIEW_STATE); + +deck.setProps({ + widgets: [ + new _ResetViewWidget({ + placement: 'top-right', + initialViewState: {...INITIAL_VIEW_STATE, transitionDuration: 300} + }) + ], + onViewStateChange: ({viewState}) => { + updateOverlay(viewState); + } +});