지도 엔진을 Leaflet 1.9에서 MapLibre GL JS 5.x + deck.gl 9.x로 전환. 15개 파일 수정, Leaflet 완전 제거. WebGL 단일 canvas로 z-index 충돌 해결, 유류 입자 ScatterplotLayer GPU 렌더링으로 10~100배 성능 향상. - MapView.tsx: MapLibre Map + DeckGLOverlay(MapboxOverlay interleaved) - 유류 입자/오일펜스/HNS: deck.gl ScatterplotLayer/PathLayer - 역추적 리플레이: createBacktrackLayers() 함수 패턴 - 기상 오버레이: WeatherMapOverlay/OceanCurrent/WindParticle deck.gl 전환 - 수온 히트맵: WaterTemperatureLayer deck.gl ScatterplotLayer - 해황예보도: MapLibre image source + raster layer - SCAT/Assets/Incidents: MapLibre Map + deck.gl 레이어 - WMS 밝기: raster-brightness-min/max 네이티브 속성 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
573 lines
22 KiB
TypeScript
Executable File
573 lines
22 KiB
TypeScript
Executable File
import { useState, useMemo, useCallback } from 'react'
|
||
import { Map, useControl, useMap } from '@vis.gl/react-maplibre'
|
||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||
import type { Layer } from '@deck.gl/core'
|
||
import type { StyleSpecification, MapLayerMouseEvent } from 'maplibre-gl'
|
||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||
import { WeatherRightPanel } from './WeatherRightPanel'
|
||
import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay'
|
||
import { OceanForecastOverlay } from './OceanForecastOverlay'
|
||
import { useOceanCurrentLayers } from './OceanCurrentLayer'
|
||
import { useWaterTemperatureLayers } from './WaterTemperatureLayer'
|
||
import { WindParticleLayer } from './WindParticleLayer'
|
||
import { useWeatherData } from '../hooks/useWeatherData'
|
||
import { useOceanForecast } from '../hooks/useOceanForecast'
|
||
|
||
type TimeOffset = '0' | '3' | '6' | '9'
|
||
|
||
interface WeatherStation {
|
||
id: string
|
||
name: string
|
||
location: { lat: number; lon: number }
|
||
wind: {
|
||
speed: number
|
||
direction: number
|
||
speed_1k: number
|
||
speed_3k: number
|
||
}
|
||
wave: {
|
||
height: number
|
||
period: number
|
||
}
|
||
temperature: {
|
||
current: number
|
||
feelsLike: number
|
||
}
|
||
pressure: number
|
||
visibility: number
|
||
}
|
||
|
||
interface WeatherForecast {
|
||
time: string
|
||
hour: string
|
||
icon: string
|
||
temperature: number
|
||
windSpeed: number
|
||
}
|
||
|
||
// Base weather station locations
|
||
const BASE_STATIONS = [
|
||
{ id: 'incheon', name: '인천', location: { lat: 37.45, lon: 126.43 } },
|
||
{ id: 'ulsan', name: '울산', location: { lat: 35.52, lon: 129.38 } },
|
||
{ id: 'yeosu', name: '여수', location: { lat: 34.74, lon: 127.75 } },
|
||
{ id: 'jeju', name: '제주', location: { lat: 33.51, lon: 126.53 } },
|
||
{ id: 'pohang', name: '포항', location: { lat: 36.03, lon: 129.38 } },
|
||
{ id: 'mokpo', name: '목포', location: { lat: 34.78, lon: 126.38 } },
|
||
{ id: 'gunsan', name: '군산', location: { lat: 35.97, lon: 126.7 } },
|
||
{ id: 'sokcho', name: '속초', location: { lat: 38.21, lon: 128.59 } },
|
||
{ id: 'tongyeong', name: '통영', location: { lat: 34.83, lon: 128.43 } },
|
||
{ id: 'donghae', name: '동해', location: { lat: 37.52, lon: 129.14 } },
|
||
]
|
||
|
||
// Generate forecast data based on time offset
|
||
const generateForecast = (timeOffset: TimeOffset): WeatherForecast[] => {
|
||
const baseHour = parseInt(timeOffset)
|
||
const forecasts: WeatherForecast[] = []
|
||
const icons = ['☀️', '⛅', '☁️', '🌦️', '🌧️']
|
||
|
||
for (let i = 0; i < 5; i++) {
|
||
const hour = baseHour + i * 3
|
||
forecasts.push({
|
||
time: `+${hour}시`,
|
||
hour: `${hour}시`,
|
||
icon: icons[i % icons.length],
|
||
temperature: Math.floor(Math.random() * 5) + 5,
|
||
windSpeed: Math.floor(Math.random() * 5) + 6,
|
||
})
|
||
}
|
||
return forecasts
|
||
}
|
||
|
||
// CartoDB Dark Matter 스타일 (기존 WeatherView와 동일)
|
||
const WEATHER_MAP_STYLE: StyleSpecification = {
|
||
version: 8,
|
||
sources: {
|
||
'carto-dark': {
|
||
type: 'raster',
|
||
tiles: [
|
||
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
||
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
||
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
||
],
|
||
tileSize: 256,
|
||
attribution:
|
||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
|
||
},
|
||
},
|
||
layers: [
|
||
{
|
||
id: 'carto-dark-layer',
|
||
type: 'raster',
|
||
source: 'carto-dark',
|
||
minzoom: 0,
|
||
maxzoom: 22,
|
||
},
|
||
],
|
||
}
|
||
|
||
// 한국 해역 중심 좌표 (한반도 중앙)
|
||
const WEATHER_MAP_CENTER: [number, number] = [127.8, 36.5] // [lng, lat]
|
||
const WEATHER_MAP_ZOOM = 7
|
||
|
||
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록)
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
function DeckGLOverlay({ layers }: { layers: Layer[] }) {
|
||
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
|
||
overlay.setProps({ layers })
|
||
return null
|
||
}
|
||
|
||
// 줌 컨트롤
|
||
function WeatherMapControls() {
|
||
const { current: map } = useMap()
|
||
|
||
return (
|
||
<div style={{ position: 'absolute', top: 16, right: 16, zIndex: 10 }}>
|
||
<div className="flex flex-col gap-2">
|
||
<button
|
||
onClick={() => map?.zoomIn()}
|
||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
|
||
>
|
||
+
|
||
</button>
|
||
<button
|
||
onClick={() => map?.zoomOut()}
|
||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
|
||
>
|
||
−
|
||
</button>
|
||
<button
|
||
onClick={() =>
|
||
map?.flyTo({ center: WEATHER_MAP_CENTER, zoom: WEATHER_MAP_ZOOM, duration: 1000 })
|
||
}
|
||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:text-text-1 transition-all text-sm"
|
||
>
|
||
🎯
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* WeatherMapInner — Map 컴포넌트 내부 (useMap / useControl 사용 가능 영역)
|
||
*/
|
||
interface WeatherMapInnerProps {
|
||
weatherStations: WeatherStation[]
|
||
enabledLayers: Set<string>
|
||
selectedStationId: string | null
|
||
oceanForecastOpacity: number
|
||
selectedForecast: ReturnType<typeof useOceanForecast>['selectedForecast']
|
||
onStationClick: (station: WeatherStation) => void
|
||
}
|
||
|
||
function WeatherMapInner({
|
||
weatherStations,
|
||
enabledLayers,
|
||
selectedStationId,
|
||
oceanForecastOpacity,
|
||
selectedForecast,
|
||
onStationClick,
|
||
}: WeatherMapInnerProps) {
|
||
// deck.gl layers 조합
|
||
const weatherDeckLayers = useWeatherDeckLayers(
|
||
weatherStations,
|
||
enabledLayers,
|
||
selectedStationId,
|
||
onStationClick
|
||
)
|
||
const oceanCurrentLayers = useOceanCurrentLayers({
|
||
visible: enabledLayers.has('oceanCurrent'),
|
||
opacity: 0.7,
|
||
})
|
||
const waterTempLayers = useWaterTemperatureLayers({
|
||
visible: enabledLayers.has('waterTemperature'),
|
||
opacity: 0.5,
|
||
})
|
||
|
||
const deckLayers = useMemo(
|
||
() => [...oceanCurrentLayers, ...waterTempLayers, ...weatherDeckLayers],
|
||
[oceanCurrentLayers, waterTempLayers, weatherDeckLayers]
|
||
)
|
||
|
||
return (
|
||
<>
|
||
{/* deck.gl 오버레이 */}
|
||
<DeckGLOverlay layers={deckLayers} />
|
||
|
||
{/* 해황예보도 — MapLibre image source + raster layer */}
|
||
<OceanForecastOverlay
|
||
forecast={selectedForecast}
|
||
opacity={oceanForecastOpacity}
|
||
visible={enabledLayers.has('oceanForecast')}
|
||
/>
|
||
|
||
{/* 기상 관측소 HTML 오버레이 (풍향 화살표 + 라벨) */}
|
||
<WeatherMapOverlay
|
||
stations={weatherStations}
|
||
enabledLayers={enabledLayers}
|
||
onStationClick={onStationClick}
|
||
selectedStationId={selectedStationId}
|
||
/>
|
||
|
||
{/* 바람 파티클 애니메이션 (Canvas 직접 조작) */}
|
||
<WindParticleLayer
|
||
visible={enabledLayers.has('windParticle')}
|
||
stations={weatherStations}
|
||
/>
|
||
|
||
{/* 줌 컨트롤 */}
|
||
<WeatherMapControls />
|
||
</>
|
||
)
|
||
}
|
||
|
||
export function WeatherView() {
|
||
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS)
|
||
|
||
const {
|
||
selectedForecast,
|
||
availableTimes,
|
||
loading: oceanLoading,
|
||
error: oceanError,
|
||
selectForecast,
|
||
} = useOceanForecast('KOREA')
|
||
|
||
const [timeOffset, setTimeOffset] = useState<TimeOffset>('0')
|
||
const [selectedStationRaw, setSelectedStation] = useState<WeatherStation | null>(null)
|
||
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lon: number } | null>(
|
||
null
|
||
)
|
||
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind', 'labels']))
|
||
const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
|
||
|
||
// 첫 관측소 자동 선택 (파생 값)
|
||
const selectedStation = selectedStationRaw ?? weatherStations[0] ?? null
|
||
|
||
const handleStationClick = useCallback(
|
||
(station: WeatherStation) => {
|
||
setSelectedStation(station)
|
||
setSelectedLocation(null)
|
||
},
|
||
[]
|
||
)
|
||
|
||
const handleMapClick = useCallback(
|
||
(e: MapLayerMouseEvent) => {
|
||
const { lat, lng } = e.lngLat
|
||
if (weatherStations.length === 0) return
|
||
|
||
// 가장 가까운 관측소 선택
|
||
const nearestStation = weatherStations.reduce((nearest, station) => {
|
||
const distance = Math.sqrt(
|
||
Math.pow(station.location.lat - lat, 2) + Math.pow(station.location.lon - lng, 2)
|
||
)
|
||
const nearestDistance = Math.sqrt(
|
||
Math.pow(nearest.location.lat - lat, 2) + Math.pow(nearest.location.lon - lng, 2)
|
||
)
|
||
return distance < nearestDistance ? station : nearest
|
||
}, weatherStations[0])
|
||
|
||
setSelectedStation(nearestStation)
|
||
setSelectedLocation({ lat, lon: lng })
|
||
},
|
||
[weatherStations]
|
||
)
|
||
|
||
const toggleLayer = useCallback((layer: string) => {
|
||
setEnabledLayers((prev) => {
|
||
const next = new Set(prev)
|
||
if (next.has(layer)) {
|
||
next.delete(layer)
|
||
} else {
|
||
next.add(layer)
|
||
}
|
||
return next
|
||
})
|
||
}, [])
|
||
|
||
const weatherData = selectedStation
|
||
? {
|
||
stationName: selectedLocation
|
||
? `해양 지점 (${selectedLocation.lat.toFixed(2)}°N, ${selectedLocation.lon.toFixed(2)}°E)`
|
||
: selectedStation.name,
|
||
location: selectedLocation || selectedStation.location,
|
||
currentTime: new Date().toLocaleTimeString('ko-KR', {
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
}),
|
||
wind: selectedStation.wind,
|
||
wave: selectedStation.wave,
|
||
temperature: selectedStation.temperature,
|
||
pressure: selectedStation.pressure,
|
||
visibility: selectedStation.visibility,
|
||
forecast: generateForecast(timeOffset),
|
||
}
|
||
: null
|
||
|
||
return (
|
||
<div className="flex flex-1 overflow-hidden">
|
||
{/* Main Map Area */}
|
||
<div className="flex-1 relative flex flex-col overflow-hidden">
|
||
{/* Tab Navigation */}
|
||
<div className="flex items-center border-b border-border bg-bg-1" style={{ flexShrink: 0 }}>
|
||
<div className="flex items-center gap-2 px-6">
|
||
{(['0', '3', '6', '9'] as TimeOffset[]).map((offset) => (
|
||
<button
|
||
key={offset}
|
||
onClick={() => setTimeOffset(offset)}
|
||
className={`px-3 py-2 text-xs font-semibold rounded transition-all ${
|
||
timeOffset === offset
|
||
? 'bg-primary-cyan text-bg-0'
|
||
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
|
||
}`}
|
||
>
|
||
{offset === '0' ? '현재' : `+${offset}시간`}
|
||
</button>
|
||
))}
|
||
|
||
<div className="flex items-center gap-2 ml-4">
|
||
<span className="text-xs text-text-3">
|
||
{lastUpdate
|
||
? `마지막 업데이트: ${lastUpdate.toLocaleTimeString('ko-KR', {
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})}`
|
||
: '데이터 로딩 중...'}
|
||
</span>
|
||
{loading && (
|
||
<div className="w-4 h-4 border-2 border-primary-cyan border-t-transparent rounded-full animate-spin" />
|
||
)}
|
||
{error && <span className="text-xs text-status-red">⚠️ {error}</span>}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1" />
|
||
|
||
<div className="px-6">
|
||
<button className="px-4 py-2 text-xs font-semibold rounded bg-status-red text-white hover:opacity-90 transition-opacity">
|
||
🚨 예보전송
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Map */}
|
||
<div className="flex-1 relative">
|
||
<Map
|
||
initialViewState={{
|
||
longitude: WEATHER_MAP_CENTER[0],
|
||
latitude: WEATHER_MAP_CENTER[1],
|
||
zoom: WEATHER_MAP_ZOOM,
|
||
}}
|
||
mapStyle={WEATHER_MAP_STYLE}
|
||
style={{ width: '100%', height: '100%' }}
|
||
onClick={handleMapClick}
|
||
attributionControl={false}
|
||
>
|
||
<WeatherMapInner
|
||
weatherStations={weatherStations}
|
||
enabledLayers={enabledLayers}
|
||
selectedStationId={selectedStation?.id || null}
|
||
oceanForecastOpacity={oceanForecastOpacity}
|
||
selectedForecast={selectedForecast}
|
||
onStationClick={handleStationClick}
|
||
/>
|
||
</Map>
|
||
|
||
{/* 레이어 컨트롤 */}
|
||
<div className="absolute top-6 left-6 bg-bg-1/90 border border-border rounded-lg p-4 backdrop-blur-sm" style={{ zIndex: 10 }}>
|
||
<div className="text-sm font-semibold text-text-1 mb-3">기상 레이어</div>
|
||
<div className="space-y-2">
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={enabledLayers.has('windParticle')}
|
||
onChange={() => toggleLayer('windParticle')}
|
||
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
|
||
/>
|
||
<span className="text-xs text-text-2">🌬️ 바람 흐름</span>
|
||
</label>
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={enabledLayers.has('wind')}
|
||
onChange={() => toggleLayer('wind')}
|
||
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
|
||
/>
|
||
<span className="text-xs text-text-2">🌬️ 바람 벡터</span>
|
||
</label>
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={enabledLayers.has('labels')}
|
||
onChange={() => toggleLayer('labels')}
|
||
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
|
||
/>
|
||
<span className="text-xs text-text-2">📊 기상 데이터</span>
|
||
</label>
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={enabledLayers.has('waves')}
|
||
onChange={() => toggleLayer('waves')}
|
||
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
|
||
/>
|
||
<span className="text-xs text-text-2">🌊 파고 분포</span>
|
||
</label>
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={enabledLayers.has('temperature')}
|
||
onChange={() => toggleLayer('temperature')}
|
||
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
|
||
/>
|
||
<span className="text-xs text-text-2">🌡️ 수온 분포</span>
|
||
</label>
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={enabledLayers.has('oceanCurrent')}
|
||
onChange={() => toggleLayer('oceanCurrent')}
|
||
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
|
||
/>
|
||
<span className="text-xs text-text-2">🌊 해류 방향</span>
|
||
</label>
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={enabledLayers.has('waterTemperature')}
|
||
onChange={() => toggleLayer('waterTemperature')}
|
||
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
|
||
/>
|
||
<span className="text-xs text-text-2">🌡️ 수온 색상도</span>
|
||
</label>
|
||
|
||
{/* 해황예보도 레이어 */}
|
||
<div className="pt-2 mt-2 border-t border-border">
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={enabledLayers.has('oceanForecast')}
|
||
onChange={() => toggleLayer('oceanForecast')}
|
||
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
|
||
/>
|
||
<span className="text-xs text-text-2">🌊 해황예보도</span>
|
||
</label>
|
||
|
||
{enabledLayers.has('oceanForecast') && (
|
||
<div className="mt-2 ml-6 space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs text-text-3">투명도:</span>
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max="100"
|
||
value={oceanForecastOpacity * 100}
|
||
onChange={(e) =>
|
||
setOceanForecastOpacity(Number(e.target.value) / 100)
|
||
}
|
||
className="flex-1 h-1 bg-bg-3 rounded-lg appearance-none cursor-pointer"
|
||
/>
|
||
<span className="text-xs text-text-3 w-8">
|
||
{Math.round(oceanForecastOpacity * 100)}%
|
||
</span>
|
||
</div>
|
||
|
||
{availableTimes.length > 0 && (
|
||
<div className="space-y-1">
|
||
<div className="text-xs text-text-3">예보 시간:</div>
|
||
<div className="max-h-32 overflow-y-auto space-y-1">
|
||
{availableTimes.map((time) => (
|
||
<button
|
||
key={`${time.day}-${time.hour}`}
|
||
onClick={() => selectForecast(time.day, time.hour)}
|
||
className={`w-full px-2 py-1 text-xs rounded transition-colors ${
|
||
selectedForecast?.day === time.day &&
|
||
selectedForecast?.hour === time.hour
|
||
? 'bg-primary-cyan text-bg-0 font-semibold'
|
||
: 'bg-bg-2 text-text-3 hover:bg-bg-3'
|
||
}`}
|
||
>
|
||
{time.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{oceanLoading && <div className="text-xs text-text-3">로딩 중...</div>}
|
||
{oceanError && <div className="text-xs text-status-red">오류 발생</div>}
|
||
{selectedForecast && (
|
||
<div className="text-xs text-text-3 pt-2 border-t border-border">
|
||
현재: {selectedForecast.name} •{' '}
|
||
{selectedForecast.day.slice(4, 6)}/{selectedForecast.day.slice(6, 8)}{' '}
|
||
{selectedForecast.hour}:00
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 범례 */}
|
||
<div className="absolute bottom-6 left-6 bg-bg-1/90 border border-border rounded-lg p-4 backdrop-blur-sm" style={{ zIndex: 10 }}>
|
||
<div className="text-sm font-semibold text-text-1 mb-3">기상 범례</div>
|
||
<div className="space-y-3 text-xs">
|
||
{/* 바람 (Windy 스타일) */}
|
||
<div>
|
||
<div className="font-semibold text-text-2 mb-1">바람 (m/s)</div>
|
||
<div className="flex items-center gap-1 h-3 rounded-sm overflow-hidden mb-1">
|
||
<div className="flex-1 h-full" style={{ background: '#6271b7' }} />
|
||
<div className="flex-1 h-full" style={{ background: '#39a0f6' }} />
|
||
<div className="flex-1 h-full" style={{ background: '#50d591' }} />
|
||
<div className="flex-1 h-full" style={{ background: '#a5e23f' }} />
|
||
<div className="flex-1 h-full" style={{ background: '#fae21e' }} />
|
||
<div className="flex-1 h-full" style={{ background: '#faaa19' }} />
|
||
<div className="flex-1 h-full" style={{ background: '#f05421' }} />
|
||
<div className="flex-1 h-full" style={{ background: '#b41e46' }} />
|
||
</div>
|
||
<div
|
||
className="flex justify-between text-text-3"
|
||
style={{ fontSize: '9px' }}
|
||
>
|
||
<span>3</span>
|
||
<span>5</span>
|
||
<span>7</span>
|
||
<span>10</span>
|
||
<span>13</span>
|
||
<span>16</span>
|
||
<span>20+</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 파고 */}
|
||
<div className="pt-2 border-t border-border">
|
||
<div className="font-semibold text-text-2 mb-1">파고 (m)</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-3 h-3 rounded-full bg-blue-500" />
|
||
<span className="text-text-3">< 1.5: 낮음</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-3 h-3 rounded-full bg-orange-500" />
|
||
<span className="text-text-3">1.5-2.5: 보통</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||
<span className="text-text-3">> 2.5: 높음</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="mt-3 pt-3 border-t border-border text-xs text-text-3">
|
||
💡 지도를 클릭하여 해당 지점의 기상 예보를 확인하세요
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right Panel */}
|
||
<WeatherRightPanel weatherData={weatherData} />
|
||
</div>
|
||
)
|
||
}
|