refactor(dashboard): 사이드바 + 상태 훅 추출 분리

DashboardPage.tsx (808줄) → 3파일 분리:
- useDashboardState.ts (147줄): UI 상태 관리 훅
- DashboardSidebar.tsx (430줄): 좌측 사이드바 컴포넌트
- DashboardPage.tsx (295줄): 레이아웃 + 지도 영역

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
htlee 2026-02-16 23:55:58 +09:00
부모 4d67b26ffa
커밋 ec03a88fbd
3개의 변경된 파일658개의 추가작업 그리고 514개의 파일을 삭제

파일 보기

@ -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<import("maplibre-gl").Map | null>(null);
const weatherOverlay = useWeatherOverlay(mapInstance);
const handleMapReady = useCallback((map: import("maplibre-gl").Map) => {
setMapInstance(map);
}, []);
const [viewBbox, setViewBbox] = useState<Bbox | null>(null);
const [useViewportFilter, setUseViewportFilter] = useState(false);
const [useApiBbox, setUseApiBbox] = useState(false);
const [apiBbox, setApiBbox] = useState<string | undefined>(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<number | null>(null);
const [highlightedMmsiSet, setHighlightedMmsiSet] = useState<number[]>([]);
const [hoveredMmsiSet, setHoveredMmsiSet] = useState<number[]>([]);
const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState<number[]>([]);
const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState<number[]>([]);
const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState<string | null>(null);
const uid = user?.id ?? null;
const [typeEnabled, setTypeEnabled] = usePersistedState<Record<VesselTypeCode, boolean>>(
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<BaseMapId>("enhanced");
// 항상 mercator로 시작 — 3D 전환은 globe 레이어 준비 후 사용자가 수동 전환
const [projection, setProjection] = useState<MapProjectionId>('mercator');
const [mapStyleSettings, setMapStyleSettings] = usePersistedState<MapStyleSettings>(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS);
const [overlays, setOverlays] = usePersistedState<MapToggleState>(uid, 'overlays', {
pairLines: true, pairRange: true, fcLines: true, zones: true,
fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false,
});
const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState<FleetRelationSortMode>(uid, 'sortMode', "count");
const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState<Record<LegacyAlarmKind, boolean>>(
uid, 'alarmKindEnabled',
() => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record<LegacyAlarmKind, boolean>,
);
const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined);
const [hoveredCableId, setHoveredCableId] = useState<string | null>(null);
const [selectedCableId, setSelectedCableId] = useState<string | null>(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<Map3DSettings>(uid, 'map3dSettings', {
showShips: true, showDensity: false, showSeamark: false,
});
const [mapView, setMapView] = usePersistedState<MapViewState | null>(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<number[]>([]);
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<LegacyAlarmKind, number>;
const base = Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, 0])) as Record<typeof LEGACY_ALARM_KINDS[number], number>;
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 (
<div className="app">
<Topbar
@ -353,370 +261,29 @@ export function DashboardPage() {
onLogout={logout}
/>
<div className="sidebar">
<div className="sb">
<div className="sb-t"> </div>
<div className="tog">
<div
className={`tog-btn ${showTargets ? "on" : ""}`}
onClick={() => {
setShowTargets((v) => {
const next = !v;
if (!next) setSelectedMmsi((m) => (legacyHits.has(m ?? -1) ? null : m));
return next;
});
}}
title="레거시(CN permit) 대상 선박 표시"
>
</div>
<div className={`tog-btn ${showOthers ? "on" : ""}`} onClick={() => setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시">
AIS
</div>
</div>
<TypeFilterGrid
enabled={typeEnabled}
totalCount={legacyVesselsAll.length}
countsByType={legacyCounts}
onToggle={(code) => {
// 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,
});
}}
/>
</div>
<div className="sb">
<div className="sb-t" style={{ display: "flex", alignItems: "center" }}>
<div style={{ flex: 1 }} />
<div
className={`tog-btn ${projection === "globe" ? "on" : ""}${isProjectionToggleDisabled ? " disabled" : ""}`}
onClick={isProjectionToggleDisabled ? undefined : () => 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
</div>
</div>
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
{/* enhanced ,
<div className="tog" style={{ flexWrap: "nowrap", alignItems: "center", marginTop: 8 }}>
<div className={`tog-btn ${baseMap === "enhanced" ? "on" : ""}`} onClick={() => setBaseMap("enhanced")} title="현재 지도(해저지형/3D 표현 포함)">
</div>
<div className={`tog-btn ${baseMap === "legacy" ? "on" : ""}`} onClick={() => setBaseMap("legacy")} title="레거시 대시보드(Carto Dark) 베이스맵">
</div>
</div> */}
</div>
<div className="sb">
<div className="sb-t"> </div>
<SpeedProfilePanel selectedType={speedPanelType} />
</div>
<div className="sb" style={{ maxHeight: 260, display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div className="sb-t sb-t-row">
<div>
{" "}
<span style={{ color: "var(--accent)", fontSize: 8 }}>
{selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : "(선박 클릭 시 표시)"}
</span>
</div>
<div className="relation-sort">
<label className="relation-sort__option">
<input
type="radio"
name="fleet-relation-sort"
checked={fleetRelationSortMode === "count"}
onChange={() => setFleetRelationSortMode("count")}
/>
</label>
<label className="relation-sort__option">
<input
type="radio"
name="fleet-relation-sort"
checked={fleetRelationSortMode === "range"}
onChange={() => setFleetRelationSortMode("range")}
/>
</label>
</div>
</div>
<div style={{ overflowY: "auto", minHeight: 0 }}>
<RelationsPanel
selectedVessel={selectedLegacyVessel}
vessels={legacyVesselsAll}
fleetVessels={legacyVesselsFiltered}
onSelectMmsi={setSelectedMmsi}
onToggleHighlightMmsi={toggleHighlightedMmsi}
onHoverMmsi={(mmsis) => 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}
/>
</div>
</div>
<div className="sb" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<div className="sb-t">
{" "}
<span style={{ color: "var(--accent)", fontSize: 8 }}>
({legacyVesselsFiltered.length})
</span>
</div>
<VesselList
vessels={legacyVesselsFiltered}
selectedMmsi={selectedMmsi}
highlightedMmsiSet={activeHighlightedMmsiSet}
onSelectMmsi={setSelectedMmsi}
onToggleHighlightMmsi={toggleHighlightedMmsi}
onHoverMmsi={(mmsi) => setHoveredMmsiSet([mmsi])}
onClearHover={() => setHoveredMmsiSet([])}
/>
</div>
<div className="sb" style={{ maxHeight: 130, display: "flex", flexDirection: "column", overflow: "visible" }}>
<div className="sb-t sb-t-row" style={{ marginBottom: 6 }}>
<div>
{" "}
<span style={{ color: "var(--accent)", fontSize: 8 }}>
({filteredAlarms.length}/{alarms.length})
</span>
</div>
{LEGACY_ALARM_KINDS.length <= 3 ? (
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
{LEGACY_ALARM_KINDS.map((k) => (
<label key={k} style={{ display: "inline-flex", gap: 4, alignItems: "center", cursor: "pointer", userSelect: "none" }}>
<input
type="checkbox"
checked={!!alarmKindEnabled[k]}
onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))}
/>
<span style={{ fontSize: 8, color: "var(--muted)", whiteSpace: "nowrap" }}>{LEGACY_ALARM_KIND_LABEL[k]}</span>
</label>
))}
</div>
) : (
<details className="alarm-filter">
<summary className="alarm-filter__summary" title="경고 종류 필터">
{alarmFilterSummary}
</summary>
<div className="alarm-filter__menu" role="menu" aria-label="alarm kind filter">
<label className="alarm-filter__row">
<input
type="checkbox"
checked={allAlarmKindsEnabled}
onChange={() =>
setAlarmKindEnabled((prev) => {
const allOn = LEGACY_ALARM_KINDS.every((k) => prev[k]);
const nextVal = allOn ? false : true;
return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, nextVal])) as Record<LegacyAlarmKind, boolean>;
})
}
/>
<span className="alarm-filter__cnt">{alarms.length}</span>
</label>
<div className="alarm-filter__sep" />
{LEGACY_ALARM_KINDS.map((k) => (
<label key={k} className="alarm-filter__row">
<input
type="checkbox"
checked={!!alarmKindEnabled[k]}
onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))}
/>
{LEGACY_ALARM_KIND_LABEL[k]}
<span className="alarm-filter__cnt">{alarmKindCounts[k] ?? 0}</span>
</label>
))}
</div>
</details>
)}
</div>
<div style={{ overflowY: "auto", minHeight: 0, flex: 1 }}>
<AlarmsPanel alarms={filteredAlarms} onSelectMmsi={setSelectedMmsi} />
</div>
</div>
{adminMode ? (
<>
<div className="sb">
<div className="sb-t">ADMIN · AIS Target Polling</div>
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
<div style={{ color: "var(--muted)", fontSize: 10 }}></div>
<div style={{ wordBreak: "break-all" }}>{AIS_API_BASE}/api/ais-target/search</div>
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}></div>
<div>
<b style={{ color: snapshot.status === "ready" ? "#22C55E" : snapshot.status === "error" ? "#EF4444" : "#F59E0B" }}>
{snapshot.status.toUpperCase()}
</b>
{snapshot.error ? <span style={{ marginLeft: 6, color: "#EF4444" }}>{snapshot.error}</span> : null}
</div>
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}> fetch</div>
<div>
{fmtIsoFull(snapshot.lastFetchAt)}{" "}
<span style={{ color: "var(--muted)", fontSize: 10 }}>
({snapshot.lastFetchMinutes ?? "-"}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted})
</span>
</div>
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}></div>
<div style={{ color: "var(--text)", fontSize: 10 }}>{snapshot.lastMessage ?? "-"}</div>
</div>
</div>
<div className="sb">
<div className="sb-t">ADMIN · Legacy (CN Permit)</div>
{legacyError ? (
<div style={{ fontSize: 11, color: "#EF4444" }}>legacy load error: {legacyError}</div>
) : (
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
<div style={{ color: "var(--muted)", fontSize: 10 }}></div>
<div style={{ wordBreak: "break-all", fontSize: 10 }}>/data/legacy/chinese-permitted.v1.json</div>
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}>( scope)</div>
<div>
<b style={{ color: "#F59E0B" }}>{legacyVesselsAll.length}</b>{" "}
<span style={{ color: "var(--muted)", fontSize: 10 }}>/ {targetsInScope.length}</span>
</div>
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}></div>
<div style={{ fontSize: 10, color: "var(--text)" }}>{legacyData?.generatedAt ? fmtIsoFull(legacyData.generatedAt) : "loading..."}</div>
</div>
)}
</div>
<div className="sb">
<div className="sb-t">ADMIN · Viewport / BBox</div>
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
<div style={{ color: "var(--muted)", fontSize: 10 }}> View BBox</div>
<div style={{ wordBreak: "break-all", fontSize: 10 }}>{fmtBbox(viewBbox)}</div>
<div style={{ marginTop: 8, display: "flex", gap: 6, flexWrap: "wrap" }}>
<button
onClick={() => setUseViewportFilter((v) => !v)}
style={{
fontSize: 10,
padding: "4px 8px",
borderRadius: 6,
border: "1px solid var(--border)",
background: useViewportFilter ? "rgba(59,130,246,.18)" : "var(--card)",
color: "var(--text)",
cursor: "pointer",
}}
title="지도/리스트에 현재 화면 영역 내 선박만 표시(클라이언트 필터)"
>
Viewport filter {useViewportFilter ? "ON" : "OFF"}
</button>
<button
onClick={() => {
if (!viewBbox) return;
setUseApiBbox((v) => {
const next = !v;
if (next && viewBbox) setApiBbox(fmtBbox(viewBbox));
if (!next) setApiBbox(undefined);
return next;
});
}}
style={{
fontSize: 10,
padding: "4px 8px",
borderRadius: 6,
border: "1px solid var(--border)",
background: useApiBbox ? "rgba(245,158,11,.14)" : "var(--card)",
color: viewBbox ? "var(--text)" : "var(--muted)",
cursor: viewBbox ? "pointer" : "not-allowed",
}}
title="서버에서 bbox로 필터링해서 내려받기(페이로드 감소). 켜는 순간 현재 view bbox로 고정됨."
disabled={!viewBbox}
>
API bbox {useApiBbox ? "ON" : "OFF"}
</button>
<button
onClick={() => {
if (!viewBbox) return;
setApiBbox(fmtBbox(viewBbox));
setUseApiBbox(true);
}}
style={{
fontSize: 10,
padding: "4px 8px",
borderRadius: 6,
border: "1px solid var(--border)",
background: "var(--card)",
color: viewBbox ? "var(--text)" : "var(--muted)",
cursor: viewBbox ? "pointer" : "not-allowed",
}}
disabled={!viewBbox}
title="현재 view bbox로 API bbox를 갱신"
>
bbox=viewport
</button>
</div>
<div style={{ marginTop: 8, color: "var(--muted)", fontSize: 10 }}>
: <b style={{ color: "var(--text)" }}>{targetsInScope.length}</b> / :{" "}
<b style={{ color: "var(--text)" }}>{snapshot.total}</b>
</div>
</div>
</div>
<div className="sb">
<div className="sb-t">ADMIN · Map (Extras)</div>
<Map3DSettingsToggles value={settings} onToggle={(k) => setSettings((prev) => ({ ...prev, [k]: !prev[k] }))} />
<div style={{ fontSize: 10, color: "var(--muted)", marginTop: 6 }}> WebGL 컨텍스트: MapboxOverlay(interleaved)</div>
</div>
<div className="sb" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<div className="sb-t">ADMIN · AIS Targets (All)</div>
<AisTargetList
targets={targetsInScope}
selectedMmsi={selectedMmsi}
onSelectMmsi={setSelectedMmsi}
legacyIndex={legacyIndex}
/>
</div>
<div className="sb" style={{ maxHeight: 130, overflowY: "auto" }}>
<div className="sb-t">ADMIN · </div>
{zonesError ? (
<div style={{ fontSize: 11, color: "#EF4444" }}>zones load error: {zonesError}</div>
) : (
<div style={{ fontSize: 11, color: "var(--muted)" }}>
{zones ? `loaded (${zones.features.length} features)` : "loading..."}
</div>
)}
</div>
</>
) : null}
</div>
<DashboardSidebar
state={state}
legacyVesselsAll={legacyVesselsAll}
legacyVesselsFiltered={legacyVesselsFiltered}
legacyCounts={legacyCounts}
selectedLegacyVessel={selectedLegacyVessel}
activeHighlightedMmsiSet={activeHighlightedMmsiSet}
legacyHits={legacyHits}
filteredAlarms={filteredAlarms}
alarms={alarms}
alarmKindCounts={alarmKindCounts}
allAlarmKindsEnabled={allAlarmKindsEnabled}
alarmFilterSummary={alarmFilterSummary}
speedPanelType={speedPanelType}
onFleetContextMenu={handleFleetContextMenu}
snapshot={snapshot}
legacyError={legacyError}
legacyData={legacyData}
targetsInScope={targetsInScope}
zonesError={zonesError}
zones={zones}
legacyIndex={legacyIndex}
/>
<div className="map-area">
{showMapLoader ? (

파일 보기

@ -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<typeof useDashboardState>;
// Derived data
legacyVesselsAll: DerivedLegacyVessel[];
legacyVesselsFiltered: DerivedLegacyVessel[];
legacyCounts: Record<VesselTypeCode, number>;
selectedLegacyVessel: DerivedLegacyVessel | null;
activeHighlightedMmsiSet: number[];
legacyHits: Map<number, LegacyVesselInfo>;
filteredAlarms: LegacyAlarm[];
alarms: LegacyAlarm[];
alarmKindCounts: Record<LegacyAlarmKind, number>;
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 (
<div className="sidebar">
<div className="sb">
<div className="sb-t"> </div>
<div className="tog">
<div
className={`tog-btn ${showTargets ? 'on' : ''}`}
onClick={() => {
setShowTargets((v) => {
const next = !v;
if (!next) setSelectedMmsi((m) => (legacyHits.has(m ?? -1) ? null : m));
return next;
});
}}
title="레거시(CN permit) 대상 선박 표시"
>
</div>
<div className={`tog-btn ${showOthers ? 'on' : ''}`} onClick={() => setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시">
AIS
</div>
</div>
<TypeFilterGrid
enabled={typeEnabled}
totalCount={legacyVesselsAll.length}
countsByType={legacyCounts}
onToggle={(code) => {
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,
});
}}
/>
</div>
<div className="sb">
<div className="sb-t" style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ flex: 1 }} />
<div
className={`tog-btn ${projection === 'globe' ? 'on' : ''}${isProjectionToggleDisabled ? ' disabled' : ''}`}
onClick={isProjectionToggleDisabled ? undefined : () => 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
</div>
</div>
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
</div>
<div className="sb">
<div className="sb-t"> </div>
<SpeedProfilePanel selectedType={speedPanelType} />
</div>
<div className="sb" style={{ maxHeight: 260, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div className="sb-t sb-t-row">
<div>
{' '}
<span style={{ color: 'var(--accent)', fontSize: 8 }}>
{selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : '(선박 클릭 시 표시)'}
</span>
</div>
<div className="relation-sort">
<label className="relation-sort__option">
<input type="radio" name="fleet-relation-sort" checked={fleetRelationSortMode === 'count'} onChange={() => setFleetRelationSortMode('count')} />
</label>
<label className="relation-sort__option">
<input type="radio" name="fleet-relation-sort" checked={fleetRelationSortMode === 'range'} onChange={() => setFleetRelationSortMode('range')} />
</label>
</div>
</div>
<div style={{ overflowY: 'auto', minHeight: 0 }}>
<RelationsPanel
selectedVessel={selectedLegacyVessel}
vessels={legacyVesselsAll}
fleetVessels={legacyVesselsFiltered}
onSelectMmsi={setSelectedMmsi}
onToggleHighlightMmsi={toggleHighlightedMmsi}
onHoverMmsi={(mmsis) => 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}
/>
</div>
</div>
<div className="sb" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div className="sb-t">
{' '}
<span style={{ color: 'var(--accent)', fontSize: 8 }}>
({legacyVesselsFiltered.length})
</span>
</div>
<VesselList
vessels={legacyVesselsFiltered}
selectedMmsi={selectedMmsi}
highlightedMmsiSet={activeHighlightedMmsiSet}
onSelectMmsi={setSelectedMmsi}
onToggleHighlightMmsi={toggleHighlightedMmsi}
onHoverMmsi={(mmsi) => setHoveredMmsiSet([mmsi])}
onClearHover={() => setHoveredMmsiSet([])}
/>
</div>
<div className="sb" style={{ maxHeight: 130, display: 'flex', flexDirection: 'column', overflow: 'visible' }}>
<div className="sb-t sb-t-row" style={{ marginBottom: 6 }}>
<div>
{' '}
<span style={{ color: 'var(--accent)', fontSize: 8 }}>
({filteredAlarms.length}/{alarms.length})
</span>
</div>
{LEGACY_ALARM_KINDS.length <= 3 ? (
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
{LEGACY_ALARM_KINDS.map((k) => (
<label key={k} style={{ display: 'inline-flex', gap: 4, alignItems: 'center', cursor: 'pointer', userSelect: 'none' }}>
<input
type="checkbox"
checked={!!alarmKindEnabled[k]}
onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))}
/>
<span style={{ fontSize: 8, color: 'var(--muted)', whiteSpace: 'nowrap' }}>{LEGACY_ALARM_KIND_LABEL[k]}</span>
</label>
))}
</div>
) : (
<details className="alarm-filter">
<summary className="alarm-filter__summary" title="경고 종류 필터">
{alarmFilterSummary}
</summary>
<div className="alarm-filter__menu" role="menu" aria-label="alarm kind filter">
<label className="alarm-filter__row">
<input
type="checkbox"
checked={allAlarmKindsEnabled}
onChange={() =>
setAlarmKindEnabled((prev) => {
const allOn = LEGACY_ALARM_KINDS.every((k) => prev[k]);
const nextVal = allOn ? false : true;
return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, nextVal])) as Record<LegacyAlarmKind, boolean>;
})
}
/>
<span className="alarm-filter__cnt">{alarms.length}</span>
</label>
<div className="alarm-filter__sep" />
{LEGACY_ALARM_KINDS.map((k) => (
<label key={k} className="alarm-filter__row">
<input
type="checkbox"
checked={!!alarmKindEnabled[k]}
onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))}
/>
{LEGACY_ALARM_KIND_LABEL[k]}
<span className="alarm-filter__cnt">{alarmKindCounts[k] ?? 0}</span>
</label>
))}
</div>
</details>
)}
</div>
<div style={{ overflowY: 'auto', minHeight: 0, flex: 1 }}>
<AlarmsPanel alarms={filteredAlarms} onSelectMmsi={setSelectedMmsi} />
</div>
</div>
{adminMode ? (
<>
<div className="sb">
<div className="sb-t">ADMIN · AIS Target Polling</div>
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
<div style={{ color: 'var(--muted)', fontSize: 10 }}></div>
<div style={{ wordBreak: 'break-all' }}>{AIS_API_BASE}/api/ais-target/search</div>
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}></div>
<div>
<b style={{ color: snapshot.status === 'ready' ? '#22C55E' : snapshot.status === 'error' ? '#EF4444' : '#F59E0B' }}>
{snapshot.status.toUpperCase()}
</b>
{snapshot.error ? <span style={{ marginLeft: 6, color: '#EF4444' }}>{snapshot.error}</span> : null}
</div>
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}> fetch</div>
<div>
{fmtIsoFull(snapshot.lastFetchAt)}{' '}
<span style={{ color: 'var(--muted)', fontSize: 10 }}>
({snapshot.lastFetchMinutes ?? '-'}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted})
</span>
</div>
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}></div>
<div style={{ color: 'var(--text)', fontSize: 10 }}>{snapshot.lastMessage ?? '-'}</div>
</div>
</div>
<div className="sb">
<div className="sb-t">ADMIN · Legacy (CN Permit)</div>
{legacyError ? (
<div style={{ fontSize: 11, color: '#EF4444' }}>legacy load error: {legacyError}</div>
) : (
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
<div style={{ color: 'var(--muted)', fontSize: 10 }}></div>
<div style={{ wordBreak: 'break-all', fontSize: 10 }}>/data/legacy/chinese-permitted.v1.json</div>
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>( scope)</div>
<div>
<b style={{ color: '#F59E0B' }}>{legacyVesselsAll.length}</b>{' '}
<span style={{ color: 'var(--muted)', fontSize: 10 }}>/ {targetsInScope.length}</span>
</div>
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}></div>
<div style={{ fontSize: 10, color: 'var(--text)' }}>{legacyData?.generatedAt ? fmtIsoFull(legacyData.generatedAt) : 'loading...'}</div>
</div>
)}
</div>
<div className="sb">
<div className="sb-t">ADMIN · Viewport / BBox</div>
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
<div style={{ color: 'var(--muted)', fontSize: 10 }}> View BBox</div>
<div style={{ wordBreak: 'break-all', fontSize: 10 }}>{fmtBbox(viewBbox)}</div>
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button
onClick={() => setUseViewportFilter((v) => !v)}
style={{
fontSize: 10, padding: '4px 8px', borderRadius: 6,
border: '1px solid var(--border)',
background: useViewportFilter ? 'rgba(59,130,246,.18)' : 'var(--card)',
color: 'var(--text)', cursor: 'pointer',
}}
title="지도/리스트에 현재 화면 영역 내 선박만 표시(클라이언트 필터)"
>
Viewport filter {useViewportFilter ? 'ON' : 'OFF'}
</button>
<button
onClick={() => {
if (!viewBbox) return;
setUseApiBbox((v) => {
const next = !v;
if (next && viewBbox) setApiBbox(fmtBbox(viewBbox));
if (!next) setApiBbox(undefined);
return next;
});
}}
style={{
fontSize: 10, padding: '4px 8px', borderRadius: 6,
border: '1px solid var(--border)',
background: useApiBbox ? 'rgba(245,158,11,.14)' : 'var(--card)',
color: viewBbox ? 'var(--text)' : 'var(--muted)',
cursor: viewBbox ? 'pointer' : 'not-allowed',
}}
title="서버에서 bbox로 필터링해서 내려받기(페이로드 감소). 켜는 순간 현재 view bbox로 고정됨."
disabled={!viewBbox}
>
API bbox {useApiBbox ? 'ON' : 'OFF'}
</button>
<button
onClick={() => {
if (!viewBbox) return;
setApiBbox(fmtBbox(viewBbox));
setUseApiBbox(true);
}}
style={{
fontSize: 10, padding: '4px 8px', borderRadius: 6,
border: '1px solid var(--border)',
background: 'var(--card)',
color: viewBbox ? 'var(--text)' : 'var(--muted)',
cursor: viewBbox ? 'pointer' : 'not-allowed',
}}
disabled={!viewBbox}
title="현재 view bbox로 API bbox를 갱신"
>
bbox=viewport
</button>
</div>
<div style={{ marginTop: 8, color: 'var(--muted)', fontSize: 10 }}>
: <b style={{ color: 'var(--text)' }}>{targetsInScope.length}</b> / :{' '}
<b style={{ color: 'var(--text)' }}>{snapshot.total}</b>
</div>
</div>
</div>
<div className="sb">
<div className="sb-t">ADMIN · Map (Extras)</div>
<Map3DSettingsToggles value={settings} onToggle={(k) => setSettings((prev) => ({ ...prev, [k]: !prev[k] }))} />
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 6 }}> WebGL 컨텍스트: MapboxOverlay(interleaved)</div>
</div>
<div className="sb" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div className="sb-t">ADMIN · AIS Targets (All)</div>
<AisTargetList
targets={targetsInScope}
selectedMmsi={selectedMmsi}
onSelectMmsi={setSelectedMmsi}
legacyIndex={legacyIndex}
/>
</div>
<div className="sb" style={{ maxHeight: 130, overflowY: 'auto' }}>
<div className="sb-t">ADMIN · </div>
{zonesError ? (
<div style={{ fontSize: 11, color: '#EF4444' }}>zones load error: {zonesError}</div>
) : (
<div style={{ fontSize: 11, color: 'var(--muted)' }}>
{zones ? `loaded (${zones.features.length} features)` : 'loading...'}
</div>
)}
</div>
</>
) : null}
</div>
);
}

