// deck.gl // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors import {Deck, assert, log} from '@deck.gl/core'; import { getViewState, getDefaultView, getDeckInstance, removeDeckInstance, getDefaultParameters, getProjection, MAPBOX_VIEW_ID } from './deck-utils'; import type {Map, IControl, MapMouseEvent, ControlPosition} from './types'; import type {MjolnirGestureEvent, MjolnirPointerEvent} from 'mjolnir.js'; import type {DeckProps, LayersList} from '@deck.gl/core'; import {resolveLayerGroups} from './resolve-layer-groups'; export type MapboxOverlayProps = Omit< DeckProps, | 'width' | 'height' | 'gl' | 'parent' | 'canvas' | '_customRender' | 'viewState' | 'initialViewState' | 'controller' > & { /** * deck.gl layers are inserted into mapbox-gl's layer stack, and share the same WebGL2RenderingContext as the base map. */ interleaved?: boolean; }; /** * Implements Mapbox [IControl](https://docs.mapbox.com/mapbox-gl-js/api/markers/#icontrol) interface * Renders deck.gl layers over the base map and automatically synchronizes with the map's camera */ export default class MapboxOverlay implements IControl { private _props: MapboxOverlayProps; private _deck?: Deck; private _map?: Map; private _container?: HTMLDivElement; private _interleaved: boolean; private _lastMouseDownPoint?: {x: number; y: number; clientX: number; clientY: number}; constructor(props: MapboxOverlayProps) { const {interleaved = false} = props; this._interleaved = interleaved; this._props = this.filterProps(props); } /** Filter out props to pass to Deck **/ filterProps(props: MapboxOverlayProps): MapboxOverlayProps { const {interleaved = false, useDevicePixels, ...deckProps} = props; if (!interleaved && useDevicePixels !== undefined) { // useDevicePixels cannot be used in interleaved mode (deckProps as MapboxOverlayProps).useDevicePixels = useDevicePixels; } return deckProps; } /** Update (partial) props of the underlying Deck instance. */ setProps(props: MapboxOverlayProps): void { if (this._interleaved && props.layers) { this._resolveLayers(this._map, this._deck, this._props.layers, props.layers); } Object.assign(this._props, this.filterProps(props)); if (this._deck && this._map) { this._deck.setProps({ ...this._props, views: this._getViews(this._map), parameters: { ...getDefaultParameters(this._map, this._interleaved), ...this._props.parameters } }); } } // The local Map type is for internal typecheck only. It does not necesarily satisefy mapbox/maplibre types at runtime. // Do not restrict the argument type here to avoid type conflict. /** Called when the control is added to a map */ onAdd(map: unknown): HTMLDivElement { this._map = map as Map; return this._interleaved ? this._onAddInterleaved(map as Map) : this._onAddOverlaid(map as Map); } private _onAddOverlaid(map: Map): HTMLDivElement { /* global document */ const container = document.createElement('div'); Object.assign(container.style, { position: 'absolute', left: 0, top: 0, textAlign: 'initial', pointerEvents: 'none' }); this._container = container; this._deck = new Deck({ ...this._props, parent: container, parameters: {...getDefaultParameters(map, false), ...this._props.parameters}, views: this._getViews(map), viewState: getViewState(map) }); map.on('resize', this._updateContainerSize); map.on('render', this._updateViewState); map.on('mousedown', this._handleMouseEvent); map.on('dragstart', this._handleMouseEvent); map.on('drag', this._handleMouseEvent); map.on('dragend', this._handleMouseEvent); map.on('mousemove', this._handleMouseEvent); map.on('mouseout', this._handleMouseEvent); map.on('click', this._handleMouseEvent); map.on('dblclick', this._handleMouseEvent); this._updateContainerSize(); return container; } private _onAddInterleaved(map: Map): HTMLDivElement { // @ts-ignore non-public map property const gl: WebGL2RenderingContext = map.painter.context.gl; if (gl instanceof WebGLRenderingContext) { log.warn( 'Incompatible basemap library. See: https://deck.gl/docs/api-reference/mapbox/overview#compatibility' )(); } this._deck = getDeckInstance({ map, deck: new Deck({ ...this._props, views: this._getViews(map), gl, parameters: {...getDefaultParameters(map, true), ...this._props.parameters} }) }); map.on('styledata', this._handleStyleChange); this._resolveLayers(map, this._deck, [], this._props.layers); return document.createElement('div'); } private _resolveLayers( map: Map | undefined, _deck: Deck | undefined, prevLayers: LayersList | undefined, newLayers: LayersList | undefined ): void { resolveLayerGroups(map, prevLayers, newLayers); } /** Called when the control is removed from a map */ onRemove(): void { const map = this._map; if (map) { if (this._interleaved) { this._onRemoveInterleaved(map); } else { this._onRemoveOverlaid(map); } } this._deck = undefined; this._map = undefined; this._container = undefined; } private _onRemoveOverlaid(map: Map): void { map.off('resize', this._updateContainerSize); map.off('render', this._updateViewState); map.off('mousedown', this._handleMouseEvent); map.off('dragstart', this._handleMouseEvent); map.off('drag', this._handleMouseEvent); map.off('dragend', this._handleMouseEvent); map.off('mousemove', this._handleMouseEvent); map.off('mouseout', this._handleMouseEvent); map.off('click', this._handleMouseEvent); map.off('dblclick', this._handleMouseEvent); this._deck?.finalize(); } private _onRemoveInterleaved(map: Map): void { map.off('styledata', this._handleStyleChange); this._resolveLayers(map, this._deck, this._props.layers, []); removeDeckInstance(map); } getDefaultPosition(): ControlPosition { return 'top-left'; } /** Forwards the Deck.pickObject method */ pickObject(params: Parameters[0]): ReturnType { assert(this._deck); return this._deck.pickObject(params); } /** Forwards the Deck.pickMultipleObjects method */ pickMultipleObjects( params: Parameters[0] ): ReturnType { assert(this._deck); return this._deck.pickMultipleObjects(params); } /** Forwards the Deck.pickObjects method */ pickObjects(params: Parameters[0]): ReturnType { assert(this._deck); return this._deck.pickObjects(params); } /** Remove from map and releases all resources */ finalize() { if (this._map) { this._map.removeControl(this); } } /** If interleaved: true, returns base map's canvas, otherwise forwards the Deck.getCanvas method. */ getCanvas(): HTMLCanvasElement | null { if (!this._map) { return null; } return this._interleaved ? this._map.getCanvas() : this._deck!.getCanvas(); } private _handleStyleChange = () => { this._resolveLayers(this._map, this._deck, this._props.layers, this._props.layers); if (!this._map) return; // getProjection() returns undefined before style is loaded const projection = getProjection(this._map); if (projection) { // Update views to match new projection (MapView vs GlobeView) this._deck?.setProps({views: this._getViews(this._map)}); } }; private _updateContainerSize = () => { if (this._map && this._container) { const {clientWidth, clientHeight} = this._map.getContainer(); Object.assign(this._container.style, { width: `${clientWidth}px`, height: `${clientHeight}px` }); } }; private _getViews(map: Map) { if (!this._props.views) { return getDefaultView(map); } // Check if custom views already include a 'mapbox' view const views = Array.isArray(this._props.views) ? this._props.views : [this._props.views]; const hasMapboxView = views.some((v: any) => v.id === MAPBOX_VIEW_ID); if (hasMapboxView) { return this._props.views; } // Add default 'mapbox' view to custom views for consistency with interleaved mode return [getDefaultView(map), ...views]; } private _updateViewState = () => { const deck = this._deck; const map = this._map; if (deck && map) { deck.setProps({ views: this._getViews(map), viewState: getViewState(map) }); // Redraw immediately if view state has changed if (deck.isInitialized) { deck.redraw(); } } }; // eslint-disable-next-line complexity private _handleMouseEvent = (event: MapMouseEvent) => { const deck = this._deck; if (!deck || !deck.isInitialized) { return; } const mockEvent: { type: string; deltaX?: number; deltaY?: number; offsetCenter: {x: number; y: number}; srcEvent: MapMouseEvent; tapCount?: number; } = { type: event.type, offsetCenter: event.point, srcEvent: event }; const lastDown = this._lastMouseDownPoint; if (!event.point && lastDown) { // drag* events do not contain a `point` field mockEvent.deltaX = event.originalEvent.clientX - lastDown.clientX; mockEvent.deltaY = event.originalEvent.clientY - lastDown.clientY; mockEvent.offsetCenter = { x: lastDown.x + mockEvent.deltaX, y: lastDown.y + mockEvent.deltaY }; } switch (mockEvent.type) { case 'mousedown': deck._onPointerDown(mockEvent as unknown as MjolnirPointerEvent); this._lastMouseDownPoint = { ...event.point, clientX: event.originalEvent.clientX, clientY: event.originalEvent.clientY }; break; case 'dragstart': mockEvent.type = 'panstart'; deck._onEvent(mockEvent as unknown as MjolnirGestureEvent); break; case 'drag': mockEvent.type = 'panmove'; deck._onEvent(mockEvent as unknown as MjolnirGestureEvent); break; case 'dragend': mockEvent.type = 'panend'; deck._onEvent(mockEvent as unknown as MjolnirGestureEvent); break; case 'click': mockEvent.tapCount = 1; deck._onEvent(mockEvent as unknown as MjolnirGestureEvent); break; case 'dblclick': mockEvent.type = 'click'; mockEvent.tapCount = 2; deck._onEvent(mockEvent as unknown as MjolnirGestureEvent); break; case 'mousemove': mockEvent.type = 'pointermove'; deck._onPointerMove(mockEvent as unknown as MjolnirPointerEvent); break; case 'mouseout': mockEvent.type = 'pointerleave'; deck._onPointerMove(mockEvent as unknown as MjolnirPointerEvent); break; default: return; } }; }