import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAuth } from "../../shared/auth"; import { usePersistedState } from "../../shared/hooks"; 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 type { DerivedLegacyVessel, LegacyAlarmKind } from "../../features/legacyDashboard/model/types"; import { LEGACY_ALARM_KIND_LABEL, LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types"; 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 { useSubcables } from "../../entities/subcable/api/useSubcables"; 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 type { MapViewState } from "../../widgets/map3d/types"; 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 { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel"; import { VesselList } from "../../widgets/vesselList/VesselList"; import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel"; import { DepthLegend } from "../../widgets/legend/DepthLegend"; import { DEFAULT_MAP_STYLE_SETTINGS } from "../../features/mapSettings/types"; import type { MapStyleSettings } from "../../features/mapSettings/types"; import { fmtDateTimeFull, fmtIsoFull } from "../../shared/lib/datetime"; import { queryTrackByMmsi } from "../../features/trackReplay/services/trackQueryService"; import { useTrackQueryStore } from "../../features/trackReplay/stores/trackQueryStore"; import { GlobalTrackReplayPanel } from "../../widgets/trackReplay/GlobalTrackReplayPanel"; import { useWeatherPolling } from "../../features/weatherOverlay/useWeatherPolling"; import { useWeatherOverlay } from "../../features/weatherOverlay/useWeatherOverlay"; import { WeatherPanel } from "../../widgets/weatherPanel/WeatherPanel"; import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel"; 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, }; type Bbox = [number, number, number, number]; // [lonMin, latMin, lonMax, latMax] type FleetRelationSortMode = "count" | "range"; 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 { user, logout } = useAuth(); const { data: zones, error: zonesError } = useZones(); const { data: legacyData, error: legacyError } = useLegacyVessels(); const { data: subcableData } = useSubcables(); const legacyIndex = useLegacyIndex(legacyData); const weather = useWeatherPolling(zones); const [mapInstance, setMapInstance] = useState(null); const weatherOverlay = useWeatherOverlay(mapInstance); const handleMapReady = useCallback((map: import("maplibre-gl").Map) => { setMapInstance(map); }, []); const [viewBbox, setViewBbox] = useState(null); const [useViewportFilter, setUseViewportFilter] = useState(false); const [useApiBbox, setUseApiBbox] = useState(false); const [apiBbox, setApiBbox] = useState(undefined); const { targets, snapshot } = useAisTargetPolling({ chnprmshipMinutes: 120, incrementalMinutes: 2, intervalMs: 60_000, retentionMinutes: 120, 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 [highlightedMmsiSet, setHighlightedMmsiSet] = useState([]); const [hoveredMmsiSet, setHoveredMmsiSet] = useState([]); const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState([]); const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState([]); const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState(null); const uid = user?.id ?? null; const [typeEnabled, setTypeEnabled] = usePersistedState>( uid, 'typeEnabled', { PT: true, "PT-S": true, GN: true, OT: true, PS: true, FC: true }, ); const [showTargets, setShowTargets] = usePersistedState(uid, 'showTargets', true); const [showOthers, setShowOthers] = usePersistedState(uid, 'showOthers', false); // 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용 // eslint-disable-next-line @typescript-eslint/no-unused-vars const [baseMap, _setBaseMap] = useState("enhanced"); // 항상 mercator로 시작 — 3D 전환은 globe 레이어 준비 후 사용자가 수동 전환 const [projection, setProjection] = useState('mercator'); const [mapStyleSettings, setMapStyleSettings] = usePersistedState(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS); const [overlays, setOverlays] = usePersistedState(uid, 'overlays', { pairLines: true, pairRange: true, fcLines: true, zones: true, fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false, }); const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState(uid, 'sortMode', "count"); const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState>( uid, 'alarmKindEnabled', () => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record, ); const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined); const [hoveredCableId, setHoveredCableId] = useState(null); const [selectedCableId, setSelectedCableId] = useState(null); // 항적 (vessel track) const [trackContextMenu, setTrackContextMenu] = useState<{ x: number; y: number; mmsi: number; vesselName: string } | null>(null); const handleOpenTrackMenu = useCallback((info: { x: number; y: number; mmsi: number; vesselName: string }) => { setTrackContextMenu(info); }, []); const handleCloseTrackMenu = useCallback(() => setTrackContextMenu(null), []); const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => { const trackStore = useTrackQueryStore.getState(); const queryKey = `${mmsi}:${minutes}:${Date.now()}`; trackStore.beginQuery(queryKey); try { const target = targets.find((item) => item.mmsi === mmsi); const tracks = await queryTrackByMmsi({ mmsi, minutes, shipNameHint: target?.name, }); if (tracks.length > 0) { trackStore.applyTracksSuccess(tracks, queryKey); } else { trackStore.applyQueryError('항적 데이터가 없습니다.', queryKey); } } catch (e) { trackStore.applyQueryError(e instanceof Error ? e.message : '항적 조회에 실패했습니다.', queryKey); } }, [targets]); const [settings, setSettings] = usePersistedState(uid, 'map3dSettings', { showShips: true, showDensity: false, showSeamark: false, }); const [mapView, setMapView] = usePersistedState(uid, 'mapView', null); const [isProjectionLoading, setIsProjectionLoading] = useState(false); // 초기값 false: globe 레이어가 백그라운드에서 준비될 때까지 토글 비활성화 const [isGlobeShipsReady, setIsGlobeShipsReady] = useState(false); const handleProjectionLoadingChange = useCallback((loading: boolean) => { setIsProjectionLoading(loading); }, []); const showMapLoader = isProjectionLoading; // globe 레이어 미준비 또는 전환 중일 때 토글 비활성화 const isProjectionToggleDisabled = !isGlobeShipsReady || isProjectionLoading; const [clock, setClock] = useState(() => fmtDateTimeFull(new Date())); useEffect(() => { const id = window.setInterval(() => setClock(fmtDateTimeFull(new Date())), 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 alarmKindCounts = useMemo(() => { const base = Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, 0])) as Record; for (const a of alarms) { base[a.kind] = (base[a.kind] ?? 0) + 1; } return base; }, [alarms]); const enabledAlarmKinds = useMemo(() => { return LEGACY_ALARM_KINDS.filter((k) => alarmKindEnabled[k]); }, [alarmKindEnabled]); const allAlarmKindsEnabled = enabledAlarmKinds.length === LEGACY_ALARM_KINDS.length; const filteredAlarms = useMemo(() => { if (allAlarmKindsEnabled) return alarms; const enabled = new Set(enabledAlarmKinds); return alarms.filter((a) => enabled.has(a.kind)); }, [alarms, enabledAlarmKinds, allAlarmKindsEnabled]); 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 availableTargetMmsiSet = useMemo( () => new Set(targetsInScope.map((t) => t.mmsi).filter((mmsi) => Number.isFinite(mmsi))), [targetsInScope], ); const activeHighlightedMmsiSet = useMemo( () => highlightedMmsiSet.filter((mmsi) => availableTargetMmsiSet.has(mmsi)), [highlightedMmsiSet, availableTargetMmsiSet], ); const setUniqueSorted = (items: number[]) => Array.from(new Set(items.filter((item) => Number.isFinite(item)))).sort((a, b) => a - b); const setSortedIfChanged = (next: number[]) => { const sorted = setUniqueSorted(next); return (prev: number[]) => (prev.length === sorted.length && prev.every((v, i) => v === sorted[i]) ? prev : sorted); }; const handleFleetContextMenu = (ownerKey: string, mmsis: number[]) => { if (!mmsis.length) return; const members = mmsis .map((mmsi) => legacyVesselsFiltered.find((v): v is DerivedLegacyVessel => v.mmsi === mmsi)) .filter( (v): v is DerivedLegacyVessel & { lat: number; lon: number } => v != null && typeof v.lat === "number" && typeof v.lon === "number" && Number.isFinite(v.lat) && Number.isFinite(v.lon), ); if (members.length === 0) return; const sumLon = members.reduce((acc, v) => acc + v.lon, 0); const sumLat = members.reduce((acc, v) => acc + v.lat, 0); const center: [number, number] = [sumLon / members.length, sumLat / members.length]; setFleetFocus({ id: `${ownerKey}-${Date.now()}`, center, zoom: 9, }); }; const toggleHighlightedMmsi = (mmsi: number) => { setHighlightedMmsiSet((prev) => { const next = new Set(prev); if (next.has(mmsi)) next.delete(mmsi); else next.add(mmsi); return Array.from(next).sort((a, b) => a - b); }); }; 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; const enabledAlarmKindCount = enabledAlarmKinds.length; const alarmFilterSummary = allAlarmKindsEnabled ? "전체" : `${enabledAlarmKindCount}/${LEGACY_ALARM_KINDS.length}`; 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, }); }} />
지도 표시 설정
setProjection((p) => (p === "globe" ? "mercator" : "globe"))} title={isProjectionToggleDisabled ? "3D 모드 준비 중..." : "3D 지구본 투영: 드래그로 회전, 휠로 확대/축소"} style={{ fontSize: 9, padding: "2px 8px", opacity: isProjectionToggleDisabled ? 0.4 : 1, cursor: isProjectionToggleDisabled ? "not-allowed" : "pointer" }} > 3D
setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} /> {/* 베이스맵 선택 — 현재 enhanced 단일 맵 사용, 레거시는 비활성
setBaseMap("enhanced")} title="현재 지도(해저지형/3D 표현 포함)"> 기본
setBaseMap("legacy")} title="레거시 대시보드(Carto Dark) 베이스맵"> 레거시
*/}
속도 프로파일
선단 연관관계{" "} {selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : "(선박 클릭 시 표시)"}
setHoveredMmsiSet(setUniqueSorted(mmsis))} onClearHover={() => setHoveredMmsiSet([])} onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))} onClearPairHover={() => setHoveredPairMmsiSet([])} onHoverFleet={(ownerKey, fleetMmsis) => { setHoveredFleetOwnerKey(ownerKey); setHoveredFleetMmsiSet(setSortedIfChanged(fleetMmsis)); }} onClearFleetHover={() => { setHoveredFleetOwnerKey(null); setHoveredFleetMmsiSet((prev) => (prev.length === 0 ? prev : [])); }} fleetSortMode={fleetRelationSortMode} hoveredFleetOwnerKey={hoveredFleetOwnerKey} hoveredFleetMmsiSet={hoveredFleetMmsiSet} onContextMenuFleet={handleFleetContextMenu} />
선박 목록{" "} ({legacyVesselsFiltered.length}척)
setHoveredMmsiSet([mmsi])} onClearHover={() => setHoveredMmsiSet([])} />
실시간 경고{" "} ({filteredAlarms.length}/{alarms.length})
{LEGACY_ALARM_KINDS.length <= 3 ? (
{LEGACY_ALARM_KINDS.map((k) => ( ))}
) : (
{alarmFilterSummary}
{LEGACY_ALARM_KINDS.map((k) => ( ))}
)}
{adminMode ? ( <>
ADMIN · AIS Target Polling
엔드포인트
{AIS_API_BASE}/api/ais-target/search
상태
{snapshot.status.toUpperCase()} {snapshot.error ? {snapshot.error} : null}
최근 fetch
{fmtIsoFull(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 ? fmtIsoFull(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}
{showMapLoader ? (
지도 모드 동기화 중...
) : null} setHoveredMmsiSet(setUniqueSorted(mmsis))} onClearMmsiHover={() => setHoveredMmsiSet((prev) => (prev.length === 0 ? prev : []))} onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))} onClearPairHover={() => setHoveredPairMmsiSet([])} onHoverFleet={(ownerKey, fleetMmsis) => { setHoveredFleetOwnerKey(ownerKey); setHoveredFleetMmsiSet(setSortedIfChanged(fleetMmsis)); }} onClearFleetHover={() => { setHoveredFleetOwnerKey(null); setHoveredFleetMmsiSet((prev) => (prev.length === 0 ? prev : [])); }} subcableGeo={subcableData?.geo ?? null} hoveredCableId={hoveredCableId} onHoverCable={setHoveredCableId} onClickCable={(id) => setSelectedCableId((prev) => (prev === id ? null : id))} mapStyleSettings={mapStyleSettings} initialView={mapView} onViewStateChange={setMapView} activeTrack={null} trackContextMenu={trackContextMenu} onRequestTrack={handleRequestTrack} onCloseTrackMenu={handleCloseTrackMenu} onOpenTrackMenu={handleOpenTrackMenu} onMapReady={handleMapReady} /> {selectedLegacyVessel ? ( setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} /> ) : selectedTarget ? ( setSelectedMmsi(null)} /> ) : null} {selectedCableId && subcableData?.details.get(selectedCableId) ? ( f.properties.id === selectedCableId)?.properties.color} onClose={() => setSelectedCableId(null)} /> ) : null}
); }