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>
142 lines
5.2 KiB
TypeScript
142 lines
5.2 KiB
TypeScript
import { useState } from 'react'
|
||
import { useReplayStore } from '../stores/replayStore'
|
||
import { replayWebSocket } from '../services/replayWebSocket'
|
||
import type maplibregl from 'maplibre-gl'
|
||
import { getViewportBounds } from '../../vessel-map/utils/viewport'
|
||
|
||
interface ReplaySetupPanelProps {
|
||
map: maplibregl.Map | null
|
||
}
|
||
|
||
function getDefaultTimeRange(): { start: string; end: string } {
|
||
const now = new Date()
|
||
const hoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000)
|
||
const pad = (n: number) => String(n).padStart(2, '0')
|
||
const fmt = (d: Date) =>
|
||
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||
return { start: fmt(hoursAgo), end: fmt(now) }
|
||
}
|
||
|
||
export default function ReplaySetupPanel({ map }: ReplaySetupPanelProps) {
|
||
const { start, end } = getDefaultTimeRange()
|
||
const [startTime, setStartTime] = useState(start)
|
||
const [endTime, setEndTime] = useState(end)
|
||
|
||
const connectionState = useReplayStore((s) => s.connectionState)
|
||
const querying = useReplayStore((s) => s.querying)
|
||
const queryCompleted = useReplayStore((s) => s.queryCompleted)
|
||
const receivedChunks = useReplayStore((s) => s.receivedChunks)
|
||
const totalChunks = useReplayStore((s) => s.totalChunks)
|
||
|
||
const handleConnect = async () => {
|
||
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/signal-batch/ws-tracks`
|
||
try {
|
||
await replayWebSocket.connect(wsUrl)
|
||
} catch (err) {
|
||
console.error('[Replay] Connection failed:', err)
|
||
}
|
||
}
|
||
|
||
const handleQuery = () => {
|
||
if (!map) return
|
||
const bounds = getViewportBounds(map)
|
||
const zoom = map.getZoom()
|
||
const startISO = startTime.replace('T', ' ') + ':00'
|
||
const endISO = endTime.replace('T', ' ') + ':00'
|
||
replayWebSocket.executeQuery(startISO, endISO, bounds, zoom)
|
||
}
|
||
|
||
const handleCancel = () => {
|
||
replayWebSocket.cancelQuery()
|
||
}
|
||
|
||
const isConnected = connectionState === 'connected'
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{/* ì—°ê²° ìƒ<C3AC>태 */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2 text-xs">
|
||
<span
|
||
className={`inline-block h-2 w-2 rounded-full ${
|
||
isConnected ? 'bg-green-500' : connectionState === 'connecting' ? 'bg-yellow-500 animate-pulse' : 'bg-gray-400'
|
||
}`}
|
||
/>
|
||
<span className="text-muted">
|
||
{connectionState === 'connected' ? 'ì—°ê²°ë<C2B0>¨' :
|
||
connectionState === 'connecting' ? '연결 중...' :
|
||
connectionState === 'error' ? '연결 실패' : '미연결'}
|
||
</span>
|
||
</div>
|
||
{!isConnected && (
|
||
<button
|
||
onClick={handleConnect}
|
||
disabled={connectionState === 'connecting'}
|
||
className="rounded-md bg-primary px-2 py-1 text-xs text-white hover:bg-primary/90 disabled:opacity-50"
|
||
>
|
||
ì—°ê²°
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* 시간 범위 */}
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<label className="mb-1 block text-xs font-medium text-muted">시작</label>
|
||
<input
|
||
type="datetime-local"
|
||
value={startTime}
|
||
onChange={(e) => setStartTime(e.target.value)}
|
||
className="w-full rounded-md border border-border bg-surface px-2 py-1.5 text-xs text-foreground focus:border-primary focus:outline-none"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs font-medium text-muted">종료</label>
|
||
<input
|
||
type="datetime-local"
|
||
value={endTime}
|
||
onChange={(e) => setEndTime(e.target.value)}
|
||
className="w-full rounded-md border border-border bg-surface px-2 py-1.5 text-xs text-foreground focus:border-primary focus:outline-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 쿼리 버튼 */}
|
||
<button
|
||
onClick={querying ? handleCancel : handleQuery}
|
||
disabled={!isConnected}
|
||
className={`w-full rounded-md px-3 py-1.5 text-xs font-medium transition ${
|
||
querying
|
||
? 'bg-red-500 text-white hover:bg-red-600'
|
||
: 'bg-primary text-white hover:bg-primary/90 disabled:opacity-50'
|
||
}`}
|
||
>
|
||
{querying ? '취소' : '현재 ë·°í<C2B0>¬íЏ ë¦¬í”Œë ˆì<CB86>´'}
|
||
</button>
|
||
|
||
{/* ì§„í–‰ ìƒ<C3AC>태 */}
|
||
{(querying || queryCompleted) && (
|
||
<div className="text-xs text-muted">
|
||
{querying && (
|
||
<div className="flex items-center gap-2">
|
||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-border">
|
||
<div
|
||
className="h-full rounded-full bg-primary transition-all"
|
||
style={{
|
||
width: totalChunks > 0 ? `${(receivedChunks / totalChunks) * 100}%` : '50%',
|
||
animation: totalChunks === 0 ? 'pulse 1.5s ease-in-out infinite' : undefined,
|
||
}}
|
||
/>
|
||
</div>
|
||
<span>{receivedChunks} ì²í<EFBFBD>¬</span>
|
||
</div>
|
||
)}
|
||
{queryCompleted && !querying && (
|
||
<span className="text-green-600">{receivedChunks} ì²í<EFBFBD>¬ ìˆ˜ì‹ ì™„ë£Œ</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|