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>
88 lines
3.0 KiB
TypeScript
88 lines
3.0 KiB
TypeScript
import { usePositionStore } from '../stores/positionStore'
|
|
import { getShipKindLabel, getShipKindColor } from '../../vessel-map/utils/shipKindColors'
|
|
|
|
interface VesselPopupProps {
|
|
onTrackQuery?: (mmsi: string) => void
|
|
}
|
|
|
|
export default function VesselPopup({ onTrackQuery }: VesselPopupProps) {
|
|
const selectedMmsi = usePositionStore((s) => s.selectedMmsi)
|
|
const positions = usePositionStore((s) => s.positions)
|
|
const selectVessel = usePositionStore((s) => s.selectVessel)
|
|
|
|
if (!selectedMmsi) return null
|
|
|
|
const vessel = positions.get(selectedMmsi)
|
|
if (!vessel) return null
|
|
|
|
const kindLabel = getShipKindLabel(vessel.shipKindCode)
|
|
const kindColor = getShipKindColor(vessel.shipKindCode)
|
|
|
|
return (
|
|
<div className="absolute bottom-4 left-4 z-10 w-72 rounded-lg border border-border bg-surface p-3 shadow-lg">
|
|
{/* 헤더 */}
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className="inline-block h-2.5 w-2.5 rounded-full"
|
|
style={{ backgroundColor: kindColor }}
|
|
/>
|
|
<span className="text-sm font-semibold text-foreground">
|
|
{vessel.shipNm || vessel.mmsi}
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={() => selectVessel(null)}
|
|
className="text-muted hover:text-foreground"
|
|
aria-label="Close"
|
|
>
|
|
×
|
|
</button>
|
|
</div>
|
|
|
|
{/* 선박 정보 그리드 */}
|
|
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-xs">
|
|
<InfoRow label="MMSI" value={vessel.mmsi} />
|
|
{vessel.imo ? <InfoRow label="IMO" value={String(vessel.imo)} /> : null}
|
|
<InfoRow label="선종" value={kindLabel} />
|
|
<InfoRow label="국적" value={vessel.nationalCode || '-'} />
|
|
<InfoRow label="SOG" value={`${vessel.sog.toFixed(1)} kn`} />
|
|
<InfoRow label="COG" value={`${vessel.cog.toFixed(1)}\u00B0`} />
|
|
<InfoRow label="위치" value={`${vessel.lat.toFixed(4)}, ${vessel.lon.toFixed(4)}`} />
|
|
<InfoRow label="업데이트" value={vessel.lastUpdate} />
|
|
</div>
|
|
|
|
{/* 선박 사진 */}
|
|
{vessel.shipImagePath && (
|
|
<div className="mt-2">
|
|
<img
|
|
src={vessel.shipImagePath}
|
|
alt={vessel.shipNm || vessel.mmsi}
|
|
className="h-20 w-full rounded object-cover"
|
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 항적 조회 버튼 */}
|
|
{onTrackQuery && (
|
|
<button
|
|
onClick={() => onTrackQuery(vessel.mmsi)}
|
|
className="mt-2 w-full rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-white hover:bg-primary/90 transition"
|
|
>
|
|
항적 조회
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<>
|
|
<span className="text-muted">{label}</span>
|
|
<span className="text-foreground">{value}</span>
|
|
</>
|
|
)
|
|
}
|