- @fontsource-variable Inter, Noto Sans KR, Fira Code 자체 호스팅 - 전체 font-family 통일 (CSS, deck.gl, 인라인 스타일) - 이란 시설물 색상 사막 대비 고채도 팔레트로 교체 - 이란 라벨 fontWeight 600→700, alpha 200→255 - 접힘 패널 상하 패딩 균일화
857 lines
41 KiB
TypeScript
857 lines
41 KiB
TypeScript
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.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 }) => (
|
||
<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>
|
||
);
|
||
}
|