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 8b6589c..36a2bc1 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 6c01447..af07f7f 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 (
+
+
+
+ {/* 좌상단: 캘린더 + 날짜별 리스트 */}
+
+ {/* 캘린더 헤더 */}
+
+
📅 촬영 날짜 선택
+
{ 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 => (
+
+ ))}
+
총 {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) */}
-
- {/* 검색바 */}
-
- 🔍
-
-
+ {/* 지도 영역 — 위성 궤도 표시 (최소 높이 보장) */}
+
+