Fix globe tooltip typing and overlay defaults

This commit is contained in:
htlee 2026-02-15 14:52:57 +09:00
부모 9a9f7302cb
커밋 ea51aee6b4
2개의 변경된 파일504개의 추가작업 그리고 35개의 파일을 삭제

파일 보기

@ -103,7 +103,7 @@ export function DashboardPage() {
const [overlays, setOverlays] = useState<MapToggleState>({ const [overlays, setOverlays] = useState<MapToggleState>({
pairLines: true, pairLines: true,
pairRange: false, pairRange: true,
fcLines: true, fcLines: true,
zones: true, zones: true,
fleetCircles: true, fleetCircles: true,

파일 보기

@ -161,6 +161,176 @@ function getDisplayHeading({
return normalizeAngleDeg(raw, offset); 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<number, LegacyVesselInfo> | null | undefined, mmsi: number) {
const legacy = legacyHits?.get(mmsi);
if (!legacy) return null;
return `${legacy.permitNo} (${legacy.shipCode})`;
}
function getTargetName(mmsi: number, targetByMmsi: Map<number, AisTarget>, legacyHits: Map<number, LegacyVesselInfo> | 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<number, AisTarget>;
legacyHits: Map<number, LegacyVesselInfo> | 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
? `<div style="margin-top: 6px; padding-top: 6px; border-top: 1px solid rgba(255,255,255,.08)">
<div><b>CN Permit</b> · <b>${legacy.shipCode}</b> · ${legacy.permitNo}</div>
<div>유효범위: ${legacy.workSeaArea || "-"}</div>
</div>`
: "";
return {
html: `<div style="font-family: system-ui; font-size: 12px; white-space: nowrap;">
<div style="font-weight: 700; margin-bottom: 4px;">${name}</div>
<div>MMSI: <b>${mmsi}</b>${vesselType ? ` · ${vesselType}` : ""}</div>
<div>SOG: <b>${sog ?? "?"}</b> kt · COG: <b>${cog ?? "?"}</b>°</div>
${msg ? `<div style="opacity:.7; font-size: 11px; margin-top: 4px;">${msg}</div>` : ""}
${legacyHtml}
</div>`,
};
}
function getPairLinkTooltipHtml({
warn,
distanceNm,
aMmsi,
bMmsi,
legacyHits,
targetByMmsi,
}: {
warn: boolean;
distanceNm: number | null | undefined;
aMmsi: number;
bMmsi: number;
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
targetByMmsi: Map<number, AisTarget>;
}) {
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: `<div style="font-family: system-ui; font-size: 12px;">
<div style="font-weight: 700; margin-bottom: 4px;"> </div>
<div>${aTag ?? `MMSI ${aMmsi}`}</div>
<div style="opacity:.85;"> ${bTag ?? `MMSI ${bMmsi}`}</div>
<div style="margin-top: 4px;">: <b>${d}</b> · : <b>${warn ? "주의" : "정상"}</b></div>
<div style="opacity:.6; margin-top: 3px;">${a} / ${b}</div>
</div>`,
};
}
function getFcLinkTooltipHtml({
suspicious,
distanceNm,
fcMmsi,
otherMmsi,
legacyHits,
targetByMmsi,
}: {
suspicious: boolean;
distanceNm: number | null | undefined;
fcMmsi: number;
otherMmsi: number;
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
targetByMmsi: Map<number, AisTarget>;
}) {
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: `<div style="font-family: system-ui; font-size: 12px;">
<div style="font-weight: 700; margin-bottom: 4px;"> </div>
<div>${aTag ?? `MMSI ${fcMmsi}`}</div>
<div style="opacity:.85;"> ${bTag ?? `MMSI ${otherMmsi}`}</div>
<div style="margin-top: 4px;">: <b>${d}</b> · : <b>${suspicious ? "의심" : "일반"}</b></div>
<div style="opacity:.6; margin-top: 3px;">${a} / ${b}</div>
</div>`,
};
}
function getRangeTooltipHtml({
warn,
distanceNm,
aMmsi,
bMmsi,
legacyHits,
}: {
warn: boolean;
distanceNm: number | null | undefined;
aMmsi: number;
bMmsi: number;
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
}) {
const d = formatNm(distanceNm);
const aTag = getLegacyTag(legacyHits, aMmsi);
const bTag = getLegacyTag(legacyHits, bMmsi);
const radiusNm = toSafeNumber(distanceNm);
return {
html: `<div style="font-family: system-ui; font-size: 12px;">
<div style="font-weight: 700; margin-bottom: 4px;"> </div>
<div>${aTag ?? `MMSI ${aMmsi}`}</div>
<div style="opacity:.85;"> ${bTag ?? `MMSI ${bMmsi}`}</div>
<div style="margin-top: 4px;">: <b>${d}</b> · : <b>${formatNm(radiusNm == null ? null : radiusNm / 2)}</b> · : <b>${warn ? "주의" : "정상"}</b></div>
</div>`,
};
}
function getFleetCircleTooltipHtml({
ownerKey,
count,
}: {
ownerKey: string;
count: number;
}) {
return {
html: `<div style="font-family: system-ui; font-size: 12px;">
<div style="font-weight: 700; margin-bottom: 4px;"> </div>
<div>소유주: ${ownerKey || "-"}</div>
<div> : <b>${count}</b></div>
</div>`,
};
}
function rgbToHex(rgb: [number, number, number]) { function rgbToHex(rgb: [number, number, number]) {
const toHex = (v: number) => { const toHex = (v: number) => {
const clamped = Math.max(0, Math.min(255, Math.round(v))); const clamped = Math.max(0, Math.min(255, Math.round(v)));
@ -511,9 +681,23 @@ function getShipColor(
return [100, 116, 139, 160]; 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. // Simple dashed effect: split into segments and render every other one.
const segs: DashSeg[] = []; const segs: DashSeg[] = [];
const steps = 14; 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 lat0 = from[1] + (to[1] - from[1]) * a0;
const lon1 = from[0] + (to[0] - from[0]) * a1; const lon1 = from[0] + (to[0] - from[0]) * a1;
const lat1 = from[1] + (to[1] - from[1]) * 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; return segs;
} }
@ -552,6 +743,9 @@ type PairRangeCircle = {
center: [number, number]; // [lon, lat] center: [number, number]; // [lon, lat]
radiusNm: number; radiusNm: number;
warn: boolean; warn: boolean;
aMmsi: number;
bMmsi: number;
distanceNm: number;
}; };
const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius` const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius`
@ -585,6 +779,7 @@ export function Map3D({
const projectionBusyRef = useRef(false); const projectionBusyRef = useRef(false);
const projectionBusyTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null); const projectionBusyTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
const projectionPrevRef = useRef<MapProjectionId>(projection); const projectionPrevRef = useRef<MapProjectionId>(projection);
const mapTooltipRef = useRef<maplibregl.Popup | null>(null);
const [mapSyncEpoch, setMapSyncEpoch] = useState(0); const [mapSyncEpoch, setMapSyncEpoch] = useState(0);
const clearProjectionBusyTimer = useCallback(() => { const clearProjectionBusyTimer = useCallback(() => {
@ -1175,12 +1370,18 @@ export function Map3D({
const srcId = "zones-src"; const srcId = "zones-src";
const fillId = "zones-fill"; const fillId = "zones-fill";
const lineId = "zones-line"; const lineId = "zones-line";
const labelId = "zones-label";
const zoneColorExpr: unknown[] = ["match", ["get", "zoneId"]]; const zoneColorExpr: unknown[] = ["match", ["get", "zoneId"]];
for (const k of Object.keys(ZONE_META) as ZoneId[]) { for (const k of Object.keys(ZONE_META) as ZoneId[]) {
zoneColorExpr.push(k, ZONE_META[k].color); zoneColorExpr.push(k, ZONE_META[k].color);
} }
zoneColorExpr.push("#3B82F6"); 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 = () => { const ensure = () => {
// Always update visibility if the layers exist. // Always update visibility if the layers exist.
@ -1195,6 +1396,11 @@ export function Map3D({
} catch { } catch {
// ignore // ignore
} }
try {
if (map.getLayer(labelId)) map.setLayoutProperty(labelId, "visibility", visibility);
} catch {
// ignore
}
if (!zones) return; if (!zones) return;
if (!map.isStyleLoaded()) return; if (!map.isStyleLoaded()) return;
@ -1252,6 +1458,34 @@ export function Map3D({
before, 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) { } catch (e) {
console.warn("Zones layer setup failed:", e); console.warn("Zones layer setup failed:", e);
} finally { } finally {
@ -1642,6 +1876,7 @@ export function Map3D({
}; };
}, [projection, settings.showShips, onSelectMmsi, mapSyncEpoch, targets]); }, [projection, settings.showShips, onSelectMmsi, mapSyncEpoch, targets]);
// Globe overlays (pair links / FC links / ranges) rendered as MapLibre GeoJSON layers. // 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. // Deck custom layers are more fragile under globe projection; MapLibre-native rendering stays aligned like zones.
useEffect(() => { useEffect(() => {
@ -1677,7 +1912,13 @@ export function Map3D({
type: "Feature", type: "Feature",
id: `${p.aMmsi}-${p.bMmsi}-${idx}`, id: `${p.aMmsi}-${p.bMmsi}-${idx}`,
geometry: { type: "LineString", coordinates: [p.from, p.to] }, 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[] = []; 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) { if (segs.length === 0) {
remove(); remove();
return; return;
@ -1769,7 +2012,13 @@ export function Map3D({
type: "Feature", type: "Feature",
id: `fc-${idx}`, id: `fc-${idx}`,
geometry: { type: "LineString", coordinates: [s.from, s.to] }, 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", type: "Feature",
id: `fleet-${c.ownerKey}-${idx}`, id: `fleet-${c.ownerKey}-${idx}`,
geometry: { type: "LineString", coordinates: ring }, 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[] = []; const ranges: PairRangeCircle[] = [];
for (const p of pairLinks || []) { for (const p of pairLinks || []) {
const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2]; 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) { if (ranges.length === 0) {
remove(); remove();
@ -1949,7 +2210,13 @@ export function Map3D({
type: "Feature", type: "Feature",
id: `pair-range-${idx}`, id: `pair-range-${idx}`,
geometry: { type: "LineString", coordinates: ring }, 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)); return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon));
}, [targets]); }, [targets]);
const shipByMmsi = useMemo(() => {
const byMmsi = new Map<number, AisTarget>();
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<string, unknown> | 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: `<div style="font-size: 12px; font-family: system-ui;">${zoneName}</div>` };
}
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<string, unknown> | null; layer?: { id?: string } }> = [];
try {
rendered = map.queryRenderedFeatures(e.point, { layers: candidateLayerIds }) as unknown as Array<{
properties?: Record<string, unknown> | 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(() => { const legacyTargets = useMemo(() => {
if (!legacyHits) return []; if (!legacyHits) return [];
return shipData.filter((t) => legacyHits.has(t.mmsi)); return shipData.filter((t) => legacyHits.has(t.mmsi));
@ -2013,7 +2442,9 @@ export function Map3D({
const fcDashed = useMemo(() => { const fcDashed = useMemo(() => {
const segs: DashSeg[] = []; 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; return segs;
}, [fcLinks]); }, [fcLinks]);
@ -2021,7 +2452,14 @@ export function Map3D({
const out: PairRangeCircle[] = []; const out: PairRangeCircle[] = [];
for (const p of pairLinks || []) { for (const p of pairLinks || []) {
const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2]; 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; return out;
}, [pairLinks]); }, [pairLinks]);
@ -2142,7 +2580,7 @@ export function Map3D({
new ScatterplotLayer<PairRangeCircle>({ new ScatterplotLayer<PairRangeCircle>({
id: "pair-range", id: "pair-range",
data: pairRanges, data: pairRanges,
pickable: false, pickable: true,
billboard: false, billboard: false,
parameters: overlayParams, parameters: overlayParams,
filled: false, filled: false,
@ -2163,7 +2601,7 @@ export function Map3D({
new LineLayer<PairLink>({ new LineLayer<PairLink>({
id: "pair-lines", id: "pair-lines",
data: pairLinks, data: pairLinks,
pickable: false, pickable: true,
parameters: overlayParams, parameters: overlayParams,
getSourcePosition: (d) => d.from, getSourcePosition: (d) => d.from,
getTargetPosition: (d) => d.to, getTargetPosition: (d) => d.to,
@ -2179,7 +2617,7 @@ export function Map3D({
new LineLayer<DashSeg>({ new LineLayer<DashSeg>({
id: "fc-lines", id: "fc-lines",
data: fcDashed, data: fcDashed,
pickable: false, pickable: true,
parameters: overlayParams, parameters: overlayParams,
getSourcePosition: (d) => d.from, getSourcePosition: (d) => d.from,
getTargetPosition: (d) => d.to, getTargetPosition: (d) => d.to,
@ -2195,7 +2633,7 @@ export function Map3D({
new ScatterplotLayer<FleetCircle>({ new ScatterplotLayer<FleetCircle>({
id: "fleet-circles", id: "fleet-circles",
data: fleetCircles, data: fleetCircles,
pickable: false, pickable: true,
billboard: false, billboard: false,
parameters: overlayParams, parameters: overlayParams,
filled: false, filled: false,
@ -2223,28 +2661,58 @@ export function Map3D({
const n = Array.isArray(o?.points) ? o.points.length : 0; const n = Array.isArray(o?.points) ? o.points.length : 0;
return { text: `AIS density: ${n}` }; return { text: `AIS density: ${n}` };
} }
// zones
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const obj: any = info.object; const obj: any = info.object;
if (typeof obj.mmsi === "number") { if (typeof obj.mmsi === "number") {
const t = obj as AisTarget; return getShipTooltipHtml({ mmsi: obj.mmsi, targetByMmsi: shipByMmsi, legacyHits });
const name = (t.name || "").trim() || "(no name)"; }
const legacy = legacyHits?.get(t.mmsi);
const legacyHtml = legacy if (info.layer && info.layer.id === "pair-lines") {
? `<div style="margin-top: 6px; padding-top: 6px; border-top: 1px solid rgba(255,255,255,.08)"> const aMmsi = toSafeNumber(obj.aMmsi) ?? toSafeNumber(obj.fromMmsi);
<div><b>CN Permit</b> · <b>${legacy.shipCode}</b> · ${legacy.permitNo}</div> const bMmsi = toSafeNumber(obj.bMmsi) ?? toSafeNumber(obj.toMmsi);
</div>` if (aMmsi == null || bMmsi == null) return null;
: ""; return getPairLinkTooltipHtml({
return { warn: !!obj.warn,
html: `<div style="font-family: system-ui; font-size: 12px;"> distanceNm: toSafeNumber(obj.distanceNm),
<div style="font-weight: 700; margin-bottom: 4px;">${name}</div> aMmsi,
<div>MMSI: <b>${t.mmsi}</b> · ${t.vesselType || "Unknown"}</div> bMmsi,
<div>SOG: <b>${t.sog ?? "?"}</b> kt · COG: <b>${t.cog ?? "?"}</b>°</div> legacyHits,
<div style="opacity:.8">${t.status || ""}</div> targetByMmsi: shipByMmsi,
<div style="opacity:.7; font-size: 11px; margin-top: 4px;">${t.messageTimestamp || ""}</div> });
${legacyHtml} }
</div>`,
}; 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; const p = obj.properties as { zoneName?: string; zoneLabel?: string } | undefined;
@ -2293,6 +2761,7 @@ export function Map3D({
pairRanges, pairRanges,
fcDashed, fcDashed,
fleetCircles, fleetCircles,
shipByMmsi,
mapSyncEpoch, mapSyncEpoch,
ensureMercatorOverlay, ensureMercatorOverlay,
]); ]);