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:
부모
820ed75585
커밋
d82eaf7e79
@ -3,9 +3,9 @@ import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { PageContainer } from '@shared/components/layout';
|
||||
import {
|
||||
Search, Ship, Clock, ChevronRight, ChevronLeft, Cloud,
|
||||
Search, Clock, ChevronRight, ChevronLeft, Cloud,
|
||||
Eye, AlertTriangle, Radio, RotateCcw,
|
||||
MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2
|
||||
Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2
|
||||
} from 'lucide-react';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
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 { GearIdentification } from './GearIdentification';
|
||||
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 type { VesselAnalysisItem } from '@/services/vesselAnalysisApi';
|
||||
import {
|
||||
fetchVesselAnalysis,
|
||||
filterDarkVessels,
|
||||
filterTransshipSuspects,
|
||||
type VesselAnalysisItem,
|
||||
type VesselAnalysisStats,
|
||||
} from '@/services/vesselAnalysisApi';
|
||||
getAnalysisStats,
|
||||
getAnalysisVessels,
|
||||
getAnalysisHistory,
|
||||
type AnalysisStats,
|
||||
type VesselAnalysis,
|
||||
} from '@/services/analysisApi';
|
||||
import { toVesselItem } from '@/services/analysisAdapter';
|
||||
|
||||
// ─── 중국 MMSI prefix ─────────────
|
||||
const CHINA_MMSI_PREFIX = '412';
|
||||
|
||||
function isChinaVessel(mmsi: string): boolean {
|
||||
return mmsi.startsWith(CHINA_MMSI_PREFIX);
|
||||
}
|
||||
|
||||
// ─── 특이운항 선박 리스트 타입 ────────────────
|
||||
type VesselStatus = '의심' | '양호' | '경고';
|
||||
interface VesselItem {
|
||||
@ -53,14 +54,16 @@ function deriveVesselStatus(score: number): VesselStatus {
|
||||
|
||||
function mapToVesselItem(item: VesselAnalysisItem, idx: number): VesselItem {
|
||||
const score = item.algorithms.riskScore.score;
|
||||
const vt = item.classification.vesselType;
|
||||
const hasType = vt && vt !== 'UNKNOWN' && vt !== '';
|
||||
return {
|
||||
id: String(idx + 1),
|
||||
mmsi: item.mmsi,
|
||||
callSign: '-',
|
||||
channel: '',
|
||||
source: 'AIS',
|
||||
name: item.classification.vesselType || item.mmsi,
|
||||
type: item.classification.fishingPct > 0.5 ? 'Fishing' : 'Cargo',
|
||||
name: hasType ? vt : '중국어선',
|
||||
type: item.classification.fishingPct > 0.5 ? 'Fishing' : hasType ? 'Cargo' : '미분류',
|
||||
country: 'China',
|
||||
status: deriveVesselStatus(score),
|
||||
riskPct: score,
|
||||
@ -202,10 +205,14 @@ export function ChinaFishing() {
|
||||
const [mode, setMode] = useState<'dashboard' | 'transfer' | 'gear'>('dashboard');
|
||||
const [vesselTab, setVesselTab] = 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
|
||||
const [allItems, setAllItems] = useState<VesselAnalysisItem[]>([]);
|
||||
const [apiStats, setApiStats] = useState<VesselAnalysisStats | null>(null);
|
||||
const [topVessels, setTopVessels] = useState<VesselAnalysisItem[]>([]);
|
||||
const [apiStats, setApiStats] = useState<AnalysisStats | null>(null);
|
||||
const [serviceAvailable, setServiceAvailable] = useState(true);
|
||||
const [apiLoading, setApiLoading] = useState(false);
|
||||
const [apiError, setApiError] = useState('');
|
||||
@ -214,10 +221,18 @@ export function ChinaFishing() {
|
||||
setApiLoading(true);
|
||||
setApiError('');
|
||||
try {
|
||||
const res = await fetchVesselAnalysis();
|
||||
setServiceAvailable(res.serviceAvailable);
|
||||
setAllItems(res.items);
|
||||
setApiStats(res.stats);
|
||||
const [stats, topPage] = await Promise.all([
|
||||
getAnalysisStats({ hours: 1, mmsiPrefix: CHINA_MMSI_PREFIX }),
|
||||
getAnalysisVessels({
|
||||
hours: 1,
|
||||
mmsiPrefix: CHINA_MMSI_PREFIX,
|
||||
minRiskScore: 40,
|
||||
size: 20,
|
||||
}),
|
||||
]);
|
||||
setApiStats(stats);
|
||||
setTopVessels(topPage.content.map(toVesselItem));
|
||||
setServiceAvailable(true);
|
||||
} catch (e: unknown) {
|
||||
setApiError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다');
|
||||
setServiceAvailable(false);
|
||||
@ -228,55 +243,77 @@ export function ChinaFishing() {
|
||||
|
||||
useEffect(() => { loadApi(); }, [loadApi]);
|
||||
|
||||
// 중국어선 필터
|
||||
const chinaVessels = useMemo(
|
||||
() => allItems.filter((i) => isChinaVessel(i.mmsi)),
|
||||
[allItems],
|
||||
// 선박 선택 시 24h 분석 이력 로드 (미니맵 anomaly 포인트 + 판별 패널 공통 데이터)
|
||||
useEffect(() => {
|
||||
if (!selectedMmsi) { setHistory([]); setHistoryError(''); return; }
|
||||
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 countersRow1 = useMemo(() => [
|
||||
{ label: '통합', count: allItems.length, color: '#6b7280' },
|
||||
{ label: 'AIS', count: allItems.length, color: '#3b82f6' },
|
||||
{ label: 'EEZ 내', count: allItems.filter((i) => i.algorithms.location.zone !== 'EEZ_OR_BEYOND').length, color: '#8b5cf6' },
|
||||
{ label: '어업선', count: allItems.filter((i) => i.classification.fishingPct > 0.5).length, color: '#10b981' },
|
||||
], [allItems]);
|
||||
const countersRow2 = useMemo(() => {
|
||||
if (!apiStats) return [];
|
||||
return [
|
||||
{ label: '중국어선', count: apiStats.total, color: '#f97316' },
|
||||
{ label: 'Dark Vessel', count: apiStats.darkCount, color: '#ef4444' },
|
||||
{ label: '환적 의심', count: apiStats.transshipCount, color: '#06b6d4' },
|
||||
{ label: '고위험', count: apiStats.criticalCount + apiStats.highCount, color: '#ef4444' },
|
||||
];
|
||||
}, [apiStats]);
|
||||
|
||||
const countersRow2 = useMemo(() => [
|
||||
{ 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)
|
||||
// 특이운항 선박 리스트 (서버에서 이미 riskScore >= 40 로 필터링된 상위 20척)
|
||||
const vesselList: VesselItem[] = useMemo(
|
||||
() => chinaVessels
|
||||
.filter((i) => i.algorithms.riskScore.score >= 40)
|
||||
.sort((a, b) => b.algorithms.riskScore.score - a.algorithms.riskScore.score)
|
||||
.slice(0, 20)
|
||||
.map((item, idx) => mapToVesselItem(item, idx)),
|
||||
[chinaVessels],
|
||||
() => topVessels.map((item, idx) => mapToVesselItem(item, idx)),
|
||||
[topVessels],
|
||||
);
|
||||
|
||||
// 위험도별 분포 (도넛 차트용)
|
||||
// 위험도별 분포 (도넛 차트용) — apiStats 기반
|
||||
const riskDistribution = useMemo(() => {
|
||||
const critical = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'CRITICAL').length;
|
||||
const high = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'HIGH').length;
|
||||
const medium = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'MEDIUM').length;
|
||||
const low = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'LOW').length;
|
||||
return { critical, high, medium, low, total: chinaVessels.length };
|
||||
}, [chinaVessels]);
|
||||
if (!apiStats) return { critical: 0, high: 0, medium: 0, low: 0, total: 0 };
|
||||
return {
|
||||
critical: apiStats.criticalCount,
|
||||
high: apiStats.highCount,
|
||||
medium: apiStats.mediumCount,
|
||||
low: apiStats.lowCount,
|
||||
total: apiStats.total,
|
||||
};
|
||||
}, [apiStats]);
|
||||
|
||||
// 안전도 지수 계산
|
||||
// 안전도 지수 계산 (avgRiskScore 0~100 → 0~10 스케일)
|
||||
const safetyIndex = useMemo(() => {
|
||||
if (chinaVessels.length === 0) return { risk: 0, safety: 100 };
|
||||
const avgRisk = chinaVessels.reduce((s, i) => s + i.algorithms.riskScore.score, 0) / chinaVessels.length;
|
||||
return { risk: Number((avgRisk / 10).toFixed(2)), safety: Number(((100 - avgRisk) / 10).toFixed(2)) };
|
||||
}, [chinaVessels]);
|
||||
const avgRisk = apiStats ? Number(apiStats.avgRiskScore) : 0;
|
||||
if (!apiStats || apiStats.total === 0) return { risk: 0, safety: 100 };
|
||||
return {
|
||||
risk: Number((avgRisk / 10).toFixed(2)),
|
||||
safety: Number(((100 - avgRisk) / 10).toFixed(2)),
|
||||
};
|
||||
}, [apiStats]);
|
||||
|
||||
const vesselTabs = ['특이운항', '비허가 선박', '제재 선박', '관심 선박'] as const;
|
||||
const statsTabs = ['불법조업 통계', '특이선박 통계', '위험선박 통계'] as const;
|
||||
@ -319,7 +356,7 @@ export function ChinaFishing() {
|
||||
{!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">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
<span>iran 분석 서비스 미연결 - 실시간 데이터를 불러올 수 없습니다</span>
|
||||
<span>분석 API 호출 실패 - 잠시 후 다시 시도해주세요</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -371,7 +408,7 @@ export function ChinaFishing() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-4 text-[10px] text-muted-foreground">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -422,12 +459,15 @@ export function ChinaFishing() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 관심영역 안전도 */}
|
||||
{/* 관심영역 안전도 (해역 지오펜스 미구축 → 데모) */}
|
||||
<div className="col-span-4">
|
||||
<Card className="bg-surface-raised border-slate-700/30 h-full">
|
||||
<CardContent className="p-4">
|
||||
<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">
|
||||
<option>영역 A</option>
|
||||
<option>영역 B</option>
|
||||
@ -453,7 +493,14 @@ export function ChinaFishing() {
|
||||
<span className="text-green-400 font-bold ml-auto">정상</span>
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -467,21 +514,30 @@ export function ChinaFishing() {
|
||||
<div className="col-span-5">
|
||||
<Card className="bg-surface-raised border-slate-700/30">
|
||||
<CardContent className="p-0">
|
||||
{/* 탭 헤더 */}
|
||||
{/* 탭 헤더 — 특이운항만 활성, 나머지 3개는 데이터 소스 미연동 */}
|
||||
<div className="flex border-b border-slate-700/30">
|
||||
{vesselTabs.map((tab) => (
|
||||
<button type="button"
|
||||
key={tab}
|
||||
onClick={() => setVesselTab(tab)}
|
||||
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${
|
||||
vesselTab === tab
|
||||
? 'text-heading border-b-2 border-blue-500 bg-surface-overlay'
|
||||
: 'text-hint hover:text-label'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
{vesselTabs.map((tab) => {
|
||||
const disabled = tab !== '특이운항';
|
||||
return (
|
||||
<button type="button"
|
||||
key={tab}
|
||||
onClick={() => !disabled && setVesselTab(tab)}
|
||||
disabled={disabled}
|
||||
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'
|
||||
: disabled
|
||||
? '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>
|
||||
|
||||
{/* 선박 목록 */}
|
||||
@ -491,28 +547,34 @@ export function ChinaFishing() {
|
||||
{apiLoading ? '데이터 로딩 중...' : '중국어선 특이운항 데이터가 없습니다'}
|
||||
</div>
|
||||
)}
|
||||
{vesselList.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
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"
|
||||
>
|
||||
<StatusRing status={v.status} riskPct={v.riskPct} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 text-[10px] text-hint mb-0.5">
|
||||
<span>MMSI | <span className="text-label">{v.mmsi}</span></span>
|
||||
<span>출처 | <span className="text-label">{v.source}</span></span>
|
||||
</div>
|
||||
<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>
|
||||
{vesselList.map((v) => {
|
||||
const selected = v.mmsi === selectedMmsi;
|
||||
return (
|
||||
<div
|
||||
key={v.id}
|
||||
onClick={() => setSelectedMmsi(selected ? null : v.mmsi)}
|
||||
className={`flex items-center gap-3 px-4 py-3 border-b border-slate-700/20 transition-colors cursor-pointer group ${
|
||||
selected ? 'bg-blue-500/10 hover:bg-blue-500/15' : 'hover:bg-surface-overlay'
|
||||
}`}
|
||||
>
|
||||
<StatusRing status={v.status} riskPct={v.riskPct} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 text-[10px] text-hint mb-0.5">
|
||||
<span>MMSI | <span className="text-label">{v.mmsi}</span></span>
|
||||
<span>출처 | <span className="text-label">{v.source}</span></span>
|
||||
</div>
|
||||
<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>
|
||||
<ChevronRight className="w-4 h-4 text-hint group-hover:text-muted-foreground shrink-0" />
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-hint group-hover:text-muted-foreground shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -524,19 +586,20 @@ export function ChinaFishing() {
|
||||
{/* 통계 차트 */}
|
||||
<Card className="bg-surface-raised border-slate-700/30">
|
||||
<CardContent className="p-0">
|
||||
{/* 탭 */}
|
||||
{/* 탭 — 월별 집계 API 미연동 */}
|
||||
<div className="flex border-b border-slate-700/30">
|
||||
{statsTabs.map((tab) => (
|
||||
<button type="button"
|
||||
key={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
|
||||
? 'text-heading border-b-2 border-green-500 bg-surface-overlay'
|
||||
: 'text-hint hover:text-label'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
<Badge intent="warning" size="xs" className="font-normal">준비중</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@ -594,11 +657,14 @@ export function ChinaFishing() {
|
||||
{/* 하단 카드 3개 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
|
||||
{/* 최근 위성영상 분석 */}
|
||||
{/* 최근 위성영상 분석 (VIIRS 수집 파이프라인 미구축 → 데모) */}
|
||||
<Card className="bg-surface-raised border-slate-700/30">
|
||||
<CardContent className="p-3">
|
||||
<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>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-[10px]">
|
||||
@ -618,11 +684,14 @@ export function ChinaFishing() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 기상 예보 */}
|
||||
{/* 기상 예보 (기상청 API 미연동 → 데모) */}
|
||||
<Card className="bg-surface-raised border-slate-700/30">
|
||||
<CardContent className="p-3">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@ -641,11 +710,14 @@ export function ChinaFishing() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* VTS연계 현황 */}
|
||||
{/* VTS연계 현황 (VTS 시스템 연계 미구축 → 데모) */}
|
||||
<Card className="bg-surface-raised border-slate-700/30">
|
||||
<CardContent className="p-3">
|
||||
<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>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
@ -677,6 +749,28 @@ export function ChinaFishing() {
|
||||
</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>
|
||||
);
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Search, Anchor, Ship, Eye, AlertTriangle, CheckCircle, XCircle,
|
||||
ChevronRight, ChevronDown, Info, Shield, Radar, Target, Waves,
|
||||
ArrowRight, Flag, Zap, HelpCircle
|
||||
Search, Anchor, Ship, AlertTriangle, CheckCircle, XCircle,
|
||||
ChevronRight, Info, Shield, Radar, Target, Waves,
|
||||
ArrowRight, Zap, HelpCircle, Loader2, RefreshCw
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
|
||||
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() {
|
||||
const { t } = useTranslation('detection');
|
||||
const [input, setInput] = useState<GearInput>(DEFAULT_INPUT);
|
||||
const [result, setResult] = useState<IdentificationResult | null>(null);
|
||||
const [showReference, setShowReference] = useState(false);
|
||||
const [autoSelected, setAutoSelected] = useState<GearDetection | null>(null);
|
||||
|
||||
const update = <K extends keyof GearInput>(key: K, value: GearInput[K]) => {
|
||||
setInput((prev) => ({ ...prev, [key]: value }));
|
||||
@ -636,6 +654,60 @@ export function GearIdentification() {
|
||||
const resetForm = () => {
|
||||
setInput(DEFAULT_INPUT);
|
||||
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 (
|
||||
@ -665,6 +737,31 @@ export function GearIdentification() {
|
||||
{/* 레퍼런스 테이블 (토글) */}
|
||||
{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="col-span-5 space-y-3">
|
||||
@ -1002,6 +1099,155 @@ and vessel_spacing < 1000 # m
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 최근 자동탐지 결과 (prediction 기반) */}
|
||||
<AutoGearDetectionSection
|
||||
onSelect={applyAutoDetection}
|
||||
selectedId={autoSelected?.id ?? null}
|
||||
/>
|
||||
</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 { Badge } from '@shared/components/ui/badge';
|
||||
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
|
||||
import type { VesselAnalysisItem } from '@/services/vesselAnalysisApi';
|
||||
import {
|
||||
fetchVesselAnalysis,
|
||||
type VesselAnalysisItem,
|
||||
type VesselAnalysisStats,
|
||||
} from '@/services/vesselAnalysisApi';
|
||||
getAnalysisVessels,
|
||||
getDarkVessels,
|
||||
getTransshipSuspects,
|
||||
} from '@/services/analysisApi';
|
||||
import { toVesselItem } from '@/services/analysisAdapter';
|
||||
|
||||
/**
|
||||
* iran 백엔드의 실시간 vessel analysis 결과를 표시.
|
||||
* vessel_analysis_results 기반 실시간 선박 분석 테이블.
|
||||
* - mode: 'dark' (Dark Vessel만) / 'spoofing' (스푸핑 의심) / 'transship' (전재) / 'all'
|
||||
* - 위험도 통계 + 필터링된 선박 테이블
|
||||
* - 위험도 통계 카드 + 상위 위험도순 선박 테이블
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
@ -21,8 +23,6 @@ interface Props {
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
// 위험도 색상은 alertLevels 카탈로그 (intent prop) 사용
|
||||
|
||||
const ZONE_LABELS: Record<string, string> = {
|
||||
TERRITORIAL_SEA: '영해',
|
||||
CONTIGUOUS_ZONE: '접속수역',
|
||||
@ -33,9 +33,15 @@ const ZONE_LABELS: Record<string, string> = {
|
||||
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) {
|
||||
const [items, setItems] = useState<VesselAnalysisItem[]>([]);
|
||||
const [stats, setStats] = useState<VesselAnalysisStats | null>(null);
|
||||
const [available, setAvailable] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@ -44,24 +50,27 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const res = await fetchVesselAnalysis();
|
||||
setItems(res.items);
|
||||
setStats(res.stats);
|
||||
setAvailable(res.serviceAvailable);
|
||||
const page = mode === 'dark'
|
||||
? await getDarkVessels({ hours: 1, size: 200 })
|
||||
: mode === 'transship'
|
||||
? await getTransshipSuspects({ hours: 1, size: 200 })
|
||||
: await getAnalysisVessels({ hours: 1, size: 200 });
|
||||
setItems(page.content.map(toVesselItem));
|
||||
setAvailable(true);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'unknown');
|
||||
setAvailable(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let result = items;
|
||||
if (mode === 'dark') result = result.filter((i) => i.algorithms.darkVessel.isDark);
|
||||
else 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);
|
||||
// spoofing 은 /analysis/vessels 결과를 클라에서 임계값 필터
|
||||
if (mode === 'spoofing') result = result.filter((i) => i.algorithms.gpsSpoofing.spoofingScore >= 0.3);
|
||||
if (zoneFilter) result = result.filter((i) => i.algorithms.location.zone === zoneFilter);
|
||||
return result;
|
||||
}, [items, mode, zoneFilter]);
|
||||
@ -71,6 +80,20 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
[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 (
|
||||
<Card>
|
||||
<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>}
|
||||
</div>
|
||||
<div className="text-[10px] text-hint mt-0.5">
|
||||
GET /api/vessel-analysis · iran 백엔드 실시간 분석 결과
|
||||
{ENDPOINT_LABEL[mode]} · prediction 5분 주기 분석 결과
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -99,17 +122,15 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
<StatBox label="전체" value={stats.total} color="text-heading" />
|
||||
<StatBox label="CRITICAL" value={stats.critical} color="text-red-400" />
|
||||
<StatBox label="HIGH" value={stats.high} color="text-orange-400" />
|
||||
<StatBox label="MEDIUM" value={stats.medium} color="text-yellow-400" />
|
||||
<StatBox label="Dark" value={stats.dark} color="text-purple-400" />
|
||||
<StatBox label="필터링" value={filtered.length} color="text-cyan-400" />
|
||||
</div>
|
||||
)}
|
||||
{/* 통계 카드 — items(= mode 필터 적용된 선박) 기준 집계 */}
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
<StatBox label="전체" value={stats.total} color="text-heading" />
|
||||
<StatBox label="CRITICAL" value={stats.criticalCount} color="text-red-400" />
|
||||
<StatBox label="HIGH" value={stats.highCount} color="text-orange-400" />
|
||||
<StatBox label="MEDIUM" value={stats.mediumCount} color="text-yellow-400" />
|
||||
<StatBox label="Dark" value={stats.darkCount} color="text-purple-400" />
|
||||
<StatBox label="필터링" value={filtered.length} color="text-cyan-400" />
|
||||
</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>}
|
||||
@ -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 (
|
||||
<div className="px-3 py-2 rounded border border-border bg-surface-overlay">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
259
frontend/src/features/detection/components/VesselMiniMap.tsx
Normal file
259
frontend/src/features/detection/components/VesselMiniMap.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
236
frontend/src/features/detection/components/vesselAnomaly.ts
Normal file
236
frontend/src/features/detection/components/vesselAnomaly.ts
Normal file
@ -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));
|
||||
}
|
||||
57
frontend/src/services/analysisAdapter.ts
Normal file
57
frontend/src/services/analysisAdapter.ts
Normal file
@ -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 서비스.
|
||||
* 백엔드 /api/analysis/* 엔드포인트 연동.
|
||||
* 백엔드 /api/analysis/* 엔드포인트 연동 (flat shape).
|
||||
* prediction이 5분 주기로 write한 분석 결과를 그대로 노출.
|
||||
*/
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
||||
|
||||
// ─── DTO (백엔드 VesselAnalysisResponse 1:1 매핑) ─────────────
|
||||
|
||||
export interface VesselAnalysis {
|
||||
id: number;
|
||||
mmsi: string;
|
||||
analyzedAt: string;
|
||||
// 분류
|
||||
vesselType: string | null;
|
||||
confidence: number | null;
|
||||
fishingPct: number | null;
|
||||
clusterId: number | null;
|
||||
season: string | null;
|
||||
// 위치
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
zoneCode: string | null;
|
||||
distToBaselineNm: number | null;
|
||||
// 행동
|
||||
activityState: string | null;
|
||||
ucafScore: number | null;
|
||||
ucftScore: number | null;
|
||||
// 위협
|
||||
isDark: boolean | null;
|
||||
gapDurationMin: number | null;
|
||||
darkPattern: string | null;
|
||||
spoofingScore: number | null;
|
||||
bd09OffsetM: number | null;
|
||||
speedJumpCount: number | null;
|
||||
// 환적
|
||||
transshipSuspect: boolean | null;
|
||||
transshipPairMmsi: string | null;
|
||||
transshipDurationMin: number | null;
|
||||
// 선단
|
||||
fleetClusterId: number | null;
|
||||
fleetRole: string | null;
|
||||
fleetIsLeader: boolean | null;
|
||||
// 위험도
|
||||
riskScore: number | null;
|
||||
riskLevel: string | null;
|
||||
// 확장
|
||||
gearCode: string | null;
|
||||
gearJudgment: string | null;
|
||||
permitStatus: string | null;
|
||||
violationCategories: string[] | null;
|
||||
features: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface AnalysisPageResponse {
|
||||
content: VesselAnalysis[];
|
||||
export interface AnalysisStats {
|
||||
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;
|
||||
totalPages: number;
|
||||
number: 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;
|
||||
mmsiPrefix?: string;
|
||||
zoneCode?: string;
|
||||
riskLevel?: string;
|
||||
isDark?: boolean;
|
||||
minRiskScore?: number;
|
||||
minFishingPct?: number;
|
||||
hours?: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<AnalysisPageResponse> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.mmsi) query.set('mmsi', params.mmsi);
|
||||
if (params?.zoneCode) query.set('zoneCode', params.zoneCode);
|
||||
if (params?.riskLevel) query.set('riskLevel', params.riskLevel);
|
||||
if (params?.isDark != null) query.set('isDark', String(params.isDark));
|
||||
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/vessels?${query}`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
return apiGet('/analysis/vessels', { hours: 1, page: 0, size: 50, ...params });
|
||||
}
|
||||
|
||||
/** MMSI별 최신 row 기준 집계 (단일 쿼리 COUNT FILTER). */
|
||||
export function getAnalysisStats(params?: {
|
||||
hours?: number;
|
||||
mmsiPrefix?: string;
|
||||
}): Promise<AnalysisStats> {
|
||||
return apiGet('/analysis/stats', { hours: 1, ...params });
|
||||
}
|
||||
|
||||
/** 특정 선박 최신 분석 결과 */
|
||||
export async function getAnalysisLatest(mmsi: string): Promise<VesselAnalysis> {
|
||||
const res = await fetch(`${API_BASE}/analysis/vessels/${mmsi}`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
export function getAnalysisLatest(mmsi: string): Promise<VesselAnalysis> {
|
||||
return apiGet(`/analysis/vessels/${encodeURIComponent(mmsi)}`);
|
||||
}
|
||||
|
||||
/** 특정 선박 분석 이력 */
|
||||
export async function getAnalysisHistory(mmsi: string, hours = 24): Promise<VesselAnalysis[]> {
|
||||
const res = await fetch(`${API_BASE}/analysis/vessels/${mmsi}/history?hours=${hours}`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
export function getAnalysisHistory(mmsi: string, hours = 24): Promise<VesselAnalysis[]> {
|
||||
return apiGet(`/analysis/vessels/${encodeURIComponent(mmsi)}/history`, { hours });
|
||||
}
|
||||
|
||||
/** 다크 베셀 목록 */
|
||||
export async function getDarkVessels(params?: {
|
||||
/** 다크 베셀 목록 (MMSI 중복 제거) */
|
||||
export function getDarkVessels(params?: {
|
||||
hours?: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<AnalysisPageResponse> {
|
||||
const query = new URLSearchParams();
|
||||
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();
|
||||
return apiGet('/analysis/dark', { hours: 1, page: 0, size: 50, ...params });
|
||||
}
|
||||
|
||||
/** 환적 의심 목록 */
|
||||
export async function getTransshipSuspects(params?: {
|
||||
/** 환적 의심 목록 (MMSI 중복 제거) */
|
||||
export function getTransshipSuspects(params?: {
|
||||
hours?: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<AnalysisPageResponse> {
|
||||
const query = new URLSearchParams();
|
||||
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/transship?${query}`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
return apiGet('/analysis/transship', { hours: 1, page: 0, size: 50, ...params });
|
||||
}
|
||||
|
||||
/** prediction 자동 어구 탐지 결과 */
|
||||
export function getGearDetections(params?: {
|
||||
hours?: number;
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 백엔드 /api/vessel-analysis 는 빈 스텁 응답만 반환한다.
|
||||
* vessel_analysis_results 조회는 {@link import('./analysisApi').getAnalysisVessels} /
|
||||
* {@link import('./analysisApi').getAnalysisStats} 를 사용한다.
|
||||
* 본 함수는 호출처 제거 후 제거 예정.
|
||||
*/
|
||||
export function fetchVesselAnalysis() {
|
||||
return apiGet<VesselAnalysisResponse>('/vessel-analysis');
|
||||
}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user