파일 보기

@ -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<import('maplibre-gl').Map | null>(null);
const handleMapReady = useCallback((map: import('maplibre-gl').Map) => setMapInstance(map), []);
// ── Viewport / API BBox ──
const [viewBbox, setViewBbox] = useState<Bbox | null>(null);
const [useViewportFilter, setUseViewportFilter] = useState(false);
const [useApiBbox, setUseApiBbox] = useState(false);
const [apiBbox, setApiBbox] = useState<string | undefined>(undefined);
// ── Selection & hover ──
const [selectedMmsi, setSelectedMmsi] = useState<number | null>(null);
const [highlightedMmsiSet, setHighlightedMmsiSet] = useState<number[]>([]);
const [hoveredMmsiSet, setHoveredMmsiSet] = useState<number[]>([]);
const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState<number[]>([]);
const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState<number[]>([]);
const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState<string | null>(null);
// ── Filters (persisted) ──
const [typeEnabled, setTypeEnabled] = usePersistedState<Record<VesselTypeCode, boolean>>(
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<BaseMapId>('enhanced');
const [projection, setProjection] = useState<MapProjectionId>('mercator');
const [mapStyleSettings, setMapStyleSettings] = usePersistedState<MapStyleSettings>(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS);
const [overlays, setOverlays] = usePersistedState<MapToggleState>(uid, 'overlays', {
pairLines: true, pairRange: true, fcLines: true, zones: true,
fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false,
});
const [settings, setSettings] = usePersistedState<Map3DSettings>(uid, 'map3dSettings', {
showShips: true, showDensity: false, showSeamark: false,
});
const [mapView, setMapView] = usePersistedState<MapViewState | null>(uid, 'mapView', null);
// ── Sort & alarm filters (persisted) ──
const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState<FleetRelationSortMode>(uid, 'sortMode', 'count');
const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState<Record<LegacyAlarmKind, boolean>>(
uid, 'alarmKindEnabled',
() => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record<LegacyAlarmKind, boolean>,
);
// ── Fleet focus ──
const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined);
// ── Cable ──
const [hoveredCableId, setHoveredCableId] = useState<string | null>(null);
const [selectedCableId, setSelectedCableId] = useState<string | null>(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<number[]>([]);
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,
};
}