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: '© OpenStreetMap © CARTO', }, }, 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(() => new MapboxOverlay({ interleaved: true })) overlay.setProps({ layers }) return null } // 줌 컨트롤 function WeatherMapControls() { const { current: map } = useMap() return (
) } /** * WeatherMapInner — Map 컴포넌트 내부 (useMap / useControl 사용 가능 영역) */ interface WeatherMapInnerProps { weatherStations: WeatherStation[] enabledLayers: Set selectedStationId: string | null oceanForecastOpacity: number selectedForecast: ReturnType['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 오버레이 */} {/* 해황예보도 — MapLibre image source + raster layer */} {/* 기상 관측소 HTML 오버레이 (풍향 화살표 + 라벨) */} {/* 바람 파티클 애니메이션 (Canvas 직접 조작) */} {/* 줌 컨트롤 */} ) } 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('0') const [selectedStationRaw, setSelectedStation] = useState(null) const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lon: number } | null>( null ) const [enabledLayers, setEnabledLayers] = useState>(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 (
{/* Main Map Area */}
{/* Tab Navigation */}
{(['0', '3', '6', '9'] as TimeOffset[]).map((offset) => ( ))}
{lastUpdate ? `마지막 업데이트: ${lastUpdate.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', })}` : '데이터 로딩 중...'} {loading && (
)} {error && ⚠️ {error}}
{/* Map */}
{/* 레이어 컨트롤 */}
기상 레이어
{/* 해황예보도 레이어 */}
{enabledLayers.has('oceanForecast') && (
투명도: setOceanForecastOpacity(Number(e.target.value) / 100) } className="flex-1 h-1 bg-bg-3 rounded-lg appearance-none cursor-pointer" /> {Math.round(oceanForecastOpacity * 100)}%
{availableTimes.length > 0 && (
예보 시간:
{availableTimes.map((time) => ( ))}
)} {oceanLoading &&
로딩 중...
} {oceanError &&
오류 발생
} {selectedForecast && (
현재: {selectedForecast.name} •{' '} {selectedForecast.day.slice(4, 6)}/{selectedForecast.day.slice(6, 8)}{' '} {selectedForecast.hour}:00
)}
)}
{/* 범례 */}
기상 범례
{/* 바람 (Windy 스타일) */}
바람 (m/s)
3 5 7 10 13 16 20+
{/* 파고 */}
파고 (m)
< 1.5: 낮음
1.5-2.5: 보통
> 2.5: 높음
💡 지도를 클릭하여 해당 지점의 기상 예보를 확인하세요
{/* Right Panel */}
) }