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 VectorSourceSpecification,
} 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 { LegacyVesselInfo } from "../../entities/legacyVessel/model/types";
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;
function clampExpr(inputExpr: unknown, minValue: number, maxValue: number): unknown[] {
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 clampNumber = (value: number, minValue: number, maxValue: number) => Math.max(minValue, Math.min(maxValue, value));
const LEGACY_CODE_COLORS: Record<string, [number, number, number]> = {
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),
// which may exceed MapLibre's per-segment 16-bit vertex limit and render incorrectly.
// We keep the fill starting at a more reasonable zoom.
minzoom: 4,
minzoom: 6,
maxzoom: 12,
paint: {
// Dark-mode friendly palette (shallow = slightly brighter; deep = near-black).
"fill-color": bathyFillColor,
@ -236,38 +225,15 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str
},
} 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 = {
id: "bathymetry-borders",
type: "line",
source: oceanSourceId,
"source-layer": "contour",
minzoom: 4,
minzoom: 6,
maxzoom: 14,
paint: {
"line-color": "rgba(255,255,255,0.06)",
"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 bathyMajorDepthFilter: unknown[] = [
"match",
"in",
["to-number", ["get", "depth"]],
...majorDepths.map((v) => [v, true]).flat(),
false,
["literal", majorDepths],
] as unknown[];
const bathyLinesMajor: LayerSpecification = {
@ -316,6 +281,7 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str
source: oceanSourceId,
"source-layer": "contour_line",
minzoom: 8,
maxzoom: 14,
filter: bathyMajorDepthFilter as unknown as unknown[],
paint: {
"line-color": "rgba(255,255,255,0.16)",
@ -331,6 +297,7 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str
source: oceanSourceId,
"source-layer": "contour",
minzoom: 4,
maxzoom: 14,
filter: bathyMajorDepthFilter as unknown as unknown[],
paint: {
"line-color": "rgba(255,255,255,0.14)",
@ -394,7 +361,6 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str
const toInsert = [
bathyFill,
bathyExtrusion,
bathyBandBorders,
bathyBandBordersMajor,
bathyLinesMinor,
@ -486,6 +452,7 @@ type PairRangeCircle = {
warn: boolean;
};
const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius`
const DECK_VIEW_ID = "mapbox";
export function Map3D({
@ -507,10 +474,11 @@ export function Map3D({
const mapRef = useRef<maplibregl.Map | null>(null);
const overlayRef = useRef<MapboxOverlay | null>(null);
const globeDeckLayerRef = useRef<MaplibreDeckCustomLayer | null>(null);
const prevGlobeSelectedRef = useRef<number | null>(null);
const globeShipsEpochRef = useRef(-1);
const showSeamarkRef = useRef(settings.showSeamark);
const baseMapRef = useRef<BaseMapId>(baseMap);
const projectionRef = useRef<MapProjectionId>(projection);
const [mapSyncEpoch, setMapSyncEpoch] = useState(0);
useEffect(() => {
showSeamarkRef.current = settings.showSeamark;
@ -675,29 +643,56 @@ export function Map3D({
const map = mapRef.current;
if (!map) return;
let cancelled = false;
let retries = 0;
const maxRetries = 6;
const syncProjectionAndDeck = () => {
if (cancelled) return;
if (!map.isStyleLoaded()) {
if (!cancelled && retries < maxRetries) {
retries += 1;
window.requestAnimationFrame(() => syncProjectionAndDeck());
}
return;
}
const next = projection;
try {
map.setProjection({ type: projection });
map.setRenderWorldCopies(projection !== "globe");
map.setProjection({ type: next });
map.setRenderWorldCopies(next !== "globe");
} catch (e) {
if (!cancelled && retries < maxRetries) {
retries += 1;
window.requestAnimationFrame(() => syncProjectionAndDeck());
return;
}
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) {
const oldOverlay = overlayRef.current;
if (projection === "globe" && oldOverlay) {
// Globe mode uses custom MapLibre deck layers and should fully replace Mercator overlays.
try {
old.finalize();
oldOverlay.finalize();
} catch {
// ignore
}
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) {
globeDeckLayerRef.current = new MaplibreDeckCustomLayer({
id: "deck-globe",
@ -745,6 +740,8 @@ export function Map3D({
} catch {
// ignore
}
setMapSyncEpoch((prev) => prev + 1);
};
if (map.isStyleLoaded()) syncProjectionAndDeck();
@ -778,6 +775,7 @@ export function Map3D({
map.once("style.load", () => {
kickRepaint(map);
requestAnimationFrame(() => kickRepaint(map));
setMapSyncEpoch((prev) => prev + 1);
});
} catch (e) {
if (cancelled) return;
@ -801,7 +799,7 @@ export function Map3D({
if (!map.isStyleLoaded()) return;
const disableBathyHeavy = projection === "globe" && baseMap === "enhanced";
const visHeavy = disableBathyHeavy ? "none" : "visible";
const disableBaseMapSea = projection === "globe" && baseMap === "enhanced";
const disableBaseMapSea = projection === "globe";
const seaVisibility = disableBaseMapSea ? "none" : "visible";
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.
// Hide them only in globe/enhanced mode and restore on return.
// Vector basemap water/raster layers can flicker on globe with dense symbols/fills in this stack.
// Hide them only in globe mode and restore on return.
try {
for (const layer of map.getStyle().layers || []) {
const id = String(layer.id ?? "");
if (!id) continue;
const sourceLayer = String((layer as Record<string, unknown>)["source-layer"] ?? "").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);
if (!isSea) continue;
const isRaster = type === "raster";
if (!isSea && !isRaster) continue;
if (!map.getLayer(id)) continue;
if (isRaster && id === "seamark") continue;
try {
map.setLayoutProperty(id, "visibility", seaVisibility);
} catch {
@ -853,7 +854,7 @@ export function Map3D({
// ignore
}
};
}, [projection, baseMap]);
}, [projection, baseMap, mapSyncEpoch]);
// seamark toggle
useEffect(() => {
@ -983,7 +984,7 @@ export function Map3D({
// 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.
// 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 {
// ignore
}
prevGlobeSelectedRef.current = null;
kickRepaint(map);
};
@ -1064,6 +1064,11 @@ export function Map3D({
return;
}
if (globeShipsEpochRef.current !== mapSyncEpoch) {
remove();
globeShipsEpochRef.current = mapSyncEpoch;
}
try {
ensureImage();
} catch (e) {
@ -1077,7 +1082,14 @@ export function Map3D({
const legacy = legacyHits?.get(t.mmsi) ?? null;
const cog = isFiniteNumber(t.cog) ? t.cog : 0;
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 {
type: "Feature",
id: t.mmsi,
@ -1085,11 +1097,14 @@ export function Map3D({
properties: {
mmsi: t.mmsi,
name: t.name || "",
cog,
cog4,
cog: cogNorm,
sog: isFiniteNumber(t.sog) ? t.sog : 0,
length: isFiniteNumber(t.length) ? t.length : 0,
width: isFiniteNumber(t.width) ? t.width : 0,
iconSize3,
iconSize7,
iconSize10,
iconSize14,
sizeScale,
selected: selected ? 1 : 0,
permitted: !!legacy,
code: legacy?.shipCode || "",
},
@ -1107,20 +1122,18 @@ export function Map3D({
}
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 = [
"interpolate",
["linear"],
["zoom"],
3,
["case", isSelected, 5, 4],
4,
7,
["case", isSelected, 8, 6],
6,
10,
["case", isSelected, 10, 8],
8,
14,
["case", isSelected, 14, 11],
11,
] as const;
// 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": [
"case",
["boolean", ["get", "permitted"], false],
["case", ["boolean", ["feature-state", "selected"], false], 2.5, 1.6],
["case", ["boolean", ["feature-state", "selected"], false], 2.0, 0.0],
["case", ["==", ["get", "selected"], 1], 2.5, 1.6],
["case", ["==", ["get", "selected"], 1], 2.0, 0.0],
] as unknown as number[],
"circle-stroke-opacity": 0.8,
},
@ -1189,27 +1202,6 @@ export function Map3D({
if (!map.getLayer(symbolId)) {
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(
{
id: symbolId,
@ -1223,25 +1215,24 @@ export function Map3D({
["linear"],
["zoom"],
3,
["*", 0.32, sizeFactor],
["to-number", ["get", "iconSize3"], 0.35],
7,
["*", 0.42, sizeFactor],
["to-number", ["get", "iconSize7"], 0.45],
10,
["*", 0.52, sizeFactor],
["to-number", ["get", "iconSize10"], 0.56],
14,
["*", 0.72, sizeFactor],
["to-number", ["get", "iconSize14"], 0.72],
] as unknown as number[],
"icon-allow-overlap": true,
"icon-ignore-placement": true,
"icon-anchor": "center",
// Debug-friendly: quantize heading to N/E/S/W while we validate globe alignment.
"icon-rotate": ["get", "cog4"],
"icon-rotate": ["to-number", ["get", "cog"], 0],
// Keep the icon on the sea surface.
"icon-rotation-alignment": "map",
"icon-pitch-alignment": "map",
},
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,
before,
@ -1257,19 +1248,7 @@ export function Map3D({
}
}
// Apply selection state for highlight.
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;
// Selection is now source-data driven (`selected` property), no per-feature state update needed.
kickRepaint(map);
};
@ -1282,7 +1261,7 @@ export function Map3D({
// ignore
}
};
}, [projection, settings.showShips, targets, legacyHits, selectedMmsi]);
}, [projection, settings.showShips, targets, legacyHits, selectedMmsi, mapSyncEpoch]);
// Globe ship click selection (MapLibre-native ships layer)
useEffect(() => {
@ -1291,14 +1270,14 @@ export function Map3D({
if (projection !== "globe" || !settings.showShips) return;
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) => {
try {
if (!map.getLayer(symbolId)) {
onSelectMmsi(null);
return;
}
const feats = map.queryRenderedFeatures(e.point, { layers: [symbolId] });
const layerIds = [symbolId, haloId, outlineId].filter((id) => map.getLayer(id));
const feats = layerIds.length > 0 ? map.queryRenderedFeatures(e.point, { layers: layerIds }) : [];
const f = feats?.[0];
const props = (f?.properties || {}) as Record<string, unknown>;
const mmsi = Number(props.mmsi);
@ -1306,6 +1285,25 @@ export function Map3D({
onSelectMmsi(mmsi);
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 {
// ignore
}
@ -1320,7 +1318,7 @@ export function Map3D({
// ignore
}
};
}, [projection, settings.showShips, onSelectMmsi]);
}, [projection, settings.showShips, onSelectMmsi, mapSyncEpoch, targets]);
// 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.
@ -1411,7 +1409,7 @@ export function Map3D({
}
remove();
};
}, [projection, overlays.pairLines, pairLinks]);
}, [projection, overlays.pairLines, pairLinks, mapSyncEpoch]);
useEffect(() => {
const map = mapRef.current;
@ -1507,7 +1505,7 @@ export function Map3D({
}
remove();
};
}, [projection, overlays.fcLines, fcLinks]);
}, [projection, overlays.fcLines, fcLinks, mapSyncEpoch]);
useEffect(() => {
const map = mapRef.current;
@ -1594,7 +1592,7 @@ export function Map3D({
}
remove();
};
}, [projection, overlays.fleetCircles, fleetCircles]);
}, [projection, overlays.fleetCircles, fleetCircles, mapSyncEpoch]);
useEffect(() => {
const map = mapRef.current;
@ -1696,22 +1694,12 @@ export function Map3D({
}
remove();
};
}, [projection, overlays.pairRange, pairLinks]);
}, [projection, overlays.pairRange, pairLinks, mapSyncEpoch]);
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));
@ -1965,6 +1953,7 @@ export function Map3D({
}, [
projection,
shipData,
baseMap,
zones,
selectedMmsi,
overlays.zones,
@ -1981,7 +1970,7 @@ export function Map3D({
pairRanges,
fcDashed,
fleetCircles,
globePosByMmsi,
mapSyncEpoch,
]);
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 } } });
}
try {
deck._drawLayers("maplibre-custom", {
clearCanvas: false,
clearStack: true,
});
} catch (e) {
// Rendering can fail transiently during style/projection transitions.
// Keep the map responsive and request a clean pass on next frame.
console.warn("Deck render sync failed, skipping frame:", e);
requestAnimationFrame(() => {
this._map?.triggerRepaint();
});
}
}
}