220 lines
7.0 KiB
TypeScript
220 lines
7.0 KiB
TypeScript
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<number | undefined>(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;
|
|
}
|