From b0d51a94901b36a05d32baaef320f0971defde06 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 12:11:39 +0900 Subject: [PATCH] fix(map): sync deck overlays with maplibre globe --- apps/web/src/widgets/map3d/Map3D.tsx | 311 +++++++++++++----- .../widgets/map3d/MaplibreDeckCustomLayer.ts | 130 ++++++++ 2 files changed, 364 insertions(+), 77 deletions(-) create mode 100644 apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 546e190..879ee26 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -1,8 +1,10 @@ import { HexagonLayer } from "@deck.gl/aggregation-layers"; -import { IconLayer, GeoJsonLayer, LineLayer, ScatterplotLayer } from "@deck.gl/layers"; +import { IconLayer, LineLayer, ScatterplotLayer } from "@deck.gl/layers"; import { MapboxOverlay } from "@deck.gl/mapbox"; -import { MapView, _GlobeView as GlobeView, type PickingInfo } from "@deck.gl/core"; +import { type PickingInfo } from "@deck.gl/core"; import maplibregl, { + type GeoJSONSource, + type GeoJSONSourceSpecification, type LayerSpecification, type RasterDEMSourceSpecification, type StyleSpecification, @@ -16,7 +18,7 @@ import type { ZoneId } from "../../entities/zone/model/meta"; import { ZONE_META } from "../../entities/zone/model/meta"; import type { MapToggleState } from "../../features/mapToggles/MapToggles"; import type { FcLink, FleetCircle, PairLink } from "../../features/legacyDashboard/model/types"; -import { hexToRgb } from "../../shared/lib/color/hexToRgb"; +import { MaplibreDeckCustomLayer } from "./MaplibreDeckCustomLayer"; export type Map3DSettings = { showSeamark: boolean; @@ -59,6 +61,18 @@ function isFiniteNumber(x: unknown): x is number { return typeof x === "number" && Number.isFinite(x); } +const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius` +const DEG2RAD = Math.PI / 180; + +function lngLatToUnitSphere(lon: number, lat: number, altitudeMeters = 0): [number, number, number] { + const lambda = lon * DEG2RAD; + const phi = lat * DEG2RAD; + const cosPhi = Math.cos(phi); + const s = 1 + altitudeMeters / EARTH_RADIUS_M; + // MapLibre globe space: x = east, y = north, z = lon=0 at equator. + return [Math.sin(lambda) * cosPhi * s, Math.sin(phi) * s, Math.cos(lambda) * cosPhi * s]; +} + const LEGACY_CODE_COLORS: Record = { PT: [30, 64, 175], // #1e40af "PT-S": [234, 88, 12], // #ea580c @@ -433,10 +447,6 @@ type PairRangeCircle = { const DECK_VIEW_ID = "mapbox"; -function getDeckView(projection: MapProjectionId) { - return projection === "globe" ? new GlobeView({ id: DECK_VIEW_ID }) : new MapView({ id: DECK_VIEW_ID }); -} - export function Map3D({ targets, zones, @@ -455,6 +465,7 @@ export function Map3D({ const containerRef = useRef(null); const mapRef = useRef(null); const overlayRef = useRef(null); + const globeDeckLayerRef = useRef(null); const showSeamarkRef = useRef(settings.showSeamark); const baseMapRef = useRef(baseMap); const projectionRef = useRef(projection); @@ -509,13 +520,22 @@ export function Map3D({ map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), "top-left"); map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: "metric" }), "bottom-left"); - // NOTE: `MapboxOverlayProps`'s TS typing pins `DeckProps` generics to `null`, - // which makes `views` type `null`. Runtime supports `views`; cast to keep TS happy. - overlay = new MapboxOverlay({ interleaved: true, layers: [], views: getDeckView(projectionRef.current) } as unknown as never); - map.addControl(overlay); - mapRef.current = map; - overlayRef.current = overlay; + + // Initial Deck integration: + // - mercator: MapboxOverlay interleaved (fast, feature-rich) + // - globe: MapLibre custom layer that feeds Deck the globe MVP matrix (keeps basemap+layers aligned) + if (projectionRef.current === "mercator") { + overlay = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never); + map.addControl(overlay); + overlayRef.current = overlay; + } else { + globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ + id: "deck-globe", + viewId: DECK_VIEW_ID, + deckProps: { layers: [] }, + }); + } function applyProjection() { if (!map) return; @@ -533,6 +553,14 @@ export function Map3D({ // Ensure the seamark raster overlay exists even when using MapTiler vector styles. map.on("style.load", () => { applyProjection(); + // Globe deck layer lives inside the style and must be re-added after any style swap. + if (projectionRef.current === "globe" && globeDeckLayerRef.current && !map!.getLayer(globeDeckLayerRef.current.id)) { + try { + map!.addLayer(globeDeckLayerRef.current); + } catch { + // ignore + } + } if (!showSeamarkRef.current) return; try { ensureSeamarkOverlay(map!, "bathymetry-lines"); @@ -587,6 +615,7 @@ export function Map3D({ } overlayRef.current = null; + globeDeckLayerRef.current = null; mapRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -598,23 +627,7 @@ export function Map3D({ if (!map) return; let cancelled = false; - // Recreate the Deck overlay so the Deck instance uses the correct View type - // (MapView vs GlobeView) and stays in sync with MapLibre. - try { - const old = overlayRef.current; - if (old) map.removeControl(old); - } catch { - // ignore - } - try { - const next = new MapboxOverlay({ interleaved: true, layers: [], views: getDeckView(projection) } as unknown as never); - map.addControl(next); - overlayRef.current = next; - } catch (e) { - console.warn("Deck overlay re-create failed:", e); - } - - const applyProjection = () => { + const syncProjectionAndDeck = () => { if (cancelled) return; try { map.setProjection({ type: projection }); @@ -622,15 +635,66 @@ export function Map3D({ } catch (e) { console.warn("Projection switch failed:", e); } + + if (projection === "globe") { + // Tear down MapboxOverlay (mercator) and use a MapLibre custom layer that renders Deck + // with MapLibre's globe MVP matrix. This avoids the Deck <-> MapLibre globe mismatch. + const old = overlayRef.current; + if (old) { + try { + old.finalize(); + } catch { + // ignore + } + overlayRef.current = null; + } + + if (!globeDeckLayerRef.current) { + globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ + id: "deck-globe", + viewId: DECK_VIEW_ID, + deckProps: { layers: [] }, + }); + } + + const layer = globeDeckLayerRef.current; + if (layer && map.isStyleLoaded() && !map.getLayer(layer.id)) { + try { + map.addLayer(layer); + } catch { + // ignore + } + } + } else { + // Tear down globe custom layer (if present), restore MapboxOverlay interleaved. + const globeLayer = globeDeckLayerRef.current; + if (globeLayer && map.getLayer(globeLayer.id)) { + try { + map.removeLayer(globeLayer.id); + } catch { + // ignore + } + } + + if (!overlayRef.current) { + try { + const next = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never); + map.addControl(next); + overlayRef.current = next; + } catch (e) { + console.warn("Deck overlay create failed:", e); + } + } + } }; - if (map.isStyleLoaded()) applyProjection(); - else map.once("style.load", applyProjection); + if (map.isStyleLoaded()) syncProjectionAndDeck(); + else map.once("style.load", syncProjectionAndDeck); return () => { cancelled = true; try { - map.off("style.load", applyProjection); + map.off("style.load", syncProjectionAndDeck); } catch { // ignore } @@ -691,10 +755,121 @@ export function Map3D({ } }, [settings.showSeamark]); + // Zones (MapLibre-native GeoJSON layers; works in both mercator + globe) + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = "zones-src"; + const fillId = "zones-fill"; + const lineId = "zones-line"; + + const zoneColorExpr: unknown[] = ["match", ["get", "zoneId"]]; + for (const k of Object.keys(ZONE_META) as ZoneId[]) { + zoneColorExpr.push(k, ZONE_META[k].color); + } + zoneColorExpr.push("#3B82F6"); + + const ensure = () => { + // Always update visibility if the layers exist. + const visibility = overlays.zones ? "visible" : "none"; + try { + if (map.getLayer(fillId)) map.setLayoutProperty(fillId, "visibility", visibility); + } catch { + // ignore + } + try { + if (map.getLayer(lineId)) map.setLayoutProperty(lineId, "visibility", visibility); + } catch { + // ignore + } + + if (!zones) return; + if (!map.isStyleLoaded()) return; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) { + existing.setData(zones); + } else { + map.addSource(srcId, { type: "geojson", data: zones } as GeoJSONSourceSpecification); + } + + // Keep zones below Deck layers (ships / deck-globe), and below seamarks if enabled. + const style = map.getStyle(); + const firstSymbol = (style.layers || []).find((l) => (l as { type?: string } | undefined)?.type === "symbol") as + | { id?: string } + | undefined; + const before = map.getLayer("deck-globe") + ? "deck-globe" + : map.getLayer("ships") + ? "ships" + : map.getLayer("seamark") + ? "seamark" + : firstSymbol?.id; + + if (!map.getLayer(fillId)) { + map.addLayer( + { + id: fillId, + type: "fill", + source: srcId, + paint: { + "fill-color": zoneColorExpr as never, + "fill-opacity": 0.12, + }, + layout: { visibility }, + } as unknown as LayerSpecification, + before, + ); + } + + if (!map.getLayer(lineId)) { + map.addLayer( + { + id: lineId, + type: "line", + source: srcId, + paint: { + "line-color": zoneColorExpr as never, + "line-opacity": 0.85, + "line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 1.4, 14, 2.1], + }, + layout: { visibility }, + } as unknown as LayerSpecification, + before, + ); + } + } catch (e) { + console.warn("Zones layer setup failed:", e); + } + }; + + if (map.isStyleLoaded()) ensure(); + map.on("style.load", ensure); + return () => { + try { + map.off("style.load", ensure); + } catch { + // ignore + } + }; + }, [zones, overlays.zones]); + const shipData = useMemo(() => { return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon)); }, [targets]); + const globePosByMmsi = useMemo(() => { + if (projection !== "globe") return null; + const m = new Map(); + for (const t of shipData) { + // Slightly above the sea surface to keep the icon readable and avoid depth-fighting. + m.set(t.mmsi, lngLatToUnitSphere(t.lon, t.lat, 12)); + } + return m; + }, [projection, shipData]); + const legacyTargets = useMemo(() => { if (!legacyHits) return []; return shipData.filter((t) => legacyHits.has(t.mmsi)); @@ -727,14 +902,15 @@ export function Map3D({ // Update Deck.gl layers useEffect(() => { - const overlay = overlayRef.current; const map = mapRef.current; - if (!overlay || !map) return; + if (!map) return; + const deckTarget = projection === "globe" ? globeDeckLayerRef.current : overlayRef.current; + if (!deckTarget) return; const overlayParams = projection === "globe" ? GLOBE_OVERLAY_PARAMS : DEPTH_DISABLED_PARAMS; const layers = []; - if (settings.showDensity) { + if (settings.showDensity && projection !== "globe") { layers.push( new HexagonLayer({ id: "density", @@ -750,42 +926,13 @@ export function Map3D({ ); } - if (overlays.zones && zones) { - layers.push( - new GeoJsonLayer({ - id: "zones", - data: zones, - pickable: true, - // Avoid z-fighting flicker with other layers in the shared MapLibre depth buffer. - parameters: overlayParams, - stroked: true, - filled: true, - getFillColor: (f) => { - const zoneId = (f.properties as { zoneId?: string } | undefined)?.zoneId as ZoneId | undefined; - const col = zoneId ? ZONE_META[zoneId]?.color : "#3B82F6"; - const [r, g, b] = hexToRgb(col); - return [r, g, b, 22]; - }, - getLineColor: (f) => { - const zoneId = (f.properties as { zoneId?: string } | undefined)?.zoneId as ZoneId | undefined; - const col = zoneId ? ZONE_META[zoneId]?.color : "#3B82F6"; - const [r, g, b] = hexToRgb(col); - return [r, g, b, 200]; - }, - lineWidthMinPixels: 1, - lineWidthMaxPixels: 2, - getLineWidth: 1, - }), - ); - } - - if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) { + if (overlays.fleetCircles && projection !== "globe" && (fleetCircles?.length ?? 0) > 0) { layers.push( new ScatterplotLayer({ id: "fleet-circles", data: fleetCircles, pickable: false, - billboard: projection === "globe", + billboard: false, parameters: overlayParams, filled: false, stroked: true, @@ -799,13 +946,13 @@ export function Map3D({ ); } - if (overlays.pairRange && pairRanges.length > 0) { + if (overlays.pairRange && projection !== "globe" && pairRanges.length > 0) { layers.push( new ScatterplotLayer({ id: "pair-range", data: pairRanges, pickable: false, - billboard: projection === "globe", + billboard: false, parameters: overlayParams, filled: false, stroked: true, @@ -827,8 +974,8 @@ export function Map3D({ data: pairLinks, pickable: false, parameters: overlayParams, - getSourcePosition: (d) => d.from, - getTargetPosition: (d) => d.to, + getSourcePosition: (d) => (projection === "globe" ? lngLatToUnitSphere(d.from[0], d.from[1]) : d.from), + getTargetPosition: (d) => (projection === "globe" ? lngLatToUnitSphere(d.to[0], d.to[1]) : d.to), getColor: (d) => (d.warn ? [245, 158, 11, 220] : [59, 130, 246, 85]), getWidth: (d) => (d.warn ? 2.2 : 1.4), widthUnits: "pixels", @@ -843,8 +990,8 @@ export function Map3D({ data: fcDashed, pickable: false, parameters: overlayParams, - getSourcePosition: (d) => d.from, - getTargetPosition: (d) => d.to, + getSourcePosition: (d) => (projection === "globe" ? lngLatToUnitSphere(d.from[0], d.from[1]) : d.from), + getTargetPosition: (d) => (projection === "globe" ? lngLatToUnitSphere(d.to[0], d.to[1]) : d.to), getColor: (d) => (d.suspicious ? [239, 68, 68, 220] : [217, 119, 6, 200]), getWidth: () => 1.3, widthUnits: "pixels", @@ -873,7 +1020,10 @@ export function Map3D({ if (!rgb) return [245, 158, 11, 200]; return [rgb[0], rgb[1], rgb[2], 200]; }, - getPosition: (d) => [d.lon, d.lat], + getPosition: (d) => + projection === "globe" + ? (globePosByMmsi?.get(d.mmsi) ?? lngLatToUnitSphere(d.lon, d.lat, 12)) + : ([d.lon, d.lat] as [number, number]), updateTriggers: { getRadius: [selectedMmsi], getLineColor: [legacyHits], @@ -895,7 +1045,10 @@ export function Map3D({ iconAtlas: "/assets/ship.svg", iconMapping: SHIP_ICON_MAPPING, getIcon: () => "ship", - getPosition: (d) => [d.lon, d.lat], + getPosition: (d) => + projection === "globe" + ? (globePosByMmsi?.get(d.mmsi) ?? lngLatToUnitSphere(d.lon, d.lat, 12)) + : ([d.lon, d.lat] as [number, number]), getAngle: (d) => (isFiniteNumber(d.cog) ? d.cog : 0), sizeUnits: "pixels", getSize: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 34 : 22), @@ -909,7 +1062,7 @@ export function Map3D({ ); } - overlay.setProps({ + const deckProps = { layers, getTooltip: (info: PickingInfo) => { if (!info.object) return null; @@ -962,7 +1115,10 @@ export function Map3D({ map.easeTo({ center: [t.lon, t.lat], zoom: Math.max(map.getZoom(), 10), duration: 600 }); } }, - }); + } as const; + + if (projection === "globe") globeDeckLayerRef.current?.setProps(deckProps); + else overlayRef.current?.setProps(deckProps as unknown as never); }, [ projection, shipData, @@ -982,6 +1138,7 @@ export function Map3D({ pairRanges, fcDashed, fleetCircles, + globePosByMmsi, ]); return
; diff --git a/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts b/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts new file mode 100644 index 0000000..c6baec1 --- /dev/null +++ b/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts @@ -0,0 +1,130 @@ +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; + + 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; + } + + 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._map = map; + + 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 { + this._deck?.finalize(); + this._deck = null; + this._map = null; + this._lastMvp = undefined; + } + + render(_gl: WebGLRenderingContext | WebGL2RenderingContext, options: maplibregl.CustomRenderMethodInput): void { + const deck = this._deck; + if (!deck) 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, + }); + } +}