diff --git a/database/migration/022_aerial_spectral_perm.sql b/database/migration/022_aerial_spectral_perm.sql new file mode 100644 index 0000000..f7790ee --- /dev/null +++ b/database/migration/022_aerial_spectral_perm.sql @@ -0,0 +1,19 @@ +-- aerial:spectral (AI 탐지/분석) 서브탭 권한 추가 +-- 기존 aerial 서브탭(satellite) 뒤, cctv 앞에 배치 (SORT_ORD = 6) + +-- 기존 cctv, theory 순서 밀기 +UPDATE AUTH_PERM_TREE SET SORT_ORD = 7 WHERE RSRC_CD = 'aerial:cctv'; +UPDATE AUTH_PERM_TREE SET SORT_ORD = 8 WHERE RSRC_CD = 'aerial:theory'; + +-- spectral 리소스 추가 +INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) +VALUES ('aerial:spectral', 'aerial', 'AI 탐지/분석', 1, 6) +ON CONFLICT (RSRC_CD) DO NOTHING; + +-- 기존 역할에 spectral READ 권한 부여 (aerial READ 권한이 있는 역할) +INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, USE_YN) +SELECT ap.ROLE_SN, 'aerial:spectral', ap.OPER_CD, ap.USE_YN +FROM AUTH_PERM ap +WHERE ap.RSRC_CD = 'aerial' + AND ap.USE_YN = 'Y' +ON CONFLICT (ROLE_SN, RSRC_CD, OPER_CD) DO NOTHING; diff --git a/frontend/src/common/hooks/useSubMenu.ts b/frontend/src/common/hooks/useSubMenu.ts index 6b1baae..f84b672 100755 --- a/frontend/src/common/hooks/useSubMenu.ts +++ b/frontend/src/common/hooks/useSubMenu.ts @@ -40,9 +40,10 @@ const subMenuConfigs: Record = { { id: 'media', label: '영상사진관리', icon: '📷' }, { id: 'analysis', label: '유출유면적분석', icon: '🧩' }, { id: 'realtime', label: '실시간드론', icon: '🛸' }, - { id: 'sensor', label: '오염/선박3D분석', icon: '🔍' }, - { id: 'satellite', label: '위성요청', icon: '🛰' }, + { id: 'satellite', label: '위성영상', icon: '🛰' }, { id: 'cctv', label: 'CCTV 조회', icon: '📹' }, + { id: 'spectral', label: 'AI 탐지/분석', icon: '🤖' }, + { id: 'sensor', label: '오염/선박3D분석', icon: '🔍' }, { id: 'theory', label: '항공탐색 이론', icon: '📐' } ], assets: null, diff --git a/frontend/src/tabs/aerial/components/AerialView.tsx b/frontend/src/tabs/aerial/components/AerialView.tsx index 1688a40..7fe6779 100755 --- a/frontend/src/tabs/aerial/components/AerialView.tsx +++ b/frontend/src/tabs/aerial/components/AerialView.tsx @@ -5,6 +5,7 @@ import { OilAreaAnalysis } from './OilAreaAnalysis' import { RealtimeDrone } from './RealtimeDrone' import { SensorAnalysis } from './SensorAnalysis' import { SatelliteRequest } from './SatelliteRequest' +import { WingAI } from './WingAI' import { CctvView } from './CctvView' export function AerialView() { @@ -16,6 +17,8 @@ export function AerialView() { return case 'satellite': return + case 'spectral': + return case 'cctv': return case 'analysis': diff --git a/frontend/src/tabs/aerial/components/SatelliteRequest.tsx b/frontend/src/tabs/aerial/components/SatelliteRequest.tsx index bbcf9c2..d5fe829 100644 --- a/frontend/src/tabs/aerial/components/SatelliteRequest.tsx +++ b/frontend/src/tabs/aerial/components/SatelliteRequest.tsx @@ -115,6 +115,9 @@ export function SatelliteRequest() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` }) const [mapSelectedItem, setMapSelectedItem] = useState(null) + const satImgOpacity = 90 + const satImgBrightness = 100 + const satShowOverlay = true const modalRef = useRef(null) @@ -436,26 +439,31 @@ export function SatelliteRequest() { ) })} - {/* 선택된 완료 항목: VWorld 위성 영상 오버레이 */} - {mapSelectedItem && mapSelectedItem.status === '완료' && VWORLD_API_KEY && ( + {/* 선택된 완료 항목: VWorld 위성 영상 오버레이 (BlackSky 스타일) */} + {mapSelectedItem && mapSelectedItem.status === '완료' && satShowOverlay && VWORLD_API_KEY && ( - + )} - {/* 선택된 항목 마커 + 팝업 */} + {/* 선택된 항목 마커 */} {mapSelectedItem && (() => { const coord = parseCoord(mapSelectedItem.zoneCoord) if (!coord) return null return ( - -
-
📷
+ +
+
+
) diff --git a/frontend/src/tabs/aerial/components/WingAI.tsx b/frontend/src/tabs/aerial/components/WingAI.tsx new file mode 100644 index 0000000..4b55647 --- /dev/null +++ b/frontend/src/tabs/aerial/components/WingAI.tsx @@ -0,0 +1,1137 @@ +import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; +import { Map, useControl } from '@vis.gl/react-maplibre'; +import { MapboxOverlay } from '@deck.gl/mapbox'; +import { PolygonLayer, ScatterplotLayer } from '@deck.gl/layers'; +import type { StyleSpecification, MapMouseEvent } from 'maplibre-gl'; +import 'maplibre-gl/dist/maplibre-gl.css'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function DeckGLOverlay({ layers }: { layers: any[] }) { + const overlay = useControl(() => new MapboxOverlay({ interleaved: true })); + overlay.setProps({ layers }); + return null; +} + +const AOI_MAP_STYLE: StyleSpecification = { + version: 8, + sources: { + 'carto-dark': { + type: 'raster', + tiles: [ + 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + ], + tileSize: 256, + }, + }, + layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 }], +}; + +type WingAITab = 'detect' | 'change' | 'aoi'; + +const tabItems: { id: WingAITab; label: string; icon: string; desc: string }[] = [ + { id: 'detect', label: '객체 탐지', icon: '🎯', desc: '위성/드론 영상에서 선박·차량·시설물 자동 탐지 및 분류' }, + { id: 'change', label: '변화 감지', icon: '🔄', desc: '동일 지역 다시점 영상 비교 분석 (Before/After)' }, + { id: 'aoi', label: '연안자동감지', icon: '📍', desc: '연안 관심지역 등록 → 변화 자동 감지 및 알림' }, +]; + +export function WingAI() { + const [activeTab, setActiveTab] = useState('detect'); + + return ( +
+ {/* 헤더 */} +
+
+
+ 🤖 +
+
AI 탐지/분석
+ WingAI +
+
+ {tabItems.map((t) => ( + + ))} +
+
+ + {/* 탭 콘텐츠 */} + {activeTab === 'detect' && } + {activeTab === 'change' && } + {activeTab === 'aoi' && } +
+ ); +} + +/* ─── 객체 탐지 패널 (MMSI 선종 불일치 탐지) ─────────── */ +type MismatchStatus = '불일치' | '의심' | '정상' | '확인중'; + +interface VesselDetection { + id: string; + mmsi: string; + vesselName: string; + /** AIS 등록 선종 */ + aisType: string; + /** AI 영상 분석 선종 */ + detectedType: string; + /** 불일치 여부 */ + mismatch: boolean; + status: MismatchStatus; + confidence: string; + coord: string; + lon: number; + lat: number; + time: string; + detail: string; +} + +function DetectPanel() { + const [selectedId, setSelectedId] = useState(null); + const [filterStatus, setFilterStatus] = useState('전체'); + + const detections: VesselDetection[] = [ + { id: 'VD-001', mmsi: '440123456', vesselName: 'OCEAN GLORY', aisType: '화물선', detectedType: '유조선', mismatch: true, status: '불일치', confidence: '94.2%', coord: '33.24°N 126.50°E', lon: 126.50, lat: 33.24, time: '14:23', detail: 'AIS 화물선 등록 → 영상 분석 결과 유조선 선형 + 탱크 구조 탐지' }, + { id: 'VD-002', mmsi: '441987654', vesselName: 'SEA PHOENIX', aisType: '유조선', detectedType: '화물선', mismatch: true, status: '불일치', confidence: '91.7%', coord: '34.73°N 127.68°E', lon: 127.68, lat: 34.73, time: '14:18', detail: 'AIS 유조선 등록 → 영상 분석 결과 컨테이너 적재 확인, 화물선 판정' }, + { id: 'VD-003', mmsi: '440555123', vesselName: 'DONGBANG 7', aisType: '어선', detectedType: '화물선', mismatch: true, status: '의심', confidence: '78.3%', coord: '35.15°N 129.13°E', lon: 129.13, lat: 35.15, time: '14:10', detail: 'AIS 어선 등록 → 선체 규모 및 구조가 어선 대비 과대, 화물선 의심' }, + { id: 'VD-004', mmsi: '440678901', vesselName: 'KOREA STAR', aisType: '화물선', detectedType: '화물선', mismatch: false, status: '정상', confidence: '97.8%', coord: '34.80°N 126.37°E', lon: 126.37, lat: 34.80, time: '14:05', detail: 'AIS 등록 선종과 영상 분석 결과 일치' }, + { id: 'VD-005', mmsi: 'N/A', vesselName: '미식별', aisType: 'AIS 미수신', detectedType: '유조선', mismatch: true, status: '확인중', confidence: '85.6%', coord: '33.11°N 126.27°E', lon: 126.27, lat: 33.11, time: '14:01', detail: 'AIS 신호 없음 → 위성 SAR로 유조선급 선형 탐지, 불법 운항 의심' }, + { id: 'VD-006', mmsi: '440234567', vesselName: 'BUSAN EXPRESS', aisType: '컨테이너선', detectedType: '유조선', mismatch: true, status: '불일치', confidence: '89.1%', coord: '35.05°N 129.10°E', lon: 129.10, lat: 35.05, time: '13:55', detail: 'AIS 컨테이너선 → 갑판 컨테이너 미확인, 탱크 구조 감지' }, + { id: 'VD-007', mmsi: '440345678', vesselName: 'JEJU BREEZE', aisType: '여객선', detectedType: '여객선', mismatch: false, status: '정상', confidence: '98.1%', coord: '33.49°N 126.52°E', lon: 126.52, lat: 33.49, time: '13:50', detail: 'AIS 등록 선종과 영상 분석 결과 일치' }, + ]; + + const mismatchCount = detections.filter((d) => d.mismatch).length; + const confirmingCount = detections.filter((d) => d.status === '확인중').length; + + const stats = [ + { value: String(detections.length), label: '분석 선박', color: '#3b82f6' }, + { value: String(mismatchCount), label: '선종 불일치', color: '#ef4444' }, + { value: String(confirmingCount), label: '확인 중', color: '#eab308' }, + { value: String(detections.filter((d) => !d.mismatch).length), label: '정상', color: '#22c55e' }, + ]; + + const filtered = filterStatus === '전체' ? detections : detections.filter((d) => d.status === filterStatus); + + const statusStyle = (s: MismatchStatus) => { + if (s === '불일치') return { background: 'rgba(239,68,68,.12)', color: '#ef4444', border: '1px solid rgba(239,68,68,.25)' }; + if (s === '의심') return { background: 'rgba(234,179,8,.12)', color: '#eab308', border: '1px solid rgba(234,179,8,.25)' }; + if (s === '확인중') return { background: 'rgba(168,85,247,.12)', color: '#a855f7', border: '1px solid rgba(168,85,247,.25)' }; + return { background: 'rgba(34,197,94,.12)', color: '#22c55e', border: '1px solid rgba(34,197,94,.25)' }; + }; + + const filters: (MismatchStatus | '전체')[] = ['전체', '불일치', '의심', '확인중', '정상']; + + return ( +
+ {/* 통계 카드 */} +
+ {stats.map((s, i) => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+ +
+ {/* 탐지 결과 지도 */} +
+
+
🎯 선종 불일치 탐지 지도
+
{filtered.length}척 표시
+
+
+ + [d.lon, d.lat], + getRadius: (d: VesselDetection) => selectedId === d.id ? 10 : 7, + radiusUnits: 'pixels' as const, + getFillColor: (d: VesselDetection) => { + if (d.status === '불일치') return [239, 68, 68, 200]; + if (d.status === '의심') return [234, 179, 8, 200]; + if (d.status === '확인중') return [168, 85, 247, 200]; + return [34, 197, 94, 160]; + }, + getLineColor: [255, 255, 255, 255], + lineWidthMinPixels: 2, + stroked: true, + pickable: true, + onClick: ({ object }: { object: VesselDetection }) => { + if (object) setSelectedId(object.id === selectedId ? null : object.id); + }, + updateTriggers: { getRadius: [selectedId] }, + }), + ]} /> + + {/* 범례 */} +
+
+ {[ + { color: '#ef4444', label: '불일치' }, + { color: '#eab308', label: '의심' }, + { color: '#a855f7', label: '확인중' }, + { color: '#22c55e', label: '정상' }, + ].map((l) => ( +
+
+ {l.label} +
+ ))} +
+
+
+
+ + {/* 탐지 목록 */} +
+
+
+
📋 MMSI 선종 검증 목록
+
{filtered.length}건
+
+
+ {filters.map((f) => ( + + ))} +
+
+
+ {filtered.map((d) => ( +
setSelectedId(selectedId === d.id ? null : d.id)} + className="px-4 py-3 hover:bg-bg-hover/30 transition-colors cursor-pointer" + style={{ background: selectedId === d.id ? 'rgba(168,85,247,.04)' : undefined }} + > +
+
+ {d.id} + {d.vesselName} +
+ + {d.status} + +
+ {/* 선종 비교 */} +
+ + AIS: {d.aisType} + + {d.mismatch && } + {!d.mismatch && =} + + AI: {d.detectedType} + +
+
+ MMSI {d.mmsi} + {d.coord} + {d.time} + 신뢰도 {d.confidence} +
+ {/* 펼침: 상세 */} + {selectedId === d.id && ( +
+ {d.detail} +
+ )} +
+ ))} +
+
+
+
+ ); +} + +/* ─── 변화 감지 패널 (복합 정보원 시점 비교) ──────────── */ +type SourceType = 'satellite' | 'cctv' | 'drone' | 'ais'; + +interface SourceConfig { + id: SourceType; + label: string; + icon: string; + color: string; + desc: string; +} + +const SOURCES: SourceConfig[] = [ + { id: 'satellite', label: '위성영상', icon: '🛰', color: '#a855f7', desc: 'KOMPSAT-3A / Sentinel SAR 수신 영상' }, + { id: 'cctv', label: 'CCTV', icon: '📹', color: '#3b82f6', desc: 'KHOA / KBS 해안 CCTV 스냅샷' }, + { id: 'drone', label: '드론', icon: '🛸', color: '#22c55e', desc: '정밀 촬영 / 열화상 이미지' }, + { id: 'ais', label: 'AIS', icon: '🚢', color: '#f59e0b', desc: '선박 위치·항적·MMSI 궤적' }, +]; + +interface ChangeRecord { + id: string; + area: string; + type: string; + date1: string; + time1: string; + date2: string; + time2: string; + severity: '심각' | '보통' | '낮음'; + detail: string; + sources: SourceType[]; + crossRef?: string; + /** 각 정보원별 AS-IS 시점 요약 */ + asIsDetail: Partial>; + /** 각 정보원별 현재 시점 요약 */ + nowDetail: Partial>; +} + +function ChangeDetectPanel() { + const [layers, setLayers] = useState>({ + satellite: true, cctv: true, drone: true, ais: true, + }); + const [sourceFilter, setSourceFilter] = useState('all'); + const [selectedChange, setSelectedChange] = useState(null); + + const toggleLayer = (id: SourceType) => setLayers((prev) => ({ ...prev, [id]: !prev[id] })); + const activeCount = Object.values(layers).filter(Boolean).length; + + const changes: ChangeRecord[] = [ + { + id: 'CHG-001', area: '여수항 북측 해안', type: '선박 이동', + date1: '03-14', time1: '14:00', date2: '03-16', time2: '14:23', + severity: '보통', detail: '정박 선박 3척 → 7척 (증가)', + sources: ['satellite', 'ais', 'cctv'], + crossRef: 'AIS MMSI 440123456 외 3척 신규 입항 — 위성+CCTV 동시 확인', + asIsDetail: { satellite: '정박 선박 3척 식별', ais: 'MMSI 3건 정박 상태', cctv: '여수 오동도 CCTV 정상' }, + nowDetail: { satellite: '선박 7척 식별 (4척 증가)', ais: 'MMSI 7건 (신규 4건 입항)', cctv: '여수 오동도 CCTV 선박 증가 확인' }, + }, + { + id: 'CHG-002', area: '제주 서귀포 해상', type: '유막 확산', + date1: '03-15', time1: '10:30', date2: '03-16', time2: '14:23', + severity: '심각', detail: '유막 면적 2.1km² → 4.8km² (확산)', + sources: ['satellite', 'drone', 'cctv', 'ais'], + crossRef: '4개 정보원 교차확인 — 유막 남동 방향 확산 일치', + asIsDetail: { satellite: 'SAR 유막 2.1km² 탐지', drone: '열화상 유막 경계 포착', cctv: '서귀포 카메라 해면 이상 없음', ais: '인근 유조선 1척 정박' }, + nowDetail: { satellite: 'SAR 유막 4.8km² 확산', drone: '열화상 유막 남동 2.7km 확대', cctv: '서귀포 카메라 해면 변색 감지', ais: '유조선 이탈, 방제선 2척 진입' }, + }, + { + id: 'CHG-003', area: '부산항 외항', type: '방제장비 배치', + date1: '03-10', time1: '09:00', date2: '03-16', time2: '14:23', + severity: '낮음', detail: '부유식 오일펜스 신규 배치 확인', + sources: ['drone', 'cctv'], + crossRef: 'CCTV 부산항 #204 + 드론 정밀 촬영 일치', + asIsDetail: { drone: '오일펜스 미배치', cctv: '부산 민락항 CCTV 해상 장비 없음' }, + nowDetail: { drone: '오일펜스 300m 배치 확인', cctv: '부산 민락항 CCTV 오일펜스 포착' }, + }, + { + id: 'CHG-004', area: '통영 해역 남측', type: '미식별 선박', + date1: '03-13', time1: '22:00', date2: '03-16', time2: '14:23', + severity: '보통', detail: 'AIS 미송출 선박 2척 위성 포착', + sources: ['satellite', 'ais'], + crossRef: 'AIS 미등록 — 위성 SAR 반사 신호로 탐지, 불법 조업 의심', + asIsDetail: { satellite: '해역 내 선박 신호 없음', ais: '등록 선박 0척' }, + nowDetail: { satellite: 'SAR 반사 2건 포착 (선박 추정)', ais: 'MMSI 미수신 — 미식별 선박' }, + }, + { + id: 'CHG-005', area: '인천 연안부두', type: '야간 이상징후', + date1: '03-15', time1: '03:42', date2: '03-16', time2: '03:45', + severity: '심각', detail: 'CCTV 야간 유출 의심 + AIS 정박선 이탈', + sources: ['cctv', 'ais'], + crossRef: 'KBS CCTV #9981 03:42 해면 반사 이상 → AIS 03:45 정박선 이탈 연계', + asIsDetail: { cctv: '인천 연안부두 CCTV 야간 정상', ais: '정박선 5척 정상 정박' }, + nowDetail: { cctv: '03:42 해면 반사광 이상 감지', ais: '03:45 정박선 1척 이탈 (MMSI 441987654)' }, + }, + { + id: 'CHG-006', area: '마라도 주변 해역', type: '해안선 변화', + date1: '03-12', time1: '11:00', date2: '03-16', time2: '11:15', + severity: '낮음', detail: '해안 퇴적물 분포 변경 감지', + sources: ['satellite', 'drone'], + crossRef: '위성 다분광 + 드론 정밀 촬영 퇴적 방향 확인', + asIsDetail: { satellite: '해안선 퇴적 분포 기준점', drone: '미촬영' }, + nowDetail: { satellite: '퇴적 남서 방향 이동 감지', drone: '정밀 촬영으로 퇴적 경계 확인' }, + }, + ]; + + const severityStyle = (s: '심각' | '보통' | '낮음') => { + if (s === '심각') return { background: 'rgba(239,68,68,.12)', color: '#ef4444', border: '1px solid rgba(239,68,68,.25)' }; + if (s === '보통') return { background: 'rgba(234,179,8,.12)', color: '#eab308', border: '1px solid rgba(234,179,8,.25)' }; + return { background: 'rgba(34,197,94,.12)', color: '#22c55e', border: '1px solid rgba(34,197,94,.25)' }; + }; + + const sourceStyle = (src: SourceConfig, active = true) => active + ? { background: `${src.color}18`, color: src.color, border: `1px solid ${src.color}40` } + : { background: 'var(--bg3)', color: 'var(--t4)', border: '1px solid var(--bd)', opacity: 0.5 }; + + const filteredChanges = sourceFilter === 'all' + ? changes + : changes.filter((c) => c.sources.includes(sourceFilter)); + + return ( +
+ {/* 레이어 토글 바 */} +
+
오버레이 레이어
+
+ {SOURCES.map((s) => ( + + ))} +
+
{activeCount}/4 레이어 활성
+
+ + {/* AS-IS / 현재 시점 비교 뷰 */} +
+ {/* AS-IS 시점 */} +
+
+
+ AS-IS + 과거 시점 +
+
+ + +
+
+ {/* 활성 레이어 표시 */} +
+ {SOURCES.filter((s) => layers[s.id]).map((s) => ( + + {s.icon} {s.label} + + ))} + {activeCount === 0 && 레이어를 선택하세요} +
+ {/* 지도 플레이스홀더 */} +
+
+
+ {SOURCES.filter((s) => layers[s.id]).map((s) => {s.icon})} +
+
과거 시점 복합 오버레이
+
+ {SOURCES.filter((s) => layers[s.id]).map((s) => s.label).join(' + ')} 통합 표시 +
+
+
+
+ + {/* 현재 시점 */} +
+
+
+ 현재 + NOW +
+ 2026-03-16 14:23 +
+ {/* 활성 레이어 표시 */} +
+ {SOURCES.filter((s) => layers[s.id]).map((s) => ( + + {s.icon} {s.label} + + ))} + {activeCount === 0 && 레이어를 선택하세요} +
+ {/* 지도 플레이스홀더 */} +
+
+
+ {SOURCES.filter((s) => layers[s.id]).map((s) => {s.icon})} +
+
현재 시점 복합 오버레이
+
+ {SOURCES.filter((s) => layers[s.id]).map((s) => s.label).join(' + ')} 실시간 통합 +
+
+
+
+
+ + {/* 복합 변화 감지 목록 */} +
+
+
🔄 복합 변화 감지 타임라인
+
+
+ + {SOURCES.map((s) => ( + + ))} +
+
{filteredChanges.length}건
+
+
+ + {/* 데이터 행 */} + {filteredChanges.map((c) => { + const isOpen = selectedChange === c.id; + return ( +
+ {/* 요약 행 */} +
setSelectedChange(isOpen ? null : c.id)} + className="grid gap-0 px-4 py-3 items-center hover:bg-bg-hover/30 transition-colors cursor-pointer" + style={{ gridTemplateColumns: '52px 1fr 100px 130px 60px', background: isOpen ? 'rgba(168,85,247,.04)' : undefined }} + > +
{c.id}
+
+
+ {c.area} + {c.type} +
+
{c.detail}
+
+
+ {c.sources.map((sid) => { + const cfg = SOURCES.find((s) => s.id === sid)!; + return {cfg.icon}; + })} +
+
+ {c.date1} {c.time1} + + {c.date2} {c.time2} +
+
+ {c.severity} +
+
+ + {/* 펼침: 정보원별 AS-IS → 현재 상세 */} + {isOpen && ( +
+ {/* 교차검증 */} + {c.crossRef && ( +
+ 교차검증 {c.crossRef} +
+ )} + {/* 정보원별 비교 그리드 */} +
+
+
정보원
+
AS-IS ({c.date1} {c.time1})
+
현재 ({c.date2} {c.time2})
+
+ {c.sources.map((sid) => { + const cfg = SOURCES.find((s) => s.id === sid)!; + return ( +
+
+ {cfg.icon} {cfg.label} +
+
{c.asIsDetail[sid] || '-'}
+
{c.nowDetail[sid] || '-'}
+
+ ); + })} +
+
+ )} +
+ ); + })} +
+
+ ); +} + +/* ─── 연안자동감지 패널 ──────────────────────────────── */ +type ZoneStatus = '정상' | '경보' | '주의'; +type MonitorSource = 'satellite' | 'cctv' | 'drone' | 'ais'; + +interface MonitorSourceConfig { + id: MonitorSource; + label: string; + icon: string; + color: string; + desc: string; +} + +const MONITOR_SOURCES: MonitorSourceConfig[] = [ + { id: 'satellite', label: '위성영상', icon: '🛰', color: '#a855f7', desc: 'KOMPSAT/Sentinel 주기 촬영' }, + { id: 'cctv', label: 'CCTV', icon: '📹', color: '#3b82f6', desc: 'KHOA/KBS 해안 CCTV 실시간' }, + { id: 'drone', label: '드론', icon: '🛸', color: '#22c55e', desc: '드론 정밀 촬영 / 열화상' }, + { id: 'ais', label: 'AIS', icon: '🚢', color: '#f59e0b', desc: '선박 위치·항적 실시간 수신' }, +]; + +interface MonitorZone { + id: string; + name: string; + interval: string; + lastCheck: string; + status: ZoneStatus; + alerts: number; + polygon: [number, number][]; + color: string; + monitoring: boolean; + /** 활성 모니터링 소스 */ + sources: MonitorSource[]; +} + +const INTERVAL_OPTIONS = ['1h', '3h', '6h', '12h', '24h']; + +const ZONE_COLORS = ['#a855f7', '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4']; + +const INITIAL_ZONES: MonitorZone[] = [ + { + id: 'AOI-001', name: '여수항 반경', interval: '6h', lastCheck: '03-16 14:00', status: '정상', alerts: 0, monitoring: true, color: '#3b82f6', + polygon: [[127.68, 34.78], [127.78, 34.78], [127.78, 34.70], [127.68, 34.70]], + sources: ['satellite', 'cctv', 'ais'], + }, + { + id: 'AOI-002', name: '제주 서귀포 해상', interval: '3h', lastCheck: '03-16 13:30', status: '경보', alerts: 2, monitoring: true, color: '#ef4444', + polygon: [[126.45, 33.28], [126.58, 33.28], [126.58, 33.20], [126.45, 33.20]], + sources: ['satellite', 'drone', 'cctv', 'ais'], + }, + { + id: 'AOI-003', name: '부산항 외항', interval: '12h', lastCheck: '03-16 08:00', status: '정상', alerts: 0, monitoring: true, color: '#22c55e', + polygon: [[129.05, 35.12], [129.20, 35.12], [129.20, 35.05], [129.05, 35.05]], + sources: ['cctv', 'ais'], + }, + { + id: 'AOI-004', name: '통영 ~ 거제 해역', interval: '24h', lastCheck: '03-15 20:00', status: '주의', alerts: 1, monitoring: true, color: '#f59e0b', + polygon: [[128.30, 34.90], [128.65, 34.90], [128.65, 34.80], [128.30, 34.80]], + sources: ['satellite', 'drone'], + }, +]; + +function AoiPanel() { + const [zones, setZones] = useState(INITIAL_ZONES); + const [selectedZone, setSelectedZone] = useState(null); + // 드로잉 상태 + const [isDrawing, setIsDrawing] = useState(false); + const [drawingPoints, setDrawingPoints] = useState<[number, number][]>([]); + // 등록 폼 + const [showForm, setShowForm] = useState(false); + const [formName, setFormName] = useState(''); + // 실시간 시뮬 + const [now, setNow] = useState(() => new Date()); + + // 시계 갱신 (1분마다) + useEffect(() => { + const t = setInterval(() => setNow(new Date()), 60_000); + return () => clearInterval(t); + }, []); + + const nextId = useRef(zones.length + 1); + + // 지도 클릭 → 폴리곤 포인트 수집 + const handleMapClick = useCallback((e: MapMouseEvent) => { + if (!isDrawing) return; + setDrawingPoints((prev) => [...prev, [e.lngLat.lng, e.lngLat.lat]]); + }, [isDrawing]); + + // 드로잉 시작 + const startDrawing = () => { + setDrawingPoints([]); + setIsDrawing(true); + setShowForm(false); + setSelectedZone(null); + }; + + // 드로잉 완료 → 폼 표시 + const finishDrawing = () => { + if (drawingPoints.length < 3) return; + setIsDrawing(false); + setShowForm(true); + setFormName(''); + }; + + // 드로잉 취소 + const cancelDrawing = () => { + setIsDrawing(false); + setDrawingPoints([]); + setShowForm(false); + }; + + // 구역 등록 (이름만 → 등록 후 설정) + const registerZone = () => { + if (!formName.trim() || drawingPoints.length < 3) return; + const newId = `AOI-${String(nextId.current++).padStart(3, '0')}`; + const newZone: MonitorZone = { + id: newId, + name: formName.trim(), + interval: '6h', + lastCheck: `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`, + status: '정상', + alerts: 0, + polygon: [...drawingPoints], + color: ZONE_COLORS[zones.length % ZONE_COLORS.length], + monitoring: true, + sources: ['satellite', 'cctv'], + }; + setZones((prev) => [...prev, newZone]); + setDrawingPoints([]); + setShowForm(false); + setSelectedZone(newId); + }; + + // 구역 설정 변경 + const updateZone = (id: string, patch: Partial) => { + setZones((prev) => prev.map((z) => z.id === id ? { ...z, ...patch } : z)); + }; + + // 모니터링 소스 토글 + const toggleSource = (id: string, src: MonitorSource) => { + setZones((prev) => prev.map((z) => { + if (z.id !== id) return z; + const has = z.sources.includes(src); + return { ...z, sources: has ? z.sources.filter((s) => s !== src) : [...z.sources, src] }; + })); + }; + + // 모니터링 토글 + const toggleMonitoring = (id: string) => { + setZones((prev) => prev.map((z) => z.id === id ? { ...z, monitoring: !z.monitoring } : z)); + }; + + // 구역 삭제 + const removeZone = (id: string) => { + setZones((prev) => prev.filter((z) => z.id !== id)); + if (selectedZone === id) setSelectedZone(null); + }; + + const statusStyle = (s: ZoneStatus) => { + if (s === '경보') return { background: 'rgba(239,68,68,.12)', color: '#ef4444', border: '1px solid rgba(239,68,68,.25)' }; + if (s === '주의') return { background: 'rgba(234,179,8,.12)', color: '#eab308', border: '1px solid rgba(234,179,8,.25)' }; + return { background: 'rgba(34,197,94,.12)', color: '#22c55e', border: '1px solid rgba(34,197,94,.25)' }; + }; + + // deck.gl 레이어: 등록된 폴리곤 + 드로잉 중 포인트 + const deckLayers = useMemo(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result: any[] = []; + + // 등록된 구역 폴리곤 + const visibleZones = zones.filter((z) => z.monitoring); + if (visibleZones.length > 0) { + result.push( + new PolygonLayer({ + id: 'aoi-zones', + data: visibleZones, + getPolygon: (d: MonitorZone) => [...d.polygon, d.polygon[0]], + getFillColor: (d: MonitorZone) => { + const hex = d.color; + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + const alpha = selectedZone === d.id ? 60 : 30; + return [r, g, b, alpha]; + }, + getLineColor: (d: MonitorZone) => { + const hex = d.color; + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return [r, g, b, selectedZone === d.id ? 220 : 140]; + }, + getLineWidth: (d: MonitorZone) => selectedZone === d.id ? 3 : 1.5, + lineWidthUnits: 'pixels', + pickable: true, + onClick: ({ object }: { object: MonitorZone }) => { + if (object && !isDrawing) setSelectedZone(object.id === selectedZone ? null : object.id); + }, + updateTriggers: { + getFillColor: [selectedZone], + getLineColor: [selectedZone], + getLineWidth: [selectedZone], + }, + }), + ); + + // 경보/주의 구역 중심점 펄스 + const alertZones = visibleZones.filter((z) => z.status !== '정상'); + if (alertZones.length > 0) { + result.push( + new ScatterplotLayer({ + id: 'aoi-alert-pulse', + data: alertZones, + getPosition: (d: MonitorZone) => { + const lngs = d.polygon.map((p) => p[0]); + const lats = d.polygon.map((p) => p[1]); + return [(Math.min(...lngs) + Math.max(...lngs)) / 2, (Math.min(...lats) + Math.max(...lats)) / 2]; + }, + getRadius: 8000, + radiusUnits: 'meters' as const, + getFillColor: (d: MonitorZone) => d.status === '경보' ? [239, 68, 68, 80] : [234, 179, 8, 60], + getLineColor: (d: MonitorZone) => d.status === '경보' ? [239, 68, 68, 180] : [234, 179, 8, 150], + lineWidthMinPixels: 2, + stroked: true, + }), + ); + } + } + + // 드로잉 중 포인트 + if (drawingPoints.length > 0) { + result.push( + new ScatterplotLayer({ + id: 'drawing-points', + data: drawingPoints, + getPosition: (d: [number, number]) => d, + getRadius: 5, + radiusUnits: 'pixels' as const, + getFillColor: [168, 85, 247, 220], + getLineColor: [255, 255, 255, 255], + lineWidthMinPixels: 2, + stroked: true, + }), + ); + + // 드로잉 폴리곤 미리보기 + if (drawingPoints.length >= 3) { + result.push( + new PolygonLayer({ + id: 'drawing-preview', + data: [{ polygon: [...drawingPoints, drawingPoints[0]] }], + getPolygon: (d: { polygon: [number, number][] }) => d.polygon, + getFillColor: [168, 85, 247, 25], + getLineColor: [168, 85, 247, 200], + getLineWidth: 2, + lineWidthUnits: 'pixels', + getDashArray: [4, 4], + }), + ); + } + } + + return result; + }, [zones, drawingPoints, selectedZone, isDrawing]); + + const activeMonitoring = zones.filter((z) => z.monitoring).length; + const alertCount = zones.filter((z) => z.status === '경보').length; + const warningCount = zones.filter((z) => z.status === '주의').length; + const totalAlerts = zones.reduce((s, a) => s + a.alerts, 0); + + const inputCls = 'w-full px-2.5 py-1.5 rounded text-[10px] font-korean outline-none border'; + const inputStyle = { background: '#161b22', borderColor: '#21262d', color: '#e2e8f0' }; + + return ( +
+ {/* 통계 */} +
+ {[ + { value: String(activeMonitoring), label: '감시 구역', color: '#3b82f6' }, + { value: String(alertCount), label: '경보', color: '#ef4444' }, + { value: String(warningCount), label: '주의', color: '#eab308' }, + { value: String(totalAlerts), label: '미확인 알림', color: '#a855f7' }, + ].map((s, i) => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+ +
+ {/* 지도 영역 */} +
+ {/* 지도 헤더 */} +
+
+
📍 연안 감시 구역
+ {isDrawing && ( + + 드로잉 모드 · 지도를 클릭하여 꼭짓점 추가 ({drawingPoints.length}점) + + )} +
+
+ {isDrawing ? ( + <> + + + + + ) : ( + + )} +
+
+ + {/* MapLibre 지도 */} +
+ + + +
+
+ + {/* 우측 패널 */} +
+ {/* 등록 폼: 이름만 입력 → 바로 등록 */} + {showForm && drawingPoints.length >= 3 && ( +
+
+
새 감시 구역 등록
+
폴리곤 {drawingPoints.length}점 설정 완료
+
+
+ + setFormName(e.target.value)} + placeholder="예: 여수항 북측 해안" + className={inputCls} + style={inputStyle} + onKeyDown={(e) => { if (e.key === 'Enter') registerZone(); }} + autoFocus + /> +
+ + +
+
+
+ )} + + {/* 선택된 구역: 모니터링 설정 패널 */} + {selectedZone && (() => { + const z = zones.find((zone) => zone.id === selectedZone); + if (!z) return null; + return ( +
+
+
+ {z.id} + {z.name} +
+ + {z.status} + +
+
+ {/* 감시 주기 */} +
+ +
+ {INTERVAL_OPTIONS.map((iv) => ( + + ))} +
+
+ + {/* 모니터링 방법 (정보원 소스) */} +
+ +
+ {MONITOR_SOURCES.map((src) => { + const active = z.sources.includes(src.id); + return ( + + ); + })} +
+ {z.sources.length === 0 && ( +
최소 1개 이상의 모니터링 방법을 선택하세요
+ )} +
+ + {/* 하단 컨트롤 */} +
+ + {z.polygon.length}점 · {z.sources.length}소스 · {z.interval} + +
+
+
+ ); + })()} + + {/* 감시 구역 목록 */} +
+
+
📋 등록된 감시 구역
+
{zones.length}건
+
+
+ {zones.map((z) => ( +
setSelectedZone(selectedZone === z.id ? null : z.id)} + className="px-4 py-2.5 hover:bg-bg-hover/30 transition-colors cursor-pointer" + style={{ background: selectedZone === z.id ? `${z.color}08` : undefined, borderLeft: `3px solid ${z.monitoring ? z.color : 'transparent'}` }} + > +
+
+ {z.id} + {z.name} +
+ + {z.status} + +
+ {/* 활성 소스 배지 + 주기 */} +
+ {z.sources.map((sid) => { + const cfg = MONITOR_SOURCES.find((s) => s.id === sid)!; + return ( + + {cfg.icon} + + ); + })} + {z.interval} · {z.lastCheck} +
+ {z.alerts > 0 && ( +
+ 미확인 알림 {z.alerts}건 +
+ )} +
+ ))} + {zones.length === 0 && ( +
+
📍
+
등록된 감시 구역이 없습니다
+
"+ 감시 구역 등록" 버튼으로 시작하세요
+
+ )} +
+
+
+
+
+ ); +}