## 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>
126 lines
3.3 KiB
TypeScript
Executable File
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
|
|
}
|