diff --git a/docs/api-reference/core/controller.md b/docs/api-reference/core/controller.md index 02abfe3cf6b..71f8988b5ef 100644 --- a/docs/api-reference/core/controller.md +++ b/docs/api-reference/core/controller.md @@ -24,6 +24,7 @@ The base Controller class supports the following options: * `rotateSpeedX` (number) - speed of rotation using shift + left/right arrow keys, in degrees. Default `15`. * `rotateSpeedY` (number) - speed of rotation using shift + up/down arrow keys, in degrees. Default `10`. * `dragMode` (string) - drag behavior without pressing function keys, one of `pan` and `rotate`. +* `zoomAround` (`'center' | 'pointer'`) - zoom anchor mode when supported by the controller. Default depends on the controller. * `inertia` (boolean | number) - Enable inertia after panning/pinching. If a number is provided, indicates the duration of time over which the velocity reduces to zero, in milliseconds. Default `false`. * `maxBounds` (`[min: number[], max: number[]]`) - constrain camera to the specified bounding box. Different type of views may handle this constraint differently. diff --git a/docs/api-reference/core/globe-controller.md b/docs/api-reference/core/globe-controller.md index 5147033b054..47bd19a301f 100644 --- a/docs/api-reference/core/globe-controller.md +++ b/docs/api-reference/core/globe-controller.md @@ -43,6 +43,7 @@ Supports all [Controller options](./controller.md#options) with the following de - `keyboard`: arrow keys to pan, +/- to zoom - `inertia`: when set to a number (milliseconds), the globe continues spinning after a fling gesture with exponential decay - `maxBounds` - constrains the viewport to the specified bounding box `[[minLng, minLat], [maxLng, maxLat]]` +- `zoomAround`: default `'center'`. Set to `'pointer'` to keep the longitude/latitude under the cursor or touch anchor fixed during wheel, pinch, and double-click zoom. If the anchor is off the rendered globe, zoom falls back to center anchoring. ## Custom GlobeController diff --git a/modules/core/src/controllers/controller.ts b/modules/core/src/controllers/controller.ts index e4d281f550e..e536850371c 100644 --- a/modules/core/src/controllers/controller.ts +++ b/modules/core/src/controllers/controller.ts @@ -74,6 +74,8 @@ export type ControllerOptions = { }; /** Drag behavior without pressing function keys, one of `pan` and `rotate`. */ dragMode?: 'pan' | 'rotate'; + /** Zoom anchor, one of `center` and `pointer`. Default depends on the controller. */ + zoomAround?: 'center' | 'pointer'; /** Enable inertia after panning/pinching. If a number is provided, indicates the duration of time over which the velocity reduces to zero, in milliseconds. Default `false`. */ inertia?: boolean | number; /** Bounding box of content that the controller is constrained in */ @@ -291,8 +293,8 @@ export default abstract class Controller) => any; + zoomAround?: GlobeZoomAround; } ) { const { @@ -55,6 +64,7 @@ class GlobeState extends MapState { startPanCameraFrame, startPanAngularRate, startPanLockBearing, + zoomAround, ...mapStateOptions } = options; mapStateOptions.normalize = false; @@ -65,6 +75,10 @@ class GlobeState extends MapState { if (startPanCameraFrame !== undefined) s.startPanCameraFrame = startPanCameraFrame; if (startPanAngularRate !== undefined) s.startPanAngularRate = startPanAngularRate; if (startPanLockBearing !== undefined) s.startPanLockBearing = startPanLockBearing; + // Unlike the transient gesture anchors above, zoomAround is a config option that + // must always carry a value: guarding it with `!== undefined` lets a partial-props + // or HMR reconstruction drop the key, silently reverting pointer zoom to center. + s.zoomAround = zoomAround || 'center'; } panStart({pos}: {pos: [number, number]}): GlobeState { @@ -142,10 +156,59 @@ class GlobeState extends MapState { }) as GlobeState; } - zoom({scale}: {scale: number}): MapState { - const startZoom = this.getState().startZoom || this.getViewportProps().zoom; - const zoom = startZoom + Math.log2(scale); - return this._getUpdatedState({zoom}); + zoomStart({pos}: {pos: [number, number]}): GlobeState { + const startZoomLngLat = this._shouldZoomAroundPointer() + ? this._unprojectZoomAnchor(pos) + : undefined; + + return this._getUpdatedState({ + startZoomLngLat, + startZoom: this.getViewportProps().zoom + }) as GlobeState; + } + + zoom({ + pos, + startPos, + scale + }: { + pos: [number, number]; + startPos?: [number, number]; + scale: number; + }): MapState { + const state = this.getState(); + const {startZoom} = state; + const initialStartZoomLngLat = state.startZoomLngLat; + const hasZoomStart = startZoom !== null && startZoom !== undefined; + const startZoomValue = hasZoomStart ? startZoom : this.getViewportProps().zoom; + const zoom = this._constrainZoom(startZoomValue + Math.log2(scale)); + + if (!this._shouldZoomAroundPointer()) { + return this._getUpdatedState({zoom}); + } + + const startZoomLngLat = + initialStartZoomLngLat ?? + this._unprojectZoomAnchor(startPos) ?? + this._unprojectZoomAnchor(pos); + + if (!startZoomLngLat) { + return this._getUpdatedState({zoom}); + } + + const zoomedViewport = this.makeViewport({...this.getViewportProps(), zoom}); + return this._getUpdatedState({ + zoom, + ...(hasZoomStart && !initialStartZoomLngLat ? {startZoomLngLat} : {}), + ...this._panByZoomAnchor(zoomedViewport, startZoomLngLat, pos) + }); + } + + zoomEnd(): GlobeState { + return this._getUpdatedState({ + startZoomLngLat: null, + startZoom: null + }) as GlobeState; } _panFromCenter(offset: [number, number]): GlobeState { @@ -242,6 +305,37 @@ class GlobeState extends MapState { const zoomAdjustment = zoomAdjust(props.latitude, true) - zoomAdjust(0, true); return clamp(zoom, minZoom + zoomAdjustment, maxZoom + zoomAdjustment); } + + private _unprojectZoomAnchor(pos?: [number, number]): [number, number] | undefined { + if (!pos) { + return undefined; + } + + const viewport = this.makeViewport(this.getViewportProps()); + if ( + viewport instanceof GlobeViewport && + !viewport.isPointOnGlobe(pos, {maxDistanceRatio: GLOBE_ZOOM_ANCHOR_MAX_DISTANCE_RATIO}) + ) { + return undefined; + } + + const lngLat = viewport.unproject(pos); + return [lngLat[0], lngLat[1]]; + } + + private _panByZoomAnchor( + viewport: Viewport, + anchorLngLat: [number, number], + pixel: [number, number] + ): Record { + return viewport instanceof GlobeViewport + ? viewport.panByGlobeAnchor(anchorLngLat, pixel) + : viewport.panByPosition(anchorLngLat, pixel); + } + + private _shouldZoomAroundPointer(): boolean { + return (this.getState() as GlobeStateInternal).zoomAround === 'pointer'; + } } export default class GlobeController extends Controller { @@ -262,6 +356,15 @@ export default class GlobeController extends Controller { // Ring buffer tracking globe position during pan for inertia velocity private _panHistory: Array<{longitude: number; latitude: number; timestamp: number}> = []; + protected _getTransitionProps(opts?: any) { + if (opts?.around && this.props.zoomAround !== 'pointer') { + const centerZoomOpts = {...opts}; + delete centerZoomOpts.around; + return super._getTransitionProps(centerZoomOpts); + } + return super._getTransitionProps(opts); + } + protected _onPanStart(event: MjolnirGestureEvent): boolean { this._panHistory = []; return super._onPanStart(event); diff --git a/modules/core/src/transitions/linear-interpolator.ts b/modules/core/src/transitions/linear-interpolator.ts index 431c3e02dbc..bf6f7e67ad7 100644 --- a/modules/core/src/transitions/linear-interpolator.ts +++ b/modules/core/src/transitions/linear-interpolator.ts @@ -5,9 +5,8 @@ import TransitionInterpolator from './transition-interpolator'; import {lerp} from '@math.gl/core'; -import log from '../utils/log'; import type Viewport from '../viewports/viewport'; -import GlobeViewport from '../viewports/globe-viewport'; +import GlobeViewport, {GLOBE_ZOOM_ANCHOR_MAX_DISTANCE_RATIO} from '../viewports/globe-viewport'; const DEFAULT_PROPS = ['longitude', 'latitude', 'zoom', 'bearing', 'pitch']; const DEFAULT_REQUIRED_PROPS = ['longitude', 'latitude', 'zoom']; @@ -15,6 +14,7 @@ const DEFAULT_REQUIRED_PROPS = ['longitude', 'latitude', 'zoom']; type PropsWithAnchor = { around?: number[]; aroundPosition?: number[]; + aroundLngLat?: number[]; [key: string]: any; }; @@ -78,11 +78,27 @@ export default class LinearInterpolator extends TransitionInterpolator { const {makeViewport, around} = this.opts; if (makeViewport && around) { - const TestViewport = makeViewport(startProps); - if (TestViewport instanceof GlobeViewport) { - log.warn('around not supported in GlobeView')(); + const startViewport = makeViewport(startProps); + if (startViewport instanceof GlobeViewport) { + // GlobeViewport uses spherical anchoring: unproject the screen point + // to a lng/lat on the globe and feed that to panByGlobeAnchor each + // frame. If the click is off-globe, fall through to a plain LERP. + if ( + startViewport.isPointOnGlobe(around, { + maxDistanceRatio: GLOBE_ZOOM_ANCHOR_MAX_DISTANCE_RATIO + }) + ) { + const endViewport = makeViewport(endProps); + const aroundLngLat = startViewport.unproject(around); + result.start.around = around; + Object.assign(result.end, { + around: endViewport.project(aroundLngLat), + aroundLngLat, + width: endProps.width, + height: endProps.height + }); + } } else { - const startViewport = makeViewport(startProps); const endViewport = makeViewport(endProps); const aroundPosition = startViewport.unproject(around); result.start.around = around; @@ -108,17 +124,31 @@ export default class LinearInterpolator extends TransitionInterpolator { propsInTransition[key] = lerp(startProps[key] || 0, endProps[key] || 0, t); } - if (endProps.aroundPosition && this.opts.makeViewport) { + if (this.opts.makeViewport && (endProps.aroundPosition || endProps.aroundLngLat)) { // Linear transition should be performed in common space const viewport = this.opts.makeViewport({...endProps, ...propsInTransition}); - Object.assign( - propsInTransition, - viewport.panByPosition( - endProps.aroundPosition, - // anchor point in current screen coordinates - lerp(startProps.around as number[], endProps.around as number[], t) as number[] - ) - ); + const anchorScreen = lerp( + startProps.around as number[], + endProps.around as number[], + t + ) as number[]; + + if (viewport instanceof GlobeViewport && endProps.aroundLngLat) { + Object.assign( + propsInTransition, + viewport.panByGlobeAnchor(endProps.aroundLngLat, anchorScreen) + ); + } else if (endProps.aroundLngLat) { + Object.assign( + propsInTransition, + viewport.panByPosition(endProps.aroundLngLat, anchorScreen) + ); + } else if (endProps.aroundPosition) { + Object.assign( + propsInTransition, + viewport.panByPosition(endProps.aroundPosition, anchorScreen) + ); + } } return propsInTransition; } diff --git a/modules/core/src/viewports/globe-viewport.ts b/modules/core/src/viewports/globe-viewport.ts index 55e37122bbb..ffcfab24273 100644 --- a/modules/core/src/viewports/globe-viewport.ts +++ b/modules/core/src/viewports/globe-viewport.ts @@ -2,18 +2,24 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import {Matrix4} from '@math.gl/core'; +import {Matrix4, vec3, vec4} from '@math.gl/core'; +import {altitudeToFovy, fovyToAltitude, MAX_LATITUDE} from '@math.gl/web-mercator'; import Viewport from './viewport'; import {PROJECTION_MODE} from '../lib/constants'; -import {altitudeToFovy, fovyToAltitude} from '@math.gl/web-mercator'; - -import {vec3, vec4} from '@math.gl/core'; const DEGREES_TO_RADIANS = Math.PI / 180; const RADIANS_TO_DEGREES = 180 / Math.PI; const EARTH_RADIUS = 6370972; export const GLOBE_RADIUS = 256; -import {MAX_LATITUDE} from '@math.gl/web-mercator'; +// Where along the screen-pixel-to-globe-center distance ratio the anchored +// zoom starts losing strength. Below this ratio the anchor pins exactly; from +// here to the limb (ratio = 1) the anchor blends toward MIN_STRENGTH so a +// near-edge pixel doesn't snap the camera across the globe. +const GLOBE_ZOOM_ANCHOR_DAMPING_START_RATIO = 0.75; +const GLOBE_ZOOM_ANCHOR_MIN_STRENGTH = 0.35; +// Allow a small grace band outside the rendered sphere so pointer zoom does not +// immediately fall back to center anchoring when the cursor grazes the limb. +export const GLOBE_ZOOM_ANCHOR_MAX_DISTANCE_RATIO = 1.15; function getDistanceScales() { const unitsPerMeter = GLOBE_RADIUS / EARTH_RADIUS; @@ -191,6 +197,70 @@ export default class GlobeViewport extends Viewport { ]; } + /** + * Builds the screen-pixel → globe-center ray and the intermediate ray/sphere + * math reused by `unproject` (intersection point) and the public hit-test + * helpers (`isPointOnGlobe`, `panByGlobeAnchor`). One function so the same + * pixelUnprojectionMatrix work isn't duplicated. + */ + private _getRayToGlobe( + xy: number[], + {topLeft = true, targetZ}: {topLeft?: boolean; targetZ?: number} = {} + ): { + coord0: number[]; + coord1: number[]; + radius: number; + rayLengthSquared: number; + coord0LengthSquared: number; + distanceToCenterSquared: number; + } { + const [x, y] = xy; + const y2 = topLeft ? y : this.height - y; + const {pixelUnprojectionMatrix} = this; + + const coord0 = transformVector(pixelUnprojectionMatrix, [x, y2, -1, 1]); + const coord1 = transformVector(pixelUnprojectionMatrix, [x, y2, 1, 1]); + + const radius = ((targetZ || 0) / EARTH_RADIUS + 1) * GLOBE_RADIUS; + const rayLengthSquared = vec3.sqrLen(vec3.sub([], coord0, coord1)); + const coord0LengthSquared = vec3.sqrLen(coord0); + const coord1LengthSquared = vec3.sqrLen(coord1); + const triangleAreaSquared = + (4 * coord0LengthSquared * coord1LengthSquared - + (rayLengthSquared - coord0LengthSquared - coord1LengthSquared) ** 2) / + 16; + const distanceToCenterSquared = (4 * triangleAreaSquared) / rayLengthSquared; + + return { + coord0, + coord1, + radius, + rayLengthSquared, + coord0LengthSquared, + distanceToCenterSquared + }; + } + + private _getRayDistanceToGlobeCenterRatio( + xy: number[], + options?: {topLeft?: boolean; targetZ?: number} + ): number { + const {distanceToCenterSquared, radius} = this._getRayToGlobe(xy, options); + + return Math.sqrt(Math.max(0, distanceToCenterSquared)) / radius; + } + + isPointOnGlobe( + xy: number[], + { + topLeft = true, + targetZ, + maxDistanceRatio = 1 + }: {topLeft?: boolean; targetZ?: number; maxDistanceRatio?: number} = {} + ): boolean { + return this._getRayDistanceToGlobeCenterRatio(xy, {topLeft, targetZ}) <= maxDistanceRatio; + } + unproject( xyz: number[], {topLeft = true, targetZ}: {topLeft?: boolean; targetZ?: number} = {} @@ -207,18 +277,17 @@ 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, + radius, + rayLengthSquared, + coord0LengthSquared, + distanceToCenterSquared + } = this._getRayToGlobe(xyz, {topLeft, targetZ}); + const r0 = Math.sqrt(coord0LengthSquared - distanceToCenterSquared); + const dr = Math.sqrt(Math.max(0, radius * radius - distanceToCenterSquared)); + const t = (r0 - dr) / Math.sqrt(rayLengthSquared); coord = vec3.lerp([], coord0, coord1, t); } @@ -283,6 +352,35 @@ export default class GlobeViewport extends Viewport { out.zoom += zoomAdjust(out.latitude); return out; } + + /** + * Pan the globe so that a known geographic point remains under a screen pixel. + * Used for cursor/touch-anchored zoom when the pointer is on the globe surface. + */ + panByGlobeAnchor(anchorLngLat: number[], pixel: number[]): GlobeViewportOptions { + const distanceRatio = this._getRayDistanceToGlobeCenterRatio(pixel); + if (distanceRatio > GLOBE_ZOOM_ANCHOR_MAX_DISTANCE_RATIO) { + return {longitude: this.longitude, latitude: this.latitude}; + } + + const currentAtPixel = this.unproject(pixel); + const edgeProgress = Math.max( + 0, + Math.min( + 1, + (distanceRatio - GLOBE_ZOOM_ANCHOR_DAMPING_START_RATIO) / + (1 - GLOBE_ZOOM_ANCHOR_DAMPING_START_RATIO) + ) + ); + const anchorStrength = 1 - edgeProgress * (1 - GLOBE_ZOOM_ANCHOR_MIN_STRENGTH); + const longitude = this.longitude + (anchorLngLat[0] - currentAtPixel[0]) * anchorStrength; + const latitude = Math.max( + Math.min(this.latitude + (anchorLngLat[1] - currentAtPixel[1]) * anchorStrength, 90), + -90 + ); + + return {longitude, latitude}; + } } export function zoomAdjust(latitude: number, clampToPoles?: boolean): number { diff --git a/test/apps/globe/app.js b/test/apps/globe/app.js index f9295b9a336..84c516e153e 100644 --- a/test/apps/globe/app.js +++ b/test/apps/globe/app.js @@ -2,6 +2,8 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors +/* global document, localStorage */ + import {Deck, _GlobeView as GlobeView} from '@deck.gl/core'; import {GeoJsonLayer, ArcLayer, ColumnLayer, BitmapLayer, PathLayer} from '@deck.gl/layers'; import {ResetViewWidget as _ResetViewWidget} from '@deck.gl/widgets'; @@ -22,12 +24,17 @@ const INITIAL_VIEW_STATE = { zoom: 1 }; +const ZOOM_AROUND_STORAGE_KEY = 'deckgl-test-app-globe-zoom-around'; + +let currentViewState = {...INITIAL_VIEW_STATE}; +let zoomAround = getInitialZoomAround(); + const GRATICULES = getGraticules(30); export const deck = new Deck({ views: new GlobeView(), initialViewState: INITIAL_VIEW_STATE, - controller: {inertia: 500}, + controller: {inertia: 500, zoomAround}, parameters: { cull: true }, @@ -73,7 +80,7 @@ export const deck = new Deck({ filled: true, pointRadiusMinPixels: 2, pointRadiusScale: 2000, - getRadius: f => 11 - f.properties.scalerank, + getPointRadius: f => 11 - f.properties.scalerank, getFillColor: [200, 0, 80, 180], // Interactive props pickable: true, @@ -119,7 +126,6 @@ function getGraticules(resolution) { } // For automated test cases -/* global document */ document.body.style.margin = '0px'; // Debug overlay @@ -141,22 +147,120 @@ Object.assign(overlay.style, { }); document.body.appendChild(overlay); +const controls = document.createElement('div'); +controls.innerHTML = ` +
Zoom anchor
+
+ + +
+
+ + +
+`; +Object.assign(controls.style, { + position: 'fixed', + top: '10px', + right: '10px', + background: 'rgba(0,0,0,0.72)', + color: '#fff', + padding: '10px', + fontFamily: 'system-ui, sans-serif', + fontSize: '12px', + borderRadius: '4px', + zIndex: '1000', + lineHeight: '1.4' +}); +for (const row of controls.querySelectorAll('.button-row')) { + Object.assign(row.style, { + display: 'flex', + gap: '6px', + marginTop: '6px' + }); +} +for (const button of controls.querySelectorAll('button')) { + Object.assign(button.style, { + padding: '5px 8px', + border: '1px solid rgba(255,255,255,0.35)', + borderRadius: '3px', + background: 'rgba(255,255,255,0.12)', + color: '#fff', + cursor: 'pointer' + }); +} +document.body.appendChild(controls); + function updateOverlay(vs) { const {longitude = 0, latitude = 0, zoom = 0, bearing = 0, pitch = 0} = vs; overlay.textContent = `lat: ${latitude.toFixed(2)} lng: ${longitude.toFixed(2)}\n` + - `zoom: ${zoom.toFixed(2)} bearing: ${bearing.toFixed(2)} pitch: ${pitch.toFixed(2)}`; + `zoom: ${zoom.toFixed(2)} bearing: ${bearing.toFixed(2)} pitch: ${pitch.toFixed(2)}\n` + + `zoomAround: ${zoomAround}`; } updateOverlay(INITIAL_VIEW_STATE); +function setViewState(nextViewState) { + currentViewState = {...currentViewState, ...nextViewState}; + deck.setProps({viewState: currentViewState}); + updateOverlay(currentViewState); +} + +function setZoomAround(nextZoomAround) { + if (!isZoomAroundMode(nextZoomAround)) { + return; + } + zoomAround = nextZoomAround; + deck.setProps({controller: {inertia: 500, zoomAround}}); + try { + localStorage.setItem(ZOOM_AROUND_STORAGE_KEY, zoomAround); + } catch { + // Ignore storage failures in sandboxed test apps. + } + for (const button of controls.querySelectorAll('[data-zoom-around]')) { + button.setAttribute('aria-pressed', String(button.dataset.zoomAround === zoomAround)); + button.style.background = + button.dataset.zoomAround === zoomAround ? 'rgba(70,120,255,0.85)' : 'rgba(255,255,255,0.12)'; + } + updateOverlay(currentViewState); +} + +function isZoomAroundMode(value) { + return value === 'center' || value === 'pointer'; +} + +function getInitialZoomAround() { + try { + const storedZoomAround = localStorage.getItem(ZOOM_AROUND_STORAGE_KEY); + return isZoomAroundMode(storedZoomAround) ? storedZoomAround : 'center'; + } catch { + return 'center'; + } +} + +controls.addEventListener('click', event => { + const button = event.target.closest('button'); + if (!button) { + return; + } + if (button.dataset.zoomAround) { + setZoomAround(button.dataset.zoomAround); + } else if (button.dataset.viewPreset === 'reset') { + setViewState(INITIAL_VIEW_STATE); + } else if (button.dataset.viewPreset === 'high-zoom') { + setViewState({...currentViewState, zoom: 13}); + } +}); +setZoomAround(zoomAround); + deck.setProps({ widgets: [ new _ResetViewWidget({ - placement: 'top-right', + placement: 'bottom-right', initialViewState: {...INITIAL_VIEW_STATE, transitionDuration: 300} }) ], onViewStateChange: ({viewState}) => { - updateOverlay(viewState); + setViewState(viewState); } }); diff --git a/test/modules/core/controllers/controllers.spec.ts b/test/modules/core/controllers/controllers.spec.ts index 01393fa3bc0..9f6ad77dc19 100644 --- a/test/modules/core/controllers/controllers.spec.ts +++ b/test/modules/core/controllers/controllers.spec.ts @@ -110,6 +110,165 @@ test('GlobeController', async () => { ); }); +test('GlobeController supports pointer anchored zoom option', () => { + const makeController = (controller: true | {zoomAround: 'center' | 'pointer'}) => + createTestController({ + view: new GlobeView({controller}), + initialViewState: { + longitude: 0, + latitude: 0, + zoom: 1 + } + }); + + const makeWheelEvent = () => ({ + type: 'wheel', + offsetCenter: {x: 75, y: 50}, + delta: -10, + srcEvent: {preventDefault() {}}, + stopPropagation() {} + }); + + const centerZoomController = makeController(true); + const pointerZoomController = makeController({zoomAround: 'pointer'}); + + centerZoomController.handleEvent(makeWheelEvent() as any); + pointerZoomController.handleEvent(makeWheelEvent() as any); + + expect(centerZoomController.props.longitude, 'center zoom preserves longitude').toBeCloseTo(0); + expect(pointerZoomController.props.longitude, 'pointer zoom adjusts longitude').not.toBeCloseTo( + 0 + ); +}); + +test('GlobeController applies updated zoomAround option', () => { + const controller = createTestController({ + view: new GlobeView({controller: {zoomAround: 'center'}}), + initialViewState: { + longitude: 0, + latitude: 0, + zoom: 1 + } + }); + + const wheelEvent = { + type: 'wheel', + offsetCenter: {x: 75, y: 50}, + delta: -10, + srcEvent: {preventDefault() {}}, + stopPropagation() {} + }; + + controller.handleEvent(wheelEvent as any); + expect(controller.props.longitude, 'center zoom preserves longitude').toBeCloseTo(0); + + controller.setProps({...controller.props, zoomAround: 'pointer'}); + controller.handleEvent(wheelEvent as any); + expect(controller.props.longitude, 'updated pointer zoom adjusts longitude').not.toBeCloseTo(0); +}); + +test('GlobeController keeps pointer anchored zoom after constrained pan', () => { + const controller = createTestController({ + view: new GlobeView({controller: {zoomAround: 'pointer'}}), + initialViewState: { + width: 800, + height: 600, + longitude: 30, + latitude: 20, + zoom: 1 + } + }); + + const makeGestureEvent = (type: string, x: number, y: number) => ({ + type, + offsetCenter: {x, y}, + delta: -10, + deltaX: 0, + deltaY: 0, + srcEvent: {preventDefault() {}}, + stopPropagation() {} + }); + + controller.handleEvent(makeGestureEvent('panstart', 400, 300) as any); + controller.handleEvent(makeGestureEvent('panmove', 400, 0) as any); + controller.handleEvent(makeGestureEvent('panend', 400, 0) as any); + + const longitudeAfterPan = controller.props.longitude; + const latitudeAfterPan = controller.props.latitude; + expect(latitudeAfterPan, 'pan reached the constrained latitude').toBeLessThan(-80); + + controller.handleEvent(makeGestureEvent('wheel', 500, 300) as any); + + expect( + controller.props.longitude, + 'pointer zoom after constrained pan still adjusts longitude' + ).not.toBeCloseTo(longitudeAfterPan); + expect( + controller.props.latitude, + 'pointer zoom after constrained pan still adjusts latitude' + ).not.toBeCloseTo(latitudeAfterPan); +}); + +test('GlobeController keeps pointer anchored zoom above GlobeViewport zoom range', () => { + const controller = createTestController({ + view: new GlobeView({controller: {zoomAround: 'pointer'}}), + initialViewState: { + width: 800, + height: 600, + longitude: 0, + latitude: 0, + zoom: 13 + } + }); + + const wheelEvent = { + type: 'wheel', + offsetCenter: {x: 500, y: 300}, + delta: 10, + srcEvent: {preventDefault() {}}, + stopPropagation() {} + }; + + expect( + () => controller.handleEvent(wheelEvent as any), + 'pointer zoom falls back to WebMercator anchoring at high zoom' + ).not.toThrow(); + expect( + Math.abs(controller.props.longitude), + 'high zoom pointer zoom adjusts longitude' + ).toBeGreaterThan(1e-5); +}); + +test('GlobeController omits transition anchor when zoomAround is center', () => { + const centerZoomController = createTestController({ + view: new GlobeView({controller: {zoomAround: 'center'}}), + initialViewState: { + longitude: 0, + latitude: 0, + zoom: 1 + } + }); + + const pointerZoomController = createTestController({ + view: new GlobeView({controller: {zoomAround: 'pointer'}}), + initialViewState: { + longitude: 0, + latitude: 0, + zoom: 1 + } + }); + + const centerTransitionAround = (centerZoomController as any)._getTransitionProps({ + around: [75, 50] + }).transitionInterpolator.opts.around; + const pointerTransitionAround = (pointerZoomController as any)._getTransitionProps({ + around: [75, 50] + }).transitionInterpolator.opts.around; + + expect(centerTransitionAround, 'center mode does not transition around pointer').toBeUndefined(); + expect(pointerTransitionAround, 'pointer mode keeps transition anchor').toEqual([75, 50]); +}); + test('OrbitController', async () => { await testController(OrbitView, { orbitAxis: 'Y', diff --git a/test/modules/core/controllers/view-states.spec.ts b/test/modules/core/controllers/view-states.spec.ts index b27117a4961..e2af25e8f93 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 @@ -173,6 +174,36 @@ test('GlobeViewState', () => { expect(viewportProps.zoom > 12, 'small bounds#zoom is adjusted').toBeTruthy(); }); +test('GlobeViewState recovers pointer anchored zoom when zoomStart misses the globe', () => { + const GlobeViewState = new GlobeController({} as any).ControllerState; + const makeViewport = (props: any) => new GlobeViewport(props); + + const pos: [number, number] = [500, 300]; + const missedStartPos: [number, number] = [0, 0]; + const startProps = { + width: 800, + height: 600, + longitude: 0, + latitude: 0, + zoom: 2, + zoomAround: 'pointer', + makeViewport + }; + const anchor = makeViewport(startProps).unproject(pos); + const zoomed = new GlobeViewState(startProps) + .zoomStart({pos: missedStartPos}) + .zoom({pos, scale: 1.25}) + .getViewportProps(); + const anchorPosition = makeViewport(zoomed).project(anchor); + + expect( + makeViewport(startProps).isPointOnGlobe(missedStartPos), + 'test starts outside the globe surface' + ).toBe(false); + expect(anchorPosition[0], 'recovered pointer anchor keeps cursor x fixed').toBeCloseTo(pos[0]); + expect(anchorPosition[1], 'recovered pointer anchor keeps cursor y fixed').toBeCloseTo(pos[1]); +}); + test('OrbitViewState', () => { const OrbitViewState = new OrbitController({} as any).ControllerState; const makeViewport = (props: any) => new OrbitViewport(props); diff --git a/test/modules/core/transitions/linear-interpolator.spec.ts b/test/modules/core/transitions/linear-interpolator.spec.ts index cb5bd0b4c03..ec42e25f100 100644 --- a/test/modules/core/transitions/linear-interpolator.spec.ts +++ b/test/modules/core/transitions/linear-interpolator.spec.ts @@ -4,6 +4,8 @@ import {test, expect} from 'vitest'; import LinearInterpolator from '@deck.gl/core/transitions/linear-interpolator'; +import GlobeViewport from '@deck.gl/core/viewports/globe-viewport'; +import WebMercatorViewport from '@deck.gl/core/viewports/web-mercator-viewport'; const TEST_CASES = [ { @@ -86,3 +88,89 @@ test('LinearInterpolator#interpolateProps', () => { }); }); }); + +test('LinearInterpolator anchors transitions on GlobeViewport', () => { + const makeViewport = (props: Record) => new GlobeViewport(props); + const startProps = {width: 800, height: 600, longitude: 0, latitude: 0, zoom: 2}; + const endProps = {width: 800, height: 600, longitude: 0, latitude: 0, zoom: 3}; + // Pick a screen point offset from center so anchoring measurably shifts lng/lat. + const around: [number, number] = [500, 250]; + + const interpolator = new LinearInterpolator({ + transitionProps: {compare: ['longitude', 'latitude', 'zoom'], required: ['zoom']}, + around, + makeViewport + }); + + const {start, end} = interpolator.initializeProps(startProps, endProps); + + expect(end.aroundLngLat, 'unprojects the anchor to a lng/lat on the globe').toBeDefined(); + expect(end.aroundPosition, 'does not fall back to the planar anchor path').toBeUndefined(); + expect(start.around, 'records the start anchor screen point').toEqual(around); + expect(end.around, 'records the anchor screen point in the end viewport').toBeDefined(); + + const propsAtHalf = interpolator.interpolateProps(start, end, 0.5); + expect( + propsAtHalf.longitude, + 'longitude shifts during the transition to keep the anchor pinned' + ).not.toBeCloseTo(0); + + const propsAtEnd = interpolator.interpolateProps(start, end, 1); + expect(propsAtEnd.longitude, 'transition ends at the requested longitude').toBeCloseTo( + endProps.longitude + ); + expect(propsAtEnd.latitude, 'transition ends at the requested latitude').toBeCloseTo( + endProps.latitude + ); +}); + +test('LinearInterpolator keeps globe anchor when transition crosses to WebMercatorViewport', () => { + const makeViewport = (props: Record) => + props.zoom > 12 ? new WebMercatorViewport(props) : new GlobeViewport(props); + const startProps = {width: 800, height: 600, longitude: 0, latitude: 0, zoom: 11.9}; + const endProps = {width: 800, height: 600, longitude: 0, latitude: 0, zoom: 12.5}; + const around: [number, number] = [500, 250]; + + const interpolator = new LinearInterpolator({ + transitionProps: {compare: ['longitude', 'latitude', 'zoom'], required: ['zoom']}, + around, + makeViewport + }); + + const {start, end} = interpolator.initializeProps(startProps, endProps); + + expect(end.aroundLngLat, 'records the spherical anchor from the globe start').toBeDefined(); + expect(end.aroundPosition, 'does not switch to a separate planar anchor').toBeUndefined(); + + const propsAtHalf = interpolator.interpolateProps(start, end, 0.5); + expect( + Math.abs(propsAtHalf.longitude), + 'WebMercator fallback keeps adjusting longitude around the anchor' + ).toBeGreaterThan(1e-5); + + const propsAtEnd = interpolator.interpolateProps(start, end, 1); + expect(propsAtEnd.longitude, 'transition still ends at requested longitude').toBeCloseTo( + endProps.longitude + ); + expect(propsAtEnd.latitude, 'transition still ends at requested latitude').toBeCloseTo( + endProps.latitude + ); +}); + +test('LinearInterpolator falls back to a plain LERP when the GlobeView anchor is off-globe', () => { + const makeViewport = (props: Record) => new GlobeViewport(props); + const startProps = {width: 800, height: 600, longitude: 0, latitude: 0, zoom: 1}; + const endProps = {width: 800, height: 600, longitude: 0, latitude: 0, zoom: 3}; + // Corner of the canvas misses the globe at zoom 1 with these dimensions. + const around: [number, number] = [0, 0]; + + const interpolator = new LinearInterpolator({ + transitionProps: {compare: ['longitude', 'latitude', 'zoom'], required: ['zoom']}, + around, + makeViewport + }); + + const {start, end} = interpolator.initializeProps(startProps, endProps); + expect(end.aroundLngLat, 'no anchor when the screen point misses the globe').toBeUndefined(); + expect(start.around, 'no anchor recorded on the start frame either').toBeUndefined(); +}); diff --git a/test/modules/core/viewports/globe-viewport.spec.ts b/test/modules/core/viewports/globe-viewport.spec.ts index b41ce392668..520242a76eb 100644 --- a/test/modules/core/viewports/globe-viewport.spec.ts +++ b/test/modules/core/viewports/globe-viewport.spec.ts @@ -3,7 +3,9 @@ // Copyright (c) vis.gl contributors import {test, expect} from 'vitest'; -import {_GlobeViewport as GlobeViewport} from '@deck.gl/core'; +import GlobeViewport, { + GLOBE_ZOOM_ANCHOR_MAX_DISTANCE_RATIO +} from '@deck.gl/core/viewports/globe-viewport'; import {equals, config} from '@math.gl/core'; const TEST_VIEWPORTS = [ @@ -168,6 +170,57 @@ test('GlobeViewport#project, unproject', () => { config.EPSILON = oldEpsilon; }); +test('GlobeViewport#isPointOnGlobe', () => { + const viewport = new GlobeViewport({ + width: 800, + height: 600, + latitude: 0, + longitude: 0, + zoom: 1 + }); + + expect( + viewport.isPointOnGlobe([viewport.width / 2, viewport.height / 2]), + 'screen center intersects the globe' + ).toBe(true); + expect(viewport.isPointOnGlobe([0, 0]), 'corner misses the globe').toBe(false); +}); + +test('GlobeViewport#isPointOnGlobe supports a zoom anchor grace band', () => { + const viewport = new GlobeViewport({ + width: 1280, + height: 720, + latitude: 20, + longitude: 30, + zoom: 0 + }); + const pixelNearLimb = [725, 360]; + + expect( + viewport.isPointOnGlobe(pixelNearLimb), + 'pixel just outside the rendered globe misses the exact globe' + ).toBe(false); + expect( + viewport.isPointOnGlobe(pixelNearLimb, { + maxDistanceRatio: GLOBE_ZOOM_ANCHOR_MAX_DISTANCE_RATIO + }), + 'pixel just outside the rendered globe is accepted as a zoom anchor' + ).toBe(true); + + const anchor = viewport.unproject(pixelNearLimb); + const zoomedViewport = new GlobeViewport({ + width: 1280, + height: 720, + latitude: 20, + longitude: 30, + zoom: 1 + }); + const anchoredProps = zoomedViewport.panByGlobeAnchor(anchor, pixelNearLimb); + expect(anchoredProps.longitude, 'near-limb anchor adjusts longitude').not.toBeCloseTo( + zoomedViewport.longitude + ); +}); + test('GlobeViewport#getBounds', () => { for (const testCase of TEST_VIEWPORTS) { const bounds = new GlobeViewport(testCase).getBounds();