2186 lines
93 KiB
TypeScript
2186 lines
93 KiB
TypeScript
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.15–2.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>
|
||
);
|
||
}
|