kcg-monitoring/frontend/src/stores/gearReplayStore.ts
htlee 71d607e499 feat: 어구 그룹 1h/6h 듀얼 폴리곤 + 리플레이 컨트롤러 개선
- Python: 1h/6h 듀얼 스냅샷 생성 (polygon_builder), 1h 멤버 기반 일치율 후보 (gear_correlation)
- DB: resolution 컬럼 추가 (011_polygon_resolution.sql)
- Backend: resolution 필드 지원 (DTO/Service/Controller)
- Frontend: 6h identity 레이어 독립 구현 (폴리곤/아이콘/라벨/항적/센터)
- 리플레이 컨트롤러: 프로그레스바 통합, 1h/6h 스냅샷 표시, A-B 구간 반복
- 리치 툴팁: 클릭 고정 + 멤버 호버 강조 + 선박/어구/모델 소속 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 11:52:38 +09:00

399 lines
14 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 (1h = primary identity polygon)
historyFrames: HistoryFrame[];
frameTimes: number[];
selectedGroupKey: string | null;
rawCorrelationTracks: CorrelationVesselTrack[];
// 6h identity (독립 레이어 — 1h/모델과 무관)
historyFrames6h: HistoryFrame[];
frameTimes6h: number[];
memberTripsData6h: TripsLayerDatum[];
centerTrailSegments6h: CenterTrailSegment[];
centerDotsPositions6h: [number, number][];
subClusterCenters6h: { subClusterId: number; path: [number, number][]; timestamps: number[] }[];
snapshotRanges6h: number[];
// Pre-computed layer data
memberTripsData: TripsLayerDatum[];
correlationTripsData: TripsLayerDatum[];
centerTrailSegments: CenterTrailSegment[];
centerDotsPositions: [number, number][];
subClusterCenters: { subClusterId: number; path: [number, number][]; timestamps: number[] }[];
/** 리플레이 전체 구간에서 등장한 모든 고유 멤버 (identity 목록용) */
allHistoryMembers: { mmsi: string; name: string; isParent: boolean }[];
snapshotRanges: number[];
modelCenterTrails: ModelCenterTrail[];
// Filter / display state
enabledModels: Set<string>;
enabledVessels: Set<string>;
hoveredMmsi: string | null;
correlationByModel: Map<string, GearCorrelationItem[]>;
showTrails: boolean;
showLabels: boolean;
focusMode: boolean; // 리플레이 집중 모드 — 주변 라이브 정보 숨김
show1hPolygon: boolean; // 1h 폴리곤 표시 (진한색/실선)
show6hPolygon: boolean; // 6h 폴리곤 표시 (옅은색/점선)
abLoop: boolean; // A-B 구간 반복 활성화
abA: number; // A 지점 (epoch ms, 0 = 미설정)
abB: number; // B 지점 (epoch ms, 0 = 미설정)
pinnedMmsis: Set<string>; // 툴팁 고정 시 강조할 MMSI 세트
// Actions
loadHistory: (
frames: HistoryFrame[],
corrTracks: CorrelationVesselTrack[],
corrData: GearCorrelationItem[],
enabledModels: Set<string>,
enabledVessels: Set<string>,
frames6h?: HistoryFrame[],
) => 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;
setFocusMode: (focus: boolean) => void;
setShow1hPolygon: (show: boolean) => void;
setShow6hPolygon: (show: boolean) => void;
setAbLoop: (on: boolean) => void;
setAbA: (t: number) => void;
setAbB: (t: number) => void;
setPinnedMmsis: (mmsis: Set<string>) => 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;
// A-B 구간 반복
if (state.abLoop && state.abA > 0 && state.abB > state.abA) {
if (newTime >= state.abB) {
set({ currentTime: state.abA });
animationFrameId = requestAnimationFrame(animate);
return;
}
// A 이전이면 A로 점프
if (newTime < state.abA) {
set({ currentTime: state.abA });
animationFrameId = requestAnimationFrame(animate);
return;
}
} else 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: [],
historyFrames6h: [],
frameTimes6h: [],
memberTripsData6h: [],
centerTrailSegments6h: [],
centerDotsPositions6h: [],
subClusterCenters6h: [],
snapshotRanges6h: [],
// Pre-computed layer data
memberTripsData: [],
correlationTripsData: [],
centerTrailSegments: [],
centerDotsPositions: [],
subClusterCenters: [],
allHistoryMembers: [],
snapshotRanges: [],
modelCenterTrails: [],
// Filter / display state
enabledModels: new Set<string>(),
enabledVessels: new Set<string>(),
hoveredMmsi: null,
showTrails: true,
showLabels: true,
focusMode: false,
show1hPolygon: true,
show6hPolygon: false,
abLoop: false,
abA: 0,
abB: 0,
pinnedMmsis: new Set<string>(),
correlationByModel: new Map<string, GearCorrelationItem[]>(),
// ── Actions ────────────────────────────────────────────────
loadHistory: (frames, corrTracks, corrData, enabledModels, enabledVessels, frames6h) => {
const startTime = Date.now() - 12 * 60 * 60 * 1000;
const endTime = Date.now();
const frameTimes = frames.map(f => new Date(f.snapshotTime).getTime());
const frameTimes6h = (frames6h ?? []).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);
// 6h 전처리 (동일한 빌드 함수)
const f6h = frames6h ?? [];
const memberTrips6h = f6h.length > 0 ? buildMemberTripsData(f6h, startTime) : [];
const { segments: seg6h, dots: dots6h } = f6h.length > 0 ? buildCenterTrailData(f6h) : { segments: [], dots: [] };
const ranges6h = f6h.length > 0 ? buildSnapshotRanges(f6h, 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,
historyFrames6h: f6h,
frameTimes,
frameTimes6h,
memberTripsData6h: memberTrips6h,
centerTrailSegments6h: seg6h,
centerDotsPositions6h: dots6h,
snapshotRanges6h: ranges6h,
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, pinnedMmsis: new Set() });
} else {
set({ isPlaying: true, pinnedMmsis: new Set() });
}
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 }),
setFocusMode: (focus) => set({ focusMode: focus }),
setShow1hPolygon: (show) => set({ show1hPolygon: show }),
setShow6hPolygon: (show) => set({ show6hPolygon: show }),
setAbLoop: (on) => {
const { startTime, endTime } = get();
if (on && startTime > 0) {
// 기본 A-B: 전체 구간의 마지막 4시간
const dur = endTime - startTime;
set({ abLoop: true, abA: endTime - Math.min(dur, 4 * 3600_000), abB: endTime });
} else {
set({ abLoop: false, abA: 0, abB: 0 });
}
},
setAbA: (t) => set({ abA: t }),
setAbB: (t) => set({ abB: t }),
setPinnedMmsis: (mmsis) => set({ pinnedMmsis: mmsis }),
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: [],
historyFrames6h: [],
frameTimes: [],
frameTimes6h: [],
memberTripsData6h: [],
centerTrailSegments6h: [],
centerDotsPositions6h: [],
subClusterCenters6h: [],
snapshotRanges6h: [],
selectedGroupKey: null,
rawCorrelationTracks: [],
memberTripsData: [],
correlationTripsData: [],
centerTrailSegments: [],
centerDotsPositions: [],
subClusterCenters: [],
allHistoryMembers: [],
snapshotRanges: [],
modelCenterTrails: [],
enabledModels: new Set<string>(),
enabledVessels: new Set<string>(),
hoveredMmsi: null,
showTrails: true,
showLabels: true,
focusMode: false,
show1hPolygon: true,
show6hPolygon: false,
abLoop: false,
abA: 0,
abB: 0,
pinnedMmsis: new Set<string>(),
correlationByModel: new Map<string, GearCorrelationItem[]>(),
});
},
};
}),
);