wing-ops/frontend/src/common/components/map/VesselInteraction.tsx
jeonghyo.k 29c5293ce7 feat(vessels): 실시간 선박 신호 지도 표출 및 폴링 스케줄러 추가
- 백엔드 vessels 라우터/서비스/스케줄러 추가 (1분 주기 한국 해역 폴링)
- 공통 useVesselSignals 훅 + vesselApi/vesselSignalClient 서비스 추가
- MapView에 VesselLayer/VesselInteraction/MapBoundsTracker 통합 (호버·팝업·상세 모달)
- OilSpill/HNS/Rescue/Incidents 뷰에 선박 신호 연동
- vesselMockData 정리, aerial IMAGE_API_URL 기본값 변경
2026-04-15 14:40:28 +09:00

648 lines
19 KiB
TypeScript

import { useState } from 'react';
import type { VesselPosition } from '@common/types/vessel';
import { getShipKindLabel } from './VesselLayer';
export interface VesselHoverInfo {
x: number;
y: number;
vessel: VesselPosition;
}
function formatDateTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '-';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function displayVal(v: unknown): string {
if (v === undefined || v === null || v === '') return '-';
return String(v);
}
export function VesselHoverTooltip({ hover }: { hover: VesselHoverInfo }) {
const v = hover.vessel;
const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '- kn';
const heading = v.heading ?? v.cog;
const headingText = heading !== undefined ? `HDG ${Math.round(heading)}°` : 'HDG -';
const typeText = [getShipKindLabel(v.shipKindCode) ?? v.shipTy ?? '-', v.nationalCode]
.filter(Boolean)
.join(' · ');
return (
<div
className="absolute z-[1000] pointer-events-none rounded-md"
style={{
left: hover.x + 12,
top: hover.y - 12,
background: 'var(--bg-elevated)',
border: '1px solid var(--stroke-default)',
padding: '8px 12px',
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
minWidth: 150,
}}
>
<div className="text-label-2 font-bold text-fg" style={{ marginBottom: 3 }}>
{v.shipNm ?? '(이름 없음)'}
</div>
<div className="text-caption text-fg-disabled" style={{ marginBottom: 4 }}>
{typeText}
</div>
<div className="flex justify-between text-caption">
<span className="text-color-accent font-semibold">{speed}</span>
<span className="text-fg-disabled">{headingText}</span>
</div>
</div>
);
}
export function VesselPopupPanel({
vessel: v,
onClose,
onDetail,
}: {
vessel: VesselPosition;
onClose: () => void;
onDetail: () => void;
}) {
const statusText = v.status ?? '-';
const isAccident = (v.status ?? '').includes('사고');
const statusColor = isAccident ? 'var(--color-danger)' : 'var(--color-success)';
const statusBg = isAccident
? 'color-mix(in srgb, var(--color-danger) 15%, transparent)'
: 'color-mix(in srgb, var(--color-success) 10%, transparent)';
const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '-';
const heading = v.heading ?? v.cog;
const headingText = heading !== undefined ? `${Math.round(heading)}°` : '-';
const receivedAt = v.lastUpdate ? formatDateTime(v.lastUpdate) : '-';
return (
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%,-50%)',
zIndex: 9995,
width: 300,
background: 'rgba(13,17,23,0.97)',
border: '1px solid rgba(48,54,61,0.8)',
borderRadius: 12,
boxShadow: '0 16px 48px rgba(0,0,0,0.7)',
overflow: 'hidden',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '10px 14px',
background: 'rgba(22,27,34,0.97)',
borderBottom: '1px solid rgba(48,54,61,0.8)',
}}
>
<div
className="flex items-center justify-center text-title-2"
style={{ width: 28, height: 20 }}
>
{v.nationalCode ?? '🚢'}
</div>
<div className="flex-1 min-w-0">
<div
className="text-label-1 font-[800] whitespace-nowrap overflow-hidden text-ellipsis"
style={{ color: '#e6edf3' }}
>
{v.shipNm ?? '(이름 없음)'}
</div>
<div className="text-caption font-mono" style={{ color: '#8b949e' }}>
MMSI: {v.mmsi}
</div>
</div>
<span
onClick={onClose}
className="text-title-3 cursor-pointer p-[2px]"
style={{ color: '#8b949e' }}
>
</span>
</div>
<div
className="w-full flex items-center justify-center text-[40px]"
style={{
height: 120,
background: 'rgba(22,27,34,0.97)',
borderBottom: '1px solid rgba(48,54,61,0.6)',
color: '#484f58',
}}
>
🚢
</div>
<div
className="flex gap-2"
style={{ padding: '6px 14px', borderBottom: '1px solid rgba(48,54,61,0.6)' }}
>
<span
className="text-caption font-bold rounded text-color-info"
style={{
padding: '2px 8px',
background: 'color-mix(in srgb, var(--color-info) 12%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-info) 25%, transparent)',
}}
>
{getShipKindLabel(v.shipKindCode) ?? v.shipTy ?? '-'}
</span>
<span
className="text-caption font-bold rounded"
style={{
padding: '2px 8px',
background: statusBg,
border: `1px solid color-mix(in srgb, ${statusColor} 40%, transparent)`,
color: statusColor,
}}
>
{statusText}
</span>
</div>
<div style={{ padding: '4px 0' }}>
<PopupRow label="속도/항로" value={`${speed} / ${headingText}`} accent />
<PopupRow label="흘수" value={v.draught !== undefined ? `${v.draught.toFixed(2)} m` : '-'} />
<div
className="flex flex-col gap-1"
style={{
padding: '6px 14px',
borderBottom: '1px solid rgba(48,54,61,0.4)',
}}
>
<div className="flex justify-between">
<span className="text-caption" style={{ color: '#8b949e' }}>
</span>
<span className="text-caption font-semibold font-mono" style={{ color: '#c9d1d9' }}>
-
</span>
</div>
<div className="flex justify-between">
<span className="text-caption" style={{ color: '#8b949e' }}>
</span>
<span className="text-caption font-semibold font-mono" style={{ color: '#c9d1d9' }}>
{v.destination ?? '-'}
</span>
</div>
</div>
<PopupRow label="데이터 수신" value={receivedAt} muted />
</div>
<div className="flex gap-1.5" style={{ padding: '10px 14px' }}>
<button
onClick={onDetail}
className="flex-1 text-caption font-bold cursor-pointer text-center rounded-sm"
style={{
padding: 6,
color: '#58a6ff',
background: 'rgba(88,166,255,0.12)',
border: '1px solid rgba(88,166,255,0.3)',
}}
>
📋
</button>
<button
className="flex-1 text-caption font-bold cursor-pointer text-center rounded-sm"
style={{
padding: 6,
color: '#a5d6ff',
background: 'rgba(165,214,255,0.1)',
border: '1px solid rgba(165,214,255,0.25)',
}}
>
🔍
</button>
<button
className="flex-1 text-caption font-bold cursor-pointer text-center rounded-sm"
style={{
padding: 6,
color: '#22d3ee',
background: 'rgba(34,211,238,0.1)',
border: '1px solid rgba(34,211,238,0.25)',
}}
>
📐
</button>
</div>
</div>
);
}
function PopupRow({
label,
value,
accent,
muted,
}: {
label: string;
value: string;
accent?: boolean;
muted?: boolean;
}) {
return (
<div
className="flex justify-between text-caption"
style={{
padding: '6px 14px',
borderBottom: '1px solid rgba(48,54,61,0.4)',
}}
>
<span style={{ color: '#8b949e' }}>{label}</span>
<span
className="font-semibold font-mono"
style={{
color: muted ? '#8b949e' : accent ? '#22d3ee' : '#c9d1d9',
fontSize: muted ? 9 : 10,
}}
>
{value}
</span>
</div>
);
}
type DetTab = 'info' | 'nav' | 'spec' | 'ins' | 'dg';
const TAB_LABELS: { key: DetTab; label: string }[] = [
{ key: 'info', label: '상세정보' },
{ key: 'nav', label: '항해정보' },
{ key: 'spec', label: '선박제원' },
{ key: 'ins', label: '보험정보' },
{ key: 'dg', label: '위험물정보' },
];
export function VesselDetailModal({
vessel: v,
onClose,
}: {
vessel: VesselPosition;
onClose: () => void;
}) {
const [tab, setTab] = useState<DetTab>('info');
return (
<div
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
className="fixed inset-0 z-[10000] flex items-center justify-center"
style={{
background: 'rgba(0,0,0,0.65)',
backdropFilter: 'blur(6px)',
}}
>
<div
className="flex flex-col overflow-hidden bg-bg-surface border border-stroke"
style={{
width: 560,
height: '85vh',
borderRadius: 14,
boxShadow: '0 24px 64px rgba(0,0,0,0.7)',
}}
>
<div
className="shrink-0 flex items-center justify-between bg-bg-surface border-b border-stroke"
style={{ padding: '14px 18px' }}
>
<div className="flex items-center gap-[10px]">
<span className="text-lg">{v.nationalCode ?? '🚢'}</span>
<div>
<div className="text-title-3 font-[800] text-fg">{v.shipNm ?? '(이름 없음)'}</div>
<div className="text-caption text-fg-disabled font-mono">
MMSI: {v.mmsi} · IMO: {displayVal(v.imo)}
</div>
</div>
</div>
<span onClick={onClose} className="text-title-2 cursor-pointer text-fg-disabled">
</span>
</div>
<div
className="shrink-0 flex gap-0.5 overflow-x-auto bg-bg-base border-b border-stroke"
style={{ padding: '0 18px' }}
>
{TAB_LABELS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className="text-label-2 cursor-pointer whitespace-nowrap"
style={{
padding: '8px 11px',
fontWeight: tab === t.key ? 600 : 400,
color: tab === t.key ? 'var(--color-accent)' : 'var(--fg-disabled)',
borderBottom:
tab === t.key ? '2px solid var(--color-accent)' : '2px solid transparent',
background: 'none',
border: 'none',
transition: '0.15s',
}}
>
{t.label}
</button>
))}
</div>
<div
className="flex-1 overflow-y-auto flex flex-col"
style={{
padding: '16px 18px',
gap: 14,
scrollbarWidth: 'thin',
scrollbarColor: 'var(--stroke-default) transparent',
}}
>
{tab === 'info' && <TabInfo v={v} />}
{tab === 'nav' && <TabNav />}
{tab === 'spec' && <TabSpec v={v} />}
{tab === 'ins' && <TabInsurance />}
{tab === 'dg' && <TabDangerous />}
</div>
</div>
</div>
);
}
function Sec({
title,
borderColor,
bgColor,
badge,
children,
}: {
title: string;
borderColor?: string;
bgColor?: string;
badge?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div
style={{
border: `1px solid ${borderColor || 'var(--stroke-default)'}`,
borderRadius: 8,
overflow: 'hidden',
}}
>
<div
className="text-label-2 font-bold text-fg-sub flex items-center justify-between"
style={{
padding: '8px 12px',
background: bgColor || 'var(--bg-base)',
borderBottom: `1px solid ${borderColor || 'var(--stroke-default)'}`,
}}
>
<span>{title}</span>
{badge}
</div>
{children}
</div>
);
}
function Grid({ children }: { children: React.ReactNode }) {
return <div className="grid grid-cols-2">{children}</div>;
}
function Cell({
label,
value,
span,
color,
}: {
label: string;
value: string;
span?: boolean;
color?: string;
}) {
return (
<div
style={{
padding: '8px 12px',
borderBottom: '1px solid color-mix(in srgb, var(--stroke-default) 60%, transparent)',
borderRight: span
? 'none'
: '1px solid color-mix(in srgb, var(--stroke-default) 60%, transparent)',
gridColumn: span ? '1 / -1' : undefined,
}}
>
<div className="text-caption text-fg-disabled" style={{ marginBottom: 2 }}>
{label}
</div>
<div
className="text-label-2 font-semibold font-mono"
style={{ color: color || 'var(--fg)' }}
>
{value}
</div>
</div>
);
}
function StatusBadge({ label, color }: { label: string; color: string }) {
return (
<span
className="text-caption font-bold"
style={{
padding: '2px 6px',
borderRadius: 8,
marginLeft: 'auto',
background: `color-mix(in srgb, ${color} 25%, transparent)`,
color,
}}
>
{label}
</span>
);
}
function TabInfo({ v }: { v: VesselPosition }) {
const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '-';
const heading = v.heading ?? v.cog;
const headingText = heading !== undefined ? `${Math.round(heading)}°` : '-';
return (
<>
<div
className="w-full rounded-lg overflow-hidden flex items-center justify-center text-[60px] text-fg-disabled bg-bg-base"
style={{ height: 160 }}
>
🚢
</div>
<Sec title="📡 실시간 현황">
<Grid>
<Cell label="선박상태" value={displayVal(v.status)} />
<Cell
label="속도 / 항로"
value={`${speed} / ${headingText}`}
color="var(--color-accent)"
/>
<Cell label="위도" value={`${v.lat.toFixed(4)}°N`} />
<Cell label="경도" value={`${v.lon.toFixed(4)}°E`} />
<Cell label="흘수" value={v.draught !== undefined ? `${v.draught} m` : '-'} />
<Cell label="수신시간" value={v.lastUpdate ? formatDateTime(v.lastUpdate) : '-'} />
</Grid>
</Sec>
<Sec title="🚢 항해 일정">
<Grid>
<Cell label="출항지" value="-" />
<Cell label="입항지" value={displayVal(v.destination)} />
<Cell label="출항일시" value="-" />
<Cell label="입항일시(ETA)" value="-" />
</Grid>
</Sec>
</>
);
}
function TabNav() {
const hours = ['08', '09', '10', '11', '12', '13', '14'];
const heights = [45, 60, 78, 82, 70, 85, 75];
const colors = [
'color-mix(in srgb, var(--color-success) 30%, transparent)',
'color-mix(in srgb, var(--color-success) 40%, transparent)',
'color-mix(in srgb, var(--color-info) 40%, transparent)',
'color-mix(in srgb, var(--color-info) 50%, transparent)',
'color-mix(in srgb, var(--color-info) 50%, transparent)',
'color-mix(in srgb, var(--color-info) 60%, transparent)',
'color-mix(in srgb, var(--color-accent) 50%, transparent)',
];
return (
<>
<Sec title="🗺 최근 항적 (24h)">
<div
className="flex items-center justify-center relative overflow-hidden bg-bg-base"
style={{ height: 180 }}
>
<svg
width="100%"
height="100%"
viewBox="0 0 400 180"
style={{ position: 'absolute', inset: 0 }}
>
<path
d="M50,150 C80,140 120,100 160,95 S240,70 280,50 S340,30 370,20"
fill="none"
stroke="var(--color-accent)"
strokeWidth="2"
strokeDasharray="6,3"
opacity=".6"
/>
<circle cx="50" cy="150" r="4" fill="var(--fg-disabled)" />
<circle cx="160" cy="95" r="3" fill="var(--color-accent)" opacity=".5" />
<circle cx="280" cy="50" r="3" fill="var(--color-accent)" opacity=".5" />
<circle cx="370" cy="20" r="5" fill="var(--color-accent)" />
</svg>
</div>
</Sec>
<Sec title="📊 속도 이력">
<div className="p-3 bg-bg-base">
<div className="flex items-end gap-1.5" style={{ height: 80 }}>
{hours.map((h, i) => (
<div key={h} className="flex-1 flex flex-col items-center gap-0.5">
<div
className="w-full"
style={{
background: colors[i],
borderRadius: '2px 2px 0 0',
height: `${heights[i]}%`,
}}
/>
<span className="text-caption text-fg-disabled">{h}</span>
</div>
))}
</div>
<div className="text-center text-caption text-fg-disabled" style={{ marginTop: 6 }}>
: <b className="text-color-info">8.4 kn</b> · :{' '}
<b className="text-color-accent">11.2 kn</b>
</div>
</div>
</Sec>
</>
);
}
function TabSpec({ v }: { v: VesselPosition }) {
const loa = v.length !== undefined ? `${v.length} m` : '-';
const beam = v.width !== undefined ? `${v.width} m` : '-';
return (
<>
<Sec title="📐 선체 제원">
<Grid>
<Cell label="선종" value={displayVal(getShipKindLabel(v.shipKindCode) ?? v.shipTy)} />
<Cell label="선적국" value={displayVal(v.nationalCode)} />
<Cell label="총톤수 (GT)" value="-" />
<Cell label="재화중량 (DWT)" value="-" />
<Cell label="전장 (LOA)" value={loa} />
<Cell label="선폭" value={beam} />
<Cell label="건조년도" value="-" />
<Cell label="건조 조선소" value="-" />
</Grid>
</Sec>
<Sec title="📡 통신 / 식별">
<Grid>
<Cell label="MMSI" value={String(v.mmsi)} />
<Cell label="IMO" value={displayVal(v.imo)} />
<Cell label="호출부호" value="-" />
<Cell label="선급" value="-" />
</Grid>
</Sec>
</>
);
}
function TabInsurance() {
return (
<>
<Sec title="🏢 선주 / 운항사">
<Grid>
<Cell label="선주" value="-" />
<Cell label="운항사" value="-" />
<Cell label="P&I Club" value="-" span />
</Grid>
</Sec>
<Sec
title="🚢 선체보험 (H&M)"
borderColor="color-mix(in srgb, var(--color-accent) 20%, transparent)"
bgColor="color-mix(in srgb, var(--color-accent) 6%, transparent)"
badge={<StatusBadge label="-" color="var(--color-success)" />}
>
<Grid>
<Cell label="보험사" value="-" />
<Cell label="보험가액" value="-" color="var(--color-accent)" />
<Cell label="보험기간" value="-" color="var(--color-success)" />
<Cell label="면책금" value="-" />
</Grid>
</Sec>
</>
);
}
function TabDangerous() {
return (
<Sec
title="⚠ 위험물 화물 신고정보"
bgColor="color-mix(in srgb, var(--color-warning) 6%, transparent)"
>
<Grid>
<Cell label="화물명" value="-" color="var(--color-warning)" />
<Cell label="컨테이너갯수/총량" value="-" />
<Cell label="하역업체코드" value="-" />
<Cell label="하역기간" value="-" />
</Grid>
</Sec>
);
}