UNPKG

@deck.gl/core

Version:

deck.gl core library

809 lines (729 loc) 27.2 kB
// deck.gl // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors /* eslint-disable max-statements, complexity */ import TransitionManager, {TransitionProps} from './transition-manager'; import LinearInterpolator from '../transitions/linear-interpolator'; import {IViewState} from './view-state'; import {ConstructorOf} from '../types/types'; import type Viewport from '../viewports/viewport'; import type {EventManager, MjolnirEvent, MjolnirGestureEvent, MjolnirWheelEvent, MjolnirKeyEvent} from 'mjolnir.js'; import type {Timeline} from '@luma.gl/engine'; const NO_TRANSITION_PROPS = { transitionDuration: 0 } as const; const DEFAULT_INERTIA = 300; const INERTIA_EASING = t => 1 - (1 - t) * (1 - t); const EVENT_TYPES = { WHEEL: ['wheel'], PAN: ['panstart', 'panmove', 'panend'], PINCH: ['pinchstart', 'pinchmove', 'pinchend'], MULTI_PAN: ['multipanstart', 'multipanmove', 'multipanend'], DOUBLE_CLICK: ['dblclick'], KEYBOARD: ['keydown'] } as const; /** Configuration of how user input is handled */ export type ControllerOptions = { /** Enable zooming with mouse wheel. Default `true`. */ scrollZoom?: boolean | { /** Scaler that translates wheel delta to the change of viewport scale. Default `0.01`. */ speed?: number; /** Smoothly transition to the new zoom. If enabled, will provide a slightly lagged but smoother experience. Default `false`. */ smooth?: boolean }; /** Enable panning with pointer drag. Default `true` */ dragPan?: boolean; /** Enable rotating with pointer drag. Default `true` */ dragRotate?: boolean; /** Enable zooming with double click. Default `true` */ doubleClickZoom?: boolean; /** Enable zooming with multi-touch. Default `true` */ touchZoom?: boolean; /** Enable rotating with multi-touch. Use two-finger rotating gesture for horizontal and three-finger swiping gesture for vertical rotation. Default `false` */ touchRotate?: boolean; /** Enable interaction with keyboard. Default `true`. */ keyboard?: | boolean | { /** Speed of zoom using +/- keys. Default `2` */ zoomSpeed?: number; /** Speed of movement using arrow keys, in pixels. */ moveSpeed?: number; /** Speed of rotation using shift + left/right arrow keys, in degrees. Default 15. */ rotateSpeedX?: number; /** Speed of rotation using shift + up/down arrow keys, in degrees. Default 10. */ rotateSpeedY?: number; }; /** Drag behavior without pressing function keys, one of `pan` and `rotate`. */ dragMode?: 'pan' | 'rotate'; /** 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; }; export type ControllerProps = { /** Identifier of the controller */ id: string; /** Viewport x position */ x: number; /** Viewport y position */ y: number; /** Viewport width */ width: number; /** Viewport height */ height: number; } & ControllerOptions & TransitionProps; /** The state of a controller */ export type InteractionState = { /** If the view state is in transition */ inTransition?: boolean; /** If the user is dragging */ isDragging?: boolean; /** If the view is being panned, either from user input or transition */ isPanning?: boolean; /** If the view is being rotated, either from user input or transition */ isRotating?: boolean; /** If the view is being zoomed, either from user input or transition */ isZooming?: boolean; } /** Parameters passed to the onViewStateChange callback */ export type ViewStateChangeParameters<ViewStateT = any> = { viewId: string; /** The next view state, either from user input or transition */ viewState: ViewStateT; /** Object describing the nature of the view state change */ interactionState: InteractionState; /** The current view state */ oldViewState?: ViewStateT; } const pinchEventWorkaround: any = {}; export default abstract class Controller<ControllerState extends IViewState<ControllerState>> { abstract get ControllerState(): ConstructorOf<ControllerState>; abstract get transition(): TransitionProps; // @ts-expect-error (2564) - not assigned in the constructor protected props: ControllerProps; protected state: Record<string, any> = {}; protected transitionManager: TransitionManager<ControllerState>; protected eventManager: EventManager; protected onViewStateChange: (params: ViewStateChangeParameters) => void; protected onStateChange: (state: InteractionState) => void; protected makeViewport: (opts: Record<string, any>) => Viewport private _controllerState?: ControllerState; private _events: Record<string, boolean> = {}; private _interactionState: InteractionState = { isDragging: false }; private _customEvents: string[] = []; private _eventStartBlocked: any = null; private _panMove: boolean = false; protected invertPan: boolean = false; protected dragMode: 'pan' | 'rotate' = 'rotate'; protected inertia: number = 0; protected scrollZoom: boolean | {speed?: number; smooth?: boolean} = true; protected dragPan: boolean = true; protected dragRotate: boolean = true; protected doubleClickZoom: boolean = true; protected touchZoom: boolean = true; protected touchRotate: boolean = false; protected keyboard: | boolean | { zoomSpeed?: number; // speed of zoom using +/- keys. Default 2. moveSpeed?: number; // speed of movement using arrow keys, in pixels. 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. } = true; constructor(opts: { timeline: Timeline, eventManager: EventManager; makeViewport: (opts: Record<string, any>) => Viewport; onViewStateChange: (params: ViewStateChangeParameters) => void; onStateChange: (state: InteractionState) => void; }) { this.transitionManager = new TransitionManager<ControllerState>({ ...opts, getControllerState: props => new this.ControllerState(props), onViewStateChange: this._onTransition.bind(this), onStateChange: this._setInteractionState.bind(this) }); this.handleEvent = this.handleEvent.bind(this); this.eventManager = opts.eventManager; this.onViewStateChange = opts.onViewStateChange || (() => {}); this.onStateChange = opts.onStateChange || (() => {}); this.makeViewport = opts.makeViewport; } set events(customEvents) { this.toggleEvents(this._customEvents, false); this.toggleEvents(customEvents, true); this._customEvents = customEvents; // Make sure default events are not overwritten if (this.props) { this.setProps(this.props); } } finalize() { for (const eventName in this._events) { if (this._events[eventName]) { // @ts-ignore (2345) event type string cannot be assifned to enum // eslint-disable-next-line @typescript-eslint/unbound-method this.eventManager?.off(eventName, this.handleEvent); } } this.transitionManager.finalize(); } /** * Callback for events */ handleEvent(event: MjolnirEvent) { // Force recalculate controller state this._controllerState = undefined; const eventStartBlocked = this._eventStartBlocked; switch (event.type) { case 'panstart': return eventStartBlocked ? false : this._onPanStart(event); case 'panmove': return this._onPan(event); case 'panend': return this._onPanEnd(event); case 'pinchstart': return eventStartBlocked ? false : this._onPinchStart(event); case 'pinchmove': return this._onPinch(event); case 'pinchend': return this._onPinchEnd(event); case 'multipanstart': return eventStartBlocked ? false : this._onMultiPanStart(event); case 'multipanmove': return this._onMultiPan(event); case 'multipanend': return this._onMultiPanEnd(event); case 'dblclick': return this._onDoubleClick(event); case 'wheel': return this._onWheel(event as MjolnirWheelEvent); case 'keydown': return this._onKeyDown(event as MjolnirKeyEvent); default: return false; } } /* Event utils */ // Event object: http://hammerjs.github.io/api/#event-object get controllerState(): ControllerState { this._controllerState = this._controllerState || new this.ControllerState({ makeViewport: this.makeViewport, ...this.props, ...this.state }); return this._controllerState ; } getCenter(event: MjolnirGestureEvent | MjolnirWheelEvent) : [number, number] { const {x, y} = this.props; const {offsetCenter} = event; return [offsetCenter.x - x, offsetCenter.y - y]; } isPointInBounds(pos: [number, number], event: MjolnirEvent): boolean { const {width, height} = this.props; if (event && event.handled) { return false; } const inside = pos[0] >= 0 && pos[0] <= width && pos[1] >= 0 && pos[1] <= height; if (inside && event) { event.stopPropagation(); } return inside; } isFunctionKeyPressed(event: MjolnirEvent): boolean { const {srcEvent} = event; return Boolean(srcEvent.metaKey || srcEvent.altKey || srcEvent.ctrlKey || srcEvent.shiftKey); } isDragging(): boolean { return this._interactionState.isDragging || false; } // When a multi-touch event ends, e.g. pinch, not all pointers are lifted at the same time. // This triggers a brief `pan` event. // Calling this method will temporarily disable *start events to avoid conflicting transitions. blockEvents(timeout: number): void { /* global setTimeout */ const timer = setTimeout(() => { if (this._eventStartBlocked === timer) { this._eventStartBlocked = null; } }, timeout); this._eventStartBlocked = timer; } /** * Extract interactivity options */ setProps(props: ControllerProps) { if (props.dragMode) { this.dragMode = props.dragMode; } this.props = props; if (!('transitionInterpolator' in props)) { // Add default transition interpolator props.transitionInterpolator = this._getTransitionProps().transitionInterpolator; } this.transitionManager.processViewStateChange(props); const {inertia} = props; this.inertia = Number.isFinite(inertia) ? (inertia as number) : (inertia === true ? DEFAULT_INERTIA : 0); // TODO - make sure these are not reset on every setProps const { scrollZoom = true, dragPan = true, dragRotate = true, doubleClickZoom = true, touchZoom = true, touchRotate = false, keyboard = true } = props; // Register/unregister events const isInteractive = Boolean(this.onViewStateChange); this.toggleEvents(EVENT_TYPES.WHEEL, isInteractive && scrollZoom); // We always need the pan events to set the correct isDragging state, even if dragPan & dragRotate are both false this.toggleEvents(EVENT_TYPES.PAN, isInteractive); 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.KEYBOARD, isInteractive && keyboard); // Interaction toggles this.scrollZoom = scrollZoom; this.dragPan = dragPan; this.dragRotate = dragRotate; this.doubleClickZoom = doubleClickZoom; this.touchZoom = touchZoom; this.touchRotate = touchRotate; this.keyboard = keyboard; } updateTransition() { this.transitionManager.updateTransition(); } toggleEvents(eventNames, enabled) { if (this.eventManager) { eventNames.forEach(eventName => { if (this._events[eventName] !== enabled) { this._events[eventName] = enabled; if (enabled) { // eslint-disable-next-line @typescript-eslint/unbound-method this.eventManager.on(eventName, this.handleEvent); } else { // eslint-disable-next-line @typescript-eslint/unbound-method this.eventManager.off(eventName, this.handleEvent); } } }); } } // Private Methods /* Callback util */ // formats map state and invokes callback function protected updateViewport(newControllerState: ControllerState, extraProps: Record<string, any> | null = null, interactionState: InteractionState = {}) { const viewState = {...newControllerState.getViewportProps(), ...extraProps}; // TODO - to restore diffing, we need to include interactionState const changed = this.controllerState !== newControllerState; // const oldViewState = this.controllerState.getViewportProps(); // const changed = Object.keys(viewState).some(key => oldViewState[key] !== viewState[key]); this.state = newControllerState.getState(); this._setInteractionState(interactionState); if (changed) { const oldViewState = this.controllerState && this.controllerState.getViewportProps(); if (this.onViewStateChange) { this.onViewStateChange({viewState, interactionState: this._interactionState, oldViewState, viewId: this.props.id}); } } } private _onTransition(params: {viewState: Record<string, any>, oldViewState: Record<string, any>}) { this.onViewStateChange({...params, interactionState: this._interactionState, viewId: this.props.id}); } private _setInteractionState(newStates: InteractionState) { Object.assign(this._interactionState, newStates); this.onStateChange(this._interactionState); } /* Event handlers */ // Default handler for the `panstart` event. protected _onPanStart(event: MjolnirGestureEvent): boolean { const pos = this.getCenter(event); if (!this.isPointInBounds(pos, event)) { return false; } let alternateMode = this.isFunctionKeyPressed(event) || event.rightButton || false; if (this.invertPan || this.dragMode === 'pan') { // invertPan is replaced by props.dragMode, keeping for backward compatibility alternateMode = !alternateMode; } const newControllerState = this.controllerState[alternateMode ? 'panStart' : 'rotateStart']({ pos }); this._panMove = alternateMode; this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {isDragging: true}); return true; } // Default handler for the `panmove` and `panend` event. protected _onPan(event: MjolnirGestureEvent): boolean { if (!this.isDragging()) { return false; } return this._panMove ? this._onPanMove(event) : this._onPanRotate(event); } protected _onPanEnd(event: MjolnirGestureEvent): boolean { if (!this.isDragging()) { return false; } return this._panMove ? this._onPanMoveEnd(event) : this._onPanRotateEnd(event); } // Default handler for panning to move. // Called by `_onPan` when panning without function key pressed. protected _onPanMove(event: MjolnirGestureEvent): boolean { if (!this.dragPan) { return false; } const pos = this.getCenter(event); const newControllerState = this.controllerState.pan({pos}); this.updateViewport(newControllerState, NO_TRANSITION_PROPS, { isDragging: true, isPanning: true }); return true; } protected _onPanMoveEnd(event: MjolnirGestureEvent): boolean { const {inertia} = this; if (this.dragPan && inertia && event.velocity) { const pos = this.getCenter(event); const endPos: [number, number] = [ pos[0] + (event.velocityX * inertia) / 2, pos[1] + (event.velocityY * inertia) / 2 ]; const newControllerState = this.controllerState.pan({pos: endPos}).panEnd(); this.updateViewport( newControllerState, { ...this._getTransitionProps(), transitionDuration: inertia, transitionEasing: INERTIA_EASING }, { isDragging: false, isPanning: true } ); } else { const newControllerState = this.controllerState.panEnd(); this.updateViewport(newControllerState, null, { isDragging: false, isPanning: false }); } return true; } // Default handler for panning to rotate. // Called by `_onPan` when panning with function key pressed. protected _onPanRotate(event: MjolnirGestureEvent): boolean { if (!this.dragRotate) { return false; } const pos = this.getCenter(event); const newControllerState = this.controllerState.rotate({pos}); this.updateViewport(newControllerState, NO_TRANSITION_PROPS, { isDragging: true, isRotating: true }); return true; } protected _onPanRotateEnd(event): boolean { const {inertia} = this; if (this.dragRotate && inertia && event.velocity) { const pos = this.getCenter(event); const endPos: [number, number] = [ pos[0] + (event.velocityX * inertia) / 2, pos[1] + (event.velocityY * inertia) / 2 ]; const newControllerState = this.controllerState.rotate({pos: endPos}).rotateEnd(); this.updateViewport( newControllerState, { ...this._getTransitionProps(), transitionDuration: inertia, transitionEasing: INERTIA_EASING }, { isDragging: false, isRotating: true } ); } else { const newControllerState = this.controllerState.rotateEnd(); this.updateViewport(newControllerState, null, { isDragging: false, isRotating: false }); } return true; } // Default handler for the `wheel` event. protected _onWheel(event: MjolnirWheelEvent): boolean { if (!this.scrollZoom) { return false; } const pos = this.getCenter(event); if (!this.isPointInBounds(pos, event)) { return false; } event.srcEvent.preventDefault(); const {speed = 0.01, smooth = false} = this.scrollZoom === true ? {} : this.scrollZoom; const {delta} = event; // Map wheel delta to relative scale let scale = 2 / (1 + Math.exp(-Math.abs(delta * speed))); if (delta < 0 && scale !== 0) { scale = 1 / scale; } const newControllerState = this.controllerState.zoom({pos, scale}); this.updateViewport( newControllerState, {...this._getTransitionProps({around: pos}), transitionDuration: smooth ? 250 : 1}, { isZooming: true, isPanning: true } ); return true; } protected _onMultiPanStart(event: MjolnirGestureEvent): boolean { const pos = this.getCenter(event); if (!this.isPointInBounds(pos, event)) { return false; } const newControllerState = this.controllerState.rotateStart({pos}); this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {isDragging: true}); return true; } protected _onMultiPan(event: MjolnirGestureEvent): boolean { if (!this.touchRotate) { return false; } if (!this.isDragging()) { return false; } const pos = this.getCenter(event); pos[0] -= event.deltaX; const newControllerState = this.controllerState.rotate({pos}); this.updateViewport(newControllerState, NO_TRANSITION_PROPS, { isDragging: true, isRotating: true }); return true; } protected _onMultiPanEnd(event: MjolnirGestureEvent): boolean { if (!this.isDragging()) { return false; } const {inertia} = this; if (this.touchRotate && inertia && event.velocityY) { const pos = this.getCenter(event); const endPos: [number, number] = [pos[0], (pos[1] += (event.velocityY * inertia) / 2)]; const newControllerState = this.controllerState.rotate({pos: endPos}); this.updateViewport( newControllerState, { ...this._getTransitionProps(), transitionDuration: inertia, transitionEasing: INERTIA_EASING }, { isDragging: false, isRotating: true } ); this.blockEvents(inertia); } else { const newControllerState = this.controllerState.rotateEnd(); this.updateViewport(newControllerState, null, { isDragging: false, isRotating: false }); } return true; } // Default handler for the `pinchstart` event. protected _onPinchStart(event: MjolnirGestureEvent): boolean { const pos = this.getCenter(event); if (!this.isPointInBounds(pos, event)) { return false; } 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; this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {isDragging: true}); return true; } // Default handler for the `pinchmove` and `pinchend` events. protected _onPinch(event: MjolnirGestureEvent): boolean { if (!this.touchZoom && !this.touchRotate) { return false; } if (!this.isDragging()) { return false; } let newControllerState = this.controllerState; if (this.touchZoom) { const {scale} = event; const pos = this.getCenter(event); newControllerState = newControllerState.zoom({pos, scale}); } if (this.touchRotate) { const {rotation} = event; newControllerState = newControllerState.rotate({ deltaAngleX: pinchEventWorkaround._startPinchRotation - rotation }); } this.updateViewport(newControllerState, NO_TRANSITION_PROPS, { isDragging: true, isPanning: this.touchZoom, 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 }); } pinchEventWorkaround._startPinchRotation = null; pinchEventWorkaround._lastPinchEvent = null; return true; } // Default handler for the `dblclick` event. protected _onDoubleClick(event: MjolnirGestureEvent): boolean { if (!this.doubleClickZoom) { return false; } const pos = this.getCenter(event); if (!this.isPointInBounds(pos, event)) { return false; } const isZoomOut = this.isFunctionKeyPressed(event); const newControllerState = this.controllerState.zoom({pos, scale: isZoomOut ? 0.5 : 2}); this.updateViewport(newControllerState, this._getTransitionProps({around: pos}), { isZooming: true, isPanning: true }); this.blockEvents(100); return true; } // Default handler for the `keydown` event protected _onKeyDown(event: MjolnirKeyEvent): boolean { if (!this.keyboard) { return false; } const funcKey = this.isFunctionKeyPressed(event); // @ts-ignore const {zoomSpeed, moveSpeed, rotateSpeedX, rotateSpeedY} = this.keyboard === true ? {} : this.keyboard; const {controllerState} = this; let newControllerState; const interactionState: InteractionState = {}; switch (event.srcEvent.code) { case 'Minus': newControllerState = funcKey ? controllerState.zoomOut(zoomSpeed).zoomOut(zoomSpeed) : controllerState.zoomOut(zoomSpeed); interactionState.isZooming = true; break; case 'Equal': newControllerState = funcKey ? controllerState.zoomIn(zoomSpeed).zoomIn(zoomSpeed) : controllerState.zoomIn(zoomSpeed); interactionState.isZooming = true; break; case 'ArrowLeft': if (funcKey) { newControllerState = controllerState.rotateLeft(rotateSpeedX); interactionState.isRotating = true; } else { newControllerState = controllerState.moveLeft(moveSpeed); interactionState.isPanning = true; } break; case 'ArrowRight': if (funcKey) { newControllerState = controllerState.rotateRight(rotateSpeedX); interactionState.isRotating = true; } else { newControllerState = controllerState.moveRight(moveSpeed); interactionState.isPanning = true; } break; case 'ArrowUp': if (funcKey) { newControllerState = controllerState.rotateUp(rotateSpeedY); interactionState.isRotating = true; } else { newControllerState = controllerState.moveUp(moveSpeed); interactionState.isPanning = true; } break; case 'ArrowDown': if (funcKey) { newControllerState = controllerState.rotateDown(rotateSpeedY); interactionState.isRotating = true; } else { newControllerState = controllerState.moveDown(moveSpeed); interactionState.isPanning = true; } break; default: return false; } this.updateViewport(newControllerState, this._getTransitionProps(), interactionState); return true; } protected _getTransitionProps(opts?: any): TransitionProps { const {transition} = this; if (!transition || !transition.transitionInterpolator) { return NO_TRANSITION_PROPS; } // Enables Transitions on double-tap and key-down events. return opts ? { ...transition, transitionInterpolator: new LinearInterpolator({ ...opts, ...(transition.transitionInterpolator as LinearInterpolator).opts, makeViewport: this.controllerState.makeViewport }) } : transition; } }