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 historyFrames: HistoryFrame[]; frameTimes: number[]; selectedGroupKey: string | null; rawCorrelationTracks: CorrelationVesselTrack[]; // Pre-computed layer data memberTripsData: TripsLayerDatum[]; correlationTripsData: TripsLayerDatum[]; centerTrailSegments: CenterTrailSegment[]; centerDotsPositions: [number, number][]; snapshotRanges: number[]; modelCenterTrails: ModelCenterTrail[]; // Filter / display state enabledModels: Set; enabledVessels: Set; hoveredMmsi: string | null; correlationByModel: Map; showTrails: boolean; showLabels: boolean; // Actions loadHistory: ( frames: HistoryFrame[], corrTracks: CorrelationVesselTrack[], corrData: GearCorrelationItem[], enabledModels: Set, enabledVessels: Set, ) => 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; 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; 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: [], // Pre-computed layer data memberTripsData: [], correlationTripsData: [], centerTrailSegments: [], centerDotsPositions: [], snapshotRanges: [], modelCenterTrails: [], // Filter / display state enabledModels: new Set(), enabledVessels: new Set(), hoveredMmsi: null, showTrails: true, showLabels: true, correlationByModel: new Map(), // ── Actions ──────────────────────────────────────────────── loadHistory: (frames, corrTracks, corrData, enabledModels, enabledVessels) => { const startTime = Date.now() - 12 * 60 * 60 * 1000; const endTime = Date.now(); const frameTimes = frames.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); 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, frameTimes, 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 }); } else { set({ isPlaying: true }); } 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 }), 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: [], frameTimes: [], selectedGroupKey: null, rawCorrelationTracks: [], memberTripsData: [], correlationTripsData: [], centerTrailSegments: [], centerDotsPositions: [], snapshotRanges: [], modelCenterTrails: [], enabledModels: new Set(), enabledVessels: new Set(), hoveredMmsi: null, showTrails: true, showLabels: true, correlationByModel: new Map(), }); }, }; }), );