kcg-monitoring/frontend/src/components/korea/FieldAnalysisModal.tsx
htlee 3f2052a46e feat: 웹폰트 내장 + 이란 시설물 색상/가독성 개선
- @fontsource-variable Inter, Noto Sans KR, Fira Code 자체 호스팅
- 전체 font-family 통일 (CSS, deck.gl, 인라인 스타일)
- 이란 시설물 색상 사막 대비 고채도 팔레트로 교체
- 이란 라벨 fontWeight 600→700, alpha 200→255
- 접힘 패널 상하 패딩 균일화
2026-03-24 10:11:59 +09:00

857 lines
41 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, string | null>();
async function loadMarineTrafficPhoto(mmsi: string): Promise<string | null> {
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<string, string | null>();
async function loadSpgPhoto(imo: string, shipImagePath: string): Promise<string | null> {
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<string, string> = {
FISHING: '조업중', SAILING: '항행중', STATIONARY: '정박', AIS_LOSS: 'AIS소실',
};
return map[s] ?? s;
}
function zoneLabel(z: string): string {
const map: Record<string, string> = {
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<string, number> = { CRITICAL: 0, WATCH: 1, MONITOR: 2, NORMAL: 3 };
export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
const emptyMap = useMemo(() => new Map<string, VesselAnalysisDto>(), []);
const analysisMap = vesselAnalysis?.analysisMap ?? emptyMap;
const [activeFilter, setActiveFilter] = useState('ALL');
const [search, setSearch] = useState('');
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
const [logs, setLogs] = useState<LogEntry[]>([]);
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<ChnPrmShipInfo | null>(null);
// 선박 사진
const [photoUrl, setPhotoUrl] = useState<string | null | undefined>(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 (
<div style={{
position: 'absolute', inset: 0, zIndex: 2000,
background: 'rgba(2,6,14,0.96)',
display: 'flex', flexDirection: 'column',
fontFamily: FONT_MONO,
}}>
{/* ── 헤더 */}
<div style={{
background: C.panel,
borderBottom: `1px solid ${C.border}`,
padding: '10px 20px',
display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0,
}}>
<span style={{ color: C.green, fontSize: 9, letterSpacing: 3 }}> FIELD ANALYSIS</span>
<span style={{ color: '#fff', fontSize: 14, fontWeight: 700, letterSpacing: 1 }}> </span>
<span style={{ color: C.ink3, fontSize: 10 }}>AIS · · BIRCH · Shepperson(2017) · Yan et al.(2022)</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 16, alignItems: 'center' }}>
<span style={{ color: C.green, fontSize: 10, display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: C.green, display: 'inline-block', animation: 'pulse 1.5s ease-in-out infinite' }} />
LIVE
</span>
<span style={{ color: C.ink2, fontSize: 10 }}>{new Date().toLocaleTimeString('ko-KR')}</span>
<button
type="button"
onClick={onClose}
style={{
background: 'rgba(255,82,82,0.1)', border: `1px solid rgba(255,82,82,0.4)`,
color: C.red, padding: '4px 14px', cursor: 'pointer',
fontSize: 11, borderRadius: 2, fontFamily: 'inherit',
}}
>
</button>
</div>
</div>
{/* ── 통계 스트립 */}
<div style={{
display: 'flex', gap: 8, padding: '8px 12px',
background: C.bg, flexShrink: 0,
borderBottom: `1px solid ${C.border}`,
}}>
{[
{ 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.55.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 }) => (
<div key={label} style={{
flex: 1, background: C.bg2, border: `1px solid ${C.border}`,
borderRadius: 3, padding: '8px 10px', textAlign: 'center',
borderTop: `2px solid ${color}`,
}}>
<div style={{ fontSize: 9, color: C.ink3, letterSpacing: 1 }}>{label}</div>
<div style={{ fontSize: 22, fontWeight: 700, color, lineHeight: 1.2 }}>{val}</div>
<div style={{ fontSize: 9, color: C.ink3 }}>{sub}</div>
</div>
))}
</div>
{/* ── 메인 그리드 */}
<div style={{
display: 'flex', flex: 1, overflow: 'hidden',
background: C.bg,
}}>
{/* ── 좌측 패널: 구역 현황 + AI 파이프라인 */}
<div style={{
width: 240, flexShrink: 0,
background: C.panel, borderRight: `1px solid ${C.border}`,
overflow: 'auto', padding: '10px 12px',
}}>
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, marginBottom: 8, paddingBottom: 6, borderBottom: `1px solid ${C.border}` }}>
<span style={{ float: 'right', color: C.green, fontSize: 8 }}></span>
</div>
{([
{ 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 (
<div key={label} style={{ marginBottom: 10 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3 }}>
<span style={{ fontSize: 10, color }}>{label}</span>
<span style={{ fontSize: 11, fontWeight: 700, color }}>{count}</span>
</div>
<div style={{ height: 4, background: C.border2, borderRadius: 2, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${Math.min((count / max) * 100, 100)}%`, background: color, borderRadius: 2, transition: 'width 0.5s' }} />
</div>
<div style={{ fontSize: 9, color: C.ink3, marginTop: 2 }}>{sub}</div>
</div>
);
})}
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, margin: '12px 0 8px', paddingBottom: 6, borderBottom: `1px solid ${C.border}` }}>
AI
<span style={{ float: 'right', color: C.green, fontSize: 8 }}></span>
</div>
{PIPE_STEPS.map((step, idx) => {
const isRunning = idx === pipeStep % PIPE_STEPS.length;
return (
<div key={step.num} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
<span style={{ fontSize: 9, color: C.ink3, width: 20 }}>{step.num}</span>
<span style={{ fontSize: 10, color: C.ink, flex: 1 }}>{step.name}</span>
<span style={{
fontSize: 8, padding: '1px 6px', borderRadius: 2,
background: isRunning ? 'rgba(0,230,118,0.15)' : 'rgba(0,230,118,0.06)',
border: `1px solid ${isRunning ? C.green : C.border}`,
color: isRunning ? C.green : C.ink3,
fontWeight: isRunning ? 700 : 400,
}}>
{isRunning ? 'PROC' : 'OK'}
</span>
</div>
);
})}
{[
{ num: 'GPS', name: 'BD-09 변환', status: 'STANDBY', color: C.ink3 },
{ num: 'NRD', name: '레이더 교차검증', status: '미연동', color: C.ink3 },
].map(step => (
<div key={step.num} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
<span style={{ fontSize: 9, color: C.ink3, width: 20 }}>{step.num}</span>
<span style={{ fontSize: 10, color: C.ink, flex: 1 }}>{step.name}</span>
<span style={{
fontSize: 8, padding: '1px 6px', borderRadius: 2,
background: 'rgba(24,255,255,0.08)', border: `1px solid ${C.border}`, color: step.color,
}}>
{step.status}
</span>
</div>
))}
{/* 알고리즘 기준 요약 */}
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, margin: '12px 0 8px', paddingBottom: 6, borderBottom: `1px solid ${C.border}` }}>
</div>
{[
{ 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 }) => (
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: 9, color: C.ink3 }}>{label}</span>
<span style={{ fontSize: 9, color }}>{val}</span>
</div>
))}
</div>
{/* ── 중앙 패널: 선박 테이블 */}
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{/* 필터 바 */}
<div style={{
display: 'flex', gap: 6, padding: '8px 12px', alignItems: 'center',
background: C.bg2, borderBottom: `1px solid ${C.border}`, flexShrink: 0,
flexWrap: 'wrap',
}}>
{[
{ key: 'ALL', label: '전체' },
{ key: 'CRITICAL', label: '긴급 경보' },
{ key: 'FISHING', label: '조업 중' },
{ key: 'AIS_LOSS', label: 'AIS 소실' },
{ key: 'TERRITORIAL', label: '영해 내' },
].map(({ key, label }) => (
<button
key={key}
type="button"
onClick={() => setActiveFilter(key)}
style={{
padding: '3px 10px', fontSize: 10, cursor: 'pointer',
borderRadius: 2, fontFamily: 'inherit',
background: activeFilter === key ? 'rgba(0,230,118,0.15)' : C.bg3,
border: `1px solid ${activeFilter === key ? C.green : C.border}`,
color: activeFilter === key ? C.green : C.ink2,
}}
>
{label}
</button>
))}
<input
value={search}
onChange={e => 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',
}}
/>
<span style={{ color: C.ink3, fontSize: 10, whiteSpace: 'nowrap' }}>
: <span style={{ color: C.cyan }}>{displayed.length}</span>
</span>
<button
type="button"
onClick={downloadCsv}
title="CSV 다운로드"
style={{
padding: '3px 10px', fontSize: 10, cursor: 'pointer',
borderRadius: 2, fontFamily: 'inherit', whiteSpace: 'nowrap',
background: 'rgba(24,255,255,0.08)', border: `1px solid ${C.border}`, color: C.cyan,
}}
>
CSV
</button>
</div>
{/* 테이블 */}
<div style={{ flex: 1, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'separate', borderSpacing: 0 }}>
<thead>
<tr style={{ position: 'sticky', top: 0, background: C.panel, zIndex: 1 }}>
{['AIS', 'MMSI', '선명', '위도', '경도', 'SOG', '침로', '상태', '선종', '구역', '클러스터', '경보', '수신'].map(h => (
<th key={h} style={{
padding: '6px 8px', fontSize: 9, color: C.ink3, fontWeight: 600,
letterSpacing: 1, textAlign: 'left',
borderBottom: `1px solid ${C.border}`, whiteSpace: 'nowrap',
}}>{h}</th>
))}
</tr>
</thead>
<tbody>
{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 (
<tr
key={v.ship.mmsi}
onClick={() => setSelectedMmsi(v.ship.mmsi)}
style={{
background: isSelected ? 'rgba(0,230,118,0.08)' : rowBg,
cursor: 'pointer',
outline: isSelected ? `1px solid ${C.green}` : undefined,
}}
>
<td style={{ padding: '5px 8px' }}>
<span style={{
display: 'inline-block', width: 7, height: 7, borderRadius: '50%',
background: v.state === 'AIS_LOSS' ? C.red : C.green,
}} />
</td>
<td style={{ fontSize: 10, color: C.cyan, whiteSpace: 'nowrap', padding: '5px 8px' }}>{v.ship.mmsi}</td>
<td style={{ fontSize: 10, color: '#fff', padding: '5px 8px', maxWidth: 90, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{v.ship.name || '(Unknown)'}
</td>
<td style={{ fontSize: 10, color: C.ink2, padding: '5px 8px', whiteSpace: 'nowrap' }}>{v.ship.lat.toFixed(3)}°N</td>
<td style={{ fontSize: 10, color: C.ink2, padding: '5px 8px', whiteSpace: 'nowrap' }}>{v.ship.lng.toFixed(3)}°E</td>
<td style={{ fontSize: 10, color: C.amber, padding: '5px 8px', whiteSpace: 'nowrap' }}>
{v.state === 'AIS_LOSS' ? '—' : `${v.ship.speed.toFixed(1)}kt`}
</td>
<td style={{ fontSize: 10, color: C.ink2, padding: '5px 8px', whiteSpace: 'nowrap' }}>
{v.state !== 'AIS_LOSS' ? `${v.ship.course}°` : '—'}
</td>
<td style={{ padding: '5px 8px' }}>
<span style={{
fontSize: 9, padding: '2px 5px', borderRadius: 2,
background: `${stateColor(v.state)}22`,
border: `1px solid ${stateColor(v.state)}66`,
color: stateColor(v.state),
}}>
{stateLabel(v.state)}
</span>
</td>
<td style={{ padding: '5px 8px' }}>
<span style={{
fontSize: 9, padding: '2px 5px', borderRadius: 2,
background: 'rgba(24,255,255,0.08)', border: `1px solid ${C.border}`, color: C.cyan,
}}>
{v.vtype}
</span>
</td>
<td style={{ padding: '5px 8px' }}>
<span style={{
fontSize: 9, padding: '2px 5px', borderRadius: 2, whiteSpace: 'nowrap',
background: `${zoneColor(v.zone)}15`,
border: `1px solid ${zoneColor(v.zone)}55`,
color: zoneColor(v.zone),
}}>
{zoneLabel(v.zone)}
</span>
</td>
<td style={{ padding: '5px 8px', fontSize: 10, color: v.cluster !== '—' ? C.purple : C.ink3 }}>
{v.cluster}
</td>
<td style={{ padding: '5px 8px' }}>
<span style={{
fontSize: 9, padding: '2px 5px', borderRadius: 2,
background: `${alertColor(v.alert)}15`,
border: `1px solid ${alertColor(v.alert)}55`,
color: alertColor(v.alert),
}}>
{v.alert}
</span>
</td>
<td style={{ fontSize: 9, color: C.ink3, padding: '5px 8px', whiteSpace: 'nowrap' }}>
{ageMins < 60 ? `${ageMins}분전` : `${Math.floor(ageMins / 60)}시간전`}
</td>
</tr>
);
})}
{displayed.length === 0 && (
<tr>
<td colSpan={13} style={{ padding: 32, textAlign: 'center', color: C.ink3, fontSize: 11 }}>
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* 하단 범례 */}
<div style={{
display: 'flex', gap: 16, padding: '6px 12px', alignItems: 'center',
background: C.bg2, borderTop: `1px solid ${C.border}`,
fontSize: 10, flexShrink: 0, flexWrap: 'wrap',
}}>
{[
{ color: C.red, label: 'CRITICAL — 즉시대응' },
{ color: C.amber, label: 'WATCH — 집중모니터링' },
{ color: C.cyan, label: 'MONITOR — 주시' },
{ color: C.green, label: 'NORMAL — 정상' },
].map(({ color, label }) => (
<span key={label} style={{ display: 'flex', alignItems: 'center', gap: 5, color: C.ink2 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, display: 'inline-block' }} />
{label}
</span>
))}
<span style={{ marginLeft: 'auto', color: C.ink3, fontSize: 9 }}>
AIS 4 | Python 7 | DBSCAN 3NM | GeoJSON
</span>
</div>
</div>
{/* ── 우측 패널: 선박 상세 + 허가 정보 + 사진 + 경보 로그 */}
<div style={{
width: 280, flexShrink: 0,
background: C.panel, borderLeft: `1px solid ${C.border}`,
overflow: 'hidden', display: 'flex', flexDirection: 'column',
}}>
{/* 패널 헤더 */}
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, padding: '10px 12px 6px', borderBottom: `1px solid ${C.border}`, flexShrink: 0 }}>
<span style={{ float: 'right', color: C.green, fontSize: 8 }}></span>
</div>
{/* 스크롤 영역: 상세 + 허가 + 사진 */}
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
{selectedVessel ? (
<>
{/* 기본 상세 필드 */}
<div style={{ padding: '8px 12px' }}>
{[
{ 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 }) => (
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', borderBottom: `1px solid ${C.border2}` }}>
<span style={{ fontSize: 9, color: C.ink3 }}>{label}</span>
<span style={{ fontSize: 10, color, fontWeight: 600, textAlign: 'right', maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{val}</span>
</div>
))}
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button
type="button"
onClick={() => addLog(selectedVessel.ship.mmsi, selectedVessel.ship.name || '', '대응 명령 발령', 'critical')}
style={{ flex: 1, padding: '5px 0', fontSize: 9, cursor: 'pointer', background: 'rgba(255,82,82,0.1)', border: `1px solid rgba(255,82,82,0.4)`, color: C.red, borderRadius: 2, fontFamily: 'inherit' }}
> </button>
<button
type="button"
onClick={() => addLog(selectedVessel.ship.mmsi, selectedVessel.ship.name || '', 'ENG/드론 투입 명령', 'watch')}
style={{ flex: 1, padding: '5px 0', fontSize: 9, cursor: 'pointer', background: 'rgba(255,215,64,0.08)', border: `1px solid rgba(255,215,64,0.3)`, color: C.amber, borderRadius: 2, fontFamily: 'inherit' }}
>ENG/</button>
</div>
</div>
{/* ── 허가 정보 */}
<div style={{ borderTop: `1px solid ${C.border}`, padding: '8px 12px' }}>
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, marginBottom: 7 }}> </div>
{/* 허가 여부 배지 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span style={{ fontSize: 9, color: C.ink3 }}> </span>
{permitStatus === 'loading' && (
<span style={{ fontSize: 9, color: C.ink3 }}> ...</span>
)}
{permitStatus === 'found' && (
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 10px', borderRadius: 2, background: 'rgba(0,230,118,0.15)', border: `1px solid ${C.green}`, color: C.green }}>
</span>
)}
{permitStatus === 'not-found' && (
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 10px', borderRadius: 2, background: 'rgba(255,82,82,0.12)', border: `1px solid ${C.red}`, color: C.red }}>
</span>
)}
</div>
{/* 허가 내역 (데이터 있을 때) */}
{permitStatus === 'found' && permitData && (
<div style={{ background: C.bg2, border: `1px solid ${C.border}`, borderRadius: 3, padding: '7px 10px' }}>
{[
{ 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 }) => (
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0', borderBottom: `1px solid ${C.border2}` }}>
<span style={{ fontSize: 9, color: C.ink3 }}>{label}</span>
<span style={{ fontSize: 9, color: C.ink, textAlign: 'right', maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{val}</span>
</div>
))}
</div>
)}
{/* 미등록 안내 */}
{permitStatus === 'not-found' && (
<div style={{ background: 'rgba(255,82,82,0.06)', border: `1px solid rgba(255,82,82,0.2)`, borderRadius: 3, padding: '7px 10px' }}>
<div style={{ fontSize: 9, color: '#FF8A80', lineHeight: 1.6 }}>
DB에 .<br />
</div>
</div>
)}
</div>
{/* ── 선박 사진 */}
<div style={{ borderTop: `1px solid ${C.border}`, padding: '8px 12px 12px' }}>
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, marginBottom: 7 }}> </div>
<div style={{
width: '100%', height: 140,
background: C.bg3, border: `1px solid ${C.border}`,
borderRadius: 3, overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{photoUrl === undefined && (
<span style={{ fontSize: 9, color: C.ink3 }}> ...</span>
)}
{photoUrl === null && (
<span style={{ fontSize: 9, color: C.ink3 }}> </span>
)}
{photoUrl && (
<img
src={photoUrl}
alt={selectedVessel.ship.name || '선박'}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={() => setPhotoUrl(null)}
/>
)}
</div>
{photoUrl && (
<div style={{ fontSize: 8, color: C.ink3, marginTop: 4, textAlign: 'right' }}>
© MarineTraffic / S&P Global
</div>
)}
</div>
</>
) : (
<div style={{ padding: '24px 0', textAlign: 'center', color: C.ink3, fontSize: 10 }}>
</div>
)}
</div>
{/* 경보 로그 — 하단 고정 */}
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, padding: '6px 12px', borderTop: `1px solid ${C.border}`, borderBottom: `1px solid ${C.border}`, flexShrink: 0, display: 'flex', justifyContent: 'space-between' }}>
<span> </span>
<span style={{ color: C.ink3, fontSize: 8 }}>{logs.length}</span>
</div>
<div style={{ flex: '0 0 160px', overflow: 'auto' }}>
{logs.map((log, i) => (
<div key={i} style={{
padding: '5px 12px',
borderBottom: `1px solid ${C.border2}`,
borderLeft: `2px solid ${log.level === 'critical' ? C.red : log.level === 'watch' ? C.amber : C.cyan}`,
}}>
<div style={{ fontSize: 9, color: C.ink3 }}>{log.ts}</div>
<div style={{ fontSize: 10, lineHeight: 1.4, color: log.level === 'critical' ? '#FF8A80' : log.level === 'watch' ? '#FFE57F' : '#80DEEA' }}>
<span style={{ color: C.cyan }}>{log.mmsi}</span> {log.name} {log.type}
</div>
</div>
))}
{logs.length === 0 && (
<div style={{ padding: 16, textAlign: 'center', color: C.ink3, fontSize: 10 }}> </div>
)}
</div>
</div>
</div>
</div>
);
}