kcg-monitoring/frontend/src/stores/gearReplayStore.ts
htlee bf412cc897 fix: enabledVessels 토글 시 모델 중심 경로 재계산
- setEnabledVessels: rawCorrelationTracks로 modelCenterTrails 재빌드
- rawCorrelationTracks 필드 추가 (원본 트랙 보존)
- 선박/어구 on/off → 폴리곤 + 중심경로 + 중심점 동시 갱신

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

298 lines
9.6 KiB
TypeScript

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<string>;
enabledVessels: Set<string>;
hoveredMmsi: string | null;
correlationByModel: Map<string, GearCorrelationItem[]>;
showTrails: boolean;
showLabels: boolean;
// Actions
loadHistory: (
frames: HistoryFrame[],
corrTracks: CorrelationVesselTrack[],
corrData: GearCorrelationItem[],
enabledModels: Set<string>,
enabledVessels: Set<string>,
) => void;
play: () => void;
pause: () => void;
seek: (timeMs: number) => void;
setPlaybackSpeed: (speed: number) => void;
setEnabledModels: (models: Set<string>) => void;
setEnabledVessels: (vessels: Set<string>) => 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<GearReplayState>()(
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<string>(),
enabledVessels: new Set<string>(),
hoveredMmsi: null,
showTrails: true,
showLabels: true,
correlationByModel: new Map<string, GearCorrelationItem[]>(),
// ── 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<string, GearCorrelationItem[]>();
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<string, GearCorrelationItem[]>();
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<string>(),
enabledVessels: new Set<string>(),
hoveredMmsi: null,
showTrails: true,
showLabels: true,
correlationByModel: new Map<string, GearCorrelationItem[]>(),
});
},
};
}),
);