import { Deck, MapController, View, Viewport, type DeckProps } from "@deck.gl/core"; import type maplibregl from "maplibre-gl"; type MatrixViewState = { // MapLibre provides a full world->clip matrix as `modelViewProjectionMatrix`. // We treat it as the viewport's projection matrix and keep viewMatrix identity. projectionMatrix: number[]; viewMatrix?: number[]; // Deck's View state is constrained to include transition props. We only need one overlapping key // to satisfy TS structural checks without pulling in internal deck.gl types. transitionDuration?: number; }; class MatrixView extends View { getViewportType(viewState: MatrixViewState): typeof Viewport { void viewState; return Viewport; } // Controller isn't used (Deck is created with `controller: false`) but View requires one. protected get ControllerType(): typeof MapController { return MapController; } } const IDENTITY_4x4: number[] = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; function readMat4(m: ArrayLike): number[] { const out = new Array(16); for (let i = 0; i < 16; i++) out[i] = m[i] as number; return out; } function mat4Changed(a: number[] | undefined, b: ArrayLike): boolean { if (!a || a.length !== 16) return true; // The matrix values change on map move/rotate. A strict compare is fine. for (let i = 0; i < 16; i++) { if (a[i] !== (b[i] as number)) return true; } return false; } export class MaplibreDeckCustomLayer implements maplibregl.CustomLayerInterface { id: string; type = "custom" as const; renderingMode = "3d" as const; private _map: maplibregl.Map | null = null; private _deck: Deck | null = null; private _deckProps: Partial> = {}; private _viewId: string; private _lastMvp: number[] | undefined; private _finalizeOnRemove: boolean = false; constructor(opts: { id: string; viewId: string; deckProps?: Partial> }) { this.id = opts.id; this._viewId = opts.viewId; this._deckProps = opts.deckProps ?? {}; } get deck(): Deck | null { return this._deck; } requestFinalize() { this._finalizeOnRemove = true; } setProps(next: Partial>) { this._deckProps = { ...this._deckProps, ...next }; if (this._deck) this._deck.setProps(this._deckProps as DeckProps); this._map?.triggerRepaint(); } onAdd(map: maplibregl.Map, gl: WebGLRenderingContext | WebGL2RenderingContext): void { this._finalizeOnRemove = false; this._map = map; if (this._deck) { // Re-attached after a style change; keep the existing Deck instance so we don't reuse // finalized Layer objects (Deck does not allow that). this._lastMvp = undefined; this._deck.setProps({ ...this._deckProps, canvas: map.getCanvas(), // Ensure any pending redraw requests trigger a map repaint again. _customRender: () => map.triggerRepaint(), } as DeckProps); return; } const deck = new Deck({ ...this._deckProps, // Share MapLibre's WebGL context + canvas (single context). gl: gl as WebGL2RenderingContext, canvas: map.getCanvas(), width: null, height: null, // Let MapLibre own pointer/touch behaviors on the shared canvas. touchAction: "none", controller: false, views: this._deckProps.views ?? [new MatrixView({ id: this._viewId })], viewState: this._deckProps.viewState ?? { [this._viewId]: { projectionMatrix: IDENTITY_4x4 } }, // Only request a repaint when Deck thinks it needs one. Drawing happens in `render()`. _customRender: () => map.triggerRepaint(), parameters: { // Match @deck.gl/mapbox interleaved defaults (premultiplied blending). 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", ...this._deckProps.parameters, }, }); this._deck = deck; } onRemove(): void { const deck = this._deck; const map = this._map; this._map = null; this._lastMvp = undefined; if (!deck) return; if (this._finalizeOnRemove) { deck.finalize(); this._deck = null; return; } // Likely a base style swap; keep Deck instance alive and re-attach in onAdd(). // Disable repaint requests until we get re-attached. try { deck.setProps({ _customRender: () => void 0 } as Partial>); } catch { // ignore } try { map?.triggerRepaint(); } catch { // ignore } } render(_gl: WebGLRenderingContext | WebGL2RenderingContext, options: maplibregl.CustomRenderMethodInput): void { const deck = this._deck; if (!this._map) return; if (!deck || !deck.isInitialized) return; // Deck reports `isInitialized` once `viewManager` exists, but we still see rare cases during // style/projection transitions where internal managers are temporarily null (or tearing down). // Guard before calling the internal `_drawLayers` to avoid crashing the whole map render. // eslint-disable-next-line @typescript-eslint/no-explicit-any const internal = deck as any; if (!internal.layerManager || !internal.viewManager) return; // MapLibre gives us a world->clip matrix for the current projection (mercator/globe). // For globe, this matrix expects unit-sphere world coordinates (see MapLibre's globe transform). if (mat4Changed(this._lastMvp, options.modelViewProjectionMatrix)) { const projectionMatrix = readMat4(options.modelViewProjectionMatrix); this._lastMvp = projectionMatrix; deck.setProps({ viewState: { [this._viewId]: { projectionMatrix, viewMatrix: IDENTITY_4x4 } } }); } deck._drawLayers("maplibre-custom", { clearCanvas: false, clearStack: true, }); } }