From 7e0da5ea7642730bf9dedeae7518f2f1edef22eb Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Thu, 9 Apr 2026 16:52:14 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(hns):=20=ED=8C=8C=ED=8B=B0=ED=81=B4=20?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=EB=A7=81=20=EC=84=B1=EB=8A=A5=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94=20=EB=B0=8F=20=EC=9C=84=ED=97=98=EB=8F=84=20?= =?UTF-8?q?=EB=B1=83=EC=A7=80=20=EB=8F=99=EC=A0=81=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../components/map/HydrParticleOverlay.tsx | 218 ++++++++++-------- .../tabs/prediction/components/RightPanel.tsx | 66 +++++- 2 files changed, 182 insertions(+), 102 deletions(-) diff --git a/frontend/src/common/components/map/HydrParticleOverlay.tsx b/frontend/src/common/components/map/HydrParticleOverlay.tsx index 7f57f8a..a825242 100644 --- a/frontend/src/common/components/map/HydrParticleOverlay.tsx +++ b/frontend/src/common/components/map/HydrParticleOverlay.tsx @@ -1,7 +1,6 @@ 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; @@ -9,24 +8,13 @@ interface HydrParticleOverlayProps { const PARTICLE_COUNT = 3000; const MAX_AGE = 300; -const SPEED_SCALE = 0.1; +const SPEED_SCALE = 0.15; 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; -} +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 lightMode = useThemeStore((s) => s.theme) === 'light'; const { current: map } = useMap(); const animRef = useRef(); @@ -52,21 +40,21 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro 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] { - 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; - } - } + 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]); @@ -78,96 +66,134 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro 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 _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 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), - })); + const bboxW = bbox.right - bbox.left; + const bboxH = bbox.top - bbox.bottom; - 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; + // 파티클: 위치 + 이전 화면좌표 (선분 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; } - // 지도 이동/줌 시 화면 좌표가 틀어지므로 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 = () => { - 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); function animate() { - // 매 프레임 완전 초기화 → 잔상 없음 - ctx.clearRect(0, 0, canvas.width, canvas.height); + const w = canvas.width; + const h = canvas.height; - // alpha band별 세그먼트 버퍼 (드로우 콜 최소화) - const bands: [number, number, number, number][][] = Array.from( - { length: NUM_ALPHA_BANDS }, - () => [], - ); + // ── 페이드: 기존 내용을 서서히 지움 (destination-out) ── + ctx.globalCompositeOperation = 'destination-out'; + ctx.fillStyle = `rgba(0, 0, 0, ${FADE_ALPHA})`; + ctx.fillRect(0, 0, w, h); + ctx.globalCompositeOperation = 'source-over'; - 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); + // 뷰포트 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((p.lat * Math.PI) / 180); - p.lon += (u * SPEED_SCALE * DT) / (cosLat * 111320); - p.lat += (v * SPEED_SCALE * DT) / 111320; - p.age++; + 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 ( - p.lon < bbox.left || - p.lon > bbox.right || - p.lat < bbox.bottom || - p.lat > bbox.top || - p.age > MAX_AGE + pLon[i] < bbox.left || + pLon[i] > bbox.right || + pLat[i] < bbox.bottom || + pLat[i] > bbox.top || + pAge[i] > MAX_AGE ) { - resetParticle(p); + resetParticle(i); 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]); + // 수동 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; } - // 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(); - } + ctx.stroke(); animRef.current = requestAnimationFrame(animate); } @@ -186,7 +212,7 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro map.off('move', onMove); canvas.remove(); }; - }, [map, hydrStep, lightMode]); + }, [map, hydrStep]); return null; } diff --git a/frontend/src/tabs/prediction/components/RightPanel.tsx b/frontend/src/tabs/prediction/components/RightPanel.tsx index 483c862..a31e6d2 100755 --- a/frontend/src/tabs/prediction/components/RightPanel.tsx +++ b/frontend/src/tabs/prediction/components/RightPanel.tsx @@ -329,7 +329,11 @@ export function RightPanel({ {/* 오염 종합 상황 */} -
+
{/* 확산 예측 요약 */} -
+
= 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 +const BADGE_STYLES: Record = { + 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({ title, badge, @@ -607,7 +663,7 @@ function Section({ }: { title: string; badge?: string; - badgeColor?: 'red' | 'green'; + badgeColor?: 'red' | 'orange' | 'yellow' | 'green'; children: React.ReactNode; }) { return ( @@ -617,9 +673,7 @@ function Section({ {badge && ( {badge} From 0d53f850b233e0b1a001ae5dec9c313c3216eac0 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Thu, 9 Apr 2026 16:54:31 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- docs/RELEASE-NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 5bb6047..32fccc3 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -5,6 +5,8 @@ ## [Unreleased] ### 추가 +- HNS 확산 파티클 렌더링 성능 최적화 (TypedArray + 수동 Mercator 투영 + 페이드 트레일) +- 오염 종합 상황/확산 예측 요약 위험도 뱃지 동적 표시 (심각/경계/주의/관심 4단계) - 디자인 시스템 Float 카탈로그 추가 (Modal / Dropdown / Overlay / Toast) - 디자인 시스템 폰트/색상 토큰을 전 탭 컴포넌트에 전면 적용 (admin, aerial, assets, board, hns, incidents, prediction, reports, rescue, scat, weather) - SR 민감자원 벡터타일 오버레이 컴포넌트 및 백엔드 프록시 엔드포인트 추가