kcg-ai-monitoring/frontend/src/features/vessel/TransferDetection.tsx
htlee d354c1ebc7 feat(frontend): 탐지 결과 운영 워크플로우 UI 구축
- DarkVesselDetection: 판정 상세 사이드 패널(점수 산출 내역 P1~P11,
  GAP 상세, 7일 이력 차트), 선박 위치 gap_start_lat/lon fallback,
  클릭 시 지도 하이라이트
- TransferDetection: 5단계 필터 기반 환적 운영 화면 재구성
  (KPI, 쌍 목록, 쌍 상세, 감시영역 지도, 탐지 조건 시각화)
- GearDetection: 모선 추론 상태(DIRECT_MATCH/AUTO_PROMOTED/REVIEW_REQUIRED),
  추정 모선 MMSI, 후보 수 3개 컬럼 추가
- EnforcementPlan: CRITICAL 이벤트를 카테고리별(다크베셀/환적/EEZ침범/고위험)
  아이콘+라벨로 "탐지 기반 단속 대상" 통합 표시
- darkVesselPatterns: prediction P1~P11 전 패턴 한국어 카탈로그 +
  buildScoreBreakdown() 점수 산출 유틸
- ScoreBreakdown: 가점/감점 분리 점수 내역 시각화 공통 컴포넌트

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 07:56:52 +09:00

297 lines
12 KiB
TypeScript

