wing-ops/frontend/src/tabs/weather/components/OceanForecastOverlay.tsx

126 lines
3.3 KiB
TypeScript
Executable File

import { useEffect, useRef } from 'react'
import { useMap } from '@vis.gl/react-maplibre'
import type { OceanForecastData } from '../services/khoaApi'
interface OceanForecastOverlayProps {
forecast: OceanForecastData | null
opacity?: number
visible?: boolean
}
// 한국 해역 범위 [lon, lat]
const BOUNDS = {
nw: [124.5, 38.5] as [number, number],
ne: [132.0, 38.5] as [number, number],
se: [132.0, 33.0] as [number, number],
sw: [124.5, 33.0] as [number, number],
}
// www.khoa.go.kr 이미지는 CORS 미지원 → Vite 프록시 경유
function toProxyUrl(url: string): string {
return url.replace('https://www.khoa.go.kr', '')
}
/**
* OceanForecastOverlay
*
* MapLibre raster layer는 deck.gl 캔버스보다 항상 아래 렌더링되므로,
* WindParticleLayer와 동일하게 canvas를 직접 map 컨테이너에 삽입하는 방식 사용.
* z-index 500으로 WindParticleLayer(450) 위에 렌더링.
*/
export function OceanForecastOverlay({
forecast,
opacity = 0.6,
visible = true,
}: OceanForecastOverlayProps) {
const { current: mapRef } = useMap()
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const imgRef = useRef<HTMLImageElement | null>(null)
useEffect(() => {
const map = mapRef?.getMap()
if (!map) return
const container = map.getContainer()
// canvas 생성 (최초 1회)
if (!canvasRef.current) {
const canvas = document.createElement('canvas')
canvas.style.position = 'absolute'
canvas.style.top = '0'
canvas.style.left = '0'
canvas.style.pointerEvents = 'none'
canvas.style.zIndex = '500' // WindParticleLayer(450) 위
container.appendChild(canvas)
canvasRef.current = canvas
}
const canvas = canvasRef.current
if (!visible || !forecast?.imgFilePath) {
canvas.style.display = 'none'
return
}
canvas.style.display = 'block'
const proxyUrl = toProxyUrl(forecast.imgFilePath)
function draw() {
const img = imgRef.current
if (!canvas || !img || !img.complete || img.naturalWidth === 0) return
const { clientWidth: w, clientHeight: h } = container
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, w, h)
// 4개 꼭짓점을 픽셀 좌표로 변환
const nw = map!.project(BOUNDS.nw)
const ne = map!.project(BOUNDS.ne)
const sw = map!.project(BOUNDS.sw)
const x = Math.min(nw.x, sw.x)
const y = nw.y
const w2 = ne.x - nw.x
const h2 = sw.y - nw.y
ctx.globalAlpha = opacity
ctx.drawImage(img, x, y, w2, h2)
}
// 이미지가 바뀌었으면 새로 로드
if (!imgRef.current || imgRef.current.dataset.src !== proxyUrl) {
const img = new Image()
img.dataset.src = proxyUrl
img.onload = draw
img.src = proxyUrl
imgRef.current = img
} else {
draw()
}
map.on('move', draw)
map.on('zoom', draw)
map.on('resize', draw)
return () => {
map.off('move', draw)
map.off('zoom', draw)
map.off('resize', draw)
}
}, [mapRef, visible, forecast?.imgFilePath, opacity])
// 언마운트 시 canvas 제거
useEffect(() => {
return () => {
canvasRef.current?.remove()
canvasRef.current = null
}
}, [])
return null
}