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 { 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">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-bold text-heading">관심영역 안전도</span>
|
<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) => {
|
||||||
|
const disabled = tab !== '특이운항';
|
||||||
|
return (
|
||||||
<button type="button"
|
<button type="button"
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setVesselTab(tab)}
|
onClick={() => !disabled && setVesselTab(tab)}
|
||||||
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${
|
disabled={disabled}
|
||||||
|
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors flex items-center justify-center gap-1 ${
|
||||||
vesselTab === tab
|
vesselTab === tab
|
||||||
? 'text-heading border-b-2 border-blue-500 bg-surface-overlay'
|
? 'text-heading border-b-2 border-blue-500 bg-surface-overlay'
|
||||||
|
: disabled
|
||||||
|
? 'text-hint opacity-50 cursor-not-allowed'
|
||||||
: 'text-hint hover:text-label'
|
: 'text-hint hover:text-label'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab}
|
{tab}
|
||||||
|
{disabled && (
|
||||||
|
<Badge intent="warning" size="xs" className="font-normal">준비중</Badge>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 선박 목록 */}
|
{/* 선박 목록 */}
|
||||||
@ -491,10 +547,15 @@ export function ChinaFishing() {
|
|||||||
{apiLoading ? '데이터 로딩 중...' : '중국어선 특이운항 데이터가 없습니다'}
|
{apiLoading ? '데이터 로딩 중...' : '중국어선 특이운항 데이터가 없습니다'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{vesselList.map((v) => (
|
{vesselList.map((v) => {
|
||||||
|
const selected = v.mmsi === selectedMmsi;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={v.id}
|
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"
|
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} />
|
<StatusRing status={v.status} riskPct={v.riskPct} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@ -512,7 +573,8 @@ export function ChinaFishing() {
|
|||||||
</div>
|
</div>
|
||||||
<ChevronRight className="w-4 h-4 text-hint group-hover:text-muted-foreground shrink-0" />
|
<ChevronRight className="w-4 h-4 text-hint group-hover:text-muted-foreground shrink-0" />
|
||||||
</div>
|
</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">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-[11px] font-bold text-heading">최근 위성영상 분석</span>
|
<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">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-[11px] font-bold text-heading">기상 예보</span>
|
<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">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-[11px] font-bold text-heading">VTS연계 현황</span>
|
<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.critical} color="text-red-400" />
|
<StatBox label="CRITICAL" value={stats.criticalCount} color="text-red-400" />
|
||||||
<StatBox label="HIGH" value={stats.high} color="text-orange-400" />
|
<StatBox label="HIGH" value={stats.highCount} color="text-orange-400" />
|
||||||
<StatBox label="MEDIUM" value={stats.medium} color="text-yellow-400" />
|
<StatBox label="MEDIUM" value={stats.mediumCount} color="text-yellow-400" />
|
||||||
<StatBox label="Dark" value={stats.dark} color="text-purple-400" />
|
<StatBox label="Dark" value={stats.darkCount} 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 서비스.
|
* 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');
|
||||||
}
|
}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user