gc-wing/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts

187 lines
6.4 KiB
TypeScript

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<MatrixViewState> {
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>): number[] {
const out = new Array<number>(16);
for (let i = 0; i < 16; i++) out[i] = m[i] as number;
return out;
}
function mat4Changed(a: number[] | undefined, b: ArrayLike<number>): 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<MatrixView[]> | null = null;
private _deckProps: Partial<DeckProps<MatrixView[]>> = {};
private _viewId: string;
private _lastMvp: number[] | undefined;
private _finalizeOnRemove: boolean = false;
constructor(opts: { id: string; viewId: string; deckProps?: Partial<DeckProps<MatrixView[]>> }) {
this.id = opts.id;
this._viewId = opts.viewId;
this._deckProps = opts.deckProps ?? {};
}
get deck(): Deck<MatrixView[]> | null {
return this._deck;
}
requestFinalize() {
this._finalizeOnRemove = true;
}
setProps(next: Partial<DeckProps<MatrixView[]>>) {
this._deckProps = { ...this._deckProps, ...next };
if (this._deck) this._deck.setProps(this._deckProps as DeckProps<MatrixView[]>);
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<MatrixView[]>);
return;
}
const deck = new Deck<MatrixView[]>({
...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<DeckProps<MatrixView[]>>);
} 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 } } });
}
try {
deck._drawLayers("maplibre-custom", {
clearCanvas: false,
clearStack: true,
});
} catch (e) {
// Rendering can fail transiently during style/projection transitions.
// Keep the map responsive and request a clean pass on next frame.
console.warn("Deck render sync failed, skipping frame:", e);
requestAnimationFrame(() => {
this._map?.triggerRepaint();
});
}
}
}