feat(aerial): 위성 히스토리 지도에 캘린더 + 날짜별 촬영 리스트 + 영상 오버레이

- 좌상단: 캘린더(date picker) + 촬영 이력 있는 날짜 바로가기 버튼
- 날짜 선택 시 해당일 촬영 내역만 필터링하여 리스트 표시
- 완료 항목 클릭 시 지도에 위성 영상 오버레이 표시 (이미지 레이어)
- 선택된 구역 폴리곤 하이라이트 (두꺼운 테두리 + 진한 채움)
- 하단 상세 정보 바: 구역명, 위성, 해상도, 좌표, 영상 표출 상태
- 요청일자를 2026-03 기준으로 업데이트 + dateKey 필드 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nan Kyung Lee 2026-03-17 10:30:00 +09:00
부모 19fdc489f3
커밋 5191e606a1

파일 보기

@ -18,16 +18,18 @@ interface SatRequest {
provider?: string provider?: string
purpose?: string purpose?: string
requester?: string requester?: string
/** ISO 날짜 (필터용) */
dateKey?: string
} }
const satRequests: SatRequest[] = [ const satRequests: SatRequest[] = [
{ id: 'SAT-004', zone: '제주 서귀포 해상 (유출 해역 중심)', zoneCoord: '33.24°N 126.50°E', zoneArea: '15km²', satellite: 'KOMPSAT-3A', requestDate: '02-20 08:14', expectedReceive: '02-20 14:30', resolution: '0.5m', status: '촬영중', provider: 'KARI', purpose: '유출유 확산 모니터링', requester: '방제과 김해양' }, { 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: '02-20 09:02', expectedReceive: '02-21 09:00', resolution: '1.0m', status: '대기', provider: 'KARI', purpose: '해안선 오염 확인', requester: '방제과 이민수' }, { 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: '02-20 09:30', expectedReceive: '02-21 11:00', resolution: '10m', status: '대기', provider: 'ESA Copernicus', purpose: '수질 분석용 다분광 촬영', requester: '환경분석팀 박수진' }, { 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: '02-20 10:05', expectedReceive: '02-22 08:00', resolution: '0.5m', status: '대기', provider: 'KARI', purpose: '확산 예측 모델 검증', requester: '방제과 김해양' }, { 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: '02-19 14:00', expectedReceive: '02-19 23:00', resolution: '20m', status: '완료', provider: 'ESA Copernicus', purpose: 'SAR 유막 탐지', requester: '환경분석팀 박수진' }, { 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: '02-18 11:30', expectedReceive: '02-18 17:45', resolution: '0.5m', status: '완료', provider: 'KARI', purpose: '유출 초기 범위 확인', requester: '방제과 김해양' }, { 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: '02-17 09:00', expectedReceive: '02-17 21:00', resolution: '20m', status: '완료', provider: 'ESA Copernicus', purpose: '야간 SAR 유막 모니터링', requester: '환경분석팀 박수진' }, { 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 = [ const satellites = [
@ -104,6 +106,11 @@ export function SatelliteRequest() {
const [up42SelPass, setUp42SelPass] = useState<string | null>(null) const [up42SelPass, setUp42SelPass] = useState<string | null>(null)
const [satPasses, setSatPasses] = useState<SatellitePass[]>([]) const [satPasses, setSatPasses] = useState<SatellitePass[]>([])
const [satPassesLoading, setSatPassesLoading] = useState(false) 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 modalRef = useRef<HTMLDivElement>(null) const modalRef = useRef<HTMLDivElement>(null)
@ -369,20 +376,24 @@ export function SatelliteRequest() {
</>)} </>)}
{/* ═══ 촬영 히스토리 지도 뷰 ═══ */} {/* ═══ 촬영 히스토리 지도 뷰 ═══ */}
{mainTab === 'map' && ( {mainTab === 'map' && (() => {
<div className="bg-bg-2 border border-border rounded-md overflow-hidden" style={{ height: 'calc(100vh - 240px)' }}> const dateFiltered = requests.filter(r => r.dateKey === mapSelectedDate)
const dateHasDots = [...new Set(requests.map(r => r.dateKey).filter(Boolean))]
return (
<div className="bg-bg-2 border border-border rounded-md overflow-hidden relative" style={{ height: 'calc(100vh - 240px)' }}>
<Map <Map
initialViewState={{ longitude: 127.5, latitude: 34.5, zoom: 7 }} initialViewState={{ longitude: 127.5, latitude: 34.5, zoom: 7 }}
mapStyle={SAT_MAP_STYLE} mapStyle={SAT_MAP_STYLE}
style={{ width: '100%', height: '100%' }} style={{ width: '100%', height: '100%' }}
attributionControl={false} attributionControl={false}
> >
{/* 촬영 구역 폴리곤 + 마커 */} {/* 선택된 날짜의 촬영 구역 폴리곤 */}
{requests.map(r => { {dateFiltered.map(r => {
const coord = parseCoord(r.zoneCoord) const coord = parseCoord(r.zoneCoord)
if (!coord) return null if (!coord) return null
const areaKm = parseFloat(r.zoneArea) || 10 const areaKm = parseFloat(r.zoneArea) || 10
const delta = Math.sqrt(areaKm) * 0.005 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' const statusColor = r.status === '촬영중' ? '#eab308' : r.status === '완료' ? '#22c55e' : r.status === '취소' ? '#ef4444' : '#3b82f6'
return ( return (
<Source key={r.id} id={`zone-${r.id}`} type="geojson" data={{ <Source key={r.id} id={`zone-${r.id}`} type="geojson" data={{
@ -395,14 +406,99 @@ export function SatelliteRequest() {
[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-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': 1.5 }} /> <Layer id={`zone-line-${r.id}`} type="line" paint={{ 'line-color': statusColor, 'line-width': isSelected ? 3 : 1.5 }} />
</Source> </Source>
) )
})} })}
{/* 선택된 항목의 위성 영상 오버레이 (시뮬레이션) */}
{mapSelectedItem && mapSelectedItem.status === '완료' && (() => {
const coord = parseCoord(mapSelectedItem.zoneCoord)
if (!coord) return null
const areaKm = parseFloat(mapSelectedItem.zoneArea) || 10
const delta = Math.sqrt(areaKm) * 0.006
return (
<Source
id="sat-image-overlay"
type="image"
url={`https://api.mapbox.com/styles/v1/mapbox/satellite-v9/static/${coord.lon},${coord.lat},12,0/400x400?access_token=pk.placeholder`}
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],
]}
>
<Layer id="sat-image-layer" type="raster" paint={{ 'raster-opacity': 0.85 }} />
</Source>
)
})()}
</Map> </Map>
{/* 지도 위 범례 */} {/* 좌상단: 캘린더 + 날짜별 리스트 */}
<div className="absolute top-3 left-3 w-[260px] rounded-lg border border-border z-10 overflow-hidden" style={{ background: 'rgba(18,25,41,.92)', backdropFilter: 'blur(8px)' }}>
{/* 캘린더 헤더 */}
<div className="px-3 py-2 border-b border-border">
<div className="text-[10px] font-bold text-text-2 font-korean mb-2">📅 </div>
<input
type="date"
value={mapSelectedDate}
onChange={e => { setMapSelectedDate(e.target.value); setMapSelectedItem(null) }}
className="w-full px-2.5 py-1.5 bg-bg-3 border border-border rounded text-[11px] font-mono text-text-1 outline-none focus:border-[var(--cyan)] transition-colors"
/>
{/* 촬영 이력 있는 날짜 점 표시 */}
<div className="flex flex-wrap gap-1 mt-2">
{dateHasDots.map(d => (
<button
key={d}
onClick={() => { setMapSelectedDate(d!); setMapSelectedItem(null) }}
className="px-1.5 py-0.5 rounded text-[8px] font-mono cursor-pointer border transition-colors"
style={mapSelectedDate === d
? { background: 'rgba(6,182,212,.2)', borderColor: 'var(--cyan)', color: 'var(--cyan)' }
: { background: 'var(--bg0)', borderColor: 'var(--bd)', color: 'var(--t3)' }
}
>{d?.slice(5)}</button>
))}
</div>
</div>
{/* 날짜별 촬영 리스트 */}
<div className="max-h-[35vh] overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
<div className="px-3 py-1.5 border-b border-border text-[9px] text-text-3 font-korean sticky top-0" style={{ background: 'rgba(18,25,41,.95)' }}>
{mapSelectedDate} · {dateFiltered.length}
</div>
{dateFiltered.length === 0 ? (
<div className="px-3 py-4 text-[10px] text-text-3 font-korean text-center"> </div>
) : dateFiltered.map(r => {
const statusColor = r.status === '촬영중' ? '#eab308' : r.status === '완료' ? '#22c55e' : r.status === '취소' ? '#ef4444' : '#3b82f6'
const isSelected = mapSelectedItem?.id === r.id
return (
<div
key={r.id}
onClick={() => setMapSelectedItem(isSelected ? null : r)}
className="px-3 py-2 border-b cursor-pointer transition-colors"
style={{
borderColor: 'rgba(255,255,255,.04)',
background: isSelected ? 'rgba(6,182,212,.1)' : 'transparent',
}}
>
<div className="flex items-center justify-between mb-0.5">
<span className="text-[10px] font-mono text-text-2">{r.id}</span>
<span className="text-[8px] font-bold px-1.5 py-px rounded-full" style={{ background: `${statusColor}20`, color: statusColor }}>{r.status}</span>
</div>
<div className="text-[9px] text-text-1 font-korean truncate">{r.zone}</div>
<div className="text-[8px] text-text-3 font-mono mt-0.5">{r.satellite} · {r.resolution}</div>
{r.status === '완료' && (
<div className="mt-1 text-[8px] font-korean" style={{ color: 'var(--cyan)' }}>📷 </div>
)}
</div>
)
})}
</div>
</div>
{/* 우상단: 범례 */}
<div className="absolute top-3 right-3 px-3 py-2.5 rounded-lg border border-border z-10" style={{ background: 'rgba(18,25,41,.9)', backdropFilter: 'blur(8px)' }}> <div className="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> <div className="text-[10px] font-bold text-text-2 font-korean mb-2"> </div>
{[ {[
@ -419,25 +515,30 @@ export function SatelliteRequest() {
<div className="text-[8px] text-text-3 font-korean mt-1.5 pt-1.5 border-t border-border"> {requests.length}</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>
{/* 지도 위 요청 리스트 (좌하단) */} {/* 선택된 항목 상세 (하단) */}
<div className="absolute bottom-3 left-3 w-[240px] max-h-[45vh] 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' }}> {mapSelectedItem && (
<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> <div className="absolute bottom-3 left-1/2 -translate-x-1/2 px-4 py-3 rounded-lg border border-border z-10 max-w-[500px]" style={{ background: 'rgba(18,25,41,.95)', backdropFilter: 'blur(8px)' }}>
{requests.map(r => { <div className="flex items-center gap-3">
const statusColor = r.status === '촬영중' ? '#eab308' : r.status === '완료' ? '#22c55e' : r.status === '취소' ? '#ef4444' : '#3b82f6' <div className="flex-1">
return ( <div className="text-[11px] font-bold text-text-1 font-korean mb-0.5">{mapSelectedItem.zone}</div>
<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="text-[9px] text-text-3 font-mono">{mapSelectedItem.satellite} · {mapSelectedItem.resolution} · {mapSelectedItem.zoneCoord}</div>
<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 className="text-center shrink-0">
})} <div className="text-[8px] text-text-3 font-korean"></div>
</div> <div className="text-[10px] font-mono text-text-1">{mapSelectedItem.requestDate}</div>
</div>
{mapSelectedItem.status === '완료' && (
<div className="px-2 py-1 rounded text-[9px] font-bold font-korean shrink-0" style={{ background: 'rgba(34,197,94,.15)', color: '#22c55e' }}>
📷
</div>
)}
<button onClick={() => setMapSelectedItem(null)} className="text-text-3 bg-transparent border-none cursor-pointer text-sm"></button>
</div>
</div>
)}
</div> </div>
)} )
})()}
{/* ═══ 모달: 제공자 선택 ═══ */} {/* ═══ 모달: 제공자 선택 ═══ */}
{modalPhase !== 'none' && ( {modalPhase !== 'none' && (