feat(aerial): 위성 요청 목록/히스토리 지도 탭 분리
- 📋 요청 목록 / 🗺 촬영 히스토리 지도 탭 토글
- 지도 뷰: MapLibre에 촬영 구역 사각형 폴리곤 표시
상태별 색상 (촬영중=노랑, 대기=파랑, 완료=초록, 취소=빨강)
- 좌측 오버레이: 요청 리스트 (ID, 구역, 위성, 해상도, 상태)
- 우측 오버레이: 상태별 범례 + 총 건수
- parseCoord 헬퍼: "33.24°N 126.50°E" → {lat, lon} 파싱
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
00e7a3e70a
커밋
7564f42918
@ -82,10 +82,18 @@ const SAT_MAP_STYLE: StyleSpecification = {
|
||||
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)
|
||||
@ -190,6 +198,27 @@ export function SatelliteRequest() {
|
||||
<button onClick={() => setModalPhase('provider')} className="px-4 py-2.5 text-white border-none rounded-sm text-[13px] font-semibold cursor-pointer font-korean flex items-center gap-1.5" style={{ background: 'linear-gradient(135deg,var(--blue),var(--purple))' }}>🛰 새 요청</button>
|
||||
</div>
|
||||
|
||||
{/* 요청목록 / 지도 뷰 탭 */}
|
||||
<div className="flex gap-1.5 mb-4">
|
||||
<button
|
||||
onClick={() => setMainTab('list')}
|
||||
className="px-4 py-2 rounded text-[11px] font-bold font-korean cursor-pointer border transition-colors"
|
||||
style={mainTab === 'list'
|
||||
? { background: 'rgba(59,130,246,.12)', borderColor: 'rgba(59,130,246,.3)', color: 'var(--blue)' }
|
||||
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }
|
||||
}
|
||||
>📋 요청 목록</button>
|
||||
<button
|
||||
onClick={() => setMainTab('map')}
|
||||
className="px-4 py-2 rounded text-[11px] font-bold font-korean cursor-pointer border transition-colors"
|
||||
style={mainTab === 'map'
|
||||
? { background: 'rgba(59,130,246,.12)', borderColor: 'rgba(59,130,246,.3)', color: 'var(--blue)' }
|
||||
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }
|
||||
}
|
||||
>🗺 촬영 히스토리 지도</button>
|
||||
</div>
|
||||
|
||||
{mainTab === 'list' && (<>
|
||||
{/* 요약 통계 */}
|
||||
<div className="grid grid-cols-4 gap-3 mb-5">
|
||||
{stats.map((s, i) => (
|
||||
@ -337,6 +366,78 @@ export function SatelliteRequest() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
|
||||
{/* ═══ 촬영 히스토리 지도 뷰 ═══ */}
|
||||
{mainTab === 'map' && (
|
||||
<div className="bg-bg-2 border border-border rounded-md overflow-hidden" style={{ height: 'calc(100vh - 240px)' }}>
|
||||
<Map
|
||||
initialViewState={{ longitude: 127.5, latitude: 34.5, zoom: 7 }}
|
||||
mapStyle={SAT_MAP_STYLE}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
attributionControl={false}
|
||||
>
|
||||
{/* 촬영 구역 폴리곤 + 마커 */}
|
||||
{requests.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 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': 0.15 }} />
|
||||
<Layer id={`zone-line-${r.id}`} type="line" paint={{ 'line-color': statusColor, 'line-width': 1.5 }} />
|
||||
</Source>
|
||||
)
|
||||
})}
|
||||
</Map>
|
||||
|
||||
{/* 지도 위 범례 */}
|
||||
<div className="absolute top-3 right-3 px-3 py-2.5 rounded-lg border border-border z-10" style={{ background: 'rgba(18,25,41,.9)', backdropFilter: 'blur(8px)' }}>
|
||||
<div className="text-[10px] font-bold text-text-2 font-korean mb-2">촬영 이력</div>
|
||||
{[
|
||||
{ label: '촬영중', color: '#eab308' },
|
||||
{ label: '대기', color: '#3b82f6' },
|
||||
{ label: '완료', color: '#22c55e' },
|
||||
{ label: '취소', color: '#ef4444' },
|
||||
].map(item => (
|
||||
<div key={item.label} className="flex items-center gap-2 mb-1">
|
||||
<div className="w-3 h-3 rounded-sm" style={{ background: item.color, opacity: 0.4, border: `1px solid ${item.color}` }} />
|
||||
<span className="text-[9px] text-text-3 font-korean">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="text-[8px] text-text-3 font-korean mt-1.5 pt-1.5 border-t border-border">총 {requests.length}건</div>
|
||||
</div>
|
||||
|
||||
{/* 지도 위 요청 리스트 (좌측) */}
|
||||
<div className="absolute top-3 left-3 w-[240px] max-h-[50vh] overflow-y-auto rounded-lg border border-border z-10" style={{ background: 'rgba(18,25,41,.92)', backdropFilter: 'blur(8px)', scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
|
||||
<div className="px-3 py-2 border-b border-border text-[10px] font-bold text-text-2 font-korean sticky top-0" style={{ background: 'rgba(18,25,41,.95)' }}>📋 촬영 요청 ({requests.length})</div>
|
||||
{requests.map(r => {
|
||||
const statusColor = r.status === '촬영중' ? '#eab308' : r.status === '완료' ? '#22c55e' : r.status === '취소' ? '#ef4444' : '#3b82f6'
|
||||
return (
|
||||
<div key={r.id} className="px-3 py-2 border-b cursor-pointer hover:bg-bg-hover/30 transition-colors" style={{ borderColor: 'rgba(255,255,255,.04)' }}>
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<span className="text-[10px] font-mono text-text-2">{r.id}</span>
|
||||
<span className="text-[8px] font-bold px-1.5 py-px rounded-full" style={{ background: `${statusColor}20`, color: statusColor }}>{r.status}</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-text-1 font-korean truncate">{r.zone}</div>
|
||||
<div className="text-[8px] text-text-3 font-mono mt-0.5">{r.satellite} · {r.resolution}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══ 모달: 제공자 선택 ═══ */}
|
||||
{modalPhase !== 'none' && (
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user