wing-ops/frontend/src/common/components/map/HydrParticleOverlay.tsx
jeonghyo.k 88eb6b121a feat(prediction): OpenDrift 유류 확산 시뮬레이션 통합 + CCTV/관리자 고도화
[예측]
- OpenDrift Python API 서버 및 스크립트 추가 (prediction/opendrift/)
- 시뮬레이션 상태 폴링 훅(useSimulationStatus), 로딩 오버레이 추가
- HydrParticleOverlay: deck.gl 기반 입자 궤적 시각화 레이어
- OilSpillView/LeftPanel/RightPanel: 시뮬레이션 실행·결과 표시 UI 개편
- predictionService/predictionRouter: 시뮬레이션 CRUD 및 상태 관리 API
- simulation.ts: OpenDrift 연동 엔드포인트 확장
- docs/PREDICTION-GUIDE.md: 예측 기능 개발 가이드 추가

[CCTV/항공방제]
- CCTV 오일 감지 GPU 추론 연동 (OilDetectionOverlay, useOilDetection)
- CCTV 안전관리 감지 기능 추가 (선박 출입, 침입 감지)
- oil_inference_server.py: Python GPU 추론 서버

[관리자]
- 관리자 화면 고도화 (사용자/권한/게시판/선박신호 패널)
- AdminSidebar, BoardMgmtPanel, VesselSignalPanel 신규 컴포넌트

[기타]
- DB: 시뮬레이션 결과, 선박보험 시드(1391건), 역할 정리 마이그레이션
- 팀 워크플로우 v1.6.1 동기화

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 14:55:46 +09:00

158 lines
5.5 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;
}
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 }: 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++) {
ctx.strokeStyle = `rgba(180, 210, 255, ${((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]);
return null;
}