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