fix(globe): stabilize ship symbols and deck rendering

This commit is contained in:
htlee 2026-02-15 14:04:37 +09:00
부모 bcd4a77f47
커밋 b8ccef23ca
2개의 변경된 파일140개의 추가작업 그리고 142개의 파일을 삭제

파일 보기

@ -9,7 +9,7 @@ import maplibregl, {
type StyleSpecification, type StyleSpecification,
type VectorSourceSpecification, type VectorSourceSpecification,
} from "maplibre-gl"; } from "maplibre-gl";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { AisTarget } from "../../entities/aisTarget/model/types"; import type { AisTarget } from "../../entities/aisTarget/model/types";
import type { LegacyVesselInfo } from "../../entities/legacyVessel/model/types"; import type { LegacyVesselInfo } from "../../entities/legacyVessel/model/types";
import type { ZonesGeoJson } from "../../entities/zone/api/useZones"; import type { ZonesGeoJson } from "../../entities/zone/api/useZones";
@ -87,21 +87,9 @@ function kickRepaint(map: maplibregl.Map | null) {
} }
} }
const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius`
const DEG2RAD = Math.PI / 180; const DEG2RAD = Math.PI / 180;
function clampExpr(inputExpr: unknown, minValue: number, maxValue: number): unknown[] { const clampNumber = (value: number, minValue: number, maxValue: number) => Math.max(minValue, Math.min(maxValue, value));
return ["min", ["max", inputExpr, minValue], maxValue];
}
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
@ -228,7 +216,8 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str
// Very low zoom tiles can contain extremely complex polygons (coastline/detail), // Very low zoom tiles can contain extremely complex polygons (coastline/detail),
// which may exceed MapLibre's per-segment 16-bit vertex limit and render incorrectly. // which may exceed MapLibre's per-segment 16-bit vertex limit and render incorrectly.
// We keep the fill starting at a more reasonable zoom. // We keep the fill starting at a more reasonable zoom.
minzoom: 4, minzoom: 6,
maxzoom: 12,
paint: { paint: {
// Dark-mode friendly palette (shallow = slightly brighter; deep = near-black). // Dark-mode friendly palette (shallow = slightly brighter; deep = near-black).
"fill-color": bathyFillColor, "fill-color": bathyFillColor,
@ -236,38 +225,15 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str
}, },
} as unknown as LayerSpecification; } as unknown as LayerSpecification;
const bathyExtrusion: LayerSpecification = {
id: "bathymetry-extrusion",
type: "fill-extrusion",
source: oceanSourceId,
"source-layer": "contour",
minzoom: 6,
paint: {
"fill-extrusion-color": bathyFillColor,
// MapLibre fill-extrusion cannot go below 0m, so we exaggerate the "relative seabed height"
// (shallow areas higher, deep areas lower) to create a stepped relief.
"fill-extrusion-base": 0,
// NOTE: `zoom` can only appear as the input to a top-level `step`/`interpolate`.
"fill-extrusion-height": [
"interpolate",
["linear"],
["zoom"],
6,
["*", ["+", depth, 12000], 0.002], // depth is negative; -> range [0..12000]
10,
["*", ["+", depth, 12000], 0.01],
],
"fill-extrusion-opacity": ["interpolate", ["linear"], ["zoom"], 6, 0.0, 7, 0.25, 10, 0.55],
"fill-extrusion-vertical-gradient": true,
},
} as unknown as LayerSpecification;
const bathyBandBorders: LayerSpecification = { const bathyBandBorders: LayerSpecification = {
id: "bathymetry-borders", id: "bathymetry-borders",
type: "line", type: "line",
source: oceanSourceId, source: oceanSourceId,
"source-layer": "contour", "source-layer": "contour",
minzoom: 4, minzoom: 6,
maxzoom: 14,
paint: { paint: {
"line-color": "rgba(255,255,255,0.06)", "line-color": "rgba(255,255,255,0.06)",
"line-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0.12, 8, 0.18, 12, 0.22], "line-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0.12, 8, 0.18, 12, 0.22],
@ -304,10 +270,9 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str
const majorDepths = [-50, -100, -200, -500, -1000, -2000, -4000, -6000, -8000, -9500]; const majorDepths = [-50, -100, -200, -500, -1000, -2000, -4000, -6000, -8000, -9500];
const bathyMajorDepthFilter: unknown[] = [ const bathyMajorDepthFilter: unknown[] = [
"match", "in",
["to-number", ["get", "depth"]], ["to-number", ["get", "depth"]],
...majorDepths.map((v) => [v, true]).flat(), ["literal", majorDepths],
false,
] as unknown[]; ] as unknown[];
const bathyLinesMajor: LayerSpecification = { const bathyLinesMajor: LayerSpecification = {
@ -316,6 +281,7 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str
source: oceanSourceId, source: oceanSourceId,
"source-layer": "contour_line", "source-layer": "contour_line",
minzoom: 8, minzoom: 8,
maxzoom: 14,
filter: bathyMajorDepthFilter as unknown as unknown[], filter: bathyMajorDepthFilter as unknown as unknown[],
paint: { paint: {
"line-color": "rgba(255,255,255,0.16)", "line-color": "rgba(255,255,255,0.16)",
@ -331,6 +297,7 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str
source: oceanSourceId, source: oceanSourceId,
"source-layer": "contour", "source-layer": "contour",
minzoom: 4, minzoom: 4,
maxzoom: 14,
filter: bathyMajorDepthFilter as unknown as unknown[], filter: bathyMajorDepthFilter as unknown as unknown[],
paint: { paint: {
"line-color": "rgba(255,255,255,0.14)", "line-color": "rgba(255,255,255,0.14)",
@ -394,7 +361,6 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str
const toInsert = [ const toInsert = [
bathyFill, bathyFill,
bathyExtrusion,
bathyBandBorders, bathyBandBorders,
bathyBandBordersMajor, bathyBandBordersMajor,
bathyLinesMinor, bathyLinesMinor,
@ -486,6 +452,7 @@ type PairRangeCircle = {
warn: boolean; warn: boolean;
}; };
const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius`
const DECK_VIEW_ID = "mapbox"; const DECK_VIEW_ID = "mapbox";
export function Map3D({ export function Map3D({
@ -507,10 +474,11 @@ export function Map3D({
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 globeDeckLayerRef = useRef<MaplibreDeckCustomLayer | null>(null);
const prevGlobeSelectedRef = useRef<number | null>(null); const globeShipsEpochRef = useRef(-1);
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);
const [mapSyncEpoch, setMapSyncEpoch] = useState(0);
useEffect(() => { useEffect(() => {
showSeamarkRef.current = settings.showSeamark; showSeamarkRef.current = settings.showSeamark;
@ -675,29 +643,56 @@ export function Map3D({
const map = mapRef.current; const map = mapRef.current;
if (!map) return; if (!map) return;
let cancelled = false; let cancelled = false;
let retries = 0;
const maxRetries = 6;
const syncProjectionAndDeck = () => { const syncProjectionAndDeck = () => {
if (cancelled) return; if (cancelled) return;
if (!map.isStyleLoaded()) {
if (!cancelled && retries < maxRetries) {
retries += 1;
window.requestAnimationFrame(() => syncProjectionAndDeck());
}
return;
}
const next = projection;
try { try {
map.setProjection({ type: projection }); map.setProjection({ type: next });
map.setRenderWorldCopies(projection !== "globe"); map.setRenderWorldCopies(next !== "globe");
} catch (e) { } catch (e) {
if (!cancelled && retries < maxRetries) {
retries += 1;
window.requestAnimationFrame(() => syncProjectionAndDeck());
return;
}
console.warn("Projection switch failed:", e); console.warn("Projection switch failed:", e);
} }
if (projection === "globe") { const oldOverlay = overlayRef.current;
// Tear down MapboxOverlay (mercator) and use a MapLibre custom layer that renders Deck if (projection === "globe" && oldOverlay) {
// with MapLibre's globe MVP matrix. This avoids the Deck <-> MapLibre globe mismatch. // Globe mode uses custom MapLibre deck layers and should fully replace Mercator overlays.
const old = overlayRef.current;
if (old) {
try { try {
old.finalize(); oldOverlay.finalize();
} catch { } catch {
// ignore // ignore
} }
overlayRef.current = null; overlayRef.current = null;
} }
if (projection === "globe") {
// Ensure any stale layer from old mode is dropped then re-added on this projection.
if (globeDeckLayerRef.current) {
try {
if (map.getLayer(globeDeckLayerRef.current.id)) {
map.removeLayer(globeDeckLayerRef.current.id);
}
} catch {
// ignore
}
}
if (!globeDeckLayerRef.current) { if (!globeDeckLayerRef.current) {
globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ globeDeckLayerRef.current = new MaplibreDeckCustomLayer({
id: "deck-globe", id: "deck-globe",
@ -745,6 +740,8 @@ export function Map3D({
} catch { } catch {
// ignore // ignore
} }
setMapSyncEpoch((prev) => prev + 1);
}; };
if (map.isStyleLoaded()) syncProjectionAndDeck(); if (map.isStyleLoaded()) syncProjectionAndDeck();
@ -778,6 +775,7 @@ export function Map3D({
map.once("style.load", () => { map.once("style.load", () => {
kickRepaint(map); kickRepaint(map);
requestAnimationFrame(() => kickRepaint(map)); requestAnimationFrame(() => kickRepaint(map));
setMapSyncEpoch((prev) => prev + 1);
}); });
} catch (e) { } catch (e) {
if (cancelled) return; if (cancelled) return;
@ -801,7 +799,7 @@ export function Map3D({
if (!map.isStyleLoaded()) return; if (!map.isStyleLoaded()) return;
const disableBathyHeavy = projection === "globe" && baseMap === "enhanced"; const disableBathyHeavy = projection === "globe" && baseMap === "enhanced";
const visHeavy = disableBathyHeavy ? "none" : "visible"; const visHeavy = disableBathyHeavy ? "none" : "visible";
const disableBaseMapSea = projection === "globe" && baseMap === "enhanced"; const disableBaseMapSea = projection === "globe";
const seaVisibility = disableBaseMapSea ? "none" : "visible"; const seaVisibility = disableBaseMapSea ? "none" : "visible";
const seaRegex = /(water|sea|ocean|river|lake|coast|bay)/i; const seaRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
@ -822,17 +820,20 @@ export function Map3D({
} }
} }
// Vector basemap water-style layers can flicker on globe with dense symbols/fills in this stack. // Vector basemap water/raster layers can flicker on globe with dense symbols/fills in this stack.
// Hide them only in globe/enhanced mode and restore on return. // Hide them only in globe mode and restore on return.
try { try {
for (const layer of map.getStyle().layers || []) { for (const layer of map.getStyle().layers || []) {
const id = String(layer.id ?? ""); const id = String(layer.id ?? "");
if (!id) continue; if (!id) continue;
const sourceLayer = String((layer as Record<string, unknown>)["source-layer"] ?? "").toLowerCase(); const sourceLayer = String((layer as Record<string, unknown>)["source-layer"] ?? "").toLowerCase();
const source = String((layer as { source?: unknown }).source ?? "").toLowerCase(); const source = String((layer as { source?: unknown }).source ?? "").toLowerCase();
const type = String((layer as { type?: unknown }).type ?? "").toLowerCase();
const isSea = seaRegex.test(id) || seaRegex.test(sourceLayer) || seaRegex.test(source); const isSea = seaRegex.test(id) || seaRegex.test(sourceLayer) || seaRegex.test(source);
if (!isSea) continue; const isRaster = type === "raster";
if (!isSea && !isRaster) continue;
if (!map.getLayer(id)) continue; if (!map.getLayer(id)) continue;
if (isRaster && id === "seamark") continue;
try { try {
map.setLayoutProperty(id, "visibility", seaVisibility); map.setLayoutProperty(id, "visibility", seaVisibility);
} catch { } catch {
@ -853,7 +854,7 @@ export function Map3D({
// ignore // ignore
} }
}; };
}, [projection, baseMap]); }, [projection, baseMap, mapSyncEpoch]);
// seamark toggle // seamark toggle
useEffect(() => { useEffect(() => {
@ -983,7 +984,7 @@ export function Map3D({
// ignore // ignore
} }
}; };
}, [zones, overlays.zones, projection, baseMap]); }, [zones, overlays.zones, projection, baseMap, mapSyncEpoch]);
// Ships in globe mode: render with MapLibre symbol layers so icons can be aligned to the globe surface. // Ships in globe mode: render with MapLibre symbol layers so icons can be aligned to the globe surface.
// Deck IconLayer billboards or uses a fixed plane, which looks like ships are pointing into the sky/ground on globe. // Deck IconLayer billboards or uses a fixed plane, which looks like ships are pointing into the sky/ground on globe.
@ -1010,7 +1011,6 @@ export function Map3D({
} catch { } catch {
// ignore // ignore
} }
prevGlobeSelectedRef.current = null;
kickRepaint(map); kickRepaint(map);
}; };
@ -1064,6 +1064,11 @@ export function Map3D({
return; return;
} }
if (globeShipsEpochRef.current !== mapSyncEpoch) {
remove();
globeShipsEpochRef.current = mapSyncEpoch;
}
try { try {
ensureImage(); ensureImage();
} catch (e) { } catch (e) {
@ -1077,7 +1082,14 @@ export function Map3D({
const legacy = legacyHits?.get(t.mmsi) ?? null; const legacy = legacyHits?.get(t.mmsi) ?? null;
const cog = isFiniteNumber(t.cog) ? t.cog : 0; const cog = isFiniteNumber(t.cog) ? t.cog : 0;
const cogNorm = ((cog % 360) + 360) % 360; const cogNorm = ((cog % 360) + 360) % 360;
const cog4 = (Math.round(cogNorm / 90) % 4) * 90; const hull = clampNumber((isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, 50, 420);
const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
const selected = t.mmsi === selectedMmsi;
const selectedScale = selected ? 1.08 : 1;
const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3);
const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45);
const iconSize10 = clampNumber(0.56 * sizeScale * selectedScale, 0.35, 1.7);
const iconSize14 = clampNumber(0.72 * sizeScale * selectedScale, 0.45, 2.1);
return { return {
type: "Feature", type: "Feature",
id: t.mmsi, id: t.mmsi,
@ -1085,11 +1097,14 @@ export function Map3D({
properties: { properties: {
mmsi: t.mmsi, mmsi: t.mmsi,
name: t.name || "", name: t.name || "",
cog, cog: cogNorm,
cog4,
sog: isFiniteNumber(t.sog) ? t.sog : 0, sog: isFiniteNumber(t.sog) ? t.sog : 0,
length: isFiniteNumber(t.length) ? t.length : 0, iconSize3,
width: isFiniteNumber(t.width) ? t.width : 0, iconSize7,
iconSize10,
iconSize14,
sizeScale,
selected: selected ? 1 : 0,
permitted: !!legacy, permitted: !!legacy,
code: legacy?.shipCode || "", code: legacy?.shipCode || "",
}, },
@ -1107,20 +1122,18 @@ export function Map3D({
} }
const visibility = settings.showShips ? "visible" : "none"; const visibility = settings.showShips ? "visible" : "none";
const isSelected = ["boolean", ["feature-state", "selected"], false] as const;
// Style-spec restriction: only one zoom-based step/interpolate is allowed in an expression.
const circleRadius = [ const circleRadius = [
"interpolate", "interpolate",
["linear"], ["linear"],
["zoom"], ["zoom"],
3, 3,
["case", isSelected, 5, 4], 4,
7, 7,
["case", isSelected, 8, 6], 6,
10, 10,
["case", isSelected, 10, 8], 8,
14, 14,
["case", isSelected, 14, 11], 11,
] as const; ] as const;
// Put ships at the top so they're always visible (especially important under globe projection). // Put ships at the top so they're always visible (especially important under globe projection).
@ -1168,8 +1181,8 @@ export function Map3D({
"circle-stroke-width": [ "circle-stroke-width": [
"case", "case",
["boolean", ["get", "permitted"], false], ["boolean", ["get", "permitted"], false],
["case", ["boolean", ["feature-state", "selected"], false], 2.5, 1.6], ["case", ["==", ["get", "selected"], 1], 2.5, 1.6],
["case", ["boolean", ["feature-state", "selected"], false], 2.0, 0.0], ["case", ["==", ["get", "selected"], 1], 2.0, 0.0],
] as unknown as number[], ] as unknown as number[],
"circle-stroke-opacity": 0.8, "circle-stroke-opacity": 0.8,
}, },
@ -1189,27 +1202,6 @@ export function Map3D({
if (!map.getLayer(symbolId)) { if (!map.getLayer(symbolId)) {
try { try {
const lengthExpr: unknown[] = ["to-number", ["get", "length"], 0];
const widthExpr: unknown[] = ["to-number", ["get", "width"], 0];
const hullExpr: unknown[] = clampExpr(["+", lengthExpr, ["*", 3, widthExpr]], 0, 420);
const sizeFactor: unknown[] = [
"interpolate",
["linear"],
hullExpr,
0,
0.85,
40,
0.95,
80,
1.0,
160,
1.25,
260,
1.55,
350,
1.85,
];
map.addLayer( map.addLayer(
{ {
id: symbolId, id: symbolId,
@ -1223,25 +1215,24 @@ export function Map3D({
["linear"], ["linear"],
["zoom"], ["zoom"],
3, 3,
["*", 0.32, sizeFactor], ["to-number", ["get", "iconSize3"], 0.35],
7, 7,
["*", 0.42, sizeFactor], ["to-number", ["get", "iconSize7"], 0.45],
10, 10,
["*", 0.52, sizeFactor], ["to-number", ["get", "iconSize10"], 0.56],
14, 14,
["*", 0.72, sizeFactor], ["to-number", ["get", "iconSize14"], 0.72],
] as unknown as number[], ] as unknown as number[],
"icon-allow-overlap": true, "icon-allow-overlap": true,
"icon-ignore-placement": true, "icon-ignore-placement": true,
"icon-anchor": "center", "icon-anchor": "center",
// Debug-friendly: quantize heading to N/E/S/W while we validate globe alignment. "icon-rotate": ["to-number", ["get", "cog"], 0],
"icon-rotate": ["get", "cog4"],
// Keep the icon on the sea surface. // Keep the icon on the sea surface.
"icon-rotation-alignment": "map", "icon-rotation-alignment": "map",
"icon-pitch-alignment": "map", "icon-pitch-alignment": "map",
}, },
paint: { paint: {
"icon-opacity": ["case", ["boolean", ["feature-state", "selected"], false], 1.0, 0.92], "icon-opacity": ["case", ["==", ["get", "selected"], 1], 1.0, 0.92],
}, },
} as unknown as LayerSpecification, } as unknown as LayerSpecification,
before, before,
@ -1257,19 +1248,7 @@ export function Map3D({
} }
} }
// Apply selection state for highlight. // Selection is now source-data driven (`selected` property), no per-feature state update needed.
try {
const prev = prevGlobeSelectedRef.current;
if (prev && prev !== selectedMmsi) map.setFeatureState({ source: srcId, id: prev }, { selected: false });
} catch {
// ignore
}
try {
if (selectedMmsi) map.setFeatureState({ source: srcId, id: selectedMmsi }, { selected: true });
} catch {
// ignore
}
prevGlobeSelectedRef.current = selectedMmsi;
kickRepaint(map); kickRepaint(map);
}; };
@ -1282,7 +1261,7 @@ export function Map3D({
// ignore // ignore
} }
}; };
}, [projection, settings.showShips, targets, legacyHits, selectedMmsi]); }, [projection, settings.showShips, targets, legacyHits, selectedMmsi, mapSyncEpoch]);
// Globe ship click selection (MapLibre-native ships layer) // Globe ship click selection (MapLibre-native ships layer)
useEffect(() => { useEffect(() => {
@ -1291,14 +1270,14 @@ export function Map3D({
if (projection !== "globe" || !settings.showShips) return; if (projection !== "globe" || !settings.showShips) return;
const symbolId = "ships-globe"; const symbolId = "ships-globe";
const haloId = "ships-globe-halo";
const outlineId = "ships-globe-outline";
const clickedRadiusDeg2 = Math.pow(0.08, 2);
const onClick = (e: maplibregl.MapMouseEvent) => { const onClick = (e: maplibregl.MapMouseEvent) => {
try { try {
if (!map.getLayer(symbolId)) { const layerIds = [symbolId, haloId, outlineId].filter((id) => map.getLayer(id));
onSelectMmsi(null); const feats = layerIds.length > 0 ? map.queryRenderedFeatures(e.point, { layers: layerIds }) : [];
return;
}
const feats = map.queryRenderedFeatures(e.point, { layers: [symbolId] });
const f = feats?.[0]; const f = feats?.[0];
const props = (f?.properties || {}) as Record<string, unknown>; const props = (f?.properties || {}) as Record<string, unknown>;
const mmsi = Number(props.mmsi); const mmsi = Number(props.mmsi);
@ -1306,6 +1285,25 @@ export function Map3D({
onSelectMmsi(mmsi); onSelectMmsi(mmsi);
return; return;
} }
const clicked = { lat: e.lngLat.lat, lon: e.lngLat.lng };
const cosLat = Math.cos(clicked.lat * DEG2RAD);
let bestMmsi: number | null = null;
let bestD2 = Number.POSITIVE_INFINITY;
for (const t of targets) {
if (!isFiniteNumber(t.lat) || !isFiniteNumber(t.lon)) continue;
const dLon = (clicked.lon - t.lon) * cosLat;
const dLat = clicked.lat - t.lat;
const d2 = dLon * dLon + dLat * dLat;
if (d2 <= clickedRadiusDeg2 && d2 < bestD2) {
bestD2 = d2;
bestMmsi = t.mmsi;
}
}
if (bestMmsi != null) {
onSelectMmsi(bestMmsi);
return;
}
} catch { } catch {
// ignore // ignore
} }
@ -1320,7 +1318,7 @@ export function Map3D({
// ignore // ignore
} }
}; };
}, [projection, settings.showShips, onSelectMmsi]); }, [projection, settings.showShips, onSelectMmsi, mapSyncEpoch, targets]);
// Globe overlays (pair links / FC links / ranges) rendered as MapLibre GeoJSON layers. // Globe overlays (pair links / FC links / ranges) rendered as MapLibre GeoJSON layers.
// Deck custom layers are more fragile under globe projection; MapLibre-native rendering stays aligned like zones. // Deck custom layers are more fragile under globe projection; MapLibre-native rendering stays aligned like zones.
@ -1411,7 +1409,7 @@ export function Map3D({
} }
remove(); remove();
}; };
}, [projection, overlays.pairLines, pairLinks]); }, [projection, overlays.pairLines, pairLinks, mapSyncEpoch]);
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
@ -1507,7 +1505,7 @@ export function Map3D({
} }
remove(); remove();
}; };
}, [projection, overlays.fcLines, fcLinks]); }, [projection, overlays.fcLines, fcLinks, mapSyncEpoch]);
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
@ -1594,7 +1592,7 @@ export function Map3D({
} }
remove(); remove();
}; };
}, [projection, overlays.fleetCircles, fleetCircles]); }, [projection, overlays.fleetCircles, fleetCircles, mapSyncEpoch]);
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
@ -1696,22 +1694,12 @@ export function Map3D({
} }
remove(); remove();
}; };
}, [projection, overlays.pairRange, pairLinks]); }, [projection, overlays.pairRange, pairLinks, mapSyncEpoch]);
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));
@ -1965,6 +1953,7 @@ export function Map3D({
}, [ }, [
projection, projection,
shipData, shipData,
baseMap,
zones, zones,
selectedMmsi, selectedMmsi,
overlays.zones, overlays.zones,
@ -1981,7 +1970,7 @@ export function Map3D({
pairRanges, pairRanges,
fcDashed, fcDashed,
fleetCircles, fleetCircles,
globePosByMmsi, mapSyncEpoch,
]); ]);
return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />; return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;

파일 보기

@ -169,9 +169,18 @@ export class MaplibreDeckCustomLayer implements maplibregl.CustomLayerInterface
deck.setProps({ viewState: { [this._viewId]: { projectionMatrix, viewMatrix: IDENTITY_4x4 } } }); deck.setProps({ viewState: { [this._viewId]: { projectionMatrix, viewMatrix: IDENTITY_4x4 } } });
} }
try {
deck._drawLayers("maplibre-custom", { deck._drawLayers("maplibre-custom", {
clearCanvas: false, clearCanvas: false,
clearStack: true, 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();
});
}
} }
} }