wing-ops/frontend/src/tabs/hns/components/HNSRightPanel.tsx
Nan Kyung Lee 8f98f63aa5 feat(aerial): CCTV 실시간 HLS 스트림 + HNS 분석 고도화
CCTV 실시간 영상:
- CCTVPlayer 컴포넌트 (hls.js 기반 HLS/MJPEG/MP4 재생)
- 백엔드 HLS 프록시 엔드포인트 (CORS 우회, m3u8 URL 재작성)
- KHOA 15개 + KBS 6개 실제 해안 CCTV 연동
- Vite dev proxy, 스트림 타입 자동 감지 유틸리티

HNS 분석:
- HNS 시나리오 저장/불러오기/재계산 기능
- 물질 DB 검색 및 상세 정보 연동
- 좌표/파라미터 입력 UI 개선
- Python 확산 모델 스크립트 (hns_dispersion.py)

공통:
- 3D 지도 토글, 보고서 생성 개선
- useSubMenu 훅, mapUtils 확장
- ESLint set-state-in-effect 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:21:41 +09:00

212 lines
8.1 KiB
TypeScript
Executable File

import type { DispersionGridResult, WeatherFetchResult } from '../utils/dispersionTypes';
import { windDirToCompass } from '../hooks/useWeatherFetch';
interface HNSRightPanelProps {
dispersionResult: {
zones: Array<{
level: string;
color: string;
radius: number;
angle: number;
}>;
timestamp: string;
windDirection: number;
substance: string;
concentration: {
'AEGL-3': string;
'AEGL-2': string;
'AEGL-1': string;
};
} | null;
computedResult?: DispersionGridResult | null;
weatherData?: WeatherFetchResult | null;
onOpenRecalc?: () => void;
onOpenReport?: () => void;
onSave?: () => void;
}
export function HNSRightPanel({
dispersionResult,
computedResult,
weatherData,
onOpenRecalc,
onOpenReport,
onSave,
}: HNSRightPanelProps) {
if (!dispersionResult) {
return (
<div className="w-[300px] bg-bg-1 border-l border-border p-4 overflow-auto">
<div className="flex flex-col gap-3 items-center justify-center h-full text-text-3 text-xs">
<div style={{ fontSize: '32px', opacity: 0.3 }}>📊</div>
<div> </div>
</div>
</div>
);
}
const area = computedResult?.aeglAreas.aegl1 ?? 0;
const maxConc = computedResult?.maxConcentration ?? 0;
const windSpd = weatherData?.windSpeed ?? 5.0;
const windDir = weatherData?.windDirection ?? dispersionResult.windDirection;
const modelLabel = computedResult?.modelType === 'plume' ? 'Gaussian Plume'
: computedResult?.modelType === 'puff' ? 'Gaussian Puff'
: computedResult?.modelType === 'dense_gas' ? 'Dense Gas (B-M)'
: 'ALOHA';
return (
<div className="w-[300px] bg-bg-1 border-l border-border p-4 overflow-auto flex flex-col gap-4">
{/* Header */}
<div>
<div className="flex items-center gap-1.5 mb-2">
<div style={{
width: '6px',
height: '6px',
borderRadius: '50%',
background: 'var(--orange)',
animation: 'pulse 1.5s infinite'
}}></div>
<h3 className="text-[13px] font-bold m-0">
</h3>
</div>
<div className="text-[10px] text-text-3 font-mono">
{dispersionResult.substance} · {modelLabel}
</div>
</div>
{/* KPI Cards */}
<div className="flex flex-col gap-2">
{/* 최대 농도 */}
<div className="p-3 bg-bg-3 border border-[rgba(239,68,68,0.2)] rounded-[var(--rS)]">
<div className="text-[10px] text-text-3 mb-1.5">
</div>
<div className="text-[20px] font-bold font-mono text-status-red">
{maxConc > 0 ? maxConc.toFixed(1) : '—'} <span className="text-[10px] font-medium">ppm</span>
</div>
</div>
{/* 확산 면적 */}
<div className="p-3 bg-bg-3 border border-[rgba(6,182,212,0.2)] rounded-[var(--rS)]">
<div className="text-[10px] text-text-3 mb-1.5">
AEGL-1
</div>
<div className="text-[20px] font-bold font-mono text-primary-cyan">
{area > 0 ? area.toFixed(2) : '—'} <span className="text-[10px] font-medium">km²</span>
</div>
</div>
{/* 풍속 */}
<div className="p-3 bg-bg-3 border border-border rounded-[var(--rS)]">
<div className="text-[10px] text-text-3 mb-1.5">
</div>
<div className="text-[20px] font-bold font-mono">
{windSpd.toFixed(1)} <span className="text-[10px] font-medium">m/s</span>
</div>
</div>
{/* 풍향 */}
<div className="p-3 bg-bg-3 border border-border rounded-[var(--rS)]">
<div className="text-[10px] text-text-3 mb-1.5">
</div>
<div className="text-[20px] font-bold font-mono">
{windDirToCompass(windDir)} <span className="text-[10px] font-medium">{windDir}°</span>
</div>
</div>
</div>
{/* AEGL Zone Details */}
<div>
<h4 className="text-[11px] font-semibold text-text-2 mt-0 mb-2.5">
AEGL
</h4>
<div className="flex flex-col gap-2">
{/* AEGL-3 */}
<div
className="py-2.5 px-3 bg-bg-2 rounded-[var(--rS)]"
style={{ borderLeft: '3px solid rgba(239,68,68,1)' }}
>
<div className="flex justify-between items-center mb-1">
<span className="text-[11px] font-semibold">AEGL-3 ()</span>
<span className="text-[10px] font-mono text-text-3">
{computedResult?.aeglDistances.aegl3 || 0}m
</span>
</div>
<div className="flex justify-between text-[10px] text-text-3">
<span>{dispersionResult.concentration['AEGL-3']}</span>
<span className="font-mono">{computedResult?.aeglAreas.aegl3 ?? 0} km²</span>
</div>
</div>
{/* AEGL-2 */}
<div
className="py-2.5 px-3 bg-bg-2 rounded-[var(--rS)]"
style={{ borderLeft: '3px solid rgba(249,115,22,1)' }}
>
<div className="flex justify-between items-center mb-1">
<span className="text-[11px] font-semibold">AEGL-2 ()</span>
<span className="text-[10px] font-mono text-text-3">
{computedResult?.aeglDistances.aegl2 || 0}m
</span>
</div>
<div className="flex justify-between text-[10px] text-text-3">
<span>{dispersionResult.concentration['AEGL-2']}</span>
<span className="font-mono">{computedResult?.aeglAreas.aegl2 ?? 0} km²</span>
</div>
</div>
{/* AEGL-1 */}
<div
className="py-2.5 px-3 bg-bg-2 rounded-[var(--rS)]"
style={{ borderLeft: '3px solid rgba(234,179,8,1)' }}
>
<div className="flex justify-between items-center mb-1">
<span className="text-[11px] font-semibold">AEGL-1 ()</span>
<span className="text-[10px] font-mono text-text-3">
{computedResult?.aeglDistances.aegl1 || 0}m
</span>
</div>
<div className="flex justify-between text-[10px] text-text-3">
<span>{dispersionResult.concentration['AEGL-1']}</span>
<span className="font-mono">{computedResult?.aeglAreas.aegl1 ?? 0} km²</span>
</div>
</div>
</div>
</div>
{/* 시간 정보 (puff/dense_gas) */}
{computedResult && computedResult.modelType !== 'plume' && (
<div className="p-2.5 bg-bg-3 border border-border rounded-[var(--rS)]">
<div className="text-[10px] text-text-3 mb-1"> </div>
<div className="text-[14px] font-bold font-mono text-primary-cyan">
t = {computedResult.timeStep}s
<span className="text-[10px] font-normal text-text-3 ml-1.5">
({(computedResult.timeStep / 60).toFixed(1)})
</span>
</div>
</div>
)}
{/* Timestamp */}
<div className="mt-auto pt-3 border-t border-border text-[10px] text-text-3 font-mono">
: {new Date(dispersionResult.timestamp).toLocaleString('ko-KR')}
</div>
{/* Bottom Action Buttons */}
<div className="flex gap-1.5 pt-3 border-t border-border">
<button onClick={onSave} className="flex-1 py-2 px-1 rounded text-[11px] font-semibold bg-gradient-to-r from-boom to-[#d97706] text-black font-korean">
💾
</button>
<button onClick={onOpenRecalc} className="flex-1 py-2 px-1 rounded text-[11px] font-semibold bg-[rgba(249,115,22,0.1)] border border-[rgba(249,115,22,0.3)] text-status-orange font-korean">
🔄
</button>
<button onClick={onOpenReport} className="flex-1 py-2 px-1 rounded text-[11px] font-semibold bg-gradient-to-r from-primary-cyan to-primary-blue text-white font-korean">
📄
</button>
</div>
</div>
);
}