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(null) const particlesRef = useRef([]) const animFrameRef = useRef(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 }