195 lines
5.9 KiB
TypeScript
195 lines
5.9 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { useMap } from '@vis.gl/react-maplibre';
|
|
import type { HydrDataStep } from '@tabs/prediction/services/predictionApi';
|
|
|
|
interface HydrParticleOverlayProps {
|
|
hydrStep: HydrDataStep | null;
|
|
lightMode?: boolean;
|
|
}
|
|
|
|
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,
|
|
lightMode = false,
|
|
}: HydrParticleOverlayProps) {
|
|
const { current: map } = useMap();
|
|
const animRef = useRef<number>();
|
|
|
|
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;
|
|
}
|