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>
126 lines
4.0 KiB
TypeScript
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])
|
|
}
|