feat(aerial): 위성 히스토리 지도에 캘린더 + 날짜별 촬영 리스트 + 영상 오버레이
- 좌상단: 캘린더(date picker) + 촬영 이력 있는 날짜 바로가기 버튼 - 날짜 선택 시 해당일 촬영 내역만 필터링하여 리스트 표시 - 완료 항목 클릭 시 지도에 위성 영상 오버레이 표시 (이미지 레이어) - 선택된 구역 폴리곤 하이라이트 (두꺼운 테두리 + 진한 채움) - 하단 상세 정보 바: 구역명, 위성, 해상도, 좌표, 영상 표출 상태 - 요청일자를 2026-03 기준으로 업데이트 + dateKey 필드 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
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' && (
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user