feat(frontend): 중국어선 감시 실데이터 연동 + 특이운항 미니맵/판별 패널

Tab 1 AI 감시 대시보드 / Tab 2 환적탐지 / Tab 3 어구판별 3개 탭을
deprecated iran proxy 에서 자체 /api/analysis/* 로 전환하고, 특이운항
선박의 24h 항적과 판별 구간 상세를 지도와 패널로 제공한다.

서비스 계층
- analysisApi.ts 확장: getAnalysisStats / getAnalysisVessels(필터 3종) /
  getGearDetections 추가. VesselAnalysis 에 violationCategories /
  bd09OffsetM / ucafScore / ucftScore / clusterId 필드 노출
- analysisAdapter.ts: flat VesselAnalysis → nested VesselAnalysisItem
  변환으로 기존 컴포넌트 재사용
- vesselAnalysisApi.ts fetchVesselAnalysis @deprecated 마킹

Tab 1 (ChinaFishing)
- 서버 집계(stats) 기준 카운터 재구성. 중국어선 / Dark / 환적 / 고위험
  모두 mmsiPrefix=412 로 서버 필터
- 선박 리스트 vessel_type UNKNOWN 인 경우 "중국어선" + "미분류" 로 표시
- 특이운항 row 클릭 → 아래 행에 미니맵 + 판별 패널 배치
- 관심영역 / VIIRS / 기상 / VTS 카드에 "데모 데이터" 뱃지. 비허가 /
  제재 / 관심 탭 disabled + "준비중" 뱃지

Tab 2 (RealVesselAnalysis)
- /analysis/dark / /analysis/transship / /analysis/vessels mode별 분기
- 상단 통계 카드를 items 클라이언트 집계로 전환해 하단 테이블과 정합

Tab 3 (GearIdentification)
- 최하단 "최근 자동탐지 결과" 섹션 추가. row 클릭 시 상단 입력 폼
  프리필 + 결과 패널에 자동탐지 근거 프리셋

특이운항 판별 시각화 (VesselMiniMap / VesselAnomalyPanel /
vesselAnomaly 유틸)
- 24h getAnalysisHistory 로드 → classifyAnomaly 로 DARK/SPOOFING/
  TRANSSHIP/GEAR_VIOLATION/HIGH_RISK 5개 카테고리 판별. 좌표는
  top-level lat/lon 우선, features.gap_start_* fallback
- groupAnomaliesToSegments: 5분 주기 반복되는 동일 신호를 시작~종료
  구간으로 병합
- 미니맵: 전체 궤적은 연한 파랑, segment 시간범위와 매칭되는 AIS
  궤적 서브구간을 severity 색(CRITICAL 빨강 / WARNING 주황 / INFO
  파랑) 으로 하이라이트. 이벤트 기준 좌표는 작은 흰 점
- 판별 패널: 시작→종료 · 지속 · N회 연속 감지 · 카테고리 뱃지 · 설명
This commit is contained in:
htlee 2026-04-16 14:31:26 +09:00
부모 820ed75585
커밋 d82eaf7e79
9개의 변경된 파일1303개의 추가작업 그리고 183개의 파일을 삭제

파일 보기

@ -3,9 +3,9 @@ import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { PageContainer } from '@shared/components/layout'; import { PageContainer } from '@shared/components/layout';
import { import {
Search, Ship, Clock, ChevronRight, ChevronLeft, Cloud, Search, Clock, ChevronRight, ChevronLeft, Cloud,
Eye, AlertTriangle, Radio, RotateCcw, Eye, AlertTriangle, Radio, RotateCcw,
MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2 Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2
} from 'lucide-react'; } from 'lucide-react';
import { formatDateTime } from '@shared/utils/dateFormat'; import { formatDateTime } from '@shared/utils/dateFormat';
import { ALERT_LEVELS, getAlertLevelLabel, type AlertLevel } from '@shared/constants/alertLevels'; import { ALERT_LEVELS, getAlertLevelLabel, type AlertLevel } from '@shared/constants/alertLevels';
@ -14,22 +14,23 @@ import { useSettingsStore } from '@stores/settingsStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { GearIdentification } from './GearIdentification'; import { GearIdentification } from './GearIdentification';
import { RealAllVessels, RealTransshipSuspects } from './RealVesselAnalysis'; import { RealAllVessels, RealTransshipSuspects } from './RealVesselAnalysis';
import { VesselMiniMap } from './components/VesselMiniMap';
import { VesselAnomalyPanel } from './components/VesselAnomalyPanel';
import { extractAnomalies, groupAnomaliesToSegments } from './components/vesselAnomaly';
import { PieChart as EcPieChart } from '@lib/charts'; import { PieChart as EcPieChart } from '@lib/charts';
import type { VesselAnalysisItem } from '@/services/vesselAnalysisApi';
import { import {
fetchVesselAnalysis, getAnalysisStats,
filterDarkVessels, getAnalysisVessels,
filterTransshipSuspects, getAnalysisHistory,
type VesselAnalysisItem, type AnalysisStats,
type VesselAnalysisStats, type VesselAnalysis,
} from '@/services/vesselAnalysisApi'; } from '@/services/analysisApi';
import { toVesselItem } from '@/services/analysisAdapter';
// ─── 중국 MMSI prefix ───────────── // ─── 중국 MMSI prefix ─────────────
const CHINA_MMSI_PREFIX = '412'; const CHINA_MMSI_PREFIX = '412';
function isChinaVessel(mmsi: string): boolean {
return mmsi.startsWith(CHINA_MMSI_PREFIX);
}
// ─── 특이운항 선박 리스트 타입 ──────────────── // ─── 특이운항 선박 리스트 타입 ────────────────
type VesselStatus = '의심' | '양호' | '경고'; type VesselStatus = '의심' | '양호' | '경고';
interface VesselItem { interface VesselItem {
@ -53,14 +54,16 @@ function deriveVesselStatus(score: number): VesselStatus {
function mapToVesselItem(item: VesselAnalysisItem, idx: number): VesselItem { function mapToVesselItem(item: VesselAnalysisItem, idx: number): VesselItem {
const score = item.algorithms.riskScore.score; const score = item.algorithms.riskScore.score;
const vt = item.classification.vesselType;
const hasType = vt && vt !== 'UNKNOWN' && vt !== '';
return { return {
id: String(idx + 1), id: String(idx + 1),
mmsi: item.mmsi, mmsi: item.mmsi,
callSign: '-', callSign: '-',
channel: '', channel: '',
source: 'AIS', source: 'AIS',
name: item.classification.vesselType || item.mmsi, name: hasType ? vt : '중국어선',
type: item.classification.fishingPct > 0.5 ? 'Fishing' : 'Cargo', type: item.classification.fishingPct > 0.5 ? 'Fishing' : hasType ? 'Cargo' : '미분류',
country: 'China', country: 'China',
status: deriveVesselStatus(score), status: deriveVesselStatus(score),
riskPct: score, riskPct: score,
@ -202,10 +205,14 @@ export function ChinaFishing() {
const [mode, setMode] = useState<'dashboard' | 'transfer' | 'gear'>('dashboard'); const [mode, setMode] = useState<'dashboard' | 'transfer' | 'gear'>('dashboard');
const [vesselTab, setVesselTab] = useState<'특이운항' | '비허가 선박' | '제재 선박' | '관심 선박'>('특이운항'); const [vesselTab, setVesselTab] = useState<'특이운항' | '비허가 선박' | '제재 선박' | '관심 선박'>('특이운항');
const [statsTab, setStatsTab] = useState<'불법조업 통계' | '특이선박 통계' | '위험선박 통계'>('불법조업 통계'); const [statsTab, setStatsTab] = useState<'불법조업 통계' | '특이선박 통계' | '위험선박 통계'>('불법조업 통계');
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
const [history, setHistory] = useState<VesselAnalysis[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState('');
// API state // API state
const [allItems, setAllItems] = useState<VesselAnalysisItem[]>([]); const [topVessels, setTopVessels] = useState<VesselAnalysisItem[]>([]);
const [apiStats, setApiStats] = useState<VesselAnalysisStats | null>(null); const [apiStats, setApiStats] = useState<AnalysisStats | null>(null);
const [serviceAvailable, setServiceAvailable] = useState(true); const [serviceAvailable, setServiceAvailable] = useState(true);
const [apiLoading, setApiLoading] = useState(false); const [apiLoading, setApiLoading] = useState(false);
const [apiError, setApiError] = useState(''); const [apiError, setApiError] = useState('');
@ -214,10 +221,18 @@ export function ChinaFishing() {
setApiLoading(true); setApiLoading(true);
setApiError(''); setApiError('');
try { try {
const res = await fetchVesselAnalysis(); const [stats, topPage] = await Promise.all([
setServiceAvailable(res.serviceAvailable); getAnalysisStats({ hours: 1, mmsiPrefix: CHINA_MMSI_PREFIX }),
setAllItems(res.items); getAnalysisVessels({
setApiStats(res.stats); hours: 1,
mmsiPrefix: CHINA_MMSI_PREFIX,
minRiskScore: 40,
size: 20,
}),
]);
setApiStats(stats);
setTopVessels(topPage.content.map(toVesselItem));
setServiceAvailable(true);
} catch (e: unknown) { } catch (e: unknown) {
setApiError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다'); setApiError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다');
setServiceAvailable(false); setServiceAvailable(false);
@ -228,55 +243,77 @@ export function ChinaFishing() {
useEffect(() => { loadApi(); }, [loadApi]); useEffect(() => { loadApi(); }, [loadApi]);
// 중국어선 필터 // 선박 선택 시 24h 분석 이력 로드 (미니맵 anomaly 포인트 + 판별 패널 공통 데이터)
const chinaVessels = useMemo( useEffect(() => {
() => allItems.filter((i) => isChinaVessel(i.mmsi)), if (!selectedMmsi) { setHistory([]); setHistoryError(''); return; }
[allItems], let cancelled = false;
setHistoryLoading(true); setHistoryError('');
getAnalysisHistory(selectedMmsi, 24)
.then((rows) => { if (!cancelled) setHistory(rows); })
.catch((e: unknown) => {
if (cancelled) return;
setHistory([]);
setHistoryError(e instanceof Error ? e.message : '이력 조회 실패');
})
.finally(() => { if (!cancelled) setHistoryLoading(false); });
return () => { cancelled = true; };
}, [selectedMmsi]);
const anomalySegments = useMemo(
() => groupAnomaliesToSegments(extractAnomalies(history)),
[history],
); );
const chinaDark = useMemo(() => filterDarkVessels(chinaVessels), [chinaVessels]); // ─ 파생 계산 (서버 집계 우선) ─
const chinaTransship = useMemo(() => filterTransshipSuspects(chinaVessels), [chinaVessels]); // Tab 1 '분석 대상' 및 카운터는 apiStats 값이 SSOT.
// topVessels 는 minRiskScore=40 으로 필터된 상위 20척 (특이운항 리스트 전용).
const countersRow1 = useMemo(() => {
if (!apiStats) return [];
return [
{ label: '통합', count: apiStats.total, color: '#6b7280' },
{ label: 'AIS', count: apiStats.total, color: '#3b82f6' },
{ label: 'EEZ 내', count: apiStats.territorialCount + apiStats.contiguousCount, color: '#8b5cf6' },
{ label: '어업선', count: apiStats.fishingCount, color: '#10b981' },
];
}, [apiStats]);
// 센서 카운터 (API 기반) const countersRow2 = useMemo(() => {
const countersRow1 = useMemo(() => [ if (!apiStats) return [];
{ label: '통합', count: allItems.length, color: '#6b7280' }, return [
{ label: 'AIS', count: allItems.length, color: '#3b82f6' }, { label: '중국어선', count: apiStats.total, color: '#f97316' },
{ label: 'EEZ 내', count: allItems.filter((i) => i.algorithms.location.zone !== 'EEZ_OR_BEYOND').length, color: '#8b5cf6' }, { label: 'Dark Vessel', count: apiStats.darkCount, color: '#ef4444' },
{ label: '어업선', count: allItems.filter((i) => i.classification.fishingPct > 0.5).length, color: '#10b981' }, { label: '환적 의심', count: apiStats.transshipCount, color: '#06b6d4' },
], [allItems]); { label: '고위험', count: apiStats.criticalCount + apiStats.highCount, color: '#ef4444' },
];
}, [apiStats]);
const countersRow2 = useMemo(() => [ // 특이운항 선박 리스트 (서버에서 이미 riskScore >= 40 로 필터링된 상위 20척)
{ label: '중국어선', count: chinaVessels.length, color: '#f97316' },
{ label: 'Dark Vessel', count: chinaDark.length, color: '#ef4444' },
{ label: '환적 의심', count: chinaTransship.length, color: '#06b6d4' },
{ label: '고위험', count: chinaVessels.filter((i) => i.algorithms.riskScore.score >= 70).length, color: '#ef4444' },
], [chinaVessels, chinaDark, chinaTransship]);
// 특이운항 선박 리스트 (중국어선 중 riskScore >= 40)
const vesselList: VesselItem[] = useMemo( const vesselList: VesselItem[] = useMemo(
() => chinaVessels () => topVessels.map((item, idx) => mapToVesselItem(item, idx)),
.filter((i) => i.algorithms.riskScore.score >= 40) [topVessels],
.sort((a, b) => b.algorithms.riskScore.score - a.algorithms.riskScore.score)
.slice(0, 20)
.map((item, idx) => mapToVesselItem(item, idx)),
[chinaVessels],
); );
// 위험도별 분포 (도넛 차트용) // 위험도별 분포 (도넛 차트용) — apiStats 기반
const riskDistribution = useMemo(() => { const riskDistribution = useMemo(() => {
const critical = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'CRITICAL').length; if (!apiStats) return { critical: 0, high: 0, medium: 0, low: 0, total: 0 };
const high = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'HIGH').length; return {
const medium = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'MEDIUM').length; critical: apiStats.criticalCount,
const low = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'LOW').length; high: apiStats.highCount,
return { critical, high, medium, low, total: chinaVessels.length }; medium: apiStats.mediumCount,
}, [chinaVessels]); low: apiStats.lowCount,
total: apiStats.total,
};
}, [apiStats]);
// 안전도 지수 계산 // 안전도 지수 계산 (avgRiskScore 0~100 → 0~10 스케일)
const safetyIndex = useMemo(() => { const safetyIndex = useMemo(() => {
if (chinaVessels.length === 0) return { risk: 0, safety: 100 }; const avgRisk = apiStats ? Number(apiStats.avgRiskScore) : 0;
const avgRisk = chinaVessels.reduce((s, i) => s + i.algorithms.riskScore.score, 0) / chinaVessels.length; if (!apiStats || apiStats.total === 0) return { risk: 0, safety: 100 };
return { risk: Number((avgRisk / 10).toFixed(2)), safety: Number(((100 - avgRisk) / 10).toFixed(2)) }; return {
}, [chinaVessels]); risk: Number((avgRisk / 10).toFixed(2)),
safety: Number(((100 - avgRisk) / 10).toFixed(2)),
};
}, [apiStats]);
const vesselTabs = ['특이운항', '비허가 선박', '제재 선박', '관심 선박'] as const; const vesselTabs = ['특이운항', '비허가 선박', '제재 선박', '관심 선박'] as const;
const statsTabs = ['불법조업 통계', '특이선박 통계', '위험선박 통계'] as const; const statsTabs = ['불법조업 통계', '특이선박 통계', '위험선박 통계'] as const;
@ -319,7 +356,7 @@ export function ChinaFishing() {
{!serviceAvailable && ( {!serviceAvailable && (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs"> <div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs">
<AlertTriangle className="w-4 h-4 shrink-0" /> <AlertTriangle className="w-4 h-4 shrink-0" />
<span>iran - </span> <span> API - </span>
</div> </div>
)} )}
@ -371,7 +408,7 @@ export function ChinaFishing() {
</div> </div>
<div className="flex items-center gap-2 mb-4 text-[10px] text-muted-foreground"> <div className="flex items-center gap-2 mb-4 text-[10px] text-muted-foreground">
<span> </span> <span> </span>
<span className="text-lg font-extrabold text-heading">{allItems.length.toLocaleString()}</span> <span className="text-lg font-extrabold text-heading">{(apiStats?.total ?? 0).toLocaleString()}</span>
<span className="text-hint">()</span> <span className="text-hint">()</span>
</div> </div>
@ -422,12 +459,15 @@ export function ChinaFishing() {
</Card> </Card>
</div> </div>
{/* 관심영역 안전도 */} {/* 관심영역 안전도 (해역 지오펜스 미구축 → 데모) */}
<div className="col-span-4"> <div className="col-span-4">
<Card className="bg-surface-raised border-slate-700/30 h-full"> <Card className="bg-surface-raised border-slate-700/30 h-full">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-heading"> </span> <div className="flex items-center gap-2">
<span className="text-sm font-bold text-heading"> </span>
<Badge intent="warning" size="xs" className="font-normal"> </Badge>
</div>
<select aria-label="관심영역 선택" className="bg-secondary border border-slate-700/50 rounded px-2 py-0.5 text-[10px] text-label focus:outline-none"> <select aria-label="관심영역 선택" className="bg-secondary border border-slate-700/50 rounded px-2 py-0.5 text-[10px] text-label focus:outline-none">
<option> A</option> <option> A</option>
<option> B</option> <option> B</option>
@ -453,7 +493,14 @@ export function ChinaFishing() {
<span className="text-green-400 font-bold ml-auto"></span> <span className="text-green-400 font-bold ml-auto"></span>
</div> </div>
</div> </div>
<CircleGauge value={chinaVessels.length > 0 ? Number(((1 - riskDistribution.critical / Math.max(chinaVessels.length, 1)) * 100).toFixed(1)) : 100} label="" /> <CircleGauge
value={
apiStats && apiStats.total > 0
? Number(((1 - apiStats.criticalCount / Math.max(apiStats.total, 1)) * 100).toFixed(1))
: 100
}
label=""
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -467,21 +514,30 @@ export function ChinaFishing() {
<div className="col-span-5"> <div className="col-span-5">
<Card className="bg-surface-raised border-slate-700/30"> <Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-0"> <CardContent className="p-0">
{/* 탭 헤더 */} {/* 탭 헤더 — 특이운항만 활성, 나머지 3개는 데이터 소스 미연동 */}
<div className="flex border-b border-slate-700/30"> <div className="flex border-b border-slate-700/30">
{vesselTabs.map((tab) => ( {vesselTabs.map((tab) => {
<button type="button" const disabled = tab !== '특이운항';
key={tab} return (
onClick={() => setVesselTab(tab)} <button type="button"
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${ key={tab}
vesselTab === tab onClick={() => !disabled && setVesselTab(tab)}
? 'text-heading border-b-2 border-blue-500 bg-surface-overlay' disabled={disabled}
: 'text-hint hover:text-label' className={`flex-1 py-2.5 text-[11px] font-medium transition-colors flex items-center justify-center gap-1 ${
}`} vesselTab === tab
> ? 'text-heading border-b-2 border-blue-500 bg-surface-overlay'
{tab} : disabled
</button> ? 'text-hint opacity-50 cursor-not-allowed'
))} : 'text-hint hover:text-label'
}`}
>
{tab}
{disabled && (
<Badge intent="warning" size="xs" className="font-normal"></Badge>
)}
</button>
);
})}
</div> </div>
{/* 선박 목록 */} {/* 선박 목록 */}
@ -491,28 +547,34 @@ export function ChinaFishing() {
{apiLoading ? '데이터 로딩 중...' : '중국어선 특이운항 데이터가 없습니다'} {apiLoading ? '데이터 로딩 중...' : '중국어선 특이운항 데이터가 없습니다'}
</div> </div>
)} )}
{vesselList.map((v) => ( {vesselList.map((v) => {
<div const selected = v.mmsi === selectedMmsi;
key={v.id} return (
className="flex items-center gap-3 px-4 py-3 border-b border-slate-700/20 hover:bg-surface-overlay transition-colors cursor-pointer group" <div
> key={v.id}
<StatusRing status={v.status} riskPct={v.riskPct} /> onClick={() => setSelectedMmsi(selected ? null : v.mmsi)}
<div className="flex-1 min-w-0"> className={`flex items-center gap-3 px-4 py-3 border-b border-slate-700/20 transition-colors cursor-pointer group ${
<div className="flex items-center gap-2 text-[10px] text-hint mb-0.5"> selected ? 'bg-blue-500/10 hover:bg-blue-500/15' : 'hover:bg-surface-overlay'
<span>MMSI | <span className="text-label">{v.mmsi}</span></span> }`}
<span> | <span className="text-label">{v.source}</span></span> >
</div> <StatusRing status={v.status} riskPct={v.riskPct} />
<div className="flex items-center gap-2"> <div className="flex-1 min-w-0">
<span className="text-[12px] font-bold text-heading">{v.name}</span> <div className="flex items-center gap-2 text-[10px] text-hint mb-0.5">
<Badge intent="info" size="xs" className="px-1.5 py-0">{v.type}</Badge> <span>MMSI | <span className="text-label">{v.mmsi}</span></span>
</div> <span> | <span className="text-label">{v.source}</span></span>
<div className="flex items-center gap-1 mt-0.5 text-[9px] text-hint"> </div>
<span>{v.country}</span> <div className="flex items-center gap-2">
<span className="text-[12px] font-bold text-heading">{v.name}</span>
<Badge intent="info" size="xs" className="px-1.5 py-0">{v.type}</Badge>
</div>
<div className="flex items-center gap-1 mt-0.5 text-[9px] text-hint">
<span>{v.country}</span>
</div>
</div> </div>
<ChevronRight className="w-4 h-4 text-hint group-hover:text-muted-foreground shrink-0" />
</div> </div>
<ChevronRight className="w-4 h-4 text-hint group-hover:text-muted-foreground shrink-0" /> );
</div> })}
))}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -524,19 +586,20 @@ export function ChinaFishing() {
{/* 통계 차트 */} {/* 통계 차트 */}
<Card className="bg-surface-raised border-slate-700/30"> <Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-0"> <CardContent className="p-0">
{/* 탭 */} {/* 탭 — 월별 집계 API 미연동 */}
<div className="flex border-b border-slate-700/30"> <div className="flex border-b border-slate-700/30">
{statsTabs.map((tab) => ( {statsTabs.map((tab) => (
<button type="button" <button type="button"
key={tab} key={tab}
onClick={() => setStatsTab(tab)} onClick={() => setStatsTab(tab)}
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${ className={`flex-1 py-2.5 text-[11px] font-medium transition-colors flex items-center justify-center gap-1 ${
statsTab === tab statsTab === tab
? 'text-heading border-b-2 border-green-500 bg-surface-overlay' ? 'text-heading border-b-2 border-green-500 bg-surface-overlay'
: 'text-hint hover:text-label' : 'text-hint hover:text-label'
}`} }`}
> >
{tab} {tab}
<Badge intent="warning" size="xs" className="font-normal"></Badge>
</button> </button>
))} ))}
</div> </div>
@ -594,11 +657,14 @@ export function ChinaFishing() {
{/* 하단 카드 3개 */} {/* 하단 카드 3개 */}
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{/* 최근 위성영상 분석 */} {/* 최근 위성영상 분석 (VIIRS 수집 파이프라인 미구축 → 데모) */}
<Card className="bg-surface-raised border-slate-700/30"> <Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-3"> <CardContent className="p-3">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-bold text-heading"> </span> <div className="flex items-center gap-1.5">
<span className="text-[11px] font-bold text-heading"> </span>
<Badge intent="warning" size="xs" className="font-normal"> </Badge>
</div>
<button type="button" className="text-[9px] text-blue-400 hover:underline"> </button> <button type="button" className="text-[9px] text-blue-400 hover:underline"> </button>
</div> </div>
<div className="space-y-1.5 text-[10px]"> <div className="space-y-1.5 text-[10px]">
@ -618,11 +684,14 @@ export function ChinaFishing() {
</CardContent> </CardContent>
</Card> </Card>
{/* 기상 예보 */} {/* 기상 예보 (기상청 API 미연동 → 데모) */}
<Card className="bg-surface-raised border-slate-700/30"> <Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-3"> <CardContent className="p-3">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-bold text-heading"> </span> <div className="flex items-center gap-1.5">
<span className="text-[11px] font-bold text-heading"> </span>
<Badge intent="warning" size="xs" className="font-normal"> </Badge>
</div>
<button type="button" className="text-[9px] text-blue-400 hover:underline"> </button> <button type="button" className="text-[9px] text-blue-400 hover:underline"> </button>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -641,11 +710,14 @@ export function ChinaFishing() {
</CardContent> </CardContent>
</Card> </Card>
{/* VTS연계 현황 */} {/* VTS연계 현황 (VTS 시스템 연계 미구축 → 데모) */}
<Card className="bg-surface-raised border-slate-700/30"> <Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-3"> <CardContent className="p-3">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-bold text-heading">VTS연계 </span> <div className="flex items-center gap-1.5">
<span className="text-[11px] font-bold text-heading">VTS연계 </span>
<Badge intent="warning" size="xs" className="font-normal"> </Badge>
</div>
<button type="button" className="text-[9px] text-blue-400 hover:underline"> </button> <button type="button" className="text-[9px] text-blue-400 hover:underline"> </button>
</div> </div>
<div className="grid grid-cols-2 gap-1.5"> <div className="grid grid-cols-2 gap-1.5">
@ -677,6 +749,28 @@ export function ChinaFishing() {
</div> </div>
</div> </div>
{/* ── 선택 시: 궤적 미니맵 + 특이운항 판별 구간 상세 (최근 24h 분석 이력 기반) ── */}
{selectedMmsi && (
<div className="grid grid-cols-12 gap-3">
<div className="col-span-5">
<VesselMiniMap
mmsi={selectedMmsi}
vesselName={vesselList.find((v) => v.mmsi === selectedMmsi)?.name}
segments={anomalySegments}
onClose={() => setSelectedMmsi(null)}
/>
</div>
<div className="col-span-7">
<VesselAnomalyPanel
segments={anomalySegments}
loading={historyLoading}
error={historyError}
totalHistoryCount={history.length}
/>
</div>
</div>
)}
</>} </>}
</PageContainer> </PageContainer>
); );

