wing-ops/frontend/src/tabs/weather/components/OceanCurrentParticleLayer.tsx
leedano 0c94c631c4 feat(weather): 해류 캔버스 파티클 레이어 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:03:17 +09:00

325 lines
9.6 KiB
TypeScript

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
}