kcg-monitoring/frontend/src/components/korea/ReportModal.tsx
htlee 98c81cd548 refactor: 현장분석/보고서 더미 데이터를 실데이터로 전환
- AI 파이프라인 PROC 순환 애니메이션 → analysisMap 기반 ON/OFF 상태
- BD-09 STANDBY → bd09OffsetM 실측 탐지 수 표시
- 보고서 수역별 허가업종: ZONE_ALLOWED 상수 동적 참조
- 건의사항: 월/최대 어구 선단 실데이터 연동
- 보고서 버튼: 헤더 → 현장분석 내부로 이동
2026-03-25 10:44:28 +09:00

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)}%`;
}