diff --git a/modules/core/src/controllers/controller.ts b/modules/core/src/controllers/controller.ts index 455c2ca3393..fd7d499ade6 100644 --- a/modules/core/src/controllers/controller.ts +++ b/modules/core/src/controllers/controller.ts @@ -64,6 +64,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 */ diff --git a/modules/core/src/controllers/globe-controller.ts b/modules/core/src/controllers/globe-controller.ts index 8993b2ffee7..bc3f1805ff1 100644 --- a/modules/core/src/controllers/globe-controller.ts +++ b/modules/core/src/controllers/globe-controller.ts @@ -9,7 +9,7 @@ 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 GlobeViewport, {zoomAdjust, GLOBE_RADIUS} from '../viewports/globe-viewport'; import { Globe, type CameraFrame, @@ -35,12 +35,15 @@ function pixelsToDegrees(pixels: number, zoom: number = 0): number { return radians * RADIANS_TO_DEGREES; } +type GlobeZoomAround = 'center' | 'pointer'; + type GlobeStateInternal = MapStateInternal & { startPanPos?: [number, number]; startPanCameraFrame?: CameraFrame; startPanAngularRate?: number; /** When true, bearing is held fixed during pan (north stays up) */ startPanLockBearing?: boolean; + zoomAround?: GlobeZoomAround; }; class GlobeState extends MapState { @@ -48,6 +51,7 @@ class GlobeState extends MapState { options: MapStateProps & GlobeStateInternal & { makeViewport: (props: Record) => any; + zoomAround?: GlobeZoomAround; } ) { const { @@ -55,6 +59,7 @@ class GlobeState extends MapState { startPanCameraFrame, startPanAngularRate, startPanLockBearing, + zoomAround, ...mapStateOptions } = options; mapStateOptions.normalize = false; @@ -65,6 +70,7 @@ class GlobeState extends MapState { if (startPanCameraFrame !== undefined) s.startPanCameraFrame = startPanCameraFrame; if (startPanAngularRate !== undefined) s.startPanAngularRate = startPanAngularRate; if (startPanLockBearing !== undefined) s.startPanLockBearing = startPanLockBearing; + if (zoomAround !== undefined) s.zoomAround = zoomAround; } panStart({pos}: {pos: [number, number]}): GlobeState { @@ -142,10 +148,57 @@ 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._unprojectOnGlobe(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; + let {startZoomLngLat} = state; + const hasZoomStart = startZoom !== undefined; + const startZoomValue = (startZoom as number) ?? this.getViewportProps().zoom; + const zoom = this._constrainZoom(startZoomValue + Math.log2(scale)); + + if (!this._shouldZoomAroundPointer()) { + return this._getUpdatedState({zoom}); + } + + if (!startZoomLngLat && !hasZoomStart) { + startZoomLngLat = this._unprojectOnGlobe(startPos) || this._unprojectOnGlobe(pos); + } + + if (!startZoomLngLat) { + return this._getUpdatedState({zoom}); + } + + const zoomedViewport = this.makeViewport({...this.getViewportProps(), zoom}) as GlobeViewport; + return this._getUpdatedState({ + zoom, + ...zoomedViewport.panByGlobeAnchor(startZoomLngLat, pos) + }); + } + + zoomEnd(): GlobeState { + return this._getUpdatedState({ + startZoomLngLat: null, + startZoom: null + }) as GlobeState; } _panFromCenter(offset: [number, number]): GlobeState { @@ -242,6 +295,24 @@ class GlobeState extends MapState { const zoomAdjustment = zoomAdjust(props.latitude, true) - zoomAdjust(0, true); return clamp(zoom, minZoom + zoomAdjustment, maxZoom + zoomAdjustment); } + + private _unprojectOnGlobe(pos?: [number, number]): [number, number] | undefined { + if (!pos) { + return undefined; + } + + const viewport = this.makeViewport(this.getViewportProps()) as GlobeViewport; + if (!viewport.isPointOnGlobe(pos)) { + return undefined; + } + + const lngLat = viewport.unproject(pos); + return [lngLat[0], lngLat[1]]; + } + + private _shouldZoomAroundPointer(): boolean { + return (this.getState() as GlobeStateInternal).zoomAround === 'pointer'; + } } export default class GlobeController extends Controller { diff --git a/modules/core/src/transitions/linear-interpolator.ts b/modules/core/src/transitions/linear-interpolator.ts index 431c3e02dbc..f39d7f14760 100644 --- a/modules/core/src/transitions/linear-interpolator.ts +++ b/modules/core/src/transitions/linear-interpolator.ts @@ -5,7 +5,6 @@ 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'; @@ -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,22 @@ 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)) { + const aroundLngLat = startViewport.unproject(around); + result.start.around = around; + Object.assign(result.end, { + around, + 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 +119,26 @@ 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.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..860a678ddfa 100644 --- a/modules/core/src/viewports/globe-viewport.ts +++ b/modules/core/src/viewports/globe-viewport.ts @@ -2,18 +2,21 @@ // 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; function getDistanceScales() { const unitsPerMeter = GLOBE_RADIUS / EARTH_RADIUS; @@ -191,6 +194,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 +274,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 +349,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 > 1) { + 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/modules/core/controllers/controllers.spec.ts b/test/modules/core/controllers/controllers.spec.ts index 4b50a2597c1..ffc135fd0aa 100644 --- a/test/modules/core/controllers/controllers.spec.ts +++ b/test/modules/core/controllers/controllers.spec.ts @@ -48,6 +48,37 @@ test('GlobeController', async () => { ); }); +test('GlobeController supports pointer anchored zoom option', () => { + const makeController = (controller: true | {zoomAround: '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('OrbitController', async () => { await testController(OrbitView, { orbitAxis: 'Y', diff --git a/test/modules/core/transitions/linear-interpolator.spec.ts b/test/modules/core/transitions/linear-interpolator.spec.ts index cb5bd0b4c03..877013d4e38 100644 --- a/test/modules/core/transitions/linear-interpolator.spec.ts +++ b/test/modules/core/transitions/linear-interpolator.spec.ts @@ -4,6 +4,7 @@ import {test, expect} from 'vitest'; import LinearInterpolator from '@deck.gl/core/transitions/linear-interpolator'; +import GlobeViewport from '@deck.gl/core/viewports/globe-viewport'; const TEST_CASES = [ { @@ -86,3 +87,47 @@ 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(end.around, 'records the anchor screen point').toEqual(around); + + const propsAtHalf = interpolator.interpolateProps(start, end, 0.5); + expect( + propsAtHalf.longitude, + 'longitude shifts during the transition to keep the anchor pinned' + ).not.toBeCloseTo(0); +}); + +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..e92aaaec5ac 100644 --- a/test/modules/core/viewports/globe-viewport.spec.ts +++ b/test/modules/core/viewports/globe-viewport.spec.ts @@ -168,6 +168,22 @@ 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#getBounds', () => { for (const testCase of TEST_VIEWPORTS) { const bounds = new GlobeViewport(testCase).getBounds();