wing-ops/frontend/src/tabs/weather/components/WeatherView.tsx
htlee 85749c2f68 feat(map): Leaflet → MapLibre GL JS + deck.gl 전환 (Phase 6)
지도 엔진을 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>
2026-03-01 02:48:54 +09:00

573 lines
22 KiB
TypeScript
Executable File
Raw Blame 히스토리

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <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"
>
&#x1F3AF;
</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">&lt; 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">&gt; 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>
)
}