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 f99b16a92f9..f246e66037c 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. @@ -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 diff --git a/modules/core/src/controllers/globe-controller.ts b/modules/core/src/controllers/globe-controller.ts index 60532e9ce3b..7a28c5886f1 100644 --- a/modules/core/src/controllers/globe-controller.ts +++ b/modules/core/src/controllers/globe-controller.ts @@ -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'; @@ -27,66 +26,53 @@ 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) => any; - } + options: MapStateProps & { + makeViewport: (props: Record) => 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): 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 +80,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 +170,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..3e27c40948d 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 */ @@ -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 @@ -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; } @@ -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); @@ -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}; } } diff --git a/modules/core/src/views/globe-view.ts b/modules/core/src/views/globe-view.ts index 148d1eb2f1c..65371a87704 100644 --- a/modules/core/src/views/globe-view.ts +++ b/modules/core/src/views/globe-view.ts @@ -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 */ 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..a2ebb2950f8 100644 --- a/test/modules/core/controllers/controllers.spec.ts +++ b/test/modules/core/controllers/controllers.spec.ts @@ -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'] ); }); diff --git a/test/modules/core/controllers/view-states.spec.ts b/test/modules/core/controllers/view-states.spec.ts index b27117a4961..502d7c265ce 100644 --- a/test/modules/core/controllers/view-states.spec.ts +++ b/test/modules/core/controllers/view-states.spec.ts @@ -111,6 +111,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 +175,82 @@ 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('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..965f629f63d 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 = [ @@ -175,3 +175,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; +});