- 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>
475 lines
27 KiB
TypeScript
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>
|
|
);
|
|
}
|