- AI 파이프라인 PROC 순환 애니메이션 → analysisMap 기반 ON/OFF 상태 - BD-09 STANDBY → bd09OffsetM 실측 탐지 수 표시 - 보고서 수역별 허가업종: ZONE_ALLOWED 상수 동적 참조 - 건의사항: 월/최대 어구 선단 실데이터 연동 - 보고서 버튼: 헤더 → 현장분석 내부로 이동
297 lines
16 KiB
TypeScript
297 lines
16 KiB
TypeScript
import { useMemo, useRef } from 'react';
|
|
import type { Ship } from '../../types';
|
|
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
|
import { aggregateFishingStats, GEAR_LABELS, classifyFishingZone, ZONE_ALLOWED } from '../../utils/fishingAnalysis';
|
|
import type { FishingGearType } from '../../utils/fishingAnalysis';
|
|
|
|
interface Props {
|
|
ships: Ship[];
|
|
onClose: () => void;
|
|
largestGearGroup?: { name: string; count: number };
|
|
}
|
|
|
|
const ALL_GEAR_TYPES = ['PT', 'OT', 'GN', 'PS', 'FC'];
|
|
|
|
const ZONE_LABELS: Record<string, string> = {
|
|
ZONE_I: '수역 I (동해)',
|
|
ZONE_II: '수역 II (남해)',
|
|
ZONE_III: '수역 III (서남해)',
|
|
ZONE_IV: '수역 IV (서해)',
|
|
};
|
|
|
|
const ZONE_EXTRA_NOTES: Record<string, string> = {
|
|
ZONE_III: '이어도 해역',
|
|
};
|
|
|
|
function zoneAllowedText(zone: string): string {
|
|
const allowed = ZONE_ALLOWED[zone];
|
|
if (!allowed || allowed.length === 0) return '-';
|
|
if (allowed.length >= ALL_GEAR_TYPES.length) return '전 업종';
|
|
return allowed.join(', ') + (allowed.length <= 2 ? '만' : '');
|
|
}
|
|
|
|
function zoneViolationText(zone: string): string {
|
|
const allowed = ZONE_ALLOWED[zone];
|
|
if (!allowed) return '-';
|
|
const violations = ALL_GEAR_TYPES.filter(t => !allowed.includes(t));
|
|
if (violations.length === 0) return ZONE_EXTRA_NOTES[zone] || '-';
|
|
const extra = ZONE_EXTRA_NOTES[zone] ? ` (${ZONE_EXTRA_NOTES[zone]})` : '';
|
|
return `${violations.join('/')} 발견 시 위반${extra}`;
|
|
}
|
|
|
|
function now() {
|
|
const d = new Date();
|
|
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
}
|
|
|
|
export function ReportModal({ ships, onClose, largestGearGroup }: Props) {
|
|
const reportRef = useRef<HTMLDivElement>(null);
|
|
const timestamp = useMemo(() => now(), []);
|
|
|
|
// Ship statistics
|
|
const stats = useMemo(() => {
|
|
const kr = ships.filter(s => s.flag === 'KR');
|
|
const cn = ships.filter(s => s.flag === 'CN');
|
|
const cnFishing = cn.filter(s => {
|
|
const cat = getMarineTrafficCategory(s.typecode, s.category);
|
|
return cat === 'fishing' || s.category === 'fishing' || s.typecode === '30';
|
|
});
|
|
|
|
// CN fishing by speed
|
|
const cnAnchored = cnFishing.filter(s => s.speed < 1);
|
|
const cnLowSpeed = cnFishing.filter(s => s.speed >= 1 && s.speed < 3);
|
|
const cnOperating = cnFishing.filter(s => s.speed >= 2 && s.speed <= 6);
|
|
const cnSailing = cnFishing.filter(s => s.speed > 6);
|
|
|
|
// Gear analysis
|
|
const fishingStats = aggregateFishingStats(cn);
|
|
|
|
// Zone analysis
|
|
const zoneStats: Record<string, number> = { ZONE_I: 0, ZONE_II: 0, ZONE_III: 0, ZONE_IV: 0, OUTSIDE: 0 };
|
|
cnFishing.forEach(s => {
|
|
const z = classifyFishingZone(s.lat, s.lng);
|
|
zoneStats[z.zone] = (zoneStats[z.zone] || 0) + 1;
|
|
});
|
|
|
|
// Dark vessels (AIS gap)
|
|
const darkSuspect = cnFishing.filter(s => s.speed === 0 && (!s.heading || s.heading === 0));
|
|
|
|
// Ship types
|
|
const byType: Record<string, number> = {};
|
|
ships.forEach(s => {
|
|
const cat = getMarineTrafficCategory(s.typecode, s.category);
|
|
byType[cat] = (byType[cat] || 0) + 1;
|
|
});
|
|
|
|
// By nationality top 10
|
|
const byFlag: Record<string, number> = {};
|
|
ships.forEach(s => { byFlag[s.flag || 'unknown'] = (byFlag[s.flag || 'unknown'] || 0) + 1; });
|
|
const topFlags = Object.entries(byFlag).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
|
|
return { total: ships.length, kr, cn, cnFishing, cnAnchored, cnLowSpeed, cnOperating, cnSailing, fishingStats, zoneStats, darkSuspect, byType, topFlags };
|
|
}, [ships]);
|
|
|
|
const handlePrint = () => {
|
|
const content = reportRef.current;
|
|
if (!content) return;
|
|
const win = window.open('', '_blank');
|
|
if (!win) return;
|
|
win.document.write(`
|
|
<html><head><title>중국어선 감시현황 보고서 - ${timestamp}</title>
|
|
<style>
|
|
body { font-family: 'Malgun Gothic', sans-serif; padding: 40px; color: #1a1a1a; line-height: 1.8; font-size: 12px; }
|
|
h1 { font-size: 20px; border-bottom: 2px solid #1e3a5f; padding-bottom: 8px; color: #1e3a5f; }
|
|
h2 { font-size: 15px; color: #1e3a5f; margin-top: 24px; border-left: 4px solid #1e3a5f; padding-left: 8px; }
|
|
h3 { font-size: 13px; color: #333; margin-top: 16px; }
|
|
table { border-collapse: collapse; width: 100%; margin: 8px 0; font-size: 11px; }
|
|
th, td { border: 1px solid #ccc; padding: 4px 8px; text-align: left; }
|
|
th { background: #1e3a5f; color: #fff; font-weight: 700; }
|
|
tr:nth-child(even) { background: #f5f7fa; }
|
|
.badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 700; }
|
|
.critical { background: #dc2626; color: #fff; }
|
|
.high { background: #f59e0b; color: #000; }
|
|
.medium { background: #3b82f6; color: #fff; }
|
|
.footer { margin-top: 30px; font-size: 9px; color: #888; border-top: 1px solid #ddd; padding-top: 8px; }
|
|
@media print { body { padding: 20px; } }
|
|
</style></head><body>${content.innerHTML}</body></html>
|
|
`);
|
|
win.document.close();
|
|
win.print();
|
|
};
|
|
|
|
const gearEntries = Object.entries(stats.fishingStats.byGear) as [FishingGearType, number][];
|
|
|
|
return (
|
|
<div style={{
|
|
position: 'fixed', inset: 0, zIndex: 9999,
|
|
background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
}} onClick={onClose}>
|
|
<div
|
|
style={{
|
|
width: '90vw', maxWidth: 900, maxHeight: '90vh', overflow: 'auto',
|
|
background: '#0f172a', borderRadius: 8, border: '1px solid rgba(99,179,237,0.3)',
|
|
}}
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
{/* Toolbar */}
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 8,
|
|
padding: '8px 16px', borderBottom: '1px solid rgba(255,255,255,0.1)',
|
|
background: 'rgba(30,58,95,0.5)',
|
|
}}>
|
|
<span style={{ fontSize: 14 }}>📋</span>
|
|
<span style={{ fontSize: 13, fontWeight: 700, color: '#e2e8f0' }}>중국어선 감시현황 분석 보고서</span>
|
|
<span style={{ fontSize: 9, color: '#64748b' }}>{timestamp} 기준</span>
|
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: 6 }}>
|
|
<button onClick={handlePrint} style={{
|
|
background: '#3b82f6', border: 'none', borderRadius: 4,
|
|
padding: '4px 12px', fontSize: 10, fontWeight: 700, color: '#fff', cursor: 'pointer',
|
|
}}>🖨 인쇄 / PDF</button>
|
|
<button onClick={onClose} style={{
|
|
background: '#334155', border: 'none', borderRadius: 4,
|
|
padding: '4px 12px', fontSize: 10, fontWeight: 700, color: '#94a3b8', cursor: 'pointer',
|
|
}}>✕ 닫기</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Report Content */}
|
|
<div ref={reportRef} style={{ padding: '16px 24px', color: '#cbd5e1', fontSize: 11, lineHeight: 1.7 }}>
|
|
<h1 style={{ fontSize: 18, color: '#60a5fa', borderBottom: '2px solid #1e3a5f', paddingBottom: 6 }}>
|
|
한중어업협정 기반 중국어선 감시 현황 분석 보고서
|
|
</h1>
|
|
<div style={{ fontSize: 9, color: '#64748b', marginBottom: 12 }}>
|
|
문서번호: GC-KCG-RPT-AUTO | 생성일시: {timestamp} | 작성: KCG AI 자동분석 시스템 | 【대외비】
|
|
</div>
|
|
|
|
{/* 1. 전체 현황 */}
|
|
<h2 style={{ fontSize: 14, color: '#60a5fa', borderLeft: '3px solid #3b82f6', paddingLeft: 8, marginTop: 16 }}>1. 전체 해양 현황</h2>
|
|
<table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 10 }}>
|
|
<thead><tr style={{ background: '#1e293b' }}>
|
|
<th style={thStyle}>구분</th><th style={thStyle}>척수</th><th style={thStyle}>비율</th>
|
|
</tr></thead>
|
|
<tbody>
|
|
<tr><td style={tdStyle}>전체 선박</td><td style={tdBold}>{stats.total.toLocaleString()}척</td><td style={tdStyle}>100%</td></tr>
|
|
<tr><td style={tdStyle}>🇰🇷 한국 선박</td><td style={tdBold}>{stats.kr.length.toLocaleString()}척</td><td style={tdStyle}>{pct(stats.kr.length, stats.total)}</td></tr>
|
|
<tr><td style={tdStyle}>🇨🇳 중국 선박</td><td style={tdBold}>{stats.cn.length.toLocaleString()}척</td><td style={tdStyle}>{pct(stats.cn.length, stats.total)}</td></tr>
|
|
<tr style={{ background: '#1e293b' }}><td style={tdStyle}>🇨🇳 중국어선</td><td style={{ ...tdBold, color: '#f59e0b' }}>{stats.cnFishing.length.toLocaleString()}척</td><td style={tdStyle}>{pct(stats.cnFishing.length, stats.total)}</td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
{/* 2. 중국어선 상세 */}
|
|
<h2 style={h2Style}>2. 중국어선 활동 분석</h2>
|
|
<table style={tableStyle}>
|
|
<thead><tr style={{ background: '#1e293b' }}>
|
|
<th style={thStyle}>활동 상태</th><th style={thStyle}>척수</th><th style={thStyle}>비율</th><th style={thStyle}>판단 기준</th>
|
|
</tr></thead>
|
|
<tbody>
|
|
<tr><td style={tdStyle}>⚓ 정박 (0~1kn)</td><td style={tdBold}>{stats.cnAnchored.length}</td><td style={tdStyle}>{pct(stats.cnAnchored.length, stats.cnFishing.length)}</td><td style={tdDim}>SOG {'<'} 1 knot</td></tr>
|
|
<tr><td style={tdStyle}>🔵 저속 이동 (1~3kn)</td><td style={tdBold}>{stats.cnLowSpeed.length}</td><td style={tdStyle}>{pct(stats.cnLowSpeed.length, stats.cnFishing.length)}</td><td style={tdDim}>투·양망 또는 이동</td></tr>
|
|
<tr style={{ background: 'rgba(245,158,11,0.1)' }}><td style={tdStyle}>🟡 조업 추정 (2~6kn)</td><td style={{ ...tdBold, color: '#f59e0b' }}>{stats.cnOperating.length}</td><td style={tdStyle}>{pct(stats.cnOperating.length, stats.cnFishing.length)}</td><td style={tdDim}>트롤/자망 조업 속도</td></tr>
|
|
<tr><td style={tdStyle}>🟢 항해 중 (6+kn)</td><td style={tdBold}>{stats.cnSailing.length}</td><td style={tdStyle}>{pct(stats.cnSailing.length, stats.cnFishing.length)}</td><td style={tdDim}>이동/귀항</td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
{/* 3. 어구별 분석 */}
|
|
<h2 style={h2Style}>3. 어구/어망 유형별 분석</h2>
|
|
<table style={tableStyle}>
|
|
<thead><tr style={{ background: '#1e293b' }}>
|
|
<th style={thStyle}>어구 유형</th><th style={thStyle}>추정 척수</th><th style={thStyle}>위험도</th><th style={thStyle}>탐지 신뢰도</th>
|
|
</tr></thead>
|
|
<tbody>
|
|
{gearEntries.map(([gear, count]) => {
|
|
const meta = GEAR_LABELS[gear];
|
|
return (
|
|
<tr key={gear}>
|
|
<td style={tdStyle}><span style={{ color: meta?.color || '#888' }}>{meta?.icon || '🎣'}</span> {meta?.label || gear}</td>
|
|
<td style={tdBold}>{count}척</td>
|
|
<td style={tdStyle}>{meta?.riskLevel === 'CRITICAL' ? '◉ CRITICAL' : meta?.riskLevel === 'HIGH' ? '⚠ HIGH' : '△ MED'}</td>
|
|
<td style={tdStyle}>{meta?.confidence || '-'}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
|
|
{/* 4. 수역별 분포 */}
|
|
<h2 style={h2Style}>4. 특정어업수역별 분포</h2>
|
|
<table style={tableStyle}>
|
|
<thead><tr style={{ background: '#1e293b' }}>
|
|
<th style={thStyle}>수역</th><th style={thStyle}>어선 수</th><th style={thStyle}>허가 업종 ({new Date().getMonth() + 1}월)</th><th style={thStyle}>비고</th>
|
|
</tr></thead>
|
|
<tbody>
|
|
{(['ZONE_I', 'ZONE_II', 'ZONE_III', 'ZONE_IV'] as const).map(zone => (
|
|
<tr key={zone}>
|
|
<td style={tdStyle}>{ZONE_LABELS[zone]}</td>
|
|
<td style={tdBold}>{stats.zoneStats[zone]}</td>
|
|
<td style={tdDim}>{zoneAllowedText(zone)}</td>
|
|
<td style={tdDim}>{zoneViolationText(zone)}</td>
|
|
</tr>
|
|
))}
|
|
<tr style={{ background: 'rgba(239,68,68,0.1)' }}><td style={tdStyle}>수역 외</td><td style={{ ...tdBold, color: '#ef4444' }}>{stats.zoneStats.OUTSIDE}</td><td style={tdDim}>-</td><td style={{ ...tdDim, color: '#ef4444' }}>비허가 구역</td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
{/* 5. 위험 분석 */}
|
|
<h2 style={h2Style}>5. 위험 평가</h2>
|
|
<table style={tableStyle}>
|
|
<thead><tr style={{ background: '#1e293b' }}>
|
|
<th style={thStyle}>위험 유형</th><th style={thStyle}>현재 상태</th><th style={thStyle}>등급</th>
|
|
</tr></thead>
|
|
<tbody>
|
|
<tr><td style={tdStyle}>다크베셀 의심</td><td style={tdBold}>{stats.darkSuspect.length}척</td><td style={tdStyle}><span style={{ ...badgeStyle, background: stats.darkSuspect.length > 10 ? '#dc2626' : '#f59e0b' }}>{stats.darkSuspect.length > 10 ? 'HIGH' : 'MEDIUM'}</span></td></tr>
|
|
<tr><td style={tdStyle}>수역 외 어선</td><td style={tdBold}>{stats.zoneStats.OUTSIDE}척</td><td style={tdStyle}><span style={{ ...badgeStyle, background: stats.zoneStats.OUTSIDE > 0 ? '#dc2626' : '#22c55e' }}>{stats.zoneStats.OUTSIDE > 0 ? 'CRITICAL' : 'NORMAL'}</span></td></tr>
|
|
<tr><td style={tdStyle}>조업 중 어선</td><td style={tdBold}>{stats.cnOperating.length}척</td><td style={tdStyle}><span style={{ ...badgeStyle, background: '#3b82f6' }}>MONITOR</span></td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
{/* 6. 국적별 현황 */}
|
|
<h2 style={h2Style}>6. 국적별 선박 현황 (TOP 10)</h2>
|
|
<table style={tableStyle}>
|
|
<thead><tr style={{ background: '#1e293b' }}>
|
|
<th style={thStyle}>순위</th><th style={thStyle}>국적</th><th style={thStyle}>척수</th><th style={thStyle}>비율</th>
|
|
</tr></thead>
|
|
<tbody>
|
|
{stats.topFlags.map(([flag, count], i) => (
|
|
<tr key={flag}><td style={tdStyle}>{i + 1}</td><td style={tdStyle}>{flag}</td><td style={tdBold}>{count.toLocaleString()}</td><td style={tdStyle}>{pct(count, stats.total)}</td></tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
|
|
{/* 7. 건의사항 */}
|
|
<h2 style={h2Style}>7. 건의사항</h2>
|
|
<div style={{ fontSize: 10, color: '#94a3b8', paddingLeft: 12 }}>
|
|
<p>1. 현재 {new Date().getMonth() + 1}월은 전 업종 조업 가능 기간으로, <strong style={{ color: '#f59e0b' }}>수역 이탈 및 본선-부속선 분리</strong> 중심 감시 권고</p>
|
|
<p>2. 다크베셀 의심 {stats.darkSuspect.length}척에 대해 <strong style={{ color: '#ef4444' }}>SAR 위성 집중 탐색</strong> 요청</p>
|
|
<p>3. 수역 외 어선 {stats.zoneStats.OUTSIDE}척에 대해 <strong style={{ color: '#ef4444' }}>즉시 현장 확인</strong> 필요</p>
|
|
<p>4. 4/16 저인망 휴어기 진입 대비 <strong>감시 강화 계획 수립</strong> 권고</p>
|
|
{largestGearGroup ? (
|
|
<p>5. {largestGearGroup.name} 선단 {largestGearGroup.count}척 <strong>그룹 위치 상시 추적</strong> 유지</p>
|
|
) : (
|
|
<p>5. 대형 어구 선단 <strong>그룹 위치 상시 추적</strong> 유지</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div style={{ marginTop: 24, paddingTop: 8, borderTop: '1px solid rgba(255,255,255,0.1)', fontSize: 8, color: '#475569' }}>
|
|
본 보고서는 KCG 해양감시 시스템에서 자동 생성된 내부 참고자료입니다. | 생성: {timestamp} | 데이터: 실시간 AIS | 분석: AI 자동분석 엔진 | 【대외비】
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Styles
|
|
const h2Style: React.CSSProperties = { fontSize: 13, color: '#60a5fa', borderLeft: '3px solid #3b82f6', paddingLeft: 8, marginTop: 20 };
|
|
const tableStyle: React.CSSProperties = { borderCollapse: 'collapse', width: '100%', fontSize: 10, marginTop: 6 };
|
|
const thStyle: React.CSSProperties = { border: '1px solid #334155', padding: '4px 8px', textAlign: 'left', color: '#e2e8f0', fontSize: 9, fontWeight: 700 };
|
|
const tdStyle: React.CSSProperties = { border: '1px solid #1e293b', padding: '3px 8px', fontSize: 10 };
|
|
const tdBold: React.CSSProperties = { ...tdStyle, fontWeight: 700, color: '#e2e8f0' };
|
|
const tdDim: React.CSSProperties = { ...tdStyle, color: '#64748b', fontSize: 9 };
|
|
const badgeStyle: React.CSSProperties = { display: 'inline-block', padding: '1px 6px', borderRadius: 3, fontSize: 9, fontWeight: 700, color: '#fff' };
|
|
|
|
function pct(n: number, total: number): string {
|
|
if (!total) return '-';
|
|
return `${((n / total) * 100).toFixed(1)}%`;
|
|
}
|