import { useEffect, useRef } from 'react'; import { useMap } from '@vis.gl/react-maplibre'; import type { HydrDataStep } from '@interfaces/prediction/PredictionInterface'; interface HydrParticleOverlayProps { hydrStep: HydrDataStep | null; } const PARTICLE_COUNT = 3000; const MAX_AGE = 300; const SPEED_SCALE = 0.15; const DT = 600; const DEG_TO_RAD = Math.PI / 180; const PI_4 = Math.PI / 4; const FADE_ALPHA = 0.02; // 프레임당 페이드량 (낮을수록 긴 꼬리) export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayProps) { const { current: mapRef } = useMap(); const animRef = useRef(undefined); useEffect(() => { if (!mapRef || !hydrStep) return; const map = mapRef; 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 bisect(arr: number[], val: number): number { let lo = 0, hi = arr.length - 2; while (lo <= hi) { const mid = (lo + hi) >> 1; if (val < arr[mid]) hi = mid - 1; else if (val >= arr[mid + 1]) lo = mid + 1; else return mid; } return -1; } function getUV(lon: number, lat: number): [number, number] { const col = bisect(lons, lon); const row = bisect(lats, lat); 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 _1fx = 1 - fx, _1fy = 1 - fy; return [ u00 * _1fx * _1fy + u01 * fx * _1fy + u10 * _1fx * fy + u11 * fx * fy, v00 * _1fx * _1fy + v01 * fx * _1fy + v10 * _1fx * fy + v11 * fx * fy, ]; } const bbox = boundLonLat; const bboxW = bbox.right - bbox.left; const bboxH = bbox.top - bbox.bottom; // 파티클: 위치 + 이전 화면좌표 (선분 1개만 그리면 됨) const pLon = new Float64Array(PARTICLE_COUNT); const pLat = new Float64Array(PARTICLE_COUNT); const pAge = new Int32Array(PARTICLE_COUNT); const pPrevX = new Float32Array(PARTICLE_COUNT); // 이전 프레임 화면 X const pPrevY = new Float32Array(PARTICLE_COUNT); // 이전 프레임 화면 Y const pHasPrev = new Uint8Array(PARTICLE_COUNT); // 이전 좌표 유효 여부 for (let i = 0; i < PARTICLE_COUNT; i++) { pLon[i] = bbox.left + Math.random() * bboxW; pLat[i] = bbox.bottom + Math.random() * bboxH; pAge[i] = Math.floor(Math.random() * MAX_AGE); pHasPrev[i] = 0; } function resetParticle(i: number) { pLon[i] = bbox.left + Math.random() * bboxW; pLat[i] = bbox.bottom + Math.random() * bboxH; pAge[i] = 0; pHasPrev[i] = 0; } // Mercator 수동 투영 function lngToMercX(lng: number, worldSize: number): number { return ((lng + 180) / 360) * worldSize; } function latToMercY(lat: number, worldSize: number): number { return ((1 - Math.log(Math.tan(PI_4 + (lat * DEG_TO_RAD) / 2)) / Math.PI) / 2) * worldSize; } // 지도 이동 시 캔버스 초기화 + 이전 좌표 무효화 const onMove = () => { ctx.clearRect(0, 0, canvas.width, canvas.height); for (let i = 0; i < PARTICLE_COUNT; i++) pHasPrev[i] = 0; }; map.on('move', onMove); function animate() { const w = canvas.width; const h = canvas.height; // ── 페이드: 기존 내용을 서서히 지움 (destination-out) ── ctx.globalCompositeOperation = 'destination-out'; ctx.fillStyle = `rgba(0, 0, 0, ${FADE_ALPHA})`; ctx.fillRect(0, 0, w, h); ctx.globalCompositeOperation = 'source-over'; // 뷰포트 transform (프레임당 1회) const zoom = map.getZoom(); const center = map.getCenter(); const bearing = map.getBearing(); const worldSize = 512 * Math.pow(2, zoom); const cx = lngToMercX(center.lng, worldSize); const cy = latToMercY(center.lat, worldSize); const halfW = w / 2; const halfH = h / 2; const bearingRad = -bearing * DEG_TO_RAD; const cosB = Math.cos(bearingRad); const sinB = Math.sin(bearingRad); const hasBearing = Math.abs(bearing) > 0.01; // ── 파티클당 선분 1개만 그리기 (3000 선분) ── ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; ctx.lineWidth = 1.5; ctx.beginPath(); for (let i = 0; i < PARTICLE_COUNT; i++) { const lon = pLon[i], lat = pLat[i]; const [u, v] = getUV(lon, lat); const speed2 = u * u + v * v; if (speed2 < 0.000001) { resetParticle(i); continue; } const cosLat = Math.cos(lat * DEG_TO_RAD); pLon[i] = lon + (u * SPEED_SCALE * DT) / (cosLat * 111320); pLat[i] = lat + (v * SPEED_SCALE * DT) / 111320; pAge[i]++; if ( pLon[i] < bbox.left || pLon[i] > bbox.right || pLat[i] < bbox.bottom || pLat[i] > bbox.top || pAge[i] > MAX_AGE ) { resetParticle(i); continue; } // 수동 Mercator 투영 let dx = lngToMercX(pLon[i], worldSize) - cx; let dy = latToMercY(pLat[i], worldSize) - cy; if (hasBearing) { const rx = dx * cosB - dy * sinB; const ry = dx * sinB + dy * cosB; dx = rx; dy = ry; } const sx = dx + halfW; const sy = dy + halfH; // 이전 좌표가 있으면 선분 1개 추가 if (pHasPrev[i]) { ctx.moveTo(pPrevX[i], pPrevY[i]); ctx.lineTo(sx, sy); } pPrevX[i] = sx; pPrevY[i] = sy; pHasPrev[i] = 1; } 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(); }; }, [mapRef, hydrStep]); return null; }