signal-batch/frontend/src/features/viewport-replay/components/ReplayLayer.tsx
htlee ed0f3056b1 feat: Ship-GIS 기능 이관 — 최근위치/선박항적/뷰포트 리플레이
dark(ship-gis) 프로젝트의 맵 기반 3대 기능을 API 탐색기에 이관.
Feature 폴더 모듈화 구조로 타 프로젝트 재활용 가능하게 구성.

Phase 1: vessel-map 공유 모듈 (Deck.gl 9 + Zustand 5 + STOMP)
Phase 2: 최근 위치 (30초 폴링 + IconLayer + 선종 필터 + 팝업)
Phase 3: 선박 항적 (MMSI 조회 + PathLayer + 타임라인 보간)
Phase 4: 뷰포트 리플레이 (STOMP WebSocket 청크 + TripsLayer 애니메이션)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 15:19:21 +09:00

126 lines
4.0 KiB
TypeScript

import { useMemo, useRef, useEffect } from 'react'
import type { Layer } from '@deck.gl/core'
import { TripsLayer } from '@deck.gl/geo-layers'
import { useAnimationStore } from '../hooks/useReplayAnimation'
import { useMergedTrackStore } from '../stores/mergedTrackStore'
import { useReplayStore } from '../stores/replayStore'
import { createVesselIconLayer } from '../../vessel-map/layers/VesselIconLayer'
import { getIconKey, getIconSize } from '../../vessel-map/utils/shipKindColors'
import type { VesselIconData } from '../../vessel-map'
const TRAIL_LENGTH_MS = 3_600_000 // 1시간 시각적 트레일
const RENDER_INTERVAL_MS = 100 // ~10fps 스로틀
interface TripsData {
vesselId: string
shipKindCode: string
path: [number, number][]
timestamps: number[]
}
interface ReplayLayerProps {
zoom: number
onLayersUpdate: (layers: Layer[]) => void
}
/**
* 리플레이 레이어 관리 훅
* TripsLayer(항적 트레일) + IconLayer(현재 위치) 조합
* React 렌더 바이패스: zustand subscribe로 직접 레이어 업데이트
*/
export function useReplayLayer({ zoom, onLayersUpdate }: ReplayLayerProps) {
const queryCompleted = useReplayStore((s) => s.queryCompleted)
const vesselChunks = useMergedTrackStore((s) => s.vesselChunks)
const tripsDataRef = useRef<TripsData[]>([])
const lastRenderTimeRef = useRef(0)
// TripsData 구축 (쿼리 완료 또는 청크 수신 시)
useMemo(() => {
const paths = useMergedTrackStore.getState().getAllMergedPaths()
if (paths.length === 0) {
tripsDataRef.current = []
return
}
const startTime = Math.min(...paths.map((p) => p.timestampsMs[0]))
tripsDataRef.current = paths.map((p) => ({
vesselId: p.vesselId,
shipKindCode: p.shipKindCode || '000027',
path: p.geometry,
timestamps: p.timestampsMs.map((t) => t - startTime),
}))
// 애니메이션 시간 범위 초기화
useAnimationStore.getState().initTimeRange()
}, [vesselChunks])
// 렌더링 루프 (zustand subscribe → React 바이패스)
useEffect(() => {
if (!queryCompleted || tripsDataRef.current.length === 0) return
const renderFrame = () => {
const { currentTime, startTime } = useAnimationStore.getState()
const relativeTime = currentTime - startTime
const positions = useAnimationStore.getState().getCurrentVesselPositions()
const iconData: VesselIconData[] = positions.map((p) => ({
mmsi: p.vesselId,
position: [p.lon, p.lat],
angle: p.heading,
icon: getIconKey(p.shipKindCode, p.speed),
size: getIconSize(zoom, p.shipKindCode, p.speed),
shipNm: p.shipName,
shipKindCode: p.shipKindCode,
sog: p.speed,
}))
const layers: Layer[] = [
new TripsLayer<TripsData>({
id: 'replay-trips-trail',
data: tripsDataRef.current,
getPath: (d) => d.path,
getTimestamps: (d) => d.timestamps,
getColor: [120, 120, 120, 180],
widthMinPixels: 2,
widthMaxPixels: 3,
jointRounded: true,
capRounded: true,
fadeTrail: true,
trailLength: TRAIL_LENGTH_MS,
currentTime: relativeTime,
}),
createVesselIconLayer({
id: 'replay-vessel-icon',
data: iconData,
pickable: false,
}),
]
onLayersUpdate(layers)
}
// 초기 렌더
renderFrame()
// currentTime 구독 (Zustand v5: selector 없이 전체 상태 구독)
let prevTime = useAnimationStore.getState().currentTime
const unsub = useAnimationStore.subscribe((state) => {
if (state.currentTime === prevTime) return
prevTime = state.currentTime
if (!state.isPlaying) {
renderFrame()
return
}
const now = performance.now()
if (now - lastRenderTimeRef.current >= RENDER_INTERVAL_MS) {
lastRenderTimeRef.current = now
renderFrame()
}
})
return unsub
}, [queryCompleted, zoom, onLayersUpdate])
}