fix(map): sync deck overlays with maplibre globe
This commit is contained in:
부모
e69ace4434
커밋
b0d51a9490
@ -1,8 +1,10 @@
|
|||||||
import { HexagonLayer } from "@deck.gl/aggregation-layers";
|
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 { 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, {
|
import maplibregl, {
|
||||||
|
type GeoJSONSource,
|
||||||
|
type GeoJSONSourceSpecification,
|
||||||
type LayerSpecification,
|
type LayerSpecification,
|
||||||
type RasterDEMSourceSpecification,
|
type RasterDEMSourceSpecification,
|
||||||
type StyleSpecification,
|
type StyleSpecification,
|
||||||
@ -16,7 +18,7 @@ import type { ZoneId } from "../../entities/zone/model/meta";
|
|||||||
import { ZONE_META } from "../../entities/zone/model/meta";
|
import { ZONE_META } from "../../entities/zone/model/meta";
|
||||||
import type { MapToggleState } from "../../features/mapToggles/MapToggles";
|
import type { MapToggleState } from "../../features/mapToggles/MapToggles";
|
||||||
import type { FcLink, FleetCircle, PairLink } from "../../features/legacyDashboard/model/types";
|
import type { FcLink, FleetCircle, PairLink } from "../../features/legacyDashboard/model/types";
|
||||||
import { hexToRgb } from "../../shared/lib/color/hexToRgb";
|
import { MaplibreDeckCustomLayer } from "./MaplibreDeckCustomLayer";
|
||||||
|
|
||||||
export type Map3DSettings = {
|
export type Map3DSettings = {
|
||||||
showSeamark: boolean;
|
showSeamark: boolean;
|
||||||
@ -59,6 +61,18 @@ function isFiniteNumber(x: unknown): x is number {
|
|||||||
return typeof x === "number" && Number.isFinite(x);
|
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]> = {
|
const LEGACY_CODE_COLORS: Record<string, [number, number, number]> = {
|
||||||
PT: [30, 64, 175], // #1e40af
|
PT: [30, 64, 175], // #1e40af
|
||||||
"PT-S": [234, 88, 12], // #ea580c
|
"PT-S": [234, 88, 12], // #ea580c
|
||||||
@ -433,10 +447,6 @@ type PairRangeCircle = {
|
|||||||
|
|
||||||
const DECK_VIEW_ID = "mapbox";
|
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({
|
export function Map3D({
|
||||||
targets,
|
targets,
|
||||||
zones,
|
zones,
|
||||||
@ -455,6 +465,7 @@ export function Map3D({
|
|||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
const overlayRef = useRef<MapboxOverlay | null>(null);
|
const overlayRef = useRef<MapboxOverlay | null>(null);
|
||||||
|
const globeDeckLayerRef = useRef<MaplibreDeckCustomLayer | null>(null);
|
||||||
const showSeamarkRef = useRef(settings.showSeamark);
|
const showSeamarkRef = useRef(settings.showSeamark);
|
||||||
const baseMapRef = useRef<BaseMapId>(baseMap);
|
const baseMapRef = useRef<BaseMapId>(baseMap);
|
||||||
const projectionRef = useRef<MapProjectionId>(projection);
|
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.NavigationControl({ showZoom: true, showCompass: true }), "top-left");
|
||||||
map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: "metric" }), "bottom-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;
|
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() {
|
function applyProjection() {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
@ -533,6 +553,14 @@ export function Map3D({
|
|||||||
// Ensure the seamark raster overlay exists even when using MapTiler vector styles.
|
// Ensure the seamark raster overlay exists even when using MapTiler vector styles.
|
||||||
map.on("style.load", () => {
|
map.on("style.load", () => {
|
||||||
applyProjection();
|
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;
|
if (!showSeamarkRef.current) return;
|
||||||
try {
|
try {
|
||||||
ensureSeamarkOverlay(map!, "bathymetry-lines");
|
ensureSeamarkOverlay(map!, "bathymetry-lines");
|
||||||
@ -587,6 +615,7 @@ export function Map3D({
|
|||||||
}
|
}
|
||||||
|
|
||||||
overlayRef.current = null;
|
overlayRef.current = null;
|
||||||
|
globeDeckLayerRef.current = null;
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@ -598,23 +627,7 @@ export function Map3D({
|
|||||||
if (!map) return;
|
if (!map) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
// Recreate the Deck overlay so the Deck instance uses the correct View type
|
const syncProjectionAndDeck = () => {
|
||||||
// (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 = () => {
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
try {
|
try {
|
||||||
map.setProjection({ type: projection });
|
map.setProjection({ type: projection });
|
||||||
@ -622,15 +635,66 @@ export function Map3D({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Projection switch failed:", 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();
|
if (map.isStyleLoaded()) syncProjectionAndDeck();
|
||||||
else map.once("style.load", applyProjection);
|
else map.once("style.load", syncProjectionAndDeck);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
try {
|
try {
|
||||||
map.off("style.load", applyProjection);
|
map.off("style.load", syncProjectionAndDeck);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@ -691,10 +755,121 @@ export function Map3D({
|
|||||||
}
|
}
|
||||||
}, [settings.showSeamark]);
|
}, [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(() => {
|
const shipData = useMemo(() => {
|
||||||
return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon));
|
return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon));
|
||||||
}, [targets]);
|
}, [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(() => {
|
const legacyTargets = useMemo(() => {
|
||||||
if (!legacyHits) return [];
|
if (!legacyHits) return [];
|
||||||
return shipData.filter((t) => legacyHits.has(t.mmsi));
|
return shipData.filter((t) => legacyHits.has(t.mmsi));
|
||||||
@ -727,14 +902,15 @@ export function Map3D({
|
|||||||
|
|
||||||
// Update Deck.gl layers
|
// Update Deck.gl layers
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const overlay = overlayRef.current;
|
|
||||||
const map = mapRef.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 overlayParams = projection === "globe" ? GLOBE_OVERLAY_PARAMS : DEPTH_DISABLED_PARAMS;
|
||||||
const layers = [];
|
const layers = [];
|
||||||
|
|
||||||
if (settings.showDensity) {
|
if (settings.showDensity && projection !== "globe") {
|
||||||
layers.push(
|
layers.push(
|
||||||
new HexagonLayer<AisTarget>({
|
new HexagonLayer<AisTarget>({
|
||||||
id: "density",
|
id: "density",
|
||||||
@ -750,42 +926,13 @@ export function Map3D({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (overlays.zones && zones) {
|
if (overlays.fleetCircles && projection !== "globe" && (fleetCircles?.length ?? 0) > 0) {
|
||||||
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) {
|
|
||||||
layers.push(
|
layers.push(
|
||||||
new ScatterplotLayer<FleetCircle>({
|
new ScatterplotLayer<FleetCircle>({
|
||||||
id: "fleet-circles",
|
id: "fleet-circles",
|
||||||
data: fleetCircles,
|
data: fleetCircles,
|
||||||
pickable: false,
|
pickable: false,
|
||||||
billboard: projection === "globe",
|
billboard: false,
|
||||||
parameters: overlayParams,
|
parameters: overlayParams,
|
||||||
filled: false,
|
filled: false,
|
||||||
stroked: true,
|
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(
|
layers.push(
|
||||||
new ScatterplotLayer<PairRangeCircle>({
|
new ScatterplotLayer<PairRangeCircle>({
|
||||||
id: "pair-range",
|
id: "pair-range",
|
||||||
data: pairRanges,
|
data: pairRanges,
|
||||||
pickable: false,
|
pickable: false,
|
||||||
billboard: projection === "globe",
|
billboard: false,
|
||||||
parameters: overlayParams,
|
parameters: overlayParams,
|
||||||
filled: false,
|
filled: false,
|
||||||
stroked: true,
|
stroked: true,
|
||||||
@ -827,8 +974,8 @@ export function Map3D({
|
|||||||
data: pairLinks,
|
data: pairLinks,
|
||||||
pickable: false,
|
pickable: false,
|
||||||
parameters: overlayParams,
|
parameters: overlayParams,
|
||||||
getSourcePosition: (d) => d.from,
|
getSourcePosition: (d) => (projection === "globe" ? lngLatToUnitSphere(d.from[0], d.from[1]) : d.from),
|
||||||
getTargetPosition: (d) => d.to,
|
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]),
|
getColor: (d) => (d.warn ? [245, 158, 11, 220] : [59, 130, 246, 85]),
|
||||||
getWidth: (d) => (d.warn ? 2.2 : 1.4),
|
getWidth: (d) => (d.warn ? 2.2 : 1.4),
|
||||||
widthUnits: "pixels",
|
widthUnits: "pixels",
|
||||||
@ -843,8 +990,8 @@ export function Map3D({
|
|||||||
data: fcDashed,
|
data: fcDashed,
|
||||||
pickable: false,
|
pickable: false,
|
||||||
parameters: overlayParams,
|
parameters: overlayParams,
|
||||||
getSourcePosition: (d) => d.from,
|
getSourcePosition: (d) => (projection === "globe" ? lngLatToUnitSphere(d.from[0], d.from[1]) : d.from),
|
||||||
getTargetPosition: (d) => d.to,
|
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]),
|
getColor: (d) => (d.suspicious ? [239, 68, 68, 220] : [217, 119, 6, 200]),
|
||||||
getWidth: () => 1.3,
|
getWidth: () => 1.3,
|
||||||
widthUnits: "pixels",
|
widthUnits: "pixels",
|
||||||
@ -873,7 +1020,10 @@ export function Map3D({
|
|||||||
if (!rgb) return [245, 158, 11, 200];
|
if (!rgb) return [245, 158, 11, 200];
|
||||||
return [rgb[0], rgb[1], rgb[2], 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: {
|
updateTriggers: {
|
||||||
getRadius: [selectedMmsi],
|
getRadius: [selectedMmsi],
|
||||||
getLineColor: [legacyHits],
|
getLineColor: [legacyHits],
|
||||||
@ -895,7 +1045,10 @@ export function Map3D({
|
|||||||
iconAtlas: "/assets/ship.svg",
|
iconAtlas: "/assets/ship.svg",
|
||||||
iconMapping: SHIP_ICON_MAPPING,
|
iconMapping: SHIP_ICON_MAPPING,
|
||||||
getIcon: () => "ship",
|
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),
|
getAngle: (d) => (isFiniteNumber(d.cog) ? d.cog : 0),
|
||||||
sizeUnits: "pixels",
|
sizeUnits: "pixels",
|
||||||
getSize: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 34 : 22),
|
getSize: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 34 : 22),
|
||||||
@ -909,7 +1062,7 @@ export function Map3D({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
overlay.setProps({
|
const deckProps = {
|
||||||
layers,
|
layers,
|
||||||
getTooltip: (info: PickingInfo) => {
|
getTooltip: (info: PickingInfo) => {
|
||||||
if (!info.object) return null;
|
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 });
|
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,
|
projection,
|
||||||
shipData,
|
shipData,
|
||||||
@ -982,6 +1138,7 @@ export function Map3D({
|
|||||||
pairRanges,
|
pairRanges,
|
||||||
fcDashed,
|
fcDashed,
|
||||||
fleetCircles,
|
fleetCircles,
|
||||||
|
globePosByMmsi,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
|
return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
|
||||||
|
|||||||
130
apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts
Normal file
130
apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts
Normal file
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
불러오는 중...
Reference in New Issue
Block a user