Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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 @@ -27,6 +27,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` (`'pointer' | 'center'`) - screen position that remains fixed during wheel, pinch, double-click, and double-click-drag zoom. The option is shared by all controllers. Pointer-based controls default to `'pointer'`; use `'center'` to keep the viewport center fixed. Keyboard zoom remains center-based because it has no pointer position.
* `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.
* `maxBoundsPadding` (`{left, right, top, bottom}`) - padding inside the viewport when fitting `maxBounds`, in the shape of `{left, right, top, bottom}` where each value is either a relative (e.g. `'50%'`) or absolute pixels. These values support the same CSS-style expressions (numbers/percentages/`px` with parentheses and `calc()` addition/subtraction) as view `x`, `y`, `width`, `height`, and `padding`. This can be used to move the target rectangle away from the center of the viewport. A non-positive remaining dimension does not contribute a zoom constraint; a negative remaining dimension also disables target constraints. Default `0`.
Expand Down
38 changes: 27 additions & 11 deletions modules/core/src/controllers/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ export type ControllerOptions = {
};
/** Drag behavior without pressing function keys, one of `pan` and `rotate`. */
dragMode?: 'pan' | 'rotate';
/** Screen position that remains fixed while zooming. Default `'pointer'`. */
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 @@ -191,6 +193,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
protected touchRotate: boolean = false;
protected multiTouchDrag: 'pan' | 'rotate' | null = null;
protected trackpadGesture: boolean = false;
protected zoomAround: 'center' | 'pointer' = 'pointer';
protected keyboard:
| boolean
| {
Expand Down Expand Up @@ -316,6 +319,11 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
return [offsetCenter.x - x, offsetCenter.y - y];
}

/** Resolves the zoom anchor for pointer-based controls. */
protected getZoomPosition(pos: [number, number]): [number, number] {
return this.zoomAround === 'center' ? [this.props.width / 2, this.props.height / 2] : pos;
}

isPointInBounds(pos: [number, number], event: MjolnirEvent): boolean {
const {width, height} = this.props;
if (event && event.handled) {
Expand Down Expand Up @@ -389,6 +397,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
touchRotate = false,
multiTouchDrag = touchRotate ? 'rotate' : null,
trackpadGesture = false,
zoomAround = 'pointer',
Comment thread
charlieforward9 marked this conversation as resolved.
keyboard = true
} = props;

Expand Down Expand Up @@ -416,6 +425,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
this.touchRotate = multiTouchDrag === 'rotate';
this.multiTouchDrag = multiTouchDrag;
this.trackpadGesture = trackpadGesture;
this.zoomAround = zoomAround;
this.keyboard = keyboard;

// Normalize view state if maxBounds is defined
Expand Down Expand Up @@ -710,11 +720,12 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
scale = 1 / scale;
}

const zoomPosition = this.getZoomPosition(pos);
const transitionProps = smooth
? {...this._getTransitionProps({around: pos}), transitionDuration: 250}
? {...this._getTransitionProps({around: zoomPosition}), transitionDuration: 250}
: NO_TRANSITION_PROPS;

