Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5ef53f5
feat(core): add GlobeView pointer zoom option
charlieforward9 May 16, 2026
5b988fe
feat(core): anchor LinearInterpolator transitions on GlobeViewport
charlieforward9 May 22, 2026
9495132
refactor(core): tidy GlobeViewport anchor helpers
charlieforward9 May 22, 2026
9f1d80b
fix(core): apply updated GlobeView zoom anchor option
charlieforward9 Jun 6, 2026
c9a70ad
fix(core): stabilize GlobeView pointer zoom
charlieforward9 Jun 10, 2026
10efe94
fix(core): address globe zoom review feedback
charlieforward9 Jun 11, 2026
4b98253
fix(core): recover globe pointer zoom anchors
charlieforward9 Jun 13, 2026
4179907
fix(core): always set GlobeState.zoomAround (fix HMR pointer-zoom dro…
charlieforward9 Jun 16, 2026
ce2b6ee
Merge branch 'master' into codex/globe-anchored-zoom
charlieforward9 Jul 16, 2026
8450a17
Merge remote-tracking branch 'upstream/master' into codex/pr-10385-sh…
charlieforward9 Aug 24, 2026
8f81051
refactor(core): share pointer zoom policy across controllers
charlieforward9 Aug 24, 2026
ece9795
fix(test-app): use current point radius accessor
charlieforward9 Aug 24, 2026
e8bbdf1
Merge shared zoom anchor base for stacked review
charlieforward9 Aug 24, 2026
27fb924
refactor(core): clarify globe anchor projection math
charlieforward9 Aug 24, 2026
b39f5f4
Merge latest shared zoom anchor base
charlieforward9 Aug 24, 2026
ac03464
Merge remote-tracking branch 'upstream/codex/shared-controller-zoom-a…
charlieforward9 Aug 26, 2026
8d771b0
fix(core): center globe zoom outside visible sphere
charlieforward9 Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/api-reference/core/controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
charlieforward9 marked this conversation as resolved.
Outdated
* `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.

Expand Down
1 change: 1 addition & 0 deletions docs/api-reference/core/globe-controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions modules/core/src/controllers/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,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 */
Expand Down Expand Up @@ -254,8 +256,8 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
get controllerState(): ControllerState {
this._controllerState = this._controllerState || new this.ControllerState({
makeViewport: this.makeViewport,
...this.props,
...this.state
...this.state,
...this.props
});
return this._controllerState;
}
Expand Down
113 changes: 108 additions & 5 deletions modules/core/src/controllers/globe-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ 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 Viewport from '../viewports/viewport';
import GlobeViewport, {
zoomAdjust,
GLOBE_RADIUS,
GLOBE_ZOOM_ANCHOR_MAX_DISTANCE_RATIO
} from '../viewports/globe-viewport';
import {
Globe,
type CameraFrame,
Expand All @@ -35,26 +40,31 @@ 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 {
constructor(
options: MapStateProps &
GlobeStateInternal & {
makeViewport: (props: Record<string, any>) => any;
zoomAround?: GlobeZoomAround;
}
) {
const {
startPanPos,
startPanCameraFrame,
startPanAngularRate,
startPanLockBearing,
zoomAround,
...mapStateOptions
} = options;
mapStateOptions.normalize = false;
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, any> {
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<MapState> {
Expand All @@ -262,6 +356,15 @@ export default class GlobeController extends Controller<MapState> {
// 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);
Expand Down
60 changes: 45 additions & 15 deletions modules/core/src/transitions/linear-interpolator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@
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'];

type PropsWithAnchor = {
around?: number[];
aroundPosition?: number[];
aroundLngLat?: number[];
[key: string]: any;
};

Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
Loading
Loading