feat(korea): 중국어선 감시현황 자동 보고서 생성 기능
- 한국 현황 탑메뉴에 '보고서' 버튼 추가 - ReportModal: 현재 실시간 데이터 기반 7개 섹션 자동 보고서 1. 전체 해양 현황 (선박수, 국적별) 2. 중국어선 활동 분석 (속도별 상태) 3. 어구/어망 유형별 분석 (GB/T 5147 기반) 4. 특정어업수역별 분포 (I~IV + 수역 외) 5. 위험 평가 (다크베셀, 수역 외, 조업 중) 6. 국적별 선박 TOP 10 7. 건의사항 5건 - 인쇄/PDF 내보내기 기능 (새 창 → window.print) - 한중어업협정 허가현황 기반 자동 위반 판정 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
df269bf19b
커밋
8f9dd0b546
@ -24,6 +24,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import LoginPage from './components/auth/LoginPage';
|
||||
import CollectorMonitor from './components/common/CollectorMonitor';
|
||||
import { FieldAnalysisModal } from './components/korea/FieldAnalysisModal';
|
||||
import { ReportModal } from './components/korea/ReportModal';
|
||||
import { filterFacilities } from './data/meEnergyHazardFacilities';
|
||||
// 정적 데이터 카운트용 import (이미 useStaticDeckLayers에서 번들 포함됨)
|
||||
import { EAST_ASIA_PORTS } from './data/ports';
|
||||
@ -192,6 +193,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
|
||||
const [showCollectorMonitor, setShowCollectorMonitor] = useState(false);
|
||||
const [showFieldAnalysis, setShowFieldAnalysis] = useState(false);
|
||||
const [showReport, setShowReport] = useState(false);
|
||||
const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST');
|
||||
const [hoveredShipMmsi, setHoveredShipMmsi] = useState<string | null>(null);
|
||||
const [focusShipMmsi, setFocusShipMmsi] = useState<string | null>(null);
|
||||
@ -375,6 +377,15 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
<span className="text-[11px]">📊</span>
|
||||
현장분석
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`mode-btn ${showReport ? 'active' : ''}`}
|
||||
onClick={() => setShowReport(v => !v)}
|
||||
title="감시 보고서"
|
||||
>
|
||||
<span className="text-[11px]">📋</span>
|
||||
보고서
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -649,6 +660,9 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
{showFieldAnalysis && (
|
||||
<FieldAnalysisModal ships={koreaData.ships} vesselAnalysis={vesselAnalysis} onClose={() => setShowFieldAnalysis(false)} />
|
||||
)}
|
||||
{showReport && (
|
||||
<ReportModal ships={koreaData.ships} onClose={() => setShowReport(false)} />
|
||||
)}
|
||||
<KoreaMap
|
||||
ships={koreaFiltersResult.filteredShips}
|
||||
allShips={koreaData.visibleShips}
|
||||
|
||||
258
frontend/src/components/korea/ReportModal.tsx
Normal file
258
frontend/src/components/korea/ReportModal.tsx
Normal file
@ -0,0 +1,258 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import type { Ship } from '../../types';
|
||||
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||
import { aggregateFishingStats, GEAR_LABELS, classifyFishingZone } from '../../utils/fishingAnalysis';
|
||||
import type { FishingGearType } from '../../utils/fishingAnalysis';
|
||||
|
||||
interface Props {
|
||||
ships: Ship[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
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 }: 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}>허가 업종 (3월)</th><th style={thStyle}>비고</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr><td style={tdStyle}>수역 I (동해)</td><td style={tdBold}>{stats.zoneStats.ZONE_I}</td><td style={tdDim}>PS, FC만</td><td style={tdDim}>PT/OT/GN 발견 시 위반</td></tr>
|
||||
<tr><td style={tdStyle}>수역 II (남해)</td><td style={tdBold}>{stats.zoneStats.ZONE_II}</td><td style={tdDim}>전 업종</td><td style={tdDim}>-</td></tr>
|
||||
<tr><td style={tdStyle}>수역 III (서남해)</td><td style={tdBold}>{stats.zoneStats.ZONE_III}</td><td style={tdDim}>전 업종</td><td style={tdDim}>이어도 해역</td></tr>
|
||||
<tr><td style={tdStyle}>수역 IV (서해)</td><td style={tdBold}>{stats.zoneStats.ZONE_IV}</td><td style={tdDim}>GN, PS, FC</td><td style={tdDim}>PT/OT 발견 시 위반</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. 현재 3월은 전 업종 조업 가능 기간으로, <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>
|
||||
<p>5. 宁波海裕 위망 선단 16척 <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)}%`;
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user