diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index bda67b1..2fbfb6d 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -155,8 +155,16 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS const d = dataRef.current; setSelectedGearGroup(prev => prev === name ? null : name); setExpandedGearGroup(name); + + // 해당 어구가 속한 섹션으로 아코디언 전환 + const isInZone = d.groupPolygons?.gearInZoneGroups.some(g => g.groupKey === name); + setActiveSection(isInZone ? 'inZone' : 'outZone'); + + // 섹션 전환 후 스크롤 requestAnimationFrame(() => { - document.getElementById(`gear-row-${name}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + setTimeout(() => { + document.getElementById(`gear-row-${name}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 50); }); const allGroups = d.groupPolygons ? [...d.groupPolygons.gearInZoneGroups, ...d.groupPolygons.gearOutZoneGroups] @@ -408,8 +416,15 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS const handleGearGroupZoom = useCallback((parentName: string) => { setSelectedGearGroup(prev => prev === parentName ? null : parentName); setExpandedGearGroup(parentName); + + // 해당 어구가 속한 섹션으로 아코디언 전환 + const isInZone = groupPolygons?.gearInZoneGroups.some(g => g.groupKey === parentName); + setActiveSection(isInZone ? 'inZone' : 'outZone'); + requestAnimationFrame(() => { - document.getElementById(`gear-row-${parentName}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + setTimeout(() => { + document.getElementById(`gear-row-${parentName}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 50); }); const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] diff --git a/frontend/src/components/korea/KoreaDashboard.tsx b/frontend/src/components/korea/KoreaDashboard.tsx index f96c6cf..be50a63 100644 --- a/frontend/src/components/korea/KoreaDashboard.tsx +++ b/frontend/src/components/korea/KoreaDashboard.tsx @@ -4,6 +4,9 @@ import { useTranslation } from 'react-i18next'; import { useLocalStorage, useLocalStorageSet } from '../../hooks/useLocalStorage'; import { KoreaMap } from './KoreaMap'; import { FieldAnalysisModal } from './FieldAnalysisModal'; +import { ReportModal } from './ReportModal'; +import { OpsGuideModal } from './OpsGuideModal'; +import type { OpsRoute } from './OpsGuideModal'; import { LayerPanel, type LayerTreeNode } from '../common/LayerPanel'; import { EventLog } from '../common/EventLog'; import { LiveControls } from '../common/LiveControls'; @@ -79,6 +82,10 @@ export const KoreaDashboard = ({ onTimeZoneChange, }: KoreaDashboardProps) => { const [showFieldAnalysis, setShowFieldAnalysis] = useState(false); + const [showReport, setShowReport] = useState(false); + const [showOpsGuide, setShowOpsGuide] = useState(false); + const [opsRoute, setOpsRoute] = useState(null); + const [externalFlyTo, setExternalFlyTo] = useState<{ lat: number; lng: number; zoom: number } | null>(null); const { t } = useTranslation(); const { hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory } = @@ -293,6 +300,14 @@ export const KoreaDashboard = ({ onClick={() => setShowFieldAnalysis(v => !v)} title="현장분석"> 📊현장분석 + + , headerSlot, )} @@ -314,6 +329,17 @@ export const KoreaDashboard = ({ onClose={() => setShowFieldAnalysis(false)} /> )} + {showReport && ( + setShowReport(false)} /> + )} + {showOpsGuide && ( + { setShowOpsGuide(false); setOpsRoute(null); }} + onFlyTo={(lat, lng, zoom) => setExternalFlyTo({ lat, lng, zoom })} + onRouteSelect={setOpsRoute} + /> + )} setExternalFlyTo(null)} + opsRoute={opsRoute} />
; hiddenNationalities?: Set; + externalFlyTo?: { lat: number; lng: number; zoom: number } | null; + onExternalFlyToDone?: () => void; + opsRoute?: { from: { lat: number; lng: number; name: string }; to: { lat: number; lng: number; name: string }; distanceNM: number; riskLevel: string } | null; } // MarineTraffic-style: satellite + dark ocean + nautical overlay @@ -110,6 +113,60 @@ const MAP_STYLE = { ], }; +// ═══ Sea routing — avoid Korean peninsula land mass ═══ +const SEA_WAYPOINTS: [number, number][] = [ + [124.5, 37.8], [124.0, 36.5], [124.5, 35.5], [125.0, 34.5], + [126.0, 33.5], [126.5, 33.2], [127.5, 33.0], [128.5, 33.5], + [129.0, 34.5], [129.5, 35.2], [129.8, 36.0], [130.0, 37.0], + [129.5, 37.8], [129.0, 38.5], +]; + +const LAND_BOXES = [ + { minLng: 125.5, maxLng: 129.5, minLat: 34.3, maxLat: 38.6 }, + { minLng: 126.1, maxLng: 126.9, minLat: 33.2, maxLat: 33.6 }, +]; + +function segmentCrossesLand(lng1: number, lat1: number, lng2: number, lat2: number): boolean { + for (let i = 1; i < 10; i++) { + const t = i / 10; + const lng = lng1 + (lng2 - lng1) * t; + const lat = lat1 + (lat2 - lat1) * t; + for (const box of LAND_BOXES) { + if (lng >= box.minLng && lng <= box.maxLng && lat >= box.minLat && lat <= box.maxLat) return true; + } + } + return false; +} + +function buildSeaRoute(from: { lat: number; lng: number }, to: { lat: number; lng: number }): [number, number][] { + if (!segmentCrossesLand(from.lng, from.lat, to.lng, to.lat)) { + return [[from.lng, from.lat], [to.lng, to.lat]]; + } + const nearest = (lng: number, lat: number) => { + let best = 0, d = Infinity; + for (let i = 0; i < SEA_WAYPOINTS.length; i++) { + const dd = (SEA_WAYPOINTS[i][0] - lng) ** 2 + (SEA_WAYPOINTS[i][1] - lat) ** 2; + if (dd < d) { d = dd; best = i; } + } + return best; + }; + const startWP = nearest(from.lng, from.lat); + const endWP = nearest(to.lng, to.lat); + const n = SEA_WAYPOINTS.length; + const cwPath: [number, number][] = []; + const ccwPath: [number, number][] = []; + for (let i = startWP; ; i = (i + 1) % n) { + cwPath.push(SEA_WAYPOINTS[i]); + if (i === endWP || cwPath.length > n) break; + } + for (let i = startWP; ; i = (i - 1 + n) % n) { + ccwPath.push(SEA_WAYPOINTS[i]); + if (i === endWP || ccwPath.length > n) break; + } + const waypoints = cwPath.length <= ccwPath.length ? cwPath : ccwPath; + return [[from.lng, from.lat], ...waypoints, [to.lng, to.lat]]; +} + // Korea-centered view const KOREA_MAP_CENTER = { longitude: 127.5, latitude: 36 }; const KOREA_MAP_ZOOM = 6; @@ -144,7 +201,7 @@ const FILTER_I18N_KEY: Record = { cnFishing: 'filters.cnFishingMonitor', }; -export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, groupPolygons, hiddenShipCategories, hiddenNationalities }: Props) { +export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, groupPolygons, hiddenShipCategories, hiddenNationalities, externalFlyTo, onExternalFlyToDone, opsRoute }: Props) { const { t } = useTranslation(); const mapRef = useRef(null); const [infra, setInfra] = useState([]); @@ -179,6 +236,13 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF } }, [flyToTarget]); + useEffect(() => { + if (externalFlyTo && mapRef.current) { + mapRef.current.flyTo({ center: [externalFlyTo.lng, externalFlyTo.lat], zoom: externalFlyTo.zoom, duration: 1500 }); + onExternalFlyToDone?.(); + } + }, [externalFlyTo, onExternalFlyToDone]); + useEffect(() => { if (!selectedAnalysisMmsi) setTrackCoords(null); }, [selectedAnalysisMmsi]); @@ -926,6 +990,38 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF onExpandedChange={setAnalysisPanelOpen} /> )} + {/* 작전가이드 임검침로 점선 — 해상 루트 (육지 우회) */} + {opsRoute && (() => { + const riskColor = opsRoute.riskLevel === 'CRITICAL' ? '#ef4444' : opsRoute.riskLevel === 'HIGH' ? '#f59e0b' : '#3b82f6'; + const coords = buildSeaRoute(opsRoute.from, opsRoute.to); + const routeGeoJson: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: [{ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } }], + }; + const midIdx = Math.floor(coords.length / 2); + return ( + <> + + + + +
+
+ +
+ + +
+ {opsRoute.distanceNM.toFixed(1)} NM +
{opsRoute.from.name} → {opsRoute.to.name}
+
+
+ + ); + })()} ); } diff --git a/frontend/src/components/korea/OpsGuideModal.tsx b/frontend/src/components/korea/OpsGuideModal.tsx new file mode 100644 index 0000000..5c7363d --- /dev/null +++ b/frontend/src/components/korea/OpsGuideModal.tsx @@ -0,0 +1,410 @@ +import { useState, useMemo, useRef, useCallback } from 'react'; +import type { Ship } from '../../types'; +import { COAST_GUARD_FACILITIES, CG_TYPE_LABEL } from '../../services/coastGuard'; +import type { CoastGuardFacility } from '../../services/coastGuard'; +import { getMarineTrafficCategory } from '../../utils/marineTraffic'; +import { classifyFishingZone } from '../../utils/fishingAnalysis'; + +export interface OpsRoute { + from: { lat: number; lng: number; name: string }; + to: { lat: number; lng: number; name: string; mmsi: string }; + distanceNM: number; + riskLevel: 'CRITICAL' | 'HIGH' | 'MEDIUM'; +} + +interface Props { + ships: Ship[]; + onClose: () => void; + onFlyTo?: (lat: number, lng: number, zoom: number) => void; + onRouteSelect?: (route: OpsRoute | null) => void; +} + +interface SuspectVessel { + ship: Ship; + distance: number; + reasons: string[]; + riskLevel: 'CRITICAL' | 'HIGH' | 'MEDIUM'; + estimatedType: 'PT' | 'GN' | 'PS' | 'FC' | 'GEAR' | 'UNKNOWN'; +} + +function haversineNM(lat1: number, lng1: number, lat2: number, lng2: number): number { + const R = 3440.065; + const dLat = (lat2 - lat1) * Math.PI / 180; + const dLng = (lng2 - lng1) * Math.PI / 180; + const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2; + return 2 * R * Math.asin(Math.sqrt(a)); +} + +const RISK_COLOR = { CRITICAL: '#ef4444', HIGH: '#f59e0b', MEDIUM: '#3b82f6' }; +const RISK_ICON = { CRITICAL: '🔴', HIGH: '🟡', MEDIUM: '🔵' }; +type Tab = 'detect' | 'procedure' | 'alert'; + +// ── 중국어 경고문 ── +const CN_WARNINGS: Record = { + PT: [ + { zh: '请立即停船接受检查', ko: '즉시 정선하여 검사를 받으시오', usage: 'VHF Ch.16 + 확성기' }, + { zh: '请出示捕捞许可证', ko: '어업허가증을 제시하시오', usage: '승선 검사 시' }, + { zh: '请出示作业日志', ko: '조업일지를 제시하시오', usage: '어획량 확인' }, + { zh: '你的网目不符合规定', ko: '망목이 규정에 미달합니다', usage: '어구 검사 (54mm 미만)' }, + ], + GN: [ + { zh: '请打开AIS', ko: 'AIS를 켜시오', usage: '다크베셀 대응' }, + { zh: '请立即停船接受检查', ko: '즉시 정선하여 검사를 받으시오', usage: 'VHF + 확성기' }, + { zh: '你在非许可区域作业', ko: '비허가 구역에서 조업 중입니다', usage: '수역 이탈 시' }, + { zh: '请立即收回渔网', ko: '어망을 즉시 회수하시오', usage: '불법 자망 발견' }, + ], + PS: [ + { zh: '所有船只立即停止作业', ko: '모든 선박 즉시 조업 중단', usage: '선단 제압 시' }, + { zh: '请立即停船接受检查', ko: '즉시 정선하여 검사를 받으시오', usage: 'VHF + 확성기' }, + { zh: '关闭集鱼灯', ko: '집어등을 끄시오', usage: '조명선 대응' }, + { zh: '不要试图逃跑', ko: '도주를 시도하지 마시오', usage: '도주 시' }, + ], + FC: [ + { zh: '请立即停船接受检查', ko: '즉시 정선하여 검사를 받으시오', usage: 'VHF + 확성기' }, + { zh: '请出示货物清单', ko: '화물 목록을 제시하시오', usage: '환적 검사' }, + { zh: '禁止转运渔获物', ko: '어획물 환적을 금지합니다', usage: '환적 현장' }, + ], + GEAR: [ + { zh: '这些渔具属于非法设置', ko: '이 어구는 불법 설치되었습니다', usage: '어구 수거 시' }, + ], + UNKNOWN: [ + { zh: '请立即停船接受检查', ko: '즉시 정선하여 검사를 받으시오', usage: '기본 경고' }, + { zh: '请打开AIS', ko: 'AIS를 켜시오', usage: '다크베셀' }, + ], +}; + +function estimateVesselType(ship: Ship): 'PT' | 'GN' | 'PS' | 'FC' | 'GEAR' | 'UNKNOWN' { + const cat = getMarineTrafficCategory(ship.typecode, ship.category); + const isGear = /[_]\d+[_]|%$/.test(ship.name); + if (isGear) return 'GEAR'; + if (cat === 'cargo' || (cat === 'unspecified' && (ship.length || 0) > 50)) return 'FC'; + if (cat !== 'fishing' && ship.category !== 'fishing' && ship.typecode !== '30') return 'UNKNOWN'; + const spd = ship.speed || 0; + if (spd >= 7) return 'PS'; + if (spd < 1.5) return 'GN'; + return 'PT'; +} + +export function OpsGuideModal({ ships, onClose, onFlyTo, onRouteSelect }: Props) { + const [selectedKCG, setSelectedKCG] = useState(null); + const [searchRadius, setSearchRadius] = useState(30); + const [pos, setPos] = useState({ x: 60, y: 60 }); + const [tab, setTab] = useState('detect'); + const [selectedSuspect, setSelectedSuspect] = useState(null); + const [copiedIdx, setCopiedIdx] = useState(null); + const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null); + + const onDragStart = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + dragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y }; + const onMove = (ev: MouseEvent) => { + if (!dragRef.current) return; + setPos({ x: dragRef.current.origX + (ev.clientX - dragRef.current.startX), y: dragRef.current.origY + (ev.clientY - dragRef.current.startY) }); + }; + const onUp = () => { dragRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + }, [pos]); + + const kcgBases = useMemo(() => + COAST_GUARD_FACILITIES.filter(f => ['hq', 'regional', 'station', 'navy'].includes(f.type)).sort((a, b) => a.name.localeCompare(b.name)), + []); + + const suspects = useMemo(() => { + if (!selectedKCG) return []; + const results: SuspectVessel[] = []; + for (const ship of ships) { + if (ship.flag !== 'CN') continue; + const dist = haversineNM(selectedKCG.lat, selectedKCG.lng, ship.lat, ship.lng); + if (dist > searchRadius) continue; + const cat = getMarineTrafficCategory(ship.typecode, ship.category); + const isFishing = cat === 'fishing' || ship.category === 'fishing' || ship.typecode === '30'; + const isGear = /[_]\d+[_]|%$/.test(ship.name); + const zone = classifyFishingZone(ship.lat, ship.lng); + const reasons: string[] = []; + let riskLevel: 'CRITICAL' | 'HIGH' | 'MEDIUM' = 'MEDIUM'; + if (isFishing && zone.zone === 'OUTSIDE') { reasons.push('비허가 수역 진입'); riskLevel = 'CRITICAL'; } + if (isFishing && zone.zone === 'ZONE_I' && ship.speed >= 2 && ship.speed <= 5) { reasons.push('수역I 저인망 의심'); riskLevel = 'HIGH'; } + if (isFishing && ship.speed === 0 && (!ship.heading || ship.heading === 0)) { reasons.push('다크베셀 의심'); if (riskLevel === 'MEDIUM') riskLevel = 'HIGH'; } + if (isFishing && ship.speed >= 2 && ship.speed <= 6) reasons.push(`조업 추정 (${ship.speed.toFixed(1)}kn)`); + if (isGear) { reasons.push('어구/어망 AIS'); if (riskLevel === 'MEDIUM') riskLevel = 'HIGH'; } + if (!isFishing && (cat === 'cargo' || cat === 'unspecified') && ship.speed < 3) reasons.push('운반선/환적 의심'); + if (reasons.length > 0) results.push({ ship, distance: dist, reasons, riskLevel, estimatedType: estimateVesselType(ship) }); + } + return results.sort((a, b) => ({ CRITICAL: 0, HIGH: 1, MEDIUM: 2 }[a.riskLevel] - { CRITICAL: 0, HIGH: 1, MEDIUM: 2 }[b.riskLevel]) || a.distance - b.distance); + }, [selectedKCG, ships, searchRadius]); + + const criticalCount = suspects.filter(s => s.riskLevel === 'CRITICAL').length; + const highCount = suspects.filter(s => s.riskLevel === 'HIGH').length; + + const [speakingIdx, setSpeakingIdx] = useState(null); + + const copyToClipboard = (text: string, idx: number) => { + navigator.clipboard.writeText(text).then(() => { setCopiedIdx(idx); setTimeout(() => setCopiedIdx(null), 1500); }); + }; + + const audioRef = useRef(null); + + const speakChinese = useCallback((text: string, idx: number) => { + // Stop previous + if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; } + setSpeakingIdx(idx); + const encoded = encodeURIComponent(text); + const url = `/api/gtts?ie=UTF-8&client=tw-ob&tl=zh-CN&q=${encoded}`; + const audio = new Audio(url); + audioRef.current = audio; + audio.onended = () => setSpeakingIdx(null); + audio.onerror = () => setSpeakingIdx(null); + audio.play().catch(() => setSpeakingIdx(null)); + }, []); + + const handleSuspectClick = (s: SuspectVessel) => { + setSelectedSuspect(s); + setTab('procedure'); + onFlyTo?.(s.ship.lat, s.ship.lng, 10); + if (selectedKCG) { + onRouteSelect?.({ from: { lat: selectedKCG.lat, lng: selectedKCG.lng, name: selectedKCG.name }, to: { lat: s.ship.lat, lng: s.ship.lng, name: s.ship.name || s.ship.mmsi, mmsi: s.ship.mmsi }, distanceNM: s.distance, riskLevel: s.riskLevel }); + } + }; + + const TYPE_LABEL: Record = { PT: '저인망(PT)', GN: '유자망(GN)', PS: '위망(PS)', FC: '운반선(FC)', GEAR: '어구/어망', UNKNOWN: '미분류' }; + + return ( +
+ {/* Header */} +
+ + + 경비함정 작전 가이드 + +
+ + {/* Tabs */} +
+ {([['detect', '🔍 실시간 탐지'], ['procedure', '📋 대응 절차'], ['alert', '🚨 조치 기준']] as [Tab, string][]).map(([k, l]) => ( + + ))} +
+ + {/* Controls (detect tab) */} + {tab === 'detect' && ( +
+ + + {selectedKCG &&
+ 🔴 {criticalCount} + 🟡 {highCount} + 🔵 {suspects.length} +
} +
+ )} + + {/* Content */} +
+ + {/* ── TAB: 실시간 탐지 ── */} + {tab === 'detect' && (<> + {!selectedKCG ? ( +
⚓ 출동 기지를 선택하면 주변 불법어선·어구를 자동 탐지합니다
+ ) : suspects.length === 0 ? ( +
✅ {selectedKCG.name} 반경 {searchRadius}NM 내 의심 선박 없음
+ ) : ( +
+ {suspects.map((s, i) => ( +
handleSuspectClick(s)} style={{ + background: '#111827', borderRadius: 4, padding: '6px 10px', borderLeft: `3px solid ${RISK_COLOR[s.riskLevel]}`, cursor: 'pointer', + }}> +
+ #{i + 1} + {RISK_ICON[s.riskLevel]} + {s.riskLevel} + {s.ship.name || s.ship.mmsi} + [{TYPE_LABEL[s.estimatedType]}] + {s.distance.toFixed(1)} NM +
+
+ {s.reasons.map((r, j) => {r})} +
+
+ ))} +
+ )} + )} + + {/* ── TAB: 대응 절차 ── */} + {tab === 'procedure' && (<> + {selectedSuspect ? ( +
+ {/* 선박 정보 */} +
+
+ {RISK_ICON[selectedSuspect.riskLevel]} + {selectedSuspect.ship.name || selectedSuspect.ship.mmsi} + {selectedSuspect.riskLevel} + 추정: {TYPE_LABEL[selectedSuspect.estimatedType]} +
+
MMSI: {selectedSuspect.ship.mmsi} | SOG: {selectedSuspect.ship.speed?.toFixed(1)}kn | {selectedSuspect.distance.toFixed(1)} NM
+
+ + {/* 업종별 대응 절차 */} + + + {/* 중국어 경고문 */} +
+
📢 중국어 경고문 (클릭: 복사 | 🔊: 음성)
+ {(CN_WARNINGS[selectedSuspect.estimatedType] || CN_WARNINGS.UNKNOWN).map((w, i) => ( +
+
copyToClipboard(w.zh, i)}> +
{w.zh}
+
{w.ko}
+
+ 사용: {w.usage} + {copiedIdx === i && ✓ 복사됨} +
+
+ +
+ ))} +
+
+ ) : ( +
+ 실시간 탐지 탭에서 의심 선박을 클릭하면
해당 업종별 대응 절차가 자동 표시됩니다 +
+ )} + )} + + {/* ── TAB: 조치 기준 ── */} + {tab === 'alert' && ()} +
+ + {/* Footer */} +
+ GC-KCG-2026-001 기반 | 허가현황 906척 | 수역: Point-in-Polygon | 중국어 경고문 클릭 시 클립보드 복사 +
+
+ ); +} + +// ── 업종별 대응 절차 컴포넌트 ── +const step: React.CSSProperties = { background: '#1e293b', borderRadius: 4, padding: '6px 10px', margin: '4px 0' }; +const stepN: React.CSSProperties = { display: 'inline-block', background: '#3b82f6', color: '#fff', borderRadius: 3, padding: '0 5px', fontSize: 8, fontWeight: 700, marginRight: 4 }; +const warn: React.CSSProperties = { background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)', borderRadius: 4, padding: '4px 8px', margin: '4px 0', fontSize: 9, color: '#fca5a5' }; + +function ProcedureSteps({ type }: { type: string }) { + switch (type) { + case 'PT': return (<> +
🔴 2척식 저인망 (PT) 대응 절차
+
⚠ 선미(船尾) 방향 접근 절대 금지 — 예인삭 스크루 감김 위험
+
1탐지/식별 — AIS MMSI → 허가DB 대조. 본선·부속선 쌍 확인, 이격거리 측정
+
2접근/경고 — 선수 45° 측면 접근. VHF Ch.16 경고 3회. 중국어 방송 병행
+
3승선 검사 — ①허가증(C21-xxxxx) ②조업일지(할당량 100톤/척) ③망목 실측(54mm)
+
4위반 판정 — 휴어기(4/16~10/15)→나포 | 할당초과→압수 | 부속선 분리→양선 나포
+
5나포/방면 — 위반: 목포·여수·제주·태안 입항. 경미: 경고 후 방면. 알람 기록 등록
+ ); + case 'GN': return (<> +
🟡 유자망 (GN) 대응 — 다크베셀 주의
+
⚠ 부표 위치 먼저 확인 → 그물 범위 외곽으로 접근 (스크루 감김 방지)
+
1다크베셀 탐지 — 레이더 탐색 + SAR 요청. 부표 다수 발견 → 1NM 이내 집중 수색
+
2그물 확인 후 접근 — 부표 배치방향 → 자망 연장선 추정 → 수직 90° 외곽 접근
+
3AIS 재가동 — "请打开AIS" 경고. 재개 확인 후 MMSI 기록. 거부 시 강제 임검
+
4승선 검사 — ①허가증(C25-xxxxx) ②수역확인(I발견→위반) ③어획량(28톤/척) ④망목·규모
+
5어구 판정 — 허가외 자망→수거/절단. 망목미달→전량압수. GPS·사진 기록
+ ); + case 'PS': return (<> +
🟣 위망 (PS) 선단 대응 — 선단 분산 주의
+
⚠ 단독 접근 금지 — 조명선 시야교란, 분산도주 전술 대비. 대형 함정 지원 후 동시 제압
+
1선단 확인/보고 — 원형궤적 + 고속→저속 패턴. 3척+ 클러스터. 즉시 상급 보고
+
2집어등 식별 — 야간 EO/육안. 조명선 MMSI 기록. 차단은 최후 단계
+
3선단 포위 — 모선·운반선·조명선 동시 포위. 서방(중국측) 탈주 차단 우선
+
4일제 임검 — 모선: C23-xxxxx, 1,500톤/척. 운반선/조명선: 0톤→적재 시 불법
+
5나포/증거 — 어획물·냉동설비 촬영. 宁波海裕 VHF 교신 확보. 목포·여수항 인계
+ ); + case 'FC': return (<> +
🟠 운반선 (FC) 환적 대응
+
1환적 알람 — FC+조업선 0.5NM + 양쪽 2kn + 30분 → HIGH. 좌표 즉시 이동
+
2증거 촬영 — 접현/고무보트 확인. 드론 항공촬영. MMSI·선명·접현 흔적 기록
+
3양선 임검 — 운반선: 화물·출발지·도착지. 조업선: 허가량 대비 실어획량
+
4증거/조치 — 사진·중량 확보. 필요시 전량 압수. 도주시 경고사격. 최근접 항구 입항
+ ); + case 'GEAR': return (<> +
🪤 불법 어구 수거 절차
+
⚠ 방치 자망 스크루 감김 주의 — 엔진 정지/저속 상태에서 수동 회수. 야간 수거 원칙적 연기
+
1발견/기록 — GPS(WGS84), 종류 추정, 사진, 소유자번호, 규모(길이·폭·그물코)
+
2중국어구 판단 — 중국어 부표, 광폭·장형 구조. 인근 중국어선 확인. 불가→항구 감식
+
3수거 실행 — RIB/크레인. 어획물→전량 압수. 절단 시 위치·잔존 기록
+
4수거 보고 — 감시 시스템 등록. 항구 감식·증거 보존. 반복 발견→집중 감시 지정
+ ); + default: return (
선박 유형을 식별할 수 없습니다. 기본 임검 절차를 적용하세요.
); + } +} + +function AlertTable() { + const rows = [ + { type: '미등록 선박', criteria: 'MMSI 허가DB 미등록', action: '즉시 정선·나포', level: 'CRITICAL', note: '허가증 불소지 추가 확인' }, + { type: '휴어기 조업', criteria: 'C21·C22: 4/16~10/15\nC25: 6/2~8/31', action: '즉시 나포', level: 'CRITICAL', note: '날짜 자동 판별' }, + { type: '허가 수역 이탈', criteria: '비허가 수역 진입', action: '경고 후 나포', level: 'HIGH', note: 'PT: I·IV이탈 GN: I이탈' }, + { type: 'PT 부속선 분리', criteria: '본선 이격 3NM+', action: '양선 동시 나포', level: 'HIGH→CRIT', note: '311쌍 실시간 모니터링' }, + { type: '환적 현장 포착', criteria: 'FC+조업선 0.5NM+2kn+30분', action: '촬영 후 양선 나포', level: 'HIGH', note: '증거 촬영 최우선' }, + { type: '불법 어구 발견', criteria: '표지 없음/미허가', action: '즉시 수거·기록', level: '자체판단', note: 'GPS 등록, 반복 요주의' }, + { type: '할당량 초과', criteria: '80~100%+ 초과', action: '계량·초과 시 압수', level: 'CRITICAL', note: 'GN 28톤 현장 계량' }, + { type: '다크베셀', criteria: 'AIS 공백 6시간+', action: '접근·임검', level: 'HIGH', note: 'SAR 교차 확인' }, + ]; + const lc = (l: string) => l.includes('CRIT') ? '#ef4444' : l === 'HIGH' ? '#f59e0b' : '#64748b'; + return ( +
+
🚨 단속 상황별 조치 기준
+ + + + + + {rows.map((r, i) => ( + + + ))} + +
위반 유형판정 기준즉시 조치알람비고
{r.type}{r.criteria}{r.action}{r.level}{r.note}
+
📅 감시 강화 시기
+ + + + + + + + +
시기상황대응
7~8월PS 16척만 허가C21·C22·C25 전원 비허가
5월GN만 허가저인망(C21·C22) 즉시 위반
4월·10월기간 경계4/16, 10/16 집중 모니터링
1~3월전 업종 가능수역이탈·할당초과 중심
+
+ ); +} + +const th: React.CSSProperties = { border: '1px solid #1e293b', padding: '3px 6px', textAlign: 'left', color: '#e2e8f0', fontSize: 8, fontWeight: 700 }; +const td: React.CSSProperties = { border: '1px solid #1e293b', padding: '2px 6px', fontSize: 9 }; diff --git a/frontend/src/components/korea/ReportModal.tsx b/frontend/src/components/korea/ReportModal.tsx new file mode 100644 index 0000000..a6004d6 --- /dev/null +++ b/frontend/src/components/korea/ReportModal.tsx @@ -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(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)}%`; +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index d81f6ba..1826b7d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -115,6 +115,16 @@ export default defineConfig(({ mode }): UserConfig => ({ changeOrigin: true, rewrite: (path) => path.replace(/^\/ollama/, ''), }, + '/api/gtts': { + target: 'https://translate.google.com', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api\/gtts/, '/translate_tts'), + secure: true, + headers: { + 'Referer': 'https://translate.google.com/', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + }, + }, }, }, }))