kcg-ai-monitoring/frontend/src/features/field-ops/MobileService.tsx
Nan Kyung Lee 353c960c3f feat: SFR-05~14 화면 시안 전면 반영 및 UI 신규 구현
- SFR-05: 위험도지도 좌측 필터 패널 + 우측 격자상세(SHAP) + 내보내기
- SFR-06: 단속계획 3단 메뉴 구조 + SFR-11 하위 11개 화면
- SFR-07: 순찰경로 가중치 슬라이더(α/β/γ) + 시나리오 + 결과통계
- SFR-08: 다함정최적화 커버리지/중복 슬라이더 + 함정별 상세 + 일괄승인
- SFR-09: 불법어선탐지 필터탭 + 탐지요약 + SHAP 패널 + AIS등급
- SFR-11: 단속 사건관리 통합(리스트→등록→상세→수정), AI탐지연계
- SFR-12: 경보현황판 지도중심 레이아웃 + 5등급 경보 + 필터 + 선박목록
- SFR-13: 통계분석 세로스크롤 대시보드 + 기관비교표 + 보고서생성
- SFR-14: 외부서비스 비식별정책 + API정의 + 이용현황 + 장비구성도
- SFR-15: 모바일서비스 상태바+경보+퀵메뉴+위치정보+지도+네비바
- 공통: OSM 지도 적용, Vite CORS 프록시 수정, 3단 메뉴 지원

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:05:44 +09:00

