kcg-ai-monitoring/frontend/src/features/detection/DarkVesselDetection.tsx

275 lines
11 KiB
TypeScript

import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Select } from '@shared/components/ui/select';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { EyeOff, AlertTriangle, Loader2, Filter } from 'lucide-react';
import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map';
import { getDarkVessels, type VesselAnalysis } from '@/services/analysisApi';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getRiskIntent } from '@shared/constants/statusIntent';
import { useSettingsStore } from '@stores/settingsStore';
/* SFR-09: Dark Vessel 탐지 — prediction 직접 API 기반 */
interface Suspect {
id: string;
mmsi: string;
name: string;
flag: string;
darkTier: string;
darkScore: number;
darkPatterns: string;
risk: number;
gap: number;
lastAIS: string;
lat: number;
lng: number;
[key: string]: unknown;
}
function deriveFlag(mmsi: string): string {
if (mmsi.startsWith('412')) return '중국';
if (mmsi.startsWith('440') || mmsi.startsWith('441')) return '한국';
return '미상';
}
const TIER_HEX: Record<string, string> = {
CRITICAL: '#ef4444',
HIGH: '#f97316',
WATCH: '#eab308',
NONE: '#6b7280',
};
function mapToSuspect(v: VesselAnalysis, idx: number): Suspect {
const feat = v.features ?? {};
const darkTier = (feat.dark_tier as string) ?? 'NONE';
const darkScore = (feat.dark_suspicion_score as number) ?? 0;
const patterns = (feat.dark_patterns as string[]) ?? [];
return {
id: `DV-${String(idx + 1).padStart(3, '0')}`,
mmsi: v.mmsi,
name: v.vesselType || v.mmsi,
flag: deriveFlag(v.mmsi),
darkTier,
darkScore,
darkPatterns: patterns.join(', ') || '-',
risk: v.riskScore ?? 0,
gap: v.gapDurationMin ?? 0,
lastAIS: formatDateTime(v.analyzedAt),
lat: v.lat ?? 0,
lng: v.lon ?? 0,
};
}
export function DarkVesselDetection() {
const { t } = useTranslation('detection');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const navigate = useNavigate();
const [tierFilter, setTierFilter] = useState<string>('');
const cols: DataColumn<Suspect>[] = useMemo(() => [
{ key: 'id', label: 'ID', width: '70px',
render: (v) => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'darkTier', label: '등급', width: '80px', sortable: true,
render: (v) => {
const tier = v as string;
return <Badge intent={getRiskIntent(tier === 'CRITICAL' ? 90 : tier === 'HIGH' ? 60 : tier === 'WATCH' ? 40 : 10)} size="sm">{tier}</Badge>;
} },
{ key: 'darkScore', label: '의심점수', width: '80px', align: 'center', sortable: true,
render: (v) => {
const n = v as number;
return <span className={`font-bold font-mono ${n >= 70 ? 'text-red-400' : n >= 50 ? 'text-orange-400' : 'text-yellow-400'}`}>{n}</span>;
} },
{ key: 'name', label: '선박 유형', sortable: true,
render: (v) => <span className="text-cyan-400 font-medium">{v as string}</span> },
{ key: 'mmsi', label: 'MMSI', width: '100px',
render: (v) => {
const mmsi = v as string;
return (
<button type="button" className="text-cyan-400 hover:text-cyan-300 hover:underline font-mono text-[10px]"
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${mmsi}`); }}>
{mmsi}
</button>
);
} },
{ key: 'flag', label: '국적', width: '50px' },
{ key: 'gap', label: 'AIS 공백', width: '80px', align: 'right', sortable: true,
render: (v) => {
const min = v as number;
return <span className="text-label font-mono text-[10px]">{min > 0 ? `${min}` : '-'}</span>;
} },
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
render: (v) => {
const n = v as number;
return <span className={`font-bold ${n >= 70 ? 'text-red-400' : n >= 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}</span>;
} },
{ key: 'darkPatterns', label: '의심 패턴', minWidth: '120px',
render: (v) => <span className="text-hint text-[9px]">{v as string}</span> },
{ key: 'lastAIS', label: '분석시각', width: '90px',
render: (v) => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
], [tc, lang, navigate]);
const [rawData, setRawData] = useState<VesselAnalysis[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const loadData = useCallback(async () => {
setLoading(true);
setError('');
try {
const res = await getDarkVessels({ hours: 1, size: 500 });
setRawData(res.content);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadData(); }, [loadData]);
// 30초 자동 갱신 (깜박임 없음 — loading 상태 변경 없이 데이터만 교체)
useEffect(() => {
const timer = setInterval(async () => {
try {
const res = await getDarkVessels({ hours: 1, size: 500 });
setRawData(res.content);
} catch { /* silent */ }
}, 30_000);
return () => clearInterval(timer);
}, []);
const DATA: Suspect[] = useMemo(() => {
let items = rawData.map((v, i) => mapToSuspect(v, i));
if (tierFilter) {
items = items.filter((d) => d.darkTier === tierFilter);
}
// 의심 점수 내림차순 정렬
return items.sort((a, b) => b.darkScore - a.darkScore);
}, [rawData, tierFilter]);
// KPI 카운트
const tierCounts = useMemo(() => {
const all = rawData.map((v) => ((v.features ?? {}).dark_tier as string) ?? 'NONE');
return {
total: all.length,
CRITICAL: all.filter((t) => t === 'CRITICAL').length,
HIGH: all.filter((t) => t === 'HIGH').length,
WATCH: all.filter((t) => t === 'WATCH').length,
};
}, [rawData]);
const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [
...createStaticLayers(),
createRadiusLayer(
'dv-radius',
DATA.filter((d) => d.darkScore >= 70).map((d) => ({
lat: d.lat, lng: d.lng, radius: 10000,
color: TIER_HEX[d.darkTier] || '#ef4444',
})),
0.08,
),
createMarkerLayer(
'dv-markers',
DATA.filter((d) => d.lat !== 0).map((d) => ({
lat: d.lat, lng: d.lng,
color: TIER_HEX[d.darkTier] || '#6b7280',
radius: d.darkScore >= 70 ? 1200 : 800,
label: `${d.id} ${d.name}`,
} as MarkerData)),
),
], [DATA]);
useMapLayers(mapRef, buildLayers, [DATA]);
return (
<PageContainer>
<PageHeader
icon={EyeOff}
iconColor="text-red-400"
title={t('darkVessel.title')}
description={t('darkVessel.desc')}
actions={
<div className="flex items-center gap-1">
<Filter className="w-3.5 h-3.5 text-hint" />
<Select size="sm" value={tierFilter} onChange={(e) => setTierFilter(e.target.value)}
title="등급 필터" className="w-32">
<option value=""> </option>
<option value="CRITICAL">CRITICAL</option>
<option value="HIGH">HIGH</option>
<option value="WATCH">WATCH</option>
</Select>
</div>
}
/>
{error && <div className="text-xs text-red-400">: {error}</div>}
{loading && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
</div>
)}
{/* KPI — tier 기반 */}
<div className="flex gap-2">
{[
{ l: '전체', v: tierCounts.total, c: 'text-red-400', filter: '' },
{ l: 'CRITICAL', v: tierCounts.CRITICAL, c: 'text-red-400', filter: 'CRITICAL' },
{ l: 'HIGH', v: tierCounts.HIGH, c: 'text-orange-400', filter: 'HIGH' },
{ l: 'WATCH', v: tierCounts.WATCH, c: 'text-yellow-400', filter: 'WATCH' },
].map((k) => (
<div key={k.l}
onClick={() => setTierFilter(k.filter)}
className={`flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border cursor-pointer transition-colors ${
tierFilter === k.filter ? 'bg-card border-blue-500/30' : 'bg-card border-border hover:border-border'
}`}>
<AlertTriangle className={`w-4 h-4 ${k.c}`} />
<span className={`text-base font-bold ${k.c}`}>{k.v}</span>
<span className="text-[9px] text-hint">{k.l}</span>
</div>
))}
</div>
<DataTable data={DATA} columns={cols} pageSize={10}
searchPlaceholder="선박유형, MMSI, 패턴 검색..."
searchKeys={['name', 'mmsi', 'darkPatterns', 'flag', 'darkTier']}
exportFilename="Dark_Vessel_탐지" />
{/* 탐지 위치 지도 */}
<Card>
<CardContent className="p-0 relative">
<BaseMap ref={mapRef} center={[36.5, 127.5]} zoom={7} height={450} className="rounded-lg overflow-hidden" />
{/* 범례 — tier 기반 */}
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
<div className="text-[9px] text-muted-foreground font-bold mb-1.5">Dark Tier</div>
<div className="space-y-1">
{(['CRITICAL', 'HIGH', 'WATCH', 'NONE'] as const).map((tier) => (
<div key={tier} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: TIER_HEX[tier] }} />
<span className="text-[8px] text-muted-foreground">{tier}</span>
</div>
))}
</div>
</div>
<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-red-500 animate-pulse" />
<span className="text-[10px] text-red-400 font-bold">{tierCounts.CRITICAL}</span>
<span className="text-[9px] text-hint">CRITICAL Dark Vessel</span>
</div>
</CardContent>
</Card>
</PageContainer>
);
}