// deck.gl // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors import {Deck, MapView, _GlobeView as GlobeView, _flatten as flatten} from '@deck.gl/core'; import type {Viewport, MapViewState, Layer} from '@deck.gl/core'; import type {Parameters} from '@luma.gl/core'; import type MapboxLayerGroup from './mapbox-layer-group'; import type {LayerOverlayProps, Map} from './types'; import {getLayerGroupId} from './resolve-layer-groups'; import {lngLatToWorld, unitsPerMeter} from '@math.gl/web-mercator'; export const MAPBOX_VIEW_ID = 'mapbox'; type UserData = { currentViewport?: Viewport | null; }; // Mercator constants const TILE_SIZE = 512; const DEGREES_TO_RADIANS = Math.PI / 180; // Create an interleaved deck instance. export function getDeckInstance({ map, deck }: { map: Map & {__deck?: Deck | null}; deck: Deck; }): Deck { // Only create one deck instance per context if (map.__deck) { return map.__deck; } // Only initialize certain props once per context const customRender = deck.props._customRender; const onLoad = deck.props.onLoad; const deckProps = { ...deck.props, _customRender: () => { map.triggerRepaint(); // customRender may be subscribed by DeckGL React component to update child props // make sure it is still called // Hack - do not pass a redraw reason here to prevent the React component from clearing the context // Rerender will be triggered by MapboxLayerGroup's render() customRender?.(''); } }; deckProps.views ||= getDefaultView(map); // deck is using the WebGLContext created by mapbox, // block deck from setting the canvas size, and use the map's viewState to drive deck. Object.assign(deckProps, { width: null, height: null, touchAction: 'unset', viewState: getViewState(map) }); if (deck.isInitialized) { watchMapMove(deck, map); } else { deckProps.onLoad = () => { onLoad?.(); watchMapMove(deck, map); }; } deck.setProps(deckProps); map.__deck = deck; map.on('render', () => { if (deck.isInitialized) afterRender(deck, map); }); return deck; } function watchMapMove(deck: Deck, map: Map & {__deck?: Deck | null}) { const _handleMapMove = () => { if (deck.isInitialized) { // call view state methods onMapMove(deck, map); } else { // deregister itself when deck is finalized map.off('move', _handleMapMove); } }; map.on('move', _handleMapMove); } export function removeDeckInstance(map: Map & {__deck?: Deck | null}) { map.__deck?.finalize(); map.__deck = null; } export function getDefaultParameters(map: Map, interleaved: boolean): Parameters { const result: Parameters = interleaved ? { depthWriteEnabled: true, depthCompare: 'less-equal', depthBias: 0, blend: true, blendColorSrcFactor: 'src-alpha', blendColorDstFactor: 'one-minus-src-alpha', blendAlphaSrcFactor: 'one', blendAlphaDstFactor: 'one-minus-src-alpha', blendColorOperation: 'add', blendAlphaOperation: 'add' } : {}; if (getProjection(map) === 'globe') { result.cullMode = 'back'; } return result; } export function drawLayerGroup( deck: Deck, map: Map, group: MapboxLayerGroup, renderParameters: any ): void { if (!deck.isInitialized) { return; } let {currentViewport} = deck.userData as UserData; let clearStack: boolean = false; if (!currentViewport) { // This is the first layer drawn in this render cycle. // Generate viewport from the current map state. currentViewport = getViewport(deck, map, renderParameters); (deck.userData as UserData).currentViewport = currentViewport; clearStack = true; } if (!currentViewport) { return; } deck._drawLayers('mapbox-repaint', { viewports: [currentViewport], layerFilter: params => { if (deck.props.layerFilter && !deck.props.layerFilter(params)) { return false; } const layer = params.layer as Layer; if (layer.props.beforeId === group.beforeId && layer.props.slot === group.slot) { return true; } return false; }, clearStack, clearCanvas: false }); } export function getProjection(map: Map): 'mercator' | 'globe' { const projection = map.getProjection?.(); const type = // maplibre projection spec projection?.type || // mapbox projection spec projection?.name; if (type === 'globe') { return 'globe'; } if (type && type !== 'mercator') { throw new Error('Unsupported projection'); } return 'mercator'; } export function getDefaultView(map: Map): GlobeView | MapView { if (getProjection(map) === 'globe') { return new GlobeView({id: MAPBOX_VIEW_ID}); } return new MapView({id: MAPBOX_VIEW_ID}); } export function getViewState(map: Map): MapViewState & { repeat: boolean; padding: { left: number; right: number; top: number; bottom: number; }; } { const {lng, lat} = map.getCenter(); const viewState: MapViewState & { repeat: boolean; padding: { left: number; right: number; top: number; bottom: number; }; } = { // Longitude returned by getCenter can be outside of [-180, 180] when zooming near the anti meridian // https://github.com/visgl/deck.gl/issues/6894 longitude: ((lng + 540) % 360) - 180, latitude: lat, zoom: map.getZoom(), bearing: map.getBearing(), pitch: map.getPitch(), padding: map.getPadding(), repeat: map.getRenderWorldCopies() }; if (map.getTerrain?.()) { // When the base map has terrain, we need to target the camera at the terrain surface centerCameraOnTerrain(map, viewState); } return viewState; } function centerCameraOnTerrain(map: Map, viewState: MapViewState) { if (map.getFreeCameraOptions) { // mapbox-gl v2 const {position} = map.getFreeCameraOptions(); if (!position || position.z === undefined) { return; } // @ts-ignore transform is not typed const height = map.transform.height; const {longitude, latitude, pitch} = viewState; // Convert mapbox mercator coordinate to deck common space const cameraX = position.x * TILE_SIZE; const cameraY = (1 - position.y) * TILE_SIZE; const cameraZ = position.z * TILE_SIZE; // Mapbox manipulates zoom in terrain mode, see discussion here: https://github.com/mapbox/mapbox-gl-js/issues/12040 const center = lngLatToWorld([longitude, latitude]); const dx = cameraX - center[0]; const dy = cameraY - center[1]; const cameraToCenterDistanceGround = Math.sqrt(dx * dx + dy * dy); const pitchRadians = pitch! * DEGREES_TO_RADIANS; const altitudePixels = 1.5 * height; const scale = pitchRadians < 0.001 ? // Pitch angle too small to deduce the look at point, assume elevation is 0 (altitudePixels * Math.cos(pitchRadians)) / cameraZ : (altitudePixels * Math.sin(pitchRadians)) / cameraToCenterDistanceGround; viewState.zoom = Math.log2(scale); const cameraZFromSurface = (altitudePixels * Math.cos(pitchRadians)) / scale; const surfaceElevation = cameraZ - cameraZFromSurface; viewState.position = [0, 0, surfaceElevation / unitsPerMeter(latitude)]; } // @ts-ignore transform is not typed else if (typeof map.transform.elevation === 'number') { // maplibre-gl // @ts-ignore transform is not typed viewState.position = [0, 0, map.transform.elevation]; } } // Since maplibre-gl@5 // https://github.com/maplibre/maplibre-gl-js/blob/main/src/style/style_layer/custom_style_layer.ts type MaplibreRenderParameters = { farZ: number; nearZ: number; fov: number; modelViewProjectionMatrix: number[]; projectionMatrix: number[]; }; function getViewport(deck: Deck, map: Map, renderParameters?: unknown): Viewport | null { const viewState = getViewState(map); // View is always MapView or GlobeView in this context const view = (deck.getView(MAPBOX_VIEW_ID) || getDefaultView(map)) as MapView | GlobeView; if (renderParameters) { // Called from MapboxLayerGroup.render // Magic number, matches mapbox-gl@>=1.3.0's projection matrix view.props.nearZMultiplier = 0.2; } // Get the base map near/far plane // renderParameters is maplibre API but not mapbox // Transform is not an official API, properties could be undefined for older versions const nearZ = (renderParameters as MaplibreRenderParameters)?.nearZ ?? map.transform._nearZ; const farZ = (renderParameters as MaplibreRenderParameters)?.farZ ?? map.transform._farZ; if (Number.isFinite(nearZ)) { viewState.nearZ = nearZ / map.transform.height; viewState.farZ = farZ / map.transform.height; } // Otherwise fallback to default calculation using nearZMultiplier/farZMultiplier return view.makeViewport({ width: deck.width, height: deck.height, viewState }); } function afterRender(deck: Deck, map: Map): void { // Draw non-Mapbox layers (layers that don't have a corresponding MapboxLayerGroup on the map) const deckLayers = flatten(deck.props.layers, Boolean) as Layer[]; const hasNonMapboxLayers = deckLayers.some( layer => layer && !map.getLayer(getLayerGroupId(layer)) ); let viewports = deck.getViewports(); const mapboxViewportIdx = viewports.findIndex(vp => vp.id === MAPBOX_VIEW_ID); const hasNonMapboxViews = viewports.length > 1 || mapboxViewportIdx < 0; if (hasNonMapboxLayers || hasNonMapboxViews) { if (mapboxViewportIdx >= 0) { viewports = viewports.slice(); const mapboxViewport = getViewport(deck, map); if (mapboxViewport) { viewports[mapboxViewportIdx] = mapboxViewport; } else { viewports.splice(mapboxViewportIdx, 1); } } deck._drawLayers('mapbox-repaint', { viewports, layerFilter: params => (!deck.props.layerFilter || deck.props.layerFilter(params)) && (params.viewport.id !== MAPBOX_VIEW_ID || !map.getLayer(getLayerGroupId(params.layer as Layer))), clearCanvas: false }); } else { // Even when there are no non-Mapbox layers to draw, fire lifecycle callbacks // so that consumers can still track view state changes via onAfterRender const device = (deck as any).device; const gl = device?.gl; deck.props.onBeforeRender?.({device, gl}); deck.props.onAfterRender?.({device, gl}); } // End of render cycle, clear generated viewport (deck.userData as UserData).currentViewport = null; } function onMapMove(deck: Deck, map: Map): void { deck.setProps({ viewState: getViewState(map) }); // Camera changed, will trigger a map repaint right after this // Clear any change flag triggered by setting viewState so that deck does not request // a second repaint deck.needsRedraw({clearRedrawFlags: true}); }