Skip to content
Open
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
30 changes: 30 additions & 0 deletions docs/api-reference/core/globe-controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,36 @@ Supports all [Controller options](./controller.md#options) with the following de
- `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]]`

## Mobile Browser Touch UI

For full-screen mobile globe experiences, use CSS guards on the Deck canvas/root element so repeated touch gestures do not trigger browser selection, tap highlight, or WebKit touch callouts:

```css
#deck-root,
#deck-root canvas {
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
```

Some iOS embeds may still surface native callout UI during long-press or rapid double-tap gestures. In those cases, prevent the browser-native UI events that originate from the canvas without stopping pointer event propagation. The controller still needs deck.gl's pointer events to receive the gesture sequence.

```js
const root = document.getElementById('deck-root');
const preventCanvasBrowserUI = event => {
if (event.target instanceof HTMLCanvasElement) {
event.preventDefault();
}
};

for (const type of ['contextmenu', 'selectstart', 'gesturestart', 'gesturechange', 'gestureend']) {
root.addEventListener(type, preventCanvasBrowserUI, {passive: false});
}
```

## Custom GlobeController

You can further customize the `GlobeController`'s behavior by extending the class:
Expand Down
21 changes: 21 additions & 0 deletions examples/website/globe/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ function getDate(data: DailyFlights[], t: number) {
}

export async function renderToDOM(container: HTMLDivElement) {
addCanvasInteractionGuards(container);

const root = createRoot(container);
root.render(<App />);

Expand Down Expand Up @@ -186,3 +188,22 @@ export async function renderToDOM(container: HTMLDivElement) {
root.render(<App data={data} />);
}
}

function addCanvasInteractionGuards(container: HTMLDivElement): void {
const preventCanvasBrowserUI = (event: Event) => {
if (event.target instanceof HTMLCanvasElement) {
event.preventDefault();
}
};
const listenerOptions = {passive: false};

for (const type of [
'contextmenu',
'selectstart',
'gesturestart',
'gesturechange',
'gestureend'
]) {
container.addEventListener(type, preventCanvasBrowserUI, listenerOptions);
}
}
49 changes: 34 additions & 15 deletions examples/website/globe/index.html
Original file line number Diff line number Diff line change
@@ -1,18 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>deck.gl Example</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {margin: 0; font-family: sans-serif; width: 100vw; height: 100vh; overflow: hidden; background: #111;}
</style>
</head>
<body>
<div id="app"></div>
</body>
<script type="module">
import {renderToDOM} from './app.tsx';
renderToDOM(document.getElementById('app'));
</script>
<head>
<meta charset="utf-8" />
<title>deck.gl Example</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
html,
body,
#app {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
overscroll-behavior: none;
font-family: sans-serif;
background: #111;
}

#app,
#app canvas {
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
</style>
</head>
<body>
<div id="app"></div>
</body>
<script type="module">
import {renderToDOM} from './app.tsx';
renderToDOM(document.getElementById('app'));
</script>
</html>
192 changes: 192 additions & 0 deletions modules/core/src/controllers/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,21 @@ const NO_TRANSITION_PROPS = {

const DEFAULT_INERTIA = 300;
const INERTIA_EASING = t => 1 - (1 - t) * (1 - t);
// One-finger double-tap-drag-to-zoom gesture (matches Google Maps).
// Empirical values chosen to feel snappy but reject accidental triggers.
const DOUBLE_TAP_DRAG_INTERVAL = 500;
const DOUBLE_TAP_DRAG_MAX_TAP_DURATION = 350;
const DOUBLE_TAP_DRAG_MAX_TAP_DISTANCE = 28;
const DOUBLE_TAP_DRAG_START_THRESHOLD = 1;
const DOUBLE_TAP_DRAG_PIXELS_PER_ZOOM = 120;

const EVENT_TYPES = {
WHEEL: ['wheel'],
PAN: ['panstart', 'panmove', 'panend'],
PINCH: ['pinchstart', 'pinchmove', 'pinchend'],
MULTI_PAN: ['multipanstart', 'multipanmove', 'multipanend'],
DOUBLE_CLICK: ['dblclick'],
DOUBLE_TAP_DRAG: ['pointerdown', 'pointermove', 'pointerup', 'pointercancel'],
KEYBOARD: ['keydown']
} as const;

Expand Down Expand Up @@ -112,6 +120,24 @@ export type ViewStateChangeParameters<ViewStateT = any> = {

const pinchEventWorkaround: any = {};

type OneFingerTapState = {
pos: [number, number];
time: number;
pointerId?: number;
};

type OneFingerZoomState = {
startPos: [number, number];
pointerId?: number;
active: boolean;
};

function getDistance(a: [number, number], b: [number, number]): number {
const dx = a[0] - b[0];
const dy = a[1] - b[1];
return Math.sqrt(dx * dx + dy * dy);
}

export default abstract class Controller<ControllerState extends IViewState<ControllerState>> {
abstract get ControllerState(): ConstructorOf<ControllerState>;
abstract get transition(): TransitionProps;
Expand All @@ -135,6 +161,10 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
private _customEvents: string[] = [];
private _eventStartBlocked: any = null;
private _panMove: boolean = false;
private _tapStart: OneFingerTapState | null = null;
private _lastTap: OneFingerTapState | null = null;
private _oneFingerZoom: OneFingerZoomState | null = null;
private _suppressDoubleClickUntil: number = 0;

protected invertPan: boolean = false;
protected dragMode: 'pan' | 'rotate' = 'rotate';
Expand Down Expand Up @@ -209,10 +239,19 @@ export default abstract class Controller<ControllerState extends IViewState<Cont

switch (event.type) {
case 'panstart':
if (this._oneFingerZoom) {
return false;
}
return eventStartBlocked ? false : this._onPanStart(event);
case 'panmove':
if (this._oneFingerZoom) {
return false;
}
return this._onPan(event);
case 'panend':
if (this._oneFingerZoom) {
return false;
}
return this._onPanEnd(event);
case 'pinchstart':
return eventStartBlocked ? false : this._onPinchStart(event);
Expand All @@ -228,6 +267,13 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
return this._onMultiPanEnd(event);
case 'dblclick':
return this._onDoubleClick(event);
case 'pointerdown':
return this._onPointerDown(event);
case 'pointermove':
return this._onPointerMove(event);
case 'pointerup':
case 'pointercancel':
return this._onPointerUp(event);
case 'wheel':
return this._onWheel(event as MjolnirWheelEvent);
case 'keydown':
Expand Down Expand Up @@ -328,6 +374,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
this.toggleEvents(EVENT_TYPES.PINCH, isInteractive && (touchZoom || touchRotate));
this.toggleEvents(EVENT_TYPES.MULTI_PAN, isInteractive && touchRotate);
this.toggleEvents(EVENT_TYPES.DOUBLE_CLICK, isInteractive && doubleClickZoom);
this.toggleEvents(EVENT_TYPES.DOUBLE_TAP_DRAG, isInteractive && touchZoom);
this.toggleEvents(EVENT_TYPES.KEYBOARD, isInteractive && keyboard);

// Interaction toggles
Expand Down Expand Up @@ -641,6 +688,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont

// Default handler for the `pinchstart` event.
protected _onPinchStart(event: MjolnirGestureEvent): boolean {
this._resetOneFingerZoom();
const pos = this.getCenter(event);
if (!this.isPointInBounds(pos, event)) {
return false;
Expand Down Expand Up @@ -735,6 +783,9 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
if (!this.doubleClickZoom) {
return false;
}
if (Date.now() < this._suppressDoubleClickUntil) {
return false;
}
const pos = this.getCenter(event);
if (!this.isPointInBounds(pos, event)) {
return false;
Expand All @@ -751,6 +802,147 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
return true;
}

protected _onPointerDown(event: MjolnirEvent): boolean {
if (!this.touchZoom || !this._isPrimaryPointer(event)) {
this._resetOneFingerZoom();
return false;
}

const pos = this.getCenter(event as MjolnirGestureEvent);
if (!this.isPointInBounds(pos, event)) {
this._resetOneFingerZoom();
return false;
}

const time = this._getEventTime(event);
const pointerId = (event.srcEvent as PointerEvent).pointerId;
if (
this._lastTap &&
time - this._lastTap.time <= DOUBLE_TAP_DRAG_INTERVAL &&
getDistance(pos, this._lastTap.pos) <= DOUBLE_TAP_DRAG_MAX_TAP_DISTANCE
) {
this._tapStart = null;
this._lastTap = null;
this._oneFingerZoom = {startPos: pos, pointerId, active: false};
event.srcEvent.preventDefault();
event.stopPropagation();
return true;
}

this._tapStart = {pos, time, pointerId};
this._lastTap = null;
if ((event.srcEvent as PointerEvent).pointerType === 'touch') {
event.srcEvent.preventDefault();
}
return false;
}

protected _onPointerMove(event: MjolnirEvent): boolean {
const oneFingerZoom = this._oneFingerZoom;
if (!oneFingerZoom || !this._isSamePointer(event, oneFingerZoom.pointerId)) {
return false;
}

const pos = this.getCenter(event as MjolnirGestureEvent);
const dy = oneFingerZoom.startPos[1] - pos[1];
if (!oneFingerZoom.active && Math.abs(dy) < DOUBLE_TAP_DRAG_START_THRESHOLD) {
event.srcEvent.preventDefault();
event.stopPropagation();
return true;
}

const scale = Math.pow(2, dy / DOUBLE_TAP_DRAG_PIXELS_PER_ZOOM);
const startPos = oneFingerZoom.startPos;
let newControllerState = this.controllerState;
if (!oneFingerZoom.active) {
oneFingerZoom.active = true;
newControllerState = newControllerState.zoomStart({pos: startPos});
}
newControllerState = newControllerState.zoom({pos: startPos, scale});
this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {
isDragging: true,
isPanning: true,
isZooming: true
});

event.srcEvent.preventDefault();
event.stopPropagation();
return true;
}

protected _onPointerUp(event: MjolnirEvent): boolean {
const oneFingerZoom = this._oneFingerZoom;
if (oneFingerZoom && this._isSamePointer(event, oneFingerZoom.pointerId)) {
this._oneFingerZoom = null;
if (oneFingerZoom.active) {
const newControllerState = this.controllerState.zoomEnd();
this.updateViewport(newControllerState, null, {
isDragging: false,
isPanning: false,
isZooming: false
});
this._suppressDoubleClickUntil = Date.now() + 100;
this.blockEvents(100);
event.srcEvent.preventDefault();
event.stopPropagation();
return true;
}
return false;
}

if (event.type === 'pointercancel') {
this._resetOneFingerZoom();
return false;
}

const tapStart = this._tapStart;
if (!tapStart || !this._isSamePointer(event, tapStart.pointerId)) {
return false;
}

const pos = this.getCenter(event as MjolnirGestureEvent);
const time = this._getEventTime(event);
if (
time - tapStart.time <= DOUBLE_TAP_DRAG_MAX_TAP_DURATION &&
getDistance(pos, tapStart.pos) <= DOUBLE_TAP_DRAG_MAX_TAP_DISTANCE
) {
this._lastTap = {pos, time, pointerId: tapStart.pointerId};
} else {
this._lastTap = null;
}
this._tapStart = null;
if ((event.srcEvent as PointerEvent).pointerType === 'touch') {
event.srcEvent.preventDefault();
}
return false;
}

private _resetOneFingerZoom(): void {
this._tapStart = null;
this._lastTap = null;
this._oneFingerZoom = null;
}

private _getEventTime(event: MjolnirEvent): number {
return (event as any).timeStamp || event.srcEvent.timeStamp || Date.now();
}

private _isPrimaryPointer(event: MjolnirEvent): boolean {
const pointers = (event as any).pointers;
if (pointers && pointers.length > 1) {
return false;
}
const srcEvent = event.srcEvent as PointerEvent;
if (srcEvent.pointerType === 'mouse') {
return (event as any).leftButton !== false;
}
return true;
}

private _isSamePointer(event: MjolnirEvent, pointerId?: number): boolean {
return pointerId === undefined || (event.srcEvent as PointerEvent).pointerId === pointerId;
}

// Default handler for the `keydown` event
protected _onKeyDown(event: MjolnirKeyEvent): boolean {
if (!this.keyboard) {
Expand Down
Loading
Loading