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 { useAuth } from "../../shared/auth";
|
||||||
import { usePersistedState } from "../../shared/hooks";
|
|
||||||
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
|
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
|
||||||
import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles";
|
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
||||||
import type { MapToggleState } from "../../features/mapToggles/MapToggles";
|
import { LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types";
|
||||||
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 { useLegacyVessels } from "../../entities/legacyVessel/api/useLegacyVessels";
|
import { useLegacyVessels } from "../../entities/legacyVessel/api/useLegacyVessels";
|
||||||
import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
||||||
import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
|
||||||
import type { LegacyVesselDataset } from "../../entities/legacyVessel/model/types";
|
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 { useZones } from "../../entities/zone/api/useZones";
|
||||||
import { useSubcables } from "../../entities/subcable/api/useSubcables";
|
import { useSubcables } from "../../entities/subcable/api/useSubcables";
|
||||||
import type { VesselTypeCode } from "../../entities/vessel/model/types";
|
|
||||||
import { AisInfoPanel } from "../../widgets/aisInfo/AisInfoPanel";
|
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 { MapLegend } from "../../widgets/legend/MapLegend";
|
||||||
import { Map3D, type BaseMapId, type Map3DSettings, type MapProjectionId } from "../../widgets/map3d/Map3D";
|
import { Map3D } from "../../widgets/map3d/Map3D";
|
||||||
import type { MapViewState } from "../../widgets/map3d/types";
|
|
||||||
import { RelationsPanel } from "../../widgets/relations/RelationsPanel";
|
|
||||||
import { SpeedProfilePanel } from "../../widgets/speed/SpeedProfilePanel";
|
|
||||||
import { Topbar } from "../../widgets/topbar/Topbar";
|
import { Topbar } from "../../widgets/topbar/Topbar";
|
||||||
import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel";
|
import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel";
|
||||||
import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel";
|
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 { 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 { queryTrackByMmsi } from "../../features/trackReplay/services/trackQueryService";
|
||||||
import { useTrackQueryStore } from "../../features/trackReplay/stores/trackQueryStore";
|
import { useTrackQueryStore } from "../../features/trackReplay/stores/trackQueryStore";
|
||||||
import { GlobalTrackReplayPanel } from "../../widgets/trackReplay/GlobalTrackReplayPanel";
|
import { GlobalTrackReplayPanel } from "../../widgets/trackReplay/GlobalTrackReplayPanel";
|
||||||
@ -39,6 +24,7 @@ import { useWeatherPolling } from "../../features/weatherOverlay/useWeatherPolli
|
|||||||
import { useWeatherOverlay } from "../../features/weatherOverlay/useWeatherOverlay";
|
import { useWeatherOverlay } from "../../features/weatherOverlay/useWeatherOverlay";
|
||||||
import { WeatherPanel } from "../../widgets/weatherPanel/WeatherPanel";
|
import { WeatherPanel } from "../../widgets/weatherPanel/WeatherPanel";
|
||||||
import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel";
|
import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel";
|
||||||
|
import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
|
||||||
import {
|
import {
|
||||||
buildLegacyHitMap,
|
buildLegacyHitMap,
|
||||||
computeCountsByType,
|
computeCountsByType,
|
||||||
@ -49,18 +35,16 @@ import {
|
|||||||
deriveLegacyVessels,
|
deriveLegacyVessels,
|
||||||
filterByShipCodes,
|
filterByShipCodes,
|
||||||
} from "../../features/legacyDashboard/model/derive";
|
} 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 = {
|
const AIS_CENTER = {
|
||||||
lon: 126.95,
|
lon: 126.95,
|
||||||
lat: 35.95,
|
lat: 35.95,
|
||||||
radiusMeters: 2_000_000,
|
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) {
|
function inBbox(lon: number, lat: number, bbox: Bbox) {
|
||||||
const [lonMin, latMin, lonMax, latMax] = bbox;
|
const [lonMin, latMin, lonMax, latMax] = bbox;
|
||||||
if (lat < latMin || lat > latMax) return false;
|
if (lat < latMin || lat > latMax) return false;
|
||||||
@ -68,34 +52,56 @@ function inBbox(lon: number, lat: number, bbox: Bbox) {
|
|||||||
return lon >= lonMin || lon <= lonMax;
|
return lon >= lonMin || lon <= lonMax;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtBbox(b: Bbox | null) {
|
function useLegacyIndex(data: LegacyVesselDataset | 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 {
|
|
||||||
return useMemo(() => (data ? buildLegacyVesselIndex(data.vessels) : null), [data]);
|
return useMemo(() => (data ? buildLegacyVesselIndex(data.vessels) : null), [data]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
const uid = user?.id ?? null;
|
||||||
|
|
||||||
|
// ── Data fetching ──
|
||||||
const { data: zones, error: zonesError } = useZones();
|
const { data: zones, error: zonesError } = useZones();
|
||||||
const { data: legacyData, error: legacyError } = useLegacyVessels();
|
const { data: legacyData, error: legacyError } = useLegacyVessels();
|
||||||
const { data: subcableData } = useSubcables();
|
const { data: subcableData } = useSubcables();
|
||||||
const legacyIndex = useLegacyIndex(legacyData);
|
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 weather = useWeatherPolling(zones);
|
||||||
const [mapInstance, setMapInstance] = useState<import("maplibre-gl").Map | null>(null);
|
|
||||||
const weatherOverlay = useWeatherOverlay(mapInstance);
|
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({
|
const { targets, snapshot } = useAisTargetPolling({
|
||||||
chnprmshipMinutes: 120,
|
chnprmshipMinutes: 120,
|
||||||
incrementalMinutes: 2,
|
incrementalMinutes: 2,
|
||||||
@ -107,48 +113,7 @@ export function DashboardPage() {
|
|||||||
radiusMeters: useApiBbox ? undefined : AIS_CENTER.radiusMeters,
|
radiusMeters: useApiBbox ? undefined : AIS_CENTER.radiusMeters,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selectedMmsi, setSelectedMmsi] = useState<number | null>(null);
|
// ── Track request ──
|
||||||
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), []);
|
|
||||||
const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => {
|
const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => {
|
||||||
const trackStore = useTrackQueryStore.getState();
|
const trackStore = useTrackQueryStore.getState();
|
||||||
const queryKey = `${mmsi}:${minutes}:${Date.now()}`;
|
const queryKey = `${mmsi}:${minutes}:${Date.now()}`;
|
||||||
@ -172,40 +137,7 @@ export function DashboardPage() {
|
|||||||
}
|
}
|
||||||
}, [targets]);
|
}, [targets]);
|
||||||
|
|
||||||
const [settings, setSettings] = usePersistedState<Map3DSettings>(uid, 'map3dSettings', {
|
// ── Derived data ──
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const targetsInScope = useMemo(() => {
|
const targetsInScope = useMemo(() => {
|
||||||
if (!useViewportFilter || !viewBbox) return targets;
|
if (!useViewportFilter || !viewBbox) return targets;
|
||||||
return targets.filter((t) => typeof t.lon === "number" && typeof t.lat === "number" && inBbox(t.lon, t.lat, viewBbox));
|
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 alarms = useMemo(() => computeLegacyAlarms({ vessels: legacyVesselsAll, pairLinks: pairLinksAll, fcLinks: fcLinksAll }), [legacyVesselsAll, pairLinksAll, fcLinksAll]);
|
||||||
|
|
||||||
const alarmKindCounts = useMemo(() => {
|
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) {
|
for (const a of alarms) {
|
||||||
base[a.kind] = (base[a.kind] ?? 0) + 1;
|
base[a.kind] = (base[a.kind] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
return base;
|
return base;
|
||||||
}, [alarms]);
|
}, [alarms]);
|
||||||
|
|
||||||
const enabledAlarmKinds = useMemo(() => {
|
const enabledAlarmKinds = useMemo(() => LEGACY_ALARM_KINDS.filter((k) => alarmKindEnabled[k]), [alarmKindEnabled]);
|
||||||
return LEGACY_ALARM_KINDS.filter((k) => alarmKindEnabled[k]);
|
|
||||||
}, [alarmKindEnabled]);
|
|
||||||
|
|
||||||
const allAlarmKindsEnabled = enabledAlarmKinds.length === LEGACY_ALARM_KINDS.length;
|
const allAlarmKindsEnabled = enabledAlarmKinds.length === LEGACY_ALARM_KINDS.length;
|
||||||
|
|
||||||
const filteredAlarms = useMemo(() => {
|
const filteredAlarms = useMemo(() => {
|
||||||
@ -291,13 +220,12 @@ export function DashboardPage() {
|
|||||||
[highlightedMmsiSet, availableTargetMmsiSet],
|
[highlightedMmsiSet, availableTargetMmsiSet],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setUniqueSorted = (items: number[]) =>
|
const fishingCount = legacyVesselsAll.filter((v) => v.state.isFishing).length;
|
||||||
Array.from(new Set(items.filter((item) => Number.isFinite(item)))).sort((a, b) => a - b);
|
const transitCount = legacyVesselsAll.filter((v) => v.state.isTransit).length;
|
||||||
|
|
||||||
const setSortedIfChanged = (next: number[]) => {
|
const enabledTypes = useMemo(() => VESSEL_TYPE_ORDER.filter((c) => typeEnabled[c]), [typeEnabled]);
|
||||||
const sorted = setUniqueSorted(next);
|
const speedPanelType = (selectedLegacyVessel?.shipCode ?? (enabledTypes.length === 1 ? enabledTypes[0] : "PT")) as VesselTypeCode;
|
||||||
return (prev: number[]) => (prev.length === sorted.length && prev.every((v, i) => v === sorted[i]) ? prev : sorted);
|
const alarmFilterSummary = allAlarmKindsEnabled ? "전체" : `${enabledAlarmKinds.length}/${LEGACY_ALARM_KINDS.length}`;
|
||||||
};
|
|
||||||
|
|
||||||
const handleFleetContextMenu = (ownerKey: string, mmsis: number[]) => {
|
const handleFleetContextMenu = (ownerKey: string, mmsis: number[]) => {
|
||||||
if (!mmsis.length) return;
|
if (!mmsis.length) return;
|
||||||
@ -312,30 +240,10 @@ export function DashboardPage() {
|
|||||||
const sumLon = members.reduce((acc, v) => acc + v.lon, 0);
|
const sumLon = members.reduce((acc, v) => acc + v.lon, 0);
|
||||||
const sumLat = members.reduce((acc, v) => acc + v.lat, 0);
|
const sumLat = members.reduce((acc, v) => acc + v.lat, 0);
|
||||||
const center: [number, number] = [sumLon / members.length, sumLat / members.length];
|
const center: [number, number] = [sumLon / members.length, sumLat / members.length];
|
||||||
setFleetFocus({
|
setFleetFocus({ id: `${ownerKey}-${Date.now()}`, center, zoom: 9 });
|
||||||
id: `${ownerKey}-${Date.now()}`,
|
|
||||||
center,
|
|
||||||
zoom: 9,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleHighlightedMmsi = (mmsi: number) => {
|
// ── Render ──
|
||||||
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}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<Topbar
|
<Topbar
|
||||||
@ -353,370 +261,29 @@ export function DashboardPage() {
|
|||||||
onLogout={logout}
|
onLogout={logout}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="sidebar">
|
<DashboardSidebar
|
||||||
<div className="sb">
|
state={state}
|
||||||
<div className="sb-t">업종 필터</div>
|
legacyVesselsAll={legacyVesselsAll}
|
||||||
<div className="tog">
|
legacyVesselsFiltered={legacyVesselsFiltered}
|
||||||
<div
|
legacyCounts={legacyCounts}
|
||||||
className={`tog-btn ${showTargets ? "on" : ""}`}
|
selectedLegacyVessel={selectedLegacyVessel}
|
||||||
onClick={() => {
|
activeHighlightedMmsiSet={activeHighlightedMmsiSet}
|
||||||
setShowTargets((v) => {
|
legacyHits={legacyHits}
|
||||||
const next = !v;
|
filteredAlarms={filteredAlarms}
|
||||||
if (!next) setSelectedMmsi((m) => (legacyHits.has(m ?? -1) ? null : m));
|
alarms={alarms}
|
||||||
return next;
|
alarmKindCounts={alarmKindCounts}
|
||||||
});
|
allAlarmKindsEnabled={allAlarmKindsEnabled}
|
||||||
}}
|
alarmFilterSummary={alarmFilterSummary}
|
||||||
title="레거시(CN permit) 대상 선박 표시"
|
speedPanelType={speedPanelType}
|
||||||
>
|
onFleetContextMenu={handleFleetContextMenu}
|
||||||
대상 선박
|
snapshot={snapshot}
|
||||||
</div>
|
legacyError={legacyError}
|
||||||
<div className={`tog-btn ${showOthers ? "on" : ""}`} onClick={() => setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시">
|
legacyData={legacyData}
|
||||||
기타 AIS
|
targetsInScope={targetsInScope}
|
||||||
</div>
|
zonesError={zonesError}
|
||||||
</div>
|
zones={zones}
|
||||||
<TypeFilterGrid
|
legacyIndex={legacyIndex}
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="map-area">
|
<div className="map-area">
|
||||||
{showMapLoader ? (
|
{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