From ec03a88fbdd8b4e9f888d817c2d315a1b38ee026 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 23:55:58 +0900 Subject: [PATCH] =?UTF-8?q?refactor(dashboard):=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=EB=B0=94=20+=20=EC=83=81=ED=83=9C=20=ED=9B=85=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DashboardPage.tsx (808줄) → 3파일 분리: - useDashboardState.ts (147줄): UI 상태 관리 훅 - DashboardSidebar.tsx (430줄): 좌측 사이드바 컴포넌트 - DashboardPage.tsx (295줄): 레이아웃 + 지도 영역 Co-Authored-By: Claude Opus 4.6 --- .../web/src/pages/dashboard/DashboardPage.tsx | 595 +++--------------- .../src/pages/dashboard/DashboardSidebar.tsx | 430 +++++++++++++ .../src/pages/dashboard/useDashboardState.ts | 147 +++++ 3 files changed, 658 insertions(+), 514 deletions(-) create mode 100644 apps/web/src/pages/dashboard/DashboardSidebar.tsx create mode 100644 apps/web/src/pages/dashboard/useDashboardState.ts diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index 734ae11..8ab706c 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -1,37 +1,22 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo } 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 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 { LegacyVesselIndex } 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 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 { Map3D } from "../../widgets/map3d/Map3D"; 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"; @@ -39,6 +24,7 @@ import { useWeatherPolling } from "../../features/weatherOverlay/useWeatherPolli 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, @@ -49,18 +35,16 @@ import { deriveLegacyVessels, filterByShipCodes, } from "../../features/legacyDashboard/model/derive"; -import { VESSEL_TYPE_ORDER } from "../../entities/vessel/model/meta"; +import { useDashboardState } from "./useDashboardState"; +import type { Bbox } from "./useDashboardState"; +import { DashboardSidebar } from "./DashboardSidebar"; -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; @@ -68,34 +52,56 @@ function inBbox(lon: number, lat: number, bbox: Bbox) { 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 { +function useLegacyIndex(data: LegacyVesselDataset | null) { return useMemo(() => (data ? buildLegacyVesselIndex(data.vessels) : null), [data]); } export function DashboardPage() { const { user, logout } = useAuth(); + 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 [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); + // ── AIS polling ── const { targets, snapshot } = useAisTargetPolling({ chnprmshipMinutes: 120, incrementalMinutes: 2, @@ -107,48 +113,7 @@ export function DashboardPage() { 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), []); + // ── Track request ── const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => { const trackStore = useTrackQueryStore.getState(); const queryKey = `${mmsi}:${minutes}:${Date.now()}`; @@ -172,40 +137,7 @@ export function DashboardPage() { } }, [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); - } - }; - + // ── 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)); @@ -244,17 +176,14 @@ export function DashboardPage() { 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; + 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 enabledAlarmKinds = useMemo(() => LEGACY_ALARM_KINDS.filter((k) => alarmKindEnabled[k]), [alarmKindEnabled]); const allAlarmKindsEnabled = enabledAlarmKinds.length === LEGACY_ALARM_KINDS.length; const filteredAlarms = useMemo(() => { @@ -291,13 +220,12 @@ export function DashboardPage() { [highlightedMmsiSet, availableTargetMmsiSet], ); - const setUniqueSorted = (items: number[]) => - Array.from(new Set(items.filter((item) => Number.isFinite(item)))).sort((a, b) => a - b); + const fishingCount = legacyVesselsAll.filter((v) => v.state.isFishing).length; + const transitCount = legacyVesselsAll.filter((v) => v.state.isTransit).length; - 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 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; @@ -312,30 +240,10 @@ export function DashboardPage() { 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, - }); + 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}`; - + // ── Render ── 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 ? ( diff --git a/apps/web/src/pages/dashboard/DashboardSidebar.tsx b/apps/web/src/pages/dashboard/DashboardSidebar.tsx new file mode 100644 index 0000000..82ef6b3 --- /dev/null +++ b/apps/web/src/pages/dashboard/DashboardSidebar.tsx @@ -0,0 +1,430 @@ +import type { AisTarget } from '../../entities/aisTarget/model/types'; +import type { LegacyVesselIndex } from '../../entities/legacyVessel/lib'; +import type { LegacyVesselDataset, LegacyVesselInfo } from '../../entities/legacyVessel/model/types'; +import type { VesselTypeCode } from '../../entities/vessel/model/types'; +import { VESSEL_TYPE_ORDER } from '../../entities/vessel/model/meta'; +import type { ZonesGeoJson } from '../../entities/zone/api/useZones'; +import type { AisPollingSnapshot } from '../../features/aisPolling/useAisTargetPolling'; +import { Map3DSettingsToggles } from '../../features/map3dSettings/Map3DSettingsToggles'; +import type { DerivedLegacyVessel, LegacyAlarm, LegacyAlarmKind } from '../../features/legacyDashboard/model/types'; +import { LEGACY_ALARM_KIND_LABEL, LEGACY_ALARM_KINDS } from '../../features/legacyDashboard/model/types'; +import { MapToggles } from '../../features/mapToggles/MapToggles'; +import { TypeFilterGrid } from '../../features/typeFilter/TypeFilterGrid'; +import { AisTargetList } from '../../widgets/aisTargetList/AisTargetList'; +import { AlarmsPanel } from '../../widgets/alarms/AlarmsPanel'; +import { RelationsPanel } from '../../widgets/relations/RelationsPanel'; +import { SpeedProfilePanel } from '../../widgets/speed/SpeedProfilePanel'; +import { VesselList } from '../../widgets/vesselList/VesselList'; +import { fmtIsoFull } from '../../shared/lib/datetime'; +import type { useDashboardState } from './useDashboardState'; +import type { Bbox } from './useDashboardState'; + +const AIS_API_BASE = (import.meta.env.VITE_API_URL || '/snp-api').replace(/\/$/, ''); + +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)}`; +} + +interface DashboardSidebarProps { + state: ReturnType; + // Derived data + legacyVesselsAll: DerivedLegacyVessel[]; + legacyVesselsFiltered: DerivedLegacyVessel[]; + legacyCounts: Record; + selectedLegacyVessel: DerivedLegacyVessel | null; + activeHighlightedMmsiSet: number[]; + legacyHits: Map; + filteredAlarms: LegacyAlarm[]; + alarms: LegacyAlarm[]; + alarmKindCounts: Record; + allAlarmKindsEnabled: boolean; + alarmFilterSummary: string; + speedPanelType: VesselTypeCode; + onFleetContextMenu: (ownerKey: string, mmsis: number[]) => void; + // Data fetching (admin panels) + snapshot: AisPollingSnapshot; + legacyError: string | null; + legacyData: LegacyVesselDataset | null; + targetsInScope: AisTarget[]; + zonesError: string | null; + zones: ZonesGeoJson | null; + legacyIndex: LegacyVesselIndex | null; +} + +export function DashboardSidebar({ + state, + legacyVesselsAll, + legacyVesselsFiltered, + legacyCounts, + selectedLegacyVessel, + activeHighlightedMmsiSet, + legacyHits, + filteredAlarms, + alarms, + alarmKindCounts, + allAlarmKindsEnabled, + alarmFilterSummary, + speedPanelType, + onFleetContextMenu, + snapshot, + legacyError, + legacyData, + targetsInScope, + zonesError, + zones, + legacyIndex, +}: DashboardSidebarProps) { + const { + showTargets, setShowTargets, showOthers, setShowOthers, + typeEnabled, setTypeEnabled, + overlays, setOverlays, + projection, setProjection, isProjectionToggleDisabled, + selectedMmsi, setSelectedMmsi, + fleetRelationSortMode, setFleetRelationSortMode, + hoveredFleetOwnerKey, hoveredFleetMmsiSet, + setHoveredMmsiSet, setHoveredPairMmsiSet, + setHoveredFleetOwnerKey, setHoveredFleetMmsiSet, + alarmKindEnabled, setAlarmKindEnabled, + adminMode, + viewBbox, useViewportFilter, setUseViewportFilter, + useApiBbox, setUseApiBbox, setApiBbox, + settings, setSettings, + setUniqueSorted, setSortedIfChanged, toggleHighlightedMmsi, + } = state; + + 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 +
+
+ { + 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; + 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] }))} /> +
+ +
+
속도 프로파일
+ +
+ +
+
+
+ 선단 연관관계{' '} + + {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={onFleetContextMenu} + /> +
+
+ +
+
+ 선박 목록{' '} + + ({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} +
+ ); +} diff --git a/apps/web/src/pages/dashboard/useDashboardState.ts b/apps/web/src/pages/dashboard/useDashboardState.ts new file mode 100644 index 0000000..f3e4034 --- /dev/null +++ b/apps/web/src/pages/dashboard/useDashboardState.ts @@ -0,0 +1,147 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { usePersistedState } from '../../shared/hooks'; +import type { VesselTypeCode } from '../../entities/vessel/model/types'; +import type { MapToggleState } from '../../features/mapToggles/MapToggles'; +import type { LegacyAlarmKind } from '../../features/legacyDashboard/model/types'; +import { LEGACY_ALARM_KINDS } from '../../features/legacyDashboard/model/types'; +import type { BaseMapId, Map3DSettings, MapProjectionId } from '../../widgets/map3d/Map3D'; +import type { MapViewState } from '../../widgets/map3d/types'; +import { DEFAULT_MAP_STYLE_SETTINGS } from '../../features/mapSettings/types'; +import type { MapStyleSettings } from '../../features/mapSettings/types'; +import { fmtDateTimeFull } from '../../shared/lib/datetime'; + +export type Bbox = [number, number, number, number]; +export type FleetRelationSortMode = 'count' | 'range'; + +export function useDashboardState(uid: number | null) { + // ── Map instance ── + const [mapInstance, setMapInstance] = useState(null); + const handleMapReady = useCallback((map: import('maplibre-gl').Map) => setMapInstance(map), []); + + // ── Viewport / API BBox ── + const [viewBbox, setViewBbox] = useState(null); + const [useViewportFilter, setUseViewportFilter] = useState(false); + const [useApiBbox, setUseApiBbox] = useState(false); + const [apiBbox, setApiBbox] = useState(undefined); + + // ── Selection & hover ── + 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); + + // ── Filters (persisted) ── + 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); + + // ── Map settings (persisted) ── + // 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용 + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [baseMap, _setBaseMap] = useState('enhanced'); + 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 [settings, setSettings] = usePersistedState(uid, 'map3dSettings', { + showShips: true, showDensity: false, showSeamark: false, + }); + const [mapView, setMapView] = usePersistedState(uid, 'mapView', null); + + // ── Sort & alarm filters (persisted) ── + const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState(uid, 'sortMode', 'count'); + const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState>( + uid, 'alarmKindEnabled', + () => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record, + ); + + // ── Fleet focus ── + const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined); + + // ── Cable ── + const [hoveredCableId, setHoveredCableId] = useState(null); + const [selectedCableId, setSelectedCableId] = useState(null); + + // ── Track context menu ── + 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), []); + + // ── Projection loading ── + const [isProjectionLoading, setIsProjectionLoading] = useState(false); + const [isGlobeShipsReady, setIsGlobeShipsReady] = useState(false); + const handleProjectionLoadingChange = useCallback((loading: boolean) => setIsProjectionLoading(loading), []); + const showMapLoader = isProjectionLoading; + const isProjectionToggleDisabled = !isGlobeShipsReady || isProjectionLoading; + + // ── Clock ── + const [clock, setClock] = useState(() => fmtDateTimeFull(new Date())); + useEffect(() => { + const id = window.setInterval(() => setClock(fmtDateTimeFull(new Date())), 1000); + return () => window.clearInterval(id); + }, []); + + // ── Admin mode (7 clicks within 900ms) ── + 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); + } + }; + + // ── Helpers ── + 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 toggleHighlightedMmsi = (mmsi: number) => { + setHighlightedMmsiSet((prev) => { + const s = new Set(prev); + if (s.has(mmsi)) s.delete(mmsi); + else s.add(mmsi); + return Array.from(s).sort((a, b) => a - b); + }); + }; + + return { + mapInstance, handleMapReady, + viewBbox, setViewBbox, useViewportFilter, setUseViewportFilter, + useApiBbox, setUseApiBbox, apiBbox, setApiBbox, + selectedMmsi, setSelectedMmsi, + highlightedMmsiSet, + hoveredMmsiSet, setHoveredMmsiSet, + hoveredFleetMmsiSet, setHoveredFleetMmsiSet, + hoveredPairMmsiSet, setHoveredPairMmsiSet, + hoveredFleetOwnerKey, setHoveredFleetOwnerKey, + typeEnabled, setTypeEnabled, showTargets, setShowTargets, showOthers, setShowOthers, + baseMap, projection, setProjection, + mapStyleSettings, setMapStyleSettings, + overlays, setOverlays, settings, setSettings, + mapView, setMapView, + fleetRelationSortMode, setFleetRelationSortMode, + alarmKindEnabled, setAlarmKindEnabled, + fleetFocus, setFleetFocus, + hoveredCableId, setHoveredCableId, selectedCableId, setSelectedCableId, + trackContextMenu, handleOpenTrackMenu, handleCloseTrackMenu, + handleProjectionLoadingChange, + isGlobeShipsReady, setIsGlobeShipsReady, + showMapLoader, isProjectionToggleDisabled, + clock, adminMode, onLogoClick, + setUniqueSorted, setSortedIfChanged, toggleHighlightedMmsi, + }; +}