import { useState, useMemo, useCallback } from 'react' import { Map, Marker, useControl } 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 { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer' import { useWeatherData } from '../hooks/useWeatherData' // import { useOceanForecast } from '../hooks/useOceanForecast' import { WeatherMapControls } from './WeatherMapControls' 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 salinity?: number } const CARDINAL_LABELS = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'] as const function degreesToCardinal(deg: number): string { const idx = Math.round(((deg % 360) + 360) % 360 / 22.5) % 16 return CARDINAL_LABELS[idx] } 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 } /** * WeatherMapInner — Map 컴포넌트 내부 (useMap / useControl 사용 가능 영역) */ interface WeatherMapInnerProps { weatherStations: WeatherStation[] enabledLayers: Set selectedStationId: string | null onStationClick: (station: WeatherStation) => void mapCenter: [number, number] mapZoom: number clickedLocation: { lat: number; lon: number } | null } function WeatherMapInner({ weatherStations, enabledLayers, selectedStationId, onStationClick, mapCenter, mapZoom, clickedLocation, }: 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( () => [...waterTempLayers, ...weatherDeckLayers], [waterTempLayers, weatherDeckLayers] ) return ( <> {/* deck.gl 오버레이 */} {/* 해황예보도 — 임시 비활성화 */} {/* 해류 흐름 파티클 애니메이션 (Canvas 직접 조작) */} {/* 기상 관측소 HTML 오버레이 (풍향 화살표 + 라벨) */} {/* 바람 파티클 애니메이션 (Canvas 직접 조작) */} {/* 클릭 위치 마커 */} {clickedLocation && ( {/* 펄스 링 */} {/* 핀 꼬리 */} {/* 좌표 라벨 */} {clickedLocation.lat.toFixed(3)}°N {clickedLocation.lon.toFixed(3)}°E )} {/* 줌 컨트롤 */} > ) } 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'])) // 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, directionLabel: degreesToCardinal(selectedStation.wind.direction), }, wave: { ...selectedStation.wave, maxHeight: Number((selectedStation.wave.height * 1.6).toFixed(1)), direction: degreesToCardinal(selectedStation.wind.direction + 45), }, temperature: selectedStation.temperature, pressure: selectedStation.pressure, visibility: selectedStation.visibility, salinity: selectedStation.salinity ?? 31.2, astronomy: { sunrise: '07:12', sunset: '17:58', moonrise: '19:35', moonset: '01:50', moonPhase: '상현달 14일', tidalRange: 6.7, }, alert: '풍랑주의보 예상 08:00~', 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-3 h-3 rounded border-border bg-bg-2 text-primary-cyan accent-[var(--cyan)]" /> 🌬️ 바람 흐름 toggleLayer('wind')} className="w-3 h-3 rounded border-border bg-bg-2 text-primary-cyan accent-[var(--cyan)]" /> 🌬️ 바람 벡터 toggleLayer('waves')} className="w-3 h-3 rounded border-border bg-bg-2 text-primary-cyan accent-[var(--cyan)]" /> 🌊 파고 분포 toggleLayer('temperature')} className="w-3 h-3 rounded border-border bg-bg-2 text-primary-cyan accent-[var(--cyan)]" /> 🌡️ 수온 분포 toggleLayer('oceanCurrentParticle')} className="w-3 h-3 rounded border-border bg-bg-2 text-primary-cyan accent-[var(--cyan)]" /> 🌊 해류 흐름 toggleLayer('waterTemperature')} className="w-3 h-3 rounded border-border bg-bg-2 text-primary-cyan accent-[var(--cyan)]" /> 🌡️ 수온 색상도 {/* 범례 */} 기상 범례 {/* 바람 */} 바람 (m/s) 35710131620+ {/* 해류 */} 해류 (m/s) 0.20.40.60.6+ {/* 파고 */} 파고 (m) <1.5 낮음 ~2.5 >2.5 💡 지도 클릭 → 기상 예보 확인 {/* Right Panel */} ) }