kcg-ai-monitoring/frontend/src/features/surveillance/MapControl.tsx
htlee 2e66f920a5 feat: prediction 알고리즘 재설계 + 프론트 CRUD 권한 가드 보완
- prediction: dark_vessel 의심 점수화(8패턴 0~100), transshipment 베테랑 재설계
- prediction: vessel_store/scheduler/config 개선, monitoring_zones 데이터 추가
- prediction: signal_api 신규, diagnostic-snapshot 스크립트 추가
- frontend: 지도 레이어 구조 정리 (BaseMap, useMapLayers, static layers)
- frontend: NoticeManagement CRUD 권한 가드 추가 (admin:notices C/U/D)
- frontend: EventList CRUD 권한 가드 추가 (enforcement:event-list U, enforcement:enforcement-history C)
- frontend: 지도 페이지 6개 + Dashboard 등 4개 페이지 소폭 개선

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:01:35 +09:00

432 lines
27 KiB
TypeScript

import { useState, useRef, useCallback } from 'react';
import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { Map, Shield, Crosshair, AlertTriangle, Eye, Anchor, Ship, Filter, Layers, Target, Clock, MapPin, Bell, Navigation, Info } from 'lucide-react';
import { getTrainingZoneIntent, getTrainingZoneHex, getTrainingZoneMeta } from '@shared/constants/trainingZoneTypes';
import type { BadgeIntent } from '@lib/theme/variants';
/*
* 해역 통제 — 한국연안 해상사격 훈련구역도 (No.462) 반영
* Chart of Firing and Bombing Exercise Areas in the Coasts of Korea
* 출처: 국립해양조사원 (WGS-84)
*
* 구역 분류:
* - 해군 훈련 구역 (노란색)
* - 공군 훈련 구역 (분홍색)
* - 육군 훈련 구역 (초록색)
* - 국방과학연구소 훈련구역 (파란색)
* - 해양경찰청 훈련구역 (보라색)
*/
type Tab = 'overview' | 'navy' | 'airforce' | 'army' | 'add' | 'kcg' | 'ntm';
// ─── 훈련구역 데이터 ──────────────────────
interface TrainingZone {
id: string;
name: string;
type: string;
sea: string;
lat: string;
lng: string;
radius: string;
status: string;
schedule: string;
note: string;
[key: string]: unknown;
}
const NAVY_ZONES: TrainingZone[] = [
{ id: 'R-77', name: 'R-77', type: '해군', sea: '동해', lat: '38°20\'N', lng: '128°40\'E', radius: '20NM', status: '활성', schedule: '수시', note: '동해 북부 사격' },
{ id: 'R-80', name: 'R-80', type: '해군', sea: '서해', lat: '35°10\'N', lng: '125°30\'E', radius: '30NM', status: '활성', schedule: '수시', note: '서해 중부 사격' },
{ id: 'R-84', name: 'R-84', type: '해군', sea: '서해', lat: '34°00\'N~33°00\'N', lng: '125°00\'E~126°00\'E', radius: '대형', status: '활성', schedule: '수시', note: '서해 남부 대규모 사격구역' },
{ id: 'R-88', name: 'R-88', type: '해군', sea: '서해', lat: '35°30\'N', lng: '125°00\'E', radius: '15NM', status: '활성', schedule: '수시', note: '서해 황해 사격' },
{ id: 'R-99', name: 'R-99', type: '해군', sea: '남해', lat: '34°50\'N', lng: '128°40\'E', radius: '10NM', status: '활성', schedule: '주간', note: '남해 동부' },
{ id: 'R-100', name: 'R-100', type: '해군', sea: '남해', lat: '35°00\'N', lng: '129°10\'E', radius: '8NM', status: '활성', schedule: '주간', note: '부산 근해' },
{ id: 'R-104', name: 'R-104', type: '해군', sea: '서해', lat: '34°30\'N', lng: '125°20\'E', radius: '12NM', status: '활성', schedule: '수시', note: '고군산군도' },
{ id: 'R-105', name: 'R-105', type: '해군', sea: '서해', lat: '34°40\'N', lng: '125°10\'E', radius: '10NM', status: '활성', schedule: '수시', note: '서해 남서' },
{ id: 'R-107', name: 'R-107', type: '해군', sea: '동해', lat: '38°10\'N', lng: '129°50\'E', radius: '15NM', status: '활성', schedule: '수시', note: '동해 북부' },
{ id: 'R-115', name: 'R-115', type: '해군', sea: '동해', lat: '37°30\'N', lng: '130°00\'E', radius: '12NM', status: '활성', schedule: '주간', note: '동해 중부' },
{ id: 'R-117', name: 'R-117', type: '해군', sea: '남해', lat: '34°30\'N', lng: '127°30\'E', radius: '10NM', status: '비활성', schedule: '-', note: '여수 근해' },
{ id: 'R-118', name: 'R-118', type: '해군', sea: '남해', lat: '33°50\'N', lng: '128°20\'E', radius: '15NM', status: '활성', schedule: '수시', note: '남해 외해' },
{ id: 'R-119', name: 'R-119', type: '해군', sea: '동해', lat: '36°10\'N', lng: '129°40\'E', radius: '8NM', status: '활성', schedule: '야간', note: '울진 근해' },
{ id: 'R-120', name: 'R-120', type: '해군', sea: '동해', lat: '36°30\'N', lng: '130°10\'E', radius: '20NM', status: '활성', schedule: '수시', note: '동해 중부 외해' },
];
const AIR_ZONES: TrainingZone[] = [
{ id: 'R-108A', name: 'R-108A', type: '공군', sea: '서해', lat: '38°30\'N', lng: '124°30\'E', radius: '대형', status: '활성', schedule: '수시', note: '서해 북부 공중사격' },
{ id: 'R-108B', name: 'R-108B', type: '공군', sea: '서해', lat: '38°10\'N', lng: '124°20\'E', radius: '대형', status: '활성', schedule: '수시', note: '서해 북부' },
{ id: 'R-108C', name: 'R-108C', type: '공군', sea: '서해', lat: '37°00\'N', lng: '124°00\'E', radius: '대형', status: '활성', schedule: '수시', note: '서해 중부' },
{ id: 'R-108D', name: 'R-108D', type: '공군', sea: '서해', lat: '38°00\'N', lng: '124°50\'E', radius: '중형', status: '활성', schedule: '주간', note: '백령도 인근' },
{ id: 'R-108E', name: 'R-108E', type: '공군', sea: '서해', lat: '38°05\'N', lng: '124°40\'E', radius: '중형', status: '활성', schedule: '주간', note: '대청도 인근' },
{ id: 'R-108F', name: 'R-108F', type: '공군', sea: '서해', lat: '37°50\'N', lng: '124°30\'E', radius: '중형', status: '활성', schedule: '수시', note: '서해 5도' },
{ id: 'R-123', name: 'R-123', type: '공군', sea: '남해', lat: '34°20\'N', lng: '126°00\'E', radius: '15NM', status: '활성', schedule: '수시', note: '진도 남방' },
{ id: 'R-124', name: 'R-124', type: '공군', sea: '서해', lat: '35°40\'N', lng: '125°40\'E', radius: '10NM', status: '활성', schedule: '주간', note: '군산 서방' },
];
const ARMY_ZONES: TrainingZone[] = [
{ id: 'R-97A', name: 'R-97A', type: '육군', sea: '서해', lat: '34°20\'N', lng: '125°40\'E', radius: '5NM', status: '활성', schedule: '주간', note: '서해안 포사격' },
{ id: 'R-97B', name: 'R-97B', type: '육군', sea: '서해', lat: '34°25\'N', lng: '125°35\'E', radius: '5NM', status: '활성', schedule: '주간', note: '서해안 포사격' },
{ id: 'R-97C', name: 'R-97C', type: '육군', sea: '서해', lat: '34°00\'N', lng: '125°20\'E', radius: '8NM', status: '활성', schedule: '수시', note: '서해 남부' },
{ id: 'R-97D', name: 'R-97D', type: '육군', sea: '서해', lat: '34°05\'N', lng: '125°15\'E', radius: '5NM', status: '비활성', schedule: '-', note: '서해 남부' },
{ id: 'R-97E', name: 'R-97E', type: '육군', sea: '서해', lat: '34°30\'N', lng: '125°50\'E', radius: '5NM', status: '활성', schedule: '주간', note: '고군산 인근' },
{ id: 'R-97F', name: 'R-97F', type: '육군', sea: '서해', lat: '34°35\'N', lng: '125°45\'E', radius: '5NM', status: '활성', schedule: '주간', note: '서해안 포사격' },
];
const ADD_ZONES: TrainingZone[] = [
{ id: 'R-121', name: 'R-121', type: '국과연', sea: '동해', lat: '38°20\'N', lng: '128°50\'E', radius: '대형', status: '활성', schedule: '수시', note: '국방과학연구소 시험구역' },
{ id: 'R-125', name: 'R-125', type: '국과연', sea: '남해', lat: '34°20\'N', lng: '127°20\'E', radius: '10NM', status: '활성', schedule: '수시', note: '남해 시험' },
{ id: 'R-126', name: 'R-126', type: '국과연', sea: '제주', lat: '33°40\'N', lng: '126°30\'E', radius: '15NM', status: '활성', schedule: '수시', note: '제주 해협' },
{ id: 'R-128', name: 'R-128', type: '국과연', sea: '남해', lat: '32°50\'N', lng: '127°00\'E', radius: '대형', status: '활성', schedule: '수시', note: '남해 외해 시험' },
];
const KCG_ZONES: TrainingZone[] = [
{ id: 'R-131', name: 'R-131', type: '해경', sea: '서해', lat: '37°40\'N', lng: '125°20\'E', radius: '5NM', status: '활성', schedule: '주간', note: '해경 서해 훈련' },
{ id: 'R-132', name: 'R-132', type: '해경', sea: '서해', lat: '37°30\'N', lng: '125°50\'E', radius: '5NM', status: '활성', schedule: '주간', note: '해경 인천 훈련' },
{ id: 'R-133', name: 'R-133', type: '해경', sea: '남해', lat: '35°20\'N', lng: '126°20\'E', radius: '3NM', status: '활성', schedule: '주간', note: '해경 남해 훈련' },
{ id: 'R-134', name: 'R-134', type: '해경', sea: '남해', lat: '35°50\'N', lng: '125°50\'E', radius: '3NM', status: '활성', schedule: '주간', note: '해경 서남해' },
];
const ALL_ZONES = [...NAVY_ZONES, ...AIR_ZONES, ...ARMY_ZONES, ...ADD_ZONES, ...KCG_ZONES];
// ─── 항행통보 데이터 (국립해양조사원 NtM) ──────
interface NtmRecord {
no: string;
date: string;
category: string;
sea: string;
title: string;
position: string;
status: string;
detail: string;
[key: string]: unknown;
}
const NTM_DATA: NtmRecord[] = [
{ no: 'NTM-2026-0412', date: '2026-04-03', category: '사격훈련', sea: '서해', title: '서해 R-84 구역 사격훈련 실시', position: 'N34°00\' E125°30\'', status: '발령중', detail: '04-03 09:00~18:00 실탄사격 실시. 선박 진입 금지.' },
{ no: 'NTM-2026-0411', date: '2026-04-03', category: '사격훈련', sea: '동해', title: '동해 R-120 구역 해군 사격', position: 'N36°30\' E130°10\'', status: '발령중', detail: '04-03~04-05 종일 사격훈련. 반경 20NM 진입금지.' },
{ no: 'NTM-2026-0410', date: '2026-04-02', category: '기뢰제거', sea: '남해', title: '여수항 인근 기뢰 제거 작업', position: 'N34°44\' E127°46\'', status: '발령중', detail: '04-02~04-06 기뢰 탐색 및 제거. 반경 3NM 항행주의.' },
{ no: 'NTM-2026-0409', date: '2026-04-02', category: '해양공사', sea: '남해', title: '부산 신항 준설작업', position: 'N35°04\' E128°49\'', status: '발령중', detail: '04-01~04-30 야간 준설. 항행선박 감속 운항.' },
{ no: 'NTM-2026-0408', date: '2026-04-01', category: '항로표지', sea: '서해', title: '인천항 서수도 등부표 소등', position: 'N37°26\' E126°34\'', status: '발령중', detail: '등부표 고장으로 소등 중. 수리 완료 시까지 항행주의.' },
{ no: 'NTM-2026-0407', date: '2026-04-01', category: '사격훈련', sea: '서해', title: '서해 R-80 공군 폭격훈련', position: 'N35°10\' E125°30\'', status: '해제', detail: '04-01 훈련 완료. 해제됨.' },
{ no: 'NTM-2026-0406', date: '2026-03-31', category: '해양오염', sea: '남해', title: '통영 해역 유류유출 경보', position: 'N34°50\' E128°25\'', status: '해제', detail: '방제 완료. 03-31 18:00 해제.' },
{ no: 'NTM-2026-0405', date: '2026-03-30', category: '수중작업', sea: '동해', title: '포항 해저케이블 설치', position: 'N36°02\' E129°24\'', status: '발령중', detail: '03-28~04-15 해저케이블 부설. 닻 투하 금지.' },
{ no: 'NTM-2026-0404', date: '2026-03-29', category: '사격훈련', sea: '동해', title: '동해 R-77 해군 사격', position: 'N38°20\' E128°40\'', status: '해제', detail: '03-29 훈련 완료.' },
{ no: 'NTM-2026-0403', date: '2026-03-28', category: '항로변경', sea: '서해', title: '평택항 입항항로 임시변경', position: 'N36°58\' E126°49\'', status: '발령중', detail: '항로 표지 공사로 임시 우회항로 지정 (~04-10).' },
{ no: 'NTM-2026-0402', date: '2026-03-27', category: '군사훈련', sea: '서해', title: '서해 R-108A 공군 훈련', position: 'N38°30\' E124°30\'', status: '해제', detail: '03-27 훈련 완료.' },
{ no: 'NTM-2026-0401', date: '2026-03-25', category: '사격훈련', sea: '서해', title: '서해 5도 해역 경비함정 훈련', position: 'N37°45\' E124°50\'', status: '해제', detail: '해경 해상사격 완료.' },
];
const NTM_CATEGORIES = ['전체', '사격훈련', '군사훈련', '기뢰제거', '해양공사', '항로표지', '항로변경', '해양오염', '수중작업'];
const ntmColumns: DataColumn<NtmRecord>[] = [
{ key: 'no', label: '통보번호', width: '120px', sortable: true, render: v => <span className="text-cyan-400 font-mono font-bold text-[10px]">{v as string}</span> },
{ key: 'date', label: '발령일', width: '90px', sortable: true, render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
{ key: 'category', label: '구분', width: '70px', align: 'center', sortable: true,
render: v => {
const c = v as string;
const intent: BadgeIntent = c.includes('사격') || c.includes('군사') ? 'critical'
: c.includes('기뢰') ? 'high'
: c.includes('오염') ? 'warning'
: 'info';
return <Badge intent={intent} size="xs">{c}</Badge>;
},
},
{ key: 'sea', label: '해역', width: '50px', sortable: true },
{ key: 'title', label: '제목', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> },
{ key: 'position', label: '위치', width: '120px', render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
render: v => <Badge intent={v === '발령중' ? 'critical' : 'muted'} size="xs">{v as string}</Badge> },
];
// 훈련구역 색상은 trainingZoneTypes 카탈로그에서 lookup
const columns: DataColumn<TrainingZone>[] = [
{ key: 'id', label: '구역번호', width: '80px', sortable: true, render: v => <span className="text-cyan-400 font-mono font-bold">{v as string}</span> },
{ key: 'type', label: '구분', width: '60px', align: 'center', sortable: true,
render: v => <Badge intent={getTrainingZoneIntent(v as string)} size="sm">{v as string}</Badge> },
{ key: 'sea', label: '해역', width: '60px', sortable: true },
{ key: 'lat', label: '위도', width: '110px', render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
{ key: 'lng', label: '경도', width: '110px', render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
{ key: 'radius', label: '반경', width: '60px', align: 'center' },
{ key: 'status', label: '상태', width: '60px', align: 'center', sortable: true,
render: v => <Badge intent={v === '활성' ? 'success' : 'muted'} size="xs">{v as string}</Badge> },
{ key: 'schedule', label: '운용', width: '60px', align: 'center' },
{ key: 'note', label: '비고', render: v => <span className="text-hint">{v as string}</span> },
];
// DMS 좌표 → 십진수 변환
function parseDMS(dms: string): number | null {
// "38°20'N" → 38.333... / "34°00'N~33°00'N" → 중간값
if (dms.includes('~')) {
const parts = dms.split('~');
const a = parseDMS(parts[0]);
const b = parseDMS(parts[1]);
if (a !== null && b !== null) return (a + b) / 2;
return a;
}
const m = dms.match(/(\d+)°(\d+)?'?([NSEW])?/);
if (!m) return null;
let val = parseInt(m[1]) + (parseInt(m[2] || '0') / 60);
if (m[3] === 'S' || m[3] === 'W') val = -val;
return val;
}
// 반경 문자열 → 미터 변환 (1NM ≈ 1852m)
function parseRadius(r: string): number {
const nm = r.match(/(\d+)\s*NM/i);
if (nm) return parseInt(nm[1]) * 1852;
if (r === '대형') return 40000;
if (r === '중형') return 25000;
return 15000;
}
export function MapControl() {
const mapRef = useRef<MapHandle>(null);
const [tab, setTab] = useState<Tab>('overview');
const [seaFilter, setSeaFilter] = useState('');
const [ntmCatFilter, setNtmCatFilter] = useState('');
const getZones = () => {
const zones = tab === 'navy' ? NAVY_ZONES : tab === 'airforce' ? AIR_ZONES : tab === 'army' ? ARMY_ZONES : tab === 'add' ? ADD_ZONES : tab === 'kcg' ? KCG_ZONES : ALL_ZONES;
return seaFilter ? zones.filter(z => z.sea === seaFilter) : zones;
};
const activeCount = ALL_ZONES.filter(z => z.status === '활성').length;
// 현재 표시할 구역
const visibleZones = getZones();
const buildLayers = useCallback(() => {
// 훈련구역 원 + 중심 마커
const parsedZones: { lat: number; lng: number; color: string; radiusM: number; isActive: boolean; zone: TrainingZone }[] = [];
visibleZones.forEach((z) => {
const lat = parseDMS(z.lat);
const lng = parseDMS(z.lng);
if (lat === null || lng === null) return;
const color = getTrainingZoneHex(z.type);
const radiusM = parseRadius(z.radius);
const isActive = z.status === '활성';
parsedZones.push({ lat, lng, color, radiusM, isActive, zone: z });
});
// 활성/비활성 구역을 opacity별로 분리
const activeZones = parsedZones.filter(pz => pz.isActive);
const inactiveZones = parsedZones.filter(pz => !pz.isActive);
// 중심점 마커
const centerMarkers = parsedZones.map((pz) => ({
lat: pz.lat,
lng: pz.lng,
color: pz.color,
radius: 600,
label: pz.zone.id,
}));
return [
...createStaticLayers(),
createRadiusLayer(
'zone-circles-active',
activeZones.map(pz => ({ lat: pz.lat, lng: pz.lng, radius: pz.radiusM, color: pz.color })),
0.15,
),
createRadiusLayer(
'zone-circles-inactive',
inactiveZones.map(pz => ({ lat: pz.lat, lng: pz.lng, radius: pz.radiusM, color: pz.color })),
0.05,
),
createMarkerLayer('zone-centers', centerMarkers),
];
}, [visibleZones]);
useMapLayers(mapRef, buildLayers, [visibleZones]);
return (
<PageContainer>
<PageHeader
icon={Map}
iconColor="text-cyan-400"
title="해역 통제"
description="한국연안 해상사격 훈련구역도 No.462 | Chart of Firing and Bombing Exercise Areas | WGS-84 | 출처: 국립해양조사원"
demo
/>
{/* KPI */}
<div className="flex gap-2">
{[
{ label: '전체 구역', value: ALL_ZONES.length, color: 'text-heading' },
{ label: '활성', value: activeCount, color: 'text-green-400' },
{ label: '해군', value: NAVY_ZONES.length, color: 'text-yellow-400' },
{ label: '공군', value: AIR_ZONES.length, color: 'text-pink-400' },
{ label: '육군', value: ARMY_ZONES.length, color: 'text-green-400' },
{ label: '국과연', value: ADD_ZONES.length, color: 'text-blue-400' },
{ label: '해경', value: KCG_ZONES.length, color: 'text-purple-400' },
].map(k => (
<div key={k.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<span className={`text-base font-bold ${k.color}`}>{k.value}</span>
<span className="text-[9px] text-hint">{k.label}</span>
</div>
))}
</div>
{/* 범례 */}
<div className="flex items-center gap-4 px-4 py-2 rounded-xl border border-border bg-card">
<span className="text-[10px] text-hint font-bold">:</span>
{(['해군', '공군', '육군', '국과연', '해경'] as const).map((type) => {
const meta = getTrainingZoneMeta(type);
if (!meta) return null;
return (
<div key={type} className="flex items-center gap-1.5">
<div className="w-4 h-3 rounded-sm" style={{ backgroundColor: meta.hex, opacity: 0.6 }} />
<span className="text-[10px] text-muted-foreground">{meta.fallback.ko}</span>
</div>
);
})}
</div>
{/* 탭 + 해역 필터 */}
<div className="flex items-center gap-3">
<div className="flex gap-0 border-b border-border">
{([
{ key: 'overview' as Tab, label: '전체', icon: Layers },
{ key: 'navy' as Tab, label: '해군', icon: Ship },
{ key: 'airforce' as Tab, label: '공군', icon: Target },
{ key: 'army' as Tab, label: '육군', icon: Crosshair },
{ key: 'add' as Tab, label: '국과연', icon: Shield },
{ key: 'kcg' as Tab, label: '해경', icon: Anchor },
{ key: 'ntm' as Tab, label: '항행통보', icon: Bell },
]).map(t => (
<button type="button" key={t.key} onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2 text-[11px] font-medium border-b-2 ${tab === t.key ? 'text-cyan-400 border-cyan-400' : 'text-hint border-transparent hover:text-label'}`}>
<t.icon className="w-3.5 h-3.5" />{t.label}
</button>
))}
</div>
<div className="flex items-center gap-1.5 ml-auto">
<Filter className="w-3.5 h-3.5 text-hint" />
{['', '서해', '남해', '동해', '제주'].map(s => (
<button type="button" key={s} onClick={() => setSeaFilter(s)}
className={`px-2.5 py-1 rounded text-[10px] ${seaFilter === s ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>
{s || '전체'}
</button>
))}
</div>
</div>
{/* ── 항행통보 탭 ── */}
{tab === 'ntm' && (
<div className="space-y-3">
{/* 항행통보 KPI */}
<div className="flex gap-2">
{[
{ label: '전체 통보', value: NTM_DATA.length, color: 'text-heading' },
{ label: '발령중', value: NTM_DATA.filter(n => n.status === '발령중').length, color: 'text-red-400' },
{ label: '해제', value: NTM_DATA.filter(n => n.status === '해제').length, color: 'text-muted-foreground' },
{ label: '사격훈련', value: NTM_DATA.filter(n => n.category.includes('사격')).length, color: 'text-orange-400' },
].map(k => (
<div key={k.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<span className={`text-base font-bold ${k.color}`}>{k.value}</span>
<span className="text-[9px] text-hint">{k.label}</span>
</div>
))}
</div>
{/* 카테고리 필터 */}
<div className="flex items-center gap-1.5 px-4 py-2 rounded-xl border border-border bg-card">
<Filter className="w-3.5 h-3.5 text-hint" />
<span className="text-[10px] text-hint">:</span>
{NTM_CATEGORIES.map(c => (
<button type="button" key={c} onClick={() => setNtmCatFilter(c === '전체' ? '' : c)}
className={`px-2.5 py-1 rounded text-[10px] ${(c === '전체' && !ntmCatFilter) || ntmCatFilter === c ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>{c}</button>
))}
</div>
{/* 최근 발령 중 통보 하이라이트 */}
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
<AlertTriangle className="w-4 h-4 text-red-400" />
</div>
<div className="space-y-2">
{NTM_DATA.filter(n => n.status === '발령중').map(n => (
<div key={n.no} className="flex items-start gap-3 px-3 py-2.5 bg-red-500/5 border border-red-500/10 rounded-lg">
<Badge intent="critical" size="sm" className="shrink-0 mt-0.5">{n.category}</Badge>
<div className="flex-1 min-w-0">
<div className="text-[11px] text-heading font-medium">{n.title}</div>
<div className="text-[10px] text-hint mt-0.5">{n.detail}</div>
</div>
<div className="text-right shrink-0">
<div className="text-[10px] text-muted-foreground font-mono">{n.position}</div>
<div className="text-[9px] text-hint mt-0.5">{n.date}</div>
</div>
</div>
))}
</div>
</CardContent></Card>
{/* 전체 통보 DataTable */}
<DataTable
data={ntmCatFilter ? NTM_DATA.filter(n => n.category === ntmCatFilter) : NTM_DATA}
columns={ntmColumns}
pageSize={10}
searchPlaceholder="통보번호, 제목, 해역 검색..."
searchKeys={['no', 'title', 'sea', 'category', 'position']}
exportFilename="항행통보"
/>
<div className="text-[9px] text-hint text-right">출처: 국립해양조사원 (https://www.khoa.go.kr/nwb)</div>
</div>
)}
{/* 훈련구역 시각화 맵 (간략) — ntm 탭이 아닐 때 */}
{/* 훈련구역 (ntm 탭이 아닐 때만) */}
{tab !== 'ntm' && (
<>
<Card>
<CardContent className="p-0 relative">
<BaseMap ref={mapRef} key={`${tab}-${seaFilter}`} center={[35.8, 127.5]} zoom={7} height={480} className="w-full rounded-lg overflow-hidden" />
{/* 범례 오버레이 */}
<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="text-[9px] text-muted-foreground font-bold mb-1.5"> </div>
<div className="space-y-1">
{(['해군', '공군', '육군', '국과연', '해경'] as const).map((type) => {
const meta = getTrainingZoneMeta(type);
if (!meta) return null;
return (
<div key={type} className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full border" style={{ backgroundColor: meta.hex, borderColor: meta.hex, opacity: 0.7 }} />
<span className="text-[9px] text-muted-foreground">{meta.fallback.ko}</span>
</div>
);
})}
</div>
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
<div className="flex items-center gap-1"><div className="w-4 h-0 border-t-2 border-dashed border-red-500/50" /><span className="text-[8px] text-hint">EEZ</span></div>
<div className="flex items-center gap-1"><div className="w-4 h-0 border-t-2 border-dashed border-orange-500/70" /><span className="text-[8px] text-hint">NLL</span></div>
</div>
<div className="text-[7px] text-hint mt-1"> </div>
</div>
{/* 표시 구역 수 */}
<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">
<span className="text-[10px] text-cyan-400 font-bold">{visibleZones.length}</span>
<span className="text-[9px] text-hint ml-1"> </span>
</div>
</CardContent>
</Card>
<DataTable
data={getZones()}
columns={columns}
pageSize={12}
searchPlaceholder="구역번호, 해역, 비고 검색..."
searchKeys={['id', 'name', 'sea', 'note', 'type']}
exportFilename="해상사격훈련구역"
/>
</>
)}
</PageContainer>
);
}