275 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|