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[] = [ { key: 'no', label: '통보번호', width: '120px', sortable: true, render: v => {v as string} }, { key: 'date', label: '발령일', width: '90px', sortable: true, render: v => {v as string} }, { 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 {c}; }, }, { key: 'sea', label: '해역', width: '50px', sortable: true }, { key: 'title', label: '제목', sortable: true, render: v => {v as string} }, { key: 'position', label: '위치', width: '120px', render: v => {v as string} }, { key: 'status', label: '상태', width: '70px', align: 'center', sortable: true, render: v => {v as string} }, ]; // 훈련구역 색상은 trainingZoneTypes 카탈로그에서 lookup const columns: DataColumn[] = [ { key: 'id', label: '구역번호', width: '80px', sortable: true, render: v => {v as string} }, { key: 'type', label: '구분', width: '60px', align: 'center', sortable: true, render: v => {v as string} }, { key: 'sea', label: '해역', width: '60px', sortable: true }, { key: 'lat', label: '위도', width: '110px', render: v => {v as string} }, { key: 'lng', label: '경도', width: '110px', render: v => {v as string} }, { key: 'radius', label: '반경', width: '60px', align: 'center' }, { key: 'status', label: '상태', width: '60px', align: 'center', sortable: true, render: v => {v as string} }, { key: 'schedule', label: '운용', width: '60px', align: 'center' }, { key: 'note', label: '비고', render: v => {v as string} }, ]; // 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(null); const [tab, setTab] = useState('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 ( {/* KPI */}
{[ { 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 => (
{k.value} {k.label}
))}
{/* 범례 */}
범례: {(['해군', '공군', '육군', '국과연', '해경'] as const).map((type) => { const meta = getTrainingZoneMeta(type); if (!meta) return null; return (
{meta.fallback.ko}
); })}
{/* 탭 + 해역 필터 */}
{([ { 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 => ( ))}
{['', '서해', '남해', '동해', '제주'].map(s => ( ))}
{/* ── 항행통보 탭 ── */} {tab === 'ntm' && (
{/* 항행통보 KPI */}
{[ { 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 => (
{k.value} {k.label}
))}
{/* 카테고리 필터 */}
구분: {NTM_CATEGORIES.map(c => ( ))}
{/* 최근 발령 중 통보 하이라이트 */}
현재 발령 중 항행통보
{NTM_DATA.filter(n => n.status === '발령중').map(n => (
{n.category}
{n.title}
{n.detail}
{n.position}
{n.date}
))}
{/* 전체 통보 DataTable */} n.category === ntmCatFilter) : NTM_DATA} columns={ntmColumns} pageSize={10} searchPlaceholder="통보번호, 제목, 해역 검색..." searchKeys={['no', 'title', 'sea', 'category', 'position']} exportFilename="항행통보" />
출처: 국립해양조사원 항행통보 (https://www.khoa.go.kr/nwb)
)} {/* 훈련구역 시각화 맵 (간략) — ntm 탭이 아닐 때 */} {/* 훈련구역 (ntm 탭이 아닐 때만) */} {tab !== 'ntm' && ( <> {/* 범례 오버레이 */}
훈련구역 범례
{(['해군', '공군', '육군', '국과연', '해경'] as const).map((type) => { const meta = getTrainingZoneMeta(type); if (!meta) return null; return (
{meta.fallback.ko}
); })}
EEZ
NLL
구역 클릭 시 상세정보 표시
{/* 표시 구역 수 */}
{visibleZones.length}개 훈련구역 표시 중
)} ); }