import { useEffect, useRef, useCallback } from 'react' import { useMap } from '@vis.gl/react-maplibre' import type { Map as MapLibreMap } from 'maplibre-gl' interface WindPoint { lat: number lon: number speed: number // m/s direction: number // degrees (0=N, 90=E, 180=S, 270=W) } interface Particle { x: number y: number age: number maxAge: number } interface WindParticleLayerProps { visible: boolean stations: { location: { lat: number; lon: number } wind: { speed: number; direction: number } }[] } // 풍속 기반 색상 (Windy.com 스타일) function getWindColor(speed: number): string { if (speed < 3) return 'rgba(98, 113, 183, 0.8)' if (speed < 5) return 'rgba(57, 160, 246, 0.8)' if (speed < 7) return 'rgba(80, 213, 145, 0.8)' if (speed < 10) return 'rgba(165, 226, 63, 0.8)' if (speed < 13) return 'rgba(250, 226, 30, 0.8)' if (speed < 16) return 'rgba(250, 170, 25, 0.8)' if (speed < 20) return 'rgba(240, 84, 33, 0.8)' return 'rgba(180, 30, 70, 0.8)' } // 풍속 기반 배경 색상 (반투명 오버레이) function getWindBgColor(speed: number): string { if (speed < 3) return 'rgba(98, 113, 183, 0.12)' if (speed < 5) return 'rgba(57, 160, 246, 0.12)' if (speed < 7) return 'rgba(80, 213, 145, 0.12)' if (speed < 10) return 'rgba(165, 226, 63, 0.12)' if (speed < 13) return 'rgba(250, 226, 30, 0.12)' if (speed < 16) return 'rgba(250, 170, 25, 0.12)' if (speed < 20) return 'rgba(240, 84, 33, 0.12)' return 'rgba(180, 30, 70, 0.12)' } // 격자 보간으로 특정 위치의 풍속/풍향 추정 (IDW) function interpolateWind( lat: number, lon: number, windPoints: WindPoint[] ): { speed: number; direction: number } { if (windPoints.length === 0) return { speed: 5, direction: 270 } let totalWeight = 0 let weightedSpeed = 0 let weightedDx = 0 let weightedDy = 0 for (const point of windPoints) { 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 weightedSpeed += point.speed * weight const rad = (point.direction * Math.PI) / 180 weightedDx += Math.sin(rad) * weight weightedDy += -Math.cos(rad) * weight } const speed = weightedSpeed / totalWeight const direction = (Math.atan2(weightedDx / totalWeight, -weightedDy / totalWeight) * 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 = 800 const FADE_ALPHA = 0.93 /** * WindParticleLayer * * 기존: Canvas 2D + requestAnimationFrame + map.containerPointToLatLng() (Leaflet) * 전환: Canvas 2D + requestAnimationFrame + map.unproject() (MapLibre) * * @vis.gl/react-maplibre의 useMap()으로 MapLibre Map 인스턴스를 참조 * Canvas는 MapLibre 컨테이너 위에 absolute 레이어로 삽입 */ export function WindParticleLayer({ visible, stations }: WindParticleLayerProps) { const { current: mapRef } = useMap() const canvasRef = useRef(null) const particlesRef = useRef([]) const animFrameRef = useRef(0) const windPoints: WindPoint[] = stations.map((s) => ({ lat: s.location.lat, lon: s.location.lon, speed: s.wind.speed, direction: s.wind.direction, })) 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() * 80), maxAge: 60 + Math.floor(Math.random() * 40), }) } }, []) 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 = '450' 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 drawWindOverlay() { if (!ctx || !canvas) return const gridSize = 30 for (let x = 0; x < canvas.width; x += gridSize) { for (let y = 0; y < canvas.height; y += gridSize) { const { lat, lng } = containerPointToLatLng(map!, x + gridSize / 2, y + gridSize / 2) const wind = interpolateWind(lat, lng, windPoints) ctx.fillStyle = getWindBgColor(wind.speed) ctx.fillRect(x, y, gridSize, gridSize) } } } 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 = 60 + Math.floor(Math.random() * 40) 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 } const wind = interpolateWind(lat, lng, windPoints) const rad = (wind.direction * Math.PI) / 180 const pixelSpeed = wind.speed * 0.4 const oldX = particle.x const oldY = particle.y particle.x += Math.sin(rad) * pixelSpeed particle.y += -Math.cos(rad) * pixelSpeed // 파티클 트레일 그리기 const alpha = 1 - particle.age / particle.maxAge offCtx.strokeStyle = getWindColor(wind.speed).replace('0.8', String(alpha * 0.8)) offCtx.lineWidth = 1.2 offCtx.beginPath() offCtx.moveTo(oldX, oldY) offCtx.lineTo(particle.x, particle.y) offCtx.stroke() } // 메인 캔버스에 합성 ctx.clearRect(0, 0, canvas.width, canvas.height) drawWindOverlay() 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 } } // windPoints 배열은 렌더마다 재생성되므로 stations만 의존 // eslint-disable-next-line react-hooks/exhaustive-deps }, [mapRef, visible, stations, initParticles]) return null }