- 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>
297 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|