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>
119 lines
4.1 KiB
TypeScript
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>
|
|
)
|
|
}
|