- Mercator/Globe track-replay 레이어 충돌 및 setProps 레이스 해결 - track DTO 좌표/시간 정규화 + stale query 응답 무시 - 조회 직후 표시 안정화 및 기본 100x 자동재생 적용 - Global Track Replay 패널 초기 위치 조정 + 헤더 드래그 지원 - liveRenderer batch rendering + trackReplay store 기반 구조 반영 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
809 lines
38 KiB
TypeScript
809 lines
38 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } 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 { 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 { 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 { 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";
|
|
import { useWeatherPolling } from "../../features/weatherOverlay/useWeatherPolling";
|
|
import { useWeatherOverlay } from "../../features/weatherOverlay/useWeatherOverlay";
|
|
import { WeatherPanel } from "../../widgets/weatherPanel/WeatherPanel";
|
|
import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel";
|
|
import {
|
|
buildLegacyHitMap,
|
|
computeCountsByType,
|
|
computeFcLinks,
|
|
computeFleetCircles,
|
|
computeLegacyAlarms,
|
|
computePairLinks,
|
|
deriveLegacyVessels,
|
|
filterByShipCodes,
|
|
} from "../../features/legacyDashboard/model/derive";
|
|
import { VESSEL_TYPE_ORDER } from "../../entities/vessel/model/meta";
|
|
|
|
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;
|
|
if (lonMin <= lonMax) return lon >= lonMin && lon <= lonMax;
|
|
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 {
|
|
return useMemo(() => (data ? buildLegacyVesselIndex(data.vessels) : null), [data]);
|
|
}
|
|
|
|
export function DashboardPage() {
|
|
const { user, logout } = useAuth();
|
|
const { data: zones, error: zonesError } = useZones();
|
|
const { data: legacyData, error: legacyError } = useLegacyVessels();
|
|
const { data: subcableData } = useSubcables();
|
|
const legacyIndex = useLegacyIndex(legacyData);
|
|
|
|
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);
|
|
|
|
const { targets, snapshot } = useAisTargetPolling({
|
|
chnprmshipMinutes: 120,
|
|
incrementalMinutes: 2,
|
|
intervalMs: 60_000,
|
|
retentionMinutes: 120,
|
|
bbox: useApiBbox ? apiBbox : undefined,
|
|
centerLon: useApiBbox ? undefined : AIS_CENTER.lon,
|
|
centerLat: useApiBbox ? undefined : AIS_CENTER.lat,
|
|
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), []);
|
|
const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => {
|
|
const trackStore = useTrackQueryStore.getState();
|
|
const queryKey = `${mmsi}:${minutes}:${Date.now()}`;
|
|
trackStore.beginQuery(queryKey);
|
|
|
|
try {
|
|
const target = targets.find((item) => item.mmsi === mmsi);
|
|
const tracks = await queryTrackByMmsi({
|
|
mmsi,
|
|
minutes,
|
|
shipNameHint: target?.name,
|
|
});
|
|
|
|
if (tracks.length > 0) {
|
|
trackStore.applyTracksSuccess(tracks, queryKey);
|
|
} else {
|
|
trackStore.applyQueryError('항적 데이터가 없습니다.', queryKey);
|
|
}
|
|
} catch (e) {
|
|
trackStore.applyQueryError(e instanceof Error ? e.message : '항적 조회에 실패했습니다.', queryKey);
|
|
}
|
|
}, [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);
|
|
}
|
|
};
|
|
|
|
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));
|
|
}, [targets, useViewportFilter, viewBbox]);
|
|
|
|
const legacyHits = useMemo(() => buildLegacyHitMap(targetsInScope, legacyIndex), [targetsInScope, legacyIndex]);
|
|
const legacyVesselsAll = useMemo(() => deriveLegacyVessels({ targets: targetsInScope, legacyHits, zones }), [targetsInScope, legacyHits, zones]);
|
|
const legacyCounts = useMemo(() => computeCountsByType(legacyVesselsAll), [legacyVesselsAll]);
|
|
|
|
const legacyVesselsFiltered = useMemo(() => {
|
|
if (!showTargets) return [];
|
|
return filterByShipCodes(legacyVesselsAll, typeEnabled);
|
|
}, [legacyVesselsAll, showTargets, typeEnabled]);
|
|
|
|
const legacyMmsiForMap = useMemo(() => new Set(legacyVesselsFiltered.map((v) => v.mmsi)), [legacyVesselsFiltered]);
|
|
|
|
const targetsForMap = useMemo(() => {
|
|
const out = [];
|
|
for (const t of targetsInScope) {
|
|
const mmsi = t.mmsi;
|
|
if (typeof mmsi !== "number") continue;
|
|
const isLegacy = legacyHits.has(mmsi);
|
|
if (isLegacy) {
|
|
if (!showTargets) continue;
|
|
if (!legacyMmsiForMap.has(mmsi)) continue;
|
|
out.push(t);
|
|
continue;
|
|
}
|
|
if (showOthers) out.push(t);
|
|
}
|
|
return out;
|
|
}, [targetsInScope, legacyHits, showTargets, showOthers, legacyMmsiForMap]);
|
|
|
|
const pairLinksAll = useMemo(() => computePairLinks(legacyVesselsAll), [legacyVesselsAll]);
|
|
const fcLinksAll = useMemo(() => computeFcLinks(legacyVesselsAll), [legacyVesselsAll]);
|
|
const alarms = useMemo(() => computeLegacyAlarms({ vessels: legacyVesselsAll, pairLinks: pairLinksAll, fcLinks: fcLinksAll }), [legacyVesselsAll, pairLinksAll, fcLinksAll]);
|
|
|
|
const alarmKindCounts = useMemo(() => {
|
|
const base = Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, 0])) as Record<LegacyAlarmKind, 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 allAlarmKindsEnabled = enabledAlarmKinds.length === LEGACY_ALARM_KINDS.length;
|
|
|
|
const filteredAlarms = useMemo(() => {
|
|
if (allAlarmKindsEnabled) return alarms;
|
|
const enabled = new Set(enabledAlarmKinds);
|
|
return alarms.filter((a) => enabled.has(a.kind));
|
|
}, [alarms, enabledAlarmKinds, allAlarmKindsEnabled]);
|
|
|
|
const pairLinksForMap = useMemo(() => computePairLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
|
const fcLinksForMap = useMemo(() => computeFcLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
|
const fleetCirclesForMap = useMemo(() => computeFleetCircles(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
|
|
|
const selectedLegacyVessel = useMemo(() => {
|
|
if (!selectedMmsi) return null;
|
|
return legacyVesselsAll.find((v) => v.mmsi === selectedMmsi) ?? null;
|
|
}, [legacyVesselsAll, selectedMmsi]);
|
|
|
|
const selectedTarget = useMemo(() => {
|
|
if (!selectedMmsi) return null;
|
|
return targetsInScope.find((t) => t.mmsi === selectedMmsi) ?? null;
|
|
}, [targetsInScope, selectedMmsi]);
|
|
|
|
const selectedLegacyInfo = useMemo(() => {
|
|
if (!selectedMmsi) return null;
|
|
return legacyHits.get(selectedMmsi) ?? null;
|
|
}, [selectedMmsi, legacyHits]);
|
|
|
|
const availableTargetMmsiSet = useMemo(
|
|
() => new Set(targetsInScope.map((t) => t.mmsi).filter((mmsi) => Number.isFinite(mmsi))),
|
|
[targetsInScope],
|
|
);
|
|
const activeHighlightedMmsiSet = useMemo(
|
|
() => highlightedMmsiSet.filter((mmsi) => availableTargetMmsiSet.has(mmsi)),
|
|
[highlightedMmsiSet, availableTargetMmsiSet],
|
|
);
|
|
|
|
const 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 handleFleetContextMenu = (ownerKey: string, mmsis: number[]) => {
|
|
if (!mmsis.length) return;
|
|
const members = mmsis
|
|
.map((mmsi) => legacyVesselsFiltered.find((v): v is DerivedLegacyVessel => v.mmsi === mmsi))
|
|
.filter(
|
|
(v): v is DerivedLegacyVessel & { lat: number; lon: number } =>
|
|
v != null && typeof v.lat === "number" && typeof v.lon === "number" && Number.isFinite(v.lat) && Number.isFinite(v.lon),
|
|
);
|
|
|
|
if (members.length === 0) return;
|
|
const sumLon = members.reduce((acc, v) => acc + v.lon, 0);
|
|
const sumLat = members.reduce((acc, v) => acc + v.lat, 0);
|
|
const center: [number, number] = [sumLon / members.length, sumLat / members.length];
|
|
setFleetFocus({
|
|
id: `${ownerKey}-${Date.now()}`,
|
|
center,
|
|
zoom: 9,
|
|
});
|
|
};
|
|
|
|
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}`;
|
|
|
|
return (
|
|
<div className="app">
|
|
<Topbar
|
|
total={legacyVesselsAll.length}
|
|
fishing={fishingCount}
|
|
transit={transitCount}
|
|
pairLinks={pairLinksAll.length}
|
|
alarms={alarms.length}
|
|
pollingStatus={snapshot.status}
|
|
lastFetchMinutes={snapshot.lastFetchMinutes}
|
|
clock={clock}
|
|
adminMode={adminMode}
|
|
onLogoClick={onLogoClick}
|
|
userName={user?.name}
|
|
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>
|
|
|
|
<div className="map-area">
|
|
{showMapLoader ? (
|
|
<div className="map-loader-overlay" role="status" aria-live="polite">
|
|
<div className="map-loader-overlay__panel">
|
|
<div className="map-loader-overlay__spinner" />
|
|
<div className="map-loader-overlay__text">지도 모드 동기화 중...</div>
|
|
<div className="map-loader-overlay__bar">
|
|
<div className="map-loader-overlay__fill" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<Map3D
|
|
targets={targetsForMap}
|
|
zones={zones}
|
|
selectedMmsi={selectedMmsi}
|
|
highlightedMmsiSet={activeHighlightedMmsiSet}
|
|
hoveredMmsiSet={hoveredMmsiSet}
|
|
hoveredFleetMmsiSet={hoveredFleetMmsiSet}
|
|
hoveredPairMmsiSet={hoveredPairMmsiSet}
|
|
hoveredFleetOwnerKey={hoveredFleetOwnerKey}
|
|
settings={settings}
|
|
baseMap={baseMap}
|
|
projection={projection}
|
|
overlays={overlays}
|
|
onSelectMmsi={setSelectedMmsi}
|
|
onToggleHighlightMmsi={toggleHighlightedMmsi}
|
|
onViewBboxChange={setViewBbox}
|
|
legacyHits={legacyHits}
|
|
pairLinks={pairLinksForMap}
|
|
fcLinks={fcLinksForMap}
|
|
fleetCircles={fleetCirclesForMap}
|
|
fleetFocus={fleetFocus}
|
|
onProjectionLoadingChange={handleProjectionLoadingChange}
|
|
onGlobeShipsReady={setIsGlobeShipsReady}
|
|
onHoverMmsi={(mmsis) => setHoveredMmsiSet(setUniqueSorted(mmsis))}
|
|
onClearMmsiHover={() => setHoveredMmsiSet((prev) => (prev.length === 0 ? prev : []))}
|
|
onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))}
|
|
onClearPairHover={() => setHoveredPairMmsiSet([])}
|
|
onHoverFleet={(ownerKey, fleetMmsis) => {
|
|
setHoveredFleetOwnerKey(ownerKey);
|
|
setHoveredFleetMmsiSet(setSortedIfChanged(fleetMmsis));
|
|
}}
|
|
onClearFleetHover={() => {
|
|
setHoveredFleetOwnerKey(null);
|
|
setHoveredFleetMmsiSet((prev) => (prev.length === 0 ? prev : []));
|
|
}}
|
|
subcableGeo={subcableData?.geo ?? null}
|
|
hoveredCableId={hoveredCableId}
|
|
onHoverCable={setHoveredCableId}
|
|
onClickCable={(id) => setSelectedCableId((prev) => (prev === id ? null : id))}
|
|
mapStyleSettings={mapStyleSettings}
|
|
initialView={mapView}
|
|
onViewStateChange={setMapView}
|
|
activeTrack={null}
|
|
trackContextMenu={trackContextMenu}
|
|
onRequestTrack={handleRequestTrack}
|
|
onCloseTrackMenu={handleCloseTrackMenu}
|
|
onOpenTrackMenu={handleOpenTrackMenu}
|
|
onMapReady={handleMapReady}
|
|
/>
|
|
<GlobalTrackReplayPanel />
|
|
<WeatherPanel
|
|
snapshot={weather.snapshot}
|
|
isLoading={weather.isLoading}
|
|
error={weather.error}
|
|
onRefresh={weather.refresh}
|
|
/>
|
|
<WeatherOverlayPanel {...weatherOverlay} />
|
|
<MapSettingsPanel value={mapStyleSettings} onChange={setMapStyleSettings} />
|
|
<DepthLegend depthStops={mapStyleSettings.depthStops} />
|
|
<MapLegend />
|
|
{selectedLegacyVessel ? (
|
|
<VesselInfoPanel vessel={selectedLegacyVessel} allVessels={legacyVesselsAll} onClose={() => setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} />
|
|
) : selectedTarget ? (
|
|
<AisInfoPanel target={selectedTarget} legacy={selectedLegacyInfo} onClose={() => setSelectedMmsi(null)} />
|
|
) : null}
|
|
{selectedCableId && subcableData?.details.get(selectedCableId) ? (
|
|
<SubcableInfoPanel
|
|
detail={subcableData.details.get(selectedCableId)!}
|
|
color={subcableData.geo.features.find((f) => f.properties.id === selectedCableId)?.properties.color}
|
|
onClose={() => setSelectedCableId(null)}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|