feat(weather): 해류 캔버스 파티클 레이어 추가 #86
@ -4,6 +4,9 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 해류 캔버스 파티클 레이어 추가
|
||||||
|
|
||||||
## [2026-03-11.2]
|
## [2026-03-11.2]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
|||||||
@ -0,0 +1,324 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { useMap } from '@vis.gl/react-maplibre'
|
||||||
|
import type { Map as MapLibreMap } from 'maplibre-gl'
|
||||||
|
|
||||||
|
interface CurrentVectorPoint {
|
||||||
|
lat: number
|
||||||
|
lon: number
|
||||||
|
u: number // 동서 방향 속도 (양수=동, 음수=서) [m/s]
|
||||||
|
v: number // 남북 방향 속도 (양수=북, 음수=남) [m/s]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Particle {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
age: number
|
||||||
|
maxAge: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OceanCurrentParticleLayerProps {
|
||||||
|
visible: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// 해류 속도 기반 색상
|
||||||
|
function getCurrentColor(speed: number): string {
|
||||||
|
if (speed < 0.2) return 'rgba(59, 130, 246, 0.8)' // 파랑
|
||||||
|
if (speed < 0.4) return 'rgba(6, 182, 212, 0.8)' // 청록
|
||||||
|
if (speed < 0.6) return 'rgba(34, 197, 94, 0.8)' // 초록
|
||||||
|
return 'rgba(249, 115, 22, 0.8)' // 주황
|
||||||
|
}
|
||||||
|
|
||||||
|
// 한반도 육지 영역 판별 (간략화된 폴리곤)
|
||||||
|
const isOnLand = (lat: number, lon: number): boolean => {
|
||||||
|
const peninsula: [number, number][] = [
|
||||||
|
[38.5, 124.5], [38.5, 128.3],
|
||||||
|
[37.8, 128.8], [37.0, 129.2],
|
||||||
|
[36.0, 129.5], [35.1, 129.2],
|
||||||
|
[34.8, 128.6], [34.5, 127.8],
|
||||||
|
[34.3, 126.5], [34.8, 126.1],
|
||||||
|
[35.5, 126.0], [36.0, 126.3],
|
||||||
|
[36.8, 126.0], [37.5, 126.2],
|
||||||
|
[38.5, 124.5],
|
||||||
|
]
|
||||||
|
|
||||||
|
// 제주도 영역
|
||||||
|
if (lat >= 33.1 && lat <= 33.7 && lon >= 126.1 && lon <= 127.0) return true
|
||||||
|
|
||||||
|
// Ray casting algorithm
|
||||||
|
let inside = false
|
||||||
|
for (let i = 0, j = peninsula.length - 1; i < peninsula.length; j = i++) {
|
||||||
|
const [yi, xi] = peninsula[i]
|
||||||
|
const [yj, xj] = peninsula[j]
|
||||||
|
if ((yi > lat) !== (yj > lat) && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) {
|
||||||
|
inside = !inside
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inside
|
||||||
|
}
|
||||||
|
|
||||||
|
// 한국 해역의 해류 u,v 벡터 데이터 생성 (Mock)
|
||||||
|
const generateOceanCurrentData = (): CurrentVectorPoint[] => {
|
||||||
|
const data: CurrentVectorPoint[] = []
|
||||||
|
|
||||||
|
for (let lat = 33.5; lat <= 38.0; lat += 0.8) {
|
||||||
|
for (let lon = 125.0; lon <= 130.5; lon += 0.8) {
|
||||||
|
if (isOnLand(lat, lon)) continue
|
||||||
|
|
||||||
|
let u = 0
|
||||||
|
let v = 0
|
||||||
|
|
||||||
|
if (lon > 128.5) {
|
||||||
|
// 동해 — 북동진하는 동한난류
|
||||||
|
u = 0.2 + Math.random() * 0.2 // 동쪽 0.2~0.4
|
||||||
|
v = 0.3 + Math.random() * 0.2 // 북쪽 0.3~0.5
|
||||||
|
} else if (lon < 126.5) {
|
||||||
|
// 서해 — 북진
|
||||||
|
u = -0.05 + Math.random() * 0.1 // 동서 -0.05~0.05
|
||||||
|
v = 0.15 + Math.random() * 0.15 // 북쪽 0.15~0.3
|
||||||
|
} else {
|
||||||
|
// 남해 — 동진
|
||||||
|
u = 0.3 + Math.random() * 0.2 // 동쪽 0.3~0.5
|
||||||
|
v = -0.05 + Math.random() * 0.15 // 남북 -0.05~0.1
|
||||||
|
}
|
||||||
|
|
||||||
|
data.push({ lat, lon, u, v })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 해류 데이터는 한 번만 생성
|
||||||
|
const CURRENT_DATA = generateOceanCurrentData()
|
||||||
|
|
||||||
|
// IDW 보간으로 특정 위치의 u,v 벡터 추정 → speed/direction 반환
|
||||||
|
function interpolateCurrent(
|
||||||
|
lat: number,
|
||||||
|
lon: number,
|
||||||
|
points: CurrentVectorPoint[]
|
||||||
|
): { speed: number; direction: number } {
|
||||||
|
if (points.length === 0) return { speed: 0.3, direction: 90 }
|
||||||
|
|
||||||
|
let totalWeight = 0
|
||||||
|
let weightedU = 0
|
||||||
|
let weightedV = 0
|
||||||
|
|
||||||
|
for (const point of points) {
|
||||||
|
const dist = Math.sqrt(
|
||||||
|
Math.pow(point.lat - lat, 2) + Math.pow(point.lon - lon, 2)
|
||||||
|
)
|
||||||
|
const weight = 1 / Math.pow(Math.max(dist, 0.01), 2)
|
||||||
|
totalWeight += weight
|
||||||
|
weightedU += point.u * weight
|
||||||
|
weightedV += point.v * weight
|
||||||
|
}
|
||||||
|
|
||||||
|
const u = weightedU / totalWeight
|
||||||
|
const v = weightedV / totalWeight
|
||||||
|
const speed = Math.sqrt(u * u + v * v)
|
||||||
|
// u=동(+), v=북(+) → 화면 방향: sin=동(+x), -cos=남(+y)
|
||||||
|
const direction = (Math.atan2(u, v) * 180) / Math.PI
|
||||||
|
return { speed, direction: (direction + 360) % 360 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapLibre map.unproject()를 통해 픽셀 → 경위도 변환
|
||||||
|
function containerPointToLatLng(
|
||||||
|
map: MapLibreMap,
|
||||||
|
x: number,
|
||||||
|
y: number
|
||||||
|
): { lat: number; lng: number } {
|
||||||
|
const lngLat = map.unproject([x, y])
|
||||||
|
return { lat: lngLat.lat, lng: lngLat.lng }
|
||||||
|
}
|
||||||
|
|
||||||
|
const PARTICLE_COUNT = 400
|
||||||
|
const FADE_ALPHA = 0.93
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OceanCurrentParticleLayer
|
||||||
|
*
|
||||||
|
* Canvas 2D + requestAnimationFrame 패턴으로 해류 흐름 시각화
|
||||||
|
* u,v 벡터 격자 데이터를 IDW 보간하여 파티클 애니메이션 렌더링
|
||||||
|
* 바람 파티클 대비: 적은 입자, 느린 속도, 긴 트레일
|
||||||
|
*/
|
||||||
|
export function OceanCurrentParticleLayer({ visible }: OceanCurrentParticleLayerProps) {
|
||||||
|
const { current: mapRef } = useMap()
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||||
|
const particlesRef = useRef<Particle[]>([])
|
||||||
|
const animFrameRef = useRef<number>(0)
|
||||||
|
|
||||||
|
const initParticles = useCallback((width: number, height: number) => {
|
||||||
|
particlesRef.current = []
|
||||||
|
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
||||||
|
particlesRef.current.push({
|
||||||
|
x: Math.random() * width,
|
||||||
|
y: Math.random() * height,
|
||||||
|
age: Math.floor(Math.random() * 150),
|
||||||
|
maxAge: 120 + Math.floor(Math.random() * 60),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef?.getMap()
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
if (canvasRef.current) {
|
||||||
|
canvasRef.current.remove()
|
||||||
|
canvasRef.current = null
|
||||||
|
}
|
||||||
|
cancelAnimationFrame(animFrameRef.current)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = map.getContainer()
|
||||||
|
|
||||||
|
// Canvas 생성 또는 재사용
|
||||||
|
let canvas = canvasRef.current
|
||||||
|
if (!canvas) {
|
||||||
|
canvas = document.createElement('canvas')
|
||||||
|
canvas.style.position = 'absolute'
|
||||||
|
canvas.style.top = '0'
|
||||||
|
canvas.style.left = '0'
|
||||||
|
canvas.style.pointerEvents = 'none'
|
||||||
|
canvas.style.zIndex = '440'
|
||||||
|
container.appendChild(canvas)
|
||||||
|
canvasRef.current = canvas
|
||||||
|
}
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
if (!canvas) return
|
||||||
|
const { clientWidth: w, clientHeight: h } = container
|
||||||
|
canvas.width = w
|
||||||
|
canvas.height = h
|
||||||
|
}
|
||||||
|
resize()
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
initParticles(canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// 오프스크린 캔버스 (트레일 효과)
|
||||||
|
let offCanvas: HTMLCanvasElement | null = null
|
||||||
|
let offCtx: CanvasRenderingContext2D | null = null
|
||||||
|
|
||||||
|
function animate() {
|
||||||
|
if (!ctx || !canvas) return
|
||||||
|
|
||||||
|
// 오프스크린 캔버스 크기 동기화
|
||||||
|
if (!offCanvas || offCanvas.width !== canvas.width || offCanvas.height !== canvas.height) {
|
||||||
|
offCanvas = document.createElement('canvas')
|
||||||
|
offCanvas.width = canvas.width
|
||||||
|
offCanvas.height = canvas.height
|
||||||
|
offCtx = offCanvas.getContext('2d')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!offCtx) return
|
||||||
|
|
||||||
|
// 트레일 페이드 효과 (느린 페이드 = 부드러운 흐름)
|
||||||
|
offCtx.globalCompositeOperation = 'destination-in'
|
||||||
|
offCtx.fillStyle = `rgba(0, 0, 0, ${FADE_ALPHA})`
|
||||||
|
offCtx.fillRect(0, 0, offCanvas.width, offCanvas.height)
|
||||||
|
offCtx.globalCompositeOperation = 'source-over'
|
||||||
|
|
||||||
|
// 현재 지도 bounds 확인
|
||||||
|
const bounds = map!.getBounds()
|
||||||
|
|
||||||
|
for (const particle of particlesRef.current) {
|
||||||
|
particle.age++
|
||||||
|
|
||||||
|
// 수명 초과 시 리셋
|
||||||
|
if (particle.age > particle.maxAge) {
|
||||||
|
particle.x = Math.random() * canvas.width
|
||||||
|
particle.y = Math.random() * canvas.height
|
||||||
|
particle.age = 0
|
||||||
|
particle.maxAge = 120 + Math.floor(Math.random() * 60)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lat, lng } = containerPointToLatLng(map!, particle.x, particle.y)
|
||||||
|
|
||||||
|
// 화면 밖이면 리셋
|
||||||
|
if (!bounds.contains([lng, lat])) {
|
||||||
|
particle.x = Math.random() * canvas.width
|
||||||
|
particle.y = Math.random() * canvas.height
|
||||||
|
particle.age = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 육지 위이면 리셋
|
||||||
|
if (isOnLand(lat, lng)) {
|
||||||
|
particle.x = Math.random() * canvas.width
|
||||||
|
particle.y = Math.random() * canvas.height
|
||||||
|
particle.age = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = interpolateCurrent(lat, lng, CURRENT_DATA)
|
||||||
|
const rad = (current.direction * Math.PI) / 180
|
||||||
|
const pixelSpeed = current.speed * 2.0
|
||||||
|
|
||||||
|
const newX = particle.x + Math.sin(rad) * pixelSpeed
|
||||||
|
const newY = particle.y + -Math.cos(rad) * pixelSpeed
|
||||||
|
|
||||||
|
// 다음 위치가 육지이면 리셋
|
||||||
|
const nextPos = containerPointToLatLng(map!, newX, newY)
|
||||||
|
if (isOnLand(nextPos.lat, nextPos.lng)) {
|
||||||
|
particle.x = Math.random() * canvas.width
|
||||||
|
particle.y = Math.random() * canvas.height
|
||||||
|
particle.age = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldX = particle.x
|
||||||
|
const oldY = particle.y
|
||||||
|
particle.x = newX
|
||||||
|
particle.y = newY
|
||||||
|
|
||||||
|
// 파티클 트레일 그리기
|
||||||
|
const alpha = 1 - particle.age / particle.maxAge
|
||||||
|
offCtx.strokeStyle = getCurrentColor(current.speed).replace('0.8', String(alpha * 0.8))
|
||||||
|
offCtx.lineWidth = 0.8
|
||||||
|
offCtx.beginPath()
|
||||||
|
offCtx.moveTo(oldX, oldY)
|
||||||
|
offCtx.lineTo(particle.x, particle.y)
|
||||||
|
offCtx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메인 캔버스에 합성 (배경 오버레이 없이 파티클만)
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
ctx.drawImage(offCanvas, 0, 0)
|
||||||
|
|
||||||
|
animFrameRef.current = requestAnimationFrame(animate)
|
||||||
|
}
|
||||||
|
|
||||||
|
animate()
|
||||||
|
|
||||||
|
// 지도 이동/줌 시 리셋
|
||||||
|
const onMoveEnd = () => {
|
||||||
|
resize()
|
||||||
|
if (canvas) initParticles(canvas.width, canvas.height)
|
||||||
|
if (offCanvas && canvas) {
|
||||||
|
offCanvas.width = canvas.width
|
||||||
|
offCanvas.height = canvas.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map.on('moveend', onMoveEnd)
|
||||||
|
map.on('zoomend', onMoveEnd)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animFrameRef.current)
|
||||||
|
map.off('moveend', onMoveEnd)
|
||||||
|
map.off('zoomend', onMoveEnd)
|
||||||
|
if (canvasRef.current) {
|
||||||
|
canvasRef.current.remove()
|
||||||
|
canvasRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [mapRef, visible, initParticles])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@ -6,12 +6,13 @@ import type { StyleSpecification, MapLayerMouseEvent } from 'maplibre-gl'
|
|||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import { WeatherRightPanel } from './WeatherRightPanel'
|
import { WeatherRightPanel } from './WeatherRightPanel'
|
||||||
import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay'
|
import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay'
|
||||||
import { OceanForecastOverlay } from './OceanForecastOverlay'
|
// import { OceanForecastOverlay } from './OceanForecastOverlay'
|
||||||
import { useOceanCurrentLayers } from './OceanCurrentLayer'
|
// import { useOceanCurrentLayers } from './OceanCurrentLayer'
|
||||||
import { useWaterTemperatureLayers } from './WaterTemperatureLayer'
|
import { useWaterTemperatureLayers } from './WaterTemperatureLayer'
|
||||||
import { WindParticleLayer } from './WindParticleLayer'
|
import { WindParticleLayer } from './WindParticleLayer'
|
||||||
|
import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer'
|
||||||
import { useWeatherData } from '../hooks/useWeatherData'
|
import { useWeatherData } from '../hooks/useWeatherData'
|
||||||
import { useOceanForecast } from '../hooks/useOceanForecast'
|
// import { useOceanForecast } from '../hooks/useOceanForecast'
|
||||||
import { WeatherMapControls } from './WeatherMapControls'
|
import { WeatherMapControls } from './WeatherMapControls'
|
||||||
|
|
||||||
type TimeOffset = '0' | '3' | '6' | '9'
|
type TimeOffset = '0' | '3' | '6' | '9'
|
||||||
@ -125,8 +126,6 @@ interface WeatherMapInnerProps {
|
|||||||
weatherStations: WeatherStation[]
|
weatherStations: WeatherStation[]
|
||||||
enabledLayers: Set<string>
|
enabledLayers: Set<string>
|
||||||
selectedStationId: string | null
|
selectedStationId: string | null
|
||||||
oceanForecastOpacity: number
|
|
||||||
selectedForecast: ReturnType<typeof useOceanForecast>['selectedForecast']
|
|
||||||
onStationClick: (station: WeatherStation) => void
|
onStationClick: (station: WeatherStation) => void
|
||||||
mapCenter: [number, number]
|
mapCenter: [number, number]
|
||||||
mapZoom: number
|
mapZoom: number
|
||||||
@ -137,8 +136,6 @@ function WeatherMapInner({
|
|||||||
weatherStations,
|
weatherStations,
|
||||||
enabledLayers,
|
enabledLayers,
|
||||||
selectedStationId,
|
selectedStationId,
|
||||||
oceanForecastOpacity,
|
|
||||||
selectedForecast,
|
|
||||||
onStationClick,
|
onStationClick,
|
||||||
mapCenter,
|
mapCenter,
|
||||||
mapZoom,
|
mapZoom,
|
||||||
@ -151,18 +148,18 @@ function WeatherMapInner({
|
|||||||
selectedStationId,
|
selectedStationId,
|
||||||
onStationClick
|
onStationClick
|
||||||
)
|
)
|
||||||
const oceanCurrentLayers = useOceanCurrentLayers({
|
// const oceanCurrentLayers = useOceanCurrentLayers({
|
||||||
visible: enabledLayers.has('oceanCurrent'),
|
// visible: enabledLayers.has('oceanCurrent'),
|
||||||
opacity: 0.7,
|
// opacity: 0.7,
|
||||||
})
|
// })
|
||||||
const waterTempLayers = useWaterTemperatureLayers({
|
const waterTempLayers = useWaterTemperatureLayers({
|
||||||
visible: enabledLayers.has('waterTemperature'),
|
visible: enabledLayers.has('waterTemperature'),
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
})
|
})
|
||||||
|
|
||||||
const deckLayers = useMemo(
|
const deckLayers = useMemo(
|
||||||
() => [...oceanCurrentLayers, ...waterTempLayers, ...weatherDeckLayers],
|
() => [...waterTempLayers, ...weatherDeckLayers],
|
||||||
[oceanCurrentLayers, waterTempLayers, weatherDeckLayers]
|
[waterTempLayers, weatherDeckLayers]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -170,11 +167,16 @@ function WeatherMapInner({
|
|||||||
{/* deck.gl 오버레이 */}
|
{/* deck.gl 오버레이 */}
|
||||||
<DeckGLOverlay layers={deckLayers} />
|
<DeckGLOverlay layers={deckLayers} />
|
||||||
|
|
||||||
{/* 해황예보도 — MapLibre image source + raster layer */}
|
{/* 해황예보도 — 임시 비활성화
|
||||||
<OceanForecastOverlay
|
<OceanForecastOverlay
|
||||||
forecast={selectedForecast}
|
forecast={selectedForecast}
|
||||||
opacity={oceanForecastOpacity}
|
opacity={oceanForecastOpacity}
|
||||||
visible={enabledLayers.has('oceanForecast')}
|
visible={enabledLayers.has('oceanForecast')}
|
||||||
|
/> */}
|
||||||
|
|
||||||
|
{/* 해류 흐름 파티클 애니메이션 (Canvas 직접 조작) */}
|
||||||
|
<OceanCurrentParticleLayer
|
||||||
|
visible={enabledLayers.has('oceanCurrentParticle')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 기상 관측소 HTML 오버레이 (풍향 화살표 + 라벨) */}
|
{/* 기상 관측소 HTML 오버레이 (풍향 화살표 + 라벨) */}
|
||||||
@ -224,13 +226,13 @@ export function WeatherView() {
|
|||||||
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS)
|
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS)
|
||||||
|
|
||||||
|
|
||||||
const {
|
// const {
|
||||||
selectedForecast,
|
// selectedForecast,
|
||||||
availableTimes,
|
// availableTimes,
|
||||||
loading: oceanLoading,
|
// loading: oceanLoading,
|
||||||
error: oceanError,
|
// error: oceanError,
|
||||||
selectForecast,
|
// selectForecast,
|
||||||
} = useOceanForecast('KOREA')
|
// } = useOceanForecast('KOREA')
|
||||||
|
|
||||||
const [timeOffset, setTimeOffset] = useState<TimeOffset>('0')
|
const [timeOffset, setTimeOffset] = useState<TimeOffset>('0')
|
||||||
const [selectedStationRaw, setSelectedStation] = useState<WeatherStation | null>(null)
|
const [selectedStationRaw, setSelectedStation] = useState<WeatherStation | null>(null)
|
||||||
@ -238,7 +240,7 @@ export function WeatherView() {
|
|||||||
null
|
null
|
||||||
)
|
)
|
||||||
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind']))
|
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind']))
|
||||||
const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
|
// const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
|
||||||
|
|
||||||
// 첫 관측소 자동 선택 (파생 값)
|
// 첫 관측소 자동 선택 (파생 값)
|
||||||
const selectedStation = selectedStationRaw ?? weatherStations[0] ?? null
|
const selectedStation = selectedStationRaw ?? weatherStations[0] ?? null
|
||||||
@ -361,8 +363,6 @@ export function WeatherView() {
|
|||||||
weatherStations={weatherStations}
|
weatherStations={weatherStations}
|
||||||
enabledLayers={enabledLayers}
|
enabledLayers={enabledLayers}
|
||||||
selectedStationId={selectedStation?.id || null}
|
selectedStationId={selectedStation?.id || null}
|
||||||
oceanForecastOpacity={oceanForecastOpacity}
|
|
||||||
selectedForecast={selectedForecast}
|
|
||||||
onStationClick={handleStationClick}
|
onStationClick={handleStationClick}
|
||||||
mapCenter={WEATHER_MAP_CENTER}
|
mapCenter={WEATHER_MAP_CENTER}
|
||||||
mapZoom={WEATHER_MAP_ZOOM}
|
mapZoom={WEATHER_MAP_ZOOM}
|
||||||
@ -424,11 +424,11 @@ export function WeatherView() {
|
|||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={enabledLayers.has('oceanCurrent')}
|
checked={enabledLayers.has('oceanCurrentParticle')}
|
||||||
onChange={() => toggleLayer('oceanCurrent')}
|
onChange={() => toggleLayer('oceanCurrentParticle')}
|
||||||
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
|
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>
|
<span className="text-xs text-text-2">🌊 해류 흐름</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
@ -440,7 +440,7 @@ export function WeatherView() {
|
|||||||
<span className="text-xs text-text-2">🌡️ 수온 색상도</span>
|
<span className="text-xs text-text-2">🌡️ 수온 색상도</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* 해황예보도 레이어 */}
|
{/* 해황예보도 레이어 — 임시 비활성화
|
||||||
<div className="pt-2 mt-2 border-t border-border">
|
<div className="pt-2 mt-2 border-t border-border">
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
@ -451,60 +451,8 @@ export function WeatherView() {
|
|||||||
/>
|
/>
|
||||||
<span className="text-xs text-text-2">🌊 해황예보도</span>
|
<span className="text-xs text-text-2">🌊 해황예보도</span>
|
||||||
</label>
|
</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?.ofcFrcstYmd === time.day &&
|
|
||||||
selectedForecast?.ofcFrcstTm === 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.ofcBrnchNm} •{' '}
|
|
||||||
{selectedForecast.ofcFrcstYmd.slice(4, 6)}/{selectedForecast.ofcFrcstYmd.slice(6, 8)}{' '}
|
|
||||||
{selectedForecast.ofcFrcstTm}:00
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
*/}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -536,6 +484,23 @@ export function WeatherView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 해류 */}
|
||||||
|
<div className="pt-2 border-t border-border">
|
||||||
|
<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: 'rgb(59, 130, 246)' }} />
|
||||||
|
<div className="flex-1 h-full" style={{ background: 'rgb(6, 182, 212)' }} />
|
||||||
|
<div className="flex-1 h-full" style={{ background: 'rgb(34, 197, 94)' }} />
|
||||||
|
<div className="flex-1 h-full" style={{ background: 'rgb(249, 115, 22)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-text-3 text-[9px]">
|
||||||
|
<span>0.2</span>
|
||||||
|
<span>0.4</span>
|
||||||
|
<span>0.6</span>
|
||||||
|
<span>0.6+</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 파고 */}
|
{/* 파고 */}
|
||||||
<div className="pt-2 border-t border-border">
|
<div className="pt-2 border-t border-border">
|
||||||
<div className="font-semibold text-text-2 mb-1">파고 (m)</div>
|
<div className="font-semibold text-text-2 mb-1">파고 (m)</div>
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user