import { useState, useMemo, useEffect, useCallback } from 'react'; import type { Ship, ChnPrmShipInfo, VesselAnalysisDto } from '../../types'; import { FONT_MONO } from '../../styles/fonts'; import { classifyFishingZone } from '../../utils/fishingAnalysis'; import { getMarineTrafficCategory } from '../../utils/marineTraffic'; import { lookupPermittedShip } from '../../services/chnPrmShip'; import type { UseVesselAnalysisResult } from '../../hooks/useVesselAnalysis'; // MarineTraffic 사진 캐시 (null = 없음, undefined = 미조회) const mtPhotoCache = new Map(); async function loadMarineTrafficPhoto(mmsi: string): Promise { if (mtPhotoCache.has(mmsi)) return mtPhotoCache.get(mmsi) ?? null; return new Promise(resolve => { const url = `https://photos.marinetraffic.com/ais/showphoto.aspx?mmsi=${mmsi}&size=thumb300`; const img = new Image(); img.onload = () => { mtPhotoCache.set(mmsi, url); resolve(url); }; img.onerror = () => { mtPhotoCache.set(mmsi, null); resolve(null); }; img.src = url; }); } // S&P Global 이미지 캐시 const spgCache = new Map(); async function loadSpgPhoto(imo: string, shipImagePath: string): Promise { if (spgCache.has(imo)) return spgCache.get(imo) ?? null; try { const res = await fetch(`/signal-batch/api/v1/shipimg/${imo}`); if (!res.ok) throw new Error(); const data: Array<{ picId: number; path: string }> = await res.json(); const url = data.length > 0 ? `${data[0].path}_2.jpg` : `${shipImagePath.replace(/_[12]\.\w+$/, '')}_2.jpg`; spgCache.set(imo, url); return url; } catch { const fallback = `${shipImagePath.replace(/_[12]\.\w+$/, '')}_2.jpg`; spgCache.set(imo, fallback); return fallback; } } // ── 항상 다크 테마 색상 팔레트 const C = { bg: '#07101A', bg2: '#0C1825', bg3: '#112033', panel: '#040C14', green: '#00E676', cyan: '#18FFFF', amber: '#FFD740', red: '#FF5252', purple: '#E040FB', ink: '#CFE2F3', ink2: '#7EA8C4', ink3: '#3D6480', border: '#1A3350', border2: '#0E2035', } as const; // AIS 수신 기준 선박 상태 분류 (Python 결과 없을 때 fallback) function classifyStateFallback(ship: Ship): string { const ageMins = (Date.now() - ship.lastSeen) / 60000; if (ageMins > 20) return 'AIS_LOSS'; if (ship.speed <= 0.5) return 'STATIONARY'; if (ship.speed >= 5.0) return 'SAILING'; return 'FISHING'; } // Python RiskLevel → 경보 등급 매핑 function riskToAlert(level: string): 'CRITICAL' | 'WATCH' | 'MONITOR' | 'NORMAL' { if (level === 'CRITICAL') return 'CRITICAL'; if (level === 'HIGH') return 'WATCH'; if (level === 'MEDIUM') return 'MONITOR'; return 'NORMAL'; } function stateLabel(s: string): string { const map: Record = { FISHING: '조업중', SAILING: '항행중', STATIONARY: '정박', AIS_LOSS: 'AIS소실', }; return map[s] ?? s; } function zoneLabel(z: string): string { const map: Record = { TERRITORIAL: '영해(침범!)', CONTIGUOUS: '접속수역', EEZ: 'EEZ', BEYOND: 'EEZ외측', }; return map[z] ?? z; } interface ProcessedVessel { ship: Ship; zone: string; state: string; alert: 'CRITICAL' | 'WATCH' | 'MONITOR' | 'NORMAL'; vtype: string; cluster: string; } interface LogEntry { ts: string; mmsi: string; name: string; type: string; level: 'critical' | 'watch' | 'info'; } interface Props { ships: Ship[]; vesselAnalysis?: UseVesselAnalysisResult; onClose: () => void; } const PIPE_STEPS = [ { num: '01', name: 'AIS 전처리' }, { num: '02', name: '행동 상태 탐지' }, { num: '03', name: '궤적 리샘플링' }, { num: '04', name: '특징 벡터 추출' }, { num: '05', name: '규칙 기반 분류' }, { num: '06', name: 'BIRCH 군집화' }, { num: '07', name: '계절 활동 분석' }, ]; const ALERT_ORDER: Record = { CRITICAL: 0, WATCH: 1, MONITOR: 2, NORMAL: 3 }; export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) { const emptyMap = useMemo(() => new Map(), []); const analysisMap = vesselAnalysis?.analysisMap ?? emptyMap; const [activeFilter, setActiveFilter] = useState('ALL'); const [search, setSearch] = useState(''); const [selectedMmsi, setSelectedMmsi] = useState(null); const [logs, setLogs] = useState([]); const [pipeStep, setPipeStep] = useState(0); const [tick, setTick] = useState(0); // 중국 어선만 필터 const cnFishing = useMemo(() => ships.filter(s => { if (s.flag !== 'CN') return false; const cat = getMarineTrafficCategory(s.typecode, s.category); return cat === 'fishing' || s.category === 'fishing'; }), [ships]); // 선박 데이터 처리 — Python 분석 결과 우선, 없으면 GeoJSON 수역 + AIS fallback const processed = useMemo((): ProcessedVessel[] => { return cnFishing.map(ship => { const dto = analysisMap.get(ship.mmsi); // 수역: Python → GeoJSON 폴리곤 fallback let zone: string; if (dto) { zone = dto.algorithms.location.zone; } else { const zoneInfo = classifyFishingZone(ship.lat, ship.lng); zone = zoneInfo.zone === 'OUTSIDE' ? 'EEZ_OR_BEYOND' : zoneInfo.zone; } // 행동 상태: Python → AIS fallback const state = dto?.algorithms.activity.state ?? classifyStateFallback(ship); // 경보 등급: Python 위험도 직접 사용 const alert = dto ? riskToAlert(dto.algorithms.riskScore.level) : 'NORMAL'; // 어구 분류: Python classification const vtype = dto?.classification.vesselType ?? 'UNKNOWN'; // 클러스터: Python cluster ID const clusterId = dto?.algorithms.cluster.clusterId ?? -1; const cluster = clusterId >= 0 ? `C-${String(clusterId).padStart(2, '0')}` : '—'; return { ship, zone, state, alert, vtype, cluster }; }); }, [cnFishing, analysisMap]); // 필터 + 정렬 const displayed = useMemo(() => { return processed .filter(v => { if (activeFilter === 'CRITICAL' && v.alert !== 'CRITICAL') return false; if (activeFilter === 'FISHING' && v.state !== 'FISHING') return false; if (activeFilter === 'AIS_LOSS' && v.state !== 'AIS_LOSS') return false; if (activeFilter === 'TERRITORIAL' && v.zone !== 'TERRITORIAL') return false; if (search && !v.ship.mmsi.includes(search) && !v.ship.name.toLowerCase().includes(search)) return false; return true; }) .sort((a, b) => ALERT_ORDER[a.alert] - ALERT_ORDER[b.alert]); }, [processed, activeFilter, search]); // 통계 — Python 분석 결과 기반 const stats = useMemo(() => { let gpsAnomaly = 0; for (const v of processed) { const dto = analysisMap.get(v.ship.mmsi); if (dto && dto.algorithms.gpsSpoofing.spoofingScore > 0.5) gpsAnomaly++; } return { total: processed.length, territorial: processed.filter(v => v.zone === 'TERRITORIAL_SEA' || v.zone === 'ZONE_I').length, fishing: processed.filter(v => v.state === 'FISHING').length, aisLoss: processed.filter(v => v.state === 'AIS_LOSS' || v.state === 'UNKNOWN').length, gpsAnomaly, clusters: new Set(processed.filter(v => v.cluster !== '—').map(v => v.cluster)).size, trawl: processed.filter(v => v.vtype === 'TRAWL').length, purse: processed.filter(v => v.vtype === 'PURSE').length, }; }, [processed, analysisMap]); // 구역별 카운트 — Python zone 분류 기반 const zoneCounts = useMemo(() => ({ terr: processed.filter(v => v.zone === 'TERRITORIAL_SEA' || v.zone === 'ZONE_I').length, cont: processed.filter(v => v.zone === 'CONTIGUOUS_ZONE' || v.zone === 'ZONE_II').length, eez: processed.filter(v => v.zone === 'EEZ_OR_BEYOND' || v.zone === 'ZONE_III' || v.zone === 'ZONE_IV').length, beyond: processed.filter(v => !['TERRITORIAL_SEA', 'CONTIGUOUS_ZONE', 'EEZ_OR_BEYOND', 'ZONE_I', 'ZONE_II', 'ZONE_III', 'ZONE_IV'].includes(v.zone)).length, }), [processed]); // 초기 경보 로그 생성 useEffect(() => { const initLogs: LogEntry[] = processed .filter(v => v.alert === 'CRITICAL' || v.alert === 'WATCH') .slice(0, 10) .map((v, i) => { const t = new Date(Date.now() - i * 4 * 60000); const ts = t.toTimeString().slice(0, 8); const type = v.zone === 'TERRITORIAL' ? '영해 내 불법조업 탐지' : v.state === 'AIS_LOSS' ? 'AIS 신호 소실 — Dark Vessel 의심' : '접속수역 조업 행위 감지'; return { ts, mmsi: v.ship.mmsi, name: v.ship.name || '(Unknown)', type, level: v.alert === 'CRITICAL' ? 'critical' : 'watch' }; }); setLogs(initLogs); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // AI 파이프라인 애니메이션 useEffect(() => { const t = setInterval(() => setPipeStep(s => s + 1), 1200); return () => clearInterval(t); }, []); // 시계 tick useEffect(() => { const t = setInterval(() => setTick(s => s + 1), 1000); return () => clearInterval(t); }, []); void tick; // used to force re-render for clock // Escape 키 닫기 useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose]); const selectedVessel = useMemo(() => selectedMmsi ? processed.find(v => v.ship.mmsi === selectedMmsi) ?? null : null, [selectedMmsi, processed], ); // 허가 정보 const [permitStatus, setPermitStatus] = useState<'idle' | 'loading' | 'found' | 'not-found'>('idle'); const [permitData, setPermitData] = useState(null); // 선박 사진 const [photoUrl, setPhotoUrl] = useState(undefined); // undefined=로딩, null=없음 useEffect(() => { if (!selectedVessel) return; const { ship } = selectedVessel; // 허가 조회 setPermitStatus('loading'); setPermitData(null); lookupPermittedShip(ship.mmsi).then(data => { setPermitData(data); setPermitStatus(data ? 'found' : 'not-found'); }); // 사진 로드: S&P Global 우선, 없으면 MarineTraffic setPhotoUrl(undefined); if (ship.imo && ship.shipImagePath) { loadSpgPhoto(ship.imo, ship.shipImagePath).then(url => { if (url) { setPhotoUrl(url); return; } loadMarineTrafficPhoto(ship.mmsi).then(setPhotoUrl); }); } else { loadMarineTrafficPhoto(ship.mmsi).then(setPhotoUrl); } }, [selectedMmsi]); // eslint-disable-line react-hooks/exhaustive-deps const addLog = useCallback((mmsi: string, name: string, type: string, level: 'critical' | 'watch') => { const ts = new Date().toTimeString().slice(0, 8); setLogs(prev => [{ ts, mmsi, name, type, level }, ...prev].slice(0, 60)); }, []); const downloadCsv = useCallback(() => { const headers = ['MMSI', '선명', '위도', '경도', 'SOG(kt)', '침로(°)', '상태', '선종', '구역', '클러스터', '경보등급', '마지막수신(분전)']; const rows = processed.map(v => { const ageMins = Math.floor((Date.now() - v.ship.lastSeen) / 60000); return [ v.ship.mmsi, v.ship.name || '', v.ship.lat.toFixed(5), v.ship.lng.toFixed(5), v.state === 'AIS_LOSS' ? '' : v.ship.speed.toFixed(1), v.state === 'AIS_LOSS' ? '' : String(v.ship.course), stateLabel(v.state), v.vtype, zoneLabel(v.zone), v.cluster, v.alert, String(ageMins), ].map(s => `"${s}"`).join(','); }); const csv = [headers.join(','), ...rows].join('\n'); const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `cn_fishing_vessels_${new Date().toISOString().slice(0, 10)}.csv`; a.click(); URL.revokeObjectURL(url); }, [processed]); // 색상 헬퍼 const alertColor = (al: string) => ({ CRITICAL: C.red, WATCH: C.amber, MONITOR: C.cyan, NORMAL: C.green }[al] ?? C.ink3); const zoneColor = (z: string) => ({ TERRITORIAL: C.red, CONTIGUOUS: C.amber, EEZ: C.cyan, BEYOND: C.green }[z] ?? C.ink3); const stateColor = (s: string) => ({ FISHING: C.amber, SAILING: C.cyan, STATIONARY: C.green, AIS_LOSS: C.red }[s] ?? C.ink3); return (
{/* ── 헤더 */}
▶ FIELD ANALYSIS 중국 불법어업 현장분석 대시보드 AIS · 규칙분류 · BIRCH · Shepperson(2017) · Yan et al.(2022)
LIVE {new Date().toLocaleTimeString('ko-KR')}
{/* ── 통계 스트립 */}
{[ { label: '총 탐지 어선', val: stats.total, color: C.cyan, sub: 'AIS 수신 기준' }, { label: '영해 침범', val: stats.territorial, color: C.red, sub: '12NM 이내' }, { label: '조업 중', val: stats.fishing, color: C.amber, sub: 'SOG 0.5–5.0kt' }, { label: 'AIS 소실', val: stats.aisLoss, color: C.red, sub: '>20분 미수신' }, { label: 'GPS 이상', val: stats.gpsAnomaly, color: C.purple, sub: 'BD-09 스푸핑 50%↑' }, { label: '집단 클러스터', val: stats.clusters, color: C.amber, sub: 'DBSCAN 군집' }, { label: '트롤어선', val: stats.trawl, color: C.purple, sub: 'Python 분류' }, { label: '선망어선', val: stats.purse, color: C.cyan, sub: 'Python 분류' }, ].map(({ label, val, color, sub }) => (
{label}
{val}
{sub}
))}
{/* ── 메인 그리드 */}
{/* ── 좌측 패널: 구역 현황 + AI 파이프라인 */}
구역별 현황
{([ { label: '영해 (12NM)', count: zoneCounts.terr, color: C.red, sub: '즉시 퇴거 명령 필요' }, { label: '접속수역 (24NM)', count: zoneCounts.cont, color: C.amber, sub: '조업 행위 집중 모니터링' }, { label: 'EEZ 내측', count: zoneCounts.eez, color: C.amber, sub: '조업밀도 핫스팟 포함' }, { label: 'EEZ 외측', count: zoneCounts.beyond, color: C.green, sub: '정상 모니터링' }, ] as const).map(({ label, count, color, sub }) => { const max = Math.max(processed.length, 1); return (
{label} {count}
{sub}
); })}
AI 파이프라인 상태
{PIPE_STEPS.map((step, idx) => { const isRunning = idx === pipeStep % PIPE_STEPS.length; return (
{step.num} {step.name} {isRunning ? 'PROC' : 'OK'}
); })} {[ { num: 'GPS', name: 'BD-09 변환', status: 'STANDBY', color: C.ink3 }, { num: 'NRD', name: '레이더 교차검증', status: '미연동', color: C.ink3 }, ].map(step => (
{step.num} {step.name} {step.status}
))} {/* 알고리즘 기준 요약 */}
알고리즘 기준
{[ { label: '위치 판정', val: 'Haversine + 기선', color: C.ink2 }, { label: '조업 패턴', val: 'UCAF/UCFT SOG', color: C.ink2 }, { label: 'AIS 소실', val: '>20분 미수신', color: C.amber }, { label: 'GPS 조작', val: 'BD-09 좌표계', color: C.purple }, { label: '클러스터', val: 'DBSCAN 3NM (Python)', color: C.ink2 }, { label: '선종 분류', val: 'Python 7단계 파이프라인', color: C.green }, ].map(({ label, val, color }) => (
{label} {val}
))}
{/* ── 중앙 패널: 선박 테이블 */}
{/* 필터 바 */}
{[ { key: 'ALL', label: '전체' }, { key: 'CRITICAL', label: '긴급 경보' }, { key: 'FISHING', label: '조업 중' }, { key: 'AIS_LOSS', label: 'AIS 소실' }, { key: 'TERRITORIAL', label: '영해 내' }, ].map(({ key, label }) => ( ))} setSearch(e.target.value.toLowerCase())} placeholder="MMSI / 선명 검색..." style={{ flex: 1, minWidth: 120, background: C.bg3, border: `1px solid ${C.border}`, color: C.ink, padding: '3px 10px', fontSize: 10, borderRadius: 2, outline: 'none', fontFamily: 'inherit', }} /> 표시: {displayed.length}
{/* 테이블 */}
{['AIS', 'MMSI', '선명', '위도', '경도', 'SOG', '침로', '상태', '선종', '구역', '클러스터', '경보', '수신'].map(h => ( ))} {displayed.slice(0, 120).map(v => { const rowBg = v.alert === 'CRITICAL' ? 'rgba(255,82,82,0.08)' : v.alert === 'WATCH' ? 'rgba(255,215,64,0.05)' : v.alert === 'MONITOR' ? 'rgba(24,255,255,0.04)' : 'transparent'; const isSelected = v.ship.mmsi === selectedMmsi; const ageMins = Math.floor((Date.now() - v.ship.lastSeen) / 60000); return ( setSelectedMmsi(v.ship.mmsi)} style={{ background: isSelected ? 'rgba(0,230,118,0.08)' : rowBg, cursor: 'pointer', outline: isSelected ? `1px solid ${C.green}` : undefined, }} > ); })} {displayed.length === 0 && ( )}
{h}
{v.ship.mmsi} {v.ship.name || '(Unknown)'} {v.ship.lat.toFixed(3)}°N {v.ship.lng.toFixed(3)}°E {v.state === 'AIS_LOSS' ? '—' : `${v.ship.speed.toFixed(1)}kt`} {v.state !== 'AIS_LOSS' ? `${v.ship.course}°` : '—'} {stateLabel(v.state)} {v.vtype} {zoneLabel(v.zone)} {v.cluster} {v.alert} {ageMins < 60 ? `${ageMins}분전` : `${Math.floor(ageMins / 60)}시간전`}
탐지된 중국 어선 없음
{/* 하단 범례 */}
{[ { color: C.red, label: 'CRITICAL — 즉시대응' }, { color: C.amber, label: 'WATCH — 집중모니터링' }, { color: C.cyan, label: 'MONITOR — 주시' }, { color: C.green, label: 'NORMAL — 정상' }, ].map(({ color, label }) => ( {label} ))} AIS 4분 갱신 | Python 7단계 파이프라인 | DBSCAN 3NM 클러스터 | GeoJSON 수역 분류
{/* ── 우측 패널: 선박 상세 + 허가 정보 + 사진 + 경보 로그 */}
{/* 패널 헤더 */}
선박 상세 정보
{/* 스크롤 영역: 상세 + 허가 + 사진 */}
{selectedVessel ? ( <> {/* 기본 상세 필드 */}
{[ { label: 'MMSI', val: selectedVessel.ship.mmsi, color: C.cyan }, { label: '선명', val: selectedVessel.ship.name || '(Unknown)', color: '#fff' }, { label: '위치', val: `${selectedVessel.ship.lat.toFixed(4)}°N ${selectedVessel.ship.lng.toFixed(4)}°E`, color: C.ink }, { label: '속도 / 침로', val: `${selectedVessel.ship.speed.toFixed(1)}kt ${selectedVessel.ship.course}°`, color: C.amber }, { label: '행동 상태', val: stateLabel(selectedVessel.state), color: stateColor(selectedVessel.state) }, { label: '선종 (Python)', val: selectedVessel.vtype, color: C.ink }, { label: '현재 구역', val: zoneLabel(selectedVessel.zone), color: zoneColor(selectedVessel.zone) }, { label: '클러스터', val: selectedVessel.cluster, color: selectedVessel.cluster !== '—' ? C.purple : C.ink3 }, { label: '위험도', val: selectedVessel.alert, color: alertColor(selectedVessel.alert) }, ...(() => { const dto = analysisMap.get(selectedVessel.ship.mmsi); if (!dto) return [{ label: 'AI 분석', val: '미분석', color: C.ink3 }]; return [ { label: '위험 점수', val: `${dto.algorithms.riskScore.score}점`, color: alertColor(selectedVessel.alert) }, { label: 'GPS 스푸핑', val: `${Math.round(dto.algorithms.gpsSpoofing.spoofingScore * 100)}%`, color: dto.algorithms.gpsSpoofing.spoofingScore > 0.5 ? C.red : C.green }, { label: 'AIS 공백', val: dto.algorithms.darkVessel.isDark ? `${Math.round(dto.algorithms.darkVessel.gapDurationMin)}분` : '정상', color: dto.algorithms.darkVessel.isDark ? C.red : C.green }, { label: '선단 역할', val: dto.algorithms.fleetRole.role, color: dto.algorithms.fleetRole.isLeader ? C.amber : C.ink2 }, ]; })(), ].map(({ label, val, color }) => (
{label} {val}
))}
{/* ── 허가 정보 */}
허가 정보
{/* 허가 여부 배지 */}
허가 여부 {permitStatus === 'loading' && ( 조회 중... )} {permitStatus === 'found' && ( ✓ 허가 선박 )} {permitStatus === 'not-found' && ( ✕ 미등록 선박 )}
{/* 허가 내역 (데이터 있을 때) */} {permitStatus === 'found' && permitData && (
{[ { label: '선명', val: permitData.name }, { label: '선종', val: permitData.vesselType }, { label: 'IMO', val: String(permitData.imo || '—') }, { label: '호출부호', val: permitData.callsign || '—' }, { label: '길이/폭', val: `${permitData.length ?? 0}m / ${permitData.width ?? 0}m` }, { label: '흘수', val: permitData.draught ? `${permitData.draught}m` : '—' }, { label: '목적지', val: permitData.destination || '—' }, { label: '상태', val: permitData.status || '—' }, ].map(({ label, val }) => (
{label} {val}
))}
)} {/* 미등록 안내 */} {permitStatus === 'not-found' && (
한중어업협정 허가 DB에 등록되지 않은 선박입니다.
불법어업 의심 — 추가 조사 및 조치 필요
)}
{/* ── 선박 사진 */}
선박 사진
{photoUrl === undefined && ( 로딩 중... )} {photoUrl === null && ( 사진 없음 )} {photoUrl && ( {selectedVessel.ship.name setPhotoUrl(null)} /> )}
{photoUrl && (
© MarineTraffic / S&P Global
)}
) : (
테이블에서 선박을 선택하세요
)}
{/* 경보 로그 — 하단 고정 */}
실시간 경보 로그 {logs.length}건
{logs.map((log, i) => (
{log.ts}
{log.mmsi} {log.name} — {log.type}
))} {logs.length === 0 && (
경보 없음
)}
); }