diff --git a/frontend/src/components/korea/OpsGuideModal.tsx b/frontend/src/components/korea/OpsGuideModal.tsx index d17ba21..d928e94 100644 --- a/frontend/src/components/korea/OpsGuideModal.tsx +++ b/frontend/src/components/korea/OpsGuideModal.tsx @@ -21,13 +21,14 @@ interface Props { interface SuspectVessel { ship: Ship; - distance: number; // NM from selected KCG + 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; // Earth radius in NM + 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; @@ -36,11 +37,61 @@ function haversineNM(lat1: number, lng1: number, lat2: number, lng2: number): nu 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); // NM + 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) => { @@ -48,226 +99,276 @@ export function OpsGuideModal({ ships, onClose, onFlyTo, onRouteSelect }: Props) 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), - }); + 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)), + 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 analysis = isFishing ? analyzeFishing(ship) : null; 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'; - } - - // ์ˆ˜์—ญ I์— PT/OT ์„ ๋ฐ• - if (isFishing && zone.zone === 'ZONE_I' && ship.speed >= 2 && ship.speed <= 5) { - reasons.push('์ˆ˜์—ญI ์ €์ธ๋ง ์˜์‹ฌ (PT/OT ๋น„ํ—ˆ๊ฐ€)'); - riskLevel = 'HIGH'; - } - - // ๋‹คํฌ๋ฒ ์…€ ์˜์‹ฌ (์†๋„ 0, ๋ฐฉํ–ฅ 0) - if (isFishing && ship.speed === 0 && (!ship.heading || ship.heading === 0)) { - reasons.push('AIS ๋น„์ •์ƒ (๋‹คํฌ๋ฒ ์…€ ์˜์‹ฌ)'); - if (riskLevel === 'MEDIUM') riskLevel = 'HIGH'; - } - - // ์กฐ์—… ์ค‘ (2-6kn) - 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 }); - } + 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) => { - const riskOrder = { CRITICAL: 0, HIGH: 1, MEDIUM: 2 }; - return riskOrder[a.riskLevel] - riskOrder[b.riskLevel] || a.distance - b.distance; - }); + 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 copyToClipboard = (text: string, idx: number) => { + navigator.clipboard.writeText(text).then(() => { setCopiedIdx(idx); setTimeout(() => setCopiedIdx(null), 1500); }); + }; + + 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 */} +
+ โ ฟ + โš“ + ๊ฒฝ๋น„ํ•จ์ • ์ž‘์ „ ๊ฐ€์ด๋“œ + +
- {/* Header โ€” drag handle */} -
- โ ฟ - โš“ - ๊ฒฝ๋น„ํ•จ์ • ์ž‘์ „ ๊ฐ€์ด๋“œ - ํ•ด๊ฒฝ ๊ธฐ์ง€ ๊ธฐ์ค€ ์ฃผ๋ณ€ ๋ถˆ๋ฒ•์–ด์„ ยท์–ด๊ตฌ ํƒ์ง€ - -
+ {/* Tabs */} +
+ {([['detect', '๐Ÿ” ์‹ค์‹œ๊ฐ„ ํƒ์ง€'], ['procedure', '๐Ÿ“‹ ๋Œ€์‘ ์ ˆ์ฐจ'], ['alert', '๐Ÿšจ ์กฐ์น˜ ๊ธฐ์ค€']] as [Tab, string][]).map(([k, l]) => ( + + ))} +
- {/* Controls */} -
- - { const f = kcgBases.find(b => b.id === Number(e.target.value)); setSelectedKCG(f || null); }} style={{ background: '#1e293b', border: '1px solid #334155', borderRadius: 4, padding: '3px 8px', fontSize: 10, color: '#e2e8f0', minWidth: 160 }}> + + {kcgBases.map(b => )} - - - setSearchRadius(Number(e.target.value))} style={{ background: '#1e293b', border: '1px solid #334155', borderRadius: 4, padding: '3px 8px', fontSize: 10, color: '#e2e8f0' }}> + {[10, 20, 30, 50, 100].map(n => )} - - {selectedKCG && ( -
- ๐Ÿ”ด CRITICAL {criticalCount} - ๐ŸŸก HIGH {highCount} - ๐Ÿ”ต TOTAL {suspects.length} -
- )} + {selectedKCG &&
+ ๐Ÿ”ด {criticalCount} + ๐ŸŸก {highCount} + ๐Ÿ”ต {suspects.length} +
}
+ )} - {/* Content */} -
+ {/* Content */} +
+ + {/* โ”€โ”€ TAB: ์‹ค์‹œ๊ฐ„ ํƒ์ง€ โ”€โ”€ */} + {tab === 'detect' && (<> {!selectedKCG ? ( -
-
โš“
-
์ถœ๋™ ๊ธฐ์ง€๋ฅผ ์„ ํƒํ•˜๋ฉด ์ฃผ๋ณ€ ๋ถˆ๋ฒ•์–ด์„ ยท์–ด๊ตฌ๋ฅผ ์ž๋™ ํƒ์ง€ํ•ฉ๋‹ˆ๋‹ค
-
ํ•ด๊ฒฝ์„œ/์ง€๋ฐฉ์ฒญ/ํ•ด๊ตฐ๋ถ€๋Œ€ ๊ธฐ์ค€ ๋ฐ˜๊ฒฝ ๋‚ด ์ค‘๊ตญ ์„ ๋ฐ• ๋ถ„์„
-
+
โš“ ์ถœ๋™ ๊ธฐ์ง€๋ฅผ ์„ ํƒํ•˜๋ฉด ์ฃผ๋ณ€ ๋ถˆ๋ฒ•์–ด์„ ยท์–ด๊ตฌ๋ฅผ ์ž๋™ ํƒ์ง€ํ•ฉ๋‹ˆ๋‹ค
) : suspects.length === 0 ? ( -
-
โœ…
-
{selectedKCG.name} ๋ฐ˜๊ฒฝ {searchRadius}NM ๋‚ด ์˜์‹ฌ ์„ ๋ฐ• ์—†์Œ
-
+
โœ… {selectedKCG.name} ๋ฐ˜๊ฒฝ {searchRadius}NM ๋‚ด ์˜์‹ฌ ์„ ๋ฐ• ์—†์Œ
) : ( -
- {/* Route summary */} -
-
- ๐Ÿ“ {selectedKCG.name} โ†’ ์ˆœ์ฐฐ ๋ฃจํŠธ ์ œ์•ˆ ({suspects.length}๊ฑด) -
-
- ์šฐ์„ ์ˆœ์œ„: CRITICAL โ†’ HIGH โ†’ MEDIUM | ๊ฑฐ๋ฆฌ์ˆœ ์ •๋ ฌ | ๊ฐ€์žฅ ๊ฐ€๊นŒ์šด ๊ณ ์œ„ํ—˜ ๋Œ€์ƒ๋ถ€ํ„ฐ ์ˆœ์ฐฐ -
- {criticalCount > 0 && ( -
- โš  CRITICAL {criticalCount}๊ฑด โ€” ์ฆ‰์‹œ ์ถœ๋™ ๊ถŒ๊ณ  -
- )} -
- - {/* Suspect list */} +
{suspects.map((s, i) => ( -
{ - 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, - }); - } - }} - > -
- #{i + 1} - {RISK_ICON[s.riskLevel]} - {s.riskLevel} - {s.ship.name || s.ship.mmsi} - MMSI: {s.ship.mmsi} - {s.distance.toFixed(1)} NM +
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} - ))} -
-
- SOG: {s.ship.speed?.toFixed(1) ?? '-'} kn - HDG: {s.ship.heading ?? '-'}ยฐ - {s.ship.lat.toFixed(4)}ยฐN, {s.ship.lng.toFixed(4)}ยฐE - {onFlyTo && ํด๋ฆญ โ†’ ์ง€๋„ ์ด๋™} +
+ {s.reasons.map((r, j) => {r})}
))}
)} -
+ )} - {/* Footer */} -
- ํ•œ์ค‘์–ด์—…ํ˜‘์ • ํ—ˆ๊ฐ€ํ˜„ํ™ฉ (906์ฒ™) ๊ธฐ๋ฐ˜ ์ž๋™ ๋ถ„์„ | ์ˆ˜์—ญ ํŒ์ •: Point-in-Polygon | ์œ„ํ—˜๋„: ์†๋„ยท์œ„์น˜ยทAIS ํŒจํ„ด ์ข…ํ•ฉ -
+ {/* โ”€โ”€ 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)} style={{ + background: copiedIdx === i ? 'rgba(34,197,94,0.15)' : '#111827', + border: copiedIdx === i ? '1px solid rgba(34,197,94,0.4)' : '1px solid #1e293b', + borderRadius: 4, padding: '6px 10px', marginBottom: 4, cursor: 'pointer', transition: 'all 0.2s', + }}> +
{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 };