wing-ops/frontend/src/tabs/aerial/components/SatelliteRequest.tsx
Nan Kyung Lee 7110d76276 feat(aerial): WingAI (AI 탐지/분석) 서브탭 추가
- MMSI 선종 불일치 탐지: AIS 등록 선종 vs AI 영상 분석 선종 비교, 지도 위 위치 표시
- 변화 감지: AS-IS/현재 시점 복합 정보원(위성/CCTV/드론/AIS) 오버레이 비교
- 연안자동감지: 지도 폴리곤 드로잉으로 감시 구역 등록, 주기/모니터링 방법 설정
- 위성요청 라벨 '위성영상'으로 변경, 서브탭 순서 재배치
- aerial:spectral 권한 트리 마이그레이션 추가 (022)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 12:07:47 +09:00

1109 lines
70 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<SatModalPhase>('none')
const [selectedRequest, setSelectedRequest] = useState<SatRequest | null>(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<string | null>(null)
const [up42SelPass, setUp42SelPass] = useState<string | null>(null)
const [satPasses, setSatPasses] = useState<SatellitePass[]>([])
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<SatRequest | null>(null)
const satImgOpacity = 90
const satImgBrightness = 100
const satShowOverlay = true
const modalRef = useRef<HTMLDivElement>(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 (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(234,179,8,.15)', border: '1px solid rgba(234,179,8,.3)', color: 'var(--yellow)' }}>
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--yellow)' }} />
</span>
)
if (s === '대기') return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(59,130,246,.15)', border: '1px solid rgba(59,130,246,.3)', color: 'var(--blue)' }}> </span>
)
if (s === '취소') return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(239,68,68,.1)', border: '1px solid rgba(239,68,68,.2)', color: 'var(--red)' }}> </span>
)
return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(34,197,94,.1)', border: '1px solid rgba(34,197,94,.2)', color: 'var(--green)' }}> </span>
)
}
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) => (
<div className="text-[11px] font-bold font-korean mb-2.5 flex items-center gap-1.5 text-[#818cf8]">
<div className="w-[18px] h-[18px] rounded-[5px] flex items-center justify-center text-[9px] font-bold text-[#818cf8]" style={{ background: 'rgba(99,102,241,.12)' }}>{num}</div>
{label}
</div>
)
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 (
<div className="overflow-y-auto px-6 pt-1 pb-2" style={{ height: mainTab === 'map' ? '100%' : undefined }}>
{/* 헤더 + 탭 + 새요청 한 줄 (높이 통일) */}
<div className="flex items-center gap-3 mb-2 h-9">
<div className="flex items-center gap-2 shrink-0">
<div className="w-7 h-7 rounded-md flex items-center justify-center text-sm" style={{ background: 'linear-gradient(135deg,rgba(59,130,246,.2),rgba(168,85,247,.2))', border: '1px solid rgba(59,130,246,.3)' }}>🛰</div>
<div className="text-[12px] font-bold font-korean text-text-1"> </div>
</div>
<div className="flex gap-1 h-7">
<button
onClick={() => setMainTab('list')}
className="px-2.5 h-full rounded text-[10px] font-bold font-korean cursor-pointer border transition-colors"
style={mainTab === 'list'
? { background: 'rgba(59,130,246,.12)', borderColor: 'rgba(59,130,246,.3)', color: 'var(--blue)' }
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }
}
>📋 </button>
<button
onClick={() => setMainTab('map')}
className="px-2.5 h-full rounded text-[10px] font-bold font-korean cursor-pointer border transition-colors"
style={mainTab === 'map'
? { background: 'rgba(59,130,246,.12)', borderColor: 'rgba(59,130,246,.3)', color: 'var(--blue)' }
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }
}
>🗺 </button>
</div>
<button onClick={() => setModalPhase('provider')} className="ml-auto px-3 h-7 text-white border-none rounded-sm text-[10px] font-semibold cursor-pointer font-korean flex items-center gap-1 shrink-0" style={{ background: 'linear-gradient(135deg,var(--blue),var(--purple))' }}>🛰 </button>
</div>
{mainTab === 'list' && (<>
{/* 요약 통계 */}
<div className="grid grid-cols-4 gap-3 mb-5">
{stats.map((s, i) => (
<div key={i} className="bg-bg-2 border border-border rounded-md p-3.5 text-center">
<div className="text-[22px] font-bold font-mono" style={{ color: s.color }}>{s.value}</div>
<div className="text-[10px] text-text-3 mt-1 font-korean">{s.label}</div>
</div>
))}
</div>
{/* 요청 목록 */}
<div className="bg-bg-2 border border-border rounded-md overflow-hidden mb-5">
<div className="flex items-center justify-between px-4 py-3.5 border-b border-border">
<div className="text-[13px] font-bold font-korean text-text-1">📋 </div>
<div className="flex gap-1.5">
{filters.map(f => (
<button
key={f}
onClick={() => setStatusFilter(f)}
className="px-2.5 py-1 rounded text-[10px] font-semibold cursor-pointer font-korean border"
style={statusFilter === f
? { background: 'rgba(59,130,246,.15)', color: 'var(--blue)', borderColor: 'rgba(59,130,246,.3)' }
: { background: 'var(--bg3)', color: 'var(--t2)', borderColor: 'var(--bd)' }
}
>{f}</button>
))}
</div>
</div>
{/* 헤더 행 */}
<div className="grid gap-0 px-4 py-2 bg-bg-3 border-b border-border" style={{ gridTemplateColumns: '60px 1fr 100px 100px 120px 80px 90px' }}>
{['번호', '촬영 구역', '위성', '요청일시', '예상수신', '해상도', '상태'].map(h => (
<div key={h} className="text-[9px] font-bold text-text-3 font-korean uppercase tracking-wider">{h}</div>
))}
</div>
{/* 데이터 행 (페이징) */}
{pagedItems.map(r => (
<div key={r.id}>
<div
onClick={() => 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,
}}
>
<div className="text-[11px] font-mono text-text-2">{r.id}</div>
<div>
<div className="text-xs font-semibold text-text-1 font-korean">{r.zone}</div>
<div className="text-[10px] text-text-3 font-mono mt-0.5">{r.zoneCoord} · {r.zoneArea}</div>
</div>
<div className="text-[11px] font-semibold text-text-1 font-korean">{r.satellite}</div>
<div className="text-[10px] text-text-2 font-mono">{r.requestDate}</div>
<div className="text-[10px] font-semibold font-mono" style={{ color: r.status === '촬영중' ? 'var(--yellow)' : 'var(--t2)' }}>{r.expectedReceive}</div>
<div className="text-[11px] font-bold font-mono" style={{ color: r.status === '완료' ? 'var(--t3)' : 'var(--cyan)' }}>{r.resolution}</div>
<div>{statusBadge(r.status)}</div>
</div>
{/* 상세 정보 패널 */}
{selectedRequest?.id === r.id && (
<div className="px-4 py-3 border-b" style={{ borderColor: 'rgba(255,255,255,.04)', background: 'rgba(99,102,241,.03)' }}>
<div className="grid grid-cols-4 gap-3 mb-2">
{[
['제공자', r.provider || '-'],
['요청 목적', r.purpose || '-'],
['요청자', r.requester || '-'],
['촬영 면적', r.zoneArea],
].map(([k, v], i) => (
<div key={i} className="px-2.5 py-2 bg-bg-0 rounded">
<div className="text-[8px] font-bold text-text-3 font-korean mb-1 uppercase">{k}</div>
<div className="text-[10px] font-semibold text-text-1 font-korean">{v}</div>
</div>
))}
</div>
<div className="flex gap-2">
<button className="px-3 py-1.5 text-[10px] font-semibold font-korean rounded border cursor-pointer hover:bg-bg-hover transition-colors" style={{ background: 'rgba(6,182,212,.08)', borderColor: 'rgba(6,182,212,.2)', color: 'var(--cyan)' }}>📍 </button>
{r.status === '완료' && (
<button className="px-3 py-1.5 text-[10px] font-semibold font-korean rounded border cursor-pointer hover:bg-bg-hover transition-colors" style={{ background: 'rgba(34,197,94,.08)', borderColor: 'rgba(34,197,94,.2)', color: 'var(--green)' }}>📥 </button>
)}
{(r.status === '대기' || r.status === '촬영중') && (
<button
onClick={(e) => {
e.stopPropagation()
if (confirm(`${r.id} (${r.zone}) 위성 촬영 요청을 취소하시겠습니까?`)) {
setRequests(prev => prev.map(req => req.id === r.id ? { ...req, status: '취소' as const } : req))
setSelectedRequest(null)
}
}}
className="px-3 py-1.5 text-[10px] font-semibold font-korean rounded border cursor-pointer hover:bg-bg-hover transition-colors"
style={{ background: 'rgba(239,68,68,.08)', borderColor: 'rgba(239,68,68,.2)', color: 'var(--red)' }}
> </button>
)}
</div>
</div>
)}
</div>
))}
{/* 페이징 */}
<div className="flex items-center justify-between px-4 py-2">
<div className="text-[9px] text-text-3 font-korean">
{filtered.length} {(currentPage - 1) * PAGE_SIZE + 1}{Math.min(currentPage * PAGE_SIZE, filtered.length)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage <= 1}
className="px-2 py-1 rounded text-[10px] font-mono cursor-pointer border transition-colors"
style={{ background: 'var(--bg3)', borderColor: 'var(--bd)', color: currentPage <= 1 ? 'var(--t3)' : 'var(--t1)', opacity: currentPage <= 1 ? 0.5 : 1 }}
></button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(p => (
<button
key={p}
onClick={() => setCurrentPage(p)}
className="w-7 h-7 rounded text-[10px] font-bold font-mono cursor-pointer border transition-colors"
style={currentPage === p
? { background: 'rgba(59,130,246,.15)', borderColor: 'rgba(59,130,246,.3)', color: 'var(--blue)' }
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }
}
>{p}</button>
))}
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage >= totalPages}
className="px-2 py-1 rounded text-[10px] font-mono cursor-pointer border transition-colors"
style={{ background: 'var(--bg3)', borderColor: 'var(--bd)', color: currentPage >= totalPages ? 'var(--t3)' : 'var(--t1)', opacity: currentPage >= totalPages ? 0.5 : 1 }}
></button>
</div>
</div>
</div>
{/* 위성 궤도 정보 */}
<div className="grid grid-cols-2 gap-3.5">
{/* 가용 위성 현황 */}
<div className="bg-bg-2 border border-border rounded-md p-4">
<div className="text-xs font-bold text-text-1 font-korean mb-3">🛰 </div>
<div className="flex flex-col gap-2">
{satellites.map((sat, i) => (
<div key={i} className="flex items-center gap-2.5 px-3 py-2 bg-bg-3 rounded-md" style={{ border: `1px solid ${sat.borderColor}` }}>
<div className={`w-2 h-2 rounded-full shrink-0 ${sat.pulse ? 'animate-pulse' : ''}`} style={{ background: sat.statusColor }} />
<div className="flex-1">
<div className="text-[11px] font-semibold text-text-1 font-korean">{sat.name}</div>
<div className="text-[9px] text-text-3 font-korean">{sat.desc}</div>
</div>
<div className="text-[10px] font-bold font-korean" style={{ color: sat.statusColor }}>{sat.status}</div>
</div>
))}
</div>
</div>
{/* 오늘 촬영 가능 시간 */}
<div className="bg-bg-2 border border-border rounded-md p-4">
<div className="text-xs font-bold text-text-1 font-korean mb-3"> (KST)</div>
<div className="flex flex-col gap-1.5">
{passSchedules.map((ps, i) => (
<div
key={i}
className="flex items-center gap-2 px-2.5 py-[7px] rounded-[5px]"
style={{
background: ps.today ? 'rgba(34,197,94,.05)' : 'rgba(59,130,246,.05)',
border: ps.today ? '1px solid rgba(34,197,94,.15)' : '1px solid rgba(59,130,246,.15)',
}}
>
<span className="text-[10px] font-bold font-mono min-w-[90px]" style={{ color: ps.today ? 'var(--cyan)' : 'var(--blue)' }}>{ps.time}</span>
<span className="text-[10px] text-text-1 font-korean">{ps.desc}</span>
</div>
))}
</div>
</div>
</div>
</>)}
{/* ═══ 촬영 히스토리 지도 뷰 ═══ */}
{mainTab === 'map' && (() => {
const dateFiltered = requests.filter(r => r.dateKey === mapSelectedDate)
const dateHasDots = [...new Set(requests.map(r => r.dateKey).filter(Boolean))]
return (
<div className="bg-bg-2 border border-border rounded-md overflow-hidden relative" style={{ height: 'calc(100vh - 160px)' }}>
<Map
initialViewState={{ longitude: 127.5, latitude: 34.5, zoom: 7 }}
mapStyle={SAT_MAP_STYLE}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
>
{/* 선택된 날짜의 촬영 구역 폴리곤 */}
{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 (
<Source key={r.id} id={`zone-${r.id}`} type="geojson" data={{
type: 'Feature', properties: {},
geometry: { type: 'Polygon', coordinates: [[
[coord.lon - delta, coord.lat - delta],
[coord.lon + delta, coord.lat - delta],
[coord.lon + delta, coord.lat + delta],
[coord.lon - delta, coord.lat + delta],
[coord.lon - delta, coord.lat - delta],
]] },
}}>
<Layer id={`zone-fill-${r.id}`} type="fill" paint={{ 'fill-color': statusColor, 'fill-opacity': isSelected ? 0.35 : 0.12 }} />
<Layer id={`zone-line-${r.id}`} type="line" paint={{ 'line-color': statusColor, 'line-width': isSelected ? 3 : 1.5 }} />
</Source>
)
})}
{/* 선택된 완료 항목: VWorld 위성 영상 오버레이 (BlackSky 스타일) */}
{mapSelectedItem && mapSelectedItem.status === '완료' && satShowOverlay && VWORLD_API_KEY && (
<Source
id="sat-vworld-overlay"
type="raster"
tiles={[`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Satellite/{z}/{y}/{x}.jpeg`]}
tileSize={256}
>
<Layer id="sat-vworld-layer" type="raster" paint={{
'raster-opacity': satImgOpacity / 100,
'raster-brightness-max': Math.min(satImgBrightness / 100 * 1.2, 1),
'raster-brightness-min': Math.max((satImgBrightness - 100) / 200, 0),
}} />
</Source>
)}
{/* 선택된 항목 마커 */}
{mapSelectedItem && (() => {
const coord = parseCoord(mapSelectedItem.zoneCoord)
if (!coord) return null
return (
<Marker longitude={coord.lon} latitude={coord.lat} anchor="center">
<div className="relative">
<div className="w-4 h-4 rounded-full" style={{ background: 'rgba(6,182,212,.6)', border: '2px solid #fff', boxShadow: '0 0 12px rgba(6,182,212,.5)' }} />
<div className="absolute inset-0 w-4 h-4 rounded-full animate-ping" style={{ background: 'rgba(6,182,212,.3)' }} />
</div>
</Marker>
)
})()}
</Map>
{/* 좌상단: 캘린더 + 날짜별 리스트 */}
<div className="absolute top-3 left-3 w-[260px] rounded-lg border border-border z-10 overflow-hidden" style={{ background: 'rgba(18,25,41,.92)', backdropFilter: 'blur(8px)' }}>
{/* 캘린더 헤더 */}
<div className="px-3 py-2 border-b border-border">
<div className="text-[10px] font-bold text-text-2 font-korean mb-2">📅 </div>
<input
type="date"
value={mapSelectedDate}
onChange={e => { 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"
/>
{/* 촬영 이력 있는 날짜 점 표시 */}
<div className="flex flex-wrap gap-1 mt-2">
{dateHasDots.map(d => (
<button
key={d}
onClick={() => { setMapSelectedDate(d!); setMapSelectedItem(null) }}
className="px-1.5 py-0.5 rounded text-[8px] font-mono cursor-pointer border transition-colors"
style={mapSelectedDate === d
? { background: 'rgba(6,182,212,.2)', borderColor: 'var(--cyan)', color: 'var(--cyan)' }
: { background: 'var(--bg0)', borderColor: 'var(--bd)', color: 'var(--t3)' }
}
>{d?.slice(5)}</button>
))}
</div>
</div>
{/* 날짜별 촬영 리스트 */}
<div className="max-h-[35vh] overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
<div className="px-3 py-1.5 border-b border-border text-[9px] text-text-3 font-korean sticky top-0" style={{ background: 'rgba(18,25,41,.95)' }}>
{mapSelectedDate} · {dateFiltered.length}
</div>
{dateFiltered.length === 0 ? (
<div className="px-3 py-4 text-[10px] text-text-3 font-korean text-center"> </div>
) : dateFiltered.map(r => {
const statusColor = r.status === '촬영중' ? '#eab308' : r.status === '완료' ? '#22c55e' : r.status === '취소' ? '#ef4444' : '#3b82f6'
const isSelected = mapSelectedItem?.id === r.id
return (
<div
key={r.id}
onClick={() => 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',
}}
>
<div className="flex items-center justify-between mb-0.5">
<span className="text-[10px] font-mono text-text-2">{r.id}</span>
<span className="text-[8px] font-bold px-1.5 py-px rounded-full" style={{ background: `${statusColor}20`, color: statusColor }}>{r.status}</span>
</div>
<div className="text-[9px] text-text-1 font-korean truncate">{r.zone}</div>
<div className="text-[8px] text-text-3 font-mono mt-0.5">{r.satellite} · {r.resolution}</div>
{r.status === '완료' && (
<div className="mt-1 text-[8px] font-korean" style={{ color: 'var(--cyan)' }}>📷 </div>
)}
</div>
)
})}
</div>
</div>
{/* 우상단: 범례 */}
<div className="absolute top-3 right-3 px-3 py-2.5 rounded-lg border border-border z-10" style={{ background: 'rgba(18,25,41,.9)', backdropFilter: 'blur(8px)' }}>
<div className="text-[10px] font-bold text-text-2 font-korean mb-2"> </div>
{[
{ label: '촬영중', color: '#eab308' },
{ label: '대기', color: '#3b82f6' },
{ label: '완료', color: '#22c55e' },
{ label: '취소', color: '#ef4444' },
].map(item => (
<div key={item.label} className="flex items-center gap-2 mb-1">
<div className="w-3 h-3 rounded-sm" style={{ background: item.color, opacity: 0.4, border: `1px solid ${item.color}` }} />
<span className="text-[9px] text-text-3 font-korean">{item.label}</span>
</div>
))}
<div className="text-[8px] text-text-3 font-korean mt-1.5 pt-1.5 border-t border-border"> {requests.length}</div>
</div>
{/* 선택된 항목 상세 (하단) */}
{mapSelectedItem && (
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 px-4 py-3 rounded-lg border border-border z-10 max-w-[500px]" style={{ background: 'rgba(18,25,41,.95)', backdropFilter: 'blur(8px)' }}>
<div className="flex items-center gap-3">
<div className="flex-1">
<div className="text-[11px] font-bold text-text-1 font-korean mb-0.5">{mapSelectedItem.zone}</div>
<div className="text-[9px] text-text-3 font-mono">{mapSelectedItem.satellite} · {mapSelectedItem.resolution} · {mapSelectedItem.zoneCoord}</div>
</div>
<div className="text-center shrink-0">
<div className="text-[8px] text-text-3 font-korean"></div>
<div className="text-[10px] font-mono text-text-1">{mapSelectedItem.requestDate}</div>
</div>
{mapSelectedItem.status === '완료' && (
<div className="px-2 py-1 rounded text-[9px] font-bold font-korean shrink-0" style={{ background: 'rgba(34,197,94,.15)', color: '#22c55e' }}>
📷
</div>
)}
<button onClick={() => setMapSelectedItem(null)} className="text-text-3 bg-transparent border-none cursor-pointer text-sm"></button>
</div>
</div>
)}
</div>
)
})()}
{/* ═══ 모달: 제공자 선택 ═══ */}
{modalPhase !== 'none' && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center" style={{ background: 'rgba(5,8,18,.75)', backdropFilter: 'blur(8px)' }}>
<div ref={modalRef}>
{/* ── 제공자 선택 ── */}
{modalPhase === 'provider' && (
<div className="border rounded-2xl w-[640px] overflow-hidden" style={{ background: 'var(--bg1)', borderColor: 'rgba(99,102,241,.3)', boxShadow: '0 24px 80px rgba(0,0,0,.6)' }}>
{/* 헤더 */}
<div className="px-7 pt-6 pb-4 relative overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ background: 'linear-gradient(90deg,#6366f1,#3b82f6,#06b6d4)' }} />
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-xl" style={{ background: 'linear-gradient(135deg,rgba(99,102,241,.15),rgba(59,130,246,.08))' }}>🛰</div>
<div>
<div className="text-base font-bold text-text-1 font-korean"> </div>
<div className="text-[10px] text-text-3 font-korean mt-0.5"> </div>
</div>
</div>
<button onClick={() => setModalPhase('none')} className="text-lg cursor-pointer text-text-3 p-1 bg-transparent border-none hover:text-text-1 transition-colors"></button>
</div>
</div>
{/* 제공자 카드 */}
<div className="px-7 pb-6 flex flex-col gap-3.5">
{/* BlackSky (Maxar) */}
<div
onClick={() => 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"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
<div className="w-[42px] h-[42px] rounded-[10px] flex items-center justify-center border border-[rgba(99,102,241,.3)]" style={{ background: 'linear-gradient(135deg,#1a1a2e,#16213e)' }}>
<span className="text-[11px] font-extrabold font-mono text-[#818cf8] tracking-[-0.5px]">B<span className="text-[#a78bfa]">Sky</span></span>
</div>
<div>
<div className="text-sm font-bold text-text-1 font-korean">BlackSky</div>
<div className="text-[9px] text-text-3 font-korean mt-px">Maxar Electro-Optical API</div>
</div>
</div>
<div className="flex items-center gap-1.5">
<span className="px-2 py-0.5 rounded-[10px] text-[8px] font-semibold text-green-500" style={{ background: 'rgba(34,197,94,.1)', border: '1px solid rgba(34,197,94,.2)' }}>API </span>
<span className="text-base text-text-3"></span>
</div>
</div>
<div className="grid grid-cols-4 gap-2 mb-2.5">
{[
['유형', 'EO (광학)', '#818cf8'],
['해상도', '~1m', 'var(--t1)'],
['재방문', '≤1시간', 'var(--t1)'],
['납기', '90분 이내', '#22c55e'],
].map(([k, v, c], i) => (
<div key={i} className="text-center p-1.5 bg-bg-0 rounded-md">
<div className="text-[7px] text-text-3 font-korean mb-0.5">{k}</div>
<div className="text-[10px] font-bold font-mono" style={{ color: c }}>{v}</div>
</div>
))}
</div>
<div className="text-[9px] text-text-3 font-korean leading-relaxed"> . . Dawn-to-Dusk .</div>
<div className="mt-2 text-[8px] text-text-3 font-mono">API: <span className="text-[#818cf8]">eapi.maxar.com/e1so/rapidoc</span></div>
</div>
{/* UP42 (EO + SAR) */}
<div
onClick={() => 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"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
<div className="w-[42px] h-[42px] rounded-[10px] flex items-center justify-center border border-[rgba(59,130,246,.3)]" style={{ background: 'linear-gradient(135deg,#0a1628,#162a50)' }}>
<span className="text-[13px] font-extrabold font-mono text-[#60a5fa] tracking-[-0.5px]">up<sup className="text-[7px] align-super">42</sup></span>
</div>
<div>
<div className="text-sm font-bold text-text-1 font-korean">UP42 EO + SAR</div>
<div className="text-[9px] text-text-3 font-korean mt-px">Optical · SAR · Elevation </div>
</div>
</div>
<div className="flex items-center gap-1.5">
<span className="px-2 py-0.5 rounded-[10px] text-[8px] font-semibold text-green-500" style={{ background: 'rgba(34,197,94,.1)', border: '1px solid rgba(34,197,94,.2)' }}>API </span>
<span className="text-base text-text-3"></span>
</div>
</div>
<div className="grid grid-cols-4 gap-2 mb-2.5">
{[
['유형', 'EO + SAR', '#60a5fa'],
['해상도', '0.3~5m', 'var(--t1)'],
['위성 수', '16+ 컬렉션', 'var(--t1)'],
['야간/악천후', 'SAR 가능', '#22c55e'],
].map(([k, v, c], i) => (
<div key={i} className="text-center p-1.5 bg-bg-0 rounded-md">
<div className="text-[7px] text-text-3 font-korean mb-0.5">{k}</div>
<div className="text-[10px] font-bold font-mono" style={{ color: c }}>{v}</div>
</div>
))}
</div>
<div className="flex gap-1.5 mb-2.5 flex-wrap">
{['Pléiades Neo', 'SPOT 6/7'].map((t, i) => (
<span key={i} className="px-1.5 py-px rounded text-[8px] font-korean text-[#60a5fa]" style={{ background: 'rgba(59,130,246,.08)', border: '1px solid rgba(59,130,246,.15)' }}>{t}</span>
))}
{['TerraSAR-X', 'Capella SAR', 'ICEYE'].map((t, i) => (
<span key={i} className="px-1.5 py-px rounded text-[8px] font-korean" style={{ background: 'rgba(6,182,212,.08)', border: '1px solid rgba(6,182,212,.15)', color: 'var(--cyan)' }}>{t}</span>
))}
<span className="px-1.5 py-px rounded text-[8px] font-korean" style={{ background: 'rgba(139,148,158,.08)', border: '1px solid rgba(139,148,158,.15)', color: 'var(--t3)' }}>+11 more</span>
</div>
<div className="text-[9px] text-text-3 font-korean leading-relaxed">(EO) + (SAR) . · SAR . .</div>
<div className="mt-2 text-[8px] text-text-3 font-mono">API: <span className="text-[#60a5fa]">up42.com</span></div>
</div>
</div>
{/* 하단 */}
<div className="px-7 pb-5 flex items-center justify-between">
<div className="text-[9px] text-text-3 font-korean leading-relaxed">💡 촬영: BlackSky (90 ) · /악천후: UP42 SAR </div>
<button onClick={() => setModalPhase('none')} className="px-4 py-2 rounded-lg border text-[11px] font-semibold cursor-pointer font-korean bg-bg-3 text-text-2 border-border"></button>
</div>
</div>
)}
{/* ── BlackSky 긴급 촬영 요청 ── */}
{modalPhase === 'blacksky' && (
<div className="border rounded-[14px] w-[860px] max-h-[90vh] flex flex-col overflow-hidden border-[rgba(99,102,241,.3)]" style={{ background: '#0d1117', boxShadow: '0 24px 80px rgba(0,0,0,.7)' }}>
{/* 헤더 */}
<div className="px-6 py-4 border-b border-[#21262d] flex items-center justify-between shrink-0 relative">
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ background: 'linear-gradient(90deg,#6366f1,#818cf8,#a78bfa)' }} />
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg flex items-center justify-center border border-[rgba(99,102,241,.3)]" style={{ background: 'linear-gradient(135deg,#1a1a2e,#16213e)' }}>
<span className="text-[10px] font-extrabold font-mono text-[#818cf8]">B<span className="text-[#a78bfa]">Sky</span></span>
</div>
<div>
<div className="text-[15px] font-bold font-korean text-[#e2e8f0]">BlackSky </div>
<div className="text-[9px] font-korean mt-0.5 text-[#64748b]">Maxar E1SO RapiDoc API · </div>
</div>
</div>
<div className="flex items-center gap-2">
<span className="px-3 py-1 rounded-md text-[9px] font-semibold font-korean text-[#818cf8]" style={{ background: 'rgba(99,102,241,.1)', border: '1px solid rgba(99,102,241,.25)' }}>API Docs </span>
<button onClick={() => setModalPhase('none')} className="text-lg cursor-pointer p-1 bg-transparent border-none text-[#64748b]"></button>
</div>
</div>
{/* 본문 */}
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-4" style={{ scrollbarWidth: 'thin', scrollbarColor: '#21262d transparent' }}>
{/* API 상태 */}
<div className="flex items-center gap-2.5 px-3.5 py-2.5 rounded-lg" style={{ background: 'rgba(34,197,94,.06)', border: '1px solid rgba(34,197,94,.15)' }}>
<div className="w-2 h-2 rounded-full" style={{ background: '#22c55e', boxShadow: '0 0 6px rgba(34,197,94,.5)' }} />
<span className="text-[10px] font-semibold font-korean text-green-500">API Connected</span>
<span className="text-[9px] font-mono text-[#64748b]">eapi.maxar.com/e1so/rapidoc · Latency: 142ms</span>
<span className="ml-auto text-[8px] font-mono text-[#64748b]">Quota: 47/50 </span>
</div>
{/* ① 태스킹 유형 */}
<div>
{sectionHeader(1, '태스킹 유형 · 우선순위')}
<div className="grid grid-cols-3 gap-2.5">
<div>
<label className="block text-[9px] font-korean mb-1 text-[#64748b]"> <span className="text-red-400">*</span></label>
<select className={bsInput} style={bsInputStyle}>
<option> (Emergency)</option>
<option> (Standard)</option>
<option> (Archive)</option>
</select>
</div>
<div>
<label className="block text-[9px] font-korean mb-1 text-[#64748b]"> <span className="text-red-400">*</span></label>
<select className={bsInput} style={bsInputStyle}>
<option>P1 (90 )</option>
<option>P2 (6 )</option>
<option>P3 (24 )</option>
</select>
</div>
<div>
<label className="block text-[9px] font-korean mb-1 text-[#64748b]"> </label>
<select className={bsInput} style={bsInputStyle}>
<option>Single Collect</option>
<option>Multi-pass Monitoring</option>
<option>Continuous ( )</option>
</select>
</div>
</div>
</div>
{/* ② AOI 지정 */}
<div>
{sectionHeader(2, '관심 영역 (AOI)')}
<div className="grid grid-cols-3 gap-2.5 items-end">
<div>
<label className="block text-[9px] font-korean mb-1 text-[#64748b]"> <span className="text-red-400">*</span></label>
<input type="text" defaultValue="34.5832" className={bsInput} style={bsInputStyle} />
</div>
<div>
<label className="block text-[9px] font-korean mb-1 text-[#64748b]"> <span className="text-red-400">*</span></label>
<input type="text" defaultValue="128.4217" className={bsInput} style={bsInputStyle} />
</div>
<button className="px-3.5 py-2 rounded-md text-[10px] font-semibold cursor-pointer font-korean whitespace-nowrap text-[#818cf8]" style={{ border: '1px solid rgba(99,102,241,.3)', background: 'rgba(99,102,241,.08)' }}>📍 AOI </button>
</div>
<div className="mt-2 grid grid-cols-3 gap-2.5">
<div>
<label className="block text-[9px] font-korean mb-1 text-[#64748b]">AOI (km)</label>
<input type="number" defaultValue={10} step={1} min={1} className={bsInput} style={bsInputStyle} />
</div>
<div>
<label className="block text-[9px] font-korean mb-1 text-[#64748b]"> (%)</label>
<input type="number" defaultValue={20} step={5} min={0} max={100} className={bsInput} style={bsInputStyle} />
</div>
<div>
<label className="block text-[9px] font-korean mb-1 text-[#64748b]"> Off-nadir (°)</label>
<input type="number" defaultValue={25} step={5} min={0} max={45} className={bsInput} style={bsInputStyle} />
</div>
</div>
</div>
{/* ③ 촬영 기간 */}
<div>
{sectionHeader(3, '촬영 기간 · 반복')}
<div className="grid grid-cols-3 gap-2.5">
<div>
<label className="block text-[9px] font-korean mb-1 text-[#64748b]"> <span className="text-red-400">*</span></label>
<input type="datetime-local" defaultValue="2026-02-26T08:00" className={bsInput} style={bsInputStyle} />
</div>
<div>
<label className="block text-[9px] font-korean mb-1 text-[#64748b]"> <span className="text-red-400">*</span></label>
<input type="datetime-local" defaultValue="2026-02-27T20:00" className={bsInput} style={bsInputStyle} />
</div>
<div>
<label className="block text-[9px] font-korean mb-1 text-[#64748b]"> </label>
<select className={bsInput} style={bsInputStyle}>
<option>1 ()</option>
<option> ( )</option>
<option> 6</option>
<option> 12</option>
<option> 1</option>
</select>
</div>
</div>
</div>
{/* ④ 산출물 설정 */}
<div>
{sectionHeader(4, '산출물 설정')}
<div className="grid grid-cols-2 gap-2.5">
<div>
<label className="block text-[9px] font-korean mb-1 text-[#64748b]"> <span className="text-red-400">*</span></label>
<select className={bsInput} style={bsInputStyle}>
<option>Ortho-Rectified ()</option>
<option>Pan-sharpened ()</option>
<option>Basic L1 ()</option>
</select>
</div>
<div>
<label className="block text-[9px] font-korean mb-1 text-[#64748b]"> </label>
<select className={bsInput} style={bsInputStyle}>
<option>GeoTIFF</option>
<option>NITF</option>
<option>JPEG2000</option>
</select>
</div>
</div>
<div className="mt-2 flex flex-wrap gap-3">
{[
{ label: '유출유 탐지 분석 (자동)', checked: true },
{ label: 'GIS 상황판 자동 오버레이', checked: true },
{ label: '변화탐지 (Change Detection)', checked: false },
{ label: '웹훅 알림', checked: false },
].map((opt, i) => (
<label key={i} className="flex items-center gap-1 text-[9px] cursor-pointer font-korean text-[#94a3b8]">
<input type="checkbox" defaultChecked={opt.checked} style={{ accentColor: '#818cf8', transform: 'scale(.85)' }} /> {opt.label}
</label>
))}
</div>
</div>
{/* ⑤ 연계 사고 · 비고 */}
<div>
{sectionHeader(5, '연계 사고 · 비고')}
<div className="grid grid-cols-2 gap-2.5 mb-2">
<div>
<label className="block text-[9px] font-korean mb-1 text-[#64748b]"> </label>
<select className={bsInput} style={bsInputStyle}>
<option>OIL-2024-0892 · M/V STELLAR DAISY</option>
<option>HNS-2024-041 · </option>
<option>RSC-2024-0127 · M/V SEA GUARDIAN</option>
<option value=""> </option>
</select>
</div>
<div>
<label className="block text-[9px] font-korean mb-1 text-[#64748b]"></label>
<input type="text" placeholder="소속 / 이름" className={bsInput} style={bsInputStyle} />
</div>
</div>
<textarea
placeholder="촬영 요청 목적, 특이사항, 관심 대상 등을 기록합니다..."
className="w-full h-[50px] px-3 py-2.5 rounded-md text-[10px] font-korean outline-none resize-y leading-relaxed box-border border border-[#21262d] text-[#e2e8f0] bg-[#161b22]"
/>
</div>
</div>
{/* 하단 버튼 */}
<div className="px-6 py-3.5 border-t border-[#21262d] flex items-center gap-2 shrink-0">
<div className="flex-1 text-[9px] font-korean leading-relaxed text-[#64748b]">
<span className="text-red-400">*</span> · P1 90
</div>
<button onClick={() => setModalPhase('provider')} className="px-5 py-2.5 rounded-lg border border-[#21262d] text-xs font-semibold cursor-pointer font-korean text-[#94a3b8] bg-[#161b22]"> </button>
<button onClick={() => setModalPhase('none')} className="px-7 py-2.5 rounded-lg border-none text-xs font-bold cursor-pointer font-korean text-white" style={{ background: 'linear-gradient(135deg,#6366f1,#818cf8)', boxShadow: '0 4px 16px rgba(99,102,241,.35)' }}>🛰 BlackSky </button>
</div>
</div>
)}
{/* ── UP42 카탈로그 주문 ── */}
{modalPhase === 'up42' && (
<div className="border rounded-[14px] w-[920px] flex flex-col overflow-hidden border-[rgba(59,130,246,.3)]" style={{ background: '#0d1117', boxShadow: '0 24px 80px rgba(0,0,0,.7)', height: '85vh' }}>
{/* 헤더 */}
<div className="px-6 py-4 border-b border-[#21262d] flex items-center justify-between shrink-0 relative">
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ background: 'linear-gradient(90deg,#3b82f6,#06b6d4,#22c55e)' }} />
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg flex items-center justify-center border border-[rgba(59,130,246,.3)]" style={{ background: 'linear-gradient(135deg,#0a1628,#162a50)' }}>
<span className="text-[13px] font-extrabold font-mono text-[#60a5fa] tracking-[-0.5px]">up<sup className="text-[7px] align-super">42</sup></span>
</div>
<div>
<div className="text-[15px] font-bold font-korean text-[#e2e8f0]"> </div>
<div className="text-[9px] font-korean mt-0.5 text-[#64748b]"> (AOI) </div>
</div>
</div>
<div className="flex items-center gap-2">
<span className="px-3 py-1 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(234,179,8,.1)', border: '1px solid rgba(234,179,8,.25)', color: '#eab308' }}> Beijing-3N 2.152.23</span>
<button onClick={() => setModalPhase('none')} className="text-lg cursor-pointer p-1 bg-transparent border-none text-[#64748b]"></button>
</div>
</div>
{/* 본문 (좌: 사이드바, 우: 지도+AOI) */}
<div className="flex-1 flex overflow-hidden">
{/* 왼쪽: 위성 카탈로그 */}
<div className="flex flex-col overflow-hidden border-r border-[#21262d]" style={{ width: 320, minWidth: 320, background: '#0d1117' }}>
{/* Optical / SAR / Elevation 탭 */}
<div className="flex border-b border-[#21262d] shrink-0">
{(['optical', 'sar', 'elevation'] as const).map(t => (
<button
key={t}
onClick={() => setUp42SubTab(t)}
className="flex-1 py-2 text-[10px] font-bold cursor-pointer border-none font-korean transition-colors"
style={up42SubTab === t
? { background: 'rgba(59,130,246,.1)', color: '#60a5fa', borderBottom: '2px solid #3b82f6' }
: { background: 'transparent', color: '#64748b' }
}
>{t === 'optical' ? 'Optical' : t === 'sar' ? 'SAR' : 'Elevation'}</button>
))}
</div>
{/* 필터 바 */}
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-[#21262d] shrink-0">
<span className="px-2 py-0.5 rounded text-[9px] font-semibold text-[#60a5fa]" style={{ background: 'rgba(59,130,246,.1)', border: '1px solid rgba(59,130,246,.2)' }}>Filters </span>
<span className="px-2 py-0.5 rounded text-[9px] font-semibold text-[#818cf8]" style={{ background: 'rgba(99,102,241,.1)', border: '1px solid rgba(99,102,241,.2)' }}> 20% </span>
<span className="ml-auto text-[9px] font-mono text-[#64748b]"> </span>
</div>
{/* 컬렉션 수 */}
<div className="px-3 py-1.5 border-b border-[#21262d] text-[9px] font-korean shrink-0 text-[#64748b]">
<b className="text-[#e2e8f0]">{up42Filtered.length}</b>
</div>
{/* 위성 목록 */}
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: '#21262d transparent' }}>
{up42Filtered.map(sat => (
<div
key={sat.id}
onClick={() => setUp42SelSat(up42SelSat === sat.id ? null : sat.id)}
className="flex items-center gap-2.5 px-3 py-2.5 border-b border-[#161b22] cursor-pointer transition-colors"
style={{
background: up42SelSat === sat.id ? 'rgba(59,130,246,.08)' : 'transparent',
}}
>
<div className="w-1 h-8 rounded-full shrink-0" style={{ background: sat.color }} />
<div className="flex-1 min-w-0">
<div className="text-[11px] font-semibold truncate font-korean text-[#e2e8f0]">{sat.name}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[9px] font-bold font-mono" style={{ color: sat.color }}>{sat.res}</span>
{sat.cloud > 0 && <span className="text-[8px] font-mono text-[#64748b]"> {sat.cloud}%</span>}
{'delay' in sat && sat.delay && <span className="text-[8px] font-bold" style={{ color: '#eab308' }}> </span>}
</div>
</div>
{up42SelSat === sat.id && <span className="text-[12px] text-blue-500"></span>}
</div>
))}
</div>
</div>
{/* 오른쪽: 궤도 지도 + 패스 목록 */}
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
{/* 지도 영역 — 위성 궤도 표시 (최소 높이 보장) */}
<div className="flex-1 relative" style={{ minHeight: 350 }}>
<Map
initialViewState={{ longitude: 128, latitude: 36, zoom: 5.5 }}
mapStyle={SAT_MAP_STYLE}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
>
{/* 한국 영역 AOI 박스 */}
<Source id="korea-aoi" type="geojson" data={{
type: 'Feature',
properties: {},
geometry: { type: 'Polygon', coordinates: [[[124, 33], [132, 33], [132, 39], [124, 39], [124, 33]]] },
}}>
<Layer id="korea-aoi-fill" type="fill" paint={{ 'fill-color': '#3b82f6', 'fill-opacity': 0.05 }} />
<Layer id="korea-aoi-line" type="line" paint={{ 'line-color': '#3b82f6', 'line-width': 1.5, 'line-dasharray': [4, 3] }} />
</Source>
{/* 위성 궤도 라인 */}
{satPasses.map(pass => (
<Source key={pass.id} id={`orbit-${pass.id}`} type="geojson" data={{
type: 'Feature',
properties: {},
geometry: { type: 'LineString', coordinates: pass.orbit.map(p => [p.lon, p.lat]) },
}}>
<Layer
id={`orbit-line-${pass.id}`}
type="line"
paint={{
'line-color': pass.color,
'line-width': up42SelPass === pass.id ? 3 : 1.5,
'line-opacity': up42SelPass === pass.id ? 1 : up42SelPass ? 0.2 : 0.6,
'line-dasharray': pass.type === 'sar' ? [6, 4] : [1],
}}
/>
{/* 궤도 위 위성 위치 (중간점) */}
{(up42SelPass === pass.id || !up42SelPass) && (
<Layer
id={`orbit-point-${pass.id}`}
type="circle"
filter={['==', '$type', 'LineString']}
paint={{}}
/>
)}
</Source>
))}
</Map>
{/* 범례 오버레이 */}
<div className="absolute top-3 left-3 px-3 py-2 rounded-lg z-10 border border-[#21262d]" style={{ background: 'rgba(13,17,23,.9)', backdropFilter: 'blur(8px)' }}>
<div className="text-[9px] font-bold text-[#64748b] mb-1.5">🛰 </div>
{satPasses.slice(0, 4).map(p => (
<div key={p.id} className="flex items-center gap-1.5 mb-1">
<div className="w-3 h-[2px] rounded-sm" style={{ background: p.color }} />
<span className="text-[8px] text-[#94a3b8]">{p.satellite}</span>
</div>
))}
<div className="flex items-center gap-1.5 mt-1.5 pt-1.5 border-t border-[#21262d]">
<div className="w-3 h-3 rounded border border-[#3b82f6]" style={{ background: 'rgba(59,130,246,.1)' }} />
<span className="text-[8px] text-[#64748b]"> AOI</span>
</div>
</div>
{/* 로딩 */}
{satPassesLoading && (
<div className="absolute inset-0 flex items-center justify-center z-10" style={{ background: 'rgba(0,0,0,.5)' }}>
<div className="text-[11px] text-[#60a5fa] font-korean animate-pulse">🛰 ...</div>
</div>
)}
</div>
{/* 위성 패스 타임라인 */}
<div className="border-t border-[#21262d] px-4 py-3 shrink-0 max-h-[200px] overflow-y-auto" style={{ background: 'rgba(13,17,23,.95)', scrollbarWidth: 'thin', scrollbarColor: '#21262d transparent' }}>
<div className="text-[10px] font-bold font-korean mb-2 text-[#e2e8f0]">
🛰 ({satPasses.length})
<span className="text-[8px] text-[#64748b] font-normal ml-2"> </span>
</div>
<div className="flex flex-col gap-1.5">
{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 (
<div
key={pass.id}
onClick={() => 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',
}}
>
<div className="w-1.5 h-6 rounded-full shrink-0" style={{ background: pass.color }} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold font-korean text-[#e2e8f0]">{pass.satellite}</span>
<span className="text-[8px] text-[#64748b]">{pass.provider}</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[9px] font-bold font-mono text-[#60a5fa]">{timeStr}</span>
<span className="text-[9px] font-mono" style={{ color: pass.color }}>{pass.resolution}</span>
<span className="text-[8px] font-mono text-[#64748b]">EL {pass.maxElevation}° · {pass.direction === 'ascending' ? '↗ 상승' : '↘ 하강'}</span>
</div>
</div>
<span className="px-1.5 py-px rounded text-[8px] font-bold shrink-0" style={{
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}</span>
{up42SelPass === pass.id && <span className="text-xs text-blue-500"></span>}
</div>
)
})}
</div>
</div>
</div>
</div>
{/* 푸터 */}
<div className="px-6 py-3 border-t border-[#21262d] flex items-center justify-between shrink-0">
<div className="text-[9px] font-korean text-[#64748b]"> ? <span className="text-[#60a5fa] cursor-pointer"> </span> <span className="text-[#60a5fa] cursor-pointer"> </span></div>
<div className="flex items-center gap-2">
<span className="text-[11px] font-korean mr-1.5 text-[#8690a6]">
: {up42SelPass ? satPasses.find(p => p.id === up42SelPass)?.satellite : up42SelSat ? up42Satellites.find(s => s.id === up42SelSat)?.name : '없음'}
</span>
<button onClick={() => setModalPhase('provider')} className="px-4 py-2 rounded-lg border border-[#21262d] text-[11px] font-semibold cursor-pointer font-korean text-[#94a3b8] bg-[#161b22]"> </button>
<button
onClick={() => setModalPhase('none')}
className="px-6 py-2 rounded-lg border-none text-[11px] font-bold cursor-pointer font-korean text-white transition-opacity"
style={{
background: up42SelSat ? 'linear-gradient(135deg,#3b82f6,#06b6d4)' : '#21262d',
opacity: up42SelSat ? 1 : 0.5,
color: up42SelSat ? '#fff' : '#64748b',
boxShadow: up42SelSat ? '0 4px 16px rgba(59,130,246,.35)' : 'none',
}}
>🛰 </button>
</div>
</div>
</div>
)}
</div>
</div>
)}
</div>
)
}