diff --git a/backend/src/aerial/aerialRouter.ts b/backend/src/aerial/aerialRouter.ts index 3921d71..7f892e7 100644 --- a/backend/src/aerial/aerialRouter.ts +++ b/backend/src/aerial/aerialRouter.ts @@ -435,6 +435,103 @@ router.post('/satellite/:sn/status', requireAuth, requirePermission('aerial', 'C } }); +// ============================================================ +// UP42 위성 패스 조회 (실시간 위성 목록 + 궤도) +// ============================================================ + +/** 한국 주변 위성 패스 시뮬레이션 데이터 (UP42 API 연동 시 교체) */ +function generateKoreaSatellitePasses() { + const now = new Date(); + const passes = [ + { + id: 'pass-kmp3a-1', satellite: 'KOMPSAT-3A', provider: 'KARI', type: 'optical', + resolution: '0.5m', color: '#a855f7', + startTime: new Date(now.getTime() + 2 * 3600000).toISOString(), + endTime: new Date(now.getTime() + 2 * 3600000 + 14 * 60000).toISOString(), + maxElevation: 72, direction: 'descending', + orbit: [ + { lat: 42.0, lon: 126.5 }, { lat: 40.5, lon: 127.0 }, { lat: 39.0, lon: 127.4 }, + { lat: 37.5, lon: 127.8 }, { lat: 36.0, lon: 128.1 }, { lat: 34.5, lon: 128.4 }, + { lat: 33.0, lon: 128.6 }, { lat: 31.5, lon: 128.8 }, + ], + }, + { + id: 'pass-pneo-1', satellite: 'Pléiades Neo', provider: 'Airbus', type: 'optical', + resolution: '0.3m', color: '#06b6d4', + startTime: new Date(now.getTime() + 3.5 * 3600000).toISOString(), + endTime: new Date(now.getTime() + 3.5 * 3600000 + 12 * 60000).toISOString(), + maxElevation: 65, direction: 'ascending', + orbit: [ + { lat: 30.0, lon: 130.0 }, { lat: 31.5, lon: 129.2 }, { lat: 33.0, lon: 128.5 }, + { lat: 34.5, lon: 127.8 }, { lat: 36.0, lon: 127.1 }, { lat: 37.5, lon: 126.4 }, + { lat: 39.0, lon: 125.8 }, { lat: 40.5, lon: 125.2 }, + ], + }, + { + id: 'pass-s1-1', satellite: 'Sentinel-1 SAR', provider: 'ESA', type: 'sar', + resolution: '20m', color: '#f59e0b', + startTime: new Date(now.getTime() + 5 * 3600000).toISOString(), + endTime: new Date(now.getTime() + 5 * 3600000 + 18 * 60000).toISOString(), + maxElevation: 58, direction: 'descending', + orbit: [ + { lat: 43.0, lon: 124.0 }, { lat: 41.0, lon: 125.0 }, { lat: 39.0, lon: 126.0 }, + { lat: 37.0, lon: 126.8 }, { lat: 35.0, lon: 127.5 }, { lat: 33.0, lon: 128.0 }, + { lat: 31.0, lon: 128.5 }, + ], + }, + { + id: 'pass-wv3-1', satellite: 'Maxar WorldView-3', provider: 'Maxar', type: 'optical', + resolution: '0.31m', color: '#3b82f6', + startTime: new Date(now.getTime() + 8 * 3600000).toISOString(), + endTime: new Date(now.getTime() + 8 * 3600000 + 10 * 60000).toISOString(), + maxElevation: 80, direction: 'descending', + orbit: [ + { lat: 41.0, lon: 129.5 }, { lat: 39.5, lon: 129.0 }, { lat: 38.0, lon: 128.5 }, + { lat: 36.5, lon: 128.0 }, { lat: 35.0, lon: 127.5 }, { lat: 33.5, lon: 127.0 }, + { lat: 32.0, lon: 126.5 }, + ], + }, + { + id: 'pass-skysat-1', satellite: 'SkySat', provider: 'Planet', type: 'optical', + resolution: '0.5m', color: '#22c55e', + startTime: new Date(now.getTime() + 12 * 3600000).toISOString(), + endTime: new Date(now.getTime() + 12 * 3600000 + 8 * 60000).toISOString(), + maxElevation: 55, direction: 'ascending', + orbit: [ + { lat: 31.0, lon: 127.0 }, { lat: 32.5, lon: 126.5 }, { lat: 34.0, lon: 126.0 }, + { lat: 35.5, lon: 125.5 }, { lat: 37.0, lon: 125.0 }, { lat: 38.5, lon: 124.5 }, + { lat: 40.0, lon: 124.0 }, + ], + }, + { + id: 'pass-s2-1', satellite: 'Sentinel-2', provider: 'ESA', type: 'optical', + resolution: '10m', color: '#ec4899', + startTime: new Date(now.getTime() + 18 * 3600000).toISOString(), + endTime: new Date(now.getTime() + 18 * 3600000 + 20 * 60000).toISOString(), + maxElevation: 62, direction: 'descending', + orbit: [ + { lat: 42.0, lon: 128.0 }, { lat: 40.0, lon: 128.0 }, { lat: 38.0, lon: 128.0 }, + { lat: 36.0, lon: 128.0 }, { lat: 34.0, lon: 128.0 }, { lat: 32.0, lon: 128.0 }, + ], + }, + ]; + return passes; +} + +// GET /api/aerial/satellite/passes — 한국 주변 실시간 위성 패스 목록 (UP42 API 연동 준비) +router.get('/satellite/passes', requireAuth, requirePermission('aerial', 'READ'), async (_req, res) => { + try { + // TODO: UP42 API 연동 시 아래 코드를 실제 API 호출로 교체 + // const token = await getUp42Token() + // const passes = await fetchUp42Catalog(token, { bbox: [124, 33, 132, 39] }) + const passes = generateKoreaSatellitePasses(); + res.json({ passes, source: 'simulation', note: 'UP42 API 연동 시 실제 데이터로 교체 예정' }); + } catch (err) { + console.error('[aerial] 위성 패스 조회 오류:', err); + res.status(500).json({ error: '위성 패스 조회 실패' }); + } +}); + // ============================================================ // OIL INFERENCE 라우트 // ============================================================ 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/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 976aca8..140504e 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -1400,23 +1400,23 @@ function MapControls({ center, zoom }: { center: [number, number]; zoom: number const { current: map } = useMap() return ( -
-
+
+
@@ -1435,7 +1435,7 @@ interface MapLegendProps { } function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], selectedModels = new Set(['OpenDrift'] as PredictionModel[]) }: MapLegendProps) { - const [minimized, setMinimized] = useState(false) + const [minimized, setMinimized] = useState(true) if (dispersionResult && incidentCoord) { return ( diff --git a/frontend/src/common/hooks/useSubMenu.ts b/frontend/src/common/hooks/useSubMenu.ts index 593d846..af37161 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/common/styles/components.css b/frontend/src/common/styles/components.css index 373fe49..7593fa5 100644 --- a/frontend/src/common/styles/components.css +++ b/frontend/src/common/styles/components.css @@ -69,6 +69,76 @@ color: var(--t3); } + /* Date/Time picker custom styling */ + .prd-date-input::-webkit-calendar-picker-indicator, + .prd-time-input::-webkit-calendar-picker-indicator { + opacity: 0; + position: absolute; + right: 0; + width: 28px; + height: 100%; + cursor: pointer; + } + + .prd-date-input, + .prd-time-input { + font-size: 10px; + color-scheme: dark; + } + + .prd-date-input::-webkit-datetime-edit, + .prd-time-input::-webkit-datetime-edit { + color: var(--t2); + font-family: var(--fM); + font-size: 10px; + letter-spacing: 0.3px; + } + + .prd-date-input::-webkit-datetime-edit-fields-wrapper, + .prd-time-input::-webkit-datetime-edit-fields-wrapper { + padding: 0; + } + + .prd-date-input::-webkit-datetime-edit-year-field, + .prd-date-input::-webkit-datetime-edit-month-field, + .prd-date-input::-webkit-datetime-edit-day-field, + .prd-time-input::-webkit-datetime-edit-hour-field, + .prd-time-input::-webkit-datetime-edit-minute-field, + .prd-time-input::-webkit-datetime-edit-ampm-field { + color: var(--t2); + background: transparent; + padding: 1px 2px; + border-radius: 2px; + } + + .prd-date-input::-webkit-datetime-edit-year-field:focus, + .prd-date-input::-webkit-datetime-edit-month-field:focus, + .prd-date-input::-webkit-datetime-edit-day-field:focus, + .prd-time-input::-webkit-datetime-edit-hour-field:focus, + .prd-time-input::-webkit-datetime-edit-minute-field:focus, + .prd-time-input::-webkit-datetime-edit-ampm-field:focus { + background: rgba(6, 182, 212, 0.12); + color: var(--cyan); + } + + .prd-date-input::-webkit-datetime-edit-text, + .prd-time-input::-webkit-datetime-edit-text { + color: var(--t3); + padding: 0 1px; + } + + /* Time hour/minute select (dark dropdown) */ + select.prd-i.prd-time-select { + color-scheme: dark; + -webkit-appearance: menulist !important; + appearance: menulist !important; + background: var(--bg3) !important; + background-image: none !important; + padding-right: 4px; + color: var(--t1); + border-color: var(--bd); + } + /* Select Dropdown */ select.prd-i { cursor: pointer; @@ -210,10 +280,11 @@ .prd-mc { display: flex; align-items: center; - gap: 6px; - padding: 6px 13px; + justify-content: center; + gap: 4px; + padding: 5px 4px; border-radius: 5px; - font-size: 12px; + font-size: 9px; font-weight: 600; font-family: 'Noto Sans KR', sans-serif; cursor: pointer; @@ -294,18 +365,20 @@ .cod { position: absolute; bottom: 80px; - left: 16px; - background: rgba(18, 25, 41, 0.85); - backdrop-filter: blur(12px); - border: 1px solid var(--bd); + left: 50%; + transform: translateX(-50%); + background: rgba(18, 25, 41, 0.5); + backdrop-filter: blur(8px); + border: 1px solid rgba(30, 42, 66, 0.4); border-radius: 6px; - padding: 8px 12px; + padding: 5px 14px; font-family: 'JetBrains Mono', monospace; - font-size: 11px; - color: var(--t2); + font-size: 10px; + color: #1a1a2e; + font-weight: 600; z-index: 20; display: flex; - gap: 16px; + gap: 14px; } .cov { @@ -316,40 +389,41 @@ /* ═══ Weather Info Panel ═══ */ .wip { position: absolute; - top: 16px; - left: 16px; - background: rgba(18, 25, 41, 0.9); - backdrop-filter: blur(12px); - border: 1px solid var(--bd); - border-radius: 8px; - padding: 12px 14px; + top: 10px; + left: 10px; + background: rgba(18, 25, 41, 0.65); + backdrop-filter: blur(10px); + border: 1px solid rgba(30, 42, 66, 0.5); + border-radius: 6px; + padding: 6px 10px; z-index: 20; display: flex; - gap: 20px; + gap: 12px; } .wii { display: flex; flex-direction: column; - gap: 2px; + gap: 1px; align-items: center; } .wii-icon { - font-size: 18px; - opacity: 0.6; + font-size: 12px; + opacity: 0.5; } .wii-value { - font-size: 15px; + font-size: 11px; font-weight: 700; color: var(--t1); font-family: 'JetBrains Mono', monospace; } .wii-label { - font-size: 9px; - color: var(--t3); + font-size: 7px; + color: #1a1a2e; + font-weight: 700; font-family: 'Noto Sans KR', sans-serif; } 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 613e408..d5fe829 100644 --- a/frontend/src/tabs/aerial/components/SatelliteRequest.tsx +++ b/frontend/src/tabs/aerial/components/SatelliteRequest.tsx @@ -1,4 +1,12 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, useCallback } from 'react' +import { Map, Source, Layer } from '@vis.gl/react-maplibre' +import type { StyleSpecification } from 'maplibre-gl' +import 'maplibre-gl/dist/maplibre-gl.css' +import { Marker } from '@vis.gl/react-maplibre' +import { fetchSatellitePasses } from '../services/aerialApi' + +const VWORLD_API_KEY = import.meta.env.VITE_VWORLD_API_KEY || '' +import type { SatellitePass } from '../services/aerialApi' interface SatRequest { id: string @@ -9,20 +17,22 @@ interface SatRequest { requestDate: string expectedReceive: string resolution: string - status: '촬영중' | '대기' | '완료' + status: '촬영중' | '대기' | '완료' | '취소' provider?: string purpose?: string requester?: string + /** ISO 날짜 (필터용) */ + dateKey?: string } const satRequests: SatRequest[] = [ - { id: 'SAT-004', zone: '제주 서귀포 해상 (유출 해역 중심)', zoneCoord: '33.24°N 126.50°E', zoneArea: '15km²', satellite: 'KOMPSAT-3A', requestDate: '02-20 08:14', expectedReceive: '02-20 14:30', resolution: '0.5m', status: '촬영중', provider: 'KARI', purpose: '유출유 확산 모니터링', requester: '방제과 김해양' }, - { id: 'SAT-005', zone: '가파도 북쪽 해안선', zoneCoord: '33.17°N 126.27°E', zoneArea: '8km²', satellite: 'KOMPSAT-3', requestDate: '02-20 09:02', expectedReceive: '02-21 09:00', resolution: '1.0m', status: '대기', provider: 'KARI', purpose: '해안선 오염 확인', requester: '방제과 이민수' }, - { id: 'SAT-006', zone: '마라도 주변 해역', zoneCoord: '33.11°N 126.27°E', zoneArea: '12km²', satellite: 'Sentinel-2', requestDate: '02-20 09:30', expectedReceive: '02-21 11:00', resolution: '10m', status: '대기', provider: 'ESA Copernicus', purpose: '수질 분석용 다분광 촬영', requester: '환경분석팀 박수진' }, - { id: 'SAT-007', zone: '대정읍 해안 오염 확산 구역', zoneCoord: '33.21°N 126.10°E', zoneArea: '20km²', satellite: 'KOMPSAT-3A', requestDate: '02-20 10:05', expectedReceive: '02-22 08:00', resolution: '0.5m', status: '대기', provider: 'KARI', purpose: '확산 예측 모델 검증', requester: '방제과 김해양' }, - { id: 'SAT-003', zone: '제주 남방 100해리 해상', zoneCoord: '33.00°N 126.50°E', zoneArea: '25km²', satellite: 'Sentinel-1', requestDate: '02-19 14:00', expectedReceive: '02-19 23:00', resolution: '20m', status: '완료', provider: 'ESA Copernicus', purpose: 'SAR 유막 탐지', requester: '환경분석팀 박수진' }, - { id: 'SAT-002', zone: '여수 오동도 인근 해역', zoneCoord: '34.73°N 127.68°E', zoneArea: '18km²', satellite: 'KOMPSAT-3A', requestDate: '02-18 11:30', expectedReceive: '02-18 17:45', resolution: '0.5m', status: '완료', provider: 'KARI', purpose: '유출 초기 범위 확인', requester: '방제과 김해양' }, - { id: 'SAT-001', zone: '통영 해역 남측', zoneCoord: '34.85°N 128.43°E', zoneArea: '30km²', satellite: 'Sentinel-1', requestDate: '02-17 09:00', expectedReceive: '02-17 21:00', resolution: '20m', status: '완료', provider: 'ESA Copernicus', purpose: '야간 SAR 유막 모니터링', requester: '환경분석팀 박수진' }, + { id: 'SAT-004', zone: '제주 서귀포 해상 (유출 해역 중심)', zoneCoord: '33.24°N 126.50°E', zoneArea: '15km²', satellite: 'KOMPSAT-3A', requestDate: '03-17 08:14', expectedReceive: '03-17 14:30', resolution: '0.5m', status: '촬영중', provider: 'KARI', purpose: '유출유 확산 모니터링', requester: '방제과 김해양', dateKey: '2026-03-17' }, + { id: 'SAT-005', zone: '가파도 북쪽 해안선', zoneCoord: '33.17°N 126.27°E', zoneArea: '8km²', satellite: 'KOMPSAT-3', requestDate: '03-17 09:02', expectedReceive: '03-18 09:00', resolution: '1.0m', status: '대기', provider: 'KARI', purpose: '해안선 오염 확인', requester: '방제과 이민수', dateKey: '2026-03-17' }, + { id: 'SAT-006', zone: '마라도 주변 해역', zoneCoord: '33.11°N 126.27°E', zoneArea: '12km²', satellite: 'Sentinel-2', requestDate: '03-16 09:30', expectedReceive: '03-16 23:00', resolution: '10m', status: '완료', provider: 'ESA Copernicus', purpose: '수질 분석용 다분광 촬영', requester: '환경분석팀 박수진', dateKey: '2026-03-16' }, + { id: 'SAT-007', zone: '대정읍 해안 오염 확산 구역', zoneCoord: '33.21°N 126.10°E', zoneArea: '20km²', satellite: 'KOMPSAT-3A', requestDate: '03-16 10:05', expectedReceive: '03-17 08:00', resolution: '0.5m', status: '완료', provider: 'KARI', purpose: '확산 예측 모델 검증', requester: '방제과 김해양', dateKey: '2026-03-16' }, + { id: 'SAT-003', zone: '제주 남방 100해리 해상', zoneCoord: '33.00°N 126.50°E', zoneArea: '25km²', satellite: 'Sentinel-1', requestDate: '03-15 14:00', expectedReceive: '03-15 23:00', resolution: '20m', status: '완료', provider: 'ESA Copernicus', purpose: 'SAR 유막 탐지', requester: '환경분석팀 박수진', dateKey: '2026-03-15' }, + { id: 'SAT-002', zone: '여수 오동도 인근 해역', zoneCoord: '34.73°N 127.68°E', zoneArea: '18km²', satellite: 'KOMPSAT-3A', requestDate: '03-14 11:30', expectedReceive: '03-14 17:45', resolution: '0.5m', status: '완료', provider: 'KARI', purpose: '유출 초기 범위 확인', requester: '방제과 김해양', dateKey: '2026-03-14' }, + { id: 'SAT-001', zone: '통영 해역 남측', zoneCoord: '34.85°N 128.43°E', zoneArea: '30km²', satellite: 'Sentinel-1', requestDate: '03-13 09:00', expectedReceive: '03-13 21:00', resolution: '20m', status: '완료', provider: 'ESA Copernicus', purpose: '야간 SAR 유막 모니터링', requester: '환경분석팀 박수진', dateKey: '2026-03-13' }, ] const satellites = [ @@ -59,28 +69,70 @@ const up42Satellites = [ { id: 'cop-dem', name: 'Copernicus DEM', res: '10m', type: 'elevation' as const, color: '#64748b', cloud: 0 }, ] -const up42Passes = [ - { sat: 'KOMPSAT-3A', time: '오늘 14:10–14:24', res: '0.5m', cloud: '≤10%', note: '최우선 추천', color: '#a855f7' }, - { sat: 'Pléiades Neo', time: '오늘 14:38–14:52', res: '0.3m', cloud: '≤15%', note: '초고해상도', color: '#06b6d4' }, - { sat: 'Sentinel-1 SAR', time: '오늘 16:55–17:08', res: '20m', cloud: '야간/우천 가능', note: 'SAR', color: '#f59e0b' }, - { sat: 'KOMPSAT-3', time: '내일 09:12', res: '1.0m', cloud: '≤15%', note: '', color: '#a855f7' }, - { sat: 'Maxar WV-3', time: '내일 13:20', res: '0.5m', cloud: '≤20%', note: '', color: '#3b82f6' }, -] +// up42Passes — 실시간 패스로 대체됨 (satPasses from API) + +const SAT_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 }], +} + +/** 좌표 문자열 파싱 ("33.24°N 126.50°E" → {lat, lon}) */ +function parseCoord(coordStr: string): { lat: number; lon: number } | null { + const m = coordStr.match(/([\d.]+)°N\s+([\d.]+)°E/) + if (!m) return null + return { lat: parseFloat(m[1]), lon: parseFloat(m[2]) } +} type SatModalPhase = 'none' | 'provider' | 'blacksky' | 'up42' export function SatelliteRequest() { + const [requests, setRequests] = useState(satRequests) + const [mainTab, setMainTab] = useState<'list' | 'map'>('list') const [statusFilter, setStatusFilter] = useState('전체') const [modalPhase, setModalPhase] = useState('none') const [selectedRequest, setSelectedRequest] = useState(null) - const [showMoreCompleted, setShowMoreCompleted] = useState(false) + const [currentPage, setCurrentPage] = useState(1) + const PAGE_SIZE = 5 // UP42 sub-tab const [up42SubTab, setUp42SubTab] = useState<'optical' | 'sar' | 'elevation'>('optical') const [up42SelSat, setUp42SelSat] = useState(null) - const [up42SelPass, setUp42SelPass] = useState(null) + const [up42SelPass, setUp42SelPass] = useState(null) + const [satPasses, setSatPasses] = useState([]) + const [satPassesLoading, setSatPassesLoading] = useState(false) + // 히스토리 지도 — 캘린더 + 선택 항목 + const [mapSelectedDate, setMapSelectedDate] = useState(() => { + 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) + const loadSatPasses = useCallback(async () => { + setSatPassesLoading(true) + try { + const passes = await fetchSatellitePasses() + setSatPasses(passes) + } catch { + setSatPasses([]) + } finally { + setSatPassesLoading(false) + } + }, []) + useEffect(() => { const handler = (e: MouseEvent) => { if (modalRef.current && !modalRef.current.contains(e.target as Node)) { @@ -91,15 +143,21 @@ export function SatelliteRequest() { return () => document.removeEventListener('mousedown', handler) }, [modalPhase]) - const allRequests = showMoreCompleted ? satRequests : satRequests.filter(r => r.status !== '완료' || r.id === 'SAT-003') + // UP42 모달 열릴 때 위성 패스 로드 + useEffect(() => { + if (modalPhase === 'up42') loadSatPasses() + }, [modalPhase, loadSatPasses]) - const filtered = allRequests.filter(r => { + const filtered = requests.filter(r => { if (statusFilter === '전체') return true if (statusFilter === '대기') return r.status === '대기' if (statusFilter === '진행') return r.status === '촬영중' if (statusFilter === '완료') return r.status === '완료' + if (statusFilter === '취소') return r.status === '취소' return true }) + const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)) + const pagedItems = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE) const statusBadge = (s: SatRequest['status']) => { if (s === '촬영중') return ( @@ -110,6 +168,9 @@ export function SatelliteRequest() { if (s === '대기') return ( ⏳ 대기 ) + if (s === '취소') return ( + ✕ 취소 + ) return ( ✅ 완료 ) @@ -122,7 +183,7 @@ export function SatelliteRequest() { { value: '0.5m', label: '최고 해상도', color: 'var(--cyan)' }, ] - const filters = ['전체', '대기', '진행', '완료'] + const filters = ['전체', '대기', '진행', '완료', '취소'] const up42Filtered = up42Satellites.filter(s => s.type === up42SubTab) @@ -138,19 +199,35 @@ export function SatelliteRequest() { const bsInputStyle = { border: '1px solid #21262d', background: '#161b22', color: '#e2e8f0' } return ( -
- {/* 헤더 */} -
-
-
🛰
-
-
위성 촬영 요청
-
위성 촬영 임무를 요청하고 수신 현황을 관리합니다
-
+
+ {/* 헤더 + 탭 + 새요청 한 줄 (높이 통일) */} +
+
+
🛰
+
위성 촬영 요청
- +
+ + +
+
+ {mainTab === 'list' && (<> {/* 요약 통계 */}
{stats.map((s, i) => ( @@ -187,8 +264,8 @@ export function SatelliteRequest() { ))}
- {/* 데이터 행 */} - {filtered.map(r => ( + {/* 데이터 행 (페이징) */} + {pagedItems.map(r => (
setSelectedRequest(selectedRequest?.id === r.id ? null : r)} @@ -197,7 +274,7 @@ export function SatelliteRequest() { gridTemplateColumns: '60px 1fr 100px 100px 120px 80px 90px', borderColor: 'rgba(255,255,255,.04)', background: selectedRequest?.id === r.id ? 'rgba(99,102,241,.06)' : r.status === '촬영중' ? 'rgba(234,179,8,.03)' : 'transparent', - opacity: r.status === '완료' ? 0.7 : 1, + opacity: (r.status === '완료' || r.status === '취소') ? 0.6 : 1, }} >
{r.id}
@@ -232,8 +309,18 @@ export function SatelliteRequest() { {r.status === '완료' && ( )} - {r.status === '대기' && ( - + {(r.status === '대기' || r.status === '촬영중') && ( + )}
@@ -241,11 +328,36 @@ export function SatelliteRequest() {
))} -
setShowMoreCompleted(!showMoreCompleted)} - className="text-center py-2.5 text-[10px] text-text-3 font-korean cursor-pointer hover:text-text-2 transition-colors" - > - {showMoreCompleted ? '▲ 완료 목록 접기' : '▼ 이전 완료 목록 더보기 (6건)'} + {/* 페이징 */} +
+
+ 총 {filtered.length}건 중 {(currentPage - 1) * PAGE_SIZE + 1}–{Math.min(currentPage * PAGE_SIZE, filtered.length)} +
+
+ + {Array.from({ length: totalPages }, (_, i) => i + 1).map(p => ( + + ))} + +
@@ -288,6 +400,179 @@ export function SatelliteRequest() {
+ )} + + {/* ═══ 촬영 히스토리 지도 뷰 ═══ */} + {mainTab === 'map' && (() => { + const dateFiltered = requests.filter(r => r.dateKey === mapSelectedDate) + const dateHasDots = [...new Set(requests.map(r => r.dateKey).filter(Boolean))] + return ( +
+ + {/* 선택된 날짜의 촬영 구역 폴리곤 */} + {dateFiltered.map(r => { + const coord = parseCoord(r.zoneCoord) + if (!coord) return null + const areaKm = parseFloat(r.zoneArea) || 10 + const delta = Math.sqrt(areaKm) * 0.005 + const isSelected = mapSelectedItem?.id === r.id + const statusColor = r.status === '촬영중' ? '#eab308' : r.status === '완료' ? '#22c55e' : r.status === '취소' ? '#ef4444' : '#3b82f6' + return ( + + + + + ) + })} + + {/* 선택된 완료 항목: VWorld 위성 영상 오버레이 (BlackSky 스타일) */} + {mapSelectedItem && mapSelectedItem.status === '완료' && satShowOverlay && VWORLD_API_KEY && ( + + + + )} + + {/* 선택된 항목 마커 */} + {mapSelectedItem && (() => { + const coord = parseCoord(mapSelectedItem.zoneCoord) + if (!coord) return null + return ( + +
+
+
+
+ + ) + })()} + + + {/* 좌상단: 캘린더 + 날짜별 리스트 */} +
+ {/* 캘린더 헤더 */} +
+
📅 촬영 날짜 선택
+ { setMapSelectedDate(e.target.value); setMapSelectedItem(null) }} + className="w-full px-2.5 py-1.5 bg-bg-3 border border-border rounded text-[11px] font-mono text-text-1 outline-none focus:border-[var(--cyan)] transition-colors" + /> + {/* 촬영 이력 있는 날짜 점 표시 */} +
+ {dateHasDots.map(d => ( + + ))} +
+
+ + {/* 날짜별 촬영 리스트 */} +
+
+ {mapSelectedDate} · {dateFiltered.length}건 +
+ {dateFiltered.length === 0 ? ( +
이 날짜에 촬영 이력이 없습니다
+ ) : dateFiltered.map(r => { + const statusColor = r.status === '촬영중' ? '#eab308' : r.status === '완료' ? '#22c55e' : r.status === '취소' ? '#ef4444' : '#3b82f6' + const isSelected = mapSelectedItem?.id === r.id + return ( +
setMapSelectedItem(isSelected ? null : r)} + className="px-3 py-2 border-b cursor-pointer transition-colors" + style={{ + borderColor: 'rgba(255,255,255,.04)', + background: isSelected ? 'rgba(6,182,212,.1)' : 'transparent', + }} + > +
+ {r.id} + {r.status} +
+
{r.zone}
+
{r.satellite} · {r.resolution}
+ {r.status === '완료' && ( +
📷 클릭하여 영상 보기
+ )} +
+ ) + })} +
+
+ + {/* 우상단: 범례 */} +
+
촬영 이력
+ {[ + { label: '촬영중', color: '#eab308' }, + { label: '대기', color: '#3b82f6' }, + { label: '완료', color: '#22c55e' }, + { label: '취소', color: '#ef4444' }, + ].map(item => ( +
+
+ {item.label} +
+ ))} +
총 {requests.length}건
+
+ + {/* 선택된 항목 상세 (하단) */} + {mapSelectedItem && ( +
+
+
+
{mapSelectedItem.zone}
+
{mapSelectedItem.satellite} · {mapSelectedItem.resolution} · {mapSelectedItem.zoneCoord}
+
+
+
요청
+
{mapSelectedItem.requestDate}
+
+ {mapSelectedItem.status === '완료' && ( +
+ 📷 영상 표출중 +
+ )} + +
+
+ )} +
+ ) + })()} {/* ═══ 모달: 제공자 선택 ═══ */} {modalPhase !== 'none' && ( @@ -596,7 +881,7 @@ export function SatelliteRequest() { {/* ── UP42 카탈로그 주문 ── */} {modalPhase === 'up42' && ( -
+
{/* 헤더 */}
@@ -672,83 +957,121 @@ export function SatelliteRequest() {
- {/* 오른쪽: 지도 + AOI + 패스 */} + {/* 오른쪽: 궤도 지도 + 패스 목록 */}
- {/* 지도 영역 (placeholder) */} -
- {/* 검색바 */} -
- 🔍 - -
+ {/* 지도 영역 — 위성 궤도 표시 (최소 높이 보장) */} +
+ + {/* 한국 영역 AOI 박스 */} + + + + - {/* 지도 placeholder */} -
-
-
🗺
-
지도 영역 — AOI를 그려 위성 패스를 확인하세요
+ {/* 위성 궤도 라인 */} + {satPasses.map(pass => ( + [p.lon, p.lat]) }, + }}> + + {/* 궤도 위 위성 위치 (중간점) */} + {(up42SelPass === pass.id || !up42SelPass) && ( + + )} + + ))} + + + {/* 범례 오버레이 */} +
+
🛰 위성 궤도
+ {satPasses.slice(0, 4).map(p => ( +
+
+ {p.satellite} +
+ ))} +
+
+ 한국 영역 AOI
- {/* AOI 도구 버튼 (오른쪽 사이드) */} -
-
ADD
- {[ - { icon: '⬜', title: '사각형 AOI' }, - { icon: '🔷', title: '다각형 AOI' }, - { icon: '⭕', title: '원형 AOI' }, - { icon: '📁', title: '파일 업로드' }, - ].map((t, i) => ( - - ))} -
-
AOI
- - -
- - {/* 줌 컨트롤 */} -
- - -
- - {/* 이 지역 검색 버튼 */} -
- -
+ {/* 로딩 */} + {satPassesLoading && ( +
+
🛰 위성 패스 조회 중...
+
+ )}
{/* 위성 패스 타임라인 */} -
-
🛰 오늘 가용 위성 패스 — 선택된 AOI 통과 예정
+
+
+ 🛰 한국 주변 실시간 위성 패스 ({satPasses.length}개) + 클릭하여 궤도 확인 +
- {up42Passes.map((p, i) => ( -
setUp42SelPass(up42SelPass === i ? null : i)} - className="flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer transition-colors" - style={{ - background: up42SelPass === i ? 'rgba(59,130,246,.1)' : '#161b22', - border: up42SelPass === i ? '1px solid rgba(59,130,246,.3)' : '1px solid #21262d', - }} - > -
-
- {p.sat} - {p.time} - {p.res} - {p.cloud} -
- {p.note && ( + {satPasses.map(pass => { + const start = new Date(pass.startTime) + const timeStr = `${start.getHours().toString().padStart(2, '0')}:${start.getMinutes().toString().padStart(2, '0')}` + const diffH = Math.max(0, (start.getTime() - Date.now()) / 3600000) + const urgency = diffH < 3 ? '긴급' : diffH < 8 ? '예정' : '내일' + return ( +
setUp42SelPass(up42SelPass === pass.id ? null : pass.id)} + className="flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer transition-colors" + style={{ + background: up42SelPass === pass.id ? 'rgba(59,130,246,.1)' : '#161b22', + border: up42SelPass === pass.id ? `1px solid ${pass.color}40` : '1px solid #21262d', + }} + > +
+
+
+ {pass.satellite} + {pass.provider} +
+
+ {timeStr} + {pass.resolution} + EL {pass.maxElevation}° · {pass.direction === 'ascending' ? '↗ 상승' : '↘ 하강'} +
+
{p.note} - )} - {up42SelPass === i && } -
- ))} + background: urgency === '긴급' ? 'rgba(239,68,68,.1)' : urgency === '예정' ? 'rgba(6,182,212,.1)' : 'rgba(100,116,139,.1)', + color: urgency === '긴급' ? '#ef4444' : urgency === '예정' ? '#06b6d4' : '#64748b', + }}>{urgency} + {up42SelPass === pass.id && } +
+ ) + })}
@@ -759,7 +1082,7 @@ export function SatelliteRequest() {
원하는 위성을 찾지 못했나요? 태스킹 주문 생성 또는 자세히 보기 ↗
- 선택: {up42SelSat ? up42Satellites.find(s => s.id === up42SelSat)?.name : '없음'} + 선택: {up42SelPass ? satPasses.find(p => p.id === up42SelPass)?.satellite : up42SelSat ? up42Satellites.find(s => s.id === up42SelSat)?.name : '없음'} + ))} +
+
+ + {/* 탭 콘텐츠 */} + {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 && ( +
+
📍
+
등록된 감시 구역이 없습니다
+
"+ 감시 구역 등록" 버튼으로 시작하세요
+
+ )} +
+
+
+
+
+ ); +} diff --git a/frontend/src/tabs/aerial/services/aerialApi.ts b/frontend/src/tabs/aerial/services/aerialApi.ts index 8b7d72d..d1eadb3 100644 --- a/frontend/src/tabs/aerial/services/aerialApi.ts +++ b/frontend/src/tabs/aerial/services/aerialApi.ts @@ -159,3 +159,26 @@ export async function stopDroneStreamApi(id: string): Promise<{ success: boolean const response = await api.post<{ success: boolean }>(`/aerial/drone/streams/${id}/stop`); return response.data; } + +// ============================================================ +// UP42 위성 패스 조회 +// ============================================================ + +export interface SatellitePass { + id: string; + satellite: string; + provider: string; + type: 'optical' | 'sar' | 'elevation'; + resolution: string; + color: string; + startTime: string; + endTime: string; + maxElevation: number; + direction: 'ascending' | 'descending'; + orbit: Array<{ lat: number; lon: number }>; +} + +export async function fetchSatellitePasses(): Promise { + const response = await api.get<{ passes: SatellitePass[] }>('/aerial/satellite/passes'); + return response.data.passes; +} diff --git a/frontend/src/tabs/incidents/components/DischargeZonePanel.tsx b/frontend/src/tabs/incidents/components/DischargeZonePanel.tsx new file mode 100644 index 0000000..34d1ea6 --- /dev/null +++ b/frontend/src/tabs/incidents/components/DischargeZonePanel.tsx @@ -0,0 +1,202 @@ +import { useState } from 'react' + +/** + * 해양환경관리법 제22조 기반 선박 발생 오염물 배출 규정 + * 영해기선으로부터의 거리에 따라 배출 가능 여부 결정 + * + * 법률 근거: + * https://lbox.kr/v2/statute/%ED%95%B4%EC%96%91%ED%99%98%EA%B2%BD%EA%B4%80%EB%A6%AC%EB%B2%95/%EB%B3%B8%EB%AC%B8%20%3E%20%EC%A0%9C3%EC%9E%A5%20%3E%20%EC%A0%9C1%EC%A0%88%20%3E%20%EC%A0%9C22%EC%A1%B0 + * 선박에서의 오염방지에 관한 규칙 제8조[별표 2] 및 제14조 + */ + +type Status = 'forbidden' | 'allowed' | 'conditional' + +interface DischargeRule { + category: string + item: string + zones: [Status, Status, Status, Status] // [~3NM, 3~12NM, 12~25NM, 25NM+] + condition?: string +} + +const RULES: DischargeRule[] = [ + // 폐기물 + { category: '폐기물', item: '플라스틱 제품', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'] }, + { category: '폐기물', item: '포장유해물질·용기', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'] }, + { category: '폐기물', item: '중금속 포함 쓰레기', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'] }, + // 화물잔류물 + { category: '화물잔류물', item: '부유성 화물잔류물', zones: ['forbidden', 'forbidden', 'forbidden', 'allowed'] }, + { category: '화물잔류물', item: '침강성 화물잔류물', zones: ['forbidden', 'forbidden', 'allowed', 'allowed'] }, + { category: '화물잔류물', item: '화물창 세정수', zones: ['forbidden', 'forbidden', 'conditional', 'conditional'], condition: '해양환경에 해롭지 않은 일반세제 사용시' }, + // 음식물 찌꺼기 + { category: '음식물찌꺼기', item: '미분쇄', zones: ['forbidden', 'forbidden', 'allowed', 'allowed'] }, + { category: '음식물찌꺼기', item: '분쇄·연마', zones: ['forbidden', 'conditional', 'allowed', 'allowed'], condition: '크기 25mm 이하시' }, + // 분뇨 + { category: '분뇨', item: '분뇨저장장치', zones: ['forbidden', 'forbidden', 'conditional', 'conditional'], condition: '항속 4노트 이상시 서서히 배출' }, + { category: '분뇨', item: '분뇨마쇄소독장치', zones: ['forbidden', 'conditional', 'conditional', 'conditional'], condition: '항속 4노트 이상시 / 400톤 미만 국내항해 선박은 3해리 이내 가능' }, + { category: '분뇨', item: '분뇨처리장치', zones: ['allowed', 'allowed', 'allowed', 'allowed'], condition: '수산자원보호구역, 보호수면 및 육성수면은 불가' }, + // 중수 + { category: '중수', item: '거주구역 중수', zones: ['allowed', 'allowed', 'allowed', 'allowed'], condition: '수산자원보호구역, 보호수면, 수산자원관리수면, 지정해역 등은 불가' }, + // 수산동식물 + { category: '수산동식물', item: '자연기원물질', zones: ['allowed', 'allowed', 'allowed', 'allowed'], condition: '면허 또는 허가를 득한 자에 한하여 어업활동 수면' }, +] + +const ZONE_LABELS = ['~3해리', '3~12해리', '12~25해리', '25해리+'] +const ZONE_COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e'] + +function getZoneIndex(distanceNm: number): number { + if (distanceNm < 3) return 0 + if (distanceNm < 12) return 1 + if (distanceNm < 25) return 2 + return 3 +} + +function StatusBadge({ status }: { status: Status }) { + if (status === 'forbidden') return 배출불가 + if (status === 'allowed') return 배출가능 + return 조건부 +} + +interface DischargeZonePanelProps { + lat: number + lon: number + distanceNm: number + onClose: () => void +} + +export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZonePanelProps) { + const zoneIdx = getZoneIndex(distanceNm) + const [expandedCat, setExpandedCat] = useState(null) + + const categories = [...new Set(RULES.map(r => r.category))] + + return ( +
+ {/* Header */} +
+
+
🚢 오염물 배출 규정
+
해양환경관리법 제22조
+
+ +
+ + {/* Location Info */} +
+
+ 선택 위치 + {lat.toFixed(4)}°N, {lon.toFixed(4)}°E +
+
+ 영해기선 거리 (추정) + + {distanceNm.toFixed(1)} NM + +
+ {/* Zone indicator */} +
+ {ZONE_LABELS.map((label, i) => ( +
+ {label} +
+ ))} +
+
+ + {/* Rules */} +
+ {categories.map(cat => { + const catRules = RULES.filter(r => r.category === cat) + const isExpanded = expandedCat === cat + const allForbidden = catRules.every(r => r.zones[zoneIdx] === 'forbidden') + const allAllowed = catRules.every(r => r.zones[zoneIdx] === 'allowed') + const summaryColor = allForbidden ? '#ef4444' : allAllowed ? '#22c55e' : '#eab308' + + return ( +
+
setExpandedCat(isExpanded ? null : cat)} + style={{ padding: '8px 14px' }} + > +
+
+ {cat} +
+
+ + {allForbidden ? '전체 불가' : allAllowed ? '전체 가능' : '항목별 상이'} + + {isExpanded ? '▾' : '▸'} +
+
+ + {isExpanded && ( +
+ {catRules.map((rule, i) => ( +
+ {rule.item} + +
+ ))} + {catRules.some(r => r.condition && r.zones[zoneIdx] !== 'forbidden') && ( +
+ {catRules.filter(r => r.condition && r.zones[zoneIdx] !== 'forbidden').map((r, i) => ( +
+ 💡 {r.item}: {r.condition} +
+ ))} +
+ )} +
+ )} +
+ ) + })} +
+ + {/* Footer */} +
+
+ ※ 거리는 최근접 해안선 기준 추정치입니다. 실제 영해기선과 차이가 있을 수 있습니다. +
+
+
+ ) +} diff --git a/frontend/src/tabs/incidents/components/IncidentsView.tsx b/frontend/src/tabs/incidents/components/IncidentsView.tsx index 29c41b2..77d6787 100755 --- a/frontend/src/tabs/incidents/components/IncidentsView.tsx +++ b/frontend/src/tabs/incidents/components/IncidentsView.tsx @@ -1,7 +1,8 @@ import { useState, useEffect, useMemo } from 'react' import { Map, Popup, useControl } from '@vis.gl/react-maplibre' import { MapboxOverlay } from '@deck.gl/mapbox' -import { ScatterplotLayer, IconLayer } from '@deck.gl/layers' +import { ScatterplotLayer, IconLayer, PathLayer } from '@deck.gl/layers' +import { PathStyleExtension } from '@deck.gl/extensions' import type { StyleSpecification } from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel' @@ -9,23 +10,25 @@ import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './Inci import { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData' import { fetchIncidents } from '../services/incidentsApi' import type { IncidentCompat } from '../services/incidentsApi' +import { DischargeZonePanel } from './DischargeZonePanel' +import { estimateDistanceFromCoast, getDischargeZoneLines } from '../utils/dischargeZoneData' -// ── CartoDB Dark Matter 베이스맵 ──────────────────────── +// ── CartoDB Positron 베이스맵 (밝은 테마) ──────────────── const BASE_STYLE: StyleSpecification = { version: 8, sources: { - 'carto-dark': { + 'carto-light': { 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', + 'https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png', + 'https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png', + 'https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png', ], tileSize: 256, attribution: '© OpenStreetMap', }, }, - layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }], + layers: [{ id: 'carto-light-layer', type: 'raster', source: 'carto-light' }], } // ── DeckGLOverlay ────────────────────────────────────── @@ -90,6 +93,10 @@ export function IncidentsView() { const [incidentPopup, setIncidentPopup] = useState(null) const [hoverInfo, setHoverInfo] = useState(null) + // Discharge zone mode + const [dischargeMode, setDischargeMode] = useState(false) + const [dischargeInfo, setDischargeInfo] = useState<{ lat: number; lon: number; distanceNm: number } | null>(null) + // Analysis view mode const [viewMode, setViewMode] = useState('overlay') const [analysisActive, setAnalysisActive] = useState(false) @@ -223,10 +230,30 @@ export function IncidentsView() { }) }, []) + // ── 배출 구역 경계선 레이어 ── + const dischargeZoneLayers = useMemo(() => { + if (!dischargeMode) return [] + const zoneLines = getDischargeZoneLines() + return zoneLines.map((line, i) => + new PathLayer({ + id: `discharge-zone-${i}`, + data: [line], + getPath: (d: typeof line) => d.path, + getColor: (d: typeof line) => d.color, + getWidth: 2, + widthUnits: 'pixels', + getDashArray: [6, 3], + dashJustified: true, + extensions: [new PathStyleExtension({ dash: true })], + pickable: false, + }) + ) + }, [dischargeMode]) + // eslint-disable-next-line @typescript-eslint/no-explicit-any const deckLayers: any[] = useMemo( - () => [incidentLayer, vesselIconLayer], - [incidentLayer, vesselIconLayer], + () => [incidentLayer, vesselIconLayer, ...dischargeZoneLayers], + [incidentLayer, vesselIconLayer, dischargeZoneLayers], ) return ( @@ -320,8 +347,17 @@ export function IncidentsView() { { + if (dischargeMode && e.lngLat) { + const lat = e.lngLat.lat + const lon = e.lngLat.lng + const distanceNm = estimateDistanceFromCoast(lat, lon) + setDischargeInfo({ lat, lon, distanceNm }) + } + }} + cursor={dischargeMode ? 'crosshair' : undefined} > @@ -428,6 +464,57 @@ export function IncidentsView() {
)} + {/* 오염물 배출 규정 토글 */} + + + {/* 오염물 배출 규정 패널 */} + {dischargeMode && dischargeInfo && ( + setDischargeInfo(null)} + /> + )} + + {/* 배출규정 모드 안내 */} + {dischargeMode && !dischargeInfo && ( +
+ 📍 지도를 클릭하여 배출 규정을 확인하세요 +
+ )} + {/* AIS Live Badge */}
[lon, lat] as [number, number]), + color: zone.color, + label: zone.label, + distanceNm: zone.nm, + }) + const jejuOffset = offsetCoastline(JEJU_POINTS, zone.nm, +1) + lines.push({ + path: jejuOffset.map(([lat, lon]) => [lon, lat] as [number, number]), + color: zone.color, + label: `${zone.label} (제주)`, + distanceNm: zone.nm, + }) + } + return lines +} diff --git a/frontend/src/tabs/prediction/components/PredictionInputSection.tsx b/frontend/src/tabs/prediction/components/PredictionInputSection.tsx index c8d7a57..01a25fd 100644 --- a/frontend/src/tabs/prediction/components/PredictionInputSection.tsx +++ b/frontend/src/tabs/prediction/components/PredictionInputSection.tsx @@ -1,5 +1,4 @@ -import { useState, useRef } from 'react' -import { decimalToDMS } from '@common/utils/coordinates' +import { useState, useRef, useEffect } from 'react' import { ComboBox } from '@common/components/ui/ComboBox' import type { PredictionModel } from './OilSpillView' import { analyzeImage } from '../services/predictionApi' @@ -267,54 +266,33 @@ const PredictionInputSection = ({ {/* 사고 발생 시각 */}
- onAccidentTimeChange(e.target.value)} - style={{ colorScheme: 'dark' }} + onChange={onAccidentTimeChange} />
- {/* Coordinates + Map Button */} + {/* Coordinates (DMS) + Map Button */}
-
- { - const value = e.target.value === '' ? 0 : parseFloat(e.target.value) - onCoordChange({ lon: incidentCoord?.lon ?? 0, lat: isNaN(value) ? 0 : value }) - }} - placeholder="위도°" - /> - { - const value = e.target.value === '' ? 0 : parseFloat(e.target.value) - onCoordChange({ lat: incidentCoord?.lat ?? 0, lon: isNaN(value) ? 0 : value }) - }} - placeholder="경도°" +
+ onCoordChange({ lon: incidentCoord?.lon ?? 0, lat: val })} /> + style={{ gridRow: '1 / 3', gridColumn: 2, whiteSpace: 'nowrap', height: '100%', minWidth: 48, padding: '0 10px' }} + >📍
지도 + onCoordChange({ lat: incidentCoord?.lat ?? 0, lon: val })} + />
- {/* 도분초 표시 */} - {incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && ( -
- {decimalToDMS(incidentCoord.lat, true)} / {decimalToDMS(incidentCoord.lon, false)} -
- )}
{/* Oil Type + Oil Kind */} @@ -384,7 +362,7 @@ const PredictionInputSection = ({ {/* Model Selection (다중 선택) */} {/* POSEIDON: 엔진 연동 완료. KOSPS: 준비 중 (ready: false) */} -
+
{([ { id: 'KOSPS' as PredictionModel, color: 'var(--cyan)', ready: false }, { id: 'POSEIDON' as PredictionModel, color: 'var(--red)', ready: true }, @@ -392,7 +370,7 @@ const PredictionInputSection = ({ ] as const).map(m => (
{ if (!m.ready) { alert(`${m.id} 모델은 현재 준비중입니다.`) @@ -445,4 +423,294 @@ const PredictionInputSection = ({ ) } +// ── 커스텀 날짜/시간 선택 컴포넌트 ───────────────────── +function DateTimeInput({ value, onChange }: { value: string; onChange: (v: string) => void }) { + const [showCal, setShowCal] = useState(false) + const ref = useRef(null) + + const datePart = value ? value.split('T')[0] : '' + const timePart = value && value.includes('T') ? value.split('T')[1] : '00:00' + const [hh, mm] = timePart.split(':').map(Number) + + const parsed = datePart ? new Date(datePart + 'T00:00:00') : new Date() + const [viewYear, setViewYear] = useState(parsed.getFullYear()) + const [viewMonth, setViewMonth] = useState(parsed.getMonth()) + + const selY = datePart ? parsed.getFullYear() : -1 + const selM = datePart ? parsed.getMonth() : -1 + const selD = datePart ? parsed.getDate() : -1 + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setShowCal(false) + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, []) + + const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate() + const firstDay = new Date(viewYear, viewMonth, 1).getDay() + const days: (number | null)[] = [] + for (let i = 0; i < firstDay; i++) days.push(null) + for (let i = 1; i <= daysInMonth; i++) days.push(i) + + const pickDate = (day: number) => { + const m = String(viewMonth + 1).padStart(2, '0') + const d = String(day).padStart(2, '0') + onChange(`${viewYear}-${m}-${d}T${timePart}`) + setShowCal(false) + } + + const updateTime = (newHH: number, newMM: number) => { + const date = datePart || new Date().toISOString().split('T')[0] + onChange(`${date}T${String(newHH).padStart(2, '0')}:${String(newMM).padStart(2, '0')}`) + } + + const prevMonth = () => { + if (viewMonth === 0) { setViewYear(viewYear - 1); setViewMonth(11) } + else setViewMonth(viewMonth - 1) + } + const nextMonth = () => { + if (viewMonth === 11) { setViewYear(viewYear + 1); setViewMonth(0) } + else setViewMonth(viewMonth + 1) + } + + const displayDate = datePart + ? `${selY}.${String(selM + 1).padStart(2, '0')}.${String(selD).padStart(2, '0')}` + : '날짜 선택' + + const today = new Date() + const todayY = today.getFullYear() + const todayM = today.getMonth() + const todayD = today.getDate() + + return ( +
+ {/* 날짜 버튼 */} + + + {/* 시 */} + updateTime(v, mm)} /> + : + {/* 분 */} + updateTime(hh, v)} /> + + {/* 캘린더 팝업 */} + {showCal && ( +
+ {/* 헤더 */} +
+ + {viewYear}년 {viewMonth + 1}월 + +
+ {/* 요일 */} +
+ {['일', '월', '화', '수', '목', '금', '토'].map((d) => ( + {d} + ))} +
+ {/* 날짜 */} +
+ {days.map((day, i) => { + if (day === null) return + const isSelected = viewYear === selY && viewMonth === selM && day === selD + const isToday = viewYear === todayY && viewMonth === todayM && day === todayD + return ( + + ) + })} +
+ {/* 오늘 버튼 */} +
+ +
+
+ )} +
+ ) +} + +// ── 커스텀 시간 드롭다운 (다크 테마) ─────────────────── +function TimeDropdown({ value, max, onChange }: { value: number; max: number; onChange: (v: number) => void }) { + const [open, setOpen] = useState(false) + const dropRef = useRef(null) + const listRef = useRef(null) + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (dropRef.current && !dropRef.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, []) + + useEffect(() => { + if (open && listRef.current) { + const activeEl = listRef.current.querySelector('[data-active="true"]') + if (activeEl) activeEl.scrollIntoView({ block: 'center' }) + } + }, [open]) + + return ( +
+ + {open && ( +
+ {Array.from({ length: max }, (_, i) => ( + + ))} +
+ )} +
+ ) +} + +// ── 도분초 좌표 입력 컴포넌트 ────────────────────────── +function DmsCoordInput({ + label, + isLatitude, + decimal, + onChange, +}: { + label: string + isLatitude: boolean + decimal: number + onChange: (val: number) => void +}) { + const abs = Math.abs(decimal) + const d = Math.floor(abs) + const mDec = (abs - d) * 60 + const m = Math.floor(mDec) + const s = parseFloat(((mDec - m) * 60).toFixed(2)) + const dir = isLatitude ? (decimal >= 0 ? 'N' : 'S') : (decimal >= 0 ? 'E' : 'W') + + const update = (deg: number, min: number, sec: number, direction: string) => { + let val = deg + min / 60 + sec / 3600 + if (direction === 'S' || direction === 'W') val = -val + onChange(val) + } + + const fieldStyle = { padding: '5px 2px', fontSize: 10, minWidth: 0 } + + return ( +
+ {label} +
+ + update(parseInt(e.target.value) || 0, m, s, dir)} style={fieldStyle} /> + ° + update(d, parseInt(e.target.value) || 0, s, dir)} style={fieldStyle} /> + ' + update(d, m, parseFloat(e.target.value) || 0, dir)} style={fieldStyle} /> + " +
+
+ ) +} + export default PredictionInputSection diff --git a/frontend/src/tabs/weather/components/WeatherRightPanel.tsx b/frontend/src/tabs/weather/components/WeatherRightPanel.tsx index 1911606..1f5afe0 100755 --- a/frontend/src/tabs/weather/components/WeatherRightPanel.tsx +++ b/frontend/src/tabs/weather/components/WeatherRightPanel.tsx @@ -96,12 +96,34 @@ function StatCard({ value, label, valueClass = 'text-primary-cyan' }: { value: s /* ── Main Component ───────────────────────────────────────── */ +/** 풍속 등급 색상 */ +function windColor(speed: number): string { + if (speed >= 14) return '#ef4444' + if (speed >= 10) return '#f97316' + if (speed >= 6) return '#eab308' + return '#22c55e' +} + +/** 파고 등급 색상 */ +function waveColor(height: number): string { + if (height >= 3) return '#ef4444' + if (height >= 2) return '#f97316' + if (height >= 1) return '#eab308' + return '#22c55e' +} + +/** 풍향 텍스트 */ +function windDirText(deg: number): string { + const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'] + return dirs[Math.round(deg / 22.5) % 16] +} + export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) { if (!weatherData) { return ( -
+
-

지도에서 해양 지점을 클릭하세요

+

지도에서 해양 지점을 클릭하세요

); @@ -121,7 +143,15 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) { 기상예보관 +
+ {/* 헤더 */} +
+
+ 📍 {weatherData.stationName} + 기상예보관
+

+ {weatherData.location.lat.toFixed(2)}°N, {weatherData.location.lon.toFixed(2)}°E · {weatherData.currentTime}

{weatherData.location.lat.toFixed(2)}°N, {weatherData.location.lon.toFixed(2)}°E · {weatherData.currentTime}

@@ -143,14 +173,71 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) { {wave.height.toFixed(1)} 파고 (m) + {/* 스크롤 콘텐츠 */} +
+ + {/* ── 핵심 지표 3칸 카드 ── */} +
+
+
{wSpd.toFixed(1)}
+
풍속 (m/s)
+
+
+
{wHgt.toFixed(1)}
+
파고 (m)
+
+
+
{wTemp.toFixed(1)}
+
수온 (°C)
+
+
+ + {/* ── 바람 상세 ── */} +
+
🌬️ 바람 현황
+
+ {/* 풍향 컴파스 */} +
+ + + + {['N', 'E', 'S', 'W'].map((d, i) => { + const angle = i * 90 + const rad = (angle - 90) * Math.PI / 180 + const x = 25 + 20 * Math.cos(rad) + const y = 25 + 20 * Math.sin(rad) + return {d} + })} + {/* 풍향 화살표 */} + + +
{temperature.current.toFixed(1)} 수온 (°C) +
+
풍향{windDir} {weatherData.wind.direction}°
+
기압{weatherData.pressure} hPa
+
1k 최고{Number(weatherData.wind.speed_1k).toFixed(1)}
+
3k 평균{Number(weatherData.wind.speed_3k).toFixed(1)}
+
가시거리{weatherData.visibility} km
+ {/* 풍속 게이지 바 */} +
+
+
+
+ {wSpd.toFixed(1)}/20 +
{/* ── 바람 현황 ──────────────────────────────────── */} @@ -164,7 +251,33 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) { 1k 최고{wind.speed_1k.toFixed(1)} 3k 평균{wind.speed_3k.toFixed(1)} 가시거리{visibility} km + {/* ── 파도 상세 ── */} +
+
🌊 파도
+
+
+
{wHgt.toFixed(1)}m
+
유의파고
+
+
{(wHgt * 1.6).toFixed(1)}m
+
최고파고
+
+
+
{weatherData.wave.period}s
+
주기
+
+
+
NW
+
파향
+
+
+ {/* 파고 게이지 바 */} +
+
+
+
+ {wHgt.toFixed(1)}/5m
+ {/* ── 수온/공기 ── */} +
+
🌡️ 수온 · 공기
+
+
+
{wTemp.toFixed(1)}°
+
수온
+
+
+
2.1°
+
기온
+
+
+
31.2
+
염분(PSU)
+
@@ -214,6 +343,16 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) { {fc.icon} {fc.temperature}° {fc.windSpeed} + {/* ── 시간별 예보 ── */} +
+
⏰ 시간별 예보
+
+ {weatherData.forecast.map((f, i) => ( +
+ {f.hour} + {f.icon} + {f.temperature}° + {f.windSpeed}
))}
@@ -269,8 +408,42 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) { {alert}
+ {/* ── 천문/조석 ── */} +
+
☀️ 천문 · 조석
+
+ {[ + { icon: '🌅', label: '일출', value: sunriseTime }, + { icon: '🌄', label: '일몰', value: sunsetTime }, + { icon: '🌙', label: '월출', value: moonrise }, + { icon: '🌜', label: '월몰', value: moonset }, + ].map((item, i) => ( +
+
{item.icon}
+
{item.label}
+
{item.value}
+
+ ))} +
+
+ 🌓 + 상현달 14일 + 조차 6.7m
)} +
+ + {/* ── 날씨 특보 ── */} +
+
🚨 날씨 특보
+
+
+ 주의 + 풍랑주의보 예상 08:00~ +
+
+
+
); diff --git a/frontend/src/tabs/weather/components/WeatherView.tsx b/frontend/src/tabs/weather/components/WeatherView.tsx index 2fb34ac..66fbed2 100755 --- a/frontend/src/tabs/weather/components/WeatherView.tsx +++ b/frontend/src/tabs/weather/components/WeatherView.tsx @@ -396,99 +396,74 @@ export function WeatherView() { {/* 레이어 컨트롤 */} -
-
기상 레이어
-
-