kcg-ai-monitoring/frontend/src/stores/gearReplayStore.ts
htlee 2ee8a0e7ff feat(detection): DAR-03 어구 탐지 워크플로우 + 모선 검토 UI + 24h 리플레이 통합
- prediction: G-01/G-04/G-05/G-06 위반 분류 + 쌍끌이 공조 탐지 추가
- backend: 모선 확정/제외 API + signal-batch 항적 프록시 + ParentResolution 점수 근거 필드 확장
- frontend: 어구 탐지 그리드 다중필터/지도 flyTo, 후보 검토 패널(점수 근거+확정/제외), 24h convex hull 리플레이 + TripsLayer 애니메이션
- gitignore: 루트 .venv/ 추가
2026-04-15 13:26:15 +09:00

242 lines
6.6 KiB
TypeScript

/**
* 어구 그룹 궤적 리플레이 스토어
*
* loadHistory() 시 전처리를 수행하고 결과를 상태로 보관.
* rAF 루프로 currentTime을 갱신 → useGearReplayLayers 훅이 구독하여 렌더링.
*/
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import {
buildMemberTripsData,
buildCenterTrailData,
buildSnapshotRanges,
buildMemberMetadata,
type HistoryFrame,
type TripsLayerDatum,
type CenterTrailSegment,
type MemberMeta,
} from './gearReplayPreprocess';
/** 24시간을 30초에 재생: SPEED_FACTOR = (24 * 3600 * 1000) / (30 * 1000) = 2880 */
const SPEED_FACTOR = 2880;
interface CorrelationItem {
targetMmsi: string;
targetName: string;
score: number;
freezeState: string;
}
interface GearReplayState {
// timeline
isPlaying: boolean;
currentTime: number;
startTime: number;
endTime: number;
playbackSpeed: number;
// data (원본)
groupKey: string | null;
historyFrames: HistoryFrame[];
correlationItems: CorrelationItem[];
// 전처리 결과
frameTimes: number[];
memberTripsData: TripsLayerDatum[];
memberMetadata: Map<string, MemberMeta>;
centerTrailSegments: CenterTrailSegment[];
centerDotsPositions: [number, number][];
snapshotRanges: number[];
dataStartTime: number;
dataEndTime: number;
// 후보 선박 항적
candidateTripsData: TripsLayerDatum[];
candidateMetadata: Map<string, { name: string }>;
// actions
loadHistory: (
groupKey: string,
frames: HistoryFrame[],
correlations: CorrelationItem[],
candidateTracks?: { vesselId: string; shipName: string; geometry: [number, number][]; timestamps: string[] }[],
) => void;
play: () => void;
pause: () => void;
seek: (time: number) => void;
setSpeed: (speed: number) => void;
reset: () => void;
}
// Module-level rAF state — React re-render 유발 없음
let animFrameId = 0;
let lastFrameTime = 0;
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
export const useGearReplayStore = create<GearReplayState>()(
subscribeWithSelector((set, get) => ({
// timeline
isPlaying: false,
currentTime: 0,
startTime: 0,
endTime: 0,
playbackSpeed: 1,
// data
groupKey: null,
historyFrames: [],
correlationItems: [],
// 전처리 결과
frameTimes: [],
memberTripsData: [],
memberMetadata: new Map(),
centerTrailSegments: [],
centerDotsPositions: [],
snapshotRanges: [],
dataStartTime: 0,
dataEndTime: 0,
// 후보 선박 항적
candidateTripsData: [],
candidateMetadata: new Map(),
loadHistory: (groupKey, frames, correlations, candidateTracks) => {
get().pause();
const sorted = [...frames].sort(
(a, b) => new Date(a.snapshotTime).getTime() - new Date(b.snapshotTime).getTime(),
);
const frameTimes = sorted.map(f => new Date(f.snapshotTime).getTime());
const dataStartTime = frameTimes.length > 0 ? frameTimes[0] : Date.now() - 86_400_000;
const dataEndTime = frameTimes.length > 0 ? frameTimes[frameTimes.length - 1] : Date.now();
// 타임라인은 데이터 실제 범위 사용
const startTime = dataStartTime;
const endTime = dataEndTime;
// 전처리
const memberTripsData = buildMemberTripsData(sorted, startTime);
const memberMetadata = buildMemberMetadata(sorted);
const { segments, dots } = buildCenterTrailData(sorted);
const snapshotRanges = buildSnapshotRanges(sorted, startTime, endTime);
// 후보 선박 항적 전처리
const candidateTripsData: TripsLayerDatum[] = [];
const candidateMetadata = new Map<string, { name: string }>();
if (candidateTracks) {
for (const track of candidateTracks) {
if (track.geometry.length < 2) continue;
candidateMetadata.set(track.vesselId, { name: track.shipName });
// timestamps: Unix초 문자열 → startTime 기준 상대 ms
const relTs = track.timestamps.map(t => Number(t) * 1000 - startTime);
candidateTripsData.push({
id: track.vesselId,
path: track.geometry,
timestamps: relTs,
color: [16, 185, 129, 200], // emerald (후보 선박)
});
}
}
set({
groupKey,
historyFrames: sorted,
correlationItems: correlations,
frameTimes,
startTime,
endTime,
dataStartTime,
dataEndTime,
memberTripsData, memberMetadata,
centerTrailSegments: segments,
centerDotsPositions: dots,
snapshotRanges,
candidateTripsData, candidateMetadata,
currentTime: dataStartTime,
isPlaying: false,
});
},
play: () => {
const state = get();
if (state.isPlaying) return;
const startFrom =
state.currentTime >= state.endTime ? state.startTime : state.currentTime;
set({ isPlaying: true, currentTime: startFrom });
lastFrameTime = performance.now();
const tick = (now: number) => {
const { isPlaying, currentTime, endTime, playbackSpeed } = get();
if (!isPlaying) return;
const delta = now - lastFrameTime;
lastFrameTime = now;
const newTime = currentTime + delta * SPEED_FACTOR * playbackSpeed;
if (newTime >= endTime) {
set({ currentTime: endTime, isPlaying: false });
animFrameId = 0;
return;
}
set({ currentTime: newTime });
animFrameId = requestAnimationFrame(tick);
};
animFrameId = requestAnimationFrame(tick);
},
pause: () => {
if (animFrameId !== 0) {
cancelAnimationFrame(animFrameId);
animFrameId = 0;
}
set({ isPlaying: false });
},
seek: (time) => {
const { startTime, endTime } = get();
set({ currentTime: clamp(time, startTime, endTime) });
},
setSpeed: (speed) => {
set({ playbackSpeed: speed });
},
reset: () => {
if (animFrameId !== 0) {
cancelAnimationFrame(animFrameId);
animFrameId = 0;
}
set({
isPlaying: false,
currentTime: 0,
startTime: 0,
endTime: 0,
playbackSpeed: 1,
groupKey: null,
historyFrames: [],
correlationItems: [],
frameTimes: [],
memberTripsData: [],
memberMetadata: new Map(),
centerTrailSegments: [],
centerDotsPositions: [],
snapshotRanges: [],
candidateTripsData: [],
candidateMetadata: new Map(),
dataStartTime: 0,
dataEndTime: 0,
});
},
})),
);