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
62 changes: 46 additions & 16 deletions modules/core/src/controllers/globe-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// 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';
Expand Down Expand Up @@ -77,23 +77,55 @@ 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;
}): MapState {
let {startZoom, startZoomLngLat} = this.getState();

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

const zoom = this._constrainZoom((startZoom as number) + Math.log2(scale));

if (!startZoomLngLat) {
// Cursor is off the globe — fall back to center zoom
return this._getUpdatedState({zoom});
}

const zoomedViewport = this.makeViewport({...this.getViewportProps(), zoom});
return this._getUpdatedState({
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 +207,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;
}
}
103 changes: 81 additions & 22 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,23 +270,44 @@ 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.
*
* Two modes:
* - **Anchor mode** (no `startPixel`): adjust center so `coords` appears at `pixel`.
* Used by zoom-toward-cursor.
* - **Delta mode** (`startPixel` provided): pan by the screen-space delta between
* `startPixel` and `pixel`. Used by drag-pan.
*/
panByPosition(
[startLng, startLat, startZoom]: number[],
pixel: number[],
startPixel: number[]
): GlobeViewportOptions {
// Scale rotation speed inversely with zoom, to approximate constant panning speed
panByPosition(coords: number[], pixel: number[], startPixel?: number[]): GlobeViewportOptions {
if (!startPixel) {
// Anchor mode: adjust center so coords[lng,lat] lands at pixel
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};
}

// Delta mode: pan by screen-space delta
const [startLng, startLat, startZoom] = coords;
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]);
const dx = startPixel[0] - pixel[0];
const dy = startPixel[1] - pixel[1];

// Rotate screen-space delta by bearing to get geo-space delta
const bearingRad = this.bearing * DEGREES_TO_RADIANS;
const cosB = Math.cos(bearingRad);
const sinB = Math.sin(bearingRad);

const longitude = startLng + rotationSpeed * (dx * cosB - dy * sinB);
let latitude = startLat - rotationSpeed * (dx * sinB + dy * cosB);
latitude = Math.max(Math.min(latitude, MAX_LATITUDE), -MAX_LATITUDE);
const out = {longitude, latitude, zoom: startZoom - zoomAdjust(startLat)};
out.zoom += zoomAdjust(out.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
51 changes: 38 additions & 13 deletions test/apps/globe/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@
// Copyright (c) vis.gl contributors

import {Deck, _GlobeView as GlobeView} from '@deck.gl/core';
import {GeoJsonLayer, ArcLayer, ColumnLayer, BitmapLayer, PathLayer} from '@deck.gl/layers';
import {
GeoJsonLayer,
ArcLayer,
ColumnLayer,
BitmapLayer,
PathLayer,
SolidPolygonLayer
} from '@deck.gl/layers';

// source: Natural Earth http://www.naturalearthdata.com/ via geojson.xyz
const COUNTRIES =
Expand All @@ -16,7 +23,9 @@ const WORLD_MAP = './map.jpg';
const INITIAL_VIEW_STATE = {
latitude: 51.47,
longitude: 0.45,
zoom: 0
zoom: 0,
pitch: 30,
bearing: 0
};

const GRATICULES = getGraticules(30);
Expand All @@ -29,6 +38,23 @@ export const deck = new Deck({
cull: true
},
layers: [
new SolidPolygonLayer({
id: 'earth-surface',
data: [
[
[-180, 90],
[0, 90],
[180, 90],
[180, -90],
[0, -90],
[-180, -90]
]
],
getPolygon: d => d,
stroked: false,
filled: true,
getFillColor: [20, 20, 40]
}),
new BitmapLayer({
id: 'base-map-raster',
image: WORLD_MAP,
Expand All @@ -52,17 +78,16 @@ export const deck = new Deck({
getLineColor: [60, 60, 60],
getFillColor: [200, 200, 200]
}),
new ColumnLayer({
id: 'airports-extruded',
data: AIR_PORTS,
dataTransform: geojson => geojson.features,
// Styles
radius: 10000,
extruded: true,
getPosition: f => f.geometry.coordinates,
getElevation: f => f.properties.scalerank * 100000,
getFillColor: [200, 0, 80, 180]
}),
// new ColumnLayer({
// id: 'airports-extruded',
// data: AIR_PORTS,
// dataTransform: geojson => geojson.features,
// radius: 10000,
// extruded: true,
// getPosition: f => f.geometry.coordinates,
// getElevation: f => f.properties.scalerank * 100000,
// getFillColor: [200, 0, 80, 180]
// }),
new GeoJsonLayer({
id: 'airports',
data: AIR_PORTS,
Expand Down
8 changes: 5 additions & 3 deletions test/modules/core/controllers/controllers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,12 @@ test('GlobeController', async () => {
{
longitude: -122.45,
latitude: 37.78,
zoom: 0
zoom: 0,
bearing: 0,
pitch: 0
},
// GlobeView cannot be rotated
['pan#function key', 'pinch', 'multipan']
// GlobeView uses globe-specific zoom (no mouse position), so pinch behaves differently
['pinch']
);
});

Expand Down
Loading
Loading