import { useCallback, useMemo, useState } from "react"; import { useAuth } from "../../shared/auth"; import { useTheme } from "../../shared/hooks"; import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling"; import type { DerivedLegacyVessel, LegacyAlarmKind } from "../../features/legacyDashboard/model/types"; import { ALARM_KIND_PRIORITY, 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 type { ShipImageInfo } from "../../entities/shipImage/model/types"; import ShipImageModal from "../../widgets/shipImage/ShipImageModal"; 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 { MOCK_AIS_TARGETS, MOCK_LEGACY_ENTRIES } from "../../features/legacyDashboard/dev/mockOverlayData"; import { useDashboardState } from "./useDashboardState"; import type { Bbox } from "./useDashboardState"; import { DashboardSidebar } from "./DashboardSidebar"; 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 [isSidebarOpen, setIsSidebarOpen] = useState(false); const uid = user?.id ?? null; const isDevMode = user?.name?.includes('(DEV)') ?? false; // ── 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({ chnprmship: { initialMinutes: 120, pollMinutes: 2, intervalMs: 60_000, retentionMinutes: 120 }, recent: { initialMinutes: 15, pollMinutes: 12, intervalMs: 600_000, retentionMinutes: 72 }, bbox: useApiBbox ? apiBbox : undefined, }); // ── Derived data ── const targetsInScope = useMemo(() => { const base = (!useViewportFilter || !viewBbox) ? targets : targets.filter((t) => typeof t.lon === "number" && typeof t.lat === "number" && inBbox(t.lon, t.lat, viewBbox)); return isDevMode ? [...base, ...MOCK_AIS_TARGETS] : base; }, [targets, useViewportFilter, viewBbox, isDevMode]); const legacyHits = useMemo(() => { const hits = buildLegacyHitMap(targetsInScope, legacyIndex); if (isDevMode) { for (const [mmsi, info] of MOCK_LEGACY_ENTRIES) hits.set(mmsi, info); } return hits; }, [targetsInScope, legacyIndex, isDevMode]); // ── 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 isPermitted = legacyHits.has(mmsi); const tracks = await queryTrackByMmsi({ mmsi, minutes, shipNameHint: target?.name, shipKindCodeHint: target?.shipKindCode, nationalCodeHint: target?.nationalCode, isPermitted, }); if (tracks.length > 0) { trackStore.applyTracksSuccess(tracks, queryKey); } else { trackStore.applyQueryError('항적 데이터가 없습니다.', queryKey); } } catch (e) { trackStore.applyQueryError(e instanceof Error ? e.message : '항적 조회에 실패했습니다.', queryKey); } }, [targets, legacyHits]); 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 alarmMmsiMap = useMemo(() => { const m = new Map(); for (const kind of ALARM_KIND_PRIORITY) { for (const alarm of filteredAlarms) { if (alarm.kind !== kind) continue; for (const mmsi of alarm.relatedMmsi) m.set(mmsi, kind); } } return m; }, [filteredAlarms]); 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 [imageModal, setImageModal] = useState<{ images?: ShipImageInfo[]; initialIndex?: number; initialImagePath?: string; totalCount?: number; imo?: number; vesselName?: string; } | null>(null); const handleOpenImageModal = useCallback((mmsi: number) => { const target = targetsInScope.find((t) => t.mmsi === mmsi); if (!target?.shipImagePath) return; const vessel = legacyVesselsAll.find((v) => v.mmsi === mmsi); const vesselName = vessel?.name || target.name || ''; setImageModal({ initialImagePath: target.shipImagePath, totalCount: target.shipImageCount ?? 1, imo: target.imo > 0 ? target.imo : undefined, vesselName, }); }, [targetsInScope, legacyVesselsAll]); // 지도에서 선박 클릭 시: 선택 + 사진이 있으면 자동으로 모달 표시 const handleMapSelectMmsi = useCallback((mmsi: number | null) => { setSelectedMmsi(mmsi); if (mmsi) handleOpenImageModal(mmsi); }, [setSelectedMmsi, handleOpenImageModal]); const handlePanelOpenImageModal = useCallback((index: number, images?: ShipImageInfo[]) => { if (!selectedMmsi) return; const target = targetsInScope.find((t) => t.mmsi === selectedMmsi); const vessel = legacyVesselsAll.find((v) => v.mmsi === selectedMmsi); const vesselName = vessel?.name || target?.name || ''; setImageModal({ images, initialIndex: index, initialImagePath: target?.shipImagePath ?? undefined, totalCount: target?.shipImageCount ?? 1, imo: target && target.imo > 0 ? target.imo : undefined, vesselName, }); }, [selectedMmsi, targetsInScope, legacyVesselsAll]); 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 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 (
setIsSidebarOpen((v) => !v)} /> setIsSidebarOpen(false)} state={state} legacyVesselsAll={legacyVesselsAll} legacyVesselsFiltered={legacyVesselsFiltered} legacyCounts={legacyCounts} selectedLegacyVessel={selectedLegacyVessel} activeHighlightedMmsiSet={activeHighlightedMmsiSet} legacyHits={legacyHits} filteredAlarms={filteredAlarms} alarms={alarms} alarmKindCounts={alarmKindCounts} speedPanelType={speedPanelType} onFleetContextMenu={handleFleetContextMenu} snapshot={snapshot} legacyError={legacyError} legacyData={legacyData} targetsInScope={targetsInScope} zonesError={zonesError} zones={zones} legacyIndex={legacyIndex} />
{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} alarmMmsiMap={alarmMmsiMap} onClickShipPhoto={handleOpenImageModal} /> {selectedLegacyVessel ? ( setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} imo={selectedTarget && selectedTarget.imo > 0 ? selectedTarget.imo : undefined} shipImagePath={selectedTarget?.shipImagePath} shipImageCount={selectedTarget?.shipImageCount} onOpenImageModal={handlePanelOpenImageModal} /> ) : selectedTarget ? ( setSelectedMmsi(null)} onOpenImageModal={handlePanelOpenImageModal} /> ) : null} {imageModal && ( setImageModal(null)} /> )} {selectedCableId && subcableData?.details.get(selectedCableId) ? ( f.properties.id === selectedCableId)?.properties.color} onClose={() => setSelectedCableId(null)} /> ) : null}
); }