feat: 현장분석 팝업 추가 — 중국 불법어업 현장분석 대시보드
- 한국 현황 탭 상단에 현장분석 버튼 추가 (지도 위 팝업) - 통계 스트립: 총탐지/영해침범/조업중/AIS소실/클러스터/선종 분류 - 구역별 현황 + AI 파이프라인 상태 (LightGBM/BIRCH/UCAF) - 선박 테이블: 필터/검색/경보 등급 정렬 + CSV 내보내기 - 선박 선택 시 허가 정보 조회 + 선박 사진 (S&P Global/MarineTraffic) - 대응 명령 / ENG드론 버튼으로 경보 로그 기록 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
d000807909
커밋
5cf69a1d22
@ -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<string | null>(null);
|
||||
const [focusShipMmsi, setFocusShipMmsi] = useState<string | null>(null);
|
||||
@ -307,6 +309,15 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
<span className="text-[11px]">🎣</span>
|
||||
중국어선감시
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`mode-btn ${showFieldAnalysis ? 'active' : ''}`}
|
||||
onClick={() => setShowFieldAnalysis(v => !v)}
|
||||
title="현장분석"
|
||||
>
|
||||
<span className="text-[11px]">📊</span>
|
||||
현장분석
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -539,6 +550,9 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
<>
|
||||
<main className="app-main">
|
||||
<div className="map-panel">
|
||||
{showFieldAnalysis && (
|
||||
<FieldAnalysisModal ships={koreaData.ships} onClose={() => setShowFieldAnalysis(false)} />
|
||||
)}
|
||||
<KoreaMap
|
||||
ships={koreaFiltersResult.filteredShips}
|
||||
aircraft={koreaData.visibleAircraft}
|
||||
|
||||
880
frontend/src/components/korea/FieldAnalysisModal.tsx
Normal file
880
frontend/src/components/korea/FieldAnalysisModal.tsx
Normal file
@ -0,0 +1,880 @@
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import type { Ship } from '../../types';
|
||||
import { analyzeFishing } from '../../utils/fishingAnalysis';
|
||||
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||
|
||||
// ── 선박 허가 정보 타입
|
||||
interface PermitRecord {
|
||||
permitNumber: string;
|
||||
permitType: string;
|
||||
issuedBy: string;
|
||||
validFrom: string;
|
||||
validTo: string;
|
||||
authorizedZones: string[];
|
||||
vesselName: string;
|
||||
grossTonnage?: number;
|
||||
}
|
||||
|
||||
// MMSI → 허가 정보 캐시 (null = 미등록, undefined = 미조회)
|
||||
const permitCache = new Map<string, PermitRecord | null>();
|
||||
|
||||
async function fetchVesselPermit(mmsi: string): Promise<PermitRecord | null> {
|
||||
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<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;
|
||||
|
||||
// 황해 위치 기반 수역 분류 (근사값)
|
||||
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<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;
|
||||
}
|
||||
|
||||
// 근접 클러스터링 (~5NM 내 2척 이상 집단)
|
||||
function buildClusters(vessels: ProcessedVessel[]): Map<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
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<string, number> = { 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<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]);
|
||||
|
||||
// 선박 데이터 처리
|
||||
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<PermitRecord | null>(null);
|
||||
|
||||
// 선박 사진
|
||||
const [photoUrl, setPhotoUrl] = useState<string | null | undefined>(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 (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, zIndex: 2000,
|
||||
background: 'rgba(2,6,14,0.96)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
fontFamily: "'IBM Plex Mono', 'Noto Sans KR', monospace",
|
||||
}}>
|
||||
{/* ── 헤더 */}
|
||||
<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 · LightGBM · 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 의심' },
|
||||
{ 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 }) => (
|
||||
<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: 'ACTIVE', color: C.amber },
|
||||
{ num: 'NRD', name: '레이더 교차검증', status: 'LINKED', color: C.cyan },
|
||||
].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: 'BIRCH 5NM', color: C.ink2 },
|
||||
{ label: '선종 분류', val: 'LightGBM 95.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분 갱신 | Shepperson(2017) 기준 | LightGBM 95.68% | BIRCH 5NM
|
||||
</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: '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 }) => (
|
||||
<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.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 }) => (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user