wing-ops/frontend/src/tabs/aerial/components/SatelliteRequest.tsx

2186 lines
93 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(--color-success)',
borderColor: 'rgba(34,197,94,.2)',
pulse: true,
},
{
name: 'KOMPSAT-3',
desc: '해상도 1.0m · 광학 · 임무 중',
status: '임무중',
statusColor: 'var(--color-caution)',
borderColor: 'rgba(234,179,8,.2)',
pulse: true,
},
{
name: 'Sentinel-1 (ESA)',
desc: '해상도 20m · SAR · 야간/우천 가능',
status: '가용',
statusColor: 'var(--color-success)',
borderColor: 'var(--stroke-default)',
pulse: false,
},
{
name: 'Sentinel-2 (ESA)',
desc: '해상도 10m · 다분광 · 수질 분석 적합',
status: '가용',
statusColor: 'var(--color-success)',
borderColor: 'var(--stroke-default)',
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(--color-caution)',
}}
>
<span
className="w-[5px] h-[5px] rounded-full inline-block animate-pulse"
style={{ background: 'var(--color-caution)' }}
/>
</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(--color-info)',
}}
>
</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(--color-danger)',
}}
>
</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(--color-success)',
}}
>
</span>
);
};
const stats = [
{ value: '3', label: '요청 대기', color: 'var(--color-info)' },
{ value: '1', label: '촬영 진행 중', color: 'var(--color-caution)' },
{ value: '7', label: '수신 완료', color: 'var(--color-success)' },
{ value: '0.5m', label: '최고 해상도', color: 'var(--color-accent)' },
];
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-color-tertiary">
<div
className="w-[18px] h-[18px] rounded-[5px] flex items-center justify-center text-[9px] font-bold text-color-tertiary"
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 var(--stroke-default)',
background: 'var(--bg-surface)',
color: 'var(--fg-default)',
};
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-fg"> </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(--color-info)',
}
: {
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-disabled)',
}
}
>
📋
</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(--color-info)',
}
: {
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-disabled)',
}
}
>
🗺
</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(--color-info),var(--color-tertiary))' }}
>
🛰
</button>
</div>
{mainTab === 'list' && (
<>
{/* 요약 통계 */}
<div className="grid grid-cols-4 gap-3 mb-5">
{stats.map((s, i) => (
<div
key={i}
className="bg-bg-elevated border border-stroke 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-fg-disabled mt-1 font-korean">{s.label}</div>
</div>
))}
</div>
{/* 요청 목록 */}
<div className="bg-bg-elevated border border-stroke rounded-md overflow-hidden mb-5">
<div className="flex items-center justify-between px-4 py-3.5 border-b border-stroke">
<div className="text-[13px] font-bold font-korean text-fg">📋 </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(--color-info)',
borderColor: 'rgba(59,130,246,.3)',
}
: {
background: 'var(--bg-card)',
color: 'var(--fg-sub)',
borderColor: 'var(--stroke-default)',
}
}
>
{f}
</button>
))}
</div>
</div>
{/* 헤더 행 */}
<div
className="grid gap-0 px-4 py-2 bg-bg-card border-b border-stroke"
style={{ gridTemplateColumns: '60px 1fr 100px 100px 120px 80px 90px' }}
>
{['번호', '촬영 구역', '위성', '요청일시', '예상수신', '해상도', '상태'].map((h) => (
<div
key={h}
className="text-[9px] font-bold text-fg-disabled 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-surface-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-fg-sub">{r.id}</div>
<div>
<div className="text-xs font-semibold text-fg font-korean">{r.zone}</div>
<div className="text-[10px] text-fg-disabled font-mono mt-0.5">
{r.zoneCoord} · {r.zoneArea}
</div>
</div>
<div className="text-[11px] font-semibold text-fg font-korean">{r.satellite}</div>
<div className="text-[10px] text-fg-sub font-mono">{r.requestDate}</div>
<div
className="text-[10px] font-semibold font-mono"
style={{
color: r.status === '촬영중' ? 'var(--color-caution)' : 'var(--fg-sub)',
}}
>
{r.expectedReceive}
</div>
<div
className="text-[11px] font-bold font-mono"
style={{
color: r.status === '완료' ? 'var(--fg-disabled)' : 'var(--color-accent)',
}}
>
{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-base rounded">
<div className="text-[8px] font-bold text-fg-disabled font-korean mb-1 uppercase">
{k}
</div>
<div className="text-[10px] font-semibold text-fg 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-surface-hover transition-colors"
style={{
background: 'rgba(6,182,212,.08)',
borderColor: 'rgba(6,182,212,.2)',
color: 'var(--color-accent)',
}}
>
📍
</button>
{r.status === '완료' && (
<button
className="px-3 py-1.5 text-[10px] font-semibold font-korean rounded border cursor-pointer hover:bg-bg-surface-hover transition-colors"
style={{
background: 'rgba(34,197,94,.08)',
borderColor: 'rgba(34,197,94,.2)',
color: 'var(--color-success)',
}}
>
📥
</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-surface-hover transition-colors"
style={{
background: 'rgba(239,68,68,.08)',
borderColor: 'rgba(239,68,68,.2)',
color: 'var(--color-danger)',
}}
>
</button>
)}
</div>
</div>
)}
</div>
))}
{/* 페이징 */}
<div className="flex items-center justify-between px-4 py-2">
<div className="text-[9px] text-fg-disabled 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(--bg-card)',
borderColor: 'var(--stroke-default)',
color: currentPage <= 1 ? 'var(--fg-disabled)' : 'var(--fg-default)',
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(--color-info)',
}
: {
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-disabled)',
}
}
>
{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(--bg-card)',
borderColor: 'var(--stroke-default)',
color: currentPage >= totalPages ? 'var(--fg-disabled)' : 'var(--fg-default)',
opacity: currentPage >= totalPages ? 0.5 : 1,
}}
>
</button>
</div>
</div>
</div>
{/* 위성 궤도 정보 */}
<div className="grid grid-cols-2 gap-3.5">
{/* 가용 위성 현황 */}
<div className="bg-bg-elevated border border-stroke rounded-md p-4">
<div className="text-xs font-bold text-fg 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-card 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-fg font-korean">
{sat.name}
</div>
<div className="text-[9px] text-fg-disabled 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-elevated border border-stroke rounded-md p-4">
<div className="text-xs font-bold text-fg 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(--color-accent)' : 'var(--color-info)' }}
>
{ps.time}
</span>
<span className="text-[10px] text-fg 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-elevated border border-stroke 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-stroke z-10 overflow-hidden"
style={{ background: 'rgba(18,25,41,.92)', backdropFilter: 'blur(8px)' }}
>
{/* 캘린더 헤더 */}
<div className="px-3 py-2 border-b border-stroke">
<div className="text-[10px] font-bold text-fg-sub 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-card border border-stroke rounded text-[11px] font-mono text-fg outline-none focus:border-[var(--color-accent)] 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(--color-accent)',
color: 'var(--color-accent)',
}
: {
background: 'var(--bg-base)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-disabled)',
}
}
>
{d?.slice(5)}
</button>
))}
</div>
</div>
{/* 날짜별 촬영 리스트 */}
<div
className="max-h-[35vh] overflow-y-auto"
style={{
scrollbarWidth: 'thin',
scrollbarColor: 'var(--stroke-light) transparent',
}}
>
<div
className="px-3 py-1.5 border-b border-stroke text-[9px] text-fg-disabled 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-fg-disabled 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-fg-sub">{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-fg font-korean truncate">{r.zone}</div>
<div className="text-[8px] text-fg-disabled font-mono mt-0.5">
{r.satellite} · {r.resolution}
</div>
{r.status === '완료' && (
<div
className="mt-1 text-[8px] font-korean"
style={{ color: 'var(--color-accent)' }}
>
📷
</div>
)}
</div>
);
})
)}
</div>
</div>
{/* 우상단: 범례 */}
<div
className="absolute top-3 right-3 px-3 py-2.5 rounded-lg border border-stroke z-10"
style={{ background: 'rgba(18,25,41,.9)', backdropFilter: 'blur(8px)' }}
>
<div className="text-[10px] font-bold text-fg-sub 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-fg-disabled font-korean">{item.label}</span>
</div>
))}
<div className="text-[8px] text-fg-disabled font-korean mt-1.5 pt-1.5 border-t border-stroke">
{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-stroke 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-fg font-korean mb-0.5">
{mapSelectedItem.zone}
</div>
<div className="text-[9px] text-fg-disabled font-mono">
{mapSelectedItem.satellite} · {mapSelectedItem.resolution} ·{' '}
{mapSelectedItem.zoneCoord}
</div>
</div>
<div className="text-center shrink-0">
<div className="text-[8px] text-fg-disabled font-korean"></div>
<div className="text-[10px] font-mono text-fg">
{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-fg-disabled 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(--bg-surface)',
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-fg font-korean">
</div>
<div className="text-[10px] text-fg-disabled font-korean mt-0.5">
</div>
</div>
</div>
<button
onClick={() => setModalPhase('none')}
className="text-lg cursor-pointer text-fg-disabled p-1 bg-transparent border-none hover:text-fg 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-elevated border border-stroke 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-color-tertiary tracking-[-0.5px]">
B<span className="text-color-tertiary">Sky</span>
</span>
</div>
<div>
<div className="text-sm font-bold text-fg font-korean">BlackSky</div>
<div className="text-[9px] text-fg-disabled 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-fg-disabled"></span>
</div>
</div>
<div className="grid grid-cols-4 gap-2 mb-2.5">
{[
['유형', 'EO (광학)', '#818cf8'],
['해상도', '~1m', 'var(--fg-default)'],
['재방문', '≤1시간', 'var(--fg-default)'],
['납기', '90분 이내', '#22c55e'],
].map(([k, v, c], i) => (
<div key={i} className="text-center p-1.5 bg-bg-base rounded-md">
<div className="text-[7px] text-fg-disabled 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-fg-disabled font-korean leading-relaxed">
. .
Dawn-to-Dusk .
</div>
<div className="mt-2 text-[8px] text-fg-disabled font-mono">
API: <span className="text-color-tertiary">eapi.maxar.com/e1so/rapidoc</span>
</div>
</div>
{/* UP42 (EO + SAR) */}
<div
onClick={() => setModalPhase('up42')}
className="cursor-pointer bg-bg-elevated border border-stroke 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-color-info tracking-[-0.5px]">
up<sup className="text-[7px] align-super">42</sup>
</span>
</div>
<div>
<div className="text-sm font-bold text-fg font-korean">
UP42 EO + SAR
</div>
<div className="text-[9px] text-fg-disabled 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-fg-disabled"></span>
</div>
</div>
<div className="grid grid-cols-4 gap-2 mb-2.5">
{[
['유형', 'EO + SAR', '#60a5fa'],
['해상도', '0.3~5m', 'var(--fg-default)'],
['위성 수', '16+ 컬렉션', 'var(--fg-default)'],
['야간/악천후', 'SAR 가능', '#22c55e'],
].map(([k, v, c], i) => (
<div key={i} className="text-center p-1.5 bg-bg-base rounded-md">
<div className="text-[7px] text-fg-disabled 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-color-info"
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(--color-accent)',
}}
>
{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(--fg-disabled)',
}}
>
+11 more
</span>
</div>
<div className="text-[9px] text-fg-disabled font-korean leading-relaxed">
(EO) + (SAR) . · SAR .
.
</div>
<div className="mt-2 text-[8px] text-fg-disabled font-mono">
API: <span className="text-color-info">up42.com</span>
</div>
</div>
</div>
{/* 하단 */}
<div className="px-7 pb-5 flex items-center justify-between">
<div className="text-[9px] text-fg-disabled 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-card text-fg-sub border-stroke"
>
</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: 'var(--bg-base)', boxShadow: '0 24px 80px rgba(0,0,0,.7)' }}
>
{/* 헤더 */}
<div className="px-6 py-4 border-b border-stroke 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-color-tertiary">
B<span className="text-color-tertiary">Sky</span>
</span>
</div>
<div>
<div className="text-[15px] font-bold font-korean text-fg">
BlackSky
</div>
<div className="text-[9px] font-korean mt-0.5 text-fg-disabled">
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-color-tertiary"
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-fg-disabled"
>
</button>
</div>
</div>
{/* 본문 */}
<div
className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-4"
style={{
scrollbarWidth: 'thin',
scrollbarColor: 'var(--stroke-default) 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-fg-disabled">
eapi.maxar.com/e1so/rapidoc · Latency: 142ms
</span>
<span className="ml-auto text-[8px] font-mono text-fg-disabled">
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-fg-disabled">
<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-fg-disabled">
<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-fg-disabled">
</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-fg-disabled">
<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-fg-disabled">
<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-color-tertiary"
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-fg-disabled">
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-fg-disabled">
(%)
</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-fg-disabled">
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-fg-disabled">
<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-fg-disabled">
<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-fg-disabled">
</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-fg-disabled">
<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-fg-disabled">
</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-fg-disabled"
>
<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-fg-disabled">
</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-fg-disabled">
</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-stroke text-fg bg-bg-elevated"
/>
</div>
</div>
{/* 하단 버튼 */}
<div className="px-6 py-3.5 border-t border-stroke flex items-center gap-2 shrink-0">
<div className="flex-1 text-[9px] font-korean leading-relaxed text-fg-disabled">
<span className="text-red-400">*</span> · P1
90
</div>
<button
onClick={() => setModalPhase('provider')}
className="px-5 py-2.5 rounded-lg border border-stroke text-xs font-semibold cursor-pointer font-korean text-fg-sub bg-bg-elevated"
>
</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: 'var(--bg-base)',
boxShadow: '0 24px 80px rgba(0,0,0,.7)',
height: '85vh',
}}
>
{/* 헤더 */}
<div className="px-6 py-4 border-b border-stroke 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-color-info tracking-[-0.5px]">
up<sup className="text-[7px] align-super">42</sup>
</span>
</div>
<div>
<div className="text-[15px] font-bold font-korean text-fg">
</div>
<div className="text-[9px] font-korean mt-0.5 text-fg-disabled">
(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-fg-disabled"
>
</button>
</div>
</div>
{/* 본문 (좌: 사이드바, 우: 지도+AOI) */}
<div className="flex-1 flex overflow-hidden">
{/* 왼쪽: 위성 카탈로그 */}
<div
className="flex flex-col overflow-hidden border-r border-stroke"
style={{ width: 320, minWidth: 320, background: 'var(--bg-base)' }}
>
{/* Optical / SAR / Elevation 탭 */}
<div className="flex border-b border-stroke 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-stroke shrink-0">
<span
className="px-2 py-0.5 rounded text-[9px] font-semibold text-color-info"
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-color-tertiary"
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-fg-disabled">
</span>
</div>
{/* 컬렉션 수 */}
<div className="px-3 py-1.5 border-b border-stroke text-[9px] font-korean shrink-0 text-fg-disabled">
<b className="text-fg">{up42Filtered.length}</b>
</div>
{/* 위성 목록 */}
<div
className="flex-1 overflow-y-auto"
style={{
scrollbarWidth: 'thin',
scrollbarColor: 'var(--stroke-default) 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-stroke 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-fg">
{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-fg-disabled">
{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-stroke"
style={{ background: 'rgba(13,17,23,.9)', backdropFilter: 'blur(8px)' }}
>
<div className="text-[9px] font-bold text-fg-disabled 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-fg-disabled">{p.satellite}</span>
</div>
))}
<div className="flex items-center gap-1.5 mt-1.5 pt-1.5 border-t border-stroke">
<div
className="w-3 h-3 rounded border border-[#3b82f6]"
style={{ background: 'rgba(59,130,246,.1)' }}
/>
<span className="text-[8px] text-fg-disabled"> 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-color-info font-korean animate-pulse">
🛰 ...
</div>
</div>
)}
</div>
{/* 위성 패스 타임라인 */}
<div
className="border-t border-stroke px-4 py-3 shrink-0 max-h-[200px] overflow-y-auto"
style={{
background: 'rgba(13,17,23,.95)',
scrollbarWidth: 'thin',
scrollbarColor: 'var(--stroke-default) transparent',
}}
>
<div className="text-[10px] font-bold font-korean mb-2 text-fg">
🛰 ({satPasses.length})
<span className="text-[8px] text-fg-disabled 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)'
: 'var(--bg-elevated)',
border:
up42SelPass === pass.id
? `1px solid ${pass.color}40`
: '1px solid var(--stroke-light)',
}}
>
<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-fg">
{pass.satellite}
</span>
<span className="text-[8px] text-fg-disabled">
{pass.provider}
</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[9px] font-bold font-mono text-color-info">
{timeStr}
</span>
<span
className="text-[9px] font-mono"
style={{ color: pass.color }}
>
{pass.resolution}
</span>
<span className="text-[8px] font-mono text-fg-disabled">
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-stroke flex items-center justify-between shrink-0">
<div className="text-[9px] font-korean text-fg-disabled">
?{' '}
<span className="text-color-info cursor-pointer"> </span> {' '}
<span className="text-color-info cursor-pointer"> </span>
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] font-korean mr-1.5 text-fg-disabled">
:{' '}
{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-stroke text-[11px] font-semibold cursor-pointer font-korean text-fg-disabled bg-bg-elevated"
>
</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)'
: 'var(--stroke-light)',
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>
);
}