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;
}
.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 {
font-size: 8px;
padding: 2px 6px;

파일 보기

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

파일 보기

@ -115,6 +115,8 @@ export function DashboardPage() {
fcLines: true,
zones: true,
fleetCircles: true,
predictVectors: false,
shipLabels: false,
});
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 }} />
FC ()
</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>
);
}

파일 보기

@ -17,7 +17,7 @@ import type { ZoneId } from "../../entities/zone/model/meta";
import { ZONE_META } from "../../entities/zone/model/meta";
import type { MapToggleState } from "../../features/mapToggles/MapToggles";
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";
export type Map3DSettings = {
@ -288,6 +288,7 @@ function extractProjectionType(map: maplibregl.Map): MapProjectionId | undefined
}
const DEG2RAD = Math.PI / 180;
const RAD2DEG = 180 / Math.PI;
const GLOBE_ICON_HEADING_OFFSET_DEG = -90;
const MAP_SELECTED_SHIP_RGB: [number, number, number] = [34, 211, 238];
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;
}
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[] {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
@ -1301,9 +1338,12 @@ export function Map3D({
"zones-fill",
"zones-line",
"zones-label",
"predict-vectors",
"predict-vectors-hl",
"ships-globe-halo",
"ships-globe-outline",
"ships-globe",
"ships-globe-label",
"ships-globe-hover-halo",
"ships-globe-hover-outline",
"ships-globe-hover",
@ -1710,6 +1750,7 @@ export function Map3D({
"ships-globe-halo",
"ships-globe-outline",
"ships-globe",
"ships-globe-label",
"ships-globe-hover-halo",
"ships-globe-hover-outline",
"ships-globe-hover",
@ -2410,6 +2451,287 @@ export function Map3D({
};
}, [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.
// Deck IconLayer billboards or uses a fixed plane, which looks like ships are pointing into the sky/ground on globe.
useEffect(() => {
@ -2421,9 +2743,10 @@ export function Map3D({
const haloId = "ships-globe-halo";
const outlineId = "ships-globe-outline";
const symbolId = "ships-globe";
const labelId = "ships-globe-label";
const remove = () => {
for (const id of [symbolId, outlineId, haloId]) {
for (const id of [labelId, symbolId, outlineId, haloId]) {
try {
if (map.getLayer(id)) map.removeLayer(id);
} catch {
@ -2532,6 +2855,11 @@ export function Map3D({
type: "FeatureCollection",
features: globeShipData.map((t) => {
const legacy = legacyHits?.get(t.mmsi) ?? null;
const labelName =
legacy?.shipNameCn ||
legacy?.shipNameRoman ||
t.name ||
"";
const heading = getDisplayHeading({
cog: t.cog,
heading: t.heading,
@ -2555,6 +2883,7 @@ export function Map3D({
properties: {
mmsi: t.mmsi,
name: t.name || "",
labelName,
cog: heading,
heading,
sog: isFiniteNumber(t.sog) ? t.sog : 0,
@ -2887,6 +3216,67 @@ export function Map3D({
}
}
// Optional ship name labels (toggle). Keep labels readable and avoid clutter.
const labelVisibility = overlays.shipLabels ? "visible" : "none";
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);
@ -2899,9 +3289,11 @@ export function Map3D({
}, [
projection,
settings.showShips,
overlays.shipLabels,
shipData,
legacyHits,
selectedMmsi,
isBaseHighlightedMmsi,
mapSyncEpoch,
reorderGlobeFeatureLayers,
]);