Fix globe tooltip typing and overlay defaults
This commit is contained in:
부모
9a9f7302cb
커밋
ea51aee6b4
@ -103,7 +103,7 @@ export function DashboardPage() {
|
||||
|
||||
const [overlays, setOverlays] = useState<MapToggleState>({
|
||||
pairLines: true,
|
||||
pairRange: false,
|
||||
pairRange: true,
|
||||
fcLines: true,
|
||||
zones: true,
|
||||
fleetCircles: true,
|
||||
|
||||
@ -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<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]) {
|
||||
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<ReturnType<typeof window.setTimeout> | null>(null);
|
||||
const projectionPrevRef = useRef<MapProjectionId>(projection);
|
||||
const mapTooltipRef = useRef<maplibregl.Popup | null>(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<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(() => {
|
||||
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<PairRangeCircle>({
|
||||
id: "pair-range",
|
||||
data: pairRanges,
|
||||
pickable: false,
|
||||
pickable: true,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
filled: false,
|
||||
@ -2163,7 +2601,7 @@ export function Map3D({
|
||||
new LineLayer<PairLink>({
|
||||
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<DashSeg>({
|
||||
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<FleetCircle>({
|
||||
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
|
||||
? `<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>`
|
||||
: "";
|
||||
return {
|
||||
html: `<div style="font-family: system-ui; font-size: 12px;">
|
||||
<div style="font-weight: 700; margin-bottom: 4px;">${name}</div>
|
||||
<div>MMSI: <b>${t.mmsi}</b> · ${t.vesselType || "Unknown"}</div>
|
||||
<div>SOG: <b>${t.sog ?? "?"}</b> kt · COG: <b>${t.cog ?? "?"}</b>°</div>
|
||||
<div style="opacity:.8">${t.status || ""}</div>
|
||||
<div style="opacity:.7; font-size: 11px; margin-top: 4px;">${t.messageTimestamp || ""}</div>
|
||||
${legacyHtml}
|
||||
</div>`,
|
||||
};
|
||||
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,
|
||||
]);
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user