import { useState, useRef, useEffect } from 'react' import { fetchHnsAnalyses, type HnsAnalysisItem } from '../services/hnsApi' // ─── Types ────────────────────────────────────────────── type Severity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'RESOLVED' type ViewTab = 0 | 1 | 2 interface HnsScenario { id: string name: string severity: Severity timeStep: string datetime: string wind: string maxConc: string idlhRadius: string erpg2: string population: string description: string detail: { maxConc: string; idlhRadius: string; erpg2: string windDir: string; windSpeed: string; population: string; spillAmount: string } zones: { idlh: string; erpg2: string; erpg1: string; twa: string } weather: { dir: string; speed: string; temp: string; stability: string; humidity: string; mixHeight: string } actions: string[] } interface HnsMaterial { key: string; name: string; mw: string; bp: string; fp: string; idlh: string; erpg2: string } const SEVERITY_STYLE: Record = { CRITICAL: { bg: 'rgba(239,68,68,0.2)', color: '#f87171' }, HIGH: { bg: 'rgba(249,115,22,0.15)', color: '#fb923c' }, MEDIUM: { bg: 'rgba(251,191,36,0.15)', color: '#fbbf24' }, RESOLVED: { bg: 'rgba(34,197,94,0.15)', color: '#22c55e' }, } // ─── Mock Data (시나리오 시뮬레이션 엔진 미구현 — 프론트 상수 유지) ── const MOCK_SCENARIOS: HnsScenario[] = [ { id: 'S-01', name: '유출 직후 (초기 확산)', severity: 'CRITICAL', timeStep: 'T+0h', datetime: '2024.11.03 08:00 KST', wind: '풍속 5.2m/s SW', maxConc: '850 ppm', idlhRadius: '1.2 km', erpg2: '2.8 km', population: '3,200명', description: '톨루엔 2.5톤 순간 유출. SW 풍향으로 온산 산업단지 방향 확산. IDLH 초과 구역 발생.', detail: { maxConc: '850ppm', idlhRadius: '1.2km', erpg2: '2.8km', windDir: 'SW 225°', windSpeed: '5.2 m/s', population: '3,200명', spillAmount: '2.5 ton' }, zones: { idlh: '1.2 km (500ppm)', erpg2: '2.8 km (300ppm)', erpg1: '4.5 km (50ppm)', twa: '6.2 km (20ppm)' }, weather: { dir: 'SW 225°', speed: '5.2 m/s', temp: '18.5°C', stability: 'D (중립)', humidity: '65%', mixHeight: '850 m' }, actions: ['반경 1.2km 즉시 대피 명령', 'Level B 화학복 착용', '화기 엄금 — 인화점 4°C', '해양확산 동시 모니터링', 'IDLH 경계 실시간 측정'], }, { id: 'S-02', name: '풍향 변화 시나리오', severity: 'HIGH', timeStep: 'T+1h', datetime: '2024.11.03 09:00 KST', wind: '풍속 4.8m/s SE', maxConc: '420 ppm', idlhRadius: '0.8 km', erpg2: '2.1 km', population: '5,100명', description: '풍향 SE 전환. 주거지역 방향 확산 확대. 영향인구 증가. 대피 범위 조정 필요.', detail: { maxConc: '420ppm', idlhRadius: '0.8km', erpg2: '2.1km', windDir: 'SE 135°', windSpeed: '4.8 m/s', population: '5,100명', spillAmount: '2.5 ton' }, zones: { idlh: '0.8 km (500ppm)', erpg2: '2.1 km (300ppm)', erpg1: '3.8 km (50ppm)', twa: '5.5 km (20ppm)' }, weather: { dir: 'SE 135°', speed: '4.8 m/s', temp: '19.2°C', stability: 'C (약간 불안정)', humidity: '62%', mixHeight: '920 m' }, actions: ['대피 범위 SE 방향 확장', '주거지역 주민 대피 알림', '실시간 농도 모니터링 강화'], }, { id: 'S-03', name: '연속유출 확대', severity: 'HIGH', timeStep: 'T+3h', datetime: '2024.11.03 11:00 KST', wind: '풍속 3.5m/s S', maxConc: '280 ppm', idlhRadius: '0.5 km', erpg2: '1.8 km', population: '4,800명', description: '연속유출 3시간 경과. 누적 유출량 증가. 풍속 감소로 체류 시간 증가.', detail: { maxConc: '280ppm', idlhRadius: '0.5km', erpg2: '1.8km', windDir: 'S 180°', windSpeed: '3.5 m/s', population: '4,800명', spillAmount: '4.2 ton' }, zones: { idlh: '0.5 km (500ppm)', erpg2: '1.8 km (300ppm)', erpg1: '3.2 km (50ppm)', twa: '4.8 km (20ppm)' }, weather: { dir: 'S 180°', speed: '3.5 m/s', temp: '20.1°C', stability: 'B (불안정)', humidity: '58%', mixHeight: '1,050 m' }, actions: ['유출원 차단 작업 투입', '풍속 감소 체류 경고', '추가 모니터링 포인트 설치'], }, { id: 'S-04', name: '유출 차단·잔류 확산', severity: 'MEDIUM', timeStep: 'T+6h', datetime: '2024.11.03 14:00 KST', wind: '풍속 6.1m/s W', maxConc: '85 ppm', idlhRadius: '—', erpg2: '0.4 km', population: '1,200명', description: '유출원 차단 완료. 잔류 증기 자연 확산중. 풍속 증가로 희석 촉진.', detail: { maxConc: '85ppm', idlhRadius: '—', erpg2: '0.4km', windDir: 'W 270°', windSpeed: '6.1 m/s', population: '1,200명', spillAmount: '0 (차단)' }, zones: { idlh: '— (해소)', erpg2: '0.4 km (300ppm)', erpg1: '1.2 km (50ppm)', twa: '2.1 km (20ppm)' }, weather: { dir: 'W 270°', speed: '6.1 m/s', temp: '21.3°C', stability: 'C (약간 불안정)', humidity: '52%', mixHeight: '1,200 m' }, actions: ['IDLH 구역 해소 확인', '잔류 농도 지속 모니터링', '일부 대피 해제 검토'], }, { id: 'S-05', name: '대기확산 해제', severity: 'RESOLVED', timeStep: 'T+12h', datetime: '2024.11.03 20:00 KST', wind: '풍속 7.3m/s NW', maxConc: '8 ppm', idlhRadius: '—', erpg2: '—', population: '0명', description: '전 구역 안전 농도 확인. 대피 해제. 잔류 오염 모니터링 지속.', detail: { maxConc: '8ppm', idlhRadius: '—', erpg2: '—', windDir: 'NW 315°', windSpeed: '7.3 m/s', population: '0명', spillAmount: '0 (종료)' }, zones: { idlh: '— (해소)', erpg2: '— (해소)', erpg1: '— (해소)', twa: '0.3 km (20ppm)' }, weather: { dir: 'NW 315°', speed: '7.3 m/s', temp: '16.8°C', stability: 'D (중립)', humidity: '68%', mixHeight: '780 m' }, actions: ['전 구역 대피 해제', '잔류 오염 최종 모니터링', '사후 환경 평가 실시'], }, ] const MATERIALS: HnsMaterial[] = [ { key: 'toluene', name: '톨루엔', mw: '92.14', bp: '110.6°C', fp: '4°C', idlh: '500 ppm', erpg2: '300 ppm' }, { key: 'ammonia', name: '암모니아', mw: '17.03', bp: '-33.3°C', fp: 'N/A', idlh: '300 ppm', erpg2: '200 ppm' }, { key: 'methanol', name: '메탄올', mw: '32.04', bp: '64.7°C', fp: '11°C', idlh: '6,000 ppm', erpg2: '1,000 ppm' }, { key: 'hydrogen', name: '수소', mw: '2.016', bp: '-252.9°C', fp: 'N/A', idlh: 'N/A', erpg2: 'N/A' }, { key: 'benzene', name: '벤젠', mw: '78.11', bp: '80.1°C', fp: '-11°C', idlh: '500 ppm', erpg2: '150 ppm' }, { key: 'styrene', name: '스티렌', mw: '104.15', bp: '145°C', fp: '31°C', idlh: '700 ppm', erpg2: '250 ppm' }, { key: 'lng', name: 'LNG', mw: '16.04', bp: '-161.5°C', fp: '-188°C', idlh: 'N/A', erpg2: '25,000 ppm' }, ] // ─── Main Component ───────────────────────────────────── export function HNSScenarioView() { const [incidents, setIncidents] = useState([]) const [selectedIncident, setSelectedIncident] = useState(0) const [scenarios, setScenarios] = useState(MOCK_SCENARIOS) const [selectedIdx, setSelectedIdx] = useState(0) const [checked, setChecked] = useState>(new Set([0, 1])) const [activeView, setActiveView] = useState(0) const [modalOpen, setModalOpen] = useState(false) useEffect(() => { let cancelled = false fetchHnsAnalyses() .then(items => { if (!cancelled) setIncidents(items) }) .catch(err => console.error('[hns] 사고 목록 조회 실패:', err)) return () => { cancelled = true } }, []) const selected = scenarios[selectedIdx] const toggleCheck = (idx: number) => { setChecked(prev => { const next = new Set(prev) if (next.has(idx)) next.delete(idx); else next.add(idx) return next }) } return (
{/* Header */}
📊
HNS 대기확산 시나리오 관리
시간·조건별 대기확산 예측 시나리오 비교·검토 및 대응 의사결정 지원
{/* Body: Left list + Right detail */}
{/* ── Left: Scenario List ── */}
시나리오 목록 — 톨루엔 대기확산
{['시간순', '위험도순'].map((label, i) => ( ))}
{/* Scrollable list */}
{scenarios.map((scn, idx) => { const sev = SEVERITY_STYLE[scn.severity] const isSel = selectedIdx === idx return (
{ setSelectedIdx(idx); setActiveView(0) }} > {/* Title + badge */}
toggleCheck(idx)} onClick={(e) => e.stopPropagation()} style={{ accentColor: 'var(--orange)' }} /> {scn.id} {scn.name}
{scn.severity}
{/* Time row */}
{scn.timeStep} {scn.datetime} {scn.wind}
{/* Metrics grid */}
{[ { label: '최대농도', value: scn.maxConc, color: '#f87171' }, { label: 'IDLH반경', value: scn.idlhRadius, color: '#f87171' }, { label: 'ERPG-2', value: scn.erpg2, color: '#f97316' }, { label: '영향인구', value: scn.population, color: '#f87171' }, ].map((m, i) => (
{m.label}
{m.value}
))}
{/* Description */}
{scn.description}
) })}
{/* Bottom buttons */}
{/* ── Right: Detail Views ── */}
{/* View Tabs */}
{['📋 시나리오 상세', '📊 비교 차트', '🗺 확산범위 오버레이'].map((label, i) => ( ))}
{/* View 0: Detail */} {activeView === 0 && selected && } {/* View 1: Comparison */} {activeView === 1 && } {/* View 2: Map overlay */} {activeView === 2 && }
{/* New Scenario Modal */} setModalOpen(false)} onSubmit={(name) => { const newScn: HnsScenario = { ...MOCK_SCENARIOS[0], id: `S-${String(scenarios.length + 1).padStart(2, '0')}`, name, severity: 'MEDIUM', } setScenarios(prev => [...prev, newScn]) setModalOpen(false) }} />
) } // ─── View 0: Scenario Detail ───────────────────────────── function ScenarioDetail({ scenario }: { scenario: HnsScenario }) { const d = scenario.detail return (
{/* Hero card */}
{scenario.id} {scenario.name} {scenario.severity} {scenario.datetime}
{[ { label: '최대농도', value: d.maxConc, color: '#f87171' }, { label: 'IDLH 반경', value: d.idlhRadius, color: '#f87171' }, { label: 'ERPG-2', value: d.erpg2, color: '#f97316' }, { label: '풍향/풍속', value: `${d.windDir}\n${d.windSpeed}`, color: 'var(--cyan)' }, { label: '영향인구', value: d.population, color: '#f87171' }, { label: '유출량', value: d.spillAmount, color: 'var(--orange)' }, ].map((m, i) => (
{m.label}
{m.value}
))}
{/* Two-column section */}
{/* Threat Zones */}

