diff --git a/frontend/src/features/detection/ChinaFishing.tsx b/frontend/src/features/detection/ChinaFishing.tsx index 2ea7e8f..aacb328 100644 --- a/frontend/src/features/detection/ChinaFishing.tsx +++ b/frontend/src/features/detection/ChinaFishing.tsx @@ -3,9 +3,9 @@ import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; import { PageContainer } from '@shared/components/layout'; import { - Search, Ship, Clock, ChevronRight, ChevronLeft, Cloud, + Search, Clock, ChevronRight, ChevronLeft, Cloud, Eye, AlertTriangle, Radio, RotateCcw, - MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2 + Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2 } from 'lucide-react'; import { formatDateTime } from '@shared/utils/dateFormat'; import { ALERT_LEVELS, getAlertLevelLabel, type AlertLevel } from '@shared/constants/alertLevels'; @@ -14,22 +14,23 @@ import { useSettingsStore } from '@stores/settingsStore'; import { useTranslation } from 'react-i18next'; import { GearIdentification } from './GearIdentification'; import { RealAllVessels, RealTransshipSuspects } from './RealVesselAnalysis'; +import { VesselMiniMap } from './components/VesselMiniMap'; +import { VesselAnomalyPanel } from './components/VesselAnomalyPanel'; +import { extractAnomalies, groupAnomaliesToSegments } from './components/vesselAnomaly'; import { PieChart as EcPieChart } from '@lib/charts'; +import type { VesselAnalysisItem } from '@/services/vesselAnalysisApi'; import { - fetchVesselAnalysis, - filterDarkVessels, - filterTransshipSuspects, - type VesselAnalysisItem, - type VesselAnalysisStats, -} from '@/services/vesselAnalysisApi'; + getAnalysisStats, + getAnalysisVessels, + getAnalysisHistory, + type AnalysisStats, + type VesselAnalysis, +} from '@/services/analysisApi'; +import { toVesselItem } from '@/services/analysisAdapter'; // ─── 중국 MMSI prefix ───────────── const CHINA_MMSI_PREFIX = '412'; -function isChinaVessel(mmsi: string): boolean { - return mmsi.startsWith(CHINA_MMSI_PREFIX); -} - // ─── 특이운항 선박 리스트 타입 ──────────────── type VesselStatus = '의심' | '양호' | '경고'; interface VesselItem { @@ -53,14 +54,16 @@ function deriveVesselStatus(score: number): VesselStatus { function mapToVesselItem(item: VesselAnalysisItem, idx: number): VesselItem { const score = item.algorithms.riskScore.score; + const vt = item.classification.vesselType; + const hasType = vt && vt !== 'UNKNOWN' && vt !== ''; return { id: String(idx + 1), mmsi: item.mmsi, callSign: '-', channel: '', source: 'AIS', - name: item.classification.vesselType || item.mmsi, - type: item.classification.fishingPct > 0.5 ? 'Fishing' : 'Cargo', + name: hasType ? vt : '중국어선', + type: item.classification.fishingPct > 0.5 ? 'Fishing' : hasType ? 'Cargo' : '미분류', country: 'China', status: deriveVesselStatus(score), riskPct: score, @@ -202,10 +205,14 @@ export function ChinaFishing() { const [mode, setMode] = useState<'dashboard' | 'transfer' | 'gear'>('dashboard'); const [vesselTab, setVesselTab] = useState<'특이운항' | '비허가 선박' | '제재 선박' | '관심 선박'>('특이운항'); const [statsTab, setStatsTab] = useState<'불법조업 통계' | '특이선박 통계' | '위험선박 통계'>('불법조업 통계'); + const [selectedMmsi, setSelectedMmsi] = useState(null); + const [history, setHistory] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyError, setHistoryError] = useState(''); // API state - const [allItems, setAllItems] = useState([]); - const [apiStats, setApiStats] = useState(null); + const [topVessels, setTopVessels] = useState([]); + const [apiStats, setApiStats] = useState(null); const [serviceAvailable, setServiceAvailable] = useState(true); const [apiLoading, setApiLoading] = useState(false); const [apiError, setApiError] = useState(''); @@ -214,10 +221,18 @@ export function ChinaFishing() { setApiLoading(true); setApiError(''); try { - const res = await fetchVesselAnalysis(); - setServiceAvailable(res.serviceAvailable); - setAllItems(res.items); - setApiStats(res.stats); + const [stats, topPage] = await Promise.all([ + getAnalysisStats({ hours: 1, mmsiPrefix: CHINA_MMSI_PREFIX }), + getAnalysisVessels({ + hours: 1, + mmsiPrefix: CHINA_MMSI_PREFIX, + minRiskScore: 40, + size: 20, + }), + ]); + setApiStats(stats); + setTopVessels(topPage.content.map(toVesselItem)); + setServiceAvailable(true); } catch (e: unknown) { setApiError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다'); setServiceAvailable(false); @@ -228,55 +243,77 @@ export function ChinaFishing() { useEffect(() => { loadApi(); }, [loadApi]); - // 중국어선 필터 - const chinaVessels = useMemo( - () => allItems.filter((i) => isChinaVessel(i.mmsi)), - [allItems], + // 선박 선택 시 24h 분석 이력 로드 (미니맵 anomaly 포인트 + 판별 패널 공통 데이터) + useEffect(() => { + if (!selectedMmsi) { setHistory([]); setHistoryError(''); return; } + let cancelled = false; + setHistoryLoading(true); setHistoryError(''); + getAnalysisHistory(selectedMmsi, 24) + .then((rows) => { if (!cancelled) setHistory(rows); }) + .catch((e: unknown) => { + if (cancelled) return; + setHistory([]); + setHistoryError(e instanceof Error ? e.message : '이력 조회 실패'); + }) + .finally(() => { if (!cancelled) setHistoryLoading(false); }); + return () => { cancelled = true; }; + }, [selectedMmsi]); + + const anomalySegments = useMemo( + () => groupAnomaliesToSegments(extractAnomalies(history)), + [history], ); - const chinaDark = useMemo(() => filterDarkVessels(chinaVessels), [chinaVessels]); - const chinaTransship = useMemo(() => filterTransshipSuspects(chinaVessels), [chinaVessels]); + // ─ 파생 계산 (서버 집계 우선) ─ + // Tab 1 '분석 대상' 및 카운터는 apiStats 값이 SSOT. + // topVessels 는 minRiskScore=40 으로 필터된 상위 20척 (특이운항 리스트 전용). + const countersRow1 = useMemo(() => { + if (!apiStats) return []; + return [ + { label: '통합', count: apiStats.total, color: '#6b7280' }, + { label: 'AIS', count: apiStats.total, color: '#3b82f6' }, + { label: 'EEZ 내', count: apiStats.territorialCount + apiStats.contiguousCount, color: '#8b5cf6' }, + { label: '어업선', count: apiStats.fishingCount, color: '#10b981' }, + ]; + }, [apiStats]); - // 센서 카운터 (API 기반) - const countersRow1 = useMemo(() => [ - { label: '통합', count: allItems.length, color: '#6b7280' }, - { label: 'AIS', count: allItems.length, color: '#3b82f6' }, - { label: 'EEZ 내', count: allItems.filter((i) => i.algorithms.location.zone !== 'EEZ_OR_BEYOND').length, color: '#8b5cf6' }, - { label: '어업선', count: allItems.filter((i) => i.classification.fishingPct > 0.5).length, color: '#10b981' }, - ], [allItems]); + const countersRow2 = useMemo(() => { + if (!apiStats) return []; + return [ + { label: '중국어선', count: apiStats.total, color: '#f97316' }, + { label: 'Dark Vessel', count: apiStats.darkCount, color: '#ef4444' }, + { label: '환적 의심', count: apiStats.transshipCount, color: '#06b6d4' }, + { label: '고위험', count: apiStats.criticalCount + apiStats.highCount, color: '#ef4444' }, + ]; + }, [apiStats]); - const countersRow2 = useMemo(() => [ - { label: '중국어선', count: chinaVessels.length, color: '#f97316' }, - { label: 'Dark Vessel', count: chinaDark.length, color: '#ef4444' }, - { label: '환적 의심', count: chinaTransship.length, color: '#06b6d4' }, - { label: '고위험', count: chinaVessels.filter((i) => i.algorithms.riskScore.score >= 70).length, color: '#ef4444' }, - ], [chinaVessels, chinaDark, chinaTransship]); - - // 특이운항 선박 리스트 (중국어선 중 riskScore >= 40) + // 특이운항 선박 리스트 (서버에서 이미 riskScore >= 40 로 필터링된 상위 20척) const vesselList: VesselItem[] = useMemo( - () => chinaVessels - .filter((i) => i.algorithms.riskScore.score >= 40) - .sort((a, b) => b.algorithms.riskScore.score - a.algorithms.riskScore.score) - .slice(0, 20) - .map((item, idx) => mapToVesselItem(item, idx)), - [chinaVessels], + () => topVessels.map((item, idx) => mapToVesselItem(item, idx)), + [topVessels], ); - // 위험도별 분포 (도넛 차트용) + // 위험도별 분포 (도넛 차트용) — apiStats 기반 const riskDistribution = useMemo(() => { - const critical = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'CRITICAL').length; - const high = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'HIGH').length; - const medium = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'MEDIUM').length; - const low = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'LOW').length; - return { critical, high, medium, low, total: chinaVessels.length }; - }, [chinaVessels]); + if (!apiStats) return { critical: 0, high: 0, medium: 0, low: 0, total: 0 }; + return { + critical: apiStats.criticalCount, + high: apiStats.highCount, + medium: apiStats.mediumCount, + low: apiStats.lowCount, + total: apiStats.total, + }; + }, [apiStats]); - // 안전도 지수 계산 + // 안전도 지수 계산 (avgRiskScore 0~100 → 0~10 스케일) const safetyIndex = useMemo(() => { - if (chinaVessels.length === 0) return { risk: 0, safety: 100 }; - const avgRisk = chinaVessels.reduce((s, i) => s + i.algorithms.riskScore.score, 0) / chinaVessels.length; - return { risk: Number((avgRisk / 10).toFixed(2)), safety: Number(((100 - avgRisk) / 10).toFixed(2)) }; - }, [chinaVessels]); + const avgRisk = apiStats ? Number(apiStats.avgRiskScore) : 0; + if (!apiStats || apiStats.total === 0) return { risk: 0, safety: 100 }; + return { + risk: Number((avgRisk / 10).toFixed(2)), + safety: Number(((100 - avgRisk) / 10).toFixed(2)), + }; + }, [apiStats]); const vesselTabs = ['특이운항', '비허가 선박', '제재 선박', '관심 선박'] as const; const statsTabs = ['불법조업 통계', '특이선박 통계', '위험선박 통계'] as const; @@ -319,7 +356,7 @@ export function ChinaFishing() { {!serviceAvailable && (
- iran 분석 서비스 미연결 - 실시간 데이터를 불러올 수 없습니다 + 분석 API 호출 실패 - 잠시 후 다시 시도해주세요
)} @@ -371,7 +408,7 @@ export function ChinaFishing() {
해역 전체 통항량 - {allItems.length.toLocaleString()} + {(apiStats?.total ?? 0).toLocaleString()} (척)
@@ -422,12 +459,15 @@ export function ChinaFishing() { - {/* 관심영역 안전도 */} + {/* 관심영역 안전도 (해역 지오펜스 미구축 → 데모) */}
- 관심영역 안전도 +
+ 관심영역 안전도 + 데모 데이터 +