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