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:
부모
4d67b26ffa
커밋
ec03a88fbd
@ -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 ? (
|
||||
|
||||
430
apps/web/src/pages/dashboard/DashboardSidebar.tsx
Normal file
430
apps/web/src/pages/dashboard/DashboardSidebar.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
147
apps/web/src/pages/dashboard/useDashboardState.ts
Normal file
147
apps/web/src/pages/dashboard/useDashboardState.ts
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user