파일 보기

@ -1,12 +1,16 @@
import { useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Search, Anchor, Ship, Eye, AlertTriangle, CheckCircle, XCircle, Search, Anchor, Ship, AlertTriangle, CheckCircle, XCircle,
ChevronRight, ChevronDown, Info, Shield, Radar, Target, Waves, ChevronRight, Info, Shield, Radar, Target, Waves,
ArrowRight, Flag, Zap, HelpCircle ArrowRight, Zap, HelpCircle, Loader2, RefreshCw
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
import { getZoneCodeLabel } from '@shared/constants/zoneCodes';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getGearDetections, type GearDetection } from '@/services/analysisApi';
// ─── 판별 기준 데이터 ───────────────── // ─── 판별 기준 데이터 ─────────────────
@ -619,11 +623,25 @@ function GearComparisonTable() {
// ─── 메인 페이지 ────────────────────── // ─── 메인 페이지 ──────────────────────
// gearCode → gearCategory 매핑 (자동탐지 → 입력 폼 프리필용)
const GEAR_CODE_CATEGORY: Record<string, GearType> = {
C21: 'trawl', C22: 'trawl', PT: 'trawl', OT: 'trawl', TRAWL: 'trawl',
C23: 'purseSeine', PS: 'purseSeine', PURSE: 'purseSeine',
C25: 'gillnet', GN: 'gillnet', GNS: 'gillnet', GND: 'gillnet', GILLNET: 'gillnet',
C40: 'unknown', FC: 'unknown',
};
const ZONE_CODE_SEA_AREA: Record<string, string> = {
ZONE_I: 'I', ZONE_II: 'II', ZONE_III: 'III', ZONE_IV: 'IV',
TERRITORIAL_SEA: '영해', CONTIGUOUS_ZONE: '접속수역', EEZ_OR_BEYOND: 'EEZ 외',
};
export function GearIdentification() { export function GearIdentification() {
const { t } = useTranslation('detection'); const { t } = useTranslation('detection');
const [input, setInput] = useState<GearInput>(DEFAULT_INPUT); const [input, setInput] = useState<GearInput>(DEFAULT_INPUT);
const [result, setResult] = useState<IdentificationResult | null>(null); const [result, setResult] = useState<IdentificationResult | null>(null);
const [showReference, setShowReference] = useState(false); const [showReference, setShowReference] = useState(false);
const [autoSelected, setAutoSelected] = useState<GearDetection | null>(null);
const update = <K extends keyof GearInput>(key: K, value: GearInput[K]) => { const update = <K extends keyof GearInput>(key: K, value: GearInput[K]) => {
setInput((prev) => ({ ...prev, [key]: value })); setInput((prev) => ({ ...prev, [key]: value }));
@ -636,6 +654,60 @@ export function GearIdentification() {
const resetForm = () => { const resetForm = () => {
setInput(DEFAULT_INPUT); setInput(DEFAULT_INPUT);
setResult(null); setResult(null);
setAutoSelected(null);
};
// 자동탐지 row 선택 → 입력 폼 프리필 + 결과 패널에 근거 프리셋
const applyAutoDetection = (v: GearDetection) => {
const code = (v.gearCode || '').toUpperCase();
const category = GEAR_CODE_CATEGORY[code] ?? 'unknown';
const seaArea = v.zoneCode ? ZONE_CODE_SEA_AREA[v.zoneCode] ?? '' : '';
setInput({
...DEFAULT_INPUT,
gearCategory: category,
permitCode: code,
mmsiPrefix: v.mmsi.slice(0, 3),
seaArea,
discoveryDate: v.analyzedAt.slice(0, 10),
});
setAutoSelected(v);
// 자동탐지 근거를 결과 패널에 프리셋
const reasons: string[] = [];
const warnings: string[] = [];
reasons.push(`MMSI ${v.mmsi} · ${v.vesselType ?? 'UNKNOWN'} · prediction 자동탐지`);
reasons.push(`어구 코드: ${code} · 판정: ${GEAR_JUDGMENT_LABEL[v.gearJudgment] ?? v.gearJudgment}`);
if (v.permitStatus) {
reasons.push(`허가 상태: ${PERMIT_STATUS_LABEL[v.permitStatus] ?? v.permitStatus}`);
}
(v.violationCategories ?? []).forEach((cat) => warnings.push(`위반 카테고리: ${cat}`));
if (v.gearJudgment === 'CLOSED_SEASON_FISHING') warnings.push('금어기 조업 의심 — 허가기간 외 조업');
if (v.gearJudgment === 'UNREGISTERED_GEAR') warnings.push('미등록 어구 — fleet_vessels 미매칭');
if (v.gearJudgment === 'GEAR_MISMATCH') warnings.push('허가 어구와 실제 탐지 어구 불일치');
if (v.gearJudgment === 'MULTIPLE_VIOLATION') warnings.push('복합 위반 — 두 개 이상 항목 동시 탐지');
const alertLevel = (v.riskLevel === 'CRITICAL' || v.riskLevel === 'HIGH' || v.riskLevel === 'MEDIUM' || v.riskLevel === 'LOW')
? v.riskLevel
: 'LOW';
setResult({
origin: 'china',
confidence: v.riskScore && v.riskScore >= 70 ? 'high' : v.riskScore && v.riskScore >= 40 ? 'medium' : 'low',
gearType: category,
gearSubType: code,
gbCode: '',
koreaName: '',
reasons,
warnings,
actionRequired: alertLevel === 'CRITICAL' || alertLevel === 'HIGH'
? '현장 확인 및 보강 정보 입력 후 최종 판별 실행'
: '추가 정보 입력 후 판별 실행',
alertLevel,
});
// 입력 폼 영역으로 스크롤
window.scrollTo({ top: 0, behavior: 'smooth' });
}; };
return ( return (
@ -665,6 +737,31 @@ export function GearIdentification() {
{/* 레퍼런스 테이블 (토글) */} {/* 레퍼런스 테이블 (토글) */}
{showReference && <GearComparisonTable />} {showReference && <GearComparisonTable />}
{/* 자동탐지 선택 힌트 */}
{autoSelected && (
<div className="flex items-center justify-between gap-3 px-4 py-2.5 rounded-lg border border-cyan-500/30 bg-cyan-500/5 text-[11px]">
<div className="flex items-center gap-2 flex-wrap">
<Badge intent="info" size="sm"> </Badge>
<span className="text-hint">MMSI</span>
<span className="font-mono text-cyan-400">{autoSelected.mmsi}</span>
<span className="text-hint">·</span>
<span className="text-hint"></span>
<span className="font-mono text-label">{autoSelected.gearCode}</span>
<span className="text-hint">·</span>
<Badge intent={GEAR_JUDGMENT_INTENT[autoSelected.gearJudgment] ?? 'muted'} size="sm">
{GEAR_JUDGMENT_LABEL[autoSelected.gearJudgment] ?? autoSelected.gearJudgment}
</Badge>
<span className="text-hint ml-2"> . .</span>
</div>
<button type="button"
onClick={() => { setAutoSelected(null); setInput(DEFAULT_INPUT); setResult(null); }}
className="text-[10px] text-hint hover:text-heading shrink-0"
>
</button>
</div>
)}
<div className="grid grid-cols-12 gap-4"> <div className="grid grid-cols-12 gap-4">
{/* ── 좌측: 입력 폼 ── */} {/* ── 좌측: 입력 폼 ── */}
<div className="col-span-5 space-y-3"> <div className="col-span-5 space-y-3">
@ -1002,6 +1099,155 @@ and vessel_spacing < 1000 # m
)} )}
</div> </div>
</div> </div>
{/* 최근 자동탐지 결과 (prediction 기반) */}
<AutoGearDetectionSection
onSelect={applyAutoDetection}
selectedId={autoSelected?.id ?? null}
/>
</div> </div>
); );
} }
// ─── 자동탐지 결과 섹션 ─────────────────
const GEAR_JUDGMENT_LABEL: Record<string, string> = {
CLOSED_SEASON_FISHING: '금어기 조업',
UNREGISTERED_GEAR: '미등록 어구',
GEAR_MISMATCH: '어구 불일치',
MULTIPLE_VIOLATION: '복합 위반',
NORMAL: '정상',
};
const GEAR_JUDGMENT_INTENT: Record<string, 'critical' | 'warning' | 'muted' | 'success'> = {
CLOSED_SEASON_FISHING: 'critical',
UNREGISTERED_GEAR: 'warning',
GEAR_MISMATCH: 'warning',
MULTIPLE_VIOLATION: 'critical',
NORMAL: 'success',
};
const PERMIT_STATUS_LABEL: Record<string, string> = {
PERMITTED: '허가',
UNPERMITTED: '미허가',
UNKNOWN: '확인불가',
};
function AutoGearDetectionSection({
onSelect,
selectedId,
}: {
onSelect: (v: GearDetection) => void;
selectedId: number | null;
}) {
const { t, i18n } = useTranslation('common');
const lang = (i18n.language as 'ko' | 'en') || 'ko';
const [items, setItems] = useState<GearDetection[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const page = await getGearDetections({ hours: 1, mmsiPrefix: '412', size: 50 });
setItems(page.content);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : '조회 실패');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
return (
<Card>
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-bold text-heading flex items-center gap-2">
<Radar className="w-4 h-4 text-cyan-500" />
(prediction, 1 )
</div>
<div className="text-[10px] text-hint mt-0.5">
GET /api/analysis/gear-detections · MMSI 412 · gear_code / gear_judgment NOT NULL ·
</div>
</div>
<button type="button" onClick={load}
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
{error && <div className="text-xs text-red-400">: {error}</div>}
{loading && <div className="flex items-center justify-center py-6 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
{!loading && (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-surface-overlay text-hint">
<tr>
<th className="px-2 py-1.5 text-left">MMSI</th>
<th className="px-2 py-1.5 text-left"></th>
<th className="px-2 py-1.5 text-center"></th>
<th className="px-2 py-1.5 text-center"></th>
<th className="px-2 py-1.5 text-center"></th>
<th className="px-2 py-1.5 text-left"></th>
<th className="px-2 py-1.5 text-center"></th>
<th className="px-2 py-1.5 text-right"></th>
<th className="px-2 py-1.5 text-left"></th>
<th className="px-2 py-1.5 text-left"></th>
</tr>
</thead>
<tbody>
{items.length === 0 && (
<tr><td colSpan={10} className="px-3 py-6 text-center text-hint"> .</td></tr>
)}
{items.map((v) => {
const selected = v.id === selectedId;
return (
<tr
key={v.id}
onClick={() => onSelect(v)}
className={`border-t border-border cursor-pointer transition-colors ${
selected ? 'bg-cyan-500/10 hover:bg-cyan-500/15' : 'hover:bg-surface-overlay/50'
}`}
title="클릭하면 상단 입력 폼에 자동으로 채워집니다"
>
<td className="px-2 py-1.5 text-cyan-400 font-mono">{v.mmsi}</td>
<td className="px-2 py-1.5 text-heading font-medium">{v.vesselType ?? '-'}</td>
<td className="px-2 py-1.5 text-center font-mono text-label">{v.gearCode}</td>
<td className="px-2 py-1.5 text-center">
<Badge intent={GEAR_JUDGMENT_INTENT[v.gearJudgment] ?? 'muted'} size="sm">
{GEAR_JUDGMENT_LABEL[v.gearJudgment] ?? v.gearJudgment}
</Badge>
</td>
<td className="px-2 py-1.5 text-center text-[10px] text-muted-foreground">
{PERMIT_STATUS_LABEL[v.permitStatus ?? ''] ?? v.permitStatus ?? '-'}
</td>
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">
{v.zoneCode ? getZoneCodeLabel(v.zoneCode, t, lang) : '-'}
</td>
<td className="px-2 py-1.5 text-center">
{v.riskLevel ? (
<Badge intent={getAlertLevelIntent(v.riskLevel)} size="sm">{v.riskLevel}</Badge>
) : <span className="text-hint">-</span>}
</td>
<td className="px-2 py-1.5 text-right text-heading font-bold">{v.riskScore ?? '-'}</td>
<td className="px-2 py-1.5 text-[10px] text-muted-foreground">
{(v.violationCategories ?? []).join(', ') || '-'}
</td>
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">
{v.analyzedAt ? formatDateTime(v.analyzedAt) : '-'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
}

파일 보기

@ -3,16 +3,18 @@ import { Loader2, RefreshCw, EyeOff, AlertTriangle, Radar } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card'; import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { getAlertLevelIntent } from '@shared/constants/alertLevels'; import { getAlertLevelIntent } from '@shared/constants/alertLevels';
import type { VesselAnalysisItem } from '@/services/vesselAnalysisApi';
import { import {
fetchVesselAnalysis, getAnalysisVessels,
type VesselAnalysisItem, getDarkVessels,
type VesselAnalysisStats, getTransshipSuspects,
} from '@/services/vesselAnalysisApi'; } from '@/services/analysisApi';
import { toVesselItem } from '@/services/analysisAdapter';
/** /**
* iran vessel analysis . * vessel_analysis_results .
* - mode: 'dark' (Dark Vessel만) / 'spoofing' ( ) / 'transship' () / 'all' * - mode: 'dark' (Dark Vessel만) / 'spoofing' ( ) / 'transship' () / 'all'
* - + * - +
*/ */
interface Props { interface Props {
@ -21,8 +23,6 @@ interface Props {
icon?: React.ReactNode; icon?: React.ReactNode;
} }
// 위험도 색상은 alertLevels 카탈로그 (intent prop) 사용
const ZONE_LABELS: Record<string, string> = { const ZONE_LABELS: Record<string, string> = {
TERRITORIAL_SEA: '영해', TERRITORIAL_SEA: '영해',
CONTIGUOUS_ZONE: '접속수역', CONTIGUOUS_ZONE: '접속수역',
@ -33,9 +33,15 @@ const ZONE_LABELS: Record<string, string> = {
ZONE_IV: '특정해역 IV', ZONE_IV: '특정해역 IV',
}; };
const ENDPOINT_LABEL: Record<Props['mode'], string> = {
all: 'GET /api/analysis/vessels',
dark: 'GET /api/analysis/dark',
transship: 'GET /api/analysis/transship',
spoofing: 'GET /api/analysis/vessels (spoofing_score ≥ 0.3)',
};
export function RealVesselAnalysis({ mode, title, icon }: Props) { export function RealVesselAnalysis({ mode, title, icon }: Props) {
const [items, setItems] = useState<VesselAnalysisItem[]>([]); const [items, setItems] = useState<VesselAnalysisItem[]>([]);
const [stats, setStats] = useState<VesselAnalysisStats | null>(null);
const [available, setAvailable] = useState(true); const [available, setAvailable] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@ -44,24 +50,27 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setError(''); setLoading(true); setError('');
try { try {
const res = await fetchVesselAnalysis(); const page = mode === 'dark'
setItems(res.items); ? await getDarkVessels({ hours: 1, size: 200 })
setStats(res.stats); : mode === 'transship'
setAvailable(res.serviceAvailable); ? await getTransshipSuspects({ hours: 1, size: 200 })
: await getAnalysisVessels({ hours: 1, size: 200 });
setItems(page.content.map(toVesselItem));
setAvailable(true);
} catch (e: unknown) { } catch (e: unknown) {
setError(e instanceof Error ? e.message : 'unknown'); setError(e instanceof Error ? e.message : 'unknown');
setAvailable(false);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [mode]);
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
let result = items; let result = items;
if (mode === 'dark') result = result.filter((i) => i.algorithms.darkVessel.isDark); // spoofing 은 /analysis/vessels 결과를 클라에서 임계값 필터
else if (mode === 'spoofing') result = result.filter((i) => i.algorithms.gpsSpoofing.spoofingScore >= 0.3); if (mode === 'spoofing') result = result.filter((i) => i.algorithms.gpsSpoofing.spoofingScore >= 0.3);
else if (mode === 'transship') result = result.filter((i) => i.algorithms.transship.isSuspect);
if (zoneFilter) result = result.filter((i) => i.algorithms.location.zone === zoneFilter); if (zoneFilter) result = result.filter((i) => i.algorithms.location.zone === zoneFilter);
return result; return result;
}, [items, mode, zoneFilter]); }, [items, mode, zoneFilter]);
@ -71,6 +80,20 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
[filtered], [filtered],
); );
// 통계 카드: mode 로 필터된 items 기반으로 집계해야 상단 숫자와 하단 리스트가 정합
const stats = useMemo(() => {
const modeFilteredItems = mode === 'spoofing'
? items.filter((i) => i.algorithms.gpsSpoofing.spoofingScore >= 0.3)
: items;
return {
total: modeFilteredItems.length,
criticalCount: modeFilteredItems.filter((i) => i.algorithms.riskScore.level === 'CRITICAL').length,
highCount: modeFilteredItems.filter((i) => i.algorithms.riskScore.level === 'HIGH').length,
mediumCount: modeFilteredItems.filter((i) => i.algorithms.riskScore.level === 'MEDIUM').length,
darkCount: modeFilteredItems.filter((i) => i.algorithms.darkVessel.isDark).length,
};
}, [items, mode]);
return ( return (
<Card> <Card>
<CardContent className="p-4 space-y-3"> <CardContent className="p-4 space-y-3">
@ -81,7 +104,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
{!available && <Badge intent="critical" size="sm"></Badge>} {!available && <Badge intent="critical" size="sm"></Badge>}
</div> </div>
<div className="text-[10px] text-hint mt-0.5"> <div className="text-[10px] text-hint mt-0.5">
GET /api/vessel-analysis · iran {ENDPOINT_LABEL[mode]} · prediction 5
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -99,17 +122,15 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
</div> </div>
</div> </div>
{/* 통계 카드 */} {/* 통계 카드 — items(= mode 필터 적용된 선박) 기준 집계 */}
{stats && ( <div className="grid grid-cols-6 gap-2">
<div className="grid grid-cols-6 gap-2"> <StatBox label="전체" value={stats.total} color="text-heading" />
<StatBox label="전체" value={stats.total} color="text-heading" /> <StatBox label="CRITICAL" value={stats.criticalCount} color="text-red-400" />
<StatBox label="CRITICAL" value={stats.critical} color="text-red-400" /> <StatBox label="HIGH" value={stats.highCount} color="text-orange-400" />
<StatBox label="HIGH" value={stats.high} color="text-orange-400" /> <StatBox label="MEDIUM" value={stats.mediumCount} color="text-yellow-400" />
<StatBox label="MEDIUM" value={stats.medium} color="text-yellow-400" /> <StatBox label="Dark" value={stats.darkCount} color="text-purple-400" />
<StatBox label="Dark" value={stats.dark} color="text-purple-400" /> <StatBox label="필터링" value={filtered.length} color="text-cyan-400" />
<StatBox label="필터링" value={filtered.length} color="text-cyan-400" /> </div>
</div>
)}
{error && <div className="text-xs text-red-400">: {error}</div>} {error && <div className="text-xs text-red-400">: {error}</div>}
{loading && <div className="flex items-center justify-center py-6 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>} {loading && <div className="flex items-center justify-center py-6 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
@ -187,11 +208,11 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
); );
} }
function StatBox({ label, value, color }: { label: string; value: number; color: string }) { function StatBox({ label, value, color }: { label: string; value: number | undefined; color: string }) {
return ( return (
<div className="px-3 py-2 rounded border border-border bg-surface-overlay"> <div className="px-3 py-2 rounded border border-border bg-surface-overlay">
<div className="text-[9px] text-hint">{label}</div> <div className="text-[9px] text-hint">{label}</div>
<div className={`text-lg font-bold ${color}`}>{value.toLocaleString()}</div> <div className={`text-lg font-bold ${color}`}>{(value ?? 0).toLocaleString()}</div>
</div> </div>
); );
} }

파일 보기

@ -0,0 +1,138 @@
/**
* 24h .
* 1 ~ .
*/
import { Loader2, AlertTriangle, ShieldAlert } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { formatDateTime } from '@shared/utils/dateFormat';
import {
type AnomalySegment,
getAnomalyCategoryIntent,
getAnomalyCategoryLabel,
} from './vesselAnomaly';
interface Props {
segments: AnomalySegment[];
loading?: boolean;
error?: string;
totalHistoryCount: number;
}
function formatDuration(min: number): string {
if (min <= 0) return '단일 샘플';
if (min < 60) return `${min}`;
const h = Math.floor(min / 60);
const m = min % 60;
return m === 0 ? `${h}시간` : `${h}시간 ${m}`;
}
export function VesselAnomalyPanel({ segments, loading, error, totalHistoryCount }: Props) {
const criticalCount = segments.filter((s) => s.severity === 'critical').length;
const warningCount = segments.filter((s) => s.severity === 'warning').length;
const infoCount = segments.filter((s) => s.severity === 'info').length;
const totalSamples = segments.reduce((sum, s) => sum + s.pointCount, 0);
return (
<Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-3 space-y-2">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">
<ShieldAlert className="w-3.5 h-3.5 text-red-400" />
<span className="text-[11px] font-bold text-heading"> </span>
{segments.length > 0 && (
<span className="text-[10px] text-hint flex items-center gap-1 flex-wrap">
· {segments.length}
{criticalCount > 0 && <span className="text-red-400 ml-0.5">CRITICAL {criticalCount}</span>}
{warningCount > 0 && <span className="text-orange-400 ml-0.5">WARNING {warningCount}</span>}
{infoCount > 0 && <span className="text-blue-400 ml-0.5">INFO {infoCount}</span>}
</span>
)}
</div>
<span className="text-[9px] text-hint">
24h {totalHistoryCount} {totalSamples}
</span>
</div>
{error && (
<div className="flex items-center gap-2 px-2 py-1.5 rounded border border-red-500/30 bg-red-500/5 text-[10px] text-red-400">
<AlertTriangle className="w-3 h-3 shrink-0" />
<span>{error}</span>
</div>
)}
{loading && (
<div className="flex items-center justify-center py-6 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
</div>
)}
{!loading && !error && segments.length === 0 && totalHistoryCount > 0 && (
<div className="px-3 py-6 text-center text-[11px] text-hint">
24h Dark / Spoofing / / / .
</div>
)}
{!loading && !error && totalHistoryCount === 0 && (
<div className="px-3 py-6 text-center text-[11px] text-hint">
.
</div>
)}
{!loading && segments.length > 0 && (
<div className="max-h-[360px] overflow-y-auto space-y-1.5 pr-1">
{segments.map((s) => (
<div
key={s.id}
className={`rounded border px-2.5 py-2 text-[10px] ${
s.severity === 'critical'
? 'border-red-500/30 bg-red-500/5'
: s.severity === 'warning'
? 'border-orange-500/30 bg-orange-500/5'
: 'border-blue-500/30 bg-blue-500/5'
}`}
>
<div className="flex items-center justify-between gap-2 mb-1 flex-wrap">
<span className="font-mono text-label">
{formatDateTime(s.startTime)}
{s.startTime !== s.endTime && (
<>
<span className="text-hint mx-1"></span>
{formatDateTime(s.endTime)}
</>
)}
</span>
<span className="text-hint text-[9px]">
<span className="text-muted-foreground">{formatDuration(s.durationMin)}</span>
<span className="mx-1">·</span>
<span>{s.pointCount} </span>
{s.representativeLat != null && s.representativeLon != null && (
<>
<span className="mx-1">·</span>
<span
className="font-mono"
title="판별 근거가 된 이벤트 발생 지점 (예: AIS 신호 단절 시작 좌표). 실제 분석 시각 위치는 지도의 색칠 궤적 구간을 확인하세요."
>
{s.representativeLat.toFixed(3)}°N, {s.representativeLon.toFixed(3)}°E
</span>
<span className="text-muted-foreground ml-0.5">( )</span>
</>
)}
</span>
</div>
<div className="flex items-center gap-1 flex-wrap mb-1">
{s.categories.map((c) => (
<Badge key={c} intent={getAnomalyCategoryIntent(c)} size="xs" className="font-normal">
{getAnomalyCategoryLabel(c)}
</Badge>
))}
</div>
<div className="text-muted-foreground text-[10px] leading-snug">{s.description}</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

파일 보기

@ -0,0 +1,259 @@
/**
* MMSI 24h .
* fetchVesselTracks (signal-batch ) PathLayer .
*/
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { Loader2, Ship, Clock, X } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PathLayer, ScatterplotLayer } from 'deck.gl';
import type { Layer } from 'deck.gl';
import { BaseMap, type MapHandle } from '@lib/map';
import { useMapLayers } from '@lib/map/hooks/useMapLayers';
import { fetchVesselTracks, type VesselTrack } from '@/services/vesselAnalysisApi';
import type { AnomalySegment } from './vesselAnomaly';
interface Props {
mmsi: string;
vesselName?: string;
hoursBack?: number;
segments?: AnomalySegment[];
onClose?: () => void;
}
function fmt(ts: string | number): string {
const n = typeof ts === 'string' ? parseInt(ts, 10) : ts;
if (!Number.isFinite(n)) return '-';
const d = new Date(n * 1000);
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${mm}/${dd} ${hh}:${mi}`;
}
export function VesselMiniMap({ mmsi, vesselName, hoursBack = 24, segments = [], onClose }: Props) {
const mapRef = useRef<MapHandle | null>(null);
const [track, setTrack] = useState<VesselTrack | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const load = useCallback(async () => {
setLoading(true); setError(''); setTrack(null);
try {
const end = new Date();
const start = new Date(end.getTime() - hoursBack * 3600 * 1000);
const res = await fetchVesselTracks(
[mmsi],
start.toISOString(),
end.toISOString(),
);
setTrack(res[0] ?? null);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : '궤적 조회 실패');
} finally {
setLoading(false);
}
}, [mmsi, hoursBack]);
useEffect(() => { load(); }, [load]);
// 궤적 로드 후 bounds 로 지도 이동
useEffect(() => {
if (!track || track.geometry.length === 0) return;
const map = mapRef.current?.map;
if (!map) return;
const lons = track.geometry.map((p) => p[0]);
const lats = track.geometry.map((p) => p[1]);
const w = Math.min(...lons), e = Math.max(...lons);
const s = Math.min(...lats), n = Math.max(...lats);
const span = Math.max(e - w, n - s);
if (span < 0.001) {
map.setCenter([(w + e) / 2, (s + n) / 2]);
map.setZoom(11);
} else {
map.fitBounds([[w, s], [e, n]], { padding: 24, maxZoom: 11, duration: 0 });
}
}, [track]);
// segment 의 [startTime, endTime] 범위에 들어오는 AIS 궤적 포인트를 뽑아 severity 색 path로 덧그린다.
// 이게 사용자가 '어떤 시간대 궤적이 특이운항으로 판별됐는지' 를 바로 알게 해주는 핵심 표시.
const segmentPaths = useMemo((): Array<{ id: string; path: [number, number][]; severity: AnomalySegment['severity'] }> => {
if (!track || track.timestamps.length === 0 || segments.length === 0) return [];
if (track.timestamps.length !== track.geometry.length) return [];
const epochs = track.timestamps.map((t) => Number(t) * 1000);
return segments
.map((seg) => {
const start = new Date(seg.startTime).getTime();
const end = new Date(seg.endTime).getTime();
const path: [number, number][] = [];
for (let i = 0; i < epochs.length; i++) {
if (epochs[i] >= start && epochs[i] <= end) path.push(track.geometry[i]);
}
return { id: seg.id, path, severity: seg.severity };
})
.filter((s) => s.path.length >= 2);
}, [track, segments]);
useMapLayers(mapRef, (): Layer[] => {
const layers: Layer[] = [];
if (track && track.geometry.length >= 2) {
layers.push(
new PathLayer({
id: `mini-track-${mmsi}`,
data: [{ path: track.geometry }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [59, 130, 246, 140],
getWidth: 2,
widthUnits: 'pixels',
jointRounded: true,
capRounded: true,
}),
);
}
if (segmentPaths.length > 0) {
layers.push(
new PathLayer<{ id: string; path: [number, number][]; severity: AnomalySegment['severity'] }>({
id: `mini-segment-paths-${mmsi}`,
data: segmentPaths,
getPath: (d) => d.path,
getColor: (d) =>
d.severity === 'critical' ? [239, 68, 68, 240]
: d.severity === 'warning' ? [249, 115, 22, 230]
: [59, 130, 246, 210],
getWidth: 4,
widthUnits: 'pixels',
widthMinPixels: 3,
jointRounded: true,
capRounded: true,
}),
);
}
if (track && track.geometry.length >= 2) {
layers.push(
new PathLayer({
id: `mini-track-head-${mmsi}`,
data: [{ path: track.geometry.slice(-2) }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [239, 68, 68, 255],
getWidth: 4,
widthUnits: 'pixels',
}),
);
}
// 이벤트 기준 좌표 (gap 시작점 등) — 분석 시각이 아니라 판별 근거가 된 과거 시점.
// 반복 분석이 같은 좌표를 참조하는 경우가 많아 작게/반투명하게 표시한다.
const geoSegments = segments.filter(
(s): s is AnomalySegment & { representativeLat: number; representativeLon: number } =>
s.representativeLat != null && s.representativeLon != null,
);
if (geoSegments.length > 0) {
layers.push(
new ScatterplotLayer<AnomalySegment & { representativeLat: number; representativeLon: number }>({
id: `mini-segments-${mmsi}`,
data: geoSegments,
getPosition: (d) => [d.representativeLon, d.representativeLat],
getRadius: 4,
radiusUnits: 'pixels',
radiusMinPixels: 4,
radiusMaxPixels: 6,
getFillColor: (d) =>
d.severity === 'critical' ? [239, 68, 68, 180]
: d.severity === 'warning' ? [249, 115, 22, 170]
: [59, 130, 246, 160],
getLineColor: [255, 255, 255, 220],
lineWidthMinPixels: 1,
stroked: true,
pickable: true,
}),
);
}
return layers;
}, [track, mmsi, segments, segmentPaths]);
const tsList = track?.timestamps ?? [];
const startTs = tsList[0];
const endTs = tsList[tsList.length - 1];
return (
<Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<Ship className="w-3.5 h-3.5 text-blue-400 shrink-0" />
<div className="min-w-0">
<div className="text-[11px] font-bold text-heading truncate flex items-center gap-1.5">
{vesselName ?? mmsi}
<span className="text-[9px] text-hint font-mono">{mmsi}</span>
{segments.length > 0 && (
<Badge intent="critical" size="xs" className="font-normal">
{segments.length}
</Badge>
)}
</div>
<div className="flex items-center gap-1 text-[9px] text-hint">
<Clock className="w-2.5 h-2.5" />
<span>{startTs ? fmt(startTs) : '-'}</span>
<span></span>
<span>{endTs ? fmt(endTs) : '-'}</span>
<span className="ml-1 text-muted-foreground">· {track?.pointCount ?? 0} pts</span>
</div>
</div>
</div>
{onClose && (
<button type="button" onClick={onClose} aria-label="미니맵 닫기"
className="p-1 rounded text-hint hover:text-heading hover:bg-surface-overlay shrink-0">
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<div className="relative h-80 rounded overflow-hidden border border-slate-700/30">
<BaseMap ref={mapRef} height={320} interactive={true} zoom={7} />
{loading && (
<div className="absolute inset-0 bg-background/60 flex items-center justify-center">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
)}
{!loading && error && (
<div className="absolute inset-0 bg-background/80 flex items-center justify-center text-xs text-red-400 px-3 text-center">
{error}
</div>
)}
{!loading && !error && track && track.geometry.length < 2 && (
<div className="absolute inset-0 bg-background/60 flex items-center justify-center text-[11px] text-hint">
24 (AIS )
</div>
)}
</div>
{segments.length > 0 && (
<div className="flex items-start gap-x-3 gap-y-0.5 flex-wrap text-[9px] text-hint">
<span className="flex items-center gap-1">
<span className="inline-block w-4 h-[2px] bg-red-500" />
CRITICAL
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-4 h-[2px] bg-orange-500" />
WARNING
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-4 h-[2px] bg-blue-500" />
INFO
</span>
<span className="text-muted-foreground"> = AIS </span>
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-white/80 inline-block border border-slate-600" />
</span>
{segmentPaths.length < segments.length && (
<span className="text-muted-foreground">
· {segments.length - segmentPaths.length}
</span>
)}
</div>
)}
</CardContent>
</Card>
);
}

파일 보기

@ -0,0 +1,236 @@
/**
* vessel_analysis_results '특이운항' .
*
* 24h history '이상 신호' AnomalyPoint .
* - DARK: is_dark=true
* - SPOOFING: spoofing_score >= 0.3
* - TRANSSHIP: transship_suspect=true
* - GEAR_VIOLATION: gear_judgment NORMAL
* - HIGH_RISK: risk_level CRITICAL/HIGH/MEDIUM (score>=40)
*
* top-level lat/lon null features.gap_start_lat/lon fallback.
* , .
*/
import type { VesselAnalysis } from '@/services/analysisApi';
export type AnomalyCategory =
| 'DARK'
| 'SPOOFING'
| 'TRANSSHIP'
| 'GEAR_VIOLATION'
| 'HIGH_RISK';
export interface AnomalyPoint {
id: number;
timestamp: string; // ISO
lat: number | null; // 좌표 없을 수 있음 (미니맵 포인트 생략)
lon: number | null;
severity: 'critical' | 'warning' | 'info';
categories: AnomalyCategory[];
description: string; // 사람이 읽는 요약
raw: VesselAnalysis;
}
const CATEGORY_LABEL: Record<AnomalyCategory, string> = {
DARK: '다크베셀',
SPOOFING: 'GPS 스푸핑',
TRANSSHIP: '환적 의심',
GEAR_VIOLATION: '어구 위반',
HIGH_RISK: '고위험',
};
export function getAnomalyCategoryLabel(c: AnomalyCategory): string {
return CATEGORY_LABEL[c];
}
export function getAnomalyCategoryIntent(c: AnomalyCategory): 'critical' | 'warning' | 'info' | 'muted' {
switch (c) {
case 'TRANSSHIP':
return 'critical';
case 'DARK':
case 'SPOOFING':
case 'GEAR_VIOLATION':
case 'HIGH_RISK':
return 'warning';
default:
return 'muted';
}
}
/** severity 우선순위 비교 (critical > warning > info). */
function bumpSeverity(cur: AnomalyPoint['severity'], next: AnomalyPoint['severity']): AnomalyPoint['severity'] {
if (cur === 'critical' || next === 'critical') return 'critical';
if (cur === 'warning' || next === 'warning') return 'warning';
return 'info';
}
/** top-level lat/lon → features.gap_start_lat/lon fallback. */
function extractCoord(v: VesselAnalysis): { lat: number | null; lon: number | null } {
if (typeof v.lat === 'number' && typeof v.lon === 'number') return { lat: v.lat, lon: v.lon };
const f = v.features;
if (f) {
const fLat = f['gap_start_lat'];
const fLon = f['gap_start_lon'];
if (typeof fLat === 'number' && typeof fLon === 'number') return { lat: fLat, lon: fLon };
}
return { lat: null, lon: null };
}
/** features.dark_patterns → 한글/영문 그대로 나열. */
function extractDarkPatterns(v: VesselAnalysis): string[] {
const dp = v.features?.['dark_patterns'];
if (!Array.isArray(dp)) return [];
return dp.filter((x): x is string => typeof x === 'string');
}
/**
* analysis AnomalyPoint ( null).
*/
export function classifyAnomaly(v: VesselAnalysis): AnomalyPoint | null {
const cats: AnomalyCategory[] = [];
const descs: string[] = [];
let severity: AnomalyPoint['severity'] = 'info';
if (v.isDark) {
cats.push('DARK');
const patterns = extractDarkPatterns(v);
const patternTxt = patterns.length ? `, 패턴: ${patterns.join(', ')}` : '';
descs.push(`다크베셀 (gap ${v.gapDurationMin ?? 0}${v.darkPattern ? `, ${v.darkPattern}` : ''}${patternTxt})`);
severity = bumpSeverity(severity, 'warning');
}
if ((v.spoofingScore ?? 0) >= 0.3) {
cats.push('SPOOFING');
descs.push(`GPS 스푸핑 (score ${Number(v.spoofingScore).toFixed(2)}${v.speedJumpCount ? `, speed jump ${v.speedJumpCount}` : ''})`);
severity = bumpSeverity(severity, 'warning');
}
if (v.transshipSuspect) {
cats.push('TRANSSHIP');
descs.push(`환적 의심 (페어 ${v.transshipPairMmsi || '-'}, ${v.transshipDurationMin ?? 0}분)`);
severity = bumpSeverity(severity, 'critical');
}
if (v.gearJudgment && v.gearJudgment !== 'NORMAL') {
cats.push('GEAR_VIOLATION');
descs.push(`어구 판정 ${v.gearJudgment}${v.gearCode ? ` (${v.gearCode})` : ''}`);
severity = bumpSeverity(severity, 'warning');
}
if (v.riskLevel === 'CRITICAL') {
if (!cats.includes('HIGH_RISK')) cats.push('HIGH_RISK');
descs.push(`고위험 CRITICAL (score ${v.riskScore ?? 0})`);
severity = bumpSeverity(severity, 'critical');
} else if (v.riskLevel === 'HIGH') {
if (!cats.includes('HIGH_RISK')) cats.push('HIGH_RISK');
descs.push(`위험 HIGH (score ${v.riskScore ?? 0})`);
severity = bumpSeverity(severity, 'warning');
} else if (v.riskLevel === 'MEDIUM' && (v.riskScore ?? 0) >= 40) {
if (!cats.includes('HIGH_RISK')) cats.push('HIGH_RISK');
descs.push(`주의 MEDIUM (score ${v.riskScore ?? 0})`);
severity = bumpSeverity(severity, 'info');
}
if (cats.length === 0) return null;
const { lat, lon } = extractCoord(v);
return {
id: v.id,
timestamp: v.analyzedAt,
lat,
lon,
severity,
categories: Array.from(new Set(cats)),
description: descs.join(' · '),
raw: v,
};
}
/** history 배열 → 최신순 anomaly 포인트. */
export function extractAnomalies(history: VesselAnalysis[]): AnomalyPoint[] {
return history
.map(classifyAnomaly)
.filter((x): x is AnomalyPoint => x !== null)
.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
}
// ─── 구간(Segment) 병합 ────────────────────────────
// prediction 은 5분 주기로 분석 결과를 write 하므로, 동일 신호(카테고리 집합 일치)가
// 연속해서 쌓인 구간은 사용자 관점에서 '같은 사건'이다. 이를 시작~종료 구간으로 병합한다.
export interface AnomalySegment {
id: string;
categories: AnomalyCategory[];
severity: AnomalyPoint['severity'];
startTime: string;
endTime: string;
durationMin: number;
pointCount: number; // 병합된 원본 분석 행 수
description: string; // 대표 description (첫 포인트)
representativeLat: number | null;
representativeLon: number | null;
geoPoints: Array<{ lat: number; lon: number; timestamp: string }>;
points: AnomalyPoint[];
}
function categoryKey(cats: AnomalyCategory[]): string {
return [...cats].sort().join('|');
}
function buildSegment(points: AnomalyPoint[]): AnomalySegment {
const first = points[0];
const last = points[points.length - 1];
const startTime = first.timestamp;
const endTime = last.timestamp;
const durationMin = Math.max(
0,
Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / 60000),
);
const geoPoints = points
.filter((p): p is AnomalyPoint & { lat: number; lon: number } => p.lat != null && p.lon != null)
.map((p) => ({ lat: p.lat, lon: p.lon, timestamp: p.timestamp }));
// 대표 좌표: 구간 중간 좌표 (없으면 첫 좌표)
const mid = geoPoints[Math.floor(geoPoints.length / 2)] ?? geoPoints[0] ?? null;
return {
id: `${first.timestamp}_${categoryKey(first.categories)}`,
categories: first.categories,
severity: first.severity,
startTime,
endTime,
durationMin,
pointCount: points.length,
description: first.description,
representativeLat: mid?.lat ?? null,
representativeLon: mid?.lon ?? null,
geoPoints,
points,
};
}
/**
* anomaly + .
* 5 .
*/
export function groupAnomaliesToSegments(points: AnomalyPoint[]): AnomalySegment[] {
if (points.length === 0) return [];
// 시간 오름차순 정렬 후 인접 병합
const asc = [...points].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
const segments: AnomalySegment[] = [];
let bucket: AnomalyPoint[] = [];
let key = '';
for (const p of asc) {
const k = categoryKey(p.categories);
if (bucket.length === 0) {
bucket.push(p);
key = k;
} else if (k === key) {
bucket.push(p);
} else {
segments.push(buildSegment(bucket));
bucket = [p];
key = k;
}
}
if (bucket.length > 0) segments.push(buildSegment(bucket));
// 최신 구간이 위로 오도록 역순 반환
return segments.sort((a, b) => b.startTime.localeCompare(a.startTime));
}

파일 보기

@ -0,0 +1,57 @@
/**
* analysisApi (flat shape) vesselAnalysisApi (nested VesselAnalysisItem) .
* nested shape에 .
*/
import type { VesselAnalysis } from './analysisApi';
import type { VesselAnalysisItem } from './vesselAnalysisApi';
export function toVesselItem(v: VesselAnalysis): VesselAnalysisItem {
return {
mmsi: v.mmsi,
timestamp: v.analyzedAt,
classification: {
vesselType: v.vesselType ?? 'UNKNOWN',
confidence: Number(v.confidence ?? 0),
fishingPct: Number(v.fishingPct ?? 0),
clusterId: v.clusterId ?? 0,
season: v.season ?? 'UNKNOWN',
},
algorithms: {
location: {
zone: v.zoneCode ?? 'EEZ_OR_BEYOND',
distToBaselineNm: Number(v.distToBaselineNm ?? 0),
},
activity: {
state: v.activityState ?? 'UNKNOWN',
ucafScore: Number(v.ucafScore ?? 0),
ucftScore: Number(v.ucftScore ?? 0),
},
darkVessel: {
isDark: v.isDark ?? false,
gapDurationMin: v.gapDurationMin ?? 0,
},
gpsSpoofing: {
spoofingScore: Number(v.spoofingScore ?? 0),
bd09OffsetM: Number(v.bd09OffsetM ?? 0),
speedJumpCount: v.speedJumpCount ?? 0,
},
cluster: {
clusterId: v.fleetClusterId ?? 0,
clusterSize: 0,
},
fleetRole: {
isLeader: v.fleetIsLeader ?? false,
role: v.fleetRole ?? 'NONE',
},
riskScore: {
score: v.riskScore ?? 0,
level: v.riskLevel ?? 'LOW',
},
transship: {
isSuspect: v.transshipSuspect ?? false,
pairMmsi: v.transshipPairMmsi ?? '',
durationMin: v.transshipDurationMin ?? 0,
},
},
};
}

파일 보기

@ -1,113 +1,176 @@
/** /**
* vessel_analysis_results API . * vessel_analysis_results API .
* /api/analysis/* . * /api/analysis/* (flat shape).
* prediction이 5 write한 .
*/ */
const API_BASE = import.meta.env.VITE_API_URL ?? '/api'; const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
// ─── DTO (백엔드 VesselAnalysisResponse 1:1 매핑) ─────────────
export interface VesselAnalysis { export interface VesselAnalysis {
id: number; id: number;
mmsi: string; mmsi: string;
analyzedAt: string; analyzedAt: string;
// 분류
vesselType: string | null; vesselType: string | null;
confidence: number | null; confidence: number | null;
fishingPct: number | null; fishingPct: number | null;
clusterId: number | null;
season: string | null; season: string | null;
// 위치
lat: number | null; lat: number | null;
lon: number | null; lon: number | null;
zoneCode: string | null; zoneCode: string | null;
distToBaselineNm: number | null; distToBaselineNm: number | null;
// 행동
activityState: string | null; activityState: string | null;
ucafScore: number | null;
ucftScore: number | null;
// 위협
isDark: boolean | null; isDark: boolean | null;
gapDurationMin: number | null; gapDurationMin: number | null;
darkPattern: string | null; darkPattern: string | null;
spoofingScore: number | null; spoofingScore: number | null;
bd09OffsetM: number | null;
speedJumpCount: number | null; speedJumpCount: number | null;
// 환적
transshipSuspect: boolean | null; transshipSuspect: boolean | null;
transshipPairMmsi: string | null; transshipPairMmsi: string | null;
transshipDurationMin: number | null; transshipDurationMin: number | null;
// 선단
fleetClusterId: number | null; fleetClusterId: number | null;
fleetRole: string | null; fleetRole: string | null;
fleetIsLeader: boolean | null; fleetIsLeader: boolean | null;
// 위험도
riskScore: number | null; riskScore: number | null;
riskLevel: string | null; riskLevel: string | null;
// 확장
gearCode: string | null; gearCode: string | null;
gearJudgment: string | null; gearJudgment: string | null;
permitStatus: string | null; permitStatus: string | null;
violationCategories: string[] | null;
features: Record<string, unknown> | null; features: Record<string, unknown> | null;
} }
export interface AnalysisPageResponse { export interface AnalysisStats {
content: VesselAnalysis[]; total: number;
darkCount: number;
spoofingCount: number;
transshipCount: number;
criticalCount: number;
highCount: number;
mediumCount: number;
lowCount: number;
territorialCount: number;
contiguousCount: number;
eezCount: number;
fishingCount: number;
avgRiskScore: number;
windowStart: string;
windowEnd: string;
}
export interface GearDetection {
id: number;
mmsi: string;
analyzedAt: string;
vesselType: string | null;
gearCode: string;
gearJudgment: string;
permitStatus: string | null;
riskLevel: string | null;
riskScore: number | null;
zoneCode: string | null;
violationCategories: string[] | null;
}
export interface AnalysisPageResponse<T = VesselAnalysis> {
content: T[];
totalElements: number; totalElements: number;
totalPages: number; totalPages: number;
number: number; number: number;
size: number; size: number;
} }
/** 분석 결과 목록 조회 */ // ─── 내부 헬퍼 ─────────────
export async function getAnalysisVessels(params?: {
function buildQuery(params: Record<string, unknown>): string {
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null || v === '') continue;
qs.set(k, String(v));
}
const s = qs.toString();
return s ? `?${s}` : '';
}
async function apiGet<T>(path: string, params: Record<string, unknown> = {}): Promise<T> {
const res = await fetch(`${API_BASE}${path}${buildQuery(params)}`, { credentials: 'include' });
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
// ─── 공개 함수 ─────────────
/** 분석 결과 목록 조회 (필터 + 페이징). */
export function getAnalysisVessels(params?: {
mmsi?: string; mmsi?: string;
mmsiPrefix?: string;
zoneCode?: string; zoneCode?: string;
riskLevel?: string; riskLevel?: string;
isDark?: boolean; isDark?: boolean;
minRiskScore?: number;
minFishingPct?: number;
hours?: number; hours?: number;
page?: number; page?: number;
size?: number; size?: number;
}): Promise<AnalysisPageResponse> { }): Promise<AnalysisPageResponse> {
const query = new URLSearchParams(); return apiGet('/analysis/vessels', { hours: 1, page: 0, size: 50, ...params });
if (params?.mmsi) query.set('mmsi', params.mmsi); }
if (params?.zoneCode) query.set('zoneCode', params.zoneCode);
if (params?.riskLevel) query.set('riskLevel', params.riskLevel); /** MMSI별 최신 row 기준 집계 (단일 쿼리 COUNT FILTER). */
if (params?.isDark != null) query.set('isDark', String(params.isDark)); export function getAnalysisStats(params?: {
query.set('hours', String(params?.hours ?? 1)); hours?: number;
query.set('page', String(params?.page ?? 0)); mmsiPrefix?: string;
query.set('size', String(params?.size ?? 50)); }): Promise<AnalysisStats> {
const res = await fetch(`${API_BASE}/analysis/vessels?${query}`, { credentials: 'include' }); return apiGet('/analysis/stats', { hours: 1, ...params });
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
} }
/** 특정 선박 최신 분석 결과 */ /** 특정 선박 최신 분석 결과 */
export async function getAnalysisLatest(mmsi: string): Promise<VesselAnalysis> { export function getAnalysisLatest(mmsi: string): Promise<VesselAnalysis> {
const res = await fetch(`${API_BASE}/analysis/vessels/${mmsi}`, { credentials: 'include' }); return apiGet(`/analysis/vessels/${encodeURIComponent(mmsi)}`);
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
} }
/** 특정 선박 분석 이력 */ /** 특정 선박 분석 이력 */
export async function getAnalysisHistory(mmsi: string, hours = 24): Promise<VesselAnalysis[]> { export function getAnalysisHistory(mmsi: string, hours = 24): Promise<VesselAnalysis[]> {
const res = await fetch(`${API_BASE}/analysis/vessels/${mmsi}/history?hours=${hours}`, { credentials: 'include' }); return apiGet(`/analysis/vessels/${encodeURIComponent(mmsi)}/history`, { hours });
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
} }
/** 다크 베셀 목록 */ /** 다크 베셀 목록 (MMSI 중복 제거) */
export async function getDarkVessels(params?: { export function getDarkVessels(params?: {
hours?: number; hours?: number;
page?: number; page?: number;
size?: number; size?: number;
}): Promise<AnalysisPageResponse> { }): Promise<AnalysisPageResponse> {
const query = new URLSearchParams(); return apiGet('/analysis/dark', { hours: 1, page: 0, size: 50, ...params });
query.set('hours', String(params?.hours ?? 1));
query.set('page', String(params?.page ?? 0));
query.set('size', String(params?.size ?? 50));
const res = await fetch(`${API_BASE}/analysis/dark?${query}`, { credentials: 'include' });
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
} }
/** 환적 의심 목록 */ /** 환적 의심 목록 (MMSI 중복 제거) */
export async function getTransshipSuspects(params?: { export function getTransshipSuspects(params?: {
hours?: number; hours?: number;
page?: number; page?: number;
size?: number; size?: number;
}): Promise<AnalysisPageResponse> { }): Promise<AnalysisPageResponse> {
const query = new URLSearchParams(); return apiGet('/analysis/transship', { hours: 1, page: 0, size: 50, ...params });
query.set('hours', String(params?.hours ?? 1)); }
query.set('page', String(params?.page ?? 0));
query.set('size', String(params?.size ?? 50)); /** prediction 자동 어구 탐지 결과 */
const res = await fetch(`${API_BASE}/analysis/transship?${query}`, { credentials: 'include' }); export function getGearDetections(params?: {
if (!res.ok) throw new Error(`API error: ${res.status}`); hours?: number;
return res.json(); mmsiPrefix?: string;
page?: number;
size?: number;
}): Promise<AnalysisPageResponse<GearDetection>> {
return apiGet('/analysis/gear-detections', { hours: 1, page: 0, size: 50, ...params });
} }

파일 보기

@ -90,6 +90,12 @@ async function apiGet<T>(path: string): Promise<T> {
return res.json(); return res.json();
} }
/**
* @deprecated /api/vessel-analysis .
* vessel_analysis_results {@link import('./analysisApi').getAnalysisVessels} /
* {@link import('./analysisApi').getAnalysisStats} .
* .
*/
export function fetchVesselAnalysis() { export function fetchVesselAnalysis() {
return apiGet<VesselAnalysisResponse>('/vessel-analysis'); return apiGet<VesselAnalysisResponse>('/vessel-analysis');
} }