⚠️ 위험 구역

{[ { label: 'IDLH (즉시위험)', value: scenario.zones.idlh, color: '#ef4444' }, { label: 'ERPG-2 (대피권고)', value: scenario.zones.erpg2, color: '#f97316' }, { label: 'ERPG-1 (주의권고)', value: scenario.zones.erpg1, color: '#fbbf24' }, { label: 'TWA (작업허용)', value: scenario.zones.twa, color: '#22c55e' }, ].map((z, i) => (
{z.label} {z.value}
))}
{/* Actions */}

🛡 대응 권고 사항

{scenario.actions.map((action, i) => (
{action}
))}
{/* Weather */}

🌊 기상 조건

{[ { label: '풍향', value: scenario.weather.dir, icon: '🌬' }, { label: '풍속', value: scenario.weather.speed, icon: '💨' }, { label: '기온', value: scenario.weather.temp, icon: '🌡' }, { label: '대기안정도', value: scenario.weather.stability, icon: '☁️' }, { label: '습도', value: scenario.weather.humidity, icon: '💧' }, { label: '혼합층', value: scenario.weather.mixHeight, icon: '📏' }, ].map((w, i) => (
{w.icon}
{w.value}
{w.label}
))}
) } // ─── View 1: Comparison ────────────────────────────────── // Chart reference data (time-series per scenario) const CHART_DATA = [ { id: 'S-01', label: 'T+0h', conc: 850, idlh: 1.2, erpg2: 2.8, pop: 3200, wind: 'SW 5.2', severity: 'CRITICAL' as Severity }, { id: 'S-02', label: 'T+1h', conc: 620, idlh: 0.9, erpg2: 3.4, pop: 5800, wind: 'SE 6.8', severity: 'CRITICAL' as Severity }, { id: 'S-03', label: 'T+3h', conc: 420, idlh: 0.5, erpg2: 2.1, pop: 1800, wind: 'S 4.1', severity: 'HIGH' as Severity }, { id: 'S-04', label: 'T+6h', conc: 85, idlh: 0, erpg2: 0.6, pop: 120, wind: 'W 3.5', severity: 'MEDIUM' as Severity }, { id: 'S-05', label: 'T+12h', conc: 8, idlh: 0, erpg2: 0, pop: 0, wind: 'NW 2.8', severity: 'RESOLVED' as Severity }, ] const SEV_COLOR: Record = { CRITICAL: '#f87171', HIGH: '#fb923c', MEDIUM: '#fbbf24', RESOLVED: '#22c55e' } function ScenarioComparison() { const D = CHART_DATA // SVG coordinate helpers for concentration chart (viewBox 500x140) const concMax = 900 const concX = [50, 157, 264, 371, 480] const concY = D.map(d => 10 + (1 - d.conc / concMax) * 110) const concPoly = concX.map((x, i) => `${x},${concY[i]}`).join(' ') const concArea = concPoly + ` ${concX[4]},120 ${concX[0]},120` // Radius chart helpers (viewBox 240x100) const radMax = 4 const radX = [30, 80, 130, 180, 230] const idlhY = D.map(d => 10 + (1 - d.idlh / radMax) * 75) const erpgY = D.map(d => 10 + (1 - d.erpg2 / radMax) * 75) // Population chart helpers const popMax = 6000 const barW = 30 const barX = [30, 70, 110, 150, 190] return (
{/* Title */}
📊 시나리오 비교 — 시간대별 대기확산 지표 추이
{/* ── Chart 1: 최대 지표면 농도 추이 (Line + Area) ── */}
최대 지표면 농도 (ppm) 변화 추이
{/* Axes */} {/* Threshold lines */} 500 IDLH 300 ERPG2 {/* Area fill */} {/* Line */} {/* Data points + labels */} {D.map((d, i) => ( {d.conc} {d.label} ))}
{/* ── Charts 2 & 3: 2-column grid ── */}
{/* Chart 2: 위험 반경 변화 (Multi-line) */}
위험 반경 (km) 변화
{/* IDLH line (red solid) */} `${x},${idlhY[i]}`).join(' ')} fill="none" stroke="#ef4444" strokeWidth={1.5} /> {/* ERPG-2 line (orange dashed) */} `${x},${erpgY[i]}`).join(' ')} fill="none" stroke="#f97316" strokeWidth={1.5} strokeDasharray="4,2" /> {/* Data points */} {D.map((d, i) => { const c = d.idlh > 0 ? (d.severity === 'CRITICAL' || d.severity === 'HIGH' ? '#ef4444' : '#fbbf24') : '#22c55e' return ( {d.label.replace('+', '+')} ) })} {/* Legend */} IDLH ERPG-2
{/* Chart 3: 영향 인구 변화 (Bar) */}
영향 인구 (명) 변화
{D.map((d, i) => { const h = Math.max(1, (d.pop / popMax) * 75) const y = 85 - h const color = SEV_COLOR[d.severity] const barColor = `${color}33` return ( {d.pop > 0 ? d.pop.toLocaleString() : '0'} {d.label.replace('+', '+')} ) })}
{/* ── Chart 4: 시나리오 비교표 ── */}
📋 시나리오 비교표
{['지표', ...D.map(d => `${d.id} (${d.label})`)].map((h, i) => ( ))} {/* 최대농도 */} {D.map(d => ( ))} {/* IDLH 반경 */} {D.map(d => ( ))} {/* ERPG-2 반경 */} {D.map(d => ( ))} {/* 영향인구 */} {D.map(d => ( ))} {/* 풍향/풍속 */} {D.map(d => ( ))} {/* 위험 등급 */} {D.map(d => ( ))}
{h}
최대농도 (ppm){d.conc}
IDLH 반경 (km) 0 ? '#f87171' : '#22c55e', fontWeight: 600 }}>{d.idlh || 0}
ERPG-2 반경 (km) 0 ? '#f97316' : '#22c55e', fontWeight: 600 }}>{d.erpg2 || 0}
영향인구 (명){d.pop.toLocaleString()}
풍향 / 풍속{d.wind}
위험 등급{d.severity}
) } // ─── View 2: Map Overlay ───────────────────────────────── function ScenarioMapOverlay() { return (
[시나리오별 확산범위 오버레이 지도]
{[ { label: 'T+0h SW방향', color: '#ef4444' }, { label: 'T+1h SE 전환', color: '#f97316' }, { label: 'T+3h S방향', color: '#fbbf24' }, { label: 'T+6h 차단 후', color: '#22c55e' }, ].map((item, i) => (
{item.label}
))}
) } // ─── New Scenario Modal ────────────────────────────────── function NewScenarioModal({ isOpen, onClose, onSubmit }: { isOpen: boolean; onClose: () => void; onSubmit: (name: string) => void }) { const backdropRef = useRef(null) const [name, setName] = useState('') const [material, setMaterial] = useState('toluene') const [releaseType, setReleaseType] = useState('instant') const [amount, setAmount] = useState('2.5') const [unit, setUnit] = useState('t') const [timeStep, setTimeStep] = useState('T+0h') const [windDir, setWindDir] = useState('SW') const [windSpeed, setWindSpeed] = useState('5.2') const [temp, setTemp] = useState('18.5') const [stability, setStability] = useState('D') const [model, setModel] = useState('ALOHA') const [predTime, setPredTime] = useState('6') const mat = MATERIALS.find(m => m.key === material) || MATERIALS[0] useEffect(() => { const handler = (e: MouseEvent) => { if (e.target === backdropRef.current) onClose() } if (isOpen) document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [isOpen, onClose]) // eslint-disable-next-line react-hooks/set-state-in-effect useEffect(() => { if (isOpen) setName('') }, [isOpen]) if (!isOpen) return null const handleSubmit = () => { if (!name.trim()) return onSubmit(name.trim()) } return (
{/* Header */}
🧪

신규 HNS 대기확산 시나리오

물질·기상·유출조건을 설정하여 새 시나리오를 생성합니다
{/* Scrollable content */}
{/* 기본 정보 */} setName(e.target.value)} placeholder="예: 풍향 변화 시나리오" />
{/* 물질·유출 조건 */} {/* Material properties card */}
{[ { label: 'MW', value: mat.mw }, { label: 'BP', value: mat.bp }, { label: 'FP', value: mat.fp }, { label: 'IDLH', value: mat.idlh }, { label: 'ERPG-2', value: mat.erpg2 }, ].map((p, i) => (
{p.label}
{p.value}
))}
setAmount(e.target.value)} style={{ flex: 1 }} />
{/* 기상 조건 */}
setWindSpeed(e.target.value)} step={0.1} /> setTemp(e.target.value)} step={0.1} />
{/* Footer */}
) } // ─── Helpers ───────────────────────────────────────────── function ModalSection({ title, children }: { title: string; children: React.ReactNode }) { return (
{title}
{children}
) } function ModalField({ label, children }: { label: string; children: React.ReactNode }) { return (
{label}
{children}
) }