wing-ops/frontend/src/tabs/weather/components/WeatherMapOverlay.tsx
leedano 3743027ce7 feat(weather): 기상 정보 기상 레이어 업데이트 (#78)
## Summary
- 기상 맵 컨트롤 컴포넌트 추가 및 KHOA API 연동 개선
- KHOA API 엔드포인트 교체 및 해양예측 오버레이 Canvas 렌더링 전환

## 변경 파일
- OceanForecastOverlay.tsx
- WeatherMapOverlay.tsx
- WeatherView.tsx
- useOceanForecast.ts
- khoaApi.ts
- vite.config.ts

## Test plan
- [ ] 기상정보 -> 기상 레이어 -> 해황 예보도 클릭 -> 이미지 렌더링 확인
- [ ] 기상정보 -> 기상 레이어 -> 백터 바람 클릭 -> 백터 이미지 렌더링 확인

Co-authored-by: Nan Kyung Lee <nankyunglee@Nanui-Macmini.local>
Reviewed-on: #78
Co-authored-by: leedano <dnlee@gcsc.co.kr>
Co-committed-by: leedano <dnlee@gcsc.co.kr>
2026-03-11 11:14:25 +09:00

279 lines
10 KiB
TypeScript
Executable File

import { useMemo } from 'react'
import { Marker } from '@vis.gl/react-maplibre'
import { ScatterplotLayer } from '@deck.gl/layers'
import type { Layer } from '@deck.gl/core'
import { hexToRgba } from '@common/components/map/mapUtils'
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 WeatherMapOverlayProps {
stations: WeatherStation[]
enabledLayers: Set<string>
onStationClick: (station: WeatherStation) => void
selectedStationId: string | null
}
// 풍속에 따른 hex 색상 반환
function getWindHexColor(speed: number, isSelected: boolean): string {
if (isSelected) return '#06b6d4'
if (speed > 10) return '#ef4444'
if (speed > 7) return '#f59e0b'
return '#3b82f6'
}
// 파고에 따른 hex 색상 반환
function getWaveHexColor(height: number): string {
if (height > 2.5) return '#ef4444'
if (height > 1.5) return '#f59e0b'
return '#3b82f6'
}
// 수온에 따른 hex 색상 반환
function getTempHexColor(temp: number): string {
if (temp > 8) return '#ef4444'
if (temp > 6) return '#f59e0b'
return '#3b82f6'
}
/**
* WeatherMapOverlay
*
* - deck.gl 레이어(ScatterplotLayer)를 layers prop으로 외부에 반환
* - 라벨(HTML)은 MapLibre Marker로 직접 렌더링
* - 풍향 화살표는 CSS rotate가 가능한 MapLibre Marker로 렌더링
*/
export function WeatherMapOverlay({
stations,
enabledLayers,
onStationClick,
selectedStationId,
}: WeatherMapOverlayProps) {
// deck.gl 레이어는 useWeatherDeckLayers 훅을 통해 외부로 전달되므로
// 이 컴포넌트는 HTML 오버레이(Marker) 부분만 담당
return (
<>
{/* 풍향 화살표 — MapLibre Marker + CSS rotate */}
{enabledLayers.has('wind') &&
stations.map((station) => {
const isSelected = selectedStationId === station.id
const color = getWindHexColor(station.wind.speed, isSelected)
return (
<Marker
key={`wind-${station.id}`}
longitude={station.location.lon}
latitude={station.location.lat}
anchor="center"
onClick={() => onStationClick(station)}
>
<div className="flex items-center gap-1 cursor-pointer">
<div style={{ transform: `rotate(${station.wind.direction}deg)` }}>
<svg
width={24}
height={24}
viewBox="0 0 24 24"
style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))' }}
>
{/* 위쪽이 바람 방향을 나타내는 삼각형 */}
<polygon
points="12,2 4,22 12,16 20,22"
fill={color}
opacity="0.9"
/>
</svg>
</div>
<span
style={{ color, textShadow: '0 1px 3px rgba(0,0,0,0.8)' }}
className="text-xs font-bold leading-none"
>
{station.wind.speed.toFixed(1)}
</span>
</div>
</Marker>
)
})}
{/* 기상 데이터 라벨 — 임시 비활성화
{enabledLayers.has('labels') &&
stations.map((station) => {
const isSelected = selectedStationId === station.id
const boxBg = isSelected ? 'rgba(6, 182, 212, 0.85)' : 'rgba(255, 255, 255, 0.1)'
const boxBorder = isSelected ? '#06b6d4' : 'rgba(255, 255, 255, 0.3)'
const textColor = isSelected ? '#000' : '#fff'
return (
<Marker
key={`label-${station.id}`}
longitude={station.location.lon}
latitude={station.location.lat}
anchor="center"
onClick={() => onStationClick(station)}
>
<div
style={{
background: boxBg,
border: `2px solid ${boxBorder}`,
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
}}
className="rounded-[10px] p-2 flex flex-col gap-1 min-w-[70px] cursor-pointer"
>
<div
style={{
color: textColor,
textShadow: '1px 1px 3px rgba(0,0,0,0.7)',
borderBottom: `1px solid ${isSelected ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.3)'}`,
}}
className="text-center text-xs font-bold pb-1 mb-0.5"
>
{station.name}
</div>
<div className="flex items-center gap-1.5">
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
style={{ background: 'linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%)', boxShadow: '0 2px 4px rgba(0,0,0,0.15)' }}
>
🌡️
</div>
<div className="flex items-baseline gap-0.5">
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
{station.temperature.current.toFixed(1)}
</span>
<span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>°C</span>
</div>
</div>
<div className="flex items-center gap-1.5">
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
style={{ background: 'linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%)', boxShadow: '0 2px 4px rgba(0,0,0,0.15)' }}
>
🌊
</div>
<div className="flex items-baseline gap-0.5">
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
{station.wave.height.toFixed(1)}
</span>
<span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m</span>
</div>
</div>
<div className="flex items-center gap-1.5">
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
style={{ background: 'linear-gradient(135deg, #10b981 0%, #34d399 100%)', boxShadow: '0 2px 4px rgba(0,0,0,0.15)' }}
>
💨
</div>
<div className="flex items-baseline gap-0.5">
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
{station.wind.speed.toFixed(1)}
</span>
<span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m/s</span>
</div>
</div>
</div>
</Marker>
)
})}
*/}
</>
)
}
/**
* WeatherMapOverlay에 대응하는 deck.gl 레이어 생성 훅
* WeatherView의 DeckGLOverlay layers 배열에 spread하여 사용
*/
// eslint-disable-next-line react-refresh/only-export-components
export function useWeatherDeckLayers(
stations: WeatherStation[],
enabledLayers: Set<string>,
selectedStationId: string | null,
onStationClick: (station: WeatherStation) => void
): Layer[] {
return useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: Layer[] = []
// 파고 분포 ScatterplotLayer (Circle 대체, 반경 = 파고 * 15km)
if (enabledLayers.has('waves')) {
const waveData = stations.map((s) => ({
position: [s.location.lon, s.location.lat] as [number, number],
radius: s.wave.height * 15000,
fillColor: hexToRgba(getWaveHexColor(s.wave.height), 38), // fillOpacity 0.15
lineColor: hexToRgba(getWaveHexColor(s.wave.height), 153), // opacity 0.6
station: s,
}))
result.push(
new ScatterplotLayer({
id: 'weather-wave-circles',
data: waveData,
getPosition: (d) => d.position,
getRadius: (d) => d.radius,
getFillColor: (d) => d.fillColor,
getLineColor: (d) => d.lineColor,
getLineWidth: 2,
stroked: true,
radiusUnits: 'meters',
pickable: true,
onClick: (info) => {
if (info.object) onStationClick(info.object.station)
},
}) as unknown as Layer
)
}
// 수온 분포 ScatterplotLayer (Circle 대체, 고정 반경 10km)
if (enabledLayers.has('temperature')) {
const tempData = stations.map((s) => ({
position: [s.location.lon, s.location.lat] as [number, number],
fillColor: hexToRgba(getTempHexColor(s.temperature.current), 51), // fillOpacity 0.2
lineColor: hexToRgba(getTempHexColor(s.temperature.current), 128), // opacity 0.5
station: s,
}))
result.push(
new ScatterplotLayer({
id: 'weather-temp-circles',
data: tempData,
getPosition: (d) => d.position,
getRadius: 10000,
getFillColor: (d) => d.fillColor,
getLineColor: (d) => d.lineColor,
getLineWidth: 1,
stroked: true,
radiusUnits: 'meters',
pickable: true,
onClick: (info) => {
if (info.object) onStationClick(info.object.station)
},
updateTriggers: {
getFillColor: [selectedStationId],
getLineColor: [selectedStationId],
},
}) as unknown as Layer
)
}
return result
}, [stations, enabledLayers, selectedStationId, onStationClick])
}