feat(hns): 파티클 렌더링 성능 최적화 및 위험도 뱃지 동적 표시
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
a33dd09485
커밋
7e0da5ea76
@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useMap } from '@vis.gl/react-maplibre';
|
import { useMap } from '@vis.gl/react-maplibre';
|
||||||
import type { HydrDataStep } from '@tabs/prediction/services/predictionApi';
|
import type { HydrDataStep } from '@tabs/prediction/services/predictionApi';
|
||||||
import { useThemeStore } from '@common/store/themeStore';
|
|
||||||
|
|
||||||
interface HydrParticleOverlayProps {
|
interface HydrParticleOverlayProps {
|
||||||
hydrStep: HydrDataStep | null;
|
hydrStep: HydrDataStep | null;
|
||||||
@ -9,24 +8,13 @@ interface HydrParticleOverlayProps {
|
|||||||
|
|
||||||
const PARTICLE_COUNT = 3000;
|
const PARTICLE_COUNT = 3000;
|
||||||
const MAX_AGE = 300;
|
const MAX_AGE = 300;
|
||||||
const SPEED_SCALE = 0.1;
|
const SPEED_SCALE = 0.15;
|
||||||
const DT = 600;
|
const DT = 600;
|
||||||
const TRAIL_LENGTH = 30; // 파티클당 저장할 화면 좌표 수
|
const DEG_TO_RAD = Math.PI / 180;
|
||||||
const NUM_ALPHA_BANDS = 4; // stroke 배치 단위
|
const PI_4 = Math.PI / 4;
|
||||||
|
const FADE_ALPHA = 0.02; // 프레임당 페이드량 (낮을수록 긴 꼬리)
|
||||||
interface TrailPoint {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
interface Particle {
|
|
||||||
lon: number;
|
|
||||||
lat: number;
|
|
||||||
trail: TrailPoint[];
|
|
||||||
age: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayProps) {
|
export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayProps) {
|
||||||
const lightMode = useThemeStore((s) => s.theme) === 'light';
|
|
||||||
const { current: map } = useMap();
|
const { current: map } = useMap();
|
||||||
const animRef = useRef<number>();
|
const animRef = useRef<number>();
|
||||||
|
|
||||||
@ -52,21 +40,21 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro
|
|||||||
const lats: number[] = [boundLonLat.bottom];
|
const lats: number[] = [boundLonLat.bottom];
|
||||||
for (const d of latInterval) lats.push(lats[lats.length - 1] + d);
|
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] {
|
function getUV(lon: number, lat: number): [number, number] {
|
||||||
let col = -1,
|
const col = bisect(lons, lon);
|
||||||
row = -1;
|
const row = bisect(lats, lat);
|
||||||
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];
|
if (col < 0 || row < 0) return [0, 0];
|
||||||
const fx = (lon - lons[col]) / (lons[col + 1] - lons[col]);
|
const fx = (lon - lons[col]) / (lons[col + 1] - lons[col]);
|
||||||
const fy = (lat - lats[row]) / (lats[row + 1] - lats[row]);
|
const fy = (lat - lats[row]) / (lats[row + 1] - lats[row]);
|
||||||
@ -78,96 +66,134 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro
|
|||||||
v01 = v2d[row]?.[col + 1] ?? v00;
|
v01 = v2d[row]?.[col + 1] ?? v00;
|
||||||
const v10 = v2d[row + 1]?.[col] ?? v00,
|
const v10 = v2d[row + 1]?.[col] ?? v00,
|
||||||
v11 = v2d[row + 1]?.[col + 1] ?? v00;
|
v11 = v2d[row + 1]?.[col + 1] ?? v00;
|
||||||
const u =
|
const _1fx = 1 - fx,
|
||||||
u00 * (1 - fx) * (1 - fy) + u01 * fx * (1 - fy) + u10 * (1 - fx) * fy + u11 * fx * fy;
|
_1fy = 1 - fy;
|
||||||
const v =
|
return [
|
||||||
v00 * (1 - fx) * (1 - fy) + v01 * fx * (1 - fy) + v10 * (1 - fx) * fy + v11 * fx * fy;
|
u00 * _1fx * _1fy + u01 * fx * _1fy + u10 * _1fx * fy + u11 * fx * fy,
|
||||||
return [u, v];
|
v00 * _1fx * _1fy + v01 * fx * _1fy + v10 * _1fx * fy + v11 * fx * fy,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const bbox = boundLonLat;
|
const bbox = boundLonLat;
|
||||||
const particles: Particle[] = Array.from({ length: PARTICLE_COUNT }, () => ({
|
const bboxW = bbox.right - bbox.left;
|
||||||
lon: bbox.left + Math.random() * (bbox.right - bbox.left),
|
const bboxH = bbox.top - bbox.bottom;
|
||||||
lat: bbox.bottom + Math.random() * (bbox.top - bbox.bottom),
|
|
||||||
trail: [],
|
|
||||||
age: Math.floor(Math.random() * MAX_AGE),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function resetParticle(p: Particle) {
|
// 파티클: 위치 + 이전 화면좌표 (선분 1개만 그리면 됨)
|
||||||
p.lon = bbox.left + Math.random() * (bbox.right - bbox.left);
|
const pLon = new Float64Array(PARTICLE_COUNT);
|
||||||
p.lat = bbox.bottom + Math.random() * (bbox.top - bbox.bottom);
|
const pLat = new Float64Array(PARTICLE_COUNT);
|
||||||
p.trail = [];
|
const pAge = new Int32Array(PARTICLE_COUNT);
|
||||||
p.age = 0;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 지도 이동/줌 시 화면 좌표가 틀어지므로 trail 초기화
|
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 = () => {
|
const onMove = () => {
|
||||||
for (const p of particles) p.trail = [];
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
for (let i = 0; i < PARTICLE_COUNT; i++) pHasPrev[i] = 0;
|
||||||
};
|
};
|
||||||
map.on('move', onMove);
|
map.on('move', onMove);
|
||||||
|
|
||||||
function animate() {
|
function animate() {
|
||||||
// 매 프레임 완전 초기화 → 잔상 없음
|
const w = canvas.width;
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
const h = canvas.height;
|
||||||
|
|
||||||
// alpha band별 세그먼트 버퍼 (드로우 콜 최소화)
|
// ── 페이드: 기존 내용을 서서히 지움 (destination-out) ──
|
||||||
const bands: [number, number, number, number][][] = Array.from(
|
ctx.globalCompositeOperation = 'destination-out';
|
||||||
{ length: NUM_ALPHA_BANDS },
|
ctx.fillStyle = `rgba(0, 0, 0, ${FADE_ALPHA})`;
|
||||||
() => [],
|
ctx.fillRect(0, 0, w, h);
|
||||||
);
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
|
|
||||||
for (const p of particles) {
|
// 뷰포트 transform (프레임당 1회)
|
||||||
const [u, v] = getUV(p.lon, p.lat);
|
const zoom = map.getZoom();
|
||||||
const speed = Math.sqrt(u * u + v * v);
|
const center = map.getCenter();
|
||||||
if (speed < 0.001) {
|
const bearing = map.getBearing();
|
||||||
resetParticle(p);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cosLat = Math.cos((p.lat * Math.PI) / 180);
|
const cosLat = Math.cos(lat * DEG_TO_RAD);
|
||||||
p.lon += (u * SPEED_SCALE * DT) / (cosLat * 111320);
|
pLon[i] = lon + (u * SPEED_SCALE * DT) / (cosLat * 111320);
|
||||||
p.lat += (v * SPEED_SCALE * DT) / 111320;
|
pLat[i] = lat + (v * SPEED_SCALE * DT) / 111320;
|
||||||
p.age++;
|
pAge[i]++;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
p.lon < bbox.left ||
|
pLon[i] < bbox.left ||
|
||||||
p.lon > bbox.right ||
|
pLon[i] > bbox.right ||
|
||||||
p.lat < bbox.bottom ||
|
pLat[i] < bbox.bottom ||
|
||||||
p.lat > bbox.top ||
|
pLat[i] > bbox.top ||
|
||||||
p.age > MAX_AGE
|
pAge[i] > MAX_AGE
|
||||||
) {
|
) {
|
||||||
resetParticle(p);
|
resetParticle(i);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const curr = map.project([p.lon, p.lat]);
|
// 수동 Mercator 투영
|
||||||
if (!curr) continue;
|
let dx = lngToMercX(pLon[i], worldSize) - cx;
|
||||||
|
let dy = latToMercY(pLat[i], worldSize) - cy;
|
||||||
p.trail.push({ x: curr.x, y: curr.y });
|
if (hasBearing) {
|
||||||
if (p.trail.length > TRAIL_LENGTH) p.trail.shift();
|
const rx = dx * cosB - dy * sinB;
|
||||||
if (p.trail.length < 2) continue;
|
const ry = dx * sinB + dy * cosB;
|
||||||
|
dx = rx;
|
||||||
for (let i = 1; i < p.trail.length; i++) {
|
dy = ry;
|
||||||
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]);
|
|
||||||
}
|
}
|
||||||
|
const sx = dx + halfW;
|
||||||
|
const sy = dy + halfH;
|
||||||
|
|
||||||
|
// 이전 좌표가 있으면 선분 1개 추가
|
||||||
|
if (pHasPrev[i]) {
|
||||||
|
ctx.moveTo(pPrevX[i], pPrevY[i]);
|
||||||
|
ctx.lineTo(sx, sy);
|
||||||
}
|
}
|
||||||
|
|
||||||
// alpha band별 일괄 렌더링
|
pPrevX[i] = sx;
|
||||||
ctx.lineWidth = 0.8;
|
pPrevY[i] = sy;
|
||||||
for (let b = 0; b < NUM_ALPHA_BANDS; b++) {
|
pHasPrev[i] = 1;
|
||||||
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();
|
ctx.stroke();
|
||||||
}
|
|
||||||
|
|
||||||
animRef.current = requestAnimationFrame(animate);
|
animRef.current = requestAnimationFrame(animate);
|
||||||
}
|
}
|
||||||
@ -186,7 +212,7 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro
|
|||||||
map.off('move', onMove);
|
map.off('move', onMove);
|
||||||
canvas.remove();
|
canvas.remove();
|
||||||
};
|
};
|
||||||
}, [map, hydrStep, lightMode]);
|
}, [map, hydrStep]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -329,7 +329,11 @@ export function RightPanel({
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* 오염 종합 상황 */}
|
{/* 오염 종합 상황 */}
|
||||||
<Section title="오염 종합 상황" badge="위험" badgeColor="red">
|
<Section
|
||||||
|
title="오염 종합 상황"
|
||||||
|
badge={getPollutionSeverity(spill?.volume)?.label}
|
||||||
|
badgeColor={getPollutionSeverity(spill?.volume)?.color}
|
||||||
|
>
|
||||||
<div className="grid grid-cols-2 gap-0.5 text-label-2">
|
<div className="grid grid-cols-2 gap-0.5 text-label-2">
|
||||||
<StatBox
|
<StatBox
|
||||||
label="유출량"
|
label="유출량"
|
||||||
@ -367,7 +371,11 @@ export function RightPanel({
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* 확산 예측 요약 */}
|
{/* 확산 예측 요약 */}
|
||||||
<Section title={`확산 예측 요약 (+${predictionTime ?? 18}h)`} badge="위험" badgeColor="red">
|
<Section
|
||||||
|
title={`확산 예측 요약 (+${predictionTime ?? 18}h)`}
|
||||||
|
badge={getSpreadSeverity(spreadSummary?.distance, spreadSummary?.speed)?.label}
|
||||||
|
badgeColor={getSpreadSeverity(spreadSummary?.distance, spreadSummary?.speed)?.color}
|
||||||
|
>
|
||||||
<div className="grid grid-cols-2 gap-0.5 text-label-2">
|
<div className="grid grid-cols-2 gap-0.5 text-label-2">
|
||||||
<PredictionCard
|
<PredictionCard
|
||||||
value={spreadSummary?.area != null ? `${spreadSummary.area.toFixed(1)} km²` : '—'}
|
value={spreadSummary?.area != null ? `${spreadSummary.area.toFixed(1)} km²` : '—'}
|
||||||
@ -598,7 +606,55 @@ export function RightPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 위험도 등급 (방제대책본부 운영 규칙 유출량 기준 + 국가 위기경보 4단계)
|
||||||
|
type SeverityColor = 'red' | 'orange' | 'yellow' | 'green';
|
||||||
|
interface SeverityLevel {
|
||||||
|
label: string;
|
||||||
|
color: SeverityColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEVERITY_LEVELS: SeverityLevel[] = [
|
||||||
|
{ label: '심각', color: 'red' },
|
||||||
|
{ label: '경계', color: 'orange' },
|
||||||
|
{ label: '주의', color: 'yellow' },
|
||||||
|
{ label: '관심', color: 'green' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 오염 종합 상황 — 유출량(kl) 기준 */
|
||||||
|
function getPollutionSeverity(volumeKl: number | null | undefined): SeverityLevel | null {
|
||||||
|
if (volumeKl == null) return null;
|
||||||
|
if (volumeKl >= 500) return SEVERITY_LEVELS[0]; // 심각 (중앙방제대책본부)
|
||||||
|
if (volumeKl >= 50) return SEVERITY_LEVELS[1]; // 경계 (광역방제대책본부)
|
||||||
|
if (volumeKl >= 10) return SEVERITY_LEVELS[2]; // 주의 (지역방제대책본부)
|
||||||
|
return SEVERITY_LEVELS[3]; // 관심
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 확산 예측 요약 — 확산거리(km) + 속도(m/s) 중 높은 등급 */
|
||||||
|
function getSpreadSeverity(
|
||||||
|
distanceKm: number | null | undefined,
|
||||||
|
speedMs: number | null | undefined,
|
||||||
|
): SeverityLevel | null {
|
||||||
|
if (distanceKm == null && speedMs == null) return null;
|
||||||
|
|
||||||
|
const distLevel =
|
||||||
|
distanceKm == null ? 3 : distanceKm >= 15 ? 0 : distanceKm >= 5 ? 1 : distanceKm >= 1 ? 2 : 3;
|
||||||
|
const speedLevel =
|
||||||
|
speedMs == null ? 3 : speedMs >= 0.3 ? 0 : speedMs >= 0.15 ? 1 : speedMs >= 0.05 ? 2 : 3;
|
||||||
|
|
||||||
|
return SEVERITY_LEVELS[Math.min(distLevel, speedLevel)];
|
||||||
|
}
|
||||||
|
|
||||||
// Helper Components
|
// Helper Components
|
||||||
|
const BADGE_STYLES: Record<string, string> = {
|
||||||
|
red: 'bg-[rgba(239,68,68,0.08)] text-color-danger border border-[rgba(239,68,68,0.25)]',
|
||||||
|
orange:
|
||||||
|
'bg-[rgba(249,115,22,0.08)] text-color-warning border border-[rgba(249,115,22,0.25)]',
|
||||||
|
yellow:
|
||||||
|
'bg-[rgba(234,179,8,0.08)] text-color-caution border border-[rgba(234,179,8,0.25)]',
|
||||||
|
green:
|
||||||
|
'bg-[rgba(34,197,94,0.08)] text-color-success border border-[rgba(34,197,94,0.25)]',
|
||||||
|
};
|
||||||
|
|
||||||
function Section({
|
function Section({
|
||||||
title,
|
title,
|
||||||
badge,
|
badge,
|
||||||
@ -607,7 +663,7 @@ function Section({
|
|||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
badge?: string;
|
badge?: string;
|
||||||
badgeColor?: 'red' | 'green';
|
badgeColor?: 'red' | 'orange' | 'yellow' | 'green';
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@ -617,9 +673,7 @@ function Section({
|
|||||||
{badge && (
|
{badge && (
|
||||||
<span
|
<span
|
||||||
className={`text-label-2 font-medium px-2 py-0.5 rounded-full ${
|
className={`text-label-2 font-medium px-2 py-0.5 rounded-full ${
|
||||||
badgeColor === 'red'
|
BADGE_STYLES[badgeColor ?? 'green']
|
||||||
? 'bg-[rgba(239,68,68,0.08)] text-color-danger border border-[rgba(239,68,68,0.25)]'
|
|
||||||
: 'bg-[rgba(34,197,94,0.08)] text-color-success border border-[rgba(34,197,94,0.25)]'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{badge}
|
{badge}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user