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 ( 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" > + 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" > − 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" > 🎯 ) } /** * 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) => ( 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}시간`} ))} {lastUpdate ? `마지막 업데이트: ${lastUpdate.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', })}` : '데이터 로딩 중...'} {loading && ( )} {error && ⚠️ {error}} 🚨 예보전송 {/* Map */} {/* 레이어 컨트롤 */} 기상 레이어 toggleLayer('windParticle')} className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan" /> 🌬️ 바람 흐름 toggleLayer('wind')} className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan" /> 🌬️ 바람 벡터 toggleLayer('labels')} className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan" /> 📊 기상 데이터 toggleLayer('waves')} className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan" /> 🌊 파고 분포 toggleLayer('temperature')} className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan" /> 🌡️ 수온 분포 toggleLayer('oceanCurrent')} className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan" /> 🌊 해류 방향 toggleLayer('waterTemperature')} className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan" /> 🌡️ 수온 색상도 {/* 해황예보도 레이어 */} toggleLayer('oceanForecast')} className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan" /> 🌊 해황예보도 {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) => ( 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} ))} )} {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 */} ) }