From 25fb61983c4d50e7e3e683bf24730591fddbb86f Mon Sep 17 00:00:00 2001 From: charlieforward9 Date: Sun, 19 Apr 2026 14:58:07 -0400 Subject: [PATCH 1/7] feat(core): GlobeView pitch, bearing, and cursor-anchored zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add camera tilt (pitch) and rotation (bearing) to GlobeView, matching the Google Maps/Earth interaction model. Uses a lookAt-based view matrix that composes pitch and bearing rotations while keeping the target lng/lat at screen center. Scroll-wheel zoom now targets the mouse cursor position instead of screen center (matching MapView). Pan reuses MapState's grabbed-lng/lat logic so the point under the cursor stays under the cursor through the drag — previously GlobeState's delta-pan produced wrong on-screen speed and yanked the center at zoom > 12 where WebMercatorViewport takes over rendering. Unproject for rays that miss the globe surface returns a plausible fallback rather than null. Changes: - GlobeViewport: lookAt view matrix with pitch/bearing, cursor-anchored zoom, ray/sphere miss fallback - GlobeController: dragRotate/touchRotate, pitch/bearing constraints, inherits pan from MapState - GlobeView: pitch/bearing/minPitch/maxPitch view state props - Tests for pitch/bearing transitions and viewport math --- docs/api-reference/core/globe-controller.md | 4 +- docs/api-reference/core/globe-view.md | 5 +- .../core/src/controllers/globe-controller.ts | 109 +++++----- modules/core/src/viewports/globe-viewport.ts | 90 ++++++--- modules/core/src/views/globe-view.ts | 8 + test/apps/globe/app.js | 51 +++-- .../core/controllers/controllers.spec.ts | 8 +- .../core/controllers/view-states.spec.ts | 78 +++++++ .../core/viewports/globe-viewport.spec.ts | 190 +++++++++++++++++- 9 files changed, 436 insertions(+), 107 deletions(-) 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/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 b4b4bf08171..7b633f2fbc2 100644 --- a/modules/core/src/views/globe-view.ts +++ b/modules/core/src/views/globe-view.ts @@ -19,10 +19,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; +}); From 557b7961c330981eecdeb23cbcc273ae9891efe9 Mon Sep 17 00:00:00 2001 From: Felix Palmer Date: Tue, 21 Apr 2026 15:37:58 +0200 Subject: [PATCH 2/7] Reinstate zoomAdjust --- modules/core/src/viewports/globe-viewport.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/core/src/viewports/globe-viewport.ts b/modules/core/src/viewports/globe-viewport.ts index 3e27c40948d..4ef7dad17a4 100644 --- a/modules/core/src/viewports/globe-viewport.ts +++ b/modules/core/src/viewports/globe-viewport.ts @@ -284,7 +284,9 @@ export default class GlobeViewport extends Viewport { Math.min(this.latitude + (coords[1] - currentAtPixel[1]), MAX_LATITUDE), -MAX_LATITUDE ); - return {longitude, latitude}; + // Adjust zoom for latitude change to maintain consistent visual scale + const zoom = this.zoom + zoomAdjust(latitude) - zoomAdjust(this.latitude); + return {longitude, latitude, zoom}; } } From 44aedf84f65fdafb429617a614afb2b6d46f7db1 Mon Sep 17 00:00:00 2001 From: charlieforward9 Date: Wed, 22 Apr 2026 13:05:58 -0400 Subject: [PATCH 3/7] fix(core): preserve GlobeView off-globe pan --- .../core/src/controllers/globe-controller.ts | 95 ++++++++++++++++--- modules/core/src/viewports/globe-viewport.ts | 86 +++++++++++++---- .../core/controllers/view-states.spec.ts | 31 ++++++ .../core/viewports/globe-viewport.spec.ts | 15 +++ 4 files changed, 199 insertions(+), 28 deletions(-) diff --git a/modules/core/src/controllers/globe-controller.ts b/modules/core/src/controllers/globe-controller.ts index 7a28c5886f1..d2cf1b69ebb 100644 --- a/modules/core/src/controllers/globe-controller.ts +++ b/modules/core/src/controllers/globe-controller.ts @@ -6,9 +6,11 @@ import {clamp} from '@math.gl/core'; 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 type GlobeViewport from '../viewports/globe-viewport'; import {MAX_LATITUDE} from '@math.gl/web-mercator'; @@ -26,18 +28,88 @@ function pixelsToDegrees(pixels: number, zoom: number = 0): number { return radians * RADIANS_TO_DEGREES; } +type GlobeStateInternal = MapStateInternal & { + startPanPos?: [number, number]; + startPanOnGlobe?: boolean; +}; + +function unprojectOnGlobe( + viewport: GlobeViewport, + pos?: [number, number] +): [number, number] | undefined { + const lngLat = pos && viewport.unproject(pos, {fallback: false}); + return lngLat ? [lngLat[0], lngLat[1]] : undefined; +} + class GlobeState extends MapState { constructor( - options: MapStateProps & { - makeViewport: (props: Record) => any; - } + options: MapStateProps & + GlobeStateInternal & { + makeViewport: (props: Record) => any; + } ) { + const {startPanPos, startPanOnGlobe, ...mapStateOptions} = options; // 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}); + 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 {longitude, latitude, zoom} = this.getViewportProps(); + const viewport = this.makeViewport(this.getViewportProps()) as GlobeViewport; + const startPanLngLat = unprojectOnGlobe(viewport, pos); + + return this._getUpdatedState({ + 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 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 {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; + } + + panEnd(): GlobeState { + return this._getUpdatedState({ + startPanLngLat: null, + startPanPos: null, + startPanOnGlobe: null, + startZoom: null + }) as GlobeState; } zoom({ @@ -50,10 +122,11 @@ class GlobeState extends MapState { scale: number; }): MapState { let {startZoom, startZoomLngLat} = this.getState(); + const viewport = this.makeViewport(this.getViewportProps()) as GlobeViewport; if (!startZoomLngLat) { startZoom = this.getViewportProps().zoom; - startZoomLngLat = this._unproject(startPos) || this._unproject(pos); + startZoomLngLat = unprojectOnGlobe(viewport, startPos) || unprojectOnGlobe(viewport, pos); } const zoom = this._constrainZoom((startZoom as number) + Math.log2(scale)); @@ -63,10 +136,10 @@ class GlobeState extends MapState { return this._getUpdatedState({zoom}); } - const zoomedViewport = this.makeViewport({...this.getViewportProps(), zoom}); + const zoomedViewport = this.makeViewport({...this.getViewportProps(), zoom}) as GlobeViewport; return this._getUpdatedState({ zoom, - ...zoomedViewport.panByPosition(startZoomLngLat, pos) + ...zoomedViewport.panByLngLat(startZoomLngLat, pos) }); } diff --git a/modules/core/src/viewports/globe-viewport.ts b/modules/core/src/viewports/globe-viewport.ts index 4ef7dad17a4..b8ab93a73b2 100644 --- a/modules/core/src/viewports/globe-viewport.ts +++ b/modules/core/src/viewports/globe-viewport.ts @@ -191,10 +191,19 @@ export default class GlobeViewport extends Viewport { ]; } + unproject(xyz: number[], options?: {topLeft?: boolean; targetZ?: number}): number[]; unproject( xyz: number[], - {topLeft = true, targetZ}: {topLeft?: boolean; targetZ?: number} = {} - ): number[] { + options: {topLeft?: boolean; targetZ?: number; fallback: false} + ): number[] | null; + unproject( + xyz: number[], + { + topLeft = true, + targetZ, + fallback = true + }: {topLeft?: boolean; targetZ?: number; fallback?: boolean} = {} + ): number[] | null { const [x, y, z] = xyz; const y2 = topLeft ? y : this.height - y; @@ -207,23 +216,21 @@ 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 discriminant = lt * lt - dSqr; + const {coord0, coord1, lSqr, r0, discriminant} = this._getRaySphereIntersection( + x, + y2, + targetZ + ); if (discriminant < 0) { + if (!fallback) { + return null; + } // 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); + const lt = ((targetZ || 0) / EARTH_RADIUS + 1) * GLOBE_RADIUS; coord = len > 0 ? vec3.scale([], closest, lt / len) : [0, 0, lt]; } else { const dr = Math.sqrt(discriminant); @@ -239,6 +246,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; @@ -269,13 +285,35 @@ export default class GlobeViewport extends Viewport { return xyz as [number, number]; } + /** + * Pan the globe using delta-based movement. + * Used when the pointer starts outside the globe so dragging spins the globe. + */ + 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]); + 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. - * Shifts center by (coords - unproject(pixel)) — i.e. keeps the grabbed lng/lat - * under the cursor. Used for drag-pan and zoom-toward-cursor. + * Used for on-globe drag-pan and zoom-toward-cursor. */ - panByPosition(coords: number[], pixel: number[]): GlobeViewportOptions { - const currentAtPixel = this.unproject(pixel); + panByLngLat(coords: number[], pixel: number[]): GlobeViewportOptions { + const currentAtPixel = this.unproject(pixel, {fallback: false}); if (!currentAtPixel) { return {longitude: this.longitude, latitude: this.latitude}; } @@ -288,6 +326,20 @@ export default class GlobeViewport extends Viewport { 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}; + } } export function zoomAdjust(latitude: number): number { diff --git a/test/modules/core/controllers/view-states.spec.ts b/test/modules/core/controllers/view-states.spec.ts index 502d7c265ce..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 @@ -251,6 +252,36 @@ test('GlobeViewState#pitch and bearing constraints', () => { ).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 965f629f63d..d080746009e 100644 --- a/test/modules/core/viewports/globe-viewport.spec.ts +++ b/test/modules/core/viewports/globe-viewport.spec.ts @@ -168,6 +168,21 @@ 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(); + expect(viewport.unproject([0, 0], {fallback: false}), 'fallback can be disabled').toBeNull(); +}); + test('GlobeViewport#getBounds', () => { for (const testCase of TEST_VIEWPORTS) { const bounds = new GlobeViewport(testCase).getBounds(); From 161c7734563c79ed4d2baa7c22d685a69528c4e2 Mon Sep 17 00:00:00 2001 From: charlieforward9 Date: Tue, 28 Apr 2026 15:51:37 -0400 Subject: [PATCH 4/7] fix(core): avoid GlobeView terrain clipping --- modules/core/src/viewports/globe-viewport.ts | 8 ++++---- modules/core/src/views/globe-view.ts | 5 ++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/modules/core/src/viewports/globe-viewport.ts b/modules/core/src/viewports/globe-viewport.ts index b8ab93a73b2..87a5cc63fc7 100644 --- a/modules/core/src/viewports/globe-viewport.ts +++ b/modules/core/src/viewports/globe-viewport.ts @@ -58,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; @@ -86,9 +86,9 @@ export default class GlobeViewport extends Viewport { 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; diff --git a/modules/core/src/views/globe-view.ts b/modules/core/src/views/globe-view.ts index 7b633f2fbc2..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'; @@ -61,8 +60,8 @@ export default class GlobeView extends View { }); } - getViewportType(viewState: GlobeViewState) { - return viewState.zoom > 12 ? WebMercatorViewport : GlobeViewport; + getViewportType() { + return GlobeViewport; } get ControllerType() { From b9a21064a7a3a0b24537364af6110c25d0e4eb5e Mon Sep 17 00:00:00 2001 From: charlieforward9 Date: Wed, 29 Apr 2026 09:20:35 -0400 Subject: [PATCH 5/7] fix(core): smooth mobile pinch zoom --- modules/core/src/controllers/controller.ts | 30 ++++++++++++++--- .../core/controllers/controllers.spec.ts | 33 +++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/modules/core/src/controllers/controller.ts b/modules/core/src/controllers/controller.ts index 455c2ca3393..bf094d5010a 100644 --- a/modules/core/src/controllers/controller.ts +++ b/modules/core/src/controllers/controller.ts @@ -20,6 +20,8 @@ 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 MAX_PINCH_INERTIA_ZOOM_DELTA = 0.35; const EVENT_TYPES = { WHEEL: ['wheel'], @@ -112,6 +114,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; @@ -650,6 +656,8 @@ export default abstract class Controller { }); }); +test('MapController clamps noisy pinch zoom-out frames', () => { + const controller = createTestController({ + view: new MapView({controller: true}), + initialViewState: { + longitude: -122.45, + latitude: 37.78, + zoom: 10, + pitch: 30, + bearing: -45, + inertia: 300 + } + }); + + const makePinchEvent = (type: string, scale: number, deltaTime: number) => ({ + type, + offsetCenter: {x: 50, y: 50}, + scale, + rotation: 0, + deltaTime, + srcEvent: {preventDefault() {}}, + stopPropagation() {} + }); + + controller.handleEvent(makePinchEvent('pinchstart', 1, 0) as any); + controller.handleEvent(makePinchEvent('pinchmove', 0.01, 16) as any); + controller.handleEvent(makePinchEvent('pinchend', 0.001, 17) as any); + + expect( + controller.props.zoom, + 'a noisy pinch-out should not jump more than the smoothed move plus capped inertia' + ).toBeGreaterThanOrEqual(9.47); +}); + test('GlobeController', async () => { await testController( GlobeView, From 1173cbbc13c44b5da9266aceec3de2b6b96a9b5d Mon Sep 17 00:00:00 2001 From: charlieforward9 Date: Thu, 30 Apr 2026 15:24:24 -0400 Subject: [PATCH 6/7] fix(core): disable pinch zoom inertia --- modules/core/src/controllers/controller.ts | 55 +++---------------- .../core/controllers/controllers.spec.ts | 49 ++++++++++------- 2 files changed, 35 insertions(+), 69 deletions(-) diff --git a/modules/core/src/controllers/controller.ts b/modules/core/src/controllers/controller.ts index bf094d5010a..852648380b7 100644 --- a/modules/core/src/controllers/controller.ts +++ b/modules/core/src/controllers/controller.ts @@ -21,7 +21,6 @@ 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 MAX_PINCH_INERTIA_ZOOM_DELTA = 0.35; const EVENT_TYPES = { WHEEL: ['wheel'], @@ -655,9 +654,7 @@ export default abstract class Controller { }); }); -test('MapController clamps noisy pinch zoom-out frames', () => { - const controller = createTestController({ - view: new MapView({controller: true}), - initialViewState: { - longitude: -122.45, - latitude: 37.78, - zoom: 10, - pitch: 30, - bearing: -45, - inertia: 300 - } - }); - +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}, @@ -58,14 +46,33 @@ test('MapController clamps noisy pinch zoom-out frames', () => { stopPropagation() {} }); - controller.handleEvent(makePinchEvent('pinchstart', 1, 0) as any); - controller.handleEvent(makePinchEvent('pinchmove', 0.01, 16) as any); - controller.handleEvent(makePinchEvent('pinchend', 0.001, 17) as any); - - expect( - controller.props.zoom, - 'a noisy pinch-out should not jump more than the smoothed move plus capped inertia' - ).toBeGreaterThanOrEqual(9.47); + 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 () => { From 30297f26e12384a31d57e43daaa31477338ce02c Mon Sep 17 00:00:00 2001 From: charlieforward9 Date: Mon, 11 May 2026 23:31:11 -0400 Subject: [PATCH 7/7] fix(core): keep GlobeViewport unproject API minimal --- .../core/src/controllers/globe-controller.ts | 2 +- modules/core/src/viewports/globe-viewport.ts | 35 ++++--------------- .../core/viewports/globe-viewport.spec.ts | 1 - 3 files changed, 8 insertions(+), 30 deletions(-) diff --git a/modules/core/src/controllers/globe-controller.ts b/modules/core/src/controllers/globe-controller.ts index d2cf1b69ebb..65966624d0c 100644 --- a/modules/core/src/controllers/globe-controller.ts +++ b/modules/core/src/controllers/globe-controller.ts @@ -37,7 +37,7 @@ function unprojectOnGlobe( viewport: GlobeViewport, pos?: [number, number] ): [number, number] | undefined { - const lngLat = pos && viewport.unproject(pos, {fallback: false}); + const lngLat = pos && viewport.isPointOnGlobe(pos) && viewport.unproject(pos); return lngLat ? [lngLat[0], lngLat[1]] : undefined; } diff --git a/modules/core/src/viewports/globe-viewport.ts b/modules/core/src/viewports/globe-viewport.ts index 87a5cc63fc7..66ab7ab3ad5 100644 --- a/modules/core/src/viewports/globe-viewport.ts +++ b/modules/core/src/viewports/globe-viewport.ts @@ -191,19 +191,10 @@ export default class GlobeViewport extends Viewport { ]; } - unproject(xyz: number[], options?: {topLeft?: boolean; targetZ?: number}): number[]; unproject( xyz: number[], - options: {topLeft?: boolean; targetZ?: number; fallback: false} - ): number[] | null; - unproject( - xyz: number[], - { - topLeft = true, - targetZ, - fallback = true - }: {topLeft?: boolean; targetZ?: number; fallback?: boolean} = {} - ): number[] | null { + {topLeft = true, targetZ}: {topLeft?: boolean; targetZ?: number} = {} + ): number[] { const [x, y, z] = xyz; const y2 = topLeft ? y : this.height - y; @@ -222,21 +213,9 @@ export default class GlobeViewport extends Viewport { targetZ ); - if (discriminant < 0) { - if (!fallback) { - return null; - } - // 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); - const lt = ((targetZ || 0) / EARTH_RADIUS + 1) * GLOBE_RADIUS; - 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 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); @@ -313,10 +292,10 @@ export default class GlobeViewport extends Viewport { * Used for on-globe drag-pan and zoom-toward-cursor. */ panByLngLat(coords: number[], pixel: number[]): GlobeViewportOptions { - const currentAtPixel = this.unproject(pixel, {fallback: false}); - if (!currentAtPixel) { + 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), diff --git a/test/modules/core/viewports/globe-viewport.spec.ts b/test/modules/core/viewports/globe-viewport.spec.ts index d080746009e..71dfc77941a 100644 --- a/test/modules/core/viewports/globe-viewport.spec.ts +++ b/test/modules/core/viewports/globe-viewport.spec.ts @@ -180,7 +180,6 @@ test('GlobeViewport#isPointOnGlobe', () => { 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(); - expect(viewport.unproject([0, 0], {fallback: false}), 'fallback can be disabled').toBeNull(); }); test('GlobeViewport#getBounds', () => {