- setEnabledVessels: rawCorrelationTracks로 modelCenterTrails 재빌드 - rawCorrelationTracks 필드 추가 (원본 트랙 보존) - 선박/어구 on/off → 폴리곤 + 중심경로 + 중심점 동시 갱신 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
298 lines
9.6 KiB
TypeScript
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[]>(),
|
|
});
|
|
},
|
|
};
|
|
}),
|
|
);
|