const newControllerState = this.controllerState.zoom({pos, scale});
const newControllerState = this.controllerState.zoom({pos: zoomPosition, scale});
this.updateViewport(newControllerState, transitionProps, {
isZooming: true,
isPanning: true
Expand Down Expand Up @@ -824,7 +835,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
}

const newControllerState = this.controllerState
.zoomStart({pos}, this._getConstraintContext('zoom', 'start'))
.zoomStart({pos: this.getZoomPosition(pos)}, this._getConstraintContext('zoom', 'start'))
.rotateStart({pos}, this._getConstraintContext('rotate', 'start'));
// hack - hammer's `rotation` field doesn't seem to produce the correct angle
pinchEventWorkaround._startPinchRotation = event.rotation;
Expand All @@ -847,7 +858,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
const {scale} = event;
const pos = this.getCenter(event);
newControllerState = newControllerState.zoom(
{pos, scale},
{pos: this.getZoomPosition(pos), scale},
this._getConstraintContext('zoom', 'update')
);
}
Expand Down Expand Up @@ -877,17 +888,18 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
const {_lastPinchEvent} = pinchEventWorkaround;
if (this.touchZoom && inertia && _lastPinchEvent && event.scale !== _lastPinchEvent.scale) {
const pos = this.getCenter(event);
const zoomPosition = this.getZoomPosition(pos);
let newControllerState = this.controllerState.rotateEnd();
const z = Math.log2(event.scale);
const velocityZ =
(z - Math.log2(_lastPinchEvent.scale)) / (event.deltaTime - _lastPinchEvent.deltaTime);
const endScale = Math.pow(2, z + (velocityZ * inertia) / 2);
newControllerState = newControllerState.zoom({pos, scale: endScale}).zoomEnd();
newControllerState = newControllerState.zoom({pos: zoomPosition, scale: endScale}).zoomEnd();

this.updateViewport(
newControllerState,
{
...this._getTransitionProps({around: pos}),
...this._getTransitionProps({around: zoomPosition}),
transitionDuration: inertia,
transitionEasing: INERTIA_EASING
},
Expand Down Expand Up @@ -936,9 +948,13 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
}

const isZoomOut = this.isFunctionKeyPressed(event);
const zoomPosition = this.getZoomPosition(pos);

const newControllerState = this.controllerState.zoom({pos, scale: isZoomOut ? 0.5 : 2});
this.updateViewport(newControllerState, this._getTransitionProps({around: pos}), {
const newControllerState = this.controllerState.zoom({
pos: zoomPosition,
scale: isZoomOut ? 0.5 : 2
});
this.updateViewport(newControllerState, this._getTransitionProps({around: zoomPosition}), {
isZooming: true,
isPanning: true
});
Expand All @@ -958,14 +974,14 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
return false;
}

this._doubleClickDragAnchor = pos;
this._doubleClickDragAnchor = this.getZoomPosition(pos);
let newControllerState = this.controllerState.zoomStart(
{pos},
{pos: this._doubleClickDragAnchor},
this._getConstraintContext('zoom', 'start')
);
if (event.scale !== 1) {
newControllerState = newControllerState.zoom(
{pos, scale: event.scale},
{pos: this._doubleClickDragAnchor, scale: event.scale},
this._getConstraintContext('zoom', 'update')
);
}
Expand Down
26 changes: 16 additions & 10 deletions modules/core/src/controllers/globe-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {getMaxBoundsExtents, getMaxBoundsRect} from './utils';

import {MapState, MapStateProps} from './map-controller';
import type {MapStateInternal} from './map-controller';
import {CONSTRAINT_AROUND, type ConstraintAround} from './view-state';
import {mod} from '../utils/math-utils';
import LinearInterpolator from '../transitions/linear-interpolator';
import {zoomAdjust, GLOBE_RADIUS} from '../viewports/globe-viewport';
Expand Down Expand Up @@ -143,12 +144,6 @@ 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});
}

_panFromCenter(offset: [number, number]): GlobeState {
const {width, height} = this.getViewportProps();
const center: [number, number] = [width / 2, height / 2];
Expand All @@ -158,14 +153,25 @@ class GlobeState extends MapState {
}

applyConstraints(props: Required<MapStateProps>): Required<MapStateProps> {
const {longitude, latitude, maxBounds} = props;
const internalProps = props as typeof props & ConstraintAround;
const constraintAround = internalProps[CONSTRAINT_AROUND];
delete internalProps[CONSTRAINT_AROUND];
const {latitude, maxBounds} = props;

props.zoom = this._constrainZoom(props.zoom, props);

if (longitude < -180 || longitude > 180) {
props.longitude = mod(longitude + 180, 360) - 180;
if (constraintAround) {
const viewport = this.makeViewport(props);
Object.assign(
props,
viewport.panByPosition(constraintAround.position, constraintAround.screenPosition)
);
}

if (props.longitude < -180 || props.longitude > 180) {
props.longitude = mod(props.longitude + 180, 360) - 180;
}
props.latitude = clamp(latitude, -90, 90);
props.latitude = clamp(props.latitude, -90, 90);

if (props.bearing < -180 || props.bearing > 180) {
props.bearing = mod(props.bearing + 180, 360) - 180;
Expand Down
27 changes: 10 additions & 17 deletions modules/core/src/transitions/linear-interpolator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@
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';

const DEFAULT_PROPS = ['longitude', 'latitude', 'zoom', 'bearing', 'pitch'];
const DEFAULT_REQUIRED_PROPS = ['longitude', 'latitude', 'zoom'];
Expand Down Expand Up @@ -78,21 +76,16 @@ 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')();
} else {
const startViewport = makeViewport(startProps);
const endViewport = makeViewport(endProps);
const aroundPosition = startViewport.unproject(around);
result.start.around = around;
Object.assign(result.end, {
around: endViewport.project(aroundPosition),
aroundPosition,
width: endProps.width,
height: endProps.height
});
}
const startViewport = makeViewport(startProps);
const endViewport = makeViewport(endProps);
const aroundPosition = startViewport.unproject(around);
result.start.around = around;
Object.assign(result.end, {
around: endViewport.project(aroundPosition),
aroundPosition,
width: endProps.width,
height: endProps.height
});
}

return result;
Expand Down
Loading