signal-batch/frontend/src/features/area-search/stores/animationStore.ts
htlee 1cc25f9f3b feat: 다중구역이동 항적 분석 + STS 접촉 분석 프론트엔드 이관
- terra-draw 기반 지도 폴리곤/사각형/원 그리기 + 편집 (OL Draw 대체)
- 구역 항적 분석: ANY/ALL/SEQUENTIAL 검색모드, 다중구역 시각화
- STS 선박쌍 접촉 분석: 접촉쌍 그룹핑, 위험도 indicator, ScatterplotLayer
- Deck.gl 레이어: PathLayer + TripsLayer + IconLayer (커서 기반 O(1) 보간)
- 공유 타임라인 컨트롤 (재생/배속/프로그레스바)
- CSV 내보내기 (다중 방문 동적 컬럼, BOM+UTF-8)
- ApiExplorer 5모드 통합 (positions/vessel/replay/area-search/sts)

신규 17파일 (features/area-search/), 수정 5파일

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 17:07:14 +09:00

122 lines
3.3 KiB
TypeScript

import { create } from 'zustand'
import { PLAYBACK_SPEEDS } from '../types/areaSearch.types'
interface AnimationState {
isPlaying: boolean
currentTime: number // Unix epoch ms
startTime: number
endTime: number
playbackSpeed: number // 1x ~ 1000x
loop: boolean
animationFrameId: number | null
lastFrameTime: number
play: () => void
pause: () => void
stop: () => void
setPlaybackSpeed: (speed: number) => void
setCurrentTime: (time: number) => void
setProgressByRatio: (ratio: number) => void
setTimeRange: (start: number, end: number) => void
toggleLoop: () => void
getProgress: () => number // 0~100
reset: () => void
}
export { PLAYBACK_SPEEDS }
export const useAreaAnimationStore = create<AnimationState>((set, get) => ({
isPlaying: false,
currentTime: 0,
startTime: 0,
endTime: 0,
playbackSpeed: 100,
loop: false,
animationFrameId: null,
lastFrameTime: 0,
play: () => {
const state = get()
if (state.isPlaying) return
if (state.startTime >= state.endTime) return
if (state.currentTime >= state.endTime) {
set({ currentTime: state.startTime })
}
set({ isPlaying: true, lastFrameTime: performance.now() })
const animate = (timestamp: number) => {
const s = get()
if (!s.isPlaying) return
const deltaMs = timestamp - s.lastFrameTime
const increment = (deltaMs / 1000) * s.playbackSpeed * 1000
let newTime = s.currentTime + increment
if (newTime > s.endTime) {
if (s.loop) {
newTime = s.startTime
} else {
set({ isPlaying: false, currentTime: s.endTime, animationFrameId: null })
return
}
}
set({ currentTime: newTime, lastFrameTime: timestamp })
const frameId = requestAnimationFrame(animate)
set({ animationFrameId: frameId })
}
const frameId = requestAnimationFrame(animate)
set({ animationFrameId: frameId })
},
pause: () => {
const { animationFrameId } = get()
if (animationFrameId !== null) cancelAnimationFrame(animationFrameId)
set({ isPlaying: false, animationFrameId: null })
},
stop: () => {
const { animationFrameId, startTime } = get()
if (animationFrameId !== null) cancelAnimationFrame(animationFrameId)
set({ isPlaying: false, animationFrameId: null, currentTime: startTime })
},
setPlaybackSpeed: (speed) => set({ playbackSpeed: speed }),
setCurrentTime: (time) => set({ currentTime: time }),
setProgressByRatio: (ratio) => {
const { startTime, endTime } = get()
const time = startTime + (endTime - startTime) * Math.max(0, Math.min(1, ratio))
set({ currentTime: time })
},
setTimeRange: (start, end) => set({ startTime: start, endTime: end, currentTime: start }),
toggleLoop: () => set((s) => ({ loop: !s.loop })),
getProgress: () => {
const { currentTime, startTime, endTime } = get()
if (endTime <= startTime) return 0
return ((currentTime - startTime) / (endTime - startTime)) * 100
},
reset: () => {
const { animationFrameId } = get()
if (animationFrameId !== null) cancelAnimationFrame(animationFrameId)
set({
isPlaying: false,
currentTime: 0,
startTime: 0,
endTime: 0,
playbackSpeed: 100,
loop: false,
animationFrameId: null,
lastFrameTime: 0,
})
},
}))