- prediction: G-01/G-04/G-05/G-06 위반 분류 + 쌍끌이 공조 탐지 추가 - backend: 모선 확정/제외 API + signal-batch 항적 프록시 + ParentResolution 점수 근거 필드 확장 - frontend: 어구 탐지 그리드 다중필터/지도 flyTo, 후보 검토 패널(점수 근거+확정/제외), 24h convex hull 리플레이 + TripsLayer 애니메이션 - gitignore: 루트 .venv/ 추가
242 lines
6.6 KiB
TypeScript
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,
|
|
});
|
|
},
|
|
})),
|
|
);
|