diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b7fa6c5..b85a8bd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,7 @@ import { useAuth } from './hooks/useAuth'; import { useTranslation } from 'react-i18next'; import LoginPage from './components/auth/LoginPage'; import CollectorMonitor from './components/common/CollectorMonitor'; +import { FieldAnalysisModal } from './components/korea/FieldAnalysisModal'; import './App.css'; function App() { @@ -147,6 +148,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { }, []); const [showCollectorMonitor, setShowCollectorMonitor] = useState(false); + const [showFieldAnalysis, setShowFieldAnalysis] = useState(false); const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST'); const [hoveredShipMmsi, setHoveredShipMmsi] = useState(null); const [focusShipMmsi, setFocusShipMmsi] = useState(null); @@ -307,6 +309,15 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { 🎣 μ€‘κ΅­μ–΄μ„ κ°μ‹œ + )} @@ -539,6 +550,9 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { <>
+ {showFieldAnalysis && ( + setShowFieldAnalysis(false)} /> + )} (); + +async function fetchVesselPermit(mmsi: string): Promise { + if (permitCache.has(mmsi)) return permitCache.get(mmsi) ?? null; + try { + const res = await fetch(`/api/kcg/vessel-permit/${mmsi}`); + if (res.status === 404) { permitCache.set(mmsi, null); return null; } + if (!res.ok) throw new Error(`${res.status}`); + const data: PermitRecord = await res.json(); + permitCache.set(mmsi, data); + return data; + } catch { + return null; + } +} + +// 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; + +// ν™©ν•΄ μœ„μΉ˜ 기반 μˆ˜μ—­ λΆ„λ₯˜ (근사값) +function classifyZone(lng: number): string { + if (lng > 124.8) return 'TERRITORIAL'; + if (lng > 124.2) return 'CONTIGUOUS'; + if (lng > 121.5) return 'EEZ'; + return 'BEYOND'; +} + +// AIS μˆ˜μ‹  κΈ°μ€€ μ„ λ°• μƒνƒœ λΆ„λ₯˜ +function classifyState(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'; +} + +function getAlertLevel(zone: string, state: string): 'CRITICAL' | 'WATCH' | 'MONITOR' | 'NORMAL' { + if (zone === 'TERRITORIAL') return 'CRITICAL'; + if (state === 'AIS_LOSS') return 'WATCH'; + if (zone === 'CONTIGUOUS' && state === 'FISHING') return 'WATCH'; + if (zone === 'EEZ' && state === 'FISHING') 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; +} + +// κ·Όμ ‘ ν΄λŸ¬μŠ€ν„°λ§ (~5NM λ‚΄ 2μ²™ 이상 집단) +function buildClusters(vessels: ProcessedVessel[]): Map { + const result = new Map(); + let clusterIdx = 0; + for (let i = 0; i < vessels.length; i++) { + if (result.has(vessels[i].ship.mmsi)) continue; + const cluster: string[] = [vessels[i].ship.mmsi]; + for (let j = i + 1; j < vessels.length; j++) { + if (result.has(vessels[j].ship.mmsi)) continue; + const dlat = Math.abs(vessels[i].ship.lat - vessels[j].ship.lat); + const dlng = Math.abs(vessels[i].ship.lng - vessels[j].ship.lng); + if (dlat < 0.08 && dlng < 0.08) { + cluster.push(vessels[j].ship.mmsi); + } + } + if (cluster.length >= 2) { + clusterIdx++; + const id = `C-${String(clusterIdx).padStart(2, '0')}`; + cluster.forEach(mmsi => result.set(mmsi, id)); + } + } + return result; +} + +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[]; + onClose: () => void; +} + +const PIPE_STEPS = [ + { num: '01', name: 'AIS μ „μ²˜λ¦¬' }, + { num: '02', name: '행동 μƒνƒœ 탐지' }, + { num: '03', name: 'ꢀ적 λ¦¬μƒ˜ν”Œλ§' }, + { num: '04', name: 'νŠΉμ§• 벑터 μΆ”μΆœ' }, + { num: '05', name: 'LightGBM λΆ„λ₯˜' }, + { num: '06', name: 'BIRCH κ΅°μ§‘ν™”' }, + { num: '07', name: 'κ³„μ ˆ ν™œλ™ 뢄석' }, +]; + +const ALERT_ORDER: Record = { CRITICAL: 0, WATCH: 1, MONITOR: 2, NORMAL: 3 }; + +export function FieldAnalysisModal({ ships, onClose }: Props) { + 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]); + + // μ„ λ°• 데이터 처리 + const processed = useMemo((): ProcessedVessel[] => { + const baseList = cnFishing.map(ship => { + const zone = classifyZone(ship.lng); + const state = classifyState(ship); + const alert = getAlertLevel(zone, state); + const analysis = analyzeFishing(ship); + const gear = analysis.gearType; + const vtype = + (gear === 'trawl_pair' || gear === 'trawl_single') ? 'TRAWL' : + gear === 'purse_seine' ? 'PURSE' : + gear === 'gillnet' ? 'GILLNET' : + gear === 'stow_net' ? 'TRAP' : + 'TRAWL'; + return { ship, zone, state, alert, vtype, cluster: '' }; + }); + const clusterMap = buildClusters(baseList); + return baseList.map(v => ({ ...v, cluster: clusterMap.get(v.ship.mmsi) ?? 'β€”' })); + }, [cnFishing]); + + // ν•„ν„° + μ •λ ¬ + 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]); + + // 톡계 + const stats = useMemo(() => ({ + total: processed.length, + territorial: processed.filter(v => v.zone === 'TERRITORIAL').length, + fishing: processed.filter(v => v.state === 'FISHING').length, + aisLoss: processed.filter(v => v.state === 'AIS_LOSS').length, + gpsAnomaly: 0, + 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]); + + // ꡬ역별 카운트 + const zoneCounts = useMemo(() => ({ + terr: processed.filter(v => v.zone === 'TERRITORIAL').length, + cont: processed.filter(v => v.zone === 'CONTIGUOUS').length, + eez: processed.filter(v => v.zone === 'EEZ').length, + beyond: processed.filter(v => v.zone === 'BEYOND').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); + fetchVesselPermit(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 Β· LightGBM Β· 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 μ˜μ‹¬' }, + { label: '집단 ν΄λŸ¬μŠ€ν„°', val: stats.clusters, color: C.amber, sub: 'BIRCH κ΅°μ§‘' }, + { label: 'νŠΈλ‘€μ–΄μ„ ', val: stats.trawl, color: C.purple, sub: 'LightGBM λΆ„λ₯˜' }, + { label: '선망어선', val: stats.purse, color: C.cyan, sub: 'LightGBM λΆ„λ₯˜' }, + ].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: 'ACTIVE', color: C.amber }, + { num: 'NRD', name: 'λ ˆμ΄λ” ꡐ차검증', status: 'LINKED', color: C.cyan }, + ].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: 'BIRCH 5NM', color: C.ink2 }, + { label: 'μ„ μ’… λΆ„λ₯˜', val: 'LightGBM 95.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λΆ„ κ°±μ‹  | Shepperson(2017) κΈ°μ€€ | LightGBM 95.68% | BIRCH 5NM + +
+
+ + {/* ── 우츑 νŒ¨λ„: μ„ λ°• 상세 + ν—ˆκ°€ 정보 + 사진 + 경보 둜그 */} +
+ {/* νŒ¨λ„ 헀더 */} +
+ μ„ λ°• 상세 정보 + ● +
+ + {/* 슀크둀 μ˜μ—­: 상세 + ν—ˆκ°€ + 사진 */} +
+ {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: 'LightGBM μ„ μ’…', val: selectedVessel.vtype, color: C.ink }, + { label: 'ν˜„μž¬ ꡬ역', val: zoneLabel(selectedVessel.zone), color: zoneColor(selectedVessel.zone) }, + { label: 'BIRCH ν΄λŸ¬μŠ€ν„°', val: selectedVessel.cluster, color: selectedVessel.cluster !== 'β€”' ? C.purple : C.ink3 }, + { label: '경보 λ“±κΈ‰', val: selectedVessel.alert, color: alertColor(selectedVessel.alert) }, + ].map(({ label, val, color }) => ( +
+ {label} + {val} +
+ ))} +
+ + +
+
+ + {/* ── ν—ˆκ°€ 정보 */} +
+
ν—ˆκ°€ 정보
+ + {/* ν—ˆκ°€ μ—¬λΆ€ λ°°μ§€ */} +
+ ν—ˆκ°€ μ—¬λΆ€ + {permitStatus === 'loading' && ( + 쑰회 쀑... + )} + {permitStatus === 'found' && ( + + βœ“ ν—ˆκ°€ μ„ λ°• + + )} + {permitStatus === 'not-found' && ( + + βœ• 미등둝 μ„ λ°• + + )} +
+ + {/* ν—ˆκ°€ λ‚΄μ—­ (데이터 μžˆμ„ λ•Œ) */} + {permitStatus === 'found' && permitData && ( +
+ {[ + { label: 'ν—ˆκ°€λ²ˆν˜Έ', val: permitData.permitNumber }, + { label: 'ν—ˆκ°€μ’…λ₯˜', val: permitData.permitType }, + { label: 'λ°œκΈ‰κΈ°κ΄€', val: permitData.issuedBy }, + { label: 'μœ νš¨κΈ°κ°„', val: `${permitData.validFrom} ~ ${permitData.validTo}` }, + { label: 'ν—ˆκ°€μˆ˜μ—­', val: permitData.authorizedZones.join(', ') }, + ...(permitData.grossTonnage ? [{ label: 'μ΄ν†€μˆ˜', val: `${permitData.grossTonnage}GT` }] : []), + ].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 && ( +
경보 μ—†μŒ
+ )} +
+
+
+
+ ); +}