- replay 패키지 (stores, components, hooks, services, utils, types) - WebSocket 기반 청크 데이터 수신 (ReplayWebSocketService) - 시간 기반 애니메이션 (재생/일시정지/정지, 배속 1x~1000x) - 항적 표시 토글 (playbackTrailStore - 프레임 기반 페이딩) - 선박 상태 관리 + 필터링 (선종, 신호원) - 드래그 가능한 타임라인 컨트롤러 - 라이브/리플레이 전환 (liveControl) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
201 lines
4.7 KiB
JavaScript
201 lines
4.7 KiB
JavaScript
/**
|
|
* 리플레이 데이터 저장소
|
|
* 참조: src/tracking/stores/mergedTrackStore.ts
|
|
*
|
|
* 청크 기반 선박 항적 데이터 저장 및 관리
|
|
*/
|
|
import { create } from 'zustand';
|
|
import { parseTimestamp } from '../types/replay.types';
|
|
|
|
/**
|
|
* 청크 기반 선박 데이터 병합
|
|
*/
|
|
function mergeVesselChunks(existingChunks, newChunk) {
|
|
if (!existingChunks) {
|
|
return {
|
|
vesselId: newChunk.vesselId,
|
|
sigSrcCd: newChunk.sigSrcCd,
|
|
targetId: newChunk.targetId,
|
|
shipName: newChunk.shipName,
|
|
shipKindCode: newChunk.shipKindCode,
|
|
chunks: [newChunk],
|
|
cachedPath: null,
|
|
totalDistance: newChunk.totalDistance || 0,
|
|
maxSpeed: newChunk.maxSpeed || 0,
|
|
avgSpeed: newChunk.avgSpeed || 0,
|
|
};
|
|
}
|
|
|
|
// 기존 청크에 새 청크 추가 (시간순 정렬)
|
|
const chunks = [...existingChunks.chunks, newChunk].sort((a, b) => {
|
|
const timeA = parseTimestamp(a.timestamps[0]);
|
|
const timeB = parseTimestamp(b.timestamps[0]);
|
|
return timeA - timeB;
|
|
});
|
|
|
|
return {
|
|
...existingChunks,
|
|
chunks,
|
|
cachedPath: null, // 캐시 무효화
|
|
totalDistance: Math.max(existingChunks.totalDistance, newChunk.totalDistance || 0),
|
|
maxSpeed: Math.max(existingChunks.maxSpeed, newChunk.maxSpeed || 0),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 병합된 경로 생성 (캐싱)
|
|
*/
|
|
function buildCachedPath(chunks) {
|
|
const geometry = [];
|
|
const timestamps = [];
|
|
const timestampsMs = [];
|
|
const speeds = [];
|
|
|
|
chunks.forEach((chunk) => {
|
|
if (chunk.geometry) {
|
|
geometry.push(...chunk.geometry);
|
|
}
|
|
if (chunk.timestamps) {
|
|
timestamps.push(...chunk.timestamps);
|
|
chunk.timestamps.forEach((ts) => {
|
|
timestampsMs.push(parseTimestamp(ts));
|
|
});
|
|
}
|
|
if (chunk.speeds) {
|
|
speeds.push(...chunk.speeds);
|
|
}
|
|
});
|
|
|
|
return {
|
|
geometry,
|
|
timestamps,
|
|
timestampsMs,
|
|
speeds,
|
|
lastUpdated: Date.now(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* MergedTrackStore
|
|
*/
|
|
const useMergedTrackStore = create((set, get) => ({
|
|
// ===== 상태 =====
|
|
|
|
// 청크 기반 저장소 (메인)
|
|
vesselChunks: new Map(), // Map<vesselId, VesselChunks>
|
|
|
|
// 원본 청크 (리플레이용)
|
|
rawChunks: [],
|
|
|
|
// 메타데이터
|
|
timeRange: null, // { start: number, end: number }
|
|
spatialBounds: null, // { minLon, maxLon, minLat, maxLat }
|
|
|
|
// ===== 액션 =====
|
|
|
|
/**
|
|
* 청크 추가 (최적화)
|
|
*/
|
|
addChunkOptimized: (chunkResponse) => {
|
|
const tracks = chunkResponse.tracks || [];
|
|
|
|
set((state) => {
|
|
const newVesselChunks = new Map(state.vesselChunks);
|
|
let timeRange = state.timeRange;
|
|
|
|
tracks.forEach((track) => {
|
|
const vesselId = track.vesselId;
|
|
const existingChunks = newVesselChunks.get(vesselId);
|
|
const mergedChunks = mergeVesselChunks(existingChunks, track);
|
|
|
|
newVesselChunks.set(vesselId, mergedChunks);
|
|
|
|
// 시간 범위 업데이트
|
|
if (track.timestamps && track.timestamps.length > 0) {
|
|
const firstTime = parseTimestamp(track.timestamps[0]);
|
|
const lastTime = parseTimestamp(track.timestamps[track.timestamps.length - 1]);
|
|
|
|
if (!timeRange) {
|
|
timeRange = { start: firstTime, end: lastTime };
|
|
} else {
|
|
timeRange = {
|
|
start: Math.min(timeRange.start, firstTime),
|
|
end: Math.max(timeRange.end, lastTime),
|
|
};
|
|
}
|
|
}
|
|
});
|
|
|
|
return {
|
|
vesselChunks: newVesselChunks,
|
|
timeRange,
|
|
};
|
|
});
|
|
},
|
|
|
|
/**
|
|
* 병합된 경로 반환 (캐시 사용)
|
|
*/
|
|
getMergedPath: (vesselId) => {
|
|
const vesselChunks = get().vesselChunks.get(vesselId);
|
|
if (!vesselChunks) return null;
|
|
|
|
// 캐시 확인
|
|
if (vesselChunks.cachedPath) {
|
|
return vesselChunks.cachedPath;
|
|
}
|
|
|
|
// 새로 생성
|
|
const cachedPath = buildCachedPath(vesselChunks.chunks);
|
|
|
|
// 캐시 저장
|
|
set((state) => {
|
|
const newVesselChunks = new Map(state.vesselChunks);
|
|
newVesselChunks.set(vesselId, {
|
|
...vesselChunks,
|
|
cachedPath,
|
|
});
|
|
return { vesselChunks: newVesselChunks };
|
|
});
|
|
|
|
return cachedPath;
|
|
},
|
|
|
|
/**
|
|
* 원본 청크 추가
|
|
*/
|
|
addRawChunk: (chunkResponse) => {
|
|
set((state) => ({
|
|
rawChunks: [...state.rawChunks, chunkResponse],
|
|
}));
|
|
},
|
|
|
|
/**
|
|
* 전체 초기화
|
|
*/
|
|
clear: () => {
|
|
set({
|
|
vesselChunks: new Map(),
|
|
rawChunks: [],
|
|
timeRange: null,
|
|
spatialBounds: null,
|
|
});
|
|
},
|
|
|
|
/**
|
|
* 모든 선박 ID 반환
|
|
*/
|
|
getAllVesselIds: () => {
|
|
return Array.from(get().vesselChunks.keys());
|
|
},
|
|
|
|
/**
|
|
* 선박 데이터 반환
|
|
*/
|
|
getVesselChunks: (vesselId) => {
|
|
return get().vesselChunks.get(vesselId) || null;
|
|
},
|
|
}));
|
|
|
|
export default useMergedTrackStore;
|