VesselDetail: 인라인 mock → fetchVesselAnalysis() + vessel-permits API - MMSI 기반 선박 분석 데이터 + 허가 정보 + 관련 이벤트 이력 - 알고리즘 결과 전체 표시 (risk/dark/spoofing/transship/fleet) LiveMapView: vesselStore mock → fetchVesselAnalysis() + getEvents() - 위험도 TOP 100 선박 마커 (riskLevel별 색상) - 활성 이벤트 오버레이 EventController에 vesselMmsi 필터 파라미터 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
434 lines
20 KiB
TypeScript
434 lines
20 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { useParams } from 'react-router-dom';
|
|
import { Badge } from '@shared/components/ui/badge';
|
|
import {
|
|
Search,
|
|
Ship, AlertTriangle, Radar, MapPin, Printer,
|
|
Camera, Crosshair, Ruler, CircleDot, Clock, LayoutGrid, Brain,
|
|
Loader2, WifiOff, ShieldAlert,
|
|
} from 'lucide-react';
|
|
import { BaseMap, STATIC_LAYERS, createZoneLayer, createPolylineLayer, JURISDICTION_AREAS, DEPTH_CONTOURS, useMapLayers, type MapHandle } from '@lib/map';
|
|
import {
|
|
fetchVesselAnalysis,
|
|
type VesselAnalysisItem,
|
|
} from '@/services/vesselAnalysisApi';
|
|
import { getEvents, type PredictionEvent } from '@/services/event';
|
|
|
|
// ─── 허가 정보 타입 ──────────────────────
|
|
interface VesselPermitData {
|
|
mmsi: string;
|
|
vesselName: string | null;
|
|
vesselNameCn: string | null;
|
|
flagCountry: string | null;
|
|
vesselType: string | null;
|
|
tonnage: number | null;
|
|
lengthM: number | null;
|
|
buildYear: number | null;
|
|
permitStatus: string | null;
|
|
permitNo: string | null;
|
|
permittedGearCodes: string[] | null;
|
|
permittedZones: string[] | null;
|
|
permitValidFrom: string | null;
|
|
permitValidTo: string | null;
|
|
}
|
|
|
|
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
|
|
|
async function fetchVesselPermit(mmsi: string): Promise<VesselPermitData | null> {
|
|
try {
|
|
const res = await fetch(`${API_BASE}/vessel-permits/${encodeURIComponent(mmsi)}`, {
|
|
credentials: 'include',
|
|
});
|
|
if (!res.ok) return null;
|
|
return res.json();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─── 위험도 레벨 → 색상 매핑 ──────────────
|
|
const RISK_LEVEL_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
|
|
CRITICAL: { label: '심각', color: 'text-red-400', bg: 'bg-red-500/15' },
|
|
HIGH: { label: '높음', color: 'text-orange-400', bg: 'bg-orange-500/15' },
|
|
MEDIUM: { label: '보통', color: 'text-yellow-400', bg: 'bg-yellow-500/15' },
|
|
LOW: { label: '낮음', color: 'text-blue-400', bg: 'bg-blue-500/15' },
|
|
};
|
|
|
|
const RIGHT_TOOLS = [
|
|
{ icon: Crosshair, label: '구역설정' }, { icon: Ruler, label: '거리' },
|
|
{ icon: CircleDot, label: '면적' }, { icon: Clock, label: '거리환' },
|
|
{ icon: Printer, label: '인쇄' }, { icon: Camera, label: '스냅샷' },
|
|
];
|
|
|
|
// ─── 메인 컴포넌트 ────────────────────
|
|
|
|
export function VesselDetail() {
|
|
const { id: mmsiParam } = useParams<{ id: string }>();
|
|
|
|
// 데이터 상태
|
|
const [vessel, setVessel] = useState<VesselAnalysisItem | null>(null);
|
|
const [permit, setPermit] = useState<VesselPermitData | null>(null);
|
|
const [events, setEvents] = useState<PredictionEvent[]>([]);
|
|
const [serviceAvailable, setServiceAvailable] = useState(true);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// 검색 상태 (검색 패널용)
|
|
const [searchMmsi, setSearchMmsi] = useState(mmsiParam ?? '');
|
|
const [startDate, setStartDate] = useState('');
|
|
const [endDate, setEndDate] = useState('');
|
|
|
|
const mapRef = useRef<MapHandle>(null);
|
|
|
|
// 데이터 로드
|
|
useEffect(() => {
|
|
if (!mmsiParam) {
|
|
setLoading(false);
|
|
setError('MMSI 파라미터가 필요합니다.');
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
|
|
const loadData = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const [analysisRes, permitRes, eventsRes] = await Promise.all([
|
|
fetchVesselAnalysis().catch(() => null),
|
|
fetchVesselPermit(mmsiParam),
|
|
getEvents({ vesselMmsi: mmsiParam, size: 10 }).catch(() => null),
|
|
]);
|
|
|
|
if (cancelled) return;
|
|
|
|
if (!analysisRes) {
|
|
setServiceAvailable(false);
|
|
setPermit(permitRes);
|
|
setEvents(eventsRes?.content ?? []);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
setServiceAvailable(analysisRes.serviceAvailable);
|
|
const found = analysisRes.items.find((item) => item.mmsi === mmsiParam) ?? null;
|
|
setVessel(found);
|
|
setPermit(permitRes);
|
|
setEvents(eventsRes?.content ?? []);
|
|
} catch (err) {
|
|
if (!cancelled) {
|
|
setError(err instanceof Error ? err.message : '데이터 로드 실패');
|
|
}
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
};
|
|
|
|
loadData();
|
|
return () => { cancelled = true; };
|
|
}, [mmsiParam]);
|
|
|
|
// 지도 레이어
|
|
const buildLayers = useCallback(() => {
|
|
const layers = [
|
|
...STATIC_LAYERS,
|
|
createZoneLayer('jurisdiction', JURISDICTION_AREAS.map((a) => ({
|
|
name: a.name, lat: a.lat, lng: a.lng, color: a.color, radiusM: 80000,
|
|
})), 80000, 0.05),
|
|
...DEPTH_CONTOURS.map((contour, i) =>
|
|
createPolylineLayer(`depth-${i}`, contour.points as [number, number][], {
|
|
color: '#06b6d4', width: 1, opacity: 0.3, dashArray: [2, 4],
|
|
})
|
|
),
|
|
];
|
|
|
|
// 선박 위치가 없으므로 분석 데이터의 zone 기반으로 대략적 위치 표시는 불가
|
|
// vessel-analysis에는 좌표가 없으므로 마커 생략
|
|
|
|
return layers;
|
|
}, []);
|
|
|
|
useMapLayers(mapRef, buildLayers, []);
|
|
|
|
// 위험도 점수 바
|
|
const riskScore = vessel?.algorithms.riskScore.score ?? 0;
|
|
const riskLevel = vessel?.algorithms.riskScore.level ?? 'LOW';
|
|
const riskConfig = RISK_LEVEL_CONFIG[riskLevel] ?? RISK_LEVEL_CONFIG.LOW;
|
|
|
|
return (
|
|
<div className="flex h-[calc(100vh-7.5rem)] gap-0 -m-4">
|
|
|
|
{/* ── 좌측: 선박 정보 패널 ── */}
|
|
<div className="w-[370px] shrink-0 bg-card border-r border-border flex flex-col overflow-hidden">
|
|
{/* 헤더: 검색 조건 */}
|
|
<div className="p-3 border-b border-border space-y-2">
|
|
<h2 className="text-sm font-bold text-heading">선박 상세 조회</h2>
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-[9px] text-hint w-14 shrink-0">시작/종료</span>
|
|
<input value={startDate} onChange={(e) => setStartDate(e.target.value)}
|
|
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50"
|
|
placeholder="YYYY-MM-DD HH:mm" />
|
|
<span className="text-hint text-[10px]">~</span>
|
|
<input value={endDate} onChange={(e) => setEndDate(e.target.value)}
|
|
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50"
|
|
placeholder="YYYY-MM-DD HH:mm" />
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-[9px] text-hint w-14 shrink-0">MMSI</span>
|
|
<input value={searchMmsi} onChange={(e) => setSearchMmsi(e.target.value)}
|
|
placeholder="MMSI 입력"
|
|
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none" />
|
|
<button className="flex items-center gap-1.5 bg-secondary border border-slate-700/50 rounded px-3 py-1 text-[10px] text-label hover:bg-switch-background transition-colors">
|
|
검색 <Search className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 로딩/에러 상태 */}
|
|
{loading && (
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<Loader2 className="w-6 h-6 text-blue-400 animate-spin" />
|
|
<span className="ml-2 text-sm text-hint">데이터 로드 중...</span>
|
|
</div>
|
|
)}
|
|
|
|
{error && !loading && (
|
|
<div className="flex-1 flex flex-col items-center justify-center gap-2 px-4">
|
|
<AlertTriangle className="w-8 h-8 text-red-400" />
|
|
<span className="text-sm text-red-400 text-center">{error}</span>
|
|
</div>
|
|
)}
|
|
|
|
{!serviceAvailable && !loading && !error && (
|
|
<div className="p-3 mx-3 mt-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
|
<div className="flex items-center gap-2">
|
|
<WifiOff className="w-4 h-4 text-yellow-400" />
|
|
<span className="text-[11px] text-yellow-400 font-medium">분석 서비스 오프라인</span>
|
|
</div>
|
|
<p className="text-[10px] text-hint mt-1">iran 백엔드가 연결되지 않아 분석 데이터를 표시할 수 없습니다.</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 선박 정보 */}
|
|
{!loading && !error && (
|
|
<div className="flex-1 overflow-y-auto">
|
|
{/* 기본 정보 카드 */}
|
|
<div className="p-3 border-b border-border">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Ship className="w-4 h-4 text-blue-400" />
|
|
<span className="text-[11px] font-bold text-heading">기본 정보</span>
|
|
</div>
|
|
<div className="bg-surface-overlay rounded border border-slate-700/20 text-[9px]">
|
|
{[
|
|
['MMSI', mmsiParam ?? '-'],
|
|
['선박 유형', vessel?.classification.vesselType ?? permit?.vesselType ?? '-'],
|
|
['국적', permit?.flagCountry ?? '-'],
|
|
['선명', permit?.vesselName ?? '-'],
|
|
['선명(중문)', permit?.vesselNameCn ?? '-'],
|
|
['톤수', permit?.tonnage != null ? `${permit.tonnage}톤` : '-'],
|
|
['길이', permit?.lengthM != null ? `${permit.lengthM}m` : '-'],
|
|
['건조년도', permit?.buildYear != null ? String(permit.buildYear) : '-'],
|
|
['구역', vessel?.algorithms.location.zone ?? '-'],
|
|
['기선거리', vessel?.algorithms.location.distToBaselineNm != null
|
|
? `${vessel.algorithms.location.distToBaselineNm.toFixed(1)}nm` : '-'],
|
|
['시즌', vessel?.classification.season ?? '-'],
|
|
].map(([k, v], i) => (
|
|
<div key={k} className={`flex ${i % 2 === 0 ? 'bg-surface-overlay' : ''}`}>
|
|
<span className="w-24 shrink-0 px-2.5 py-1.5 text-hint border-r border-slate-700/20">{k}</span>
|
|
<span className="flex-1 px-2.5 py-1.5 text-label">{v}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 허가 정보 */}
|
|
{permit && (
|
|
<div className="p-3 border-b border-border">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<ShieldAlert className="w-4 h-4 text-green-400" />
|
|
<span className="text-[11px] font-bold text-heading">허가 정보</span>
|
|
</div>
|
|
<div className="bg-surface-overlay rounded border border-slate-700/20 text-[9px]">
|
|
{[
|
|
['허가 상태', permit.permitStatus ?? '-'],
|
|
['허가 번호', permit.permitNo ?? '-'],
|
|
['허가 기간', permit.permitValidFrom && permit.permitValidTo
|
|
? `${permit.permitValidFrom} ~ ${permit.permitValidTo}` : '-'],
|
|
['허용 어구', permit.permittedGearCodes?.join(', ') || '-'],
|
|
['허용 구역', permit.permittedZones?.join(', ') || '-'],
|
|
].map(([k, v], i) => (
|
|
<div key={k} className={`flex ${i % 2 === 0 ? 'bg-surface-overlay' : ''}`}>
|
|
<span className="w-24 shrink-0 px-2.5 py-1.5 text-hint border-r border-slate-700/20">{k}</span>
|
|
<span className="flex-1 px-2.5 py-1.5 text-label">{v}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* AI 분석 결과 */}
|
|
{vessel && (
|
|
<div className="p-3 border-b border-border">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Brain className="w-4 h-4 text-purple-400" />
|
|
<span className="text-[11px] font-bold text-heading">AI 분석 결과</span>
|
|
</div>
|
|
|
|
{/* 위험도 점수 */}
|
|
<div className="mb-3 p-2 bg-surface-overlay rounded border border-slate-700/20">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="text-[10px] text-hint">위험도</span>
|
|
<Badge className={`border-0 text-[9px] ${riskConfig.bg} ${riskConfig.color}`}>
|
|
{riskConfig.label}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex items-baseline gap-1 mb-1">
|
|
<span className={`text-xl font-bold ${riskConfig.color}`}>
|
|
{Math.round(riskScore * 100)}
|
|
</span>
|
|
<span className="text-[10px] text-hint">/100</span>
|
|
</div>
|
|
<div className="h-1.5 bg-switch-background rounded-full overflow-hidden">
|
|
<div
|
|
className="h-1.5 bg-gradient-to-r from-red-600 to-red-400 rounded-full transition-all"
|
|
style={{ width: `${riskScore * 100}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 알고리즘 상세 */}
|
|
<div className="bg-surface-overlay rounded border border-slate-700/20 text-[9px]">
|
|
{[
|
|
['활동 상태', vessel.algorithms.activity.state],
|
|
['UCAF 점수', vessel.algorithms.activity.ucafScore.toFixed(2)],
|
|
['UCFT 점수', vessel.algorithms.activity.ucftScore.toFixed(2)],
|
|
['다크베셀', vessel.algorithms.darkVessel.isDark ? '예 (의심)' : '아니오'],
|
|
['AIS 공백', vessel.algorithms.darkVessel.gapDurationMin > 0
|
|
? `${vessel.algorithms.darkVessel.gapDurationMin}분` : '-'],
|
|
['스푸핑 점수', vessel.algorithms.gpsSpoofing.spoofingScore.toFixed(2)],
|
|
['BD09 오프셋', `${vessel.algorithms.gpsSpoofing.bd09OffsetM.toFixed(0)}m`],
|
|
['속도 점프', `${vessel.algorithms.gpsSpoofing.speedJumpCount}회`],
|
|
['클러스터', `#${vessel.algorithms.cluster.clusterId} (${vessel.algorithms.cluster.clusterSize}척)`],
|
|
['선단 역할', vessel.algorithms.fleetRole.role],
|
|
['환적 의심', vessel.algorithms.transship.isSuspect ? '예' : '아니오'],
|
|
['환적 상대', vessel.algorithms.transship.pairMmsi || '-'],
|
|
['환적 시간', vessel.algorithms.transship.durationMin > 0
|
|
? `${vessel.algorithms.transship.durationMin}분` : '-'],
|
|
].map(([k, v], i) => (
|
|
<div key={k} className={`flex ${i % 2 === 0 ? 'bg-surface-overlay' : ''}`}>
|
|
<span className="w-24 shrink-0 px-2.5 py-1.5 text-hint border-r border-slate-700/20">{k}</span>
|
|
<span className={`flex-1 px-2.5 py-1.5 ${
|
|
(k === '다크베셀' && v === '예 (의심)') || (k === '환적 의심' && v === '예')
|
|
? 'text-red-400 font-bold' : 'text-label'
|
|
}`}>{v}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 관련 이벤트 이력 */}
|
|
<div className="p-3">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<AlertTriangle className="w-4 h-4 text-red-400" />
|
|
<span className="text-[11px] font-bold text-heading">관련 이벤트 이력</span>
|
|
<span className="text-[9px] text-hint ml-auto">{events.length}건</span>
|
|
</div>
|
|
{events.length === 0 ? (
|
|
<div className="text-[10px] text-hint text-center py-4">관련 이벤트가 없습니다.</div>
|
|
) : (
|
|
<div className="space-y-1.5">
|
|
{events.map((evt) => {
|
|
const lvl = RISK_LEVEL_CONFIG[evt.level] ?? RISK_LEVEL_CONFIG.LOW;
|
|
return (
|
|
<div key={evt.id} className="bg-surface-overlay rounded border border-slate-700/20 px-2.5 py-2">
|
|
<div className="flex items-center gap-2 mb-0.5">
|
|
<Badge className={`border-0 text-[8px] px-1.5 py-0 ${lvl.bg} ${lvl.color}`}>
|
|
{evt.level}
|
|
</Badge>
|
|
<span className="text-[10px] text-heading font-medium flex-1 truncate">{evt.title}</span>
|
|
<Badge className="border-0 text-[8px] bg-muted text-muted-foreground px-1.5 py-0">
|
|
{evt.status}
|
|
</Badge>
|
|
</div>
|
|
<div className="text-[9px] text-hint">
|
|
{evt.occurredAt} {evt.areaName ? `| ${evt.areaName}` : ''}
|
|
</div>
|
|
{evt.detail && (
|
|
<div className="text-[9px] text-muted-foreground mt-0.5 truncate">{evt.detail}</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── 중앙: 지도 ── */}
|
|
<div className="flex-1 relative bg-card/40 overflow-hidden">
|
|
{/* MMSI 표시 */}
|
|
{mmsiParam && (
|
|
<div className="absolute top-3 left-3 z-10 bg-card/95 backdrop-blur-sm rounded-lg border border-border px-3 py-2">
|
|
<div className="flex items-center gap-2">
|
|
<Ship className="w-4 h-4 text-blue-400" />
|
|
<span className="text-[11px] font-bold text-heading">MMSI: {mmsiParam}</span>
|
|
{vessel && (
|
|
<Badge className={`border-0 text-[9px] ${riskConfig.bg} ${riskConfig.color}`}>
|
|
위험도: {riskConfig.label}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<BaseMap
|
|
ref={mapRef}
|
|
center={[34.5, 126.5]}
|
|
zoom={7}
|
|
height="100%"
|
|
/>
|
|
|
|
{/* 하단 좌표 바 */}
|
|
<div className="absolute bottom-0 left-0 right-0 h-6 bg-background/90 backdrop-blur-sm border-t border-border flex items-center justify-center gap-4 px-4 z-[1000]">
|
|
<span className="flex items-center gap-1 text-[8px]">
|
|
<MapPin className="w-2.5 h-2.5 text-green-400" />
|
|
<span className="text-hint">위도</span>
|
|
<span className="text-green-400 font-mono font-bold">34.5000</span>
|
|
</span>
|
|
<span className="flex items-center gap-1 text-[8px]">
|
|
<MapPin className="w-2.5 h-2.5 text-green-400" />
|
|
<span className="text-hint">경도</span>
|
|
<span className="text-green-400 font-mono font-bold">126.5000</span>
|
|
</span>
|
|
<span className="text-[8px]">
|
|
<span className="text-blue-400 font-bold">UTC</span>
|
|
<span className="text-label font-mono ml-1">{new Date().toISOString().substring(0, 19).replace('T', ' ')}</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── 우측 도구바 ── */}
|
|
<div className="w-10 bg-background border-l border-border flex flex-col items-center py-2 gap-0.5 shrink-0">
|
|
{RIGHT_TOOLS.map((t) => (
|
|
<button key={t.label} className="flex flex-col items-center gap-0.5 py-1.5 px-1 rounded hover:bg-surface-overlay text-hint hover:text-label transition-colors" title={t.label}>
|
|
<t.icon className="w-3.5 h-3.5" /><span className="text-[6px]">{t.label}</span>
|
|
</button>
|
|
))}
|
|
<div className="flex-1" />
|
|
<div className="flex flex-col border border-border rounded-lg overflow-hidden">
|
|
<button className="p-1 hover:bg-secondary text-hint hover:text-heading text-sm font-bold">+</button>
|
|
<div className="h-px bg-white/[0.06]" />
|
|
<button className="p-1 hover:bg-secondary text-hint hover:text-heading text-sm font-bold">-</button>
|
|
</div>
|
|
<button className="mt-1 flex flex-col items-center py-1 text-hint hover:text-label"><LayoutGrid className="w-3.5 h-3.5" /><span className="text-[6px]">범례</span></button>
|
|
<button className="flex flex-col items-center py-1 text-hint hover:text-label"><div className="w-3.5 h-3.5 border border-slate-500 rounded-sm" /><span className="text-[6px]">미니맵</span></button>
|
|
<button className="flex flex-col items-center py-1 bg-blue-600/20 text-blue-400 rounded"><Radar className="w-3.5 h-3.5" /><span className="text-[6px]">AI모드</span></button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|