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 환적 연결 (의심)
+
+
+ 예측 벡터 (15분) +
); } 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(() => {