fix(map): sync deck overlays with maplibre globe

This commit is contained in:
htlee 2026-02-15 12:11:39 +09:00
부모 e69ace4434
커밋 b0d51a9490
2개의 변경된 파일364개의 추가작업 그리고 77개의 파일을 삭제

파일 보기

@ -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<string, [number, number, number]> = {
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<HTMLDivElement | null>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const overlayRef = useRef<MapboxOverlay | null>(null);
const globeDeckLayerRef = useRef<MaplibreDeckCustomLayer | null>(null);
const showSeamarkRef = useRef(settings.showSeamark);
const baseMapRef = useRef<BaseMapId>(baseMap);
const projectionRef = useRef<MapProjectionId>(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<number, [number, number, number]>();
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<AisTarget>({
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<FleetCircle>({
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<PairRangeCircle>({
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 <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;

파일 보기

@ -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<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;
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;
}
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._map = map;
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 {
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,
});
}
}