UNPKG

@deck.gl/core

Version:

deck.gl core library

1,230 lines (1,065 loc) 40.8 kB
// deck.gl // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors import LayerManager from './layer-manager'; import ViewManager from './view-manager'; import MapView from '../views/map-view'; import EffectManager from './effect-manager'; import DeckRenderer from './deck-renderer'; import DeckPicker from './deck-picker'; import {WidgetManager, Widget} from './widget-manager'; import Tooltip from './tooltip'; import log from '../utils/log'; import {deepEqual} from '../utils/deep-equal'; import typedArrayManager from '../utils/typed-array-manager'; import {VERSION} from './init'; import {luma} from '@luma.gl/core'; import {webgl2Adapter} from '@luma.gl/webgl'; import {Timeline} from '@luma.gl/engine'; import {AnimationLoop} from '@luma.gl/engine'; import {GL} from '@luma.gl/constants'; import type {Device, DeviceProps, Framebuffer, Parameters} from '@luma.gl/core'; import type {ShaderModule} from '@luma.gl/shadertools'; import {Stats} from '@probe.gl/stats'; import {EventManager} from 'mjolnir.js'; import assert from '../utils/assert'; import {EVENT_HANDLERS, RECOGNIZERS, RecognizerOptions} from './constants'; import type {Effect} from './effect'; import type {FilterContext} from '../passes/layers-pass'; import type Layer from './layer'; import type View from '../views/view'; import type Viewport from '../viewports/viewport'; import type {EventManagerOptions, MjolnirGestureEvent, MjolnirPointerEvent} from 'mjolnir.js'; import type {TypedArrayManagerOptions} from '../utils/typed-array-manager'; import type {ViewStateChangeParameters, InteractionState} from '../controllers/controller'; import type {PickingInfo} from './picking/pick-info'; import type {PickByPointOptions, PickByRectOptions} from './deck-picker'; import type {LayersList} from './layer-manager'; import type {TooltipContent} from './tooltip'; import type {ViewStateMap, AnyViewStateOf, ViewOrViews, ViewStateObject} from './view-manager'; import {CreateDeviceProps} from '@luma.gl/core'; /* global document */ // eslint-disable-next-line @typescript-eslint/no-empty-function function noop() {} const getCursor = ({isDragging}) => (isDragging ? 'grabbing' : 'grab'); export type DeckMetrics = { fps: number; setPropsTime: number; updateAttributesTime: number; framesRedrawn: number; pickTime: number; pickCount: number; gpuTime: number; gpuTimePerFrame: number; cpuTime: number; cpuTimePerFrame: number; bufferMemory: number; textureMemory: number; renderbufferMemory: number; gpuMemory: number; }; type CursorState = { /** Whether the cursor is over a pickable object */ isHovering: boolean; /** Whether the cursor is down */ isDragging: boolean; }; export type DeckProps<ViewsT extends ViewOrViews = null> = { /** Id of this Deck instance */ id?: string; /** Width of the canvas, a number in pixels or a valid CSS string. * @default `'100%'` */ width?: string | number | null; /** Height of the canvas, a number in pixels or a valid CSS string. * @default `'100%'` */ height?: string | number | null; /** Additional CSS styles for the canvas. */ style?: Partial<CSSStyleDeclaration> | null; /** Controls the resolution of drawing buffer used for rendering. * @default `true` (use browser devicePixelRatio) */ useDevicePixels?: boolean | number; /** Extra pixels around the pointer to include while picking. * @default `0` */ pickingRadius?: number; /** WebGL parameters to be set before each frame is rendered. */ parameters?: Parameters; /** If supplied, will be called before a layer is drawn to determine whether it should be rendered. */ layerFilter?: ((context: FilterContext) => boolean) | null; /** The container to append the auto-created canvas to. * @default `document.body` */ parent?: HTMLDivElement | null; /** The canvas to render into. * Can be either a HTMLCanvasElement or the element id. * Will be auto-created if not supplied. */ canvas?: HTMLCanvasElement | string | null; /** Use an existing luma.gl GPU device. @note If not supplied, a new device will be created using props.deviceProps */ device?: Device | null; /** A new device will be created using these props, assuming that an existing device is not supplied using props.device) */ deviceProps?: CreateDeviceProps; /** WebGL context @deprecated Use props.deviceProps.webgl. Also note that preserveDrawingBuffers is true by default */ gl?: WebGL2RenderingContext | null; /** * The array of Layer instances to be rendered. * Nested arrays are accepted, as well as falsy values (`null`, `false`, `undefined`) */ layers?: LayersList; /** The array of effects to be rendered. A lighting effect will be added if an empty array is supplied. */ effects?: Effect[]; /** A single View instance, or an array of `View` instances. * @default `new MapView()` */ views?: ViewsT; /** Options for viewport interactivity, e.g. pan, rotate and zoom with mouse, touch and keyboard. * This is a shorthand for defining interaction with the `views` prop if you are using the default view (i.e. a single `MapView`) */ controller?: View['props']['controller']; /** * An object that describes the view state for each view in the `views` prop. * Use if the camera state should be managed external to the `Deck` instance. */ viewState?: ViewStateMap<ViewsT> | null; /** * If provided, the `Deck` instance will track camera state changes automatically, * with `initialViewState` as its initial settings. */ initialViewState?: ViewStateMap<ViewsT> | null; /** Allow browser default touch actions. * @default `'none'` */ touchAction?: EventManagerOptions['touchAction']; /** * Optional mjolnir.js recognizer options */ eventRecognizerOptions?: RecognizerOptions; /** (Experimental) Render to a custom frame buffer other than to screen. */ _framebuffer?: Framebuffer | null; /** (Experimental) Forces deck.gl to redraw layers every animation frame. */ _animate?: boolean; /** (Experimental) If set to `false`, force disables all picking features, disregarding the `pickable` prop set in any layer. */ _pickable?: boolean; /** (Experimental) Fine-tune attribute memory usage. See documentation for details. */ _typedArrayManagerProps?: TypedArrayManagerOptions; /** An array of Widget instances to be added to the parent element. */ widgets?: Widget[]; /** Called once the GPU Device has been initiated. */ onDeviceInitialized?: (device: Device) => void; /** @deprecated Called once the WebGL context has been initiated. */ onWebGLInitialized?: (gl: WebGL2RenderingContext) => void; /** Called when the canvas resizes. */ onResize?: (dimensions: {width: number; height: number}) => void; /** Called when the user has interacted with the deck.gl canvas, e.g. using mouse, touch or keyboard. */ onViewStateChange?: <ViewStateT extends AnyViewStateOf<ViewsT>>( params: ViewStateChangeParameters<ViewStateT> ) => ViewStateT | null | void; /** Called when the user has interacted with the deck.gl canvas, e.g. using mouse, touch or keyboard. */ onInteractionStateChange?: (state: InteractionState) => void; /** Called just before the canvas rerenders. */ onBeforeRender?: (context: {device: Device; gl: WebGL2RenderingContext}) => void; /** Called right after the canvas rerenders. */ onAfterRender?: (context: {device: Device; gl: WebGL2RenderingContext}) => void; /** Called once after gl context and all Deck components are created. */ onLoad?: () => void; /** Called if deck.gl encounters an error. * If this callback is set to `null`, errors are silently ignored. * @default `console.error` */ onError?: ((error: Error, layer?: Layer) => void) | null; /** Called when the pointer moves over the canvas. */ onHover?: ((info: PickingInfo, event: MjolnirPointerEvent) => void) | null; /** Called when clicking on the canvas. */ onClick?: ((info: PickingInfo, event: MjolnirGestureEvent) => void) | null; /** Called when the user starts dragging on the canvas. */ onDragStart?: ((info: PickingInfo, event: MjolnirGestureEvent) => void) | null; /** Called when dragging the canvas. */ onDrag?: ((info: PickingInfo, event: MjolnirGestureEvent) => void) | null; /** Called when the user releases from dragging the canvas. */ onDragEnd?: ((info: PickingInfo, event: MjolnirGestureEvent) => void) | null; /** (Experimental) Replace the default redraw procedure */ _customRender?: ((reason: string) => void) | null; /** (Experimental) Called once every second with performance metrics. */ _onMetrics?: ((metrics: DeckMetrics) => void) | null; /** A custom callback to retrieve the cursor type. */ getCursor?: (state: CursorState) => string; /** Callback that takes a hovered-over point and renders a tooltip. */ getTooltip?: ((info: PickingInfo) => TooltipContent) | null; /** (Debug) Flag to enable WebGL debug mode. Requires importing `@luma.gl/debug`. */ debug?: boolean; /** (Debug) Render the picking buffer to screen. */ drawPickingColors?: boolean; }; const defaultProps: DeckProps = { id: '', width: '100%', height: '100%', style: null, viewState: null, initialViewState: null, pickingRadius: 0, layerFilter: null, parameters: {}, parent: null, device: null, deviceProps: {} as DeviceProps, gl: null, canvas: null, layers: [], effects: [], views: null, controller: null, // Rely on external controller, e.g. react-map-gl useDevicePixels: true, touchAction: 'none', eventRecognizerOptions: {}, _framebuffer: null, _animate: false, _pickable: true, _typedArrayManagerProps: {}, _customRender: null, widgets: [], onDeviceInitialized: noop, onWebGLInitialized: noop, onResize: noop, onViewStateChange: noop, onInteractionStateChange: noop, onBeforeRender: noop, onAfterRender: noop, onLoad: noop, onError: (error: Error) => log.error(error.message, error.cause)(), onHover: null, onClick: null, onDragStart: null, onDrag: null, onDragEnd: null, _onMetrics: null, getCursor, getTooltip: null, debug: false, drawPickingColors: false }; /* eslint-disable max-statements */ export default class Deck<ViewsT extends ViewOrViews = null> { static defaultProps = defaultProps; // This is used to defeat tree shaking of init.js // https://github.com/visgl/deck.gl/issues/3213 static VERSION = VERSION; readonly props: Required<DeckProps<ViewsT>>; readonly width: number = 0; readonly height: number = 0; // Allows attaching arbitrary data to the instance readonly userData: Record<string, any> = {}; protected device: Device | null = null; protected canvas: HTMLCanvasElement | null = null; protected viewManager: ViewManager<View[]> | null = null; protected layerManager: LayerManager | null = null; protected effectManager: EffectManager | null = null; protected deckRenderer: DeckRenderer | null = null; protected deckPicker: DeckPicker | null = null; protected eventManager: EventManager | null = null; protected widgetManager: WidgetManager | null = null; protected tooltip: Tooltip | null = null; protected animationLoop: AnimationLoop | null = null; /** Internal view state if no callback is supplied */ protected viewState: ViewStateObject<ViewsT> | null; protected cursorState: CursorState = { isHovering: false, isDragging: false }; protected stats = new Stats({id: 'deck.gl'}); protected metrics: DeckMetrics = { fps: 0, setPropsTime: 0, updateAttributesTime: 0, framesRedrawn: 0, pickTime: 0, pickCount: 0, gpuTime: 0, gpuTimePerFrame: 0, cpuTime: 0, cpuTimePerFrame: 0, bufferMemory: 0, textureMemory: 0, renderbufferMemory: 0, gpuMemory: 0 }; private _metricsCounter: number = 0; private _needsRedraw: false | string = 'Initial render'; private _pickRequest: { mode: string; event: MjolnirPointerEvent | null; x: number; y: number; radius: number; } = { mode: 'hover', x: -1, y: -1, radius: 0, event: null }; /** * Pick and store the object under the pointer on `pointerdown`. * This object is reused for subsequent `onClick` and `onDrag*` callbacks. */ private _lastPointerDownInfo: PickingInfo | null = null; constructor(props: DeckProps<ViewsT>) { // @ts-ignore views this.props = {...defaultProps, ...props}; props = this.props; if (props.viewState && props.initialViewState) { log.warn( 'View state tracking is disabled. Use either `initialViewState` for auto update or `viewState` for manual update.' )(); } this.viewState = this.props.initialViewState; // See if we already have a device if (props.device) { this.device = props.device; } let deviceOrPromise: Device | Promise<Device> | null = this.device; // Attach a new luma.gl device to a WebGL2 context if supplied if (!deviceOrPromise && props.gl) { if (props.gl instanceof WebGLRenderingContext) { log.error('WebGL1 context not supported.')(); } deviceOrPromise = webgl2Adapter.attach(props.gl); } // Create a new device if (!deviceOrPromise) { // Create the "best" device supported from the registered adapters deviceOrPromise = luma.createDevice({ type: 'best-available', // luma by default throws if a device is already attached // asynchronous device creation could happen after finalize() is called // TODO - createDevice should support AbortController? _reuseDevices: true, adapters: [webgl2Adapter], ...props.deviceProps, createCanvasContext: { canvas: this._createCanvas(props), useDevicePixels: this.props.useDevicePixels, // TODO v9.2 - replace AnimationLoop's `autoResizeDrawingBuffer` with CanvasContext's `autoResize` autoResize: false } }); } this.animationLoop = this._createAnimationLoop(deviceOrPromise, props); this.setProps(props); // UNSAFE/experimental prop: only set at initialization to avoid performance hit if (props._typedArrayManagerProps) { typedArrayManager.setOptions(props._typedArrayManagerProps); } this.animationLoop.start(); } /** Stop rendering and dispose all resources */ finalize() { this.animationLoop?.stop(); this.animationLoop?.destroy(); this.animationLoop = null; this._lastPointerDownInfo = null; this.layerManager?.finalize(); this.layerManager = null; this.viewManager?.finalize(); this.viewManager = null; this.effectManager?.finalize(); this.effectManager = null; this.deckRenderer?.finalize(); this.deckRenderer = null; this.deckPicker?.finalize(); this.deckPicker = null; this.eventManager?.destroy(); this.eventManager = null; this.widgetManager?.finalize(); this.widgetManager = null; if (!this.props.canvas && !this.props.device && !this.props.gl && this.canvas) { // remove internally created canvas this.canvas.parentElement?.removeChild(this.canvas); this.canvas = null; } } /** Partially update props */ setProps(props: DeckProps<ViewsT>): void { this.stats.get('setProps Time').timeStart(); if ('onLayerHover' in props) { log.removed('onLayerHover', 'onHover')(); } if ('onLayerClick' in props) { log.removed('onLayerClick', 'onClick')(); } if ( props.initialViewState && // depth = 3 when comparing viewStates: viewId.position.0 !deepEqual(this.props.initialViewState, props.initialViewState, 3) ) { // Overwrite internal view state this.viewState = props.initialViewState; } // Merge with existing props Object.assign(this.props, props); // Update CSS size of canvas this._setCanvasSize(this.props); // We need to overwrite CSS style width and height with actual, numeric values const resolvedProps: Required<DeckProps> & { width: number; height: number; views: View[]; viewState: ViewStateObject<ViewsT> | null; } = Object.create(this.props); Object.assign(resolvedProps, { views: this._getViews(), width: this.width, height: this.height, viewState: this._getViewState() }); // Update the animation loop this.animationLoop?.setProps(resolvedProps); // If initialized, update sub manager props if (this.layerManager) { this.viewManager!.setProps(resolvedProps); // Make sure that any new layer gets initialized with the current viewport this.layerManager.activateViewport(this.getViewports()[0]); this.layerManager.setProps(resolvedProps); this.effectManager!.setProps(resolvedProps); this.deckRenderer!.setProps(resolvedProps); this.deckPicker!.setProps(resolvedProps); this.widgetManager!.setProps(resolvedProps); } this.stats.get('setProps Time').timeEnd(); } // Public API /** * Check if a redraw is needed * @returns `false` or a string summarizing the redraw reason */ needsRedraw( opts: { /** Reset the redraw flag afterwards. Default `true` */ clearRedrawFlags: boolean; } = {clearRedrawFlags: false} ): false | string { if (!this.layerManager) { // Not initialized or already finalized return false; } if (this.props._animate) { return 'Deck._animate'; } let redraw: false | string = this._needsRedraw; if (opts.clearRedrawFlags) { this._needsRedraw = false; } const viewManagerNeedsRedraw = this.viewManager!.needsRedraw(opts); const layerManagerNeedsRedraw = this.layerManager.needsRedraw(opts); const effectManagerNeedsRedraw = this.effectManager!.needsRedraw(opts); const deckRendererNeedsRedraw = this.deckRenderer!.needsRedraw(opts); redraw = redraw || viewManagerNeedsRedraw || layerManagerNeedsRedraw || effectManagerNeedsRedraw || deckRendererNeedsRedraw; return redraw; } /** * Redraw the GL context * @param reason If not provided, only redraw if deemed necessary. Otherwise redraw regardless of internal states. * @returns */ redraw(reason?: string): void { if (!this.layerManager) { // Not yet initialized return; } // Check if we need to redraw let redrawReason = this.needsRedraw({clearRedrawFlags: true}); // User-supplied should take precedent, however the redraw flags get cleared regardless redrawReason = reason || redrawReason; if (!redrawReason) { return; } this.stats.get('Redraw Count').incrementCount(); if (this.props._customRender) { this.props._customRender(redrawReason); } else { this._drawLayers(redrawReason); } } /** Flag indicating that the Deck instance has initialized its resources and it's safe to call public methods. */ get isInitialized(): boolean { return this.viewManager !== null; } /** Get a list of views that are currently rendered */ getViews(): View[] { assert(this.viewManager); return this.viewManager.views; } /** Get a list of viewports that are currently rendered. * @param rect If provided, only returns viewports within the given bounding box. */ getViewports(rect?: {x: number; y: number; width?: number; height?: number}): Viewport[] { assert(this.viewManager); return this.viewManager.getViewports(rect); } /** Get the current canvas element. */ getCanvas(): HTMLCanvasElement | null { return this.canvas; } /** Query the object rendered on top at a given point */ pickObject(opts: { /** x position in pixels */ x: number; /** y position in pixels */ y: number; /** Radius of tolerance in pixels. Default `0`. */ radius?: number; /** A list of layer ids to query from. If not specified, then all pickable and visible layers are queried. */ layerIds?: string[]; /** If `true`, `info.coordinate` will be a 3D point by unprojecting the `x, y` screen coordinates onto the picked geometry. Default `false`. */ unproject3D?: boolean; }): PickingInfo | null { const infos = this._pick('pickObject', 'pickObject Time', opts).result; return infos.length ? infos[0] : null; } /* Query all rendered objects at a given point */ pickMultipleObjects(opts: { /** x position in pixels */ x: number; /** y position in pixels */ y: number; /** Radius of tolerance in pixels. Default `0`. */ radius?: number; /** Specifies the max number of objects to return. Default `10`. */ depth?: number; /** A list of layer ids to query from. If not specified, then all pickable and visible layers are queried. */ layerIds?: string[]; /** If `true`, `info.coordinate` will be a 3D point by unprojecting the `x, y` screen coordinates onto the picked geometry. Default `false`. */ unproject3D?: boolean; }): PickingInfo[] { opts.depth = opts.depth || 10; return this._pick('pickObject', 'pickMultipleObjects Time', opts).result; } /* Query all objects rendered on top within a bounding box */ pickObjects(opts: { /** Left of the bounding box in pixels */ x: number; /** Top of the bounding box in pixels */ y: number; /** Width of the bounding box in pixels. Default `1` */ width?: number; /** Height of the bounding box in pixels. Default `1` */ height?: number; /** A list of layer ids to query from. If not specified, then all pickable and visible layers are queried. */ layerIds?: string[]; /** If specified, limits the number of objects that can be returned. */ maxObjects?: number | null; }): PickingInfo[] { return this._pick('pickObjects', 'pickObjects Time', opts); } /** Experimental * Add a global resource for sharing among layers */ _addResources( resources: { [id: string]: any; }, forceUpdate = false ) { for (const id in resources) { this.layerManager!.resourceManager.add({resourceId: id, data: resources[id], forceUpdate}); } } /** Experimental * Remove a global resource */ _removeResources(resourceIds: string[]) { for (const id of resourceIds) { this.layerManager!.resourceManager.remove(id); } } /** Experimental * Register a default effect. Effects will be sorted by order, those with a low order will be rendered first */ _addDefaultEffect(effect: Effect) { this.effectManager!.addDefaultEffect(effect); } _addDefaultShaderModule(module: ShaderModule<Record<string, unknown>>) { this.layerManager!.addDefaultShaderModule(module); } _removeDefaultShaderModule(module: ShaderModule<Record<string, unknown>>) { this.layerManager?.removeDefaultShaderModule(module); } // Private Methods private _pick( method: 'pickObject', statKey: string, opts: PickByPointOptions & {layerIds?: string[]} ): { result: PickingInfo[]; emptyInfo: PickingInfo; }; private _pick( method: 'pickObjects', statKey: string, opts: PickByRectOptions & {layerIds?: string[]} ): PickingInfo[]; private _pick( method: 'pickObject' | 'pickObjects', statKey: string, opts: (PickByPointOptions | PickByRectOptions) & {layerIds?: string[]} ) { assert(this.deckPicker); const {stats} = this; stats.get('Pick Count').incrementCount(); stats.get(statKey).timeStart(); const infos = this.deckPicker[method]({ // layerManager, viewManager and effectManager are always defined if deckPicker is layers: this.layerManager!.getLayers(opts), views: this.viewManager!.getViews(), viewports: this.getViewports(opts), onViewportActive: this.layerManager!.activateViewport, effects: this.effectManager!.getEffects(), ...opts }); stats.get(statKey).timeEnd(); return infos; } /** Resolve props.canvas to element */ private _createCanvas(props: DeckProps<ViewsT>): HTMLCanvasElement { let canvas = props.canvas; // TODO EventManager should accept element id if (typeof canvas === 'string') { canvas = document.getElementById(canvas) as HTMLCanvasElement; assert(canvas); } if (!canvas) { canvas = document.createElement('canvas'); canvas.id = props.id || 'deckgl-overlay'; const parent = props.parent || document.body; parent.appendChild(canvas); } Object.assign(canvas.style, props.style); return canvas; } /** Updates canvas width and/or height, if provided as props */ private _setCanvasSize(props: Required<DeckProps<ViewsT>>): void { if (!this.canvas) { return; } const {width, height} = props; // Set size ONLY if props are being provided, otherwise let canvas be layouted freely if (width || width === 0) { const cssWidth = Number.isFinite(width) ? `${width}px` : (width as string); this.canvas.style.width = cssWidth; } if (height || height === 0) { const cssHeight = Number.isFinite(height) ? `${height}px` : (height as string); // Note: position==='absolute' required for height 100% to work this.canvas.style.position = props.style?.position || 'absolute'; this.canvas.style.height = cssHeight; } } /** If canvas size has changed, reads out the new size and update */ private _updateCanvasSize(): void { const {canvas} = this; if (!canvas) { return; } // Fallback to width/height when clientWidth/clientHeight are undefined (OffscreenCanvas). const newWidth = canvas.clientWidth ?? canvas.width; const newHeight = canvas.clientHeight ?? canvas.height; if (newWidth !== this.width || newHeight !== this.height) { // @ts-expect-error private assign to read-only property this.width = newWidth; // @ts-expect-error private assign to read-only property this.height = newHeight; this.viewManager?.setProps({width: newWidth, height: newHeight}); // Make sure that any new layer gets initialized with the current viewport this.layerManager?.activateViewport(this.getViewports()[0]); this.props.onResize({width: newWidth, height: newHeight}); } } private _createAnimationLoop( deviceOrPromise: Device | Promise<Device>, props: DeckProps<ViewsT> ): AnimationLoop { const { // width, // height, gl, // debug, onError, // onBeforeRender, // onAfterRender, useDevicePixels } = props; return new AnimationLoop({ device: deviceOrPromise, useDevicePixels, // TODO v9 autoResizeDrawingBuffer: !gl, // do not auto resize external context autoResizeViewport: false, // @ts-expect-error luma.gl needs to accept Promise<void> return value onInitialize: context => this._setDevice(context.device), onRender: this._onRenderFrame.bind(this), // @ts-expect-error typing mismatch: AnimationLoop does not accept onError:null onError // onBeforeRender, // onAfterRender, }); } // Get the most relevant view state: props.viewState, if supplied, shadows internal viewState // TODO: For backwards compatibility ensure numeric width and height is added to the viewState private _getViewState(): ViewStateObject<ViewsT> | null { return this.props.viewState || this.viewState; } // Get the view descriptor list private _getViews(): View[] { const {views} = this.props; const normalizedViews: View[] = Array.isArray(views) ? views : // If null, default to a full screen map view port views ? [views] : [new MapView({id: 'default-view'})]; if (normalizedViews.length && this.props.controller) { // Backward compatibility: support controller prop normalizedViews[0].props.controller = this.props.controller; } return normalizedViews; } private _onContextLost() { const {onError} = this.props; if (this.animationLoop && onError) { onError(new Error('WebGL context is lost')); } } // The `pointermove` event may fire multiple times in between two animation frames, // it's a waste of time to run picking without rerender. Instead we save the last pick // request and only do it once on the next animation frame. /** Internal use only: event handler for pointerdown */ _onPointerMove = (event: MjolnirPointerEvent) => { const {_pickRequest} = this; if (event.type === 'pointerleave') { _pickRequest.x = -1; _pickRequest.y = -1; _pickRequest.radius = 0; } else if (event.leftButton || event.rightButton) { // Do not trigger onHover callbacks if mouse button is down. return; } else { const pos = event.offsetCenter; // Do not trigger callbacks when click/hover position is invalid. Doing so will cause a // assertion error when attempting to unproject the position. if (!pos) { return; } _pickRequest.x = pos.x; _pickRequest.y = pos.y; _pickRequest.radius = this.props.pickingRadius; } if (this.layerManager) { this.layerManager.context.mousePosition = {x: _pickRequest.x, y: _pickRequest.y}; } _pickRequest.event = event; }; /** Actually run picking */ private _pickAndCallback() { if (this.device?.type === 'webgpu') { return; } const {_pickRequest} = this; if (_pickRequest.event) { // Perform picking const {result, emptyInfo} = this._pick('pickObject', 'pickObject Time', _pickRequest); this.cursorState.isHovering = result.length > 0; // There are 4 possible scenarios: // result is [outInfo, pickedInfo] (moved from one pickable layer to another) // result is [outInfo] (moved outside of a pickable layer) // result is [pickedInfo] (moved into or over a pickable layer) // result is [] (nothing is or was picked) // // `layer.props.onHover` should be called on all affected layers (out/over) // `deck.props.onHover` should be called with the picked info if any, or empty info otherwise // `deck.props.getTooltip` should be called with the picked info if any, or empty info otherwise // Execute callbacks let pickedInfo = emptyInfo; let handled = false; for (const info of result) { pickedInfo = info; handled = info.layer?.onHover(info, _pickRequest.event) || handled; } if (!handled) { this.props.onHover?.(pickedInfo, _pickRequest.event); this.widgetManager!.onHover(pickedInfo, _pickRequest.event); } // Clear pending pickRequest _pickRequest.event = null; } } private _updateCursor(): void { const container = this.props.parent || this.canvas; if (container) { container.style.cursor = this.props.getCursor(this.cursorState); } } private _setDevice(device: Device) { this.device = device; if (!this.animationLoop) { // finalize() has been called return; } // if external context... if (!this.canvas) { this.canvas = this.device.canvasContext?.canvas as HTMLCanvasElement; // TODO v9 // ts-expect-error - Currently luma.gl v9 does not expose these options // All WebGLDevice contexts are instrumented, but it seems the device // should have a method to start state tracking even if not enabled? // instrumentGLContext(this.device.gl, {enable: true, copyState: true}); } if (this.device.type === 'webgl') { this.device.setParametersWebGL({ blend: true, blendFunc: [GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA, GL.ONE, GL.ONE_MINUS_SRC_ALPHA], polygonOffsetFill: true, depthTest: true, depthFunc: GL.LEQUAL }); } this.props.onDeviceInitialized(this.device); if (this.device.type === 'webgl') { // Legacy callback - warn? // @ts-expect-error gl is not visible on Device base class this.props.onWebGLInitialized(this.device.gl); } // timeline for transitions const timeline = new Timeline(); timeline.play(); this.animationLoop.attachTimeline(timeline); this.eventManager = new EventManager(this.props.parent || this.canvas, { touchAction: this.props.touchAction, recognizers: Object.keys(RECOGNIZERS).map((eventName: string) => { // Resolve recognizer settings const [RecognizerConstructor, defaultOptions, recognizeWith, requestFailure] = RECOGNIZERS[eventName]; const optionsOverride = this.props.eventRecognizerOptions?.[eventName]; const options = {...defaultOptions, ...optionsOverride, event: eventName}; return { recognizer: new RecognizerConstructor(options), recognizeWith, requestFailure }; }), events: { pointerdown: this._onPointerDown, pointermove: this._onPointerMove, pointerleave: this._onPointerMove } }); for (const eventType in EVENT_HANDLERS) { this.eventManager.on(eventType, this._onEvent); } this.viewManager = new ViewManager({ timeline, eventManager: this.eventManager, onViewStateChange: this._onViewStateChange.bind(this), onInteractionStateChange: this._onInteractionStateChange.bind(this), views: this._getViews(), viewState: this._getViewState(), width: this.width, height: this.height }); // viewManager must be initialized before layerManager // layerManager depends on viewport created by viewManager. const viewport = this.viewManager.getViewports()[0]; // Note: avoid React setState due GL animation loop / setState timing issue this.layerManager = new LayerManager(this.device, { deck: this, stats: this.stats, viewport, timeline }); this.effectManager = new EffectManager({ deck: this, device: this.device }); this.deckRenderer = new DeckRenderer(this.device); this.deckPicker = new DeckPicker(this.device); this.widgetManager = new WidgetManager({ deck: this, parentElement: this.canvas?.parentElement }); this.widgetManager.addDefault(new Tooltip()); this.setProps(this.props); this._updateCanvasSize(); this.props.onLoad(); } /** Internal only: default render function (redraw all layers and views) */ _drawLayers( redrawReason: string, renderOptions?: { target?: Framebuffer; layerFilter?: (context: FilterContext) => boolean; layers?: Layer[]; viewports?: Viewport[]; views?: {[viewId: string]: View}; pass?: string; effects?: Effect[]; clearStack?: boolean; clearCanvas?: boolean; } ) { const {device, gl} = this.layerManager!.context; this.props.onBeforeRender({device, gl}); const opts = { target: this.props._framebuffer, layers: this.layerManager!.getLayers(), viewports: this.viewManager!.getViewports(), onViewportActive: this.layerManager!.activateViewport, views: this.viewManager!.getViews(), pass: 'screen', effects: this.effectManager!.getEffects(), ...renderOptions }; this.deckRenderer?.renderLayers(opts); if (opts.pass === 'screen') { // This method could be called when drawing to picking buffer, texture etc. // Only when drawing to screen, update all widgets (UI components) this.widgetManager!.onRedraw({ viewports: opts.viewports, layers: opts.layers }); } this.props.onAfterRender({device, gl}); } // Callbacks private _onRenderFrame() { this._getFrameStats(); // Log perf stats every second if (this._metricsCounter++ % 60 === 0) { this._getMetrics(); this.stats.reset(); log.table(4, this.metrics)(); // Experimental: report metrics if (this.props._onMetrics) { this.props._onMetrics(this.metrics); } } this._updateCanvasSize(); this._updateCursor(); // Update layers if needed (e.g. some async prop has loaded) // Note: This can trigger a redraw this.layerManager!.updateLayers(); // Perform picking request if any // TODO(ibgreen): Picking not yet supported on WebGPU if (this.device?.type !== 'webgpu') { this._pickAndCallback(); } // Redraw if necessary this.redraw(); // Update viewport transition if needed // Note: this can trigger `onViewStateChange`, and affect layers // We want to defer these changes to the next frame if (this.viewManager) { this.viewManager.updateViewStates(); } } // Callbacks private _onViewStateChange(params: ViewStateChangeParameters & {viewId: string}) { // Let app know that view state is changing, and give it a chance to change it const viewState = this.props.onViewStateChange(params) || params.viewState; // If initialViewState was set on creation, auto track position if (this.viewState) { this.viewState = {...this.viewState, [params.viewId]: viewState}; if (!this.props.viewState) { // Apply internal view state if (this.viewManager) { this.viewManager.setProps({viewState: this.viewState}); } } } } private _onInteractionStateChange(interactionState: InteractionState) { this.cursorState.isDragging = interactionState.isDragging || false; this.props.onInteractionStateChange(interactionState); } /** Internal use only: event handler for click & drag */ _onEvent = (event: MjolnirGestureEvent) => { const eventHandlerProp = EVENT_HANDLERS[event.type]; const pos = event.offsetCenter; if (!eventHandlerProp || !pos || !this.layerManager) { return; } // Reuse last picked object const layers = this.layerManager.getLayers(); const info = this.deckPicker!.getLastPickedObject( { x: pos.x, y: pos.y, layers, viewports: this.getViewports(pos) }, this._lastPointerDownInfo ) as PickingInfo; const {layer} = info; const layerHandler = layer && (layer[eventHandlerProp] || layer.props[eventHandlerProp]); const rootHandler = this.props[eventHandlerProp]; let handled = false; if (layerHandler) { handled = layerHandler.call(layer, info, event); } if (!handled) { rootHandler?.(info, event); this.widgetManager!.onEvent(info, event); } }; /** Internal use only: evnet handler for pointerdown */ _onPointerDown = (event: MjolnirPointerEvent) => { // TODO(ibgreen) Picking not yet supported on WebGPU if (this.device?.type === 'webgpu') { return; } const pos = event.offsetCenter; const pickedInfo = this._pick('pickObject', 'pickObject Time', { x: pos.x, y: pos.y, radius: this.props.pickingRadius }); this._lastPointerDownInfo = pickedInfo.result[0] || pickedInfo.emptyInfo; }; private _getFrameStats(): void { const {stats} = this; stats.get('frameRate').timeEnd(); stats.get('frameRate').timeStart(); // Get individual stats from luma.gl so reset works const animationLoopStats = this.animationLoop!.stats; stats.get('GPU Time').addTime(animationLoopStats.get('GPU Time').lastTiming); stats.get('CPU Time').addTime(animationLoopStats.get('CPU Time').lastTiming); } private _getMetrics(): void { const {metrics, stats} = this; metrics.fps = stats.get('frameRate').getHz(); metrics.setPropsTime = stats.get('setProps Time').time; metrics.updateAttributesTime = stats.get('Update Attributes').time; metrics.framesRedrawn = stats.get('Redraw Count').count; metrics.pickTime = stats.get('pickObject Time').time + stats.get('pickMultipleObjects Time').time + stats.get('pickObjects Time').time; metrics.pickCount = stats.get('Pick Count').count; // Luma stats metrics.gpuTime = stats.get('GPU Time').time; metrics.cpuTime = stats.get('CPU Time').time; metrics.gpuTimePerFrame = stats.get('GPU Time').getAverageTime(); metrics.cpuTimePerFrame = stats.get('CPU Time').getAverageTime(); const memoryStats = luma.stats.get('Memory Usage'); metrics.bufferMemory = memoryStats.get('Buffer Memory').count; metrics.textureMemory = memoryStats.get('Texture Memory').count; metrics.renderbufferMemory = memoryStats.get('Renderbuffer Memory').count; metrics.gpuMemory = memoryStats.get('GPU Memory').count; } }