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>
212 lines
8.1 KiB
TypeScript
Executable File
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>
|
|
);
|
|
}
|