import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; import type { HistoryFrame } from '../components/korea/fleetClusterTypes'; import type { GearCorrelationItem, CorrelationVesselTrack } from '../services/vesselAnalysis'; import { buildMemberTripsData, buildCorrelationTripsData, buildCenterTrailData, buildSnapshotRanges, buildModelCenterTrails, } from './gearReplayPreprocess'; import type { ModelCenterTrail } from './gearReplayPreprocess'; // ── Pre-processed data types for deck.gl layers ────────────────── export interface TripsLayerDatum { id: string; path: [number, number][]; timestamps: number[]; color: [number, number, number, number]; } export interface MemberPosition { mmsi: string; name: string; lon: number; lat: number; cog: number; role: string; isParent: boolean; isGear: boolean; stale: boolean; } export interface CenterTrailSegment { path: [number, number][]; isInterpolated: boolean; } // ── Speed factor: 1x = 30 real seconds covers 12 timeline hours ── const SPEED_FACTOR = (12 * 60 * 60 * 1000) / (30 * 1000); // 1440 // ── Module-level rAF state (outside React) ─────────────────────── let animationFrameId: number | null = null; let lastFrameTime: number | null = null; // ── Store interface ─────────────────────────────────────────────── interface GearReplayState { // Playback state isPlaying: boolean; currentTime: number; startTime: number; endTime: number; playbackSpeed: number; // Source data (1h = primary identity polygon) historyFrames: HistoryFrame[]; frameTimes: number[]; selectedGroupKey: string | null; rawCorrelationTracks: CorrelationVesselTrack[]; // 6h identity (독립 레이어 — 1h/모델과 무관) historyFrames6h: HistoryFrame[]; frameTimes6h: number[]; memberTripsData6h: TripsLayerDatum[]; centerTrailSegments6h: CenterTrailSegment[]; centerDotsPositions6h: [number, number][]; subClusterCenters6h: { subClusterId: number; path: [number, number][]; timestamps: number[] }[]; snapshotRanges6h: number[]; // Pre-computed layer data memberTripsData: TripsLayerDatum[]; correlationTripsData: TripsLayerDatum[]; centerTrailSegments: CenterTrailSegment[]; centerDotsPositions: [number, number][]; subClusterCenters: { subClusterId: number; path: [number, number][]; timestamps: number[] }[]; /** 리플레이 전체 구간에서 등장한 모든 고유 멤버 (identity 목록용) */ allHistoryMembers: { mmsi: string; name: string; isParent: boolean }[]; snapshotRanges: number[]; modelCenterTrails: ModelCenterTrail[]; // Filter / display state enabledModels: Set; enabledVessels: Set; hoveredMmsi: string | null; correlationByModel: Map; showTrails: boolean; showLabels: boolean; focusMode: boolean; // 리플레이 집중 모드 — 주변 라이브 정보 숨김 show1hPolygon: boolean; // 1h 폴리곤 표시 (진한색/실선) show6hPolygon: boolean; // 6h 폴리곤 표시 (옅은색/점선) abLoop: boolean; // A-B 구간 반복 활성화 abA: number; // A 지점 (epoch ms, 0 = 미설정) abB: number; // B 지점 (epoch ms, 0 = 미설정) pinnedMmsis: Set; // 툴팁 고정 시 강조할 MMSI 세트 // Actions loadHistory: ( frames: HistoryFrame[], corrTracks: CorrelationVesselTrack[], corrData: GearCorrelationItem[], enabledModels: Set, enabledVessels: Set, frames6h?: HistoryFrame[], ) => void; play: () => void; pause: () => void; seek: (timeMs: number) => void; setPlaybackSpeed: (speed: number) => void; setEnabledModels: (models: Set) => void; setEnabledVessels: (vessels: Set) => void; setHoveredMmsi: (mmsi: string | null) => void; setShowTrails: (show: boolean) => void; setShowLabels: (show: boolean) => void; setFocusMode: (focus: boolean) => void; setShow1hPolygon: (show: boolean) => void; setShow6hPolygon: (show: boolean) => void; setAbLoop: (on: boolean) => void; setAbA: (t: number) => void; setAbB: (t: number) => void; setPinnedMmsis: (mmsis: Set) => void; updateCorrelation: (corrData: GearCorrelationItem[], corrTracks: CorrelationVesselTrack[]) => void; reset: () => void; } // ── Store ───────────────────────────────────────────────────────── export const useGearReplayStore = create()( subscribeWithSelector((set, get) => { const animate = (): void => { const state = get(); if (!state.isPlaying) return; const now = performance.now(); if (lastFrameTime === null) lastFrameTime = now; const delta = now - lastFrameTime; lastFrameTime = now; const newTime = state.currentTime + delta * SPEED_FACTOR * state.playbackSpeed; // A-B 구간 반복 if (state.abLoop && state.abA > 0 && state.abB > state.abA) { if (newTime >= state.abB) { set({ currentTime: state.abA }); animationFrameId = requestAnimationFrame(animate); return; } // A 이전이면 A로 점프 if (newTime < state.abA) { set({ currentTime: state.abA }); animationFrameId = requestAnimationFrame(animate); return; } } else if (newTime >= state.endTime) { set({ currentTime: state.startTime }); animationFrameId = requestAnimationFrame(animate); return; } set({ currentTime: newTime }); animationFrameId = requestAnimationFrame(animate); }; return { // Playback state isPlaying: false, currentTime: 0, startTime: 0, endTime: 0, playbackSpeed: 1, // Source data historyFrames: [], frameTimes: [], selectedGroupKey: null, rawCorrelationTracks: [], historyFrames6h: [], frameTimes6h: [], memberTripsData6h: [], centerTrailSegments6h: [], centerDotsPositions6h: [], subClusterCenters6h: [], snapshotRanges6h: [], // Pre-computed layer data memberTripsData: [], correlationTripsData: [], centerTrailSegments: [], centerDotsPositions: [], subClusterCenters: [], allHistoryMembers: [], snapshotRanges: [], modelCenterTrails: [], // Filter / display state enabledModels: new Set(), enabledVessels: new Set(), hoveredMmsi: null, showTrails: true, showLabels: true, focusMode: false, show1hPolygon: true, show6hPolygon: false, abLoop: false, abA: 0, abB: 0, pinnedMmsis: new Set(), correlationByModel: new Map(), // ── Actions ──────────────────────────────────────────────── loadHistory: (frames, corrTracks, corrData, enabledModels, enabledVessels, frames6h) => { const startTime = Date.now() - 12 * 60 * 60 * 1000; const endTime = Date.now(); const frameTimes = frames.map(f => new Date(f.snapshotTime).getTime()); const frameTimes6h = (frames6h ?? []).map(f => new Date(f.snapshotTime).getTime()); const memberTrips = buildMemberTripsData(frames, startTime); const corrTrips = buildCorrelationTripsData(corrTracks, startTime); const { segments, dots } = buildCenterTrailData(frames); const ranges = buildSnapshotRanges(frames, startTime, endTime); // 6h 전처리 (동일한 빌드 함수) const f6h = frames6h ?? []; const memberTrips6h = f6h.length > 0 ? buildMemberTripsData(f6h, startTime) : []; const { segments: seg6h, dots: dots6h } = f6h.length > 0 ? buildCenterTrailData(f6h) : { segments: [], dots: [] }; const ranges6h = f6h.length > 0 ? buildSnapshotRanges(f6h, startTime, endTime) : []; const byModel = new Map(); for (const c of corrData) { const list = byModel.get(c.modelName) ?? []; list.push(c); byModel.set(c.modelName, list); } const modelTrails = buildModelCenterTrails(frames, corrTracks, byModel, enabledVessels, startTime); set({ historyFrames: frames, historyFrames6h: f6h, frameTimes, frameTimes6h, memberTripsData6h: memberTrips6h, centerTrailSegments6h: seg6h, centerDotsPositions6h: dots6h, snapshotRanges6h: ranges6h, startTime, endTime, currentTime: startTime, rawCorrelationTracks: corrTracks, memberTripsData: memberTrips, correlationTripsData: corrTrips, centerTrailSegments: segments, centerDotsPositions: dots, snapshotRanges: ranges, modelCenterTrails: modelTrails, enabledModels, enabledVessels, correlationByModel: byModel, selectedGroupKey: frames[0]?.groupKey ?? null, }); }, play: () => { const state = get(); if (state.endTime <= state.startTime) return; lastFrameTime = null; if (state.currentTime >= state.endTime) { set({ isPlaying: true, currentTime: state.startTime, pinnedMmsis: new Set() }); } else { set({ isPlaying: true, pinnedMmsis: new Set() }); } animationFrameId = requestAnimationFrame(animate); }, pause: () => { if (animationFrameId !== null) { cancelAnimationFrame(animationFrameId); animationFrameId = null; } lastFrameTime = null; set({ isPlaying: false }); }, seek: (timeMs) => { const { startTime, endTime } = get(); set({ currentTime: Math.max(startTime, Math.min(endTime, timeMs)) }); }, setPlaybackSpeed: (speed) => set({ playbackSpeed: speed }), setEnabledModels: (models) => set({ enabledModels: models }), setEnabledVessels: (vessels) => { const state = get(); const modelTrails = state.historyFrames.length > 0 ? buildModelCenterTrails(state.historyFrames, state.rawCorrelationTracks, state.correlationByModel, vessels, state.startTime) : []; set({ enabledVessels: vessels, modelCenterTrails: modelTrails }); }, setHoveredMmsi: (mmsi) => set({ hoveredMmsi: mmsi }), setShowTrails: (show) => set({ showTrails: show }), setShowLabels: (show) => set({ showLabels: show }), setFocusMode: (focus) => set({ focusMode: focus }), setShow1hPolygon: (show) => set({ show1hPolygon: show }), setShow6hPolygon: (show) => set({ show6hPolygon: show }), setAbLoop: (on) => { const { startTime, endTime } = get(); if (on && startTime > 0) { // 기본 A-B: 전체 구간의 마지막 4시간 const dur = endTime - startTime; set({ abLoop: true, abA: endTime - Math.min(dur, 4 * 3600_000), abB: endTime }); } else { set({ abLoop: false, abA: 0, abB: 0 }); } }, setAbA: (t) => set({ abA: t }), setAbB: (t) => set({ abB: t }), setPinnedMmsis: (mmsis) => set({ pinnedMmsis: mmsis }), updateCorrelation: (corrData, corrTracks) => { const state = get(); if (state.historyFrames.length === 0) { console.log('[GearReplayStore] updateCorrelation 스킵: historyFrames 비어있음'); return; } const byModel = new Map(); for (const c of corrData) { const list = byModel.get(c.modelName) ?? []; list.push(c); byModel.set(c.modelName, list); } const corrTrips = buildCorrelationTripsData(corrTracks, state.startTime); console.log('[GearReplayStore] updateCorrelation:', { corrData: corrData.length, models: [...byModel.keys()], corrTrips: corrTrips.length, corrTracks: corrTracks.length, }); const modelTrails = buildModelCenterTrails(state.historyFrames, corrTracks, byModel, state.enabledVessels, state.startTime); set({ correlationByModel: byModel, correlationTripsData: corrTrips, modelCenterTrails: modelTrails, rawCorrelationTracks: corrTracks }); }, reset: () => { if (animationFrameId !== null) { cancelAnimationFrame(animationFrameId); animationFrameId = null; } lastFrameTime = null; set({ isPlaying: false, currentTime: 0, startTime: 0, endTime: 0, playbackSpeed: 1, historyFrames: [], historyFrames6h: [], frameTimes: [], frameTimes6h: [], memberTripsData6h: [], centerTrailSegments6h: [], centerDotsPositions6h: [], subClusterCenters6h: [], snapshotRanges6h: [], selectedGroupKey: null, rawCorrelationTracks: [], memberTripsData: [], correlationTripsData: [], centerTrailSegments: [], centerDotsPositions: [], subClusterCenters: [], allHistoryMembers: [], snapshotRanges: [], modelCenterTrails: [], enabledModels: new Set(), enabledVessels: new Set(), hoveredMmsi: null, showTrails: true, showLabels: true, focusMode: false, show1hPolygon: true, show6hPolygon: false, abLoop: false, abA: 0, abB: 0, pinnedMmsis: new Set(), correlationByModel: new Map(), }); }, }; }), );