gc-wing/apps/web/src/pages/dashboard/DashboardPage.tsx

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>
);
}