import { useCallback, useMemo } from "react"; import { useAuth } from "../../shared/auth"; import { useTheme } from "../../shared/hooks"; import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling"; import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types"; import { LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types"; import { useLegacyVessels } from "../../entities/legacyVessel/api/useLegacyVessels"; import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib"; import type { LegacyVesselDataset } from "../../entities/legacyVessel/model/types"; import type { VesselTypeCode } from "../../entities/vessel/model/types"; import { VESSEL_TYPE_ORDER } from "../../entities/vessel/model/meta"; import { useZones } from "../../entities/zone/api/useZones"; import { useSubcables } from "../../entities/subcable/api/useSubcables"; import { AisInfoPanel } from "../../widgets/aisInfo/AisInfoPanel"; import { MapLegend } from "../../widgets/legend/MapLegend"; import { Map3D } from "../../widgets/map3d/Map3D"; import { Topbar } from "../../widgets/topbar/Topbar"; import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel"; import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel"; import { DepthLegend } from "../../widgets/legend/DepthLegend"; 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 { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel"; import { buildLegacyHitMap, computeCountsByType, computeFcLinks, computeFleetCircles, computeLegacyAlarms, computePairLinks, deriveLegacyVessels, filterByShipCodes, } from "../../features/legacyDashboard/model/derive"; import { useDashboardState } from "./useDashboardState"; import type { Bbox } from "./useDashboardState"; import { DashboardSidebar } from "./DashboardSidebar"; const AIS_CENTER = { lon: 126.95, lat: 35.95, radiusMeters: 2_000_000, }; 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 useLegacyIndex(data: LegacyVesselDataset | null) { return useMemo(() => (data ? buildLegacyVesselIndex(data.vessels) : null), [data]); } export function DashboardPage() { const { user, logout } = useAuth(); const { theme, toggleTheme } = useTheme(); const uid = user?.id ?? null; // ── Data fetching ── const { data: zones, error: zonesError } = useZones(); const { data: legacyData, error: legacyError } = useLegacyVessels(); const { data: subcableData } = useSubcables(); const legacyIndex = useLegacyIndex(legacyData); // ── UI state ── const state = useDashboardState(uid); const { mapInstance, handleMapReady, viewBbox, setViewBbox, useViewportFilter, useApiBbox, apiBbox, selectedMmsi, setSelectedMmsi, highlightedMmsiSet, hoveredMmsiSet, setHoveredMmsiSet, hoveredFleetMmsiSet, setHoveredFleetMmsiSet, hoveredPairMmsiSet, setHoveredPairMmsiSet, hoveredFleetOwnerKey, setHoveredFleetOwnerKey, typeEnabled, showTargets, showOthers, baseMap, projection, mapStyleSettings, setMapStyleSettings, overlays, settings, mapView, setMapView, fleetFocus, setFleetFocus, hoveredCableId, setHoveredCableId, selectedCableId, setSelectedCableId, trackContextMenu, handleOpenTrackMenu, handleCloseTrackMenu, handleProjectionLoadingChange, setIsGlobeShipsReady, showMapLoader, clock, adminMode, onLogoClick, setUniqueSorted, setSortedIfChanged, toggleHighlightedMmsi, alarmKindEnabled, } = state; // ── Weather ── const weather = useWeatherPolling(zones); const weatherOverlay = useWeatherOverlay(mapInstance); // ── AIS polling ── 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, }); // ── Track request ── 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]); // ── Derived data ── 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(() => 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 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 alarmFilterSummary = allAlarmKindsEnabled ? "전체" : `${enabledAlarmKinds.length}/${LEGACY_ALARM_KINDS.length}`; 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 }); }; // ── Render ── return (
{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}
); }