feat(korea): 작전가이드 3탭 구성 — 실시간탐지 + 대응절차 + 조치기준

- 3개 탭: 실시간 탐지 / 대응 절차 / 조치 기준
- 의심 선박 클릭 → 자동으로 대응 절차 탭 전환
- 선박 추정 업종(PT/GN/PS/FC/GEAR) 자동 분류 → 해당 STEP 표시
- 중국어 경고문 업종별 배치 (클릭 → 클립보드 복사)
  PT: 4개, GN: 4개, PS: 4개, FC: 3개, GEAR: 1개
- 조치 기준 탭: 8대 위반유형 테이블 + 감시 강화 시기
- GC-KCG-2026-001 제7장 작전가이드 PDF 전문 반영

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nan Kyung Lee 2026-03-24 15:51:04 +09:00
부모 612973e9ab
커밋 1aa887fce4

파일 보기

@ -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<string, { zh: string; ko: string; usage: string }[]> = {
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<CoastGuardFacility | null>(null);
const [searchRadius, setSearchRadius] = useState(30); // NM
const [searchRadius, setSearchRadius] = useState(30);
const [pos, setPos] = useState({ x: 60, y: 60 });
const [tab, setTab] = useState<Tab>('detect');
const [selectedSuspect, setSelectedSuspect] = useState<SuspectVessel | null>(null);
const [copiedIdx, setCopiedIdx] = useState<number | null>(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<SuspectVessel[]>(() => {
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<string, string> = { PT: '저인망(PT)', GN: '유자망(GN)', PS: '위망(PS)', FC: '운반선(FC)', GEAR: '어구/어망', UNKNOWN: '미분류' };
return (
<div style={{ position: 'fixed', left: pos.x, top: pos.y, zIndex: 9999, width: 720, maxHeight: '80vh', overflow: 'hidden', background: '#0a0f1a', borderRadius: 8, border: '1px solid #1e293b', display: 'flex', flexDirection: 'column', boxShadow: '0 8px 32px rgba(0,0,0,0.6)', resize: 'both' }}>
<div>
<div style={{ position: 'fixed', left: pos.x, top: pos.y, zIndex: 9999, width: 760, maxHeight: '85vh', overflow: 'hidden', background: '#0a0f1a', borderRadius: 8, border: '1px solid #1e293b', display: 'flex', flexDirection: 'column', boxShadow: '0 8px 32px rgba(0,0,0,0.6)', resize: 'both' }}>
{/* Header */}
<div onMouseDown={onDragStart} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 16px', borderBottom: '1px solid #1e293b', background: 'rgba(30,58,95,0.5)', cursor: 'grab', userSelect: 'none', flexShrink: 0 }}>
<span style={{ fontSize: 10, color: '#475569', letterSpacing: 2 }}></span>
<span style={{ fontSize: 14 }}></span>
<span style={{ fontSize: 13, fontWeight: 700, color: '#e2e8f0' }}> </span>
<button onClick={onClose} style={{ marginLeft: 'auto', background: 'rgba(255,82,82,0.1)', border: '1px solid rgba(255,82,82,0.4)', color: '#ef4444', padding: '4px 12px', cursor: 'pointer', fontSize: 10, borderRadius: 2 }}></button>
</div>
{/* Header — drag handle */}
<div onMouseDown={onDragStart} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 16px', borderBottom: '1px solid #1e293b', background: 'rgba(30,58,95,0.5)', flexShrink: 0, cursor: 'grab', userSelect: 'none' }}>
<span style={{ fontSize: 10, color: '#475569', cursor: 'grab', letterSpacing: 2 }}></span>
<span style={{ fontSize: 14 }}></span>
<span style={{ fontSize: 14, fontWeight: 700, color: '#e2e8f0' }}> </span>
<span style={{ fontSize: 9, color: '#64748b' }}> · </span>
<button onClick={onClose} style={{ marginLeft: 'auto', background: 'rgba(255,82,82,0.1)', border: '1px solid rgba(255,82,82,0.4)', color: '#ef4444', padding: '4px 14px', cursor: 'pointer', fontSize: 11, borderRadius: 2 }}> </button>
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: 2, padding: '4px 16px', borderBottom: '1px solid #1e293b', flexShrink: 0 }}>
{([['detect', '🔍 실시간 탐지'], ['procedure', '📋 대응 절차'], ['alert', '🚨 조치 기준']] as [Tab, string][]).map(([k, l]) => (
<button key={k} onClick={() => setTab(k)} style={{
padding: '3px 10px', fontSize: 10, fontWeight: 700, borderRadius: 3, cursor: 'pointer',
background: tab === k ? 'rgba(59,130,246,0.2)' : 'transparent',
border: tab === k ? '1px solid rgba(59,130,246,0.4)' : '1px solid transparent',
color: tab === k ? '#60a5fa' : '#64748b',
}}>{l}</button>
))}
</div>
{/* Controls */}
<div style={{ display: 'flex', gap: 8, padding: '8px 16px', borderBottom: '1px solid #1e293b', flexShrink: 0, alignItems: 'center', flexWrap: 'wrap' }}>
<label style={{ fontSize: 10, color: '#94a3b8', fontWeight: 700 }}> :</label>
<select
value={selectedKCG?.id ?? ''}
onChange={e => {
const f = kcgBases.find(b => b.id === Number(e.target.value));
setSelectedKCG(f || null);
}}
style={{ background: '#1e293b', border: '1px solid #334155', borderRadius: 4, padding: '4px 8px', fontSize: 10, color: '#e2e8f0', minWidth: 180 }}
>
<option value="">-- --</option>
{kcgBases.map(b => (
<option key={b.id} value={b.id}>[{CG_TYPE_LABEL[b.type]}] {b.name}</option>
))}
{/* Controls (detect tab) */}
{tab === 'detect' && (
<div style={{ display: 'flex', gap: 8, padding: '6px 16px', borderBottom: '1px solid #1e293b', flexShrink: 0, alignItems: 'center', flexWrap: 'wrap' }}>
<select value={selectedKCG?.id ?? ''} onChange={e => { 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 }}>
<option value=""> </option>
{kcgBases.map(b => <option key={b.id} value={b.id}>[{CG_TYPE_LABEL[b.type]}] {b.name}</option>)}
</select>
<label style={{ fontSize: 10, color: '#94a3b8', fontWeight: 700, marginLeft: 8 }}> :</label>
<select value={searchRadius} onChange={e => setSearchRadius(Number(e.target.value))} style={{ background: '#1e293b', border: '1px solid #334155', borderRadius: 4, padding: '4px 8px', fontSize: 10, color: '#e2e8f0' }}>
<option value={10}>10 NM</option>
<option value={20}>20 NM</option>
<option value={30}>30 NM</option>
<option value={50}>50 NM</option>
<option value={100}>100 NM</option>
<select value={searchRadius} onChange={e => 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 => <option key={n} value={n}>{n} NM</option>)}
</select>
{selectedKCG && (
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8, fontSize: 10 }}>
<span style={{ color: '#ef4444', fontWeight: 700 }}>🔴 CRITICAL {criticalCount}</span>
<span style={{ color: '#f59e0b', fontWeight: 700 }}>🟡 HIGH {highCount}</span>
<span style={{ color: '#3b82f6', fontWeight: 700 }}>🔵 TOTAL {suspects.length}</span>
</div>
)}
{selectedKCG && <div style={{ marginLeft: 'auto', display: 'flex', gap: 6, fontSize: 9 }}>
<span style={{ color: '#ef4444', fontWeight: 700 }}>🔴 {criticalCount}</span>
<span style={{ color: '#f59e0b', fontWeight: 700 }}>🟡 {highCount}</span>
<span style={{ color: '#3b82f6', fontWeight: 700 }}>🔵 {suspects.length}</span>
</div>}
</div>
)}
{/* Content */}
<div style={{ flex: 1, overflow: 'auto', padding: '8px 16px' }}>
{/* Content */}
<div style={{ flex: 1, overflow: 'auto', padding: '8px 16px', minHeight: 200 }}>
{/* ── TAB: 실시간 탐지 ── */}
{tab === 'detect' && (<>
{!selectedKCG ? (
<div style={{ textAlign: 'center', padding: '40px 0', color: '#64748b' }}>
<div style={{ fontSize: 32, marginBottom: 8 }}></div>
<div style={{ fontSize: 12 }}> · </div>
<div style={{ fontSize: 10, marginTop: 4 }}>// </div>
</div>
<div style={{ textAlign: 'center', padding: '30px 0', color: '#64748b', fontSize: 11 }}> · </div>
) : suspects.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px 0', color: '#22c55e' }}>
<div style={{ fontSize: 32, marginBottom: 8 }}></div>
<div style={{ fontSize: 12 }}>{selectedKCG.name} {searchRadius}NM </div>
</div>
<div style={{ textAlign: 'center', padding: '30px 0', color: '#22c55e', fontSize: 11 }}> {selectedKCG.name} {searchRadius}NM </div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{/* Route summary */}
<div style={{ background: '#111827', borderRadius: 6, padding: '8px 12px', border: '1px solid #1e293b', marginBottom: 4 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>
📍 {selectedKCG.name} ({suspects.length})
</div>
<div style={{ fontSize: 9, color: '#94a3b8' }}>
우선순위: CRITICAL HIGH MEDIUM | |
</div>
{criticalCount > 0 && (
<div style={{ fontSize: 10, color: '#ef4444', fontWeight: 700, marginTop: 4 }}>
CRITICAL {criticalCount}
</div>
)}
</div>
{/* Suspect list */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{suspects.map((s, i) => (
<div
key={s.ship.mmsi}
style={{
background: '#111827', borderRadius: 6, padding: '8px 12px',
border: `1px solid ${RISK_COLOR[s.riskLevel]}30`,
borderLeft: `3px solid ${RISK_COLOR[s.riskLevel]}`,
cursor: onFlyTo ? 'pointer' : 'default',
}}
onClick={() => {
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,
});
}
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: '#64748b', minWidth: 20 }}>#{i + 1}</span>
<span style={{ fontSize: 9 }}>{RISK_ICON[s.riskLevel]}</span>
<span style={{
fontSize: 9, fontWeight: 700, padding: '1px 6px', borderRadius: 3,
background: RISK_COLOR[s.riskLevel] + '20', color: RISK_COLOR[s.riskLevel],
}}>{s.riskLevel}</span>
<span style={{ fontSize: 11, fontWeight: 700, color: '#e2e8f0' }}>{s.ship.name || s.ship.mmsi}</span>
<span style={{ fontSize: 9, color: '#64748b' }}>MMSI: {s.ship.mmsi}</span>
<span style={{ marginLeft: 'auto', fontSize: 10, color: '#60a5fa', fontWeight: 700 }}>{s.distance.toFixed(1)} NM</span>
<div key={s.ship.mmsi} onClick={() => handleSuspectClick(s)} style={{
background: '#111827', borderRadius: 4, padding: '6px 10px', borderLeft: `3px solid ${RISK_COLOR[s.riskLevel]}`, cursor: 'pointer',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 10 }}>
<span style={{ color: '#475569', minWidth: 18 }}>#{i + 1}</span>
<span>{RISK_ICON[s.riskLevel]}</span>
<span style={{ fontSize: 8, fontWeight: 700, padding: '0 4px', borderRadius: 2, background: RISK_COLOR[s.riskLevel] + '20', color: RISK_COLOR[s.riskLevel] }}>{s.riskLevel}</span>
<span style={{ fontWeight: 700, color: '#e2e8f0' }}>{s.ship.name || s.ship.mmsi}</span>
<span style={{ fontSize: 8, color: '#64748b' }}>[{TYPE_LABEL[s.estimatedType]}]</span>
<span style={{ marginLeft: 'auto', color: '#60a5fa', fontWeight: 700 }}>{s.distance.toFixed(1)} NM</span>
</div>
<div style={{ display: 'flex', gap: 4, marginTop: 4, flexWrap: 'wrap' }}>
{s.reasons.map((r, j) => (
<span key={j} style={{
fontSize: 8, padding: '1px 5px', borderRadius: 2,
background: 'rgba(255,255,255,0.06)', color: '#94a3b8',
}}>{r}</span>
))}
</div>
<div style={{ display: 'flex', gap: 12, marginTop: 4, fontSize: 9, color: '#64748b' }}>
<span>SOG: {s.ship.speed?.toFixed(1) ?? '-'} kn</span>
<span>HDG: {s.ship.heading ?? '-'}°</span>
<span>{s.ship.lat.toFixed(4)}°N, {s.ship.lng.toFixed(4)}°E</span>
{onFlyTo && <span style={{ color: '#60a5fa', marginLeft: 'auto' }}> </span>}
<div style={{ display: 'flex', gap: 3, marginTop: 2, flexWrap: 'wrap' }}>
{s.reasons.map((r, j) => <span key={j} style={{ fontSize: 7, padding: '0 4px', borderRadius: 2, background: 'rgba(255,255,255,0.05)', color: '#94a3b8' }}>{r}</span>)}
</div>
</div>
))}
</div>
)}
</div>
</>)}
{/* Footer */}
<div style={{ padding: '6px 16px', borderTop: '1px solid #1e293b', fontSize: 8, color: '#475569', flexShrink: 0 }}>
(906) | 판정: Point-in-Polygon | 위험도: 속도··AIS
</div>
{/* ── TAB: 대응 절차 ── */}
{tab === 'procedure' && (<>
{selectedSuspect ? (
<div style={{ fontSize: 10, color: '#e2e8f0', lineHeight: 1.7 }}>
{/* 선박 정보 */}
<div style={{ background: '#111827', borderRadius: 6, padding: '8px 12px', border: `1px solid ${RISK_COLOR[selectedSuspect.riskLevel]}30`, marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span>{RISK_ICON[selectedSuspect.riskLevel]}</span>
<span style={{ fontWeight: 700, fontSize: 12 }}>{selectedSuspect.ship.name || selectedSuspect.ship.mmsi}</span>
<span style={{ fontSize: 9, padding: '1px 6px', borderRadius: 3, background: RISK_COLOR[selectedSuspect.riskLevel] + '20', color: RISK_COLOR[selectedSuspect.riskLevel], fontWeight: 700 }}>{selectedSuspect.riskLevel}</span>
<span style={{ fontSize: 9, color: '#64748b' }}>: {TYPE_LABEL[selectedSuspect.estimatedType]}</span>
</div>
<div style={{ fontSize: 9, color: '#64748b', marginTop: 2 }}>MMSI: {selectedSuspect.ship.mmsi} | SOG: {selectedSuspect.ship.speed?.toFixed(1)}kn | {selectedSuspect.distance.toFixed(1)} NM</div>
</div>
{/* 업종별 대응 절차 */}
<ProcedureSteps type={selectedSuspect.estimatedType} />
{/* 중국어 경고문 */}
<div style={{ marginTop: 12, borderTop: '1px solid #1e293b', paddingTop: 8 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#f59e0b', marginBottom: 6 }}>📢 ( )</div>
{(CN_WARNINGS[selectedSuspect.estimatedType] || CN_WARNINGS.UNKNOWN).map((w, i) => (
<div key={i} onClick={() => 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',
}}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#fbbf24' }}>{w.zh}</div>
<div style={{ fontSize: 9, color: '#94a3b8' }}>{w.ko}</div>
<div style={{ fontSize: 8, color: '#475569' }}>: {w.usage} {copiedIdx === i && <span style={{ color: '#22c55e', fontWeight: 700 }}> </span>}</div>
</div>
))}
</div>
</div>
) : (
<div style={{ textAlign: 'center', padding: '30px 0', color: '#64748b', fontSize: 11 }}>
<br/>
</div>
)}
</>)}
{/* ── TAB: 조치 기준 ── */}
{tab === 'alert' && (<AlertTable />)}
</div>
{/* Footer */}
<div style={{ padding: '4px 16px', borderTop: '1px solid #1e293b', fontSize: 8, color: '#475569', flexShrink: 0 }}>
GC-KCG-2026-001 | 906 | 수역: Point-in-Polygon |
</div>
</div>
);
}
// ── 업종별 대응 절차 컴포넌트 ──
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 (<>
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>🔴 2 (PT) </div>
<div style={warn}> () </div>
<div style={step}><span style={stepN}>1</span><b>/</b> AIS MMSI DB . · , </div>
<div style={step}><span style={stepN}>2</span><b>/</b> 45° . VHF Ch.16 3. </div>
<div style={step}><span style={stepN}>3</span><b> </b> (C21-xxxxx) ( 100/) (54mm)</div>
<div style={step}><span style={stepN}>4</span><b> </b> (4/16~10/15) | | </div>
<div style={step}><span style={stepN}>5</span><b>/</b> 위반: 목포··· . 경미: 경고 . </div>
</>);
case 'GN': return (<>
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>🟡 (GN) </div>
<div style={warn}> ( )</div>
<div style={step}><span style={stepN}>1</span><b> </b> + SAR . 1NM </div>
<div style={step}><span style={stepN}>2</span><b> </b> 90° </div>
<div style={step}><span style={stepN}>3</span><b>AIS </b> "请打开AIS" . MMSI . </div>
<div style={step}><span style={stepN}>4</span><b> </b> (C25-xxxxx) (I발견) (28/) ·</div>
<div style={step}><span style={stepN}>5</span><b> </b> /. . GPS· </div>
</>);
case 'PS': return (<>
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>🟣 (PS) </div>
<div style={warn}> , . </div>
<div style={step}><span style={stepN}>1</span><b> /</b> + . 3+ . </div>
<div style={step}><span style={stepN}>2</span><b> </b> EO/. MMSI . </div>
<div style={step}><span style={stepN}>3</span><b> </b> ·· . () </div>
<div style={step}><span style={stepN}>4</span><b> </b> 모선: C23-xxxxx, 1,500/. /조명선: 0톤 </div>
<div style={step}><span style={stepN}>5</span><b>/</b> · . VHF . · </div>
</>);
case 'FC': return (<>
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>🟠 (FC) </div>
<div style={step}><span style={stepN}>1</span><b> </b> FC+ 0.5NM + 2kn + 30 HIGH. </div>
<div style={step}><span style={stepN}>2</span><b> </b> / . . MMSI·· </div>
<div style={step}><span style={stepN}>3</span><b> </b> 운반선: 화물··. 조업선: 허가량 </div>
<div style={step}><span style={stepN}>4</span><b>/</b> · . . . </div>
</>);
case 'GEAR': return (<>
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>🪤 </div>
<div style={warn}> / . </div>
<div style={step}><span style={stepN}>1</span><b>/</b> GPS(WGS84), , , , (··)</div>
<div style={step}><span style={stepN}>2</span><b> </b> , · . . </div>
<div style={step}><span style={stepN}>3</span><b> </b> RIB/. . · </div>
<div style={step}><span style={stepN}>4</span><b> </b> . · . </div>
</>);
default: return (<div style={{ color: '#64748b', fontSize: 10 }}> . .</div>);
}
}
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 (
<div style={{ fontSize: 10, color: '#e2e8f0' }}>
<div style={{ fontSize: 12, fontWeight: 700, color: '#60a5fa', marginBottom: 6 }}>🚨 </div>
<table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 9 }}>
<thead><tr style={{ background: '#1e293b' }}>
<th style={th}> </th><th style={th}> </th><th style={th}> </th><th style={th}></th><th style={th}></th>
</tr></thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}><td style={td}>{r.type}</td><td style={{ ...td, whiteSpace: 'pre-line' }}>{r.criteria}</td><td style={td}>{r.action}</td>
<td style={{ ...td, color: lc(r.level), fontWeight: 700 }}>{r.level}</td><td style={{ ...td, color: '#64748b', fontSize: 8 }}>{r.note}</td></tr>
))}
</tbody>
</table>
<div style={{ marginTop: 12, fontSize: 11, fontWeight: 700, color: '#60a5fa' }}>📅 </div>
<table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 9, marginTop: 4 }}>
<thead><tr style={{ background: '#1e293b' }}><th style={th}></th><th style={th}></th><th style={th}></th></tr></thead>
<tbody>
<tr><td style={{ ...td, fontWeight: 700 }}>7~8</td><td style={td}>PS 16 </td><td style={{ ...td, color: '#ef4444' }}>C21·C22·C25 </td></tr>
<tr><td style={{ ...td, fontWeight: 700 }}>5</td><td style={td}>GN만 </td><td style={td}>(C21·C22) </td></tr>
<tr><td style={{ ...td, fontWeight: 700 }}>4·10</td><td style={td}> </td><td style={td}>4/16, 10/16 </td></tr>
<tr><td style={{ ...td, fontWeight: 700 }}>1~3</td><td style={td}> </td><td style={td}>· </td></tr>
</tbody>
</table>
</div>
);
}
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 };