feat(map): add prediction vectors and ship labels toggles
This commit is contained in:
부모
0899223c75
커밋
11aff67a04
@ -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(() => {
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user