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/viewports/globe-viewport.ts b/modules/core/src/viewports/globe-viewport.ts index 55e37122bbb..b59845b5443 100644 --- a/modules/core/src/viewports/globe-viewport.ts +++ b/modules/core/src/viewports/globe-viewport.ts @@ -13,6 +13,8 @@ const DEGREES_TO_RADIANS = Math.PI / 180; const RADIANS_TO_DEGREES = 180 / Math.PI; const EARTH_RADIUS = 6370972; export const GLOBE_RADIUS = 256; +const GLOBE_ZOOM_ANCHOR_DAMPING_START_RATIO = 0.75; +const GLOBE_ZOOM_ANCHOR_MIN_STRENGTH = 0.35; import {MAX_LATITUDE} from '@math.gl/web-mercator'; function getDistanceScales() { @@ -191,6 +193,64 @@ export default class GlobeViewport extends Viewport { ]; } + 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 +267,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 +342,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/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();