From 8f9dd0b5463a8e08e91c232505d1c4f470d211d0 Mon Sep 17 00:00:00 2001 From: Nan Kyung Lee Date: Tue, 24 Mar 2026 09:10:41 +0900 Subject: [PATCH] =?UTF-8?q?feat(korea):=20=EC=A4=91=EA=B5=AD=EC=96=B4?= =?UTF-8?q?=EC=84=A0=20=EA=B0=90=EC=8B=9C=ED=98=84=ED=99=A9=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EB=B3=B4=EA=B3=A0=EC=84=9C=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 한국 현황 탑메뉴에 '보고서' 버튼 추가 - 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) --- frontend/src/App.tsx | 14 + frontend/src/components/korea/ReportModal.tsx | 258 ++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 frontend/src/components/korea/ReportModal.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c5b36b6..63b1478 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null); const [focusShipMmsi, setFocusShipMmsi] = useState(null); @@ -375,6 +377,15 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { 📊 현장분석 + )} @@ -649,6 +660,9 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { {showFieldAnalysis && ( setShowFieldAnalysis(false)} /> )} + {showReport && ( + setShowReport(false)} /> + )} 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(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 = { 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 = {}; + ships.forEach(s => { + const cat = getMarineTrafficCategory(s.typecode, s.category); + byType[cat] = (byType[cat] || 0) + 1; + }); + + // By nationality top 10 + const byFlag: Record = {}; + 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(` + 중국어선 감시현황 보고서 - ${timestamp} + ${content.innerHTML} + `); + win.document.close(); + win.print(); + }; + + const gearEntries = Object.entries(stats.fishingStats.byGear) as [FishingGearType, number][]; + + return ( +
+
e.stopPropagation()} + > + {/* Toolbar */} +
+ 📋 + 중국어선 감시현황 분석 보고서 + {timestamp} 기준 +
+ + +
+
+ + {/* Report Content */} +
+

+ 한중어업협정 기반 중국어선 감시 현황 분석 보고서 +

+
+ 문서번호: GC-KCG-RPT-AUTO | 생성일시: {timestamp} | 작성: KCG AI 자동분석 시스템 | 【대외비】 +
+ + {/* 1. 전체 현황 */} +

1. 전체 해양 현황

+ + + + + + + + + + +
구분척수비율
전체 선박{stats.total.toLocaleString()}척100%
🇰🇷 한국 선박{stats.kr.length.toLocaleString()}척{pct(stats.kr.length, stats.total)}
🇨🇳 중국 선박{stats.cn.length.toLocaleString()}척{pct(stats.cn.length, stats.total)}
🇨🇳 중국어선{stats.cnFishing.length.toLocaleString()}척{pct(stats.cnFishing.length, stats.total)}
+ + {/* 2. 중국어선 상세 */} +

2. 중국어선 활동 분석

+ + + + + + + + + + +
활동 상태척수비율판단 기준
⚓ 정박 (0~1kn){stats.cnAnchored.length}{pct(stats.cnAnchored.length, stats.cnFishing.length)}SOG {'<'} 1 knot
🔵 저속 이동 (1~3kn){stats.cnLowSpeed.length}{pct(stats.cnLowSpeed.length, stats.cnFishing.length)}투·양망 또는 이동
🟡 조업 추정 (2~6kn){stats.cnOperating.length}{pct(stats.cnOperating.length, stats.cnFishing.length)}트롤/자망 조업 속도
🟢 항해 중 (6+kn){stats.cnSailing.length}{pct(stats.cnSailing.length, stats.cnFishing.length)}이동/귀항
+ + {/* 3. 어구별 분석 */} +

3. 어구/어망 유형별 분석

+ + + + + + {gearEntries.map(([gear, count]) => { + const meta = GEAR_LABELS[gear]; + return ( + + + + + + + ); + })} + +
어구 유형추정 척수위험도탐지 신뢰도
{meta?.icon || '🎣'} {meta?.label || gear}{count}척{meta?.riskLevel === 'CRITICAL' ? '◉ CRITICAL' : meta?.riskLevel === 'HIGH' ? '⚠ HIGH' : '△ MED'}{meta?.confidence || '-'}
+ + {/* 4. 수역별 분포 */} +

4. 특정어업수역별 분포

+ + + + + + + + + + + +
수역어선 수허가 업종 (3월)비고
수역 I (동해){stats.zoneStats.ZONE_I}PS, FC만PT/OT/GN 발견 시 위반
수역 II (남해){stats.zoneStats.ZONE_II}전 업종-
수역 III (서남해){stats.zoneStats.ZONE_III}전 업종이어도 해역
수역 IV (서해){stats.zoneStats.ZONE_IV}GN, PS, FCPT/OT 발견 시 위반
수역 외{stats.zoneStats.OUTSIDE}-비허가 구역
+ + {/* 5. 위험 분석 */} +

5. 위험 평가

+ + + + + + + + + +
위험 유형현재 상태등급
다크베셀 의심{stats.darkSuspect.length}척 10 ? '#dc2626' : '#f59e0b' }}>{stats.darkSuspect.length > 10 ? 'HIGH' : 'MEDIUM'}
수역 외 어선{stats.zoneStats.OUTSIDE}척 0 ? '#dc2626' : '#22c55e' }}>{stats.zoneStats.OUTSIDE > 0 ? 'CRITICAL' : 'NORMAL'}
조업 중 어선{stats.cnOperating.length}척MONITOR
+ + {/* 6. 국적별 현황 */} +

6. 국적별 선박 현황 (TOP 10)

+ + + + + + {stats.topFlags.map(([flag, count], i) => ( + + ))} + +
순위국적척수비율
{i + 1}{flag}{count.toLocaleString()}{pct(count, stats.total)}
+ + {/* 7. 건의사항 */} +

7. 건의사항

+
+

1. 현재 3월은 전 업종 조업 가능 기간으로, 수역 이탈 및 본선-부속선 분리 중심 감시 권고

+

2. 다크베셀 의심 {stats.darkSuspect.length}척에 대해 SAR 위성 집중 탐색 요청

+

3. 수역 외 어선 {stats.zoneStats.OUTSIDE}척에 대해 즉시 현장 확인 필요

+

4. 4/16 저인망 휴어기 진입 대비 감시 강화 계획 수립 권고

+

5. 宁波海裕 위망 선단 16척 그룹 위치 상시 추적 유지

+
+ + {/* Footer */} +
+ 본 보고서는 KCG 해양감시 시스템에서 자동 생성된 내부 참고자료입니다. | 생성: {timestamp} | 데이터: 실시간 AIS | 분석: AI 자동분석 엔진 | 【대외비】 +
+
+
+
+ ); +} + +// 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)}%`; +}