475 lines
27 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import {
Smartphone, MapPin, Bell, Shield,
Plus, Ship, Calendar, Clock, Crosshair, Loader2, Upload,
Radar, ChevronLeft, CheckCircle, Navigation, AlertTriangle, Anchor,
Building2, User,
} from 'lucide-react';
import maplibregl from 'maplibre-gl';
import { BaseMap, createMarkerLayer, createPolylineLayer, useMapLayers, type MapHandle, type MarkerData } from '@lib/map';
import { TextLayer } from 'deck.gl';
import { useAuth } from '@/app/auth/AuthContext';
/*
* SFR-15: 단속요원 모바일 대응 서비스
*
* 모바일 프리뷰 구성:
* 메인 — 지도 (AI 불법선박 + 어구 마커) + 하단 2버튼
* 화면1 — 단속 이력 등록
* 화면2 — 주변 AI 탐지 상세
*/
type MobileScreen = 'main' | 'register' | 'ai-detail';
function nowLocalISO(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** AI 탐지 마커 데이터 */
const AI_MARKERS = [
// ── 불법선박 (제주 남방 해상) ──
{ id: 'V1', lat: 32.65, lng: 125.80, name: '鲁荣渔56555', type: '불법선박', risk: 'CRITICAL', confidence: 94, detail: 'EEZ 침범, AIS 6h 단절 후 재진입. 중국 선단 합류 의심.' },
{ id: 'V2', lat: 32.90, lng: 127.40, name: '미상선박-A', type: 'Dark Vessel', risk: 'HIGH', confidence: 87, detail: 'SAR 위성 탐지, AIS 미수신. 야간 조업 패턴.' },
{ id: 'V3', lat: 32.40, lng: 126.50, name: '冀黄港渔05001', type: '불법선박', risk: 'HIGH', confidence: 78, detail: '금어기 해역 불법 조업 의심. 통발 투하 행위.' },
{ id: 'V4', lat: 33.80, lng: 125.60, name: '浙岭渔11038', type: '불법선박', risk: 'CRITICAL', confidence: 91, detail: '협정선 위반 조업. MMSI 변조 이력 3회.' },
{ id: 'V5', lat: 32.55, lng: 127.60, name: '闽东渔60888', type: 'Dark Vessel', risk: 'HIGH', confidence: 83, detail: 'AIS 12h 단절. 위성 레이더 탐지 위치.' },
{ id: 'V6', lat: 33.70, lng: 125.30, name: '미상선박-B', type: 'Dark Vessel', risk: 'HIGH', confidence: 79, detail: '적외선 위성 열원 탐지. 선체 추정 30m급.' },
// ── 불법어구 (제주 근해) ──
{ id: 'G1', lat: 33.05, lng: 126.10, name: '어구-A01', type: '불법어구', risk: 'MEDIUM', confidence: 72, detail: '금지구역 자망 어구 AIS(Class-B) 신호 탐지.' },
{ id: 'G2', lat: 33.15, lng: 127.00, name: '어구-B03', type: '불법어구', risk: 'MEDIUM', confidence: 68, detail: '미등록 통발 어구. 위치 위반 의심.' },
{ id: 'G3', lat: 32.80, lng: 126.30, name: '어구-C07', type: '불법어구', risk: 'MEDIUM', confidence: 75, detail: '금어기 해역 연승 어구 탐지. 09:15 최초 신호.' },
{ id: 'G4', lat: 33.00, lng: 127.30, name: '어구-D12', type: '불법어구', risk: 'MEDIUM', confidence: 65, detail: '허가 범위 외 저인망 어구 신호. 이동 패턴 감지.' },
{ id: 'G5', lat: 32.70, lng: 125.95, name: '어구-E05', type: '불법어구', risk: 'MEDIUM', confidence: 70, detail: '폐어구 추정. 48h 이상 동일 위치 정지.' },
// ── 아군 함정 ──
{ id: 'P1', lat: 33.10, lng: 126.55, name: '3009함', type: '아군함정', risk: 'NONE', confidence: 100, detail: '순찰 중. 속력 12kts.' },
{ id: 'P2', lat: 32.85, lng: 126.80, name: '1502함', type: '아군함정', risk: 'NONE', confidence: 100, detail: '대기 중. 정박.' },
];
export function MobileService() {
const { t } = useTranslation('fieldOps');
const { user } = useAuth();
const [screen, setScreen] = useState<MobileScreen>('main');
const [selectedMarker, setSelectedMarker] = useState<typeof AI_MARKERS[0] | null>(null);
// GPS
const [regDateTime, setRegDateTime] = useState(nowLocalISO());
const [regLocation, setRegLocation] = useState('');
const [gpsLoading, setGpsLoading] = useState(false);
const [gpsError, setGpsError] = useState('');
const [registered, setRegistered] = useState(false);
const fetchGPS = useCallback(() => {
if (!navigator.geolocation) { setGpsError('GPS 미지원'); return; }
setGpsLoading(true); setGpsError('');
navigator.geolocation.getCurrentPosition(
(pos) => { setRegLocation(`${pos.coords.latitude.toFixed(4)}°N ${pos.coords.longitude.toFixed(4)}°E`); setGpsLoading(false); },
() => { setGpsError('위치 확인 실패'); setGpsLoading(false); },
{ enableHighAccuracy: true, timeout: 10000 },
);
}, []);
useEffect(() => { fetchGPS(); }, [fetchGPS]);
// 모바일 내 지도
const mobileMapRef = useRef<MapHandle>(null);
const markerColor = (m: typeof AI_MARKERS[0]) =>
m.type === '아군함정' ? '#a855f7' : m.type === '불법어구' ? '#eab308' : m.risk === 'CRITICAL' ? '#ef4444' : '#f97316';
const markerEmoji = (m: typeof AI_MARKERS[0]) =>
m.type === '불법어구' ? '🎣' : m.type === '아군함정' ? '⚓' : '🚢';
/** MapLibre 네이티브 HTML 마커를 지도에 추가 */
const addNativeMarkers = useCallback((map: maplibregl.Map) => {
AI_MARKERS.forEach(m => {
const color = markerColor(m);
const el = document.createElement('div');
el.style.cssText = 'display:flex;flex-direction:column;align-items:center;z-index:10;';
// 라벨
const label = document.createElement('div');
label.style.cssText = `font-size:10px;font-weight:800;color:${color};white-space:nowrap;text-shadow:-1px -1px 0 #fff,1px -1px 0 #fff,-1px 1px 0 #fff,1px 1px 0 #fff,0 0 4px #fff;margin-bottom:2px;line-height:1;`;
label.textContent = `${markerEmoji(m)} ${m.name}`;
el.appendChild(label);
// 도트
const dot = document.createElement('div');
const size = m.type === '불법어구' ? 12 : m.type === '아군함정' ? 12 : 16;
dot.style.cssText = `width:${size}px;height:${size}px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 0 8px ${color},0 0 16px ${color}80;`;
el.appendChild(dot);
new maplibregl.Marker({ element: el, anchor: 'bottom' })
.setLngLat([m.lng, m.lat])
.addTo(map);
});
}, []);
/** 모바일 지도 onMapReady */
const onMobileMapReady = useCallback((map: maplibregl.Map) => {
addNativeMarkers(map);
}, [addNativeMarkers]);
// 데스크톱용 상세 지도
const detailMapRef = useRef<MapHandle>(null);
const buildDetailLayers = useCallback(() => [
createPolylineLayer('eez-d', [[38.5, 124.0], [37.0, 123.0], [36.0, 122.5], [35.0, 123.0]], { color: '#ef4444', width: 1, opacity: 0.3, dashArray: [4, 4] }),
createMarkerLayer('all-markers',
AI_MARKERS.map(m => ({
lat: m.lat, lng: m.lng,
color: markerColor(m),
radius: m.type === '불법어구' ? 800 : 1200,
} as MarkerData)),
),
new TextLayer({
id: 'detail-labels',
data: AI_MARKERS,
getPosition: (d: typeof AI_MARKERS[0]) => [d.lng, d.lat],
getText: (d: typeof AI_MARKERS[0]) => {
const icon = d.type === '불법어구' ? '🎣' : d.type === '아군함정' ? '⚓' : '🚢';
return `${icon} ${d.name}`;
},
getSize: 12,
getColor: (d: typeof AI_MARKERS[0]) => {
if (d.type === '아군함정') return [168, 85, 247, 255];
if (d.type === '불법어구') return [234, 179, 8, 255];
if (d.risk === 'CRITICAL') return [239, 68, 68, 255];
return [249, 115, 22, 255];
},
getPixelOffset: [0, -18],
fontFamily: 'Pretendard, sans-serif',
fontWeight: 700,
outlineWidth: 3,
outlineColor: [0, 0, 0, 200],
billboard: false,
sizeUnits: 'pixels',
}),
], []);
useMapLayers(detailMapRef, buildDetailLayers, []);
const riskBadge = (r: string) =>
r === 'CRITICAL' ? 'bg-red-500/15 text-red-400' :
r === 'HIGH' ? 'bg-orange-500/15 text-orange-400' :
r === 'MEDIUM' ? 'bg-yellow-500/15 text-yellow-400' :
'bg-purple-500/15 text-purple-400';
const typeIcon = (type: string) =>
type === '불법어구' ? <Anchor className="w-3 h-3" /> :
type === '아군함정' ? <Shield className="w-3 h-3" /> :
<Ship className="w-3 h-3" />;
// 모바일 하단 네비게이션 탭 상태
const [mobileNav, setMobileNav] = useState<'home' | 'map' | 'alert' | 'profile'>('home');
// ─── 모바일 프리뷰 내부 렌더 ───
const renderMobileContent = () => {
// 메인 홈
if (screen === 'main') return (
<div className="flex flex-col h-full">
{/* ── 상단 상태바 ── */}
<div className="h-7 bg-slate-900 flex items-center justify-between px-3 shrink-0">
<div className="flex items-center gap-1.5">
<span className="text-[8px] text-green-400 font-bold">📶 4G</span>
<span className="text-[8px] text-hint">🔋 85%</span>
</div>
<span className="text-[8px] text-hint font-mono">
{new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}
</span>
<div className="flex items-center gap-1">
<span className="text-[8px] text-red-400 font-bold">🔔 3</span>
</div>
</div>
{/* ── 긴급 경보 배너 ── */}
<div className="mx-2.5 mt-2 px-3 py-2 bg-red-500/15 border border-red-500/30 rounded-xl animate-pulse">
<div className="flex items-center gap-1.5">
<AlertTriangle className="w-4 h-4 text-red-400 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-[9px] text-red-400 font-bold truncate"> 5 (14:25)</div>
<div className="text-[7px] text-red-400/70">Dark Vessel 2 · 94%</div>
</div>
<button type="button" onClick={() => setScreen('ai-detail')} className="px-2 py-0.5 bg-red-500/20 rounded text-[7px] text-red-400 font-bold shrink-0"> </button>
</div>
</div>
{/* ── 퀵 메뉴 4개 ── */}
<div className="grid grid-cols-4 gap-2 px-2.5 mt-2.5">
{[
{ label: '위험도\n지도', icon: '🗺️', color: 'bg-red-500/15 border-red-500/30', action: () => setMobileNav('map') },
{ label: '의심\n선박', icon: '🚢', color: 'bg-orange-500/15 border-orange-500/30', action: () => setScreen('ai-detail') },
{ label: '경로\n추천', icon: '🧭', color: 'bg-blue-500/15 border-blue-500/30', action: () => {} },
{ label: '단속\n이력', icon: '📋', color: 'bg-green-500/15 border-green-500/30', action: () => { setScreen('register'); setRegDateTime(nowLocalISO()); setRegistered(false); } },
].map(m => (
<button key={m.label} type="button" onClick={m.action} className={`${m.color} border rounded-xl py-2.5 flex flex-col items-center gap-1 active:scale-95 transition-transform`}>
<span className="text-[16px]">{m.icon}</span>
<span className="text-[8px] text-heading font-bold text-center whitespace-pre-line leading-tight">{m.label}</span>
</button>
))}
</div>
{/* ── 현재 위치 기반 정보 ── */}
<div className="mx-2.5 mt-2.5 px-3 py-2.5 bg-surface-overlay border border-border rounded-xl">
<div className="flex items-center gap-1.5 mb-1.5">
<MapPin className="w-3.5 h-3.5 text-blue-400" />
<span className="text-[9px] text-heading font-bold">{regLocation || '위치 확인 중...'}</span>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="text-center">
<div className="text-[14px] font-extrabold text-orange-400">85</div>
<div className="text-[7px] text-hint">()</div>
</div>
<div className="text-center">
<div className="text-[14px] font-extrabold text-red-400">2<span className="text-[9px]"></span></div>
<div className="text-[7px] text-hint"></div>
</div>
<div className="text-center">
<div className="text-[14px] font-extrabold text-hint">3</div>
<div className="text-[7px] text-hint"></div>
</div>
</div>
</div>
{/* ── 지도 (축소) ── */}
<div className="flex-1 mx-2.5 mt-2 mb-1 rounded-xl overflow-hidden relative" style={{ minHeight: 160 }}>
<BaseMap ref={mobileMapRef} center={[33.0, 126.5]} zoom={8} height="100%" forceTheme="light" navPosition="bottom-right" onMapReady={onMobileMapReady} />
<div className="absolute top-1.5 right-1.5 z-[1000] bg-red-500/90 rounded px-1.5 py-0.5">
<span className="text-[8px] text-white font-bold">{AI_MARKERS.filter(m => m.type !== '아군함정').length}</span>
</div>
{/* 범례 */}
<div className="absolute top-1.5 left-1.5 z-[1000] bg-black/60 rounded px-1.5 py-1 flex gap-2">
{[['bg-red-500', '선박'], ['bg-yellow-500', '어구'], ['bg-purple-500', '아군']].map(([c, l]) => (
<div key={l} className="flex items-center gap-0.5"><div className={`w-1.5 h-1.5 rounded-full ${c}`} /><span className="text-[6px] text-white/80">{l}</span></div>
))}
</div>
</div>
{/* ── 하단 네비게이션 바 ── */}
<div className="flex items-center justify-around px-2 py-1.5 bg-slate-900 border-t border-slate-700 shrink-0">
{[
{ key: 'home' as const, icon: '🏠', label: '홈' },
{ key: 'map' as const, icon: '🗺️', label: '지도' },
{ key: 'alert' as const, icon: '🔔', label: '알림' },
{ key: 'profile' as const, icon: '👤', label: '프로필' },
].map(n => (
<button
key={n.key}
type="button"
onClick={() => {
setMobileNav(n.key);
if (n.key === 'alert') setScreen('ai-detail');
}}
className={`flex flex-col items-center gap-0.5 px-3 py-0.5 rounded-lg transition-colors ${mobileNav === n.key ? 'text-blue-400' : 'text-hint'}`}
>
<span className="text-[14px]">{n.icon}</span>
<span className={`text-[7px] font-bold ${mobileNav === n.key ? 'text-blue-400' : 'text-hint'}`}>{n.label}</span>
</button>
))}
</div>
</div>
);
// 단속 이력 등록
if (screen === 'register') return (
<div className="flex flex-col h-full">
<div className="h-7 bg-secondary flex items-center px-3 gap-2 shrink-0">
<button type="button" onClick={() => setScreen('main')} className="text-hint hover:text-heading" title="뒤로가기"><ChevronLeft className="w-4 h-4" /></button>
<span className="text-[9px] text-heading font-medium"> </span>
</div>
{registered ? (
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-4">
<CheckCircle className="w-10 h-10 text-green-400" />
<span className="text-xs font-bold text-heading"> </span>
<span className="text-[9px] text-hint text-center"> </span>
<button type="button" onClick={() => setRegistered(false)} className="px-4 py-1.5 bg-blue-600 rounded-lg text-[9px] text-white font-bold"> </button>
</div>
) : (
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{/* 자동 입력 */}
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-2.5 space-y-1">
<div className="text-[8px] text-blue-400 font-bold flex items-center gap-1"><Clock className="w-2.5 h-2.5" /> </div>
<div className="grid grid-cols-2 gap-1 text-[8px]">
<div className="text-label flex items-center gap-1"><Calendar className="w-2.5 h-2.5 text-hint" />{regDateTime.replace('T', ' ')}</div>
<div className="text-label flex items-center gap-1"><MapPin className="w-2.5 h-2.5 text-hint" />{regLocation || '확인 중'}</div>
<div className="text-label flex items-center gap-1"><Building2 className="w-2.5 h-2.5 text-hint" />{user?.org || '해양경찰청'}</div>
<div className="text-label flex items-center gap-1"><User className="w-2.5 h-2.5 text-hint" />{user?.name || '-'}</div>
</div>
</div>
{/* 입력 필드 */}
<div className="space-y-2">
<div>
<div className="text-[8px] text-hint mb-0.5"> MMSI <span className="text-red-400">*</span></div>
<input type="text" placeholder="MMSI 입력" className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-2.5 py-1.5 text-[10px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/60" />
</div>
<div>
<div className="text-[8px] text-hint mb-0.5"></div>
<input type="text" placeholder="자동 연결" className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-2.5 py-1.5 text-[10px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/60" />
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-[8px] text-hint mb-0.5"> <span className="text-red-400">*</span></div>
<select className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-2 py-1.5 text-[10px] text-heading focus:outline-none appearance-none" title="위반 유형">
<option value=""></option><option></option><option></option><option></option><option>AIS </option><option> </option>
</select>
</div>
<div>
<div className="text-[8px] text-hint mb-0.5"> <span className="text-red-400">*</span></div>
<select className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-2 py-1.5 text-[10px] text-heading focus:outline-none appearance-none" title="조치 결과">
<option value=""></option><option></option><option></option><option></option><option></option>
</select>
</div>
</div>
</div>
{/* 증적 첨부 */}
<div className="grid grid-cols-3 gap-1.5">
{['사진', '영상', '조서'].map(l => (
<div key={l} className="border border-dashed border-slate-600 rounded-lg p-2.5 flex flex-col items-center gap-1 cursor-pointer hover:border-blue-500/50">
<Upload className="w-4 h-4 text-hint" />
<span className="text-[8px] text-hint">{l}</span>
</div>
))}
</div>
<button type="button" onClick={() => setRegistered(true)} className="w-full py-2.5 bg-blue-600 rounded-xl text-[10px] text-white font-bold"></button>
</div>
)}
</div>
);
// 주변 AI 탐지
return (
<div className="flex flex-col h-full">
<div className="h-7 bg-secondary flex items-center px-3 gap-2 shrink-0">
<button type="button" onClick={() => { setScreen('main'); setSelectedMarker(null); }} className="text-hint hover:text-heading"><ChevronLeft className="w-4 h-4" /></button>
<span className="text-[9px] text-heading font-medium"> AI </span>
<span className="text-[8px] text-red-400 font-bold ml-auto">{AI_MARKERS.filter(m => m.type !== '아군함정').length}</span>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1.5">
{AI_MARKERS.filter(m => m.type !== '아군함정').map(m => (
<div key={m.id}>
<button
type="button"
onClick={() => setSelectedMarker(selectedMarker?.id === m.id ? null : m)}
className={`w-full text-left rounded-lg p-2.5 border transition-colors ${
selectedMarker?.id === m.id ? 'border-blue-500/40 bg-blue-500/5' : 'border-border bg-card hover:bg-surface-overlay'
}`}
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5">
<span className={`px-1.5 py-0.5 rounded text-[8px] font-bold ${riskBadge(m.risk)}`}>
{m.risk === 'CRITICAL' ? '긴급' : m.risk === 'HIGH' ? '높음' : '주의'}
</span>
{typeIcon(m.type)}
<span className="text-[9px] text-heading font-medium">{m.type}</span>
</div>
<span className={`text-[9px] font-bold ${m.confidence >= 80 ? 'text-green-400' : 'text-yellow-400'}`}>{m.confidence}%</span>
</div>
<div className="text-[9px] text-heading">{m.name}</div>
<div className="text-[8px] text-hint mt-0.5">{m.lat.toFixed(2)}°N {m.lng.toFixed(2)}°E</div>
</button>
{selectedMarker?.id === m.id && (
<div className="mx-1 mt-1 p-2.5 bg-surface-overlay rounded-lg space-y-2">
<div className="text-[9px] text-label leading-relaxed">{m.detail}</div>
<div className="grid grid-cols-2 gap-1.5">
<button type="button" className="flex items-center justify-center gap-1 py-1.5 bg-blue-600 rounded-lg text-[9px] text-white font-medium">
<Navigation className="w-3 h-3" />
</button>
<button
type="button"
onClick={() => { setScreen('register'); setRegDateTime(nowLocalISO()); setRegistered(false); }}
className="flex items-center justify-center gap-1 py-1.5 bg-orange-600 rounded-lg text-[9px] text-white font-medium"
>
<Plus className="w-3 h-3" />
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
);
};
return (
<div className="p-5 space-y-4">
<div>
<h2 className="text-lg font-bold text-heading flex items-center gap-2">
<Smartphone className="w-5 h-5 text-blue-400" />{t('mobileService.title')}
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('mobileService.desc')}</p>
</div>
<div className="grid grid-cols-3 gap-4">
{/* ── 모바일 단말기 프리뷰 ── */}
<Card>
<CardContent className="p-4 flex justify-center">
<div className="w-[320px] h-[640px] bg-background border-2 border-slate-600 rounded-[32px] overflow-hidden relative shadow-2xl shadow-black/50">
{/* 노치 */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-24 h-5 bg-secondary rounded-b-xl z-10" />
{/* 컨텐츠 */}
<div className="h-full pt-5 pb-5">
{renderMobileContent()}
</div>
{/* 홈바 */}
<div className="absolute bottom-1.5 left-1/2 -translate-x-1/2 w-28 h-1 bg-slate-500 rounded-full" />
</div>
</CardContent>
</Card>
{/* ── 데스크톱 상세 영역 ── */}
<div className="col-span-2 space-y-3">
{/* 지도 (전체 마커 표시) */}
<Card>
<CardContent className="p-0 relative">
<BaseMap ref={detailMapRef} center={[33.0, 126.5]} zoom={8} height={340} className="rounded-lg overflow-hidden" forceTheme="light" onMapReady={onMobileMapReady} />
<div className="absolute top-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5 flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
<span className="text-[10px] text-heading font-medium"> </span>
<span className="text-[9px] text-hint">{regLocation || 'GPS 확인 중'}</span>
</div>
<div className="absolute top-3 right-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
<span className="text-[10px] text-red-400 font-bold">{AI_MARKERS.filter(m => m.type !== '아군함정').length}</span>
<span className="text-[9px] text-hint ml-1">AI </span>
</div>
{/* 범례 */}
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
<div className="space-y-0.5">
{[['bg-red-500', '불법선박 (긴급)'], ['bg-orange-500', 'Dark Vessel'], ['bg-yellow-500', '불법어구'], ['bg-purple-500', '아군함정']].map(([c, l]) => (
<div key={l} className="flex items-center gap-1.5"><div className={`w-2 h-2 rounded-full ${c}`} /><span className="text-[8px] text-muted-foreground">{l}</span></div>
))}
</div>
</div>
</CardContent>
</Card>
{/* AI 탐지 리스트 */}
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Radar className="w-4 h-4 text-purple-400" />
<span className="text-xs font-bold text-heading">AI </span>
</div>
<div className="grid grid-cols-2 gap-2">
{AI_MARKERS.filter(m => m.type !== '아군함정').map(m => (
<div key={m.id} className="flex items-center gap-3 p-3 bg-surface-overlay rounded-lg border border-border">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${riskBadge(m.risk)}`}>
{typeIcon(m.type)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-heading font-medium truncate">{m.name}</span>
<span className={`text-[9px] font-bold ${m.confidence >= 80 ? 'text-green-400' : 'text-yellow-400'}`}>{m.confidence}%</span>
</div>
<div className="text-[9px] text-hint">{m.type} · {m.lat.toFixed(2)}°N {m.lng.toFixed(2)}°E</div>
<div className="text-[8px] text-hint mt-0.5 truncate">{m.detail}</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}