signal-batch/frontend/src/features/viewport-replay/components/ReplaySetupPanel.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

142 lines
5.2 KiB
TypeScript
Raw Blame 히스토리

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}