## 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>
279 lines
10 KiB
TypeScript
Executable File
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])
|
|
}
|