feat(map): add prediction vectors and ship labels toggles

This commit is contained in:
htlee 2026-02-15 19:15:20 +09:00
부모 0899223c75
커밋 11aff67a04
5개의 변경된 파일499개의 추가작업 그리고 77개의 파일을 삭제

파일 보기

@ -638,6 +638,19 @@ body {
margin-bottom: 6px; margin-bottom: 6px;
} }
.tog.tog-map {
/* Keep "지도 표시 설정" buttons in a predictable 2-row layout (4 columns). */
gap: 4px;
}
.tog.tog-map .tog-btn {
flex: 1 1 calc(25% - 4px);
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tog-btn { .tog-btn {
font-size: 8px; font-size: 8px;
padding: 2px 6px; padding: 2px 6px;

파일 보기

@ -4,6 +4,8 @@ export type MapToggleState = {
fcLines: boolean; fcLines: boolean;
zones: boolean; zones: boolean;
fleetCircles: boolean; fleetCircles: boolean;
predictVectors: boolean;
shipLabels: boolean;
}; };
type Props = { type Props = {
@ -16,12 +18,14 @@ export function MapToggles({ value, onToggle }: Props) {
{ id: "pairLines", label: "쌍 연결선" }, { id: "pairLines", label: "쌍 연결선" },
{ id: "pairRange", label: "쌍 연결범위" }, { id: "pairRange", label: "쌍 연결범위" },
{ id: "fcLines", label: "환적 연결선" }, { id: "fcLines", label: "환적 연결선" },
{ id: "zones", label: "수역 표시" },
{ id: "fleetCircles", label: "선단 범위" }, { id: "fleetCircles", label: "선단 범위" },
{ id: "zones", label: "수역 표시" },
{ id: "predictVectors", label: "예측 벡터" },
{ id: "shipLabels", label: "선박명 표시" },
]; ];
return ( return (
<div className="tog"> <div className="tog tog-map">
{items.map((t) => ( {items.map((t) => (
<div key={t.id} className={`tog-btn ${value[t.id] ? "on" : ""}`} onClick={() => onToggle(t.id)}> <div key={t.id} className={`tog-btn ${value[t.id] ? "on" : ""}`} onClick={() => onToggle(t.id)}>
{t.label} {t.label}

파일 보기

@ -115,6 +115,8 @@ export function DashboardPage() {
fcLines: true, fcLines: true,
zones: true, zones: true,
fleetCircles: true, fleetCircles: true,
predictVectors: false,
shipLabels: false,
}); });
const [fleetRelationSortMode, setFleetRelationSortMode] = useState<FleetRelationSortMode>("count"); const [fleetRelationSortMode, setFleetRelationSortMode] = useState<FleetRelationSortMode>("count");

파일 보기

@ -98,6 +98,17 @@ export function MapLegend() {
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.suspicious), borderRadius: 1 }} /> <div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.suspicious), borderRadius: 1 }} />
FC () FC ()
</div> </div>
<div className="li">
<div
style={{
width: 20,
height: 2,
borderRadius: 1,
background: "repeating-linear-gradient(to right, rgba(226,232,240,0.55), rgba(226,232,240,0.55) 4px, rgba(0,0,0,0) 4px, rgba(0,0,0,0) 7px)",
}}
/>
(15)
</div>
</div> </div>
); );
} }

파일 보기

