From ea51aee6b41fca48b0fa00d0d6f622c0cc868e10 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 14:52:57 +0900 Subject: [PATCH] Fix globe tooltip typing and overlay defaults --- .../web/src/pages/dashboard/DashboardPage.tsx | 2 +- apps/web/src/widgets/map3d/Map3D.tsx | 537 ++++++++++++++++-- 2 files changed, 504 insertions(+), 35 deletions(-) diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index c2f9453..001d320 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -103,7 +103,7 @@ export function DashboardPage() { const [overlays, setOverlays] = useState({ pairLines: true, - pairRange: false, + pairRange: true, fcLines: true, zones: true, fleetCircles: true, diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index af4d3be..085674d 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -161,6 +161,176 @@ function getDisplayHeading({ return normalizeAngleDeg(raw, offset); } +function toSafeNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + return null; +} + +function toIntMmsi(value: unknown): number | null { + const n = toSafeNumber(value); + if (n == null) return null; + return Math.trunc(n); +} + +function formatNm(value: number | null | undefined) { + if (!isFiniteNumber(value)) return "-"; + return `${value.toFixed(2)} NM`; +} + +function getLegacyTag(legacyHits: Map | null | undefined, mmsi: number) { + const legacy = legacyHits?.get(mmsi); + if (!legacy) return null; + return `${legacy.permitNo} (${legacy.shipCode})`; +} + +function getTargetName(mmsi: number, targetByMmsi: Map, legacyHits: Map | null | undefined) { + const legacy = legacyHits?.get(mmsi); + const target = targetByMmsi.get(mmsi); + return ( + (target?.name || "").trim() || legacy?.shipNameCn || legacy?.shipNameRoman || `MMSI ${mmsi}` + ); +} + +function getShipTooltipHtml({ + mmsi, + targetByMmsi, + legacyHits, +}: { + mmsi: number; + targetByMmsi: Map; + legacyHits: Map | null | undefined; +}) { + const legacy = legacyHits?.get(mmsi); + const t = targetByMmsi.get(mmsi); + const name = getTargetName(mmsi, targetByMmsi, legacyHits); + const sog = isFiniteNumber(t?.sog) ? t.sog : null; + const cog = isFiniteNumber(t?.cog) ? t.cog : null; + const msg = t?.messageTimestamp ?? null; + const vesselType = t?.vesselType || ""; + + const legacyHtml = legacy + ? `
+
CN Permit · ${legacy.shipCode} · ${legacy.permitNo}
+
유효범위: ${legacy.workSeaArea || "-"}
+
` + : ""; + + return { + html: `
+
${name}
+
MMSI: ${mmsi}${vesselType ? ` · ${vesselType}` : ""}
+
SOG: ${sog ?? "?"} kt · COG: ${cog ?? "?"}°
+ ${msg ? `
${msg}
` : ""} + ${legacyHtml} +
`, + }; +} + +function getPairLinkTooltipHtml({ + warn, + distanceNm, + aMmsi, + bMmsi, + legacyHits, + targetByMmsi, +}: { + warn: boolean; + distanceNm: number | null | undefined; + aMmsi: number; + bMmsi: number; + legacyHits: Map | null | undefined; + targetByMmsi: Map; +}) { + const d = formatNm(distanceNm); + const a = getTargetName(aMmsi, targetByMmsi, legacyHits); + const b = getTargetName(bMmsi, targetByMmsi, legacyHits); + const aTag = getLegacyTag(legacyHits, aMmsi); + const bTag = getLegacyTag(legacyHits, bMmsi); + return { + html: `
+
쌍 연결
+
${aTag ?? `MMSI ${aMmsi}`}
+
↔ ${bTag ?? `MMSI ${bMmsi}`}
+
거리: ${d} · 상태: ${warn ? "주의" : "정상"}
+
${a} / ${b}
+
`, + }; +} + +function getFcLinkTooltipHtml({ + suspicious, + distanceNm, + fcMmsi, + otherMmsi, + legacyHits, + targetByMmsi, +}: { + suspicious: boolean; + distanceNm: number | null | undefined; + fcMmsi: number; + otherMmsi: number; + legacyHits: Map | null | undefined; + targetByMmsi: Map; +}) { + const d = formatNm(distanceNm); + const a = getTargetName(fcMmsi, targetByMmsi, legacyHits); + const b = getTargetName(otherMmsi, targetByMmsi, legacyHits); + const aTag = getLegacyTag(legacyHits, fcMmsi); + const bTag = getLegacyTag(legacyHits, otherMmsi); + return { + html: `
+
환적 연결
+
${aTag ?? `MMSI ${fcMmsi}`}
+
→ ${bTag ?? `MMSI ${otherMmsi}`}
+
거리: ${d} · 상태: ${suspicious ? "의심" : "일반"}
+
${a} / ${b}
+
`, + }; +} + +function getRangeTooltipHtml({ + warn, + distanceNm, + aMmsi, + bMmsi, + legacyHits, +}: { + warn: boolean; + distanceNm: number | null | undefined; + aMmsi: number; + bMmsi: number; + legacyHits: Map | null | undefined; +}) { + const d = formatNm(distanceNm); + const aTag = getLegacyTag(legacyHits, aMmsi); + const bTag = getLegacyTag(legacyHits, bMmsi); + const radiusNm = toSafeNumber(distanceNm); + return { + html: `
+
쌍 연결범위
+
${aTag ?? `MMSI ${aMmsi}`}
+
↔ ${bTag ?? `MMSI ${bMmsi}`}
+
범위: ${d} · 반경: ${formatNm(radiusNm == null ? null : radiusNm / 2)} · 상태: ${warn ? "주의" : "정상"}
+
`, + }; +} + +function getFleetCircleTooltipHtml({ + ownerKey, + count, +}: { + ownerKey: string; + count: number; +}) { + return { + html: `
+
선단 범위
+
소유주: ${ownerKey || "-"}
+
선박 수: ${count}
+
`, + }; +} + function rgbToHex(rgb: [number, number, number]) { const toHex = (v: number) => { const clamped = Math.max(0, Math.min(255, Math.round(v))); @@ -511,9 +681,23 @@ function getShipColor( return [100, 116, 139, 160]; } -type DashSeg = { from: [number, number]; to: [number, number]; suspicious: boolean }; +type DashSeg = { + from: [number, number]; + to: [number, number]; + suspicious: boolean; + distanceNm?: number; + fromMmsi?: number; + toMmsi?: number; +}; -function dashifyLine(from: [number, number], to: [number, number], suspicious: boolean): DashSeg[] { +function dashifyLine( + from: [number, number], + to: [number, number], + suspicious: boolean, + distanceNm?: number, + fromMmsi?: number, + toMmsi?: number, +): DashSeg[] { // Simple dashed effect: split into segments and render every other one. const segs: DashSeg[] = []; const steps = 14; @@ -525,7 +709,14 @@ function dashifyLine(from: [number, number], to: [number, number], suspicious: b const lat0 = from[1] + (to[1] - from[1]) * a0; const lon1 = from[0] + (to[0] - from[0]) * a1; const lat1 = from[1] + (to[1] - from[1]) * a1; - segs.push({ from: [lon0, lat0], to: [lon1, lat1], suspicious }); + segs.push({ + from: [lon0, lat0], + to: [lon1, lat1], + suspicious, + distanceNm, + fromMmsi, + toMmsi, + }); } return segs; } @@ -552,6 +743,9 @@ type PairRangeCircle = { center: [number, number]; // [lon, lat] radiusNm: number; warn: boolean; + aMmsi: number; + bMmsi: number; + distanceNm: number; }; const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius` @@ -585,6 +779,7 @@ export function Map3D({ const projectionBusyRef = useRef(false); const projectionBusyTimerRef = useRef | null>(null); const projectionPrevRef = useRef(projection); + const mapTooltipRef = useRef(null); const [mapSyncEpoch, setMapSyncEpoch] = useState(0); const clearProjectionBusyTimer = useCallback(() => { @@ -1175,12 +1370,18 @@ export function Map3D({ const srcId = "zones-src"; const fillId = "zones-fill"; const lineId = "zones-line"; + const labelId = "zones-label"; const zoneColorExpr: unknown[] = ["match", ["get", "zoneId"]]; for (const k of Object.keys(ZONE_META) as ZoneId[]) { zoneColorExpr.push(k, ZONE_META[k].color); } zoneColorExpr.push("#3B82F6"); + const zoneLabelExpr: unknown[] = ["match", ["to-string", ["coalesce", ["get", "zoneId"], ""]]]; + for (const k of Object.keys(ZONE_META) as ZoneId[]) { + zoneLabelExpr.push(k, ZONE_META[k].name); + } + zoneLabelExpr.push(["coalesce", ["get", "zoneName"], ["get", "zoneLabel"], ["get", "NAME"], "수역"]); const ensure = () => { // Always update visibility if the layers exist. @@ -1195,6 +1396,11 @@ export function Map3D({ } catch { // ignore } + try { + if (map.getLayer(labelId)) map.setLayoutProperty(labelId, "visibility", visibility); + } catch { + // ignore + } if (!zones) return; if (!map.isStyleLoaded()) return; @@ -1252,6 +1458,34 @@ export function Map3D({ before, ); } + + if (!map.getLayer(labelId)) { + map.addLayer( + { + id: labelId, + type: "symbol", + source: srcId, + layout: { + visibility, + "symbol-placement": "point", + "text-field": zoneLabelExpr as never, + "text-size": 11, + "text-font": ["Noto Sans Regular", "Open Sans Regular"], + "text-anchor": "top", + "text-offset": [0, 0.35], + "text-allow-overlap": false, + "text-ignore-placement": false, + }, + paint: { + "text-color": "#dbeafe", + "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("Zones layer setup failed:", e); } finally { @@ -1642,6 +1876,7 @@ export function Map3D({ }; }, [projection, settings.showShips, onSelectMmsi, mapSyncEpoch, targets]); + // Globe overlays (pair links / FC links / ranges) rendered as MapLibre GeoJSON layers. // Deck custom layers are more fragile under globe projection; MapLibre-native rendering stays aligned like zones. useEffect(() => { @@ -1677,7 +1912,13 @@ export function Map3D({ type: "Feature", id: `${p.aMmsi}-${p.bMmsi}-${idx}`, geometry: { type: "LineString", coordinates: [p.from, p.to] }, - properties: { warn: p.warn }, + properties: { + type: "pair", + aMmsi: p.aMmsi, + bMmsi: p.bMmsi, + distanceNm: p.distanceNm, + warn: p.warn, + }, })), }; @@ -1757,7 +1998,9 @@ export function Map3D({ } const segs: DashSeg[] = []; - for (const l of fcLinks || []) segs.push(...dashifyLine(l.from, l.to, l.suspicious)); + for (const l of fcLinks || []) { + segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi)); + } if (segs.length === 0) { remove(); return; @@ -1769,7 +2012,13 @@ export function Map3D({ type: "Feature", id: `fc-${idx}`, geometry: { type: "LineString", coordinates: [s.from, s.to] }, - properties: { suspicious: s.suspicious }, + properties: { + type: "fc", + suspicious: s.suspicious, + distanceNm: s.distanceNm, + fcMmsi: s.fromMmsi ?? -1, + otherMmsi: s.toMmsi ?? -1, + }, })), }; @@ -1856,7 +2105,12 @@ export function Map3D({ type: "Feature", id: `fleet-${c.ownerKey}-${idx}`, geometry: { type: "LineString", coordinates: ring }, - properties: { count: c.count }, + properties: { + type: "fleet", + ownerKey: c.ownerKey, + count: c.count, + vesselMmsis: c.vesselMmsis.length, + }, }; }), }; @@ -1934,7 +2188,14 @@ export function Map3D({ const ranges: PairRangeCircle[] = []; for (const p of pairLinks || []) { const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2]; - ranges.push({ center, radiusNm: Math.max(0.05, p.distanceNm / 2), warn: p.warn }); + ranges.push({ + center, + radiusNm: Math.max(0.05, p.distanceNm / 2), + warn: p.warn, + aMmsi: p.aMmsi, + bMmsi: p.bMmsi, + distanceNm: p.distanceNm, + }); } if (ranges.length === 0) { remove(); @@ -1949,7 +2210,13 @@ export function Map3D({ type: "Feature", id: `pair-range-${idx}`, geometry: { type: "LineString", coordinates: ring }, - properties: { warn: c.warn }, + properties: { + type: "pair-range", + warn: c.warn, + aMmsi: c.aMmsi, + bMmsi: c.bMmsi, + distanceNm: c.distanceNm, + }, }; }), }; @@ -2006,6 +2273,168 @@ export function Map3D({ return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon)); }, [targets]); + const shipByMmsi = useMemo(() => { + const byMmsi = new Map(); + for (const t of shipData) byMmsi.set(t.mmsi, t); + return byMmsi; + }, [shipData]); + + const clearGlobeTooltip = useCallback(() => { + if (!mapTooltipRef.current) return; + try { + mapTooltipRef.current.remove(); + } catch { + // ignore + } + mapTooltipRef.current = null; + }, []); + + const buildGlobeFeatureTooltip = useCallback( + (feature: { properties?: Record | null; layer?: { id?: string } } | null | undefined) => { + if (!feature) return null; + const props = feature.properties || {}; + const layerId = feature.layer?.id; + + const maybeMmsi = toIntMmsi(props.mmsi); + if (maybeMmsi != null && maybeMmsi > 0) { + return getShipTooltipHtml({ mmsi: maybeMmsi, targetByMmsi: shipByMmsi, legacyHits }); + } + + if (layerId === "pair-lines-ml") { + const warn = props.warn === true; + const aMmsi = toIntMmsi(props.aMmsi); + const bMmsi = toIntMmsi(props.bMmsi); + if (aMmsi == null || bMmsi == null) return null; + return getPairLinkTooltipHtml({ + warn, + distanceNm: toSafeNumber(props.distanceNm), + aMmsi, + bMmsi, + legacyHits, + targetByMmsi: shipByMmsi, + }); + } + + if (layerId === "fc-lines-ml") { + const fcMmsi = toIntMmsi(props.fcMmsi); + const otherMmsi = toIntMmsi(props.otherMmsi); + if (fcMmsi == null || otherMmsi == null) return null; + return getFcLinkTooltipHtml({ + suspicious: props.suspicious === true, + distanceNm: toSafeNumber(props.distanceNm), + fcMmsi, + otherMmsi, + legacyHits, + targetByMmsi: shipByMmsi, + }); + } + + if (layerId === "pair-range-ml") { + const aMmsi = toIntMmsi(props.aMmsi); + const bMmsi = toIntMmsi(props.bMmsi); + if (aMmsi == null || bMmsi == null) return null; + return getRangeTooltipHtml({ + warn: props.warn === true, + distanceNm: toSafeNumber(props.distanceNm), + aMmsi, + bMmsi, + legacyHits, + }); + } + + if (layerId === "fleet-circles-ml") { + return getFleetCircleTooltipHtml({ + ownerKey: String(props.ownerKey ?? ""), + count: Number(props.count ?? 0), + }); + } + + const zoneLabel = String((props.zoneLabel ?? props.zoneName ?? "").toString()); + if (zoneLabel) { + const zoneName = zoneLabel || ZONE_META[(String(props.zoneId ?? "") as ZoneId)]?.name || "수역"; + return { html: `
${zoneName}
` }; + } + + return null; + }, + [legacyHits, shipByMmsi], + ); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const onMouseMove = (e: maplibregl.MapMouseEvent) => { + if (projection !== "globe") { + clearGlobeTooltip(); + return; + } + + const candidateLayerIds = [ + "ships-globe", + "ships-globe-halo", + "ships-globe-outline", + "pair-lines-ml", + "fc-lines-ml", + "fleet-circles-ml", + "pair-range-ml", + "zones-fill", + "zones-line", + "zones-label", + ].filter((id) => map.getLayer(id)); + + if (candidateLayerIds.length === 0) { + clearGlobeTooltip(); + return; + } + + let rendered: Array<{ properties?: Record | null; layer?: { id?: string } }> = []; + try { + rendered = map.queryRenderedFeatures(e.point, { layers: candidateLayerIds }) as unknown as Array<{ + properties?: Record | null; + layer?: { id?: string }; + }>; + } catch { + rendered = []; + } + + const first = rendered[0]; + const tooltip = buildGlobeFeatureTooltip(first); + if (!tooltip) { + clearGlobeTooltip(); + return; + } + + if (!mapTooltipRef.current) { + mapTooltipRef.current = new maplibregl.Popup({ + closeButton: false, + closeOnClick: false, + className: "maplibre-tooltip-popup", + }); + } + + const content = tooltip?.html ?? ""; + if (content) { + mapTooltipRef.current.setLngLat(e.lngLat).setHTML(content).addTo(map); + return; + } + clearGlobeTooltip(); + }; + + const onMouseOut = () => { + clearGlobeTooltip(); + }; + + map.on("mousemove", onMouseMove); + map.on("mouseout", onMouseOut); + + return () => { + map.off("mousemove", onMouseMove); + map.off("mouseout", onMouseOut); + clearGlobeTooltip(); + }; + }, [projection, buildGlobeFeatureTooltip, clearGlobeTooltip]); + const legacyTargets = useMemo(() => { if (!legacyHits) return []; return shipData.filter((t) => legacyHits.has(t.mmsi)); @@ -2013,7 +2442,9 @@ export function Map3D({ const fcDashed = useMemo(() => { const segs: DashSeg[] = []; - for (const l of fcLinks || []) segs.push(...dashifyLine(l.from, l.to, l.suspicious)); + for (const l of fcLinks || []) { + segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi)); + } return segs; }, [fcLinks]); @@ -2021,7 +2452,14 @@ export function Map3D({ const out: PairRangeCircle[] = []; for (const p of pairLinks || []) { const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2]; - out.push({ center, radiusNm: Math.max(0.05, p.distanceNm / 2), warn: p.warn }); + out.push({ + center, + radiusNm: Math.max(0.05, p.distanceNm / 2), + warn: p.warn, + aMmsi: p.aMmsi, + bMmsi: p.bMmsi, + distanceNm: p.distanceNm, + }); } return out; }, [pairLinks]); @@ -2142,7 +2580,7 @@ export function Map3D({ new ScatterplotLayer({ id: "pair-range", data: pairRanges, - pickable: false, + pickable: true, billboard: false, parameters: overlayParams, filled: false, @@ -2163,7 +2601,7 @@ export function Map3D({ new LineLayer({ id: "pair-lines", data: pairLinks, - pickable: false, + pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, @@ -2179,7 +2617,7 @@ export function Map3D({ new LineLayer({ id: "fc-lines", data: fcDashed, - pickable: false, + pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, @@ -2195,7 +2633,7 @@ export function Map3D({ new ScatterplotLayer({ id: "fleet-circles", data: fleetCircles, - pickable: false, + pickable: true, billboard: false, parameters: overlayParams, filled: false, @@ -2223,28 +2661,58 @@ export function Map3D({ const n = Array.isArray(o?.points) ? o.points.length : 0; return { text: `AIS density: ${n}` }; } - // zones // eslint-disable-next-line @typescript-eslint/no-explicit-any const obj: any = info.object; if (typeof obj.mmsi === "number") { - const t = obj as AisTarget; - const name = (t.name || "").trim() || "(no name)"; - const legacy = legacyHits?.get(t.mmsi); - const legacyHtml = legacy - ? `
-
CN Permit · ${legacy.shipCode} · ${legacy.permitNo}
-
` - : ""; - return { - html: `
-
${name}
-
MMSI: ${t.mmsi} · ${t.vesselType || "Unknown"}
-
SOG: ${t.sog ?? "?"} kt · COG: ${t.cog ?? "?"}°
-
${t.status || ""}
-
${t.messageTimestamp || ""}
- ${legacyHtml} -
`, - }; + return getShipTooltipHtml({ mmsi: obj.mmsi, targetByMmsi: shipByMmsi, legacyHits }); + } + + if (info.layer && info.layer.id === "pair-lines") { + const aMmsi = toSafeNumber(obj.aMmsi) ?? toSafeNumber(obj.fromMmsi); + const bMmsi = toSafeNumber(obj.bMmsi) ?? toSafeNumber(obj.toMmsi); + if (aMmsi == null || bMmsi == null) return null; + return getPairLinkTooltipHtml({ + warn: !!obj.warn, + distanceNm: toSafeNumber(obj.distanceNm), + aMmsi, + bMmsi, + legacyHits, + targetByMmsi: shipByMmsi, + }); + } + + if (info.layer && info.layer.id === "fc-lines") { + const fcMmsi = toSafeNumber(obj.fcMmsi) ?? toSafeNumber(obj.fromMmsi); + const otherMmsi = toSafeNumber(obj.otherMmsi) ?? toSafeNumber(obj.toMmsi); + if (fcMmsi == null || otherMmsi == null) return null; + return getFcLinkTooltipHtml({ + suspicious: !!obj.suspicious, + distanceNm: toSafeNumber(obj.distanceNm), + fcMmsi, + otherMmsi, + legacyHits, + targetByMmsi: shipByMmsi, + }); + } + + if (info.layer && info.layer.id === "pair-range") { + const aMmsi = toSafeNumber(obj.aMmsi) ?? toSafeNumber(obj.fromMmsi); + const bMmsi = toSafeNumber(obj.bMmsi) ?? toSafeNumber(obj.toMmsi); + if (aMmsi == null || bMmsi == null) return null; + return getRangeTooltipHtml({ + warn: !!obj.warn, + distanceNm: toSafeNumber(obj.distanceNm), + aMmsi, + bMmsi, + legacyHits, + }); + } + + if (info.layer && info.layer.id === "fleet-circles") { + return getFleetCircleTooltipHtml({ + ownerKey: String(obj.ownerKey ?? ""), + count: Number(obj.count ?? 0), + }); } const p = obj.properties as { zoneName?: string; zoneLabel?: string } | undefined; @@ -2293,6 +2761,7 @@ export function Map3D({ pairRanges, fcDashed, fleetCircles, + shipByMmsi, mapSyncEpoch, ensureMercatorOverlay, ]);