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 = { 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(''); const cols: DataColumn[] = useMemo(() => [ { key: 'id', label: 'ID', width: '70px', render: (v) => {v as string} }, { key: 'darkTier', label: '등급', width: '80px', sortable: true, render: (v) => { const tier = v as string; return {tier}; } }, { key: 'darkScore', label: '의심점수', width: '80px', align: 'center', sortable: true, render: (v) => { const n = v as number; return = 70 ? 'text-red-400' : n >= 50 ? 'text-orange-400' : 'text-yellow-400'}`}>{n}; } }, { key: 'name', label: '선박 유형', sortable: true, render: (v) => {v as string} }, { key: 'mmsi', label: 'MMSI', width: '100px', render: (v) => { const mmsi = v as string; return ( ); } }, { key: 'flag', label: '국적', width: '50px' }, { key: 'gap', label: 'AIS 공백', width: '80px', align: 'right', sortable: true, render: (v) => { const min = v as number; return {min > 0 ? `${min}분` : '-'}; } }, { key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true, render: (v) => { const n = v as number; return = 70 ? 'text-red-400' : n >= 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}; } }, { key: 'darkPatterns', label: '의심 패턴', minWidth: '120px', render: (v) => {v as string} }, { key: 'lastAIS', label: '분석시각', width: '90px', render: (v) => {v as string} }, ], [tc, lang, navigate]); const [rawData, setRawData] = useState([]); 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(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 ( } /> {error &&
에러: {error}
} {loading && (
)} {/* KPI — tier 기반 */}
{[ { 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) => (
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' }`}> {k.v} {k.l}
))}
{/* 탐지 위치 지도 */} {/* 범례 — tier 기반 */}
Dark Tier
{(['CRITICAL', 'HIGH', 'WATCH', 'NONE'] as const).map((tier) => (
{tier}
))}
{tierCounts.CRITICAL}척 CRITICAL Dark Vessel
); }