499 lines
22 KiB
TypeScript
499 lines
22 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
|
|
import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles";
|
|
import type { MapToggleState } from "../../features/mapToggles/MapToggles";
|
|
import { MapToggles } from "../../features/mapToggles/MapToggles";
|
|
import { TypeFilterGrid } from "../../features/typeFilter/TypeFilterGrid";
|
|
import { useLegacyVessels } from "../../entities/legacyVessel/api/useLegacyVessels";
|
|
import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
|
import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
|
import type { LegacyVesselDataset } from "../../entities/legacyVessel/model/types";
|
|
import { useZones } from "../../entities/zone/api/useZones";
|
|
import type { VesselTypeCode } from "../../entities/vessel/model/types";
|
|
import { AisInfoPanel } from "../../widgets/aisInfo/AisInfoPanel";
|
|
import { AisTargetList } from "../../widgets/aisTargetList/AisTargetList";
|
|
import { AlarmsPanel } from "../../widgets/alarms/AlarmsPanel";
|
|
import { MapLegend } from "../../widgets/legend/MapLegend";
|
|
import { Map3D, type BaseMapId, type Map3DSettings, type MapProjectionId } from "../../widgets/map3d/Map3D";
|
|
import { RelationsPanel } from "../../widgets/relations/RelationsPanel";
|
|
import { SpeedProfilePanel } from "../../widgets/speed/SpeedProfilePanel";
|
|
import { Topbar } from "../../widgets/topbar/Topbar";
|
|
import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel";
|
|
import { VesselList } from "../../widgets/vesselList/VesselList";
|
|
import {
|
|
buildLegacyHitMap,
|
|
computeCountsByType,
|
|
computeFcLinks,
|
|
computeFleetCircles,
|
|
computeLegacyAlarms,
|
|
computePairLinks,
|
|
deriveLegacyVessels,
|
|
filterByShipCodes,
|
|
} from "../../features/legacyDashboard/model/derive";
|
|
import { VESSEL_TYPE_ORDER } from "../../entities/vessel/model/meta";
|
|
|
|
const AIS_API_BASE = (import.meta.env.VITE_API_URL || "/snp-api").replace(/\/$/, "");
|
|
const AIS_CENTER = {
|
|
lon: 126.95,
|
|
lat: 35.95,
|
|
radiusMeters: 2_000_000,
|
|
};
|
|
|
|
function fmtLocal(iso: string | null) {
|
|
if (!iso) return "-";
|
|
const d = new Date(iso);
|
|
if (Number.isNaN(d.getTime())) return iso;
|
|
return d.toLocaleString("ko-KR", { hour12: false });
|
|
}
|
|
|
|
type Bbox = [number, number, number, number]; // [lonMin, latMin, lonMax, latMax]
|
|
|
|
function inBbox(lon: number, lat: number, bbox: Bbox) {
|
|
const [lonMin, latMin, lonMax, latMax] = bbox;
|
|
if (lat < latMin || lat > latMax) return false;
|
|
if (lonMin <= lonMax) return lon >= lonMin && lon <= lonMax;
|
|
return lon >= lonMin || lon <= lonMax;
|
|
}
|
|
|
|
function fmtBbox(b: Bbox | null) {
|
|
if (!b) return "-";
|
|
return `${b[0].toFixed(4)},${b[1].toFixed(4)},${b[2].toFixed(4)},${b[3].toFixed(4)}`;
|
|
}
|
|
|
|
function useLegacyIndex(data: LegacyVesselDataset | null): LegacyVesselIndex | null {
|
|
return useMemo(() => (data ? buildLegacyVesselIndex(data.vessels) : null), [data]);
|
|
}
|
|
|
|
export function DashboardPage() {
|
|
const { data: zones, error: zonesError } = useZones();
|
|
const { data: legacyData, error: legacyError } = useLegacyVessels();
|
|
const legacyIndex = useLegacyIndex(legacyData);
|
|
|
|
const [viewBbox, setViewBbox] = useState<Bbox | null>(null);
|
|
const [useViewportFilter, setUseViewportFilter] = useState(false);
|
|
const [useApiBbox, setUseApiBbox] = useState(false);
|
|
const [apiBbox, setApiBbox] = useState<string | undefined>(undefined);
|
|
|
|
const { targets, snapshot } = useAisTargetPolling({
|
|
initialMinutes: 60,
|
|
incrementalMinutes: 2,
|
|
intervalMs: 60_000,
|
|
retentionMinutes: 90,
|
|
bbox: useApiBbox ? apiBbox : undefined,
|
|
centerLon: useApiBbox ? undefined : AIS_CENTER.lon,
|
|
centerLat: useApiBbox ? undefined : AIS_CENTER.lat,
|
|
radiusMeters: useApiBbox ? undefined : AIS_CENTER.radiusMeters,
|
|
});
|
|
|
|
const [selectedMmsi, setSelectedMmsi] = useState<number | null>(null);
|
|
const [typeEnabled, setTypeEnabled] = useState<Record<VesselTypeCode, boolean>>({
|
|
PT: true,
|
|
"PT-S": true,
|
|
GN: true,
|
|
OT: true,
|
|
PS: true,
|
|
FC: true,
|
|
});
|
|
const [showTargets, setShowTargets] = useState(true);
|
|
const [showOthers, setShowOthers] = useState(false);
|
|
|
|
const [baseMap, setBaseMap] = useState<BaseMapId>("enhanced");
|
|
const [projection, setProjection] = useState<MapProjectionId>("mercator");
|
|
|
|
const [overlays, setOverlays] = useState<MapToggleState>({
|
|
pairLines: true,
|
|
pairRange: false,
|
|
fcLines: true,
|
|
zones: true,
|
|
fleetCircles: true,
|
|
});
|
|
|
|
const [settings, setSettings] = useState<Map3DSettings>({
|
|
showShips: true,
|
|
showDensity: false,
|
|
showSeamark: false,
|
|
});
|
|
|
|
const [clock, setClock] = useState(() => new Date().toLocaleString("ko-KR", { hour12: false }));
|
|
useEffect(() => {
|
|
const id = window.setInterval(() => setClock(new Date().toLocaleString("ko-KR", { hour12: false })), 1000);
|
|
return () => window.clearInterval(id);
|
|
}, []);
|
|
|
|
// Secret admin toggle: 7 clicks within 900ms on the logo.
|
|
const [adminMode, setAdminMode] = useState(false);
|
|
const clicksRef = useRef<number[]>([]);
|
|
const onLogoClick = () => {
|
|
const now = Date.now();
|
|
clicksRef.current = clicksRef.current.filter((t) => now - t < 900);
|
|
clicksRef.current.push(now);
|
|
if (clicksRef.current.length >= 7) {
|
|
clicksRef.current = [];
|
|
setAdminMode((v) => !v);
|
|
}
|
|
};
|
|
|
|
const targetsInScope = useMemo(() => {
|
|
if (!useViewportFilter || !viewBbox) return targets;
|
|
return targets.filter((t) => typeof t.lon === "number" && typeof t.lat === "number" && inBbox(t.lon, t.lat, viewBbox));
|
|
}, [targets, useViewportFilter, viewBbox]);
|
|
|
|
const legacyHits = useMemo(() => buildLegacyHitMap(targetsInScope, legacyIndex), [targetsInScope, legacyIndex]);
|
|
const legacyVesselsAll = useMemo(() => deriveLegacyVessels({ targets: targetsInScope, legacyHits, zones }), [targetsInScope, legacyHits, zones]);
|
|
const legacyCounts = useMemo(() => computeCountsByType(legacyVesselsAll), [legacyVesselsAll]);
|
|
|
|
const legacyVesselsFiltered = useMemo(() => {
|
|
if (!showTargets) return [];
|
|
return filterByShipCodes(legacyVesselsAll, typeEnabled);
|
|
}, [legacyVesselsAll, showTargets, typeEnabled]);
|
|
|
|
const legacyMmsiForMap = useMemo(() => new Set(legacyVesselsFiltered.map((v) => v.mmsi)), [legacyVesselsFiltered]);
|
|
|
|
const targetsForMap = useMemo(() => {
|
|
const out = [];
|
|
for (const t of targetsInScope) {
|
|
const mmsi = t.mmsi;
|
|
if (typeof mmsi !== "number") continue;
|
|
const isLegacy = legacyHits.has(mmsi);
|
|
if (isLegacy) {
|
|
if (!showTargets) continue;
|
|
if (!legacyMmsiForMap.has(mmsi)) continue;
|
|
out.push(t);
|
|
continue;
|
|
}
|
|
if (showOthers) out.push(t);
|
|
}
|
|
return out;
|
|
}, [targetsInScope, legacyHits, showTargets, showOthers, legacyMmsiForMap]);
|
|
|
|
const pairLinksAll = useMemo(() => computePairLinks(legacyVesselsAll), [legacyVesselsAll]);
|
|
const fcLinksAll = useMemo(() => computeFcLinks(legacyVesselsAll), [legacyVesselsAll]);
|
|
const alarms = useMemo(() => computeLegacyAlarms({ vessels: legacyVesselsAll, pairLinks: pairLinksAll, fcLinks: fcLinksAll }), [legacyVesselsAll, pairLinksAll, fcLinksAll]);
|
|
|
|
const pairLinksForMap = useMemo(() => computePairLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
|
const fcLinksForMap = useMemo(() => computeFcLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
|
const fleetCirclesForMap = useMemo(() => computeFleetCircles(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
|
|
|
const selectedLegacyVessel = useMemo(() => {
|
|
if (!selectedMmsi) return null;
|
|
return legacyVesselsAll.find((v) => v.mmsi === selectedMmsi) ?? null;
|
|
}, [legacyVesselsAll, selectedMmsi]);
|
|
|
|
const selectedTarget = useMemo(() => {
|
|
if (!selectedMmsi) return null;
|
|
return targetsInScope.find((t) => t.mmsi === selectedMmsi) ?? null;
|
|
}, [targetsInScope, selectedMmsi]);
|
|
|
|
const selectedLegacyInfo = useMemo(() => {
|
|
if (!selectedMmsi) return null;
|
|
return legacyHits.get(selectedMmsi) ?? null;
|
|
}, [selectedMmsi, legacyHits]);
|
|
|
|
const fishingCount = legacyVesselsAll.filter((v) => v.state.isFishing).length;
|
|
const transitCount = legacyVesselsAll.filter((v) => v.state.isTransit).length;
|
|
|
|
const enabledTypes = useMemo(() => VESSEL_TYPE_ORDER.filter((c) => typeEnabled[c]), [typeEnabled]);
|
|
const speedPanelType = (selectedLegacyVessel?.shipCode ?? (enabledTypes.length === 1 ? enabledTypes[0] : "PT")) as VesselTypeCode;
|
|
|
|
return (
|
|
<div className="app">
|
|
<Topbar
|
|
total={legacyVesselsAll.length}
|
|
fishing={fishingCount}
|
|
transit={transitCount}
|
|
pairLinks={pairLinksAll.length}
|
|
alarms={alarms.length}
|
|
pollingStatus={snapshot.status}
|
|
lastFetchMinutes={snapshot.lastFetchMinutes}
|
|
clock={clock}
|
|
adminMode={adminMode}
|
|
onLogoClick={onLogoClick}
|
|
/>
|
|
|
|
<div className="sidebar">
|
|
<div className="sb">
|
|
<div className="sb-t">업종 필터</div>
|
|
<div className="tog">
|
|
<div
|
|
className={`tog-btn ${showTargets ? "on" : ""}`}
|
|
onClick={() => {
|
|
setShowTargets((v) => {
|
|
const next = !v;
|
|
if (!next) setSelectedMmsi((m) => (legacyHits.has(m ?? -1) ? null : m));
|
|
return next;
|
|
});
|
|
}}
|
|
title="레거시(CN permit) 대상 선박 표시"
|
|
>
|
|
대상 선박
|
|
</div>
|
|
<div className={`tog-btn ${showOthers ? "on" : ""}`} onClick={() => setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시">
|
|
기타 AIS
|
|
</div>
|
|
</div>
|
|
<TypeFilterGrid
|
|
enabled={typeEnabled}
|
|
totalCount={legacyVesselsAll.length}
|
|
countsByType={legacyCounts}
|
|
onToggle={(code) => {
|
|
// When hiding the currently selected legacy vessel's type, clear selection.
|
|
if (selectedLegacyVessel && selectedLegacyVessel.shipCode === code && typeEnabled[code]) setSelectedMmsi(null);
|
|
setTypeEnabled((prev) => ({ ...prev, [code]: !prev[code] }));
|
|
}}
|
|
onToggleAll={() => {
|
|
const allOn = VESSEL_TYPE_ORDER.every((c) => typeEnabled[c]);
|
|
const nextVal = !allOn; // any-off -> true, all-on -> false
|
|
if (!nextVal && selectedLegacyVessel) setSelectedMmsi(null);
|
|
setTypeEnabled({
|
|
PT: nextVal,
|
|
"PT-S": nextVal,
|
|
GN: nextVal,
|
|
OT: nextVal,
|
|
PS: nextVal,
|
|
FC: nextVal,
|
|
});
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className="sb">
|
|
<div className="sb-t">지도 표시 설정</div>
|
|
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
|
<div style={{ fontSize: 9, fontWeight: 700, color: "var(--muted)", letterSpacing: 1.5, marginTop: 8, marginBottom: 6 }}>
|
|
베이스맵
|
|
</div>
|
|
<div className="tog" style={{ flexWrap: "nowrap", alignItems: "center" }}>
|
|
<div className={`tog-btn ${baseMap === "enhanced" ? "on" : ""}`} onClick={() => setBaseMap("enhanced")} title="현재 지도(해저지형/3D 표현 포함)">
|
|
기본
|
|
</div>
|
|
<div className={`tog-btn ${baseMap === "legacy" ? "on" : ""}`} onClick={() => setBaseMap("legacy")} title="레거시 대시보드(Carto Dark) 베이스맵">
|
|
레거시
|
|
</div>
|
|
|
|
<div style={{ flex: 1 }} />
|
|
|
|
<div
|
|
className={`tog-btn ${projection === "globe" ? "on" : ""}`}
|
|
onClick={() => setProjection((p) => (p === "globe" ? "mercator" : "globe"))}
|
|
title="지구본(globe) 투영: 드래그로 회전, 휠로 확대/축소"
|
|
>
|
|
지구본
|
|
</div>
|
|
</div>
|
|
<div style={{ fontSize: 10, color: "var(--muted)" }}>지도 우하단 Attribution(라이센스) 표기 유지</div>
|
|
</div>
|
|
|
|
<div className="sb">
|
|
<div className="sb-t">속도 프로파일</div>
|
|
<SpeedProfilePanel selectedType={speedPanelType} />
|
|
</div>
|
|
|
|
<div className="sb" style={{ maxHeight: 260, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
|
<div className="sb-t">
|
|
선단 연관관계{" "}
|
|
<span style={{ color: "var(--accent)", fontSize: 8 }}>
|
|
{selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : "(선박 클릭 시 표시)"}
|
|
</span>
|
|
</div>
|
|
<div style={{ overflowY: "auto", minHeight: 0 }}>
|
|
<RelationsPanel
|
|
selectedVessel={selectedLegacyVessel}
|
|
vessels={legacyVesselsAll}
|
|
fleetVessels={legacyVesselsFiltered}
|
|
onSelectMmsi={setSelectedMmsi}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="sb" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
|
<div className="sb-t">
|
|
선박 목록{" "}
|
|
<span style={{ color: "var(--accent)", fontSize: 8 }}>
|
|
({legacyVesselsFiltered.length}척)
|
|
</span>
|
|
</div>
|
|
<VesselList vessels={legacyVesselsFiltered} selectedMmsi={selectedMmsi} onSelectMmsi={setSelectedMmsi} />
|
|
</div>
|
|
|
|
<div className="sb" style={{ maxHeight: 130, overflowY: "auto" }}>
|
|
<div className="sb-t">실시간 경고</div>
|
|
<AlarmsPanel alarms={alarms} onSelectMmsi={setSelectedMmsi} />
|
|
</div>
|
|
|
|
{adminMode ? (
|
|
<>
|
|
<div className="sb">
|
|
<div className="sb-t">ADMIN · AIS Target Polling</div>
|
|
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
|
<div style={{ color: "var(--muted)", fontSize: 10 }}>엔드포인트</div>
|
|
<div style={{ wordBreak: "break-all" }}>{AIS_API_BASE}/api/ais-target/search</div>
|
|
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}>상태</div>
|
|
<div>
|
|
<b style={{ color: snapshot.status === "ready" ? "#22C55E" : snapshot.status === "error" ? "#EF4444" : "#F59E0B" }}>
|
|
{snapshot.status.toUpperCase()}
|
|
</b>
|
|
{snapshot.error ? <span style={{ marginLeft: 6, color: "#EF4444" }}>{snapshot.error}</span> : null}
|
|
</div>
|
|
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}>최근 fetch</div>
|
|
<div>
|
|
{fmtLocal(snapshot.lastFetchAt)}{" "}
|
|
<span style={{ color: "var(--muted)", fontSize: 10 }}>
|
|
({snapshot.lastFetchMinutes ?? "-"}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted})
|
|
</span>
|
|
</div>
|
|
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}>메시지</div>
|
|
<div style={{ color: "var(--text)", fontSize: 10 }}>{snapshot.lastMessage ?? "-"}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="sb">
|
|
<div className="sb-t">ADMIN · Legacy (CN Permit)</div>
|
|
{legacyError ? (
|
|
<div style={{ fontSize: 11, color: "#EF4444" }}>legacy load error: {legacyError}</div>
|
|
) : (
|
|
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
|
<div style={{ color: "var(--muted)", fontSize: 10 }}>데이터셋</div>
|
|
<div style={{ wordBreak: "break-all", fontSize: 10 }}>/data/legacy/chinese-permitted.v1.json</div>
|
|
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}>매칭(현재 scope)</div>
|
|
<div>
|
|
<b style={{ color: "#F59E0B" }}>{legacyVesselsAll.length}</b>{" "}
|
|
<span style={{ color: "var(--muted)", fontSize: 10 }}>/ {targetsInScope.length}</span>
|
|
</div>
|
|
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}>생성시각</div>
|
|
<div style={{ fontSize: 10, color: "var(--text)" }}>{legacyData?.generatedAt ? fmtLocal(legacyData.generatedAt) : "loading..."}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="sb">
|
|
<div className="sb-t">ADMIN · Viewport / BBox</div>
|
|
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
|
<div style={{ color: "var(--muted)", fontSize: 10 }}>현재 View BBox</div>
|
|
<div style={{ wordBreak: "break-all", fontSize: 10 }}>{fmtBbox(viewBbox)}</div>
|
|
<div style={{ marginTop: 8, display: "flex", gap: 6, flexWrap: "wrap" }}>
|
|
<button
|
|
onClick={() => setUseViewportFilter((v) => !v)}
|
|
style={{
|
|
fontSize: 10,
|
|
padding: "4px 8px",
|
|
borderRadius: 6,
|
|
border: "1px solid var(--border)",
|
|
background: useViewportFilter ? "rgba(59,130,246,.18)" : "var(--card)",
|
|
color: "var(--text)",
|
|
cursor: "pointer",
|
|
}}
|
|
title="지도/리스트에 현재 화면 영역 내 선박만 표시(클라이언트 필터)"
|
|
>
|
|
Viewport filter {useViewportFilter ? "ON" : "OFF"}
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => {
|
|
if (!viewBbox) return;
|
|
setUseApiBbox((v) => {
|
|
const next = !v;
|
|
if (next && viewBbox) setApiBbox(fmtBbox(viewBbox));
|
|
if (!next) setApiBbox(undefined);
|
|
return next;
|
|
});
|
|
}}
|
|
style={{
|
|
fontSize: 10,
|
|
padding: "4px 8px",
|
|
borderRadius: 6,
|
|
border: "1px solid var(--border)",
|
|
background: useApiBbox ? "rgba(245,158,11,.14)" : "var(--card)",
|
|
color: viewBbox ? "var(--text)" : "var(--muted)",
|
|
cursor: viewBbox ? "pointer" : "not-allowed",
|
|
}}
|
|
title="서버에서 bbox로 필터링해서 내려받기(페이로드 감소). 켜는 순간 현재 view bbox로 고정됨."
|
|
disabled={!viewBbox}
|
|
>
|
|
API bbox {useApiBbox ? "ON" : "OFF"}
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => {
|
|
if (!viewBbox) return;
|
|
setApiBbox(fmtBbox(viewBbox));
|
|
setUseApiBbox(true);
|
|
}}
|
|
style={{
|
|
fontSize: 10,
|
|
padding: "4px 8px",
|
|
borderRadius: 6,
|
|
border: "1px solid var(--border)",
|
|
background: "var(--card)",
|
|
color: viewBbox ? "var(--text)" : "var(--muted)",
|
|
cursor: viewBbox ? "pointer" : "not-allowed",
|
|
}}
|
|
disabled={!viewBbox}
|
|
title="현재 view bbox로 API bbox를 갱신"
|
|
>
|
|
bbox=viewport
|
|
</button>
|
|
</div>
|
|
<div style={{ marginTop: 8, color: "var(--muted)", fontSize: 10 }}>
|
|
표시 선박: <b style={{ color: "var(--text)" }}>{targetsInScope.length}</b> / 스토어:{" "}
|
|
<b style={{ color: "var(--text)" }}>{snapshot.total}</b>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="sb">
|
|
<div className="sb-t">ADMIN · Map (Extras)</div>
|
|
<Map3DSettingsToggles value={settings} onToggle={(k) => setSettings((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
|
<div style={{ fontSize: 10, color: "var(--muted)", marginTop: 6 }}>단일 WebGL 컨텍스트: MapboxOverlay(interleaved)</div>
|
|
</div>
|
|
|
|
<div className="sb" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
|
<div className="sb-t">ADMIN · AIS Targets (All)</div>
|
|
<AisTargetList
|
|
targets={targetsInScope}
|
|
selectedMmsi={selectedMmsi}
|
|
onSelectMmsi={setSelectedMmsi}
|
|
legacyIndex={legacyIndex}
|
|
/>
|
|
</div>
|
|
|
|
<div className="sb" style={{ maxHeight: 130, overflowY: "auto" }}>
|
|
<div className="sb-t">ADMIN · 수역 데이터</div>
|
|
{zonesError ? (
|
|
<div style={{ fontSize: 11, color: "#EF4444" }}>zones load error: {zonesError}</div>
|
|
) : (
|
|
<div style={{ fontSize: 11, color: "var(--muted)" }}>
|
|
{zones ? `loaded (${zones.features.length} features)` : "loading..."}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="map-area">
|
|
<Map3D
|
|
targets={targetsForMap}
|
|
zones={zones}
|
|
selectedMmsi={selectedMmsi}
|
|
settings={settings}
|
|
baseMap={baseMap}
|
|
projection={projection}
|
|
overlays={overlays}
|
|
onSelectMmsi={setSelectedMmsi}
|
|
onViewBboxChange={setViewBbox}
|
|
legacyHits={legacyHits}
|
|
pairLinks={pairLinksForMap}
|
|
fcLinks={fcLinksForMap}
|
|
fleetCircles={fleetCirclesForMap}
|
|
/>
|
|
<MapLegend />
|
|
{selectedLegacyVessel ? (
|
|
<VesselInfoPanel vessel={selectedLegacyVessel} allVessels={legacyVesselsAll} onClose={() => setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} />
|
|
) : selectedTarget ? (
|
|
<AisInfoPanel target={selectedTarget} legacy={selectedLegacyInfo} onClose={() => setSelectedMmsi(null)} />
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|