gc-wing/apps/web/src/pages/dashboard/DashboardPage.tsx

401 lines
17 KiB
TypeScript
Raw Normal View 히스토리

import { useCallback, useMemo, useState } from "react";
import { useAuth } from "../../shared/auth";
import { useTheme } from "../../shared/hooks";
2026-02-15 11:22:38 +09:00
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
import type { DerivedLegacyVessel, LegacyAlarmKind } from "../../features/legacyDashboard/model/types";
import { ALARM_KIND_PRIORITY, LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types";
2026-02-15 11:22:38 +09:00
import { useLegacyVessels } from "../../entities/legacyVessel/api/useLegacyVessels";
import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib";
import type { LegacyVesselDataset } from "../../entities/legacyVessel/model/types";
import type { VesselTypeCode } from "../../entities/vessel/model/types";
import { VESSEL_TYPE_ORDER } from "../../entities/vessel/model/meta";
2026-02-15 11:22:38 +09:00
import { useZones } from "../../entities/zone/api/useZones";
import { useSubcables } from "../../entities/subcable/api/useSubcables";
2026-02-15 11:22:38 +09:00
import { AisInfoPanel } from "../../widgets/aisInfo/AisInfoPanel";
import { MapLegend } from "../../widgets/legend/MapLegend";
import { Map3D } from "../../widgets/map3d/Map3D";
2026-02-15 11:22:38 +09:00
import { Topbar } from "../../widgets/topbar/Topbar";
import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel";
import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel";
import { DepthLegend } from "../../widgets/legend/DepthLegend";
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 { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
2026-02-15 11:22:38 +09:00
import {
buildLegacyHitMap,
computeCountsByType,
computeFcLinks,
computeFleetCircles,
computeLegacyAlarms,
computePairLinks,
deriveLegacyVessels,
filterByShipCodes,
} from "../../features/legacyDashboard/model/derive";
import { MOCK_AIS_TARGETS, MOCK_LEGACY_ENTRIES } from "../../features/legacyDashboard/dev/mockOverlayData";
import { useDashboardState } from "./useDashboardState";
import type { Bbox } from "./useDashboardState";
import { DashboardSidebar } from "./DashboardSidebar";
2026-02-15 11:22:38 +09:00
const AIS_CENTER = {
lon: 126.95,
lat: 35.95,
radiusMeters: 2_000_000,
};
2026-02-15 11:22:38 +09:00
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 useLegacyIndex(data: LegacyVesselDataset | null) {
2026-02-15 11:22:38 +09:00
return useMemo(() => (data ? buildLegacyVesselIndex(data.vessels) : null), [data]);
}
export function DashboardPage() {
const { user, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const uid = user?.id ?? null;
const isDevMode = user?.name?.includes('(DEV)') ?? false;
// ── Data fetching ──
2026-02-15 11:22:38 +09:00
const { data: zones, error: zonesError } = useZones();
const { data: legacyData, error: legacyError } = useLegacyVessels();
const { data: subcableData } = useSubcables();
2026-02-15 11:22:38 +09:00
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 weatherOverlay = useWeatherOverlay(mapInstance);
2026-02-15 11:22:38 +09:00
// ── AIS polling ──
2026-02-15 11:22:38 +09:00
const { targets, snapshot } = useAisTargetPolling({
chnprmshipMinutes: 120,
2026-02-15 11:22:38 +09:00
incrementalMinutes: 2,
intervalMs: 60_000,
retentionMinutes: 120,
2026-02-15 11:22:38 +09:00
bbox: useApiBbox ? apiBbox : undefined,
centerLon: useApiBbox ? undefined : AIS_CENTER.lon,
centerLat: useApiBbox ? undefined : AIS_CENTER.lat,
radiusMeters: useApiBbox ? undefined : AIS_CENTER.radiusMeters,
2026-02-15 11:22:38 +09:00
});
// ── Track request ──
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]);
// ── Derived data ──
2026-02-15 11:22:38 +09:00
const targetsInScope = useMemo(() => {
const base = (!useViewportFilter || !viewBbox)
? targets
: targets.filter((t) => typeof t.lon === "number" && typeof t.lat === "number" && inBbox(t.lon, t.lat, viewBbox));
return isDevMode ? [...base, ...MOCK_AIS_TARGETS] : base;
}, [targets, useViewportFilter, viewBbox, isDevMode]);
2026-02-15 11:22:38 +09:00
const legacyHits = useMemo(() => {
const hits = buildLegacyHitMap(targetsInScope, legacyIndex);
if (isDevMode) {
for (const [mmsi, info] of MOCK_LEGACY_ENTRIES) hits.set(mmsi, info);
}
return hits;
}, [targetsInScope, legacyIndex, isDevMode]);
2026-02-15 11:22:38 +09:00
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<typeof LEGACY_ALARM_KINDS[number], number>;
for (const a of alarms) {
base[a.kind] = (base[a.kind] ?? 0) + 1;
}
return base;
}, [alarms]);
const enabledAlarmKinds = useMemo(() => 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 alarmMmsiMap = useMemo(() => {
const m = new Map<number, LegacyAlarmKind>();
for (const kind of ALARM_KIND_PRIORITY) {
for (const alarm of filteredAlarms) {
if (alarm.kind !== kind) continue;
for (const mmsi of alarm.relatedMmsi) m.set(mmsi, kind);
}
}
return m;
}, [filteredAlarms]);
2026-02-15 11:22:38 +09:00
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 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 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 });
};
// ── Render ──
2026-02-15 11:22:38 +09:00
return (
<div className="grid h-screen grid-cols-[310px_1fr] grid-rows-[44px_1fr] max-md:grid-cols-[1fr] max-md:grid-rows-[auto_1fr]">
2026-02-15 11:22:38 +09:00
<Topbar
total={legacyVesselsAll.length}
fishing={fishingCount}
transit={transitCount}
pairLinks={pairLinksAll.length}
alarms={alarms.length}
clock={clock}
adminMode={adminMode}
onLogoClick={onLogoClick}
userName={user?.name}
onLogout={logout}
theme={theme}
onToggleTheme={toggleTheme}
isSidebarOpen={isSidebarOpen}
onMenuToggle={() => setIsSidebarOpen((v) => !v)}
2026-02-15 11:22:38 +09:00
/>
<DashboardSidebar
isOpen={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
state={state}
legacyVesselsAll={legacyVesselsAll}
legacyVesselsFiltered={legacyVesselsFiltered}
legacyCounts={legacyCounts}
selectedLegacyVessel={selectedLegacyVessel}
activeHighlightedMmsiSet={activeHighlightedMmsiSet}
legacyHits={legacyHits}
filteredAlarms={filteredAlarms}
alarms={alarms}
alarmKindCounts={alarmKindCounts}
speedPanelType={speedPanelType}
onFleetContextMenu={handleFleetContextMenu}
snapshot={snapshot}
legacyError={legacyError}
legacyData={legacyData}
targetsInScope={targetsInScope}
zonesError={zonesError}
zones={zones}
legacyIndex={legacyIndex}
/>
2026-02-15 11:22:38 +09:00
<div className="relative bg-[#010610]">
{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}
2026-02-15 11:22:38 +09:00
<Map3D
targets={targetsForMap}
zones={zones}
selectedMmsi={selectedMmsi}
highlightedMmsiSet={activeHighlightedMmsiSet}
hoveredMmsiSet={hoveredMmsiSet}
hoveredFleetMmsiSet={hoveredFleetMmsiSet}
hoveredPairMmsiSet={hoveredPairMmsiSet}
hoveredFleetOwnerKey={hoveredFleetOwnerKey}
2026-02-15 11:22:38 +09:00
settings={settings}
baseMap={baseMap}
projection={projection}
overlays={overlays}
onSelectMmsi={setSelectedMmsi}
onToggleHighlightMmsi={toggleHighlightedMmsi}
2026-02-15 11:22:38 +09:00
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}
alarmMmsiMap={alarmMmsiMap}
2026-02-15 11:22:38 +09:00
/>
<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} />
2026-02-15 11:22:38 +09:00
<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}
2026-02-15 11:22:38 +09:00
</div>
</div>
);
}