Skip to content
Closed
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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe zoomAround: pointer is default behavior for most controllers - not sure if this is necessary.

* `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 @@ -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 */
Expand Down Expand Up @@ -291,8 +293,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
110 changes: 105 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 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 @@
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,7 @@
if (startPanCameraFrame !== undefined) s.startPanCameraFrame = startPanCameraFrame;
if (startPanAngularRate !== undefined) s.startPanAngularRate = startPanAngularRate;
if (startPanLockBearing !== undefined) s.startPanLockBearing = startPanLockBearing;
if (zoomAround !== undefined) s.zoomAround = zoomAround;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was investigating the intermittent issue where pointer-anchored zoom stops working after hot reload. I think this may help:

-    if (zoomAround !== undefined) s.zoomAround = zoomAround;
+    s.zoomAround = zoomAround || 'center';

The problem might be that _shouldZoomAroundPointer() reads from _state, but the conditional guard means if the constructor is ever called without zoomAround in options (which can happen during HMR when props haven’t fully propagated yet), the key is just absent from state and the feature silently disengages.

To reproduce: set controller: {zoomAround: 'pointer'}, confirm it works, then trigger a hot reload.. it should intermittently reverts to center-zoom.

Unlike the other fields guarded by !== undefined (which are transient gesture anchors that are legitimately absent between interactions), zoomAround is a config option that always needs a value. Can you test it?

}

panStart({pos}: {pos: [number, number]}): GlobeState {
Expand Down Expand Up @@ -142,10 +153,59 @@
}) 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 as [number, number] | undefined;

Check failure on line 178 in modules/core/src/controllers/globe-controller.ts

View workflow job for this annotation

GitHub Actions / test-node

This assertion is unnecessary since it does not change the type of the expression
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 +302,37 @@
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 +353,15 @@
// 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
});
}
Comment thread
charlieforward9 marked this conversation as resolved.
} 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