From 044994bd57978fe7ef80a67e9e47305d936815f7 Mon Sep 17 00:00:00 2001 From: Nan Kyung Lee Date: Tue, 17 Mar 2026 10:11:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(aerial):=20UP42=20=EC=9C=84=EC=84=B1=20?= =?UTF-8?q?=ED=8C=A8=EC=8A=A4=20=EC=A1=B0=ED=9A=8C=20+=20=EA=B6=A4?= =?UTF-8?q?=EB=8F=84=20=EC=A7=80=EB=8F=84=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 백엔드: GET /api/aerial/satellite/passes — 한국 주변 위성 패스 시뮬레이션 UP42 API 연동 준비 (Workspace ID: b9bc92ae, TODO 주석) 6개 위성 궤도 데이터 (KOMPSAT-3A, Pléiades Neo, Sentinel-1/2, WV-3, SkySat) - 프론트 API: fetchSatellitePasses() + SatellitePass 인터페이스 - UP42 모달: MapLibre 지도에 위성 궤도 라인 실시간 표시 한국 영역 AOI 점선 박스 + 궤도별 색상 구분 위성 클릭 시 해당 궤도 하이라이트 (나머지 투명) - 패스 타임라인: 통과 시각, 해상도, 앙각, 상승/하강 방향, 긴급도 표시 - 궤도 범례 오버레이 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/src/aerial/aerialRouter.ts | 97 ++++++++ .../aerial/components/SatelliteRequest.tsx | 226 ++++++++++++------ .../src/tabs/aerial/services/aerialApi.ts | 23 ++ 3 files changed, 269 insertions(+), 77 deletions(-) diff --git a/backend/src/aerial/aerialRouter.ts b/backend/src/aerial/aerialRouter.ts index 113ad87..6823ccf 100644 --- a/backend/src/aerial/aerialRouter.ts +++ b/backend/src/aerial/aerialRouter.ts @@ -396,6 +396,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/frontend/src/tabs/aerial/components/SatelliteRequest.tsx b/frontend/src/tabs/aerial/components/SatelliteRequest.tsx index 613e408..40194e2 100644 --- a/frontend/src/tabs/aerial/components/SatelliteRequest.tsx +++ b/frontend/src/tabs/aerial/components/SatelliteRequest.tsx @@ -1,4 +1,9 @@ -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 { fetchSatellitePasses } from '../services/aerialApi' +import type { SatellitePass } from '../services/aerialApi' interface SatRequest { id: string @@ -59,13 +64,23 @@ 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 }], +} type SatModalPhase = 'none' | 'provider' | 'blacksky' | 'up42' @@ -77,10 +92,24 @@ export function SatelliteRequest() { // 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 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,6 +120,11 @@ export function SatelliteRequest() { return () => document.removeEventListener('mousedown', handler) }, [modalPhase]) + // UP42 모달 열릴 때 위성 패스 로드 + useEffect(() => { + if (modalPhase === 'up42') loadSatPasses() + }, [modalPhase, loadSatPasses]) + const allRequests = showMoreCompleted ? satRequests : satRequests.filter(r => r.status !== '완료' || r.id === 'SAT-003') const filtered = allRequests.filter(r => { @@ -672,83 +706,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 +831,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 : '없음'}