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 zone: string zoneCoord: string zoneArea: string satellite: string requestDate: string expectedReceive: string resolution: string 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: '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 = [ { name: 'KOMPSAT-3A', desc: '해상도 0.5m · 광학 / IR · 촬영 가능', status: '가용', statusColor: 'var(--green)', borderColor: 'rgba(34,197,94,.2)', pulse: true }, { name: 'KOMPSAT-3', desc: '해상도 1.0m · 광학 · 임무 중', status: '임무중', statusColor: 'var(--yellow)', borderColor: 'rgba(234,179,8,.2)', pulse: true }, { name: 'Sentinel-1 (ESA)', desc: '해상도 20m · SAR · 야간/우천 가능', status: '가용', statusColor: 'var(--green)', borderColor: 'var(--bd)', pulse: false }, { name: 'Sentinel-2 (ESA)', desc: '해상도 10m · 다분광 · 수질 분석 적합', status: '가용', statusColor: 'var(--green)', borderColor: 'var(--bd)', pulse: false }, ] const passSchedules = [ { time: '14:10 – 14:24', desc: 'KOMPSAT-3A 패스 (제주 남방)', today: true }, { time: '16:55 – 17:08', desc: 'Sentinel-1 패스 (제주 전역)', today: true }, { time: '내일 09:12', desc: 'KOMPSAT-3 패스 (가파도~마라도)', today: false }, { time: '내일 10:40', desc: 'Sentinel-2 패스 (제주 서측)', today: false }, ] // UP42 위성 카탈로그 데이터 const up42Satellites = [ { id: 'mwl-hd15', name: 'Maxar WorldView Legion HD15', res: '0.3m', type: 'optical' as const, color: '#3b82f6', cloud: 15 }, { id: 'pneo-hd15', name: 'Pléiades Neo HD15', res: '0.3m', type: 'optical' as const, color: '#06b6d4', cloud: 10 }, { id: 'mwl', name: 'Maxar WorldView Legion', res: '0.5m', type: 'optical' as const, color: '#3b82f6', cloud: 20 }, { id: 'mwv3', name: 'Maxar WorldView-3', res: '0.5m', type: 'optical' as const, color: '#3b82f6', cloud: 20 }, { id: 'pneo', name: 'Pléiades Neo', res: '0.5m', type: 'optical' as const, color: '#06b6d4', cloud: 15 }, { id: 'bj3n', name: 'Beijing-3N', res: '0.5m', type: 'optical' as const, color: '#f97316', cloud: 20, delay: true }, { id: 'skysat', name: 'SkySat', res: '0.7m', type: 'optical' as const, color: '#22c55e', cloud: 15 }, { id: 'kmp3a', name: 'KOMPSAT-3A', res: '0.5m', type: 'optical' as const, color: '#a855f7', cloud: 10 }, { id: 'kmp3', name: 'KOMPSAT-3', res: '1.0m', type: 'optical' as const, color: '#a855f7', cloud: 15 }, { id: 'spot7', name: 'SPOT 7', res: '1.5m', type: 'optical' as const, color: '#eab308', cloud: 20 }, { id: 's2', name: 'Sentinel-2', res: '10m', type: 'optical' as const, color: '#ec4899', cloud: 20 }, { id: 's1', name: 'Sentinel-1 SAR', res: '20m', type: 'sar' as const, color: '#f59e0b', cloud: 0 }, { id: 'alos2', name: 'ALOS-2 PALSAR-2', res: '3m', type: 'sar' as const, color: '#f59e0b', cloud: 0 }, { id: 'rcm', name: 'RCM (Radarsat)', res: '3m', type: 'sar' as const, color: '#f59e0b', cloud: 0 }, { id: 'srtm', name: 'SRTM DEM', res: '30m', type: 'elevation' as const, color: '#64748b', cloud: 0 }, { id: 'cop-dem', name: 'Copernicus DEM', res: '10m', type: 'elevation' as const, color: '#64748b', cloud: 0 }, ] // 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 [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 [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)) { setModalPhase('none') } } if (modalPhase !== 'none') document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [modalPhase]) // UP42 모달 열릴 때 위성 패스 로드 useEffect(() => { if (modalPhase === 'up42') loadSatPasses() }, [modalPhase, loadSatPasses]) 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 ( 촬영중 ) if (s === '대기') return ( ⏳ 대기 ) if (s === '취소') return ( ✕ 취소 ) return ( ✅ 완료 ) } const stats = [ { value: '3', label: '요청 대기', color: 'var(--blue)' }, { value: '1', label: '촬영 진행 중', color: 'var(--yellow)' }, { value: '7', label: '수신 완료', color: 'var(--green)' }, { value: '0.5m', label: '최고 해상도', color: 'var(--cyan)' }, ] const filters = ['전체', '대기', '진행', '완료', '취소'] const up42Filtered = up42Satellites.filter(s => s.type === up42SubTab) // ── 섹션 헤더 헬퍼 (BlackSky 폼) ── const sectionHeader = (num: number, label: string) => (
{num}
{label}
) const bsInput = "w-full px-3 py-2 rounded-md text-[11px] font-korean outline-none box-border" const bsInputStyle = { border: '1px solid #21262d', background: '#161b22', color: '#e2e8f0' } return (
{/* 헤더 + 탭 + 새요청 한 줄 (높이 통일) */}
🛰
위성 촬영 요청
{mainTab === 'list' && (<> {/* 요약 통계 */}
{stats.map((s, i) => (
{s.value}
{s.label}
))}
{/* 요청 목록 */}
📋 위성 요청 목록
{filters.map(f => ( ))}
{/* 헤더 행 */}
{['번호', '촬영 구역', '위성', '요청일시', '예상수신', '해상도', '상태'].map(h => (
{h}
))}
{/* 데이터 행 (페이징) */} {pagedItems.map(r => (
setSelectedRequest(selectedRequest?.id === r.id ? null : r)} className="grid gap-0 px-4 py-3 border-b items-center cursor-pointer hover:bg-bg-hover/30 transition-colors" style={{ 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 === '완료' || r.status === '취소') ? 0.6 : 1, }} >
{r.id}
{r.zone}
{r.zoneCoord} · {r.zoneArea}
{r.satellite}
{r.requestDate}
{r.expectedReceive}
{r.resolution}
{statusBadge(r.status)}
{/* 상세 정보 패널 */} {selectedRequest?.id === r.id && (
{[ ['제공자', r.provider || '-'], ['요청 목적', r.purpose || '-'], ['요청자', r.requester || '-'], ['촬영 면적', r.zoneArea], ].map(([k, v], i) => (
{k}
{v}
))}
{r.status === '완료' && ( )} {(r.status === '대기' || r.status === '촬영중') && ( )}
)}
))} {/* 페이징 */}
총 {filtered.length}건 중 {(currentPage - 1) * PAGE_SIZE + 1}–{Math.min(currentPage * PAGE_SIZE, filtered.length)}
{Array.from({ length: totalPages }, (_, i) => i + 1).map(p => ( ))}
{/* 위성 궤도 정보 */}
{/* 가용 위성 현황 */}
🛰 가용 위성 현황
{satellites.map((sat, i) => (
{sat.name}
{sat.desc}
{sat.status}
))}
{/* 오늘 촬영 가능 시간 */}
⏰ 오늘 촬영 가능 시간 (KST)
{passSchedules.map((ps, i) => (
{ps.time} {ps.desc}
))}
)} {/* ═══ 촬영 히스토리 지도 뷰 ═══ */} {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' && (
{/* ── 제공자 선택 ── */} {modalPhase === 'provider' && (
{/* 헤더 */}
🛰
위성 촬영 요청 — 제공자 선택
요청할 위성 서비스 제공자를 선택하세요
{/* 제공자 카드 */}
{/* BlackSky (Maxar) */}
setModalPhase('blacksky')} className="cursor-pointer bg-bg-2 border border-border rounded-xl p-5 relative overflow-hidden hover:border-[rgba(99,102,241,.5)] hover:bg-[rgba(99,102,241,.04)] transition-all" >
BSky
BlackSky
Maxar Electro-Optical API
API 연결됨
{[ ['유형', 'EO (광학)', '#818cf8'], ['해상도', '~1m', 'var(--t1)'], ['재방문', '≤1시간', 'var(--t1)'], ['납기', '90분 이내', '#22c55e'], ].map(([k, v, c], i) => (
{k}
{v}
))}
고빈도 소형위성 군집 기반 긴급 촬영. 해양 사고 현장 신속 모니터링에 최적화. Dawn-to-Dusk 촬영 가능.
API: eapi.maxar.com/e1so/rapidoc
{/* UP42 (EO + SAR) */}
setModalPhase('up42')} className="cursor-pointer bg-bg-2 border border-border rounded-xl p-5 relative overflow-hidden hover:border-[rgba(59,130,246,.5)] hover:bg-[rgba(59,130,246,.04)] transition-all" >
up42
UP42 — EO + SAR
Optical · SAR · Elevation 통합 마켓플레이스
API 연결됨
{[ ['유형', 'EO + SAR', '#60a5fa'], ['해상도', '0.3~5m', 'var(--t1)'], ['위성 수', '16+ 컬렉션', 'var(--t1)'], ['야간/악천후', 'SAR 가능', '#22c55e'], ].map(([k, v, c], i) => (
{k}
{v}
))}
{['Pléiades Neo', 'SPOT 6/7'].map((t, i) => ( {t} ))} {['TerraSAR-X', 'Capella SAR', 'ICEYE'].map((t, i) => ( {t} ))} +11 more
광학(EO) + 합성개구레이더(SAR) 통합 마켓. 야간·악천후 시 SAR 활용. 다중 위성 소스 자동 최적 선택.
API: up42.com
{/* 하단 */}
💡 긴급 촬영: BlackSky 권장 (90분 납기) · 야간/악천후: UP42 SAR 권장
)} {/* ── BlackSky 긴급 촬영 요청 ── */} {modalPhase === 'blacksky' && (
{/* 헤더 */}
BSky
BlackSky — 긴급 위성 촬영 요청
Maxar E1SO RapiDoc API · 고빈도 긴급 태스킹
API Docs ↗
{/* 본문 */}
{/* API 상태 */}
API Connected eapi.maxar.com/e1so/rapidoc · Latency: 142ms Quota: 47/50 요청 잔여
{/* ① 태스킹 유형 */}
{sectionHeader(1, '태스킹 유형 · 우선순위')}
{/* ② AOI 지정 */}
{sectionHeader(2, '관심 영역 (AOI)')}
{/* ③ 촬영 기간 */}
{sectionHeader(3, '촬영 기간 · 반복')}
{/* ④ 산출물 설정 */}
{sectionHeader(4, '산출물 설정')}
{[ { label: '유출유 탐지 분석 (자동)', checked: true }, { label: 'GIS 상황판 자동 오버레이', checked: true }, { label: '변화탐지 (Change Detection)', checked: false }, { label: '웹훅 알림', checked: false }, ].map((opt, i) => ( ))}
{/* ⑤ 연계 사고 · 비고 */}
{sectionHeader(5, '연계 사고 · 비고')}