- 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>
399 lines
14 KiB
TypeScript
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[]>(),
|
|
});
|
|
},
|
|
};
|
|
}),
|
|
);
|