diff --git a/docs/api-reference/core/globe-controller.md b/docs/api-reference/core/globe-controller.md index 4cb2ad72ed5..252554bede1 100644 --- a/docs/api-reference/core/globe-controller.md +++ b/docs/api-reference/core/globe-controller.md @@ -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]]` diff --git a/docs/api-reference/core/globe-view.md b/docs/api-reference/core/globe-view.md index 24a41fdf67a..6eadbf9a3eb 100644 --- a/docs/api-reference/core/globe-view.md +++ b/docs/api-reference/core/globe-view.md @@ -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. @@ -72,8 +71,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 diff --git a/modules/core/src/controllers/controller.ts b/modules/core/src/controllers/controller.ts index 455c2ca3393..852648380b7 100644 --- a/modules/core/src/controllers/controller.ts +++ b/modules/core/src/controllers/controller.ts @@ -20,6 +20,7 @@ const NO_TRANSITION_PROPS = { const DEFAULT_INERTIA = 300; const INERTIA_EASING = t => 1 - (1 - t) * (1 - t); +const MAX_PINCH_ZOOM_DELTA_PER_EVENT = 0.18; const EVENT_TYPES = { WHEEL: ['wheel'], @@ -112,6 +113,10 @@ export type ViewStateChangeParameters = { const pinchEventWorkaround: any = {}; +function clampPinchZoomDelta(delta: number): number { + return Math.max(-MAX_PINCH_ZOOM_DELTA_PER_EVENT, Math.min(MAX_PINCH_ZOOM_DELTA_PER_EVENT, delta)); +} + export default abstract class Controller> { abstract get ControllerState(): ConstructorOf; abstract get transition(): TransitionProps; @@ -649,7 +654,7 @@ export default abstract class Controller) => any; } ) { - const {startPanPos, ...mapStateOptions} = options; - mapStateOptions.normalize = false; // disable MapState default normalization - super(mapStateOptions); + const {startPanPos, startPanOnGlobe, ...mapStateOptions} = options; + // Disable MapState's default web-mercator bounds; globe covers the whole earth. + super({...mapStateOptions, normalize: false}); if (startPanPos !== undefined) { (this as any)._state.startPanPos = startPanPos; } + if (startPanOnGlobe !== undefined) { + (this as any)._state.startPanOnGlobe = startPanOnGlobe; + } } panStart({pos}: {pos: [number, number]}): GlobeState { - const {latitude, longitude, zoom} = this.getViewportProps(); + const {longitude, latitude, zoom} = this.getViewportProps(); + const viewport = this.makeViewport(this.getViewportProps()) as GlobeViewport; + const startPanLngLat = unprojectOnGlobe(viewport, pos); + return this._getUpdatedState({ - startPanLngLat: [longitude, latitude], + startPanLngLat: startPanLngLat || [longitude, latitude], startPanPos: pos, + startPanOnGlobe: Boolean(startPanLngLat), 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 viewport = this.makeViewport(this.getViewportProps()) as GlobeViewport; + const startPanOnGlobe = + state.startPanOnGlobe ?? (startPos ? viewport.isPointOnGlobe(startPos) : true); + + if (startPanOnGlobe) { + const startPanLngLat = state.startPanLngLat || unprojectOnGlobe(viewport, startPos); + if (!startPanLngLat) { + return this; + } + return this._getUpdatedState(viewport.panByLngLat(startPanLngLat, pos)) as GlobeState; + } + const startPanPos = state.startPanPos || startPos; + if (!startPanPos) { + return this; + } - const coords = [startPanLngLat[0], startPanLngLat[1], startZoom]; - const viewport = this.makeViewport(this.getViewportProps()); - const newProps = viewport.panByPosition(coords, pos, startPanPos); + const {longitude, latitude, zoom} = this.getViewportProps(); + const startPanLngLat = state.startPanLngLat || [longitude, latitude]; + const startZoom = state.startZoom ?? zoom; + const newProps = viewport.panByPosition( + [startPanLngLat[0], startPanLngLat[1], startZoom], + pos, + startPanPos + ); return this._getUpdatedState(newProps) as GlobeState; } @@ -73,20 +107,45 @@ class GlobeState extends MapState { return this._getUpdatedState({ startPanLngLat: null, startPanPos: null, + startPanOnGlobe: 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({ + pos, + startPos, + scale + }: { + pos: [number, number]; + startPos?: [number, number]; + scale: number; + }): MapState { + let {startZoom, startZoomLngLat} = this.getState(); + const viewport = this.makeViewport(this.getViewportProps()) as GlobeViewport; + + if (!startZoomLngLat) { + startZoom = this.getViewportProps().zoom; + startZoomLngLat = unprojectOnGlobe(viewport, startPos) || unprojectOnGlobe(viewport, 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}) as GlobeViewport; + return this._getUpdatedState({ + zoom, + ...zoomedViewport.panByLngLat(startZoomLngLat, pos) + }); } applyConstraints(props: Required): Required { // 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); @@ -94,6 +153,15 @@ class GlobeState extends MapState { 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]); @@ -175,16 +243,14 @@ export default class GlobeController extends Controller { 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; - } } diff --git a/modules/core/src/viewports/globe-viewport.ts b/modules/core/src/viewports/globe-viewport.ts index 578d49530ce..66ab7ab3ad5 100644 --- a/modules/core/src/viewports/globe-viewport.ts +++ b/modules/core/src/viewports/globe-viewport.ts @@ -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 */ @@ -54,7 +58,7 @@ export type GlobeViewportOptions = { orthographic?: boolean; /** Camera fovy in degrees. If provided, overrides `altitude` */ fovy?: number; - /** Scaler for the near plane, 1 unit equals to the height of the viewport. Default `0.5` */ + /** Scaler for the near plane, 1 unit equals to the height of the viewport. Default `0.01` */ nearZMultiplier?: number; /** Scaler for the far plane, 1 unit equals to the distance from the camera to the edge of the screen. Default `1` */ farZMultiplier?: number; @@ -71,16 +75,20 @@ 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 - nearZMultiplier = 0.5, + // Keep the near plane close enough for terrain flythroughs. + // Higher values clip nearby elevated mesh peaks when the camera is low. + nearZMultiplier = 0.01, farZMultiplier = 1, resolution = 10 } = opts; @@ -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, @@ -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; } @@ -178,19 +207,14 @@ export default class GlobeViewport extends Viewport { } else { // since we don't know the correct projected z value for the point, // unproject two points to get a line and then find the point on that line that intersects with the sphere - const coord0 = transformVector(pixelUnprojectionMatrix, [x, y2, -1, 1]); - const coord1 = transformVector(pixelUnprojectionMatrix, [x, y2, 1, 1]); - - const lt = ((targetZ || 0) / EARTH_RADIUS + 1) * GLOBE_RADIUS; - const lSqr = vec3.sqrLen(vec3.sub([], coord0, coord1)); - const l0Sqr = vec3.sqrLen(coord0); - const l1Sqr = vec3.sqrLen(coord1); - 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); + const {coord0, coord1, lSqr, r0, discriminant} = this._getRaySphereIntersection( + x, + y2, + targetZ + ); + const dr = Math.sqrt(Math.max(0, discriminant)); + const t = (r0 - dr) / Math.sqrt(lSqr); coord = vec3.lerp([], coord0, coord1, t); } const [X, Y, Z] = this.unprojectPosition(coord); @@ -201,6 +225,15 @@ export default class GlobeViewport extends Viewport { return Number.isFinite(targetZ) ? [X, Y, targetZ as number] : [X, Y]; } + isPointOnGlobe( + pixel: number[], + {topLeft = true, targetZ}: {topLeft?: boolean; targetZ?: number} = {} + ): boolean { + const [x, y] = pixel; + const y2 = topLeft ? y : this.height - y; + return this._getRaySphereIntersection(x, y2, targetZ).discriminant >= 0; + } + projectPosition(xyz: number[]): [number, number, number] { const [lng, lat, Z = 0] = xyz; const lambda = lng * DEGREES_TO_RADIANS; @@ -232,27 +265,59 @@ 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 using delta-based movement. + * Used when the pointer starts outside the globe so dragging spins the globe. */ - 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) { + return this.panByLngLat(coords, pixel); + } + + const [startLng, startLat, startZoom] = coords; + // Scale rotation speed inversely with zoom to keep off-globe drags predictable. 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; + const latitude = Math.max( + Math.min(startLat - rotationSpeed * (startPixel[1] - pixel[1]), MAX_LATITUDE), + -MAX_LATITUDE + ); + const zoom = startZoom + zoomAdjust(latitude) - zoomAdjust(startLat); + return {longitude, latitude, zoom}; + } + + /** + * Pan the globe so that a geographic position appears at a given screen pixel. + * Used for on-globe drag-pan and zoom-toward-cursor. + */ + panByLngLat(coords: number[], pixel: number[]): GlobeViewportOptions { + if (!this.isPointOnGlobe(pixel)) { + return {longitude: this.longitude, latitude: this.latitude}; + } + const currentAtPixel = this.unproject(pixel); + const longitude = this.longitude + (coords[0] - currentAtPixel[0]); + const latitude = Math.max( + Math.min(this.latitude + (coords[1] - currentAtPixel[1]), MAX_LATITUDE), + -MAX_LATITUDE + ); + // Adjust zoom for latitude change to maintain consistent visual scale + const zoom = this.zoom + zoomAdjust(latitude) - zoomAdjust(this.latitude); + return {longitude, latitude, zoom}; + } + + private _getRaySphereIntersection(x: number, y: number, targetZ?: number) { + const coord0 = transformVector(this.pixelUnprojectionMatrix, [x, y, -1, 1]); + const coord1 = transformVector(this.pixelUnprojectionMatrix, [x, y, 1, 1]); + const lt = ((targetZ || 0) / EARTH_RADIUS + 1) * GLOBE_RADIUS; + const lSqr = vec3.sqrLen(vec3.sub([], coord0, coord1)); + const l0Sqr = vec3.sqrLen(coord0); + const l1Sqr = vec3.sqrLen(coord1); + const sSqr = (4 * l0Sqr * l1Sqr - (lSqr - l0Sqr - l1Sqr) ** 2) / 16; + const dSqr = (4 * sSqr) / lSqr; + const r0 = Math.sqrt(l0Sqr - dSqr); + + return {coord0, coord1, lSqr, r0, discriminant: lt * lt - dSqr}; } } diff --git a/modules/core/src/views/globe-view.ts b/modules/core/src/views/globe-view.ts index b4b4bf08171..48b5b79c314 100644 --- a/modules/core/src/views/globe-view.ts +++ b/modules/core/src/views/globe-view.ts @@ -4,7 +4,6 @@ import View, {CommonViewState, CommonViewProps} from './view'; import GlobeViewport from '../viewports/globe-viewport'; -import WebMercatorViewport from '../viewports/web-mercator-viewport'; import GlobeController from '../controllers/globe-controller'; import type {Parameters} from '@luma.gl/core'; @@ -19,10 +18,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 */ @@ -53,8 +60,8 @@ export default class GlobeView extends View { }); } - getViewportType(viewState: GlobeViewState) { - return viewState.zoom > 12 ? WebMercatorViewport : GlobeViewport; + getViewportType() { + return GlobeViewport; } get ControllerType() { diff --git a/test/apps/globe/app.js b/test/apps/globe/app.js index 6361407fa72..39015edd4a1 100644 --- a/test/apps/globe/app.js +++ b/test/apps/globe/app.js @@ -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 = @@ -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); @@ -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, @@ -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, diff --git a/test/modules/core/controllers/controllers.spec.ts b/test/modules/core/controllers/controllers.spec.ts index 4b50a2597c1..ef135aa24af 100644 --- a/test/modules/core/controllers/controllers.spec.ts +++ b/test/modules/core/controllers/controllers.spec.ts @@ -35,16 +35,58 @@ test('MapController#inertia', async () => { }); }); +test('MapController does not apply pinch zoom inertia after quick lift', () => { + const makePinchEvent = (type: string, scale: number, deltaTime: number) => ({ + type, + offsetCenter: {x: 50, y: 50}, + scale, + rotation: 0, + deltaTime, + srcEvent: {preventDefault() {}}, + stopPropagation() {} + }); + + for (const {moveScale, endScale} of [ + {moveScale: 0.01, endScale: 0.001}, + {moveScale: 100, endScale: 1000} + ]) { + const controller = createTestController({ + view: new MapView({controller: true}), + initialViewState: { + longitude: -122.45, + latitude: 37.78, + zoom: 10, + pitch: 30, + bearing: -45, + inertia: 300 + } + }); + + controller.handleEvent(makePinchEvent('pinchstart', 1, 0) as any); + controller.handleEvent(makePinchEvent('pinchmove', moveScale, 16) as any); + const zoomAfterMove = controller.props.zoom; + + controller.handleEvent(makePinchEvent('pinchend', endScale, 17) as any); + + expect( + controller.props.zoom, + 'quick two-finger lift should end at the last live pinch zoom' + ).toBeCloseTo(zoomAfterMove); + } +}); + test('GlobeController', async () => { await testController( GlobeView, { 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'] ); }); diff --git a/test/modules/core/controllers/view-states.spec.ts b/test/modules/core/controllers/view-states.spec.ts index b27117a4961..f8e2bea5ea3 100644 --- a/test/modules/core/controllers/view-states.spec.ts +++ b/test/modules/core/controllers/view-states.spec.ts @@ -8,6 +8,7 @@ import { OrbitController, FirstPersonController, _GlobeController as GlobeController, + _GlobeViewport as GlobeViewport, OrbitViewport, OrthographicController, Viewport @@ -111,6 +112,8 @@ test('GlobeViewState', () => { expect(viewportProps.longitude, 'no bounds#longitude is normalized').toBe(178); expect(viewportProps.latitude, 'no bounds#latitude is not changed').toBe(36); expect(viewportProps.zoom, 'no bounds#zoom is not changed').toBe(0); + expect(viewportProps.bearing, 'default bearing is 0').toBe(0); + expect(viewportProps.pitch, 'default pitch is 0').toBe(0); viewState = new GlobeViewState({ width: 800, @@ -173,6 +176,112 @@ test('GlobeViewState', () => { expect(viewportProps.zoom > 12, 'small bounds#zoom is adjusted').toBeTruthy(); }); +test('GlobeViewState#pitch and bearing constraints', () => { + const GlobeViewState = new GlobeController({} as any).ControllerState; + + // Bearing should be normalized to [-180, 180] + let viewState = new GlobeViewState({ + width: 800, + height: 600, + longitude: 0, + latitude: 0, + zoom: 0, + bearing: 270, + makeViewport: dummyMakeViewport + }); + let viewportProps = viewState.getViewportProps(); + expect(viewportProps.bearing, 'bearing is normalized').toBe(-90); + + // Pitch should be clamped to [minPitch, maxPitch] + viewState = new GlobeViewState({ + width: 800, + height: 600, + longitude: 0, + latitude: 0, + zoom: 0, + pitch: 80, + maxPitch: 60, + minPitch: 0, + makeViewport: dummyMakeViewport + }); + viewportProps = viewState.getViewportProps(); + expect(viewportProps.pitch, 'pitch is clamped to maxPitch').toBe(60); + + // Negative pitch should be clamped to minPitch + viewState = new GlobeViewState({ + width: 800, + height: 600, + longitude: 0, + latitude: 0, + zoom: 0, + pitch: -10, + minPitch: 0, + makeViewport: dummyMakeViewport + }); + viewportProps = viewState.getViewportProps(); + expect(viewportProps.pitch, 'pitch is clamped to minPitch').toBe(0); + + // shortestPathFrom should handle bearing wrapping + const viewState1 = new GlobeViewState({ + width: 800, + height: 600, + longitude: 0, + latitude: 0, + zoom: 0, + bearing: 170, + makeViewport: dummyMakeViewport + }); + const viewState2 = new GlobeViewState({ + width: 800, + height: 600, + longitude: 0, + latitude: 0, + zoom: 0, + bearing: -170, + makeViewport: dummyMakeViewport + }); + const transitionProps = viewState2.shortestPathFrom(viewState1); + expect( + Math.abs(transitionProps.bearing - -170) <= 360, + 'shortestPathFrom normalizes bearing' + ).toBeTruthy(); + // The shortest path from 170 to -170 should go through ±180 (20° distance, not 340°) + expect( + Math.abs(transitionProps.bearing - 190) < 1 || Math.abs(transitionProps.bearing - -170) < 1, + 'shortestPathFrom picks shortest rotation for bearing' + ).toBeTruthy(); +}); + +test('GlobeViewState#pan starts outside globe with delta spin', () => { + const GlobeViewState = new GlobeController({} as any).ControllerState; + const makeViewport = (props: any) => new GlobeViewport(props); + const startPos: [number, number] = [0, 0]; + const pos: [number, number] = [100, 0]; + const startProps = { + width: 800, + height: 600, + longitude: 0, + latitude: 0, + zoom: 0, + makeViewport + }; + const viewport = makeViewport(startProps); + + expect(viewport.isPointOnGlobe(startPos), 'test starts off globe').toBe(false); + expect(viewport.isPointOnGlobe([startProps.width / 2, startProps.height / 2])).toBe(true); + + const viewState = new GlobeViewState(startProps); + const pannedState = viewState.panStart({pos: startPos}).pan({pos}); + const viewportProps = pannedState.getViewportProps(); + const rotationSpeed = 0.25 / Math.pow(2, startProps.zoom - Math.log2(Math.PI)); + + expect(viewportProps.longitude, 'off-globe pan uses delta longitude').toBeCloseTo( + rotationSpeed * (startPos[0] - pos[0]) + ); + expect(viewportProps.latitude, 'horizontal off-globe pan keeps latitude').toBe(0); + expect(viewportProps.zoom, 'off-globe pan preserves zoom at the equator').toBe(0); +}); + test('OrbitViewState', () => { const OrbitViewState = new OrbitController({} as any).ControllerState; const makeViewport = (props: any) => new OrbitViewport(props); diff --git a/test/modules/core/viewports/globe-viewport.spec.ts b/test/modules/core/viewports/globe-viewport.spec.ts index b41ce392668..71dfc77941a 100644 --- a/test/modules/core/viewports/globe-viewport.spec.ts +++ b/test/modules/core/viewports/globe-viewport.spec.ts @@ -3,7 +3,7 @@ // Copyright (c) vis.gl contributors import {test, expect} from 'vitest'; -import {_GlobeViewport as GlobeViewport} from '@deck.gl/core'; +import {_GlobeViewport as GlobeViewport, WebMercatorViewport} from '@deck.gl/core'; import {equals, config} from '@math.gl/core'; const TEST_VIEWPORTS = [ @@ -168,6 +168,20 @@ test('GlobeViewport#project, unproject', () => { config.EPSILON = oldEpsilon; }); +test('GlobeViewport#isPointOnGlobe', () => { + const viewport = new GlobeViewport({ + width: 800, + height: 600, + latitude: 0, + longitude: 0, + zoom: 0 + }); + + expect(viewport.isPointOnGlobe([viewport.width / 2, viewport.height / 2])).toBe(true); + expect(viewport.isPointOnGlobe([0, 0])).toBe(false); + expect(viewport.unproject([0, 0]), 'unproject falls back to a surface point').toBeTruthy(); +}); + test('GlobeViewport#getBounds', () => { for (const testCase of TEST_VIEWPORTS) { const bounds = new GlobeViewport(testCase).getBounds(); @@ -175,3 +189,191 @@ test('GlobeViewport#getBounds', () => { expect(bounds[0] < testCase.longitude && bounds[2] > testCase.longitude).toBeTruthy(); } }); + +test('GlobeViewport#bearing', () => { + // Default bearing is 0 + const viewport0 = new GlobeViewport({ + width: 800, + height: 600, + latitude: 38, + longitude: -122, + zoom: 4 + }); + expect(viewport0.bearing).toBe(0); + + // Non-zero bearing + const viewport45 = new GlobeViewport({ + width: 800, + height: 600, + latitude: 38, + longitude: -122, + zoom: 4, + bearing: 45 + }); + expect(viewport45.bearing).toBe(45); + + // Center projection still works with bearing + const screenCenter = viewport45.project([viewport45.longitude, viewport45.latitude, 0]); + expect( + Math.abs(screenCenter[0] - viewport45.width / 2) < 1, + 'viewport center is projected to screen center x with bearing' + ).toBeTruthy(); + expect( + Math.abs(screenCenter[1] - viewport45.height / 2) < 1, + 'viewport center is projected to screen center y with bearing' + ).toBeTruthy(); + + // Negative bearing + const viewportNeg90 = new GlobeViewport({ + width: 800, + height: 600, + latitude: 38, + longitude: -122, + zoom: 4, + bearing: -90 + }); + expect(viewportNeg90.bearing).toBe(-90); +}); + +test('GlobeViewport#pitch', () => { + // Default pitch is 0 + const viewport0 = new GlobeViewport({ + width: 800, + height: 600, + latitude: 38, + longitude: -122, + zoom: 4 + }); + expect(viewport0.pitch).toBe(0); + + // Non-zero pitch — target should still project to screen center + const viewport30 = new GlobeViewport({ + width: 800, + height: 600, + latitude: 38, + longitude: -122, + zoom: 4, + pitch: 30 + }); + expect(viewport30.pitch).toBe(30); + + const screenCenter = viewport30.project([viewport30.longitude, viewport30.latitude, 0]); + expect( + Math.abs(screenCenter[0] - viewport30.width / 2) < 1, + 'viewport center is projected to screen center x with pitch' + ).toBeTruthy(); + expect( + Math.abs(screenCenter[1] - viewport30.height / 2) < 1, + 'viewport center is projected to screen center y with pitch' + ).toBeTruthy(); +}); + +test('GlobeViewport#pitch+bearing', () => { + // Combined pitch and bearing — target should still project to screen center + const viewport = new GlobeViewport({ + width: 800, + height: 600, + latitude: 38, + longitude: -122, + zoom: 4, + pitch: 45, + bearing: 60 + }); + expect(viewport.pitch).toBe(45); + expect(viewport.bearing).toBe(60); + + const screenCenter = viewport.project([viewport.longitude, viewport.latitude, 0]); + expect( + Math.abs(screenCenter[0] - viewport.width / 2) < 1, + 'viewport center is projected to screen center x with pitch+bearing' + ).toBeTruthy(); + expect( + Math.abs(screenCenter[1] - viewport.height / 2) < 1, + 'viewport center is projected to screen center y with pitch+bearing' + ).toBeTruthy(); +}); + +test('GlobeViewport#bearing direction matches WebMercator', () => { + // At low zoom, Globe and WM projections differ significantly, so we compare + // relative cardinal point directions rather than exact coordinates. + // At bearing=90°, East should be at top and North at left — same as WebMercator. + const viewport = new GlobeViewport({ + width: 800, + height: 600, + latitude: 0, + longitude: 0, + zoom: 4, + bearing: 90 + }); + const center = viewport.project([0, 0, 0]); + const north = viewport.project([0, 0.5, 0]); + const east = viewport.project([0.5, 0, 0]); + + // North should be to the left of center (smaller x) + expect(north[0] < center[0], 'bearing=90: north is left of center').toBeTruthy(); + // East should be above center (smaller y in top-left coords) + expect(east[1] < center[1], 'bearing=90: east is above center').toBeTruthy(); + + // Cross-check with WebMercatorViewport + const wmViewport = new WebMercatorViewport({ + width: 800, + height: 600, + latitude: 0, + longitude: 0, + zoom: 4, + bearing: 90 + }); + const wmNorth = wmViewport.project([0, 0.5, 0]); + const wmEast = wmViewport.project([0.5, 0, 0]); + const wmCenter = wmViewport.project([0, 0, 0]); + + // Globe and WM should agree on which side North/East end up + expect( + Math.sign(north[0] - center[0]) === Math.sign(wmNorth[0] - wmCenter[0]), + 'bearing direction matches WM for north x' + ).toBeTruthy(); + expect( + Math.sign(east[1] - center[1]) === Math.sign(wmEast[1] - wmCenter[1]), + 'bearing direction matches WM for east y' + ).toBeTruthy(); +}); + +test('GlobeViewport#pitch=0,bearing=0 matches default', () => { + const oldEpsilon = config.EPSILON; + config.EPSILON = 1e-9; + + // Explicit pitch=0, bearing=0 should produce the same result as omitting them + const viewportDefault = new GlobeViewport({ + width: 800, + height: 600, + latitude: 38, + longitude: -122, + zoom: 4 + }); + const viewportExplicit = new GlobeViewport({ + width: 800, + height: 600, + latitude: 38, + longitude: -122, + zoom: 4, + pitch: 0, + bearing: 0 + }); + + // Both should project the same point identically + const pos = [-122, 38, 0]; + const screen1 = viewportDefault.project(pos); + const screen2 = viewportExplicit.project(pos); + expect(equals(screen1, screen2), 'pitch=0,bearing=0 matches default projection').toBeTruthy(); + + // Nearby point should also match + const nearPos = [-121.9, 38.1, 0]; + const screenNear1 = viewportDefault.project(nearPos); + const screenNear2 = viewportExplicit.project(nearPos); + expect( + equals(screenNear1, screenNear2), + 'pitch=0,bearing=0 matches default for nearby point' + ).toBeTruthy(); + + config.EPSILON = oldEpsilon; +});