Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions docs/api-reference/core/globe-controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ new Deck({
Supports all [Controller options](./controller.md#options) with the following default behavior:

- `dragPan`: default `'pan'` (drag to pan)
- `dragRotate`: not effective, this view does not currently support rotation
- `touchRotate`: not effective, this view does not currently support rotation
- `dragRotate`: default `true` (right-click drag to rotate pitch/bearing)
- `touchRotate`: default `true` (two-finger rotate to change pitch/bearing)
- `keyboard`: arrow keys to pan, +/- to zoom
- `maxBounds` - constrains the viewport to the specified bounding box `[[minLng, minLat], [maxLng, maxLat]]`

Expand Down
5 changes: 4 additions & 1 deletion docs/api-reference/core/globe-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ It's recommended that you read the [Views and Projections guide](../../developer
The goal of `GlobeView` is to provide a generic solution to rendering and navigating data in the 3D space.
In the initial release, this class mainly addresses the need to render an overview of the entire globe. The following limitations apply, as features are still under development:

- No support for rotation (`pitch` or `bearing`). The camera always points towards the center of the earth, with north up.
- No high-precision rendering at high zoom levels (> 12). Features at the city-block scale may not be rendered accurately.
- Only supports `'lnglat'` (the default value of the `coordinateSystem` prop).
- Known rendering issues when using multiple views mixing `GlobeView` and `MapView`, or switching between the two.
Expand Down Expand Up @@ -72,8 +71,12 @@ To render, `GlobeView` needs to be used together with a `viewState` with the fol
- `longitude` (number) - longitude at the viewport center
- `latitude` (number) - latitude at the viewport center
- `zoom` (number) - zoom level
- `bearing` (number, optional) - bearing angle in degrees. Default `0` (north up).
- `pitch` (number, optional) - pitch angle in degrees. `0` is looking straight down. Default `0`.
- `maxZoom` (number, optional) - max zoom level. Default `20`.
- `minZoom` (number, optional) - min zoom level. Default `0`.
- `maxPitch` (number, optional) - max pitch angle. Default `60`.
- `minPitch` (number, optional) - min pitch angle. Default `0`.


## Controller
Expand Down
59 changes: 20 additions & 39 deletions modules/core/src/controllers/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const NO_TRANSITION_PROPS = {

const DEFAULT_INERTIA = 300;
const INERTIA_EASING = t => 1 - (1 - t) * (1 - t);
const MAX_PINCH_ZOOM_DELTA_PER_EVENT = 0.18;

const EVENT_TYPES = {
WHEEL: ['wheel'],
Expand Down Expand Up @@ -112,6 +113,10 @@ export type ViewStateChangeParameters<ViewStateT = any> = {

const pinchEventWorkaround: any = {};

function clampPinchZoomDelta(delta: number): number {
return Math.max(-MAX_PINCH_ZOOM_DELTA_PER_EVENT, Math.min(MAX_PINCH_ZOOM_DELTA_PER_EVENT, delta));
}

export default abstract class Controller<ControllerState extends IViewState<ControllerState>> {
abstract get ControllerState(): ConstructorOf<ControllerState>;
abstract get transition(): TransitionProps;
Expand Down Expand Up @@ -649,7 +654,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
const newControllerState = this.controllerState.zoomStart({pos}).rotateStart({pos});
// hack - hammer's `rotation` field doesn't seem to produce the correct angle
pinchEventWorkaround._startPinchRotation = event.rotation;
pinchEventWorkaround._lastPinchEvent = event;
pinchEventWorkaround._smoothedPinchScaleLog = 0;
this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {isDragging: true});
return true;
}
Expand All @@ -667,7 +672,12 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
if (this.touchZoom) {
const {scale} = event;
const pos = this.getCenter(event);
newControllerState = newControllerState.zoom({pos, scale});
const rawScaleLog = Math.log2(scale);
const previousScaleLog = pinchEventWorkaround._smoothedPinchScaleLog ?? 0;
const smoothedScaleLog =
previousScaleLog + clampPinchZoomDelta(rawScaleLog - previousScaleLog);
pinchEventWorkaround._smoothedPinchScaleLog = smoothedScaleLog;
newControllerState = newControllerState.zoom({pos, scale: Math.pow(2, smoothedScaleLog)});
}
if (this.touchRotate) {
const {rotation} = event;
Expand All @@ -682,51 +692,22 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
isZooming: this.touchZoom,
isRotating: this.touchRotate
});
pinchEventWorkaround._lastPinchEvent = event;
return true;
}

protected _onPinchEnd(event: MjolnirGestureEvent): boolean {
if (!this.isDragging()) {
return false;
}
const {inertia} = this;
const {_lastPinchEvent} = pinchEventWorkaround;
if (this.touchZoom && inertia && _lastPinchEvent && event.scale !== _lastPinchEvent.scale) {
const pos = this.getCenter(event);
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();

this.updateViewport(
newControllerState,
{
...this._getTransitionProps({around: pos}),
transitionDuration: inertia,
transitionEasing: INERTIA_EASING
},
{
isDragging: false,
isPanning: this.touchZoom,
isZooming: this.touchZoom,
isRotating: false
}
);
this.blockEvents(inertia);
} else {
const newControllerState = this.controllerState.zoomEnd().rotateEnd();
this.updateViewport(newControllerState, null, {
isDragging: false,
isPanning: false,
isZooming: false,
isRotating: false
});
}
const newControllerState = this.controllerState.zoomEnd().rotateEnd();
this.updateViewport(newControllerState, null, {
isDragging: false,
isPanning: false,
isZooming: false,
isRotating: false
});
pinchEventWorkaround._startPinchRotation = null;
pinchEventWorkaround._lastPinchEvent = null;
pinchEventWorkaround._smoothedPinchScaleLog = null;
return true;
}

Expand Down
120 changes: 93 additions & 27 deletions modules/core/src/controllers/globe-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
// Copyright (c) vis.gl contributors

import {clamp} from '@math.gl/core';
import Controller, {ControllerProps} from './controller';
import Controller from './controller';

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 GlobeViewport from '../viewports/globe-viewport';

import {MAX_LATITUDE} from '@math.gl/web-mercator';

Expand All @@ -29,71 +30,138 @@ function pixelsToDegrees(pixels: number, zoom: number = 0): number {

type GlobeStateInternal = MapStateInternal & {
startPanPos?: [number, number];
startPanOnGlobe?: boolean;
};

function unprojectOnGlobe(
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.

maybe create a globe-projection.ts or globe-projection-utils.ts?

class GlobeProjection {
  project()
  unproject()
  getViewMatrix()
   ..
}

or would this be part of GlobeViewport?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I checked the existing viewport pattern and kept this in GlobeViewport for now. Core viewport-specific projection logic generally lives on the viewport class, while WebMercator delegates to math.gl. Pulling out only globe projection would be a one-off; extracting projection logic feels better as a broader refactor across projection types.

viewport: GlobeViewport,
pos?: [number, number]
): [number, number] | undefined {
const lngLat = pos && viewport.isPointOnGlobe(pos) && viewport.unproject(pos);
return lngLat ? [lngLat[0], lngLat[1]] : undefined;
}

class GlobeState extends MapState {
constructor(
options: MapStateProps &
GlobeStateInternal & {
makeViewport: (props: Record<string, any>) => any;
}
) {
const {startPanPos, ...mapStateOptions} = options;
mapStateOptions.normalize = false; // disable MapState default normalization
super(mapStateOptions);
const {startPanPos, startPanOnGlobe, ...mapStateOptions} = options;
// Disable MapState's default web-mercator bounds; globe covers the whole earth.
super({...mapStateOptions, normalize: false});

if (startPanPos !== undefined) {
(this as any)._state.startPanPos = startPanPos;
}
if (startPanOnGlobe !== undefined) {
(this as any)._state.startPanOnGlobe = startPanOnGlobe;
}
}

panStart({pos}: {pos: [number, number]}): GlobeState {
const {latitude, longitude, zoom} = this.getViewportProps();
const {longitude, latitude, zoom} = this.getViewportProps();
const viewport = this.makeViewport(this.getViewportProps()) as GlobeViewport;
const startPanLngLat = unprojectOnGlobe(viewport, pos);

return this._getUpdatedState({
startPanLngLat: [longitude, latitude],
startPanLngLat: startPanLngLat || [longitude, latitude],
startPanPos: pos,
startPanOnGlobe: Boolean(startPanLngLat),
startZoom: zoom
}) as GlobeState;
}

pan({pos, startPos}: {pos: [number, number]; startPos?: [number, number]}): GlobeState {
const state = this.getState() as GlobeStateInternal;
const startPanLngLat = state.startPanLngLat || this._unproject(startPos);
if (!startPanLngLat) return this;
const startZoom = state.startZoom ?? this.getViewportProps().zoom;
const viewport = this.makeViewport(this.getViewportProps()) as GlobeViewport;
const startPanOnGlobe =
state.startPanOnGlobe ?? (startPos ? viewport.isPointOnGlobe(startPos) : true);

if (startPanOnGlobe) {
const startPanLngLat = state.startPanLngLat || unprojectOnGlobe(viewport, startPos);
if (!startPanLngLat) {
return this;
}
return this._getUpdatedState(viewport.panByLngLat(startPanLngLat, pos)) as GlobeState;
}

const startPanPos = state.startPanPos || startPos;
if (!startPanPos) {
return this;
}

const coords = [startPanLngLat[0], startPanLngLat[1], startZoom];
const viewport = this.makeViewport(this.getViewportProps());
const newProps = viewport.panByPosition(coords, pos, startPanPos);
const {longitude, latitude, zoom} = this.getViewportProps();
const startPanLngLat = state.startPanLngLat || [longitude, latitude];
const startZoom = state.startZoom ?? zoom;
const newProps = viewport.panByPosition(
[startPanLngLat[0], startPanLngLat[1], startZoom],
pos,
startPanPos
);
return this._getUpdatedState(newProps) as GlobeState;
}

panEnd(): GlobeState {
return this._getUpdatedState({
startPanLngLat: null,
startPanPos: null,
startPanOnGlobe: null,
startZoom: null
}) as GlobeState;
}

zoom({scale}: {scale: number}): MapState {
// In Globe view zoom does not take into account the mouse position
const startZoom = this.getState().startZoom || this.getViewportProps().zoom;
const zoom = startZoom + Math.log2(scale);
return this._getUpdatedState({zoom});
zoom({
pos,
startPos,
scale
}: {
pos: [number, number];
startPos?: [number, number];
scale: number;
}): MapState {
let {startZoom, startZoomLngLat} = this.getState();
const viewport = this.makeViewport(this.getViewportProps()) as GlobeViewport;

if (!startZoomLngLat) {
startZoom = this.getViewportProps().zoom;
startZoomLngLat = unprojectOnGlobe(viewport, startPos) || unprojectOnGlobe(viewport, pos);
}

const zoom = this._constrainZoom((startZoom as number) + Math.log2(scale));

if (!startZoomLngLat) {
// Cursor is off the globe — fall back to center zoom
return this._getUpdatedState({zoom});
}

const zoomedViewport = this.makeViewport({...this.getViewportProps(), zoom}) as GlobeViewport;
return this._getUpdatedState({
zoom,
...zoomedViewport.panByLngLat(startZoomLngLat, pos)
});
}

applyConstraints(props: Required<MapStateProps>): Required<MapStateProps> {
// Ensure zoom is within specified range
const {longitude, latitude, maxBounds} = props;
const {longitude, latitude, maxBounds, maxPitch, minPitch} = props;

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

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

// Normalize bearing to [-180, 180]
if (props.bearing < -180 || props.bearing > 180) {
props.bearing = mod(props.bearing + 180, 360) - 180;
}

// Clamp pitch to [minPitch, maxPitch]
props.pitch = clamp(props.pitch, minPitch, maxPitch);

if (maxBounds) {
props.longitude = clamp(props.longitude, maxBounds[0][0], maxBounds[1][0]);
props.latitude = clamp(props.latitude, maxBounds[0][1], maxBounds[1][1]);
Expand Down Expand Up @@ -175,16 +243,14 @@ export default class GlobeController extends Controller<MapState> {

transition = {
transitionDuration: 300,
transitionInterpolator: new LinearInterpolator(['longitude', 'latitude', 'zoom'])
transitionInterpolator: new LinearInterpolator([
'longitude',
'latitude',
'zoom',
'bearing',
'pitch'
])
};

dragMode: 'pan' | 'rotate' = 'pan';

setProps(props: ControllerProps) {
super.setProps(props);

// TODO - support pitching?
this.dragRotate = false;
this.touchRotate = false;
}
}
Loading
Loading