@ -17,7 +17,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 { LEGACY_CODE_COLORS_RGB, OVERLAY_RGB, rgba as rgbaCss } from "../../shared/lib/map/palette"; import { LEGACY_CODE_COLORS_RGB, OTHER_AIS_SPEED_RGB, OVERLAY_RGB, rgba as rgbaCss } from "../../shared/lib/map/palette";
import { MaplibreDeckCustomLayer } from "./MaplibreDeckCustomLayer"; import { MaplibreDeckCustomLayer } from "./MaplibreDeckCustomLayer";
export type Map3DSettings = { export type Map3DSettings = {
@ -288,6 +288,7 @@ function extractProjectionType(map: maplibregl.Map): MapProjectionId | undefined
} }
const DEG2RAD = Math.PI / 180; const DEG2RAD = Math.PI / 180;
const RAD2DEG = 180 / Math.PI;
const GLOBE_ICON_HEADING_OFFSET_DEG = -90; const GLOBE_ICON_HEADING_OFFSET_DEG = -90;
const MAP_SELECTED_SHIP_RGB: [number, number, number] = [34, 211, 238]; const MAP_SELECTED_SHIP_RGB: [number, number, number] = [34, 211, 238];
const MAP_HIGHLIGHT_SHIP_RGB: [number, number, number] = [245, 158, 11]; const MAP_HIGHLIGHT_SHIP_RGB: [number, number, number] = [245, 158, 11];
@ -301,6 +302,42 @@ function getLayerId(value: unknown): string | null {
return typeof candidate === "string" ? candidate : null; return typeof candidate === "string" ? candidate : null;
} }
function wrapLonDeg(lon: number) {
// Normalize longitude into [-180, 180).
const v = ((lon + 180) % 360 + 360) % 360;
return v - 180;
}
function destinationPointLngLat(
from: [number, number], // [lon, lat]
bearingDeg: number,
distanceMeters: number,
): [number, number] {
const [lonDeg, latDeg] = from;
const lat1 = latDeg * DEG2RAD;
const lon1 = lonDeg * DEG2RAD;
const brng = bearingDeg * DEG2RAD;
const dr = Math.max(0, distanceMeters) / EARTH_RADIUS_M;
if (!Number.isFinite(dr) || dr === 0) return [lonDeg, latDeg];
const sinLat1 = Math.sin(lat1);
const cosLat1 = Math.cos(lat1);
const sinDr = Math.sin(dr);
const cosDr = Math.cos(dr);
const lat2 = Math.asin(sinLat1 * cosDr + cosLat1 * sinDr * Math.cos(brng));
const lon2 =
lon1 +
Math.atan2(
Math.sin(brng) * sinDr * cosLat1,
cosDr - sinLat1 * Math.sin(lat2),
);
const outLon = wrapLonDeg(lon2 * RAD2DEG);
const outLat = clampNumber(lat2 * RAD2DEG, -85.0, 85.0);
return [outLon, outLat];
}
function sanitizeDeckLayerList(value: unknown): unknown[] { function sanitizeDeckLayerList(value: unknown): unknown[] {
if (!Array.isArray(value)) return []; if (!Array.isArray(value)) return [];
const seen = new Set<string>(); const seen = new Set<string>();
@ -1297,18 +1334,21 @@ export function Map3D({
if (!map || projectionRef.current !== "globe") return; if (!map || projectionRef.current !== "globe") return;
if (projectionBusyRef.current) return; if (projectionBusyRef.current) return;
const ordering = [ const ordering = [
"zones-fill", "zones-fill",
"zones-line", "zones-line",
"zones-label", "zones-label",
"ships-globe-halo", "predict-vectors",
"ships-globe-outline", "predict-vectors-hl",
"ships-globe", "ships-globe-halo",
"ships-globe-hover-halo", "ships-globe-outline",
"ships-globe-hover-outline", "ships-globe",
"ships-globe-hover", "ships-globe-label",
"pair-lines-ml", "ships-globe-hover-halo",
"fc-lines-ml", "ships-globe-hover-outline",
"ships-globe-hover",
"pair-lines-ml",
"fc-lines-ml",
"pair-range-ml", "pair-range-ml",
"fleet-circles-ml-fill", "fleet-circles-ml-fill",
"fleet-circles-ml", "fleet-circles-ml",
@ -1706,15 +1746,16 @@ export function Map3D({
const map = mapRef.current; const map = mapRef.current;
if (!map) return; if (!map) return;
const layerIds = [ const layerIds = [
"ships-globe-halo", "ships-globe-halo",
"ships-globe-outline", "ships-globe-outline",
"ships-globe", "ships-globe",
"ships-globe-hover-halo", "ships-globe-label",
"ships-globe-hover-outline", "ships-globe-hover-halo",
"ships-globe-hover", "ships-globe-hover-outline",
"pair-lines-ml", "ships-globe-hover",
"fc-lines-ml", "pair-lines-ml",
"fc-lines-ml",
"fleet-circles-ml-fill", "fleet-circles-ml-fill",
"fleet-circles-ml", "fleet-circles-ml",
"pair-range-ml", "pair-range-ml",
@ -2410,25 +2451,307 @@ export function Map3D({
}; };
}, [zones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]); }, [zones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]);
// Prediction vectors: MapLibre-native GeoJSON line layer so it stays stable in both mercator + globe.
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const srcId = "predict-vectors-src";
const lineId = "predict-vectors";
const hlId = "predict-vectors-hl";
const ensure = () => {
if (projectionBusyRef.current) return;
if (!map.isStyleLoaded()) return;
const visibility = overlays.predictVectors ? "visible" : "none";
const horizonMinutes = 15;
const horizonSeconds = horizonMinutes * 60;
const metersPerSecondPerKnot = 0.514444;
const features: GeoJSON.Feature<GeoJSON.LineString>[] = [];
if (overlays.predictVectors && settings.showShips && shipData.length > 0) {
for (const t of shipData) {
const legacy = legacyHits?.get(t.mmsi) ?? null;
const isTarget = !!legacy;
const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi;
const isPinnedHighlight = externalHighlightedSetRef.has(t.mmsi);
if (!isTarget && !isSelected && !isPinnedHighlight) continue;
const sog = isFiniteNumber(t.sog) ? t.sog : null;
const cog =
isFiniteNumber(t.cog) ? t.cog : isFiniteNumber(t.heading) ? t.heading : null;
if (sog == null || cog == null) continue;
if (sog < 0.2) continue;
const distM = sog * metersPerSecondPerKnot * horizonSeconds;
if (!Number.isFinite(distM) || distM <= 0) continue;
const to = destinationPointLngLat([t.lon, t.lat], cog, distM);
const rgb = isTarget
? LEGACY_CODE_COLORS_RGB[legacy?.shipCode ?? ""] ?? OTHER_AIS_SPEED_RGB.moving
: OTHER_AIS_SPEED_RGB.moving;
const alpha = isTarget ? 0.48 : 0.28;
const hl = isSelected || isPinnedHighlight ? 1 : 0;
features.push({
type: "Feature",
id: `pred-${t.mmsi}`,
geometry: { type: "LineString", coordinates: [[t.lon, t.lat], to] },
properties: {
mmsi: t.mmsi,
minutes: horizonMinutes,
sog,
cog,
target: isTarget ? 1 : 0,
hl,
color: rgbaCss(rgb, alpha),
},
});
}
}
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = { type: "FeatureCollection", features };
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(fc);
else map.addSource(srcId, { type: "geojson", data: fc } as GeoJSONSourceSpecification);
} catch (e) {
console.warn("Prediction vector source setup failed:", e);
return;
}
const ensureLayer = (id: string, paint: LayerSpecification["paint"], filter?: unknown[]) => {
if (!map.getLayer(id)) {
try {
map.addLayer(
{
id,
type: "line",
source: srcId,
...(filter ? { filter: filter as never } : {}),
layout: {
visibility,
"line-cap": "round",
"line-join": "round",
},
paint,
} as unknown as LayerSpecification,
undefined,
);
} catch (e) {
console.warn("Prediction vector layer add failed:", e);
}
} else {
try {
map.setLayoutProperty(id, "visibility", visibility);
} catch {
// ignore
}
}
};
ensureLayer(
lineId,
{
"line-color": ["coalesce", ["get", "color"], "rgba(148,163,184,0.3)"] as never,
"line-width": 1.2,
"line-opacity": 1,
"line-dasharray": [1.2, 1.8] as never,
} as never,
);
ensureLayer(
hlId,
{
"line-color": ["coalesce", ["get", "color"], "rgba(226,232,240,0.7)"] as never,
"line-width": 2.2,
"line-opacity": 1,
"line-dasharray": [1.2, 1.8] as never,
} as never,
["==", ["to-number", ["get", "hl"], 0], 1] as unknown as unknown[],
);
reorderGlobeFeatureLayers();
kickRepaint(map);
};
const stop = onMapStyleReady(map, ensure);
return () => {
stop();
};
}, [
overlays.predictVectors,
settings.showShips,
shipData,
legacyHits,
selectedMmsi,
externalHighlightedSetRef,
projection,
baseMap,
mapSyncEpoch,
reorderGlobeFeatureLayers,
]);
// Ship name labels in mercator: MapLibre-native symbol layer so collision/placement is handled automatically.
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const srcId = "ship-labels-src";
const layerId = "ship-labels";
const remove = () => {
try {
if (map.getLayer(layerId)) map.removeLayer(layerId);
} catch {
// ignore
}
try {
if (map.getSource(srcId)) map.removeSource(srcId);
} catch {
// ignore
}
};
const ensure = () => {
if (projectionBusyRef.current) return;
if (!map.isStyleLoaded()) return;
if (projection !== "mercator" || !settings.showShips) {
remove();
return;
}
const visibility = overlays.shipLabels ? "visible" : "none";
const features: GeoJSON.Feature<GeoJSON.Point>[] = [];
for (const t of shipData) {
const legacy = legacyHits?.get(t.mmsi) ?? null;
const isTarget = !!legacy;
const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi;
const isPinnedHighlight = externalHighlightedSetRef.has(t.mmsi);
if (!isTarget && !isSelected && !isPinnedHighlight) continue;
const labelName = (legacy?.shipNameCn || legacy?.shipNameRoman || t.name || "").trim();
if (!labelName) continue;
features.push({
type: "Feature",
id: `ship-label-${t.mmsi}`,
geometry: { type: "Point", coordinates: [t.lon, t.lat] },
properties: {
mmsi: t.mmsi,
labelName,
selected: isSelected ? 1 : 0,
highlighted: isPinnedHighlight ? 1 : 0,
permitted: isTarget ? 1 : 0,
},
});
}
const fc: GeoJSON.FeatureCollection<GeoJSON.Point> = { type: "FeatureCollection", features };
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(fc);
else map.addSource(srcId, { type: "geojson", data: fc } as GeoJSONSourceSpecification);
} catch (e) {
console.warn("Ship label source setup failed:", e);
return;
}
const filter = ["!=", ["to-string", ["coalesce", ["get", "labelName"], ""]], ""] as unknown as unknown[];
if (!map.getLayer(layerId)) {
try {
map.addLayer(
{
id: layerId,
type: "symbol",
source: srcId,
minzoom: 7,
filter: filter as never,
layout: {
visibility,
"symbol-placement": "point",
"text-field": ["get", "labelName"] as never,
"text-font": ["Noto Sans Regular", "Open Sans Regular"],
"text-size": ["interpolate", ["linear"], ["zoom"], 7, 10, 10, 11, 12, 12, 14, 13] as never,
"text-anchor": "top",
"text-offset": [0, 1.1],
"text-padding": 2,
"text-allow-overlap": false,
"text-ignore-placement": false,
},
paint: {
"text-color": [
"case",
["==", ["get", "selected"], 1],
"rgba(14,234,255,0.95)",
["==", ["get", "highlighted"], 1],
"rgba(245,158,11,0.95)",
"rgba(226,232,240,0.92)",
] as never,
"text-halo-color": "rgba(2,6,23,0.85)",
"text-halo-width": 1.2,
"text-halo-blur": 0.8,
},
} as unknown as LayerSpecification,
undefined,
);
} catch (e) {
console.warn("Ship label layer add failed:", e);
}
} else {
try {
map.setLayoutProperty(layerId, "visibility", visibility);
} catch {
// ignore
}
}
kickRepaint(map);
};
const stop = onMapStyleReady(map, ensure);
return () => {
stop();
};
}, [
projection,
settings.showShips,
overlays.shipLabels,
shipData,
legacyHits,
selectedMmsi,
externalHighlightedSetRef,
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.
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
if (!map) return; if (!map) return;
const imgId = "ship-globe-icon"; const imgId = "ship-globe-icon";
const srcId = "ships-globe-src"; const srcId = "ships-globe-src";
const haloId = "ships-globe-halo"; const haloId = "ships-globe-halo";
const outlineId = "ships-globe-outline"; const outlineId = "ships-globe-outline";
const symbolId = "ships-globe"; const symbolId = "ships-globe";
const labelId = "ships-globe-label";
const remove = () => { const remove = () => {
for (const id of [symbolId, outlineId, haloId]) { for (const id of [labelId, symbolId, outlineId, haloId]) {
try { try {
if (map.getLayer(id)) map.removeLayer(id); if (map.getLayer(id)) map.removeLayer(id);
} catch { } catch {
// ignore // ignore
} }
} }
try { try {
if (map.getSource(srcId)) map.removeSource(srcId); if (map.getSource(srcId)) map.removeSource(srcId);
@ -2527,16 +2850,21 @@ export function Map3D({
console.warn("Ship icon image setup failed:", e); console.warn("Ship icon image setup failed:", e);
} }
const globeShipData = shipData; const globeShipData = shipData;
const geojson: GeoJSON.FeatureCollection<GeoJSON.Point> = { const geojson: GeoJSON.FeatureCollection<GeoJSON.Point> = {
type: "FeatureCollection", type: "FeatureCollection",
features: globeShipData.map((t) => { features: globeShipData.map((t) => {
const legacy = legacyHits?.get(t.mmsi) ?? null; const legacy = legacyHits?.get(t.mmsi) ?? null;
const heading = getDisplayHeading({ const labelName =
cog: t.cog, legacy?.shipNameCn ||
heading: t.heading, legacy?.shipNameRoman ||
offset: GLOBE_ICON_HEADING_OFFSET_DEG, t.name ||
}); "";
const heading = getDisplayHeading({
cog: t.cog,
heading: t.heading,
offset: GLOBE_ICON_HEADING_OFFSET_DEG,
});
const hull = clampNumber((isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, 50, 420); 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 sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
const selected = t.mmsi === selectedMmsi; const selected = t.mmsi === selectedMmsi;
@ -2552,14 +2880,15 @@ export function Map3D({
type: "Feature", type: "Feature",
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}), ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
geometry: { type: "Point", coordinates: [t.lon, t.lat] }, geometry: { type: "Point", coordinates: [t.lon, t.lat] },
properties: { properties: {
mmsi: t.mmsi, mmsi: t.mmsi,
name: t.name || "", name: t.name || "",
cog: heading, labelName,
heading, cog: heading,
sog: isFiniteNumber(t.sog) ? t.sog : 0, heading,
shipColor: getGlobeBaseShipColor({ sog: isFiniteNumber(t.sog) ? t.sog : 0,
legacy: legacy?.shipCode || null, shipColor: getGlobeBaseShipColor({
legacy: legacy?.shipCode || null,
sog: isFiniteNumber(t.sog) ? t.sog : null, sog: isFiniteNumber(t.sog) ? t.sog : null,
}), }),
iconSize3: iconSize3 * iconScale, iconSize3: iconSize3 * iconScale,
@ -2783,10 +3112,10 @@ export function Map3D({
} }
} }
if (!map.getLayer(symbolId)) { if (!map.getLayer(symbolId)) {
try { try {
map.addLayer( map.addLayer(
{ {
id: symbolId, id: symbolId,
type: "symbol", type: "symbol",
source: srcId, source: srcId,
@ -2847,10 +3176,10 @@ export function Map3D({
} catch (e) { } catch (e) {
console.warn("Ship symbol layer add failed:", e); console.warn("Ship symbol layer add failed:", e);
} }
} else { } else {
try { try {
map.setLayoutProperty(symbolId, "visibility", visibility); map.setLayoutProperty(symbolId, "visibility", visibility);
map.setLayoutProperty( map.setLayoutProperty(
symbolId, symbolId,
"symbol-sort-key", "symbol-sort-key",
[ [
@ -2884,27 +3213,90 @@ export function Map3D({
); );
} catch { } catch {
// ignore // ignore
} }
} }
// Selection and highlight are now source-data driven. // Optional ship name labels (toggle). Keep labels readable and avoid clutter.
reorderGlobeFeatureLayers(); const labelVisibility = overlays.shipLabels ? "visible" : "none";
kickRepaint(map); const labelFilter = [
}; "all",
["!=", ["to-string", ["coalesce", ["get", "labelName"], ""]], ""],
[
"any",
["==", ["get", "permitted"], 1],
["==", ["get", "selected"], 1],
["==", ["get", "highlighted"], 1],
],
] as unknown as unknown[];
if (!map.getLayer(labelId)) {
try {
map.addLayer(
{
id: labelId,
type: "symbol",
source: srcId,
minzoom: 7,
filter: labelFilter as never,
layout: {
visibility: labelVisibility,
"symbol-placement": "point",
"text-field": ["get", "labelName"] as never,
"text-font": ["Noto Sans Regular", "Open Sans Regular"],
"text-size": ["interpolate", ["linear"], ["zoom"], 7, 10, 10, 11, 12, 12, 14, 13] as never,
"text-anchor": "top",
"text-offset": [0, 1.1],
"text-padding": 2,
"text-allow-overlap": false,
"text-ignore-placement": false,
},
paint: {
"text-color": [
"case",
["==", ["get", "selected"], 1],
"rgba(14,234,255,0.95)",
["==", ["get", "highlighted"], 1],
"rgba(245,158,11,0.95)",
"rgba(226,232,240,0.92)",
] as never,
"text-halo-color": "rgba(2,6,23,0.85)",
"text-halo-width": 1.2,
"text-halo-blur": 0.8,
},
} as unknown as LayerSpecification,
undefined,
);
} catch (e) {
console.warn("Ship label layer add failed:", e);
}
} else {
try {
map.setLayoutProperty(labelId, "visibility", labelVisibility);
} catch {
// ignore
}
}
// Selection and highlight are now source-data driven.
reorderGlobeFeatureLayers();
kickRepaint(map);
};
const stop = onMapStyleReady(map, ensure); const stop = onMapStyleReady(map, ensure);
return () => { return () => {
stop(); stop();
}; };
}, [ }, [
projection, projection,
settings.showShips, settings.showShips,
shipData, overlays.shipLabels,
legacyHits, shipData,
selectedMmsi, legacyHits,
mapSyncEpoch, selectedMmsi,
reorderGlobeFeatureLayers, isBaseHighlightedMmsi,
]); mapSyncEpoch,
reorderGlobeFeatureLayers,
]);
// Globe hover overlay ships: update only hovered/selected targets to avoid rebuilding full ship layer on every hover. // Globe hover overlay ships: update only hovered/selected targets to avoid rebuilding full ship layer on every hover.
useEffect(() => { useEffect(() => {