signal-batch/frontend/src/features/vessel-tracks/components/TrackQueryPanel.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

119 lines
4.1 KiB
TypeScript

import { useState } from 'react'
import { useVesselTracks } from '../hooks/useVesselTracks'
import { useTrackStore } from '../stores/trackStore'
interface TrackQueryPanelProps {
/** 최근위치에서 클릭하여 넘어온 MMSI */
initialMmsi?: string
}
/** 기본 시간 범위: 지금부터 24시간 전 */
function getDefaultTimeRange(): { start: string; end: string } {
const now = new Date()
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)
return {
start: toLocalISOString(yesterday),
end: toLocalISOString(now),
}
}
function toLocalISOString(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
export default function TrackQueryPanel({ initialMmsi }: TrackQueryPanelProps) {
const { start, end } = getDefaultTimeRange()
const [mmsiInput, setMmsiInput] = useState(initialMmsi || '')
const [startTime, setStartTime] = useState(start)
const [endTime, setEndTime] = useState(end)
const { fetchTracks, clearTracks } = useVesselTracks()
const loading = useTrackStore((s) => s.loading)
const tracks = useTrackStore((s) => s.tracks)
const handleQuery = () => {
const mmsiList = mmsiInput
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean)
if (mmsiList.length === 0) return
const startISO = startTime.replace('T', ' ') + ':00'
const endISO = endTime.replace('T', ' ') + ':00'
fetchTracks(mmsiList, startISO, endISO)
}
return (
<div className="space-y-3">
{/* MMSI 입력 */}
<div>
<label className="mb-1 block text-xs font-medium text-muted">
MMSI ( )
</label>
<input
type="text"
value={mmsiInput}
onChange={(e) => setMmsiInput(e.target.value)}
placeholder="440113620, 441027000"
className="w-full rounded-md border border-border bg-surface px-3 py-1.5 text-sm text-foreground placeholder:text-muted focus:border-primary focus:outline-none"
/>
</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>
{/* 버튼 */}
<div className="flex gap-2">
<button
onClick={handleQuery}
disabled={loading || !mmsiInput.trim()}
className="flex-1 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-white hover:bg-primary/90 disabled:opacity-50 transition"
>
{loading ? '조회 중...' : '항적 조회'}
</button>
{tracks.length > 0 && (
<button
onClick={clearTracks}
className="rounded-md border border-border px-3 py-1.5 text-xs text-muted hover:bg-surface-hover transition"
>
</button>
)}
</div>
{/* 결과 요약 */}
{tracks.length > 0 && (
<div className="rounded-lg border border-border p-2 text-xs text-muted">
<div className="font-medium text-foreground">{tracks.length} </div>
{tracks.map((t) => (
<div key={t.vesselId} className="mt-1 flex justify-between">
<span>{t.shipName || t.vesselId}</span>
<span>{t.geometry.length} pts</span>
</div>
))}
</div>
)}
</div>
)
}