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(null); const [useViewportFilter, setUseViewportFilter] = useState(false); const [useApiBbox, setUseApiBbox] = useState(false); const [apiBbox, setApiBbox] = useState(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(null); const [typeEnabled, setTypeEnabled] = useState>({ 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("enhanced"); const [projection, setProjection] = useState("mercator"); const [overlays, setOverlays] = useState({ pairLines: true, pairRange: false, fcLines: true, zones: true, fleetCircles: true, }); const [settings, setSettings] = useState({ 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([]); 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 (
업종 필터
{ setShowTargets((v) => { const next = !v; if (!next) setSelectedMmsi((m) => (legacyHits.has(m ?? -1) ? null : m)); return next; }); }} title="레거시(CN permit) 대상 선박 표시" > 대상 선박
setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시"> 기타 AIS
{ // 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, }); }} />
지도 표시 설정
setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
베이스맵
setBaseMap("enhanced")} title="현재 지도(해저지형/3D 표현 포함)"> 기본
setBaseMap("legacy")} title="레거시 대시보드(Carto Dark) 베이스맵"> 레거시
setProjection((p) => (p === "globe" ? "mercator" : "globe"))} title="지구본(globe) 투영: 드래그로 회전, 휠로 확대/축소" > 지구본
지도 우하단 Attribution(라이센스) 표기 유지
속도 프로파일
선단 연관관계{" "} {selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : "(선박 클릭 시 표시)"}
선박 목록{" "} ({legacyVesselsFiltered.length}척)
실시간 경고
{adminMode ? ( <>
ADMIN · AIS Target Polling
엔드포인트
{AIS_API_BASE}/api/ais-target/search
상태
{snapshot.status.toUpperCase()} {snapshot.error ? {snapshot.error} : null}
최근 fetch
{fmtLocal(snapshot.lastFetchAt)}{" "} ({snapshot.lastFetchMinutes ?? "-"}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted})
메시지
{snapshot.lastMessage ?? "-"}
ADMIN · Legacy (CN Permit)
{legacyError ? (
legacy load error: {legacyError}
) : (
데이터셋
/data/legacy/chinese-permitted.v1.json
매칭(현재 scope)
{legacyVesselsAll.length}{" "} / {targetsInScope.length}
생성시각
{legacyData?.generatedAt ? fmtLocal(legacyData.generatedAt) : "loading..."}
)}
ADMIN · Viewport / BBox
현재 View BBox
{fmtBbox(viewBbox)}
표시 선박: {targetsInScope.length} / 스토어:{" "} {snapshot.total}
ADMIN · Map (Extras)
setSettings((prev) => ({ ...prev, [k]: !prev[k] }))} />
단일 WebGL 컨텍스트: MapboxOverlay(interleaved)
ADMIN · AIS Targets (All)
ADMIN · 수역 데이터
{zonesError ? (
zones load error: {zonesError}
) : (
{zones ? `loaded (${zones.features.length} features)` : "loading..."}
)}
) : null}
{selectedLegacyVessel ? ( setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} /> ) : selectedTarget ? ( setSelectedMmsi(null)} /> ) : null}
); }