kcg-monitoring/frontend/src/stores/shipDeckStore.ts
htlee 6f4044ce39 feat: MapLibre → deck.gl 전면 전환 + 어구 서브클러스터 구조 개선
- 실시간 선박 13K: MapLibre symbol → deck.gl IconLayer (useShipDeckLayers + shipDeckStore)
- 선단/어구 폴리곤: MapLibre Source/Layer → deck.gl GeoJsonLayer (useFleetClusterDeckLayers)
- 선박 팝업: MapLibre Popup → React 오버레이 (ShipPopupOverlay + ShipHoverTooltip)
- 리플레이 집중 모드 (focusMode), 라벨 클러스터링, fontScale 연동
- Python: group_key 고정 + sub_cluster_id 분리, 한국 국적 어구 오탐 제외
- DB: sub_cluster_id 컬럼 추가 + 기존 '#N' 데이터 마이그레이션
- Backend: DISTINCT ON CTE로 서브클러스터 중복 제거, subClusterId DTO 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:44:09 +09:00

109 lines
3.8 KiB
TypeScript

import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import type { Ship, VesselAnalysisDto } from '../types';
// ── Store interface ───────────────────────────────────────────────
interface ShipDeckState {
// Ship data (from 5-second polling)
ships: Ship[];
shipMap: Map<string, Ship>; // mmsi → Ship lookup (for popup, hover)
// Filter state
militaryOnly: boolean;
hiddenShipCategories: Set<string>; // mtCategory strings like 'cargo', 'tanker'
hiddenNationalities: Set<string>; // natGroup strings like 'KR', 'JP'
layerVisible: boolean; // layers.ships toggle from LayerPanel
// Interaction state
hoveredMmsi: string | null;
hoverScreenPos: { x: number; y: number } | null; // screen coords for tooltip
selectedMmsi: string | null; // popup target
focusMmsi: string | null; // external focus request (e.g., from analysis panel)
// Display state
highlightKorean: boolean; // korean ships ring + label toggle
zoomLevel: number; // integer floor of map zoom
// Analysis state (for analysis ship markers overlay)
analysisMap: Map<string, VesselAnalysisDto> | null;
analysisActiveFilter: string | null; // 'illegalFishing' | 'darkVessel' | 'cnFishing' | null
// Actions
setShips: (ships: Ship[]) => void;
setFilters: (patch: {
militaryOnly?: boolean;
hiddenShipCategories?: Set<string>;
hiddenNationalities?: Set<string>;
layerVisible?: boolean;
}) => void;
setHoveredMmsi: (mmsi: string | null, screenPos?: { x: number; y: number }) => void;
setSelectedMmsi: (mmsi: string | null) => void;
setFocusMmsi: (mmsi: string | null) => void;
setHighlightKorean: (hl: boolean) => void;
setZoomLevel: (zoom: number) => void;
setAnalysis: (map: Map<string, VesselAnalysisDto> | null, filter: string | null) => void;
}
// ── Store ─────────────────────────────────────────────────────────
export const useShipDeckStore = create<ShipDeckState>()(
subscribeWithSelector((set) => ({
// Ship data
ships: [],
shipMap: new Map<string, Ship>(),
// Filter state
militaryOnly: false,
hiddenShipCategories: new Set<string>(),
hiddenNationalities: new Set<string>(),
layerVisible: true,
// Interaction state
hoveredMmsi: null,
hoverScreenPos: null,
selectedMmsi: null,
focusMmsi: null,
// Display state
highlightKorean: false,
zoomLevel: 5,
// Analysis state
analysisMap: null,
analysisActiveFilter: null,
// ── Actions ────────────────────────────────────────────────
setShips: (ships) => {
const shipMap = new Map<string, Ship>();
for (const ship of ships) {
shipMap.set(ship.mmsi, ship);
}
set({ ships, shipMap });
},
setFilters: (patch) => set((state) => ({
militaryOnly: patch.militaryOnly ?? state.militaryOnly,
hiddenShipCategories: patch.hiddenShipCategories ?? state.hiddenShipCategories,
hiddenNationalities: patch.hiddenNationalities ?? state.hiddenNationalities,
layerVisible: patch.layerVisible ?? state.layerVisible,
})),
setHoveredMmsi: (mmsi, screenPos) => set({
hoveredMmsi: mmsi,
hoverScreenPos: mmsi ? (screenPos ?? null) : null,
}),
setSelectedMmsi: (mmsi) => set({ selectedMmsi: mmsi }),
setFocusMmsi: (mmsi) => set({ focusMmsi: mmsi }),
setHighlightKorean: (hl) => set({ highlightKorean: hl }),
setZoomLevel: (zoom) => set({ zoomLevel: zoom }),
setAnalysis: (map, filter) => set({ analysisMap: map, analysisActiveFilter: filter }),
})),
);