import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader, Section } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { RefreshCw, AlertTriangle, Ship, Anchor, Loader2, MapPin, Clock, ArrowRight } from 'lucide-react';
import { BaseMap, createStaticLayers, createMarkerLayer, useMapLayers, type MapHandle } from '@lib/map';
import { getTransshipSuspects, type VesselAnalysis } from '@/services/analysisApi';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getRiskIntent } from '@shared/constants/statusIntent';
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
import { useSettingsStore } from '@stores/settingsStore';
/* 환적 의심 운영 화면 — prediction 5단계 필터 파이프라인 결과 표시 */
interface TransshipPair {
id: string;
mmsiA: string;
mmsiB: string;
duration: number;
score: number;
tier: string;
zone: string;
lat: number;
lng: number;
analyzedAt: string;
vesselTypeA: string;
vesselTypeB: string;
[key: string]: unknown;
}
function mapToPair(v: VesselAnalysis, idx: number): TransshipPair | null {
if (!v.transshipSuspect || !v.transshipPairMmsi) return null;
const feat = v.features ?? {};
return {
id: `TS-${String(idx + 1).padStart(3, '0')}`,
mmsiA: v.mmsi,
mmsiB: v.transshipPairMmsi,
duration: v.transshipDurationMin ?? 0,
score: (feat.transship_score as number) ?? 0,
tier: (feat.transship_tier as string) ?? 'HIGH',
zone: v.zoneCode ?? '-',
lat: v.lat ?? 0,
lng: v.lon ?? 0,
analyzedAt: formatDateTime(v.analyzedAt),
vesselTypeA: v.vesselType ?? 'UNKNOWN',
vesselTypeB: '-',
};
}
export function TransferDetection() {
const navigate = useNavigate();
const lang = useSettingsStore((s) => s.language);
const [rawData, setRawData] = useState<VesselAnalysis[]>([]);
const [loading, setLoading] = useState(false);
const [selectedPair, setSelectedPair] = useState<TransshipPair | null>(null);
const loadData = useCallback(async () => {
setLoading(true);
try {
const res = await getTransshipSuspects({ hours: 24, size: 200 });
setRawData(res.content);
} catch { /* silent */ }
finally { setLoading(false); }
}, []);
useEffect(() => { loadData(); }, [loadData]);
// 30초 자동 갱신
useEffect(() => {
const timer = setInterval(async () => {
try {
const res = await getTransshipSuspects({ hours: 24, size: 200 });
setRawData(res.content);
} catch { /* silent */ }
}, 30_000);
return () => clearInterval(timer);
}, []);
// 쌍 단위 중복 제거 (A↔B 동일 쌍)
const DATA: TransshipPair[] = useMemo(() => {
const seen = new Set<string>();
const pairs: TransshipPair[] = [];
rawData.forEach((v, i) => {
const p = mapToPair(v, i);
if (!p) return;
const key = [p.mmsiA, p.mmsiB].sort().join('-');
if (seen.has(key)) return;
seen.add(key);
pairs.push(p);
});
return pairs.sort((a, b) => b.score - a.score);
}, [rawData]);
const kpi = useMemo(() => ({
total: DATA.length,
critical: DATA.filter(d => d.score >= 70).length,
high: DATA.filter(d => d.score >= 50 && d.score < 70).length,
}), [DATA]);
const cols: DataColumn<TransshipPair>[] = useMemo(() => [
{ key: 'id', label: 'ID', width: '70px',
render: (v) => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'tier', label: '등급', width: '80px', sortable: true,
render: (v) => {
const tier = v as string;
return <Badge intent={getAlertLevelIntent(tier)} size="sm">{tier}</Badge>;
} },
{ key: 'score', label: '점수', width: '60px', align: 'center', sortable: true,
render: (v) => {
const n = v as number;
return <span className={`font-bold font-mono ${n >= 70 ? 'text-red-400' : 'text-orange-400'}`}>{n}</span>;
} },
{ key: 'mmsiA', label: '어선', width: '100px',
render: (v) => (
<button type="button" className="text-cyan-400 hover:underline font-mono text-[10px]"
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${v as string}`); }}>
{v as string}
</button>
) },
{ key: 'mmsiB', label: '상대선박', width: '100px',
render: (v) => (
<button type="button" className="text-orange-400 hover:underline font-mono text-[10px]"
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${v as string}`); }}>
{v as string}
</button>
) },
{ key: 'duration', label: '지속시간', width: '80px', align: 'right', sortable: true,
render: (v) => {
const min = v as number;
return <span className="font-mono text-[10px]">{min > 60 ? `${(min/60).toFixed(1)}h` : `${min}`}</span>;
} },
{ key: 'zone', label: '해역', width: '100px' },
{ key: 'analyzedAt', label: '탐지시각', width: '100px',
render: (v) => <span className="text-hint text-[10px]">{v as string}</span> },
], [navigate]);
// 지도
const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [
...createStaticLayers(),
// 어선 마커 (파랑)
createMarkerLayer('ts-fishing',
DATA.map(d => ({ lat: d.lat, lng: d.lng, color: '#3b82f6', radius: 1000, label: d.mmsiA })),
),
// 상대선 마커 (빨강) — 같은 위치에 오프셋
createMarkerLayer('ts-carrier',
DATA.map(d => ({ lat: d.lat + 0.003, lng: d.lng + 0.003, color: '#ef4444', radius: 1000, label: d.mmsiB })),
),
], [DATA]);
useMapLayers(mapRef, buildLayers, [DATA]);
return (
<PageContainer>
<PageHeader
icon={RefreshCw}
iconColor="text-cyan-400"
title="환적 의심 탐지"
description="어선↔운반선 근접 체류 기반 환적 의심 행위 분석 (5단계 필터 파이프라인)"
/>
{loading && (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-5 h-5 animate-spin text-hint" />
</div>
)}
{/* KPI */}
<div className="flex gap-2">
{[
{ label: '전체 의심', value: kpi.total, icon: AlertTriangle, color: 'text-red-400' },
{ label: 'CRITICAL', value: kpi.critical, icon: Ship, color: 'text-red-400' },
{ label: 'HIGH', value: kpi.high, icon: Anchor, color: 'text-orange-400' },
].map(k => (
<div key={k.label} className="flex-1 bg-card border border-border rounded-xl px-3 py-2 flex items-center gap-2">
<k.icon className={`w-4 h-4 ${k.color}`} />
<span className={`text-base font-bold ${k.color}`}>{k.value}</span>
<span className="text-[9px] text-hint">{k.label}</span>
</div>
))}
</div>
{/* 의심 목록 */}
<DataTable data={DATA} columns={cols} pageSize={10}
searchPlaceholder="MMSI 검색..."
searchKeys={['mmsiA', 'mmsiB', 'zone', 'tier']}
exportFilename="환적_의심_탐지"
onRowClick={(row) => setSelectedPair(row)}
/>
{/* 선택 쌍 상세 */}
{selectedPair && (
<Section title={`의심 쌍 상세 — ${selectedPair.id}`}>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs">
<Ship className="w-3.5 h-3.5 text-blue-400" />
<span className="text-label font-medium"></span>
</div>
<div className="bg-surface-raised rounded-lg p-3 text-xs space-y-1">
<div className="flex justify-between">
<span className="text-hint">MMSI</span>
<button type="button" className="text-cyan-400 hover:underline font-mono"
onClick={() => navigate(`/vessel/${selectedPair.mmsiA}`)}>
{selectedPair.mmsiA}
</button>
</div>
<div className="flex justify-between">
<span className="text-hint">()</span>
<span className="text-label">{selectedPair.vesselTypeA}</span>
</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs">
<Anchor className="w-3.5 h-3.5 text-orange-400" />
<span className="text-label font-medium"></span>
</div>
<div className="bg-surface-raised rounded-lg p-3 text-xs space-y-1">
<div className="flex justify-between">
<span className="text-hint">MMSI</span>
<button type="button" className="text-cyan-400 hover:underline font-mono"
onClick={() => navigate(`/vessel/${selectedPair.mmsiB}`)}>
{selectedPair.mmsiB}
</button>
</div>
</div>
</div>
</div>
<div className="bg-surface-raised rounded-lg p-3 mt-3 text-xs space-y-1">
<div className="flex justify-between">
<span className="text-hint"></span>
<span className="font-mono font-bold text-heading">
{selectedPair.duration} ({(selectedPair.duration / 60).toFixed(1)}h)
</span>
</div>
<div className="flex justify-between">
<span className="text-hint"></span>
<Badge intent={selectedPair.score >= 70 ? 'critical' : 'high'} size="sm">
{selectedPair.score} ({selectedPair.tier})
</Badge>
</div>
<div className="flex justify-between">
<span className="text-hint"></span>
<span className="font-mono text-label text-[10px]">
{selectedPair.lat.toFixed(4)}°N {selectedPair.lng.toFixed(4)}°E
</span>
</div>
<div className="flex justify-between">
<span className="text-hint"></span>
<span className="text-label">{selectedPair.zone}</span>
</div>
<div className="flex items-center justify-center gap-2 pt-2 text-hint text-[10px]">
<MapPin className="w-3 h-3" /> APPROACH <ArrowRight className="w-3 h-3" /> RENDEZVOUS ({selectedPair.duration}) <ArrowRight className="w-3 h-3" /> <Clock className="w-3 h-3" />
</div>
</div>
</Section>
)}
{/* 탐지 위치 지도 */}
<Card>
<CardContent className="p-0 relative">
<BaseMap ref={mapRef} center={[35.0, 126.0]} zoom={7} height={400} className="rounded-lg overflow-hidden" />
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
<div className="w-2 h-2 rounded-full bg-blue-500" />
<span className="text-[9px] text-hint"></span>
<div className="w-2 h-2 rounded-full bg-red-500" />
<span className="text-[9px] text-hint"></span>
<span className="text-[10px] text-label font-bold ml-2">{DATA.length}</span>
</div>
</CardContent>
</Card>
{/* 탐지 조건 */}
<Section title="탐지 조건 (5단계 파이프라인)">
<div className="grid grid-cols-5 gap-2">
{[
{ label: 'Stage 1', desc: '이종 쌍 필수', detail: '어선↔화물/유조선' },
{ label: 'Stage 2', desc: '감시영역', detail: '서해EEZ·남해·동해' },
{ label: 'Stage 3', desc: '패턴 검증', detail: '접근→체류90분+→분리' },
{ label: 'Stage 4', desc: '점수 산출', detail: '50점 이상만 출력' },
{ label: 'Stage 5', desc: '밀집 방폭', detail: '1운반선:1어선' },
].map(s => (
<div key={s.label} className="bg-surface-raised rounded-lg p-3 text-center">
<div className="text-[10px] text-cyan-400 font-bold">{s.label}</div>
<div className="text-xs text-heading font-medium mt-1">{s.desc}</div>
<div className="text-[9px] text-hint mt-0.5">{s.detail}</div>
</div>
))}
</div>
</Section>
</PageContainer>
);
}