import { useEffect, useRef } from 'react'; import { useMap } from '@vis.gl/react-maplibre'; import type { HydrDataStep } from '@tabs/prediction/services/predictionApi'; import { useThemeStore } from '@common/store/themeStore'; interface HydrParticleOverlayProps { hydrStep: HydrDataStep | null; } const PARTICLE_COUNT = 3000; const MAX_AGE = 300; const SPEED_SCALE = 0.1; const DT = 600; const TRAIL_LENGTH = 30; // 파티클당 저장할 화면 좌표 수 const NUM_ALPHA_BANDS = 4; // stroke 배치 단위 interface TrailPoint { x: number; y: number; } interface Particle { lon: number; lat: number; trail: TrailPoint[]; age: number; } export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayProps) { const lightMode = useThemeStore((s) => s.theme) === 'light'; const { current: map } = useMap(); const animRef = useRef(); useEffect(() => { if (!map || !hydrStep) return; const container = map.getContainer(); const canvas = document.createElement('canvas'); canvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:5;'; canvas.width = container.clientWidth; canvas.height = container.clientHeight; container.appendChild(canvas); const ctx = canvas.getContext('2d')!; const { value: [u2d, v2d], grid, } = hydrStep; const { boundLonLat, lonInterval, latInterval } = grid; const lons: number[] = [boundLonLat.left]; for (const d of lonInterval) lons.push(lons[lons.length - 1] + d); const lats: number[] = [boundLonLat.bottom]; for (const d of latInterval) lats.push(lats[lats.length - 1] + d); function getUV(lon: number, lat: number): [number, number] { let col = -1, row = -1; for (let i = 0; i < lons.length - 1; i++) { if (lon >= lons[i] && lon < lons[i + 1]) { col = i; break; } } for (let i = 0; i < lats.length - 1; i++) { if (lat >= lats[i] && lat < lats[i + 1]) { row = i; break; } } if (col < 0 || row < 0) return [0, 0]; const fx = (lon - lons[col]) / (lons[col + 1] - lons[col]); const fy = (lat - lats[row]) / (lats[row + 1] - lats[row]); const u00 = u2d[row]?.[col] ?? 0, u01 = u2d[row]?.[col + 1] ?? u00; const u10 = u2d[row + 1]?.[col] ?? u00, u11 = u2d[row + 1]?.[col + 1] ?? u00; const v00 = v2d[row]?.[col] ?? 0, v01 = v2d[row]?.[col + 1] ?? v00; const v10 = v2d[row + 1]?.[col] ?? v00, v11 = v2d[row + 1]?.[col + 1] ?? v00; const u = u00 * (1 - fx) * (1 - fy) + u01 * fx * (1 - fy) + u10 * (1 - fx) * fy + u11 * fx * fy; const v = v00 * (1 - fx) * (1 - fy) + v01 * fx * (1 - fy) + v10 * (1 - fx) * fy + v11 * fx * fy; return [u, v]; } const bbox = boundLonLat; const particles: Particle[] = Array.from({ length: PARTICLE_COUNT }, () => ({ lon: bbox.left + Math.random() * (bbox.right - bbox.left), lat: bbox.bottom + Math.random() * (bbox.top - bbox.bottom), trail: [], age: Math.floor(Math.random() * MAX_AGE), })); function resetParticle(p: Particle) { p.lon = bbox.left + Math.random() * (bbox.right - bbox.left); p.lat = bbox.bottom + Math.random() * (bbox.top - bbox.bottom); p.trail = []; p.age = 0; } // 지도 이동/줌 시 화면 좌표가 틀어지므로 trail 초기화 const onMove = () => { for (const p of particles) p.trail = []; }; map.on('move', onMove); function animate() { // 매 프레임 완전 초기화 → 잔상 없음 ctx.clearRect(0, 0, canvas.width, canvas.height); // alpha band별 세그먼트 버퍼 (드로우 콜 최소화) const bands: [number, number, number, number][][] = Array.from( { length: NUM_ALPHA_BANDS }, () => [], ); for (const p of particles) { const [u, v] = getUV(p.lon, p.lat); const speed = Math.sqrt(u * u + v * v); if (speed < 0.001) { resetParticle(p); continue; } const cosLat = Math.cos((p.lat * Math.PI) / 180); p.lon += (u * SPEED_SCALE * DT) / (cosLat * 111320); p.lat += (v * SPEED_SCALE * DT) / 111320; p.age++; if ( p.lon < bbox.left || p.lon > bbox.right || p.lat < bbox.bottom || p.lat > bbox.top || p.age > MAX_AGE ) { resetParticle(p); continue; } const curr = map.project([p.lon, p.lat]); if (!curr) continue; p.trail.push({ x: curr.x, y: curr.y }); if (p.trail.length > TRAIL_LENGTH) p.trail.shift(); if (p.trail.length < 2) continue; for (let i = 1; i < p.trail.length; i++) { const t = i / p.trail.length; // 0=oldest, 1=newest const band = Math.min(NUM_ALPHA_BANDS - 1, Math.floor(t * NUM_ALPHA_BANDS)); const a = p.trail[i - 1], b = p.trail[i]; bands[band].push([a.x, a.y, b.x, b.y]); } } // alpha band별 일괄 렌더링 ctx.lineWidth = 0.8; for (let b = 0; b < NUM_ALPHA_BANDS; b++) { const [pr, pg, pb] = lightMode ? [30, 90, 180] : [180, 210, 255]; ctx.strokeStyle = `rgba(${pr}, ${pg}, ${pb}, ${((b + 1) / NUM_ALPHA_BANDS) * 0.75})`; ctx.beginPath(); for (const [x1, y1, x2, y2] of bands[b]) { ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); } ctx.stroke(); } animRef.current = requestAnimationFrame(animate); } animRef.current = requestAnimationFrame(animate); const onResize = () => { canvas.width = container.clientWidth; canvas.height = container.clientHeight; }; map.on('resize', onResize); return () => { cancelAnimationFrame(animRef.current!); map.off('resize', onResize); map.off('move', onMove); canvas.remove(); }; }, [map, hydrStep, lightMode]); return null; }