- 실시간 선박 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>
109 lines
3.8 KiB
TypeScript
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 }),
|
|
})),
|
|
);
|