diff --git a/frontend/src/components/korea/KoreaDashboard.tsx b/frontend/src/components/korea/KoreaDashboard.tsx index f96c6cf..8f6ad1f 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 { OpsGuideModal } from './OpsGuideModal'; +import type { OpsRoute } from './OpsGuideModal'; +import { ReportModal } from './ReportModal'; 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 [showOpsGuide, setShowOpsGuide] = useState(false); + const [showReport, setShowReport] = useState(false); + const [opsRoute, setOpsRoute] = useState(null); + const [flyToTarget, setFlyToTarget] = useState<{ lat: number; lng: number; zoom: number } | null>(null); const { t } = useTranslation(); const { hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory } = @@ -293,6 +300,10 @@ export const KoreaDashboard = ({ onClick={() => setShowFieldAnalysis(v => !v)} title="ํ˜„์žฅ๋ถ„์„"> ๐Ÿ“Šํ˜„์žฅ๋ถ„์„ + , headerSlot, )} @@ -312,8 +323,20 @@ export const KoreaDashboard = ({ ships={koreaData.ships} vesselAnalysis={vesselAnalysis} onClose={() => setShowFieldAnalysis(false)} + onReport={() => setShowReport(true)} /> )} + {showOpsGuide && ( + { setShowOpsGuide(false); setOpsRoute(null); }} + onFlyTo={(lat, lng, zoom) => setFlyToTarget({ lat, lng, zoom })} + onRouteSelect={setOpsRoute} + /> + )} + {showReport && ( + setShowReport(false)} /> + )} setFlyToTarget(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 @@ -144,7 +147,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 +182,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 +936,36 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF onExpandedChange={setAnalysisPanelOpen} /> )} + + {/* ์ž‘์ „๊ฐ€์ด๋“œ ์ž„๊ฒ€์นจ๋กœ ์ ์„  */} + {opsRoute && (() => { + const riskColor = opsRoute.riskLevel === 'CRITICAL' ? '#ef4444' : opsRoute.riskLevel === 'HIGH' ? '#f59e0b' : '#3b82f6'; + const routeGeoJson: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: [{ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: [[opsRoute.from.lng, opsRoute.from.lat], [opsRoute.to.lng, opsRoute.to.lat]] } }], + }; + const midLng = (opsRoute.from.lng + opsRoute.to.lng) / 2; + const midLat = (opsRoute.from.lat + opsRoute.to.lat) / 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..4720d90 --- /dev/null +++ b/frontend/src/components/korea/OpsGuideModal.tsx @@ -0,0 +1,409 @@ +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 { analyzeFishing, 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) => { + if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; } + setSpeakingIdx(idx); + const encoded = encodeURIComponent(text); + const url = `/api/gtts?ie=UTF-8&q=${encoded}&tl=zh-CN&total=1&idx=0&textlen=${text.length}&client=webapp&prev=input&ttsspeed=0.24`; + 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..debbe49 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 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }, + }, }, }, }))