diff --git a/apps/web/src/app/styles.css b/apps/web/src/app/styles.css
index 36528cf..a959fd6 100644
--- a/apps/web/src/app/styles.css
+++ b/apps/web/src/app/styles.css
@@ -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;
diff --git a/apps/web/src/features/mapToggles/MapToggles.tsx b/apps/web/src/features/mapToggles/MapToggles.tsx
index babb959..cf2722a 100644
--- a/apps/web/src/features/mapToggles/MapToggles.tsx
+++ b/apps/web/src/features/mapToggles/MapToggles.tsx
@@ -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 (
-
+
{items.map((t) => (
onToggle(t.id)}>
{t.label}
diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx
index 50dea15..4c1ab37 100644
--- a/apps/web/src/pages/dashboard/DashboardPage.tsx
+++ b/apps/web/src/pages/dashboard/DashboardPage.tsx
@@ -115,6 +115,8 @@ export function DashboardPage() {
fcLines: true,
zones: true,
fleetCircles: true,
+ predictVectors: false,
+ shipLabels: false,
});
const [fleetRelationSortMode, setFleetRelationSortMode] = useState
("count");
diff --git a/apps/web/src/widgets/legend/MapLegend.tsx b/apps/web/src/widgets/legend/MapLegend.tsx
index 3377175..1d7a7d3 100644
--- a/apps/web/src/widgets/legend/MapLegend.tsx
+++ b/apps/web/src/widgets/legend/MapLegend.tsx
@@ -98,6 +98,17 @@ export function MapLegend() {
FC 환적 연결 (의심)
+
);
}
diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx
index dce2dd8..16a537a 100644
--- a/apps/web/src/widgets/map3d/Map3D.tsx
+++ b/apps/web/src/widgets/map3d/Map3D.tsx
@@ -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
();
@@ -1297,18 +1334,21 @@ export function Map3D({
if (!map || projectionRef.current !== "globe") return;
if (projectionBusyRef.current) return;
- const ordering = [
- "zones-fill",
- "zones-line",
- "zones-label",
- "ships-globe-halo",
- "ships-globe-outline",
- "ships-globe",
- "ships-globe-hover-halo",
- "ships-globe-hover-outline",
- "ships-globe-hover",
- "pair-lines-ml",
- "fc-lines-ml",
+ const ordering = [
+ "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",
+ "pair-lines-ml",
+ "fc-lines-ml",
"pair-range-ml",
"fleet-circles-ml-fill",
"fleet-circles-ml",
@@ -1706,15 +1746,16 @@ export function Map3D({
const map = mapRef.current;
if (!map) return;
- const layerIds = [
- "ships-globe-halo",
- "ships-globe-outline",
- "ships-globe",
- "ships-globe-hover-halo",
- "ships-globe-hover-outline",
- "ships-globe-hover",
- "pair-lines-ml",
- "fc-lines-ml",
+ const layerIds = [
+ "ships-globe-halo",
+ "ships-globe-outline",
+ "ships-globe",
+ "ships-globe-label",
+ "ships-globe-hover-halo",
+ "ships-globe-hover-outline",
+ "ships-globe-hover",
+ "pair-lines-ml",
+ "fc-lines-ml",
"fleet-circles-ml-fill",
"fleet-circles-ml",
"pair-range-ml",
@@ -2410,25 +2451,307 @@ 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[] = [];
+ 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 = { 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[] = [];
+ 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 = { 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(() => {
const map = mapRef.current;
if (!map) return;
- const imgId = "ship-globe-icon";
- const srcId = "ships-globe-src";
- const haloId = "ships-globe-halo";
- const outlineId = "ships-globe-outline";
- const symbolId = "ships-globe";
+ const imgId = "ship-globe-icon";
+ const srcId = "ships-globe-src";
+ 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]) {
- try {
- if (map.getLayer(id)) map.removeLayer(id);
- } catch {
- // ignore
- }
+ const remove = () => {
+ for (const id of [labelId, symbolId, outlineId, haloId]) {
+ try {
+ if (map.getLayer(id)) map.removeLayer(id);
+ } catch {
+ // ignore
+ }
}
try {
if (map.getSource(srcId)) map.removeSource(srcId);
@@ -2527,16 +2850,21 @@ export function Map3D({
console.warn("Ship icon image setup failed:", e);
}
- const globeShipData = shipData;
- const geojson: GeoJSON.FeatureCollection = {
- type: "FeatureCollection",
- features: globeShipData.map((t) => {
- const legacy = legacyHits?.get(t.mmsi) ?? null;
- const heading = getDisplayHeading({
- cog: t.cog,
- heading: t.heading,
- offset: GLOBE_ICON_HEADING_OFFSET_DEG,
- });
+ const globeShipData = shipData;
+ const geojson: GeoJSON.FeatureCollection = {
+ 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,
+ 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 sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
const selected = t.mmsi === selectedMmsi;
@@ -2552,14 +2880,15 @@ export function Map3D({
type: "Feature",
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
geometry: { type: "Point", coordinates: [t.lon, t.lat] },
- properties: {
- mmsi: t.mmsi,
- name: t.name || "",
- cog: heading,
- heading,
- sog: isFiniteNumber(t.sog) ? t.sog : 0,
- shipColor: getGlobeBaseShipColor({
- legacy: legacy?.shipCode || null,
+ properties: {
+ mmsi: t.mmsi,
+ name: t.name || "",
+ labelName,
+ cog: heading,
+ heading,
+ sog: isFiniteNumber(t.sog) ? t.sog : 0,
+ shipColor: getGlobeBaseShipColor({
+ legacy: legacy?.shipCode || null,
sog: isFiniteNumber(t.sog) ? t.sog : null,
}),
iconSize3: iconSize3 * iconScale,
@@ -2783,10 +3112,10 @@ export function Map3D({
}
}
- if (!map.getLayer(symbolId)) {
- try {
- map.addLayer(
- {
+ if (!map.getLayer(symbolId)) {
+ try {
+ map.addLayer(
+ {
id: symbolId,
type: "symbol",
source: srcId,
@@ -2847,10 +3176,10 @@ export function Map3D({
} catch (e) {
console.warn("Ship symbol layer add failed:", e);
}
- } else {
- try {
- map.setLayoutProperty(symbolId, "visibility", visibility);
- map.setLayoutProperty(
+ } else {
+ try {
+ map.setLayoutProperty(symbolId, "visibility", visibility);
+ map.setLayoutProperty(
symbolId,
"symbol-sort-key",
[
@@ -2884,27 +3213,90 @@ export function Map3D({
);
} catch {
// ignore
- }
- }
+ }
+ }
- // Selection and highlight are now source-data driven.
- reorderGlobeFeatureLayers();
- kickRepaint(map);
- };
+ // 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);
+ };
const stop = onMapStyleReady(map, ensure);
return () => {
stop();
};
- }, [
- projection,
- settings.showShips,
- shipData,
- legacyHits,
- selectedMmsi,
- mapSyncEpoch,
- reorderGlobeFeatureLayers,
- ]);
+ }, [
+ projection,
+ settings.showShips,
+ overlays.shipLabels,
+ shipData,
+ legacyHits,
+ selectedMmsi,
+ isBaseHighlightedMmsi,
+ mapSyncEpoch,
+ reorderGlobeFeatureLayers,
+ ]);
// Globe hover overlay ships: update only hovered/selected targets to avoid rebuilding full ship layer on every hover.
useEffect(() => {