wing-ops/frontend/src/tabs/hns/components/HNSScenarioView.tsx
htlee 34cf046787 fix(css): CSS 회귀 버그 3건 수정 + SCAT 우측 패널 구현
- className 중복 속성 31건 수정 (12파일)
- KOSPS codeBox spread TypeError 해결
- HNS 페놀(C₆H₅OH) 물질 데이터 추가
- ScatRightPanel 280px 우측 패널 신규 구현 (3탭+액션버튼)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:11:21 +09:00

840 lines
42 KiB
TypeScript
Executable File
Raw Blame 히스토리

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Severity, { bg: string; color: string }> = {
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<HnsAnalysisItem[]>([])
const [selectedIncident, setSelectedIncident] = useState(0)
const [scenarios, setScenarios] = useState(MOCK_SCENARIOS)
const [selectedIdx, setSelectedIdx] = useState(0)
const [checked, setChecked] = useState<Set<number>>(new Set([0, 1]))
const [activeView, setActiveView] = useState<ViewTab>(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 (
<div className="flex flex-col flex-1 w-full h-full overflow-hidden bg-bg-0">
{/* Header */}
<div className="flex items-center justify-between shrink-0 border-b border-border px-5 py-[14px] bg-bg-1">
<div className="flex items-center gap-2.5">
<span className="text-base">📊</span>
<div>
<div className="text-sm font-bold">
HNS
</div>
<div className="text-[10px] text-text-3">
· ·
</div>
</div>
</div>
<div className="flex gap-2 items-center">
<select
value={selectedIncident}
onChange={(e) => setSelectedIncident(Number(e.target.value))}
className="prd-i w-[280px] text-[11px]"
>
{incidents.length === 0
? <option value={0}> </option>
: incidents.map((inc, i) => (
<option key={inc.hnsAnlysSn} value={i}>
HNS-{String(inc.hnsAnlysSn).padStart(3, '0')} · {inc.anlysNm}
</option>
))
}
</select>
<button
onClick={() => setModalOpen(true)}
className="cursor-pointer whitespace-nowrap font-bold text-status-orange text-[11px] px-[14px] py-1.5 rounded-sm"
style={{
background: 'rgba(249,115,22,0.12)',
border: '1px solid rgba(249,115,22,0.3)',
}}
>
+
</button>
</div>
</div>
{/* Body: Left list + Right detail */}
<div className="flex flex-1 overflow-hidden">
{/* ── Left: Scenario List ── */}
<div className="flex flex-col overflow-hidden shrink-0 border-r border-border bg-bg-1" style={{ width: '370px', minWidth: '370px' }}>
<div className="flex items-center justify-between border-b border-border px-[14px] py-2.5">
<span className="text-[11px] font-bold text-text-3">
</span>
<div className="flex gap-1">
{['시간순', '위험도순'].map((label, i) => (
<button key={i} className="cursor-pointer px-2 py-[3px] text-[9px] font-semibold rounded-sm border border-border" style={{
background: i === 0 ? 'rgba(249,115,22,0.08)' : 'var(--bg3)',
color: i === 0 ? 'var(--orange)' : 'var(--t3)',
}}>
{label}
</button>
))}
</div>
</div>
{/* Scrollable list */}
<div className="flex-1 overflow-y-auto flex flex-col gap-1.5 p-2" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
{scenarios.map((scn, idx) => {
const sev = SEVERITY_STYLE[scn.severity]
const isSel = selectedIdx === idx
return (
<div
key={scn.id}
className={`hns-scn-card ${isSel ? 'sel' : ''}`}
onClick={() => { setSelectedIdx(idx); setActiveView(0) }}
>
{/* Title + badge */}
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1.5">
<input
type="checkbox"
checked={checked.has(idx)}
onChange={() => toggleCheck(idx)}
onClick={(e) => e.stopPropagation()}
style={{ accentColor: 'var(--orange)' }}
/>
<span className="text-[12px] font-bold">
{scn.id} {scn.name}
</span>
</div>
<span className="font-bold px-2 py-[2px] rounded-lg text-[8px]" style={{ background: sev.bg, color: sev.color }}>
{scn.severity}
</span>
</div>
{/* Time row */}
<div className="flex items-center gap-1.5 mb-1.5">
<span className="font-bold font-mono text-status-orange text-[9px] px-1.5 py-[2px] rounded-[3px]" style={{ background: 'rgba(249,115,22,0.1)' }}>
{scn.timeStep}
</span>
<span className="text-[9px] text-text-3 font-mono">{scn.datetime}</span>
<span className="ml-auto text-text-3 text-[8px]">{scn.wind}</span>
</div>
{/* Metrics grid */}
<div className="grid grid-cols-4 gap-1 font-mono text-[8px]">
{[
{ 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) => (
<div key={i} className="text-center p-[3px] bg-bg-0 rounded-[3px]">
<div className="text-text-3 text-[7px]">{m.label}</div>
<div className="font-bold" style={{ color: m.color }}>{m.value}</div>
</div>
))}
</div>
{/* Description */}
<div className="text-text-2 mt-1.5 text-[8px] leading-[1.4]">
{scn.description}
</div>
</div>
)
})}
</div>
{/* Bottom buttons */}
<div className="flex gap-2 border-t border-border px-[14px] py-2.5">
<button
onClick={() => setActiveView(1)}
className="flex-1 cursor-pointer font-bold text-status-orange text-[11px] p-2 rounded-sm"
style={{
background: 'rgba(249,115,22,0.1)',
border: '1px solid rgba(249,115,22,0.3)',
}}
>
📊
</button>
<button className="cursor-pointer font-semibold text-text-2 text-[11px] px-[14px] py-2 rounded-sm bg-bg-3 border border-border">
📄
</button>
</div>
</div>
{/* ── Right: Detail Views ── */}
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
{/* View Tabs */}
<div className="flex border-b border-border shrink-0 px-4 bg-bg-1">
{['📋 시나리오 상세', '📊 비교 차트', '🗺 확산범위 오버레이'].map((label, i) => (
<button
key={i}
onClick={() => setActiveView(i as ViewTab)}
className={`rsc-atab ${activeView === i ? 'on' : ''}`}
>
{label}
</button>
))}
</div>
{/* View 0: Detail */}
{activeView === 0 && selected && <ScenarioDetail scenario={selected} />}
{/* View 1: Comparison */}
{activeView === 1 && <ScenarioComparison />}
{/* View 2: Map overlay */}
{activeView === 2 && <ScenarioMapOverlay />}
</div>
</div>
{/* New Scenario Modal */}
<NewScenarioModal
isOpen={modalOpen}
onClose={() => 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)
}}
/>
</div>
)
}
// ─── View 0: Scenario Detail ─────────────────────────────
function ScenarioDetail({ scenario }: { scenario: HnsScenario }) {
const d = scenario.detail
return (
<div className="flex-1 overflow-y-auto flex flex-col gap-3.5 p-4" style={{ scrollbarWidth: 'thin' }}>
{/* Hero card */}
<div className="relative overflow-hidden rounded-md p-4" style={{
background: 'linear-gradient(135deg, rgba(249,115,22,0.06), rgba(239,68,68,0.04))',
border: '1px solid rgba(249,115,22,0.2)',
}}>
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ background: 'linear-gradient(90deg, #f97316, #ef4444, #a855f7)' }} />
<div className="flex items-center gap-2 mb-3">
<span className="text-sm font-bold">
{scenario.id} {scenario.name}
</span>
<span className="font-bold px-2 py-[2px] rounded-lg text-[9px]" style={{
background: SEVERITY_STYLE[scenario.severity].bg, color: SEVERITY_STYLE[scenario.severity].color,
}}>
{scenario.severity}
</span>
<span className="ml-auto text-[10px] text-text-3 font-mono">
{scenario.datetime}
</span>
</div>
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(6, 1fr)' }}>
{[
{ 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) => (
<div key={i} className="text-center rounded-sm p-2" style={{ background: 'rgba(0,0,0,0.15)' }}>
<div className="text-text-3 text-[8px]">{m.label}</div>
<div className="text-base font-bold font-mono whitespace-pre-line mt-[2px]" style={{ color: m.color, lineHeight: 1.2 }}>{m.value}</div>
</div>
))}
</div>
</div>
{/* Two-column section */}
<div className="grid grid-cols-2 gap-3">
{/* Threat Zones */}
<div className="rounded-md border border-border bg-bg-2 p-[14px]">
<h4 className="text-[12px] font-bold mb-2.5">
</h4>
<div className="flex flex-col gap-1.5">
{[
{ 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) => (
<div key={i} className="flex justify-between items-center bg-bg-0 rounded-sm" style={{ padding: '6px 8px', borderLeft: `3px solid ${z.color}` }}>
<span className="text-[10px] text-text-2">{z.label}</span>
<span className="text-[11px] font-bold font-mono" style={{ color: z.color }}>{z.value}</span>
</div>
))}
</div>
</div>
{/* Actions */}
<div className="rounded-md border border-border bg-bg-2 p-[14px]">
<h4 className="text-[12px] font-bold mb-2.5">
🛡
</h4>
<div className="flex flex-col gap-1.5">
{scenario.actions.map((action, i) => (
<div key={i} className="flex items-start gap-1.5 text-[10px] text-text-2 bg-bg-0 rounded-sm leading-[1.4] py-[5px] px-2">
<span className="text-status-orange font-bold shrink-0"></span>
{action}
</div>
))}
</div>
</div>
</div>
{/* Weather */}
<div className="rounded-md border border-border bg-bg-2 p-[14px]">
<h4 className="text-[12px] font-bold mb-2.5">
🌊
</h4>
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(6, 1fr)' }}>
{[
{ 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) => (
<div key={i} className="text-center p-2 rounded-sm bg-bg-0">
<div className="text-sm mb-0.5">{w.icon}</div>
<div className="text-[12px] font-bold font-mono">{w.value}</div>
<div className="text-text-3 mt-0.5 text-[8px]">{w.label}</div>
</div>
))}
</div>
</div>
</div>
)
}
// ─── 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<Severity, string> = { 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 (
<div className="flex-1 overflow-y-auto flex flex-col gap-3.5 px-5 py-4" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
{/* Title */}
<div className="text-[13px] font-bold mb-0.5">
📊
</div>
{/* ── Chart 1: 최대 지표면 농도 추이 (Line + Area) ── */}
<div className="rounded-md border border-border bg-bg-3 p-[14px]">
<div className="text-[11px] font-bold mb-2.5">
(ppm)
</div>
<svg viewBox="0 0 500 140" className="w-full" style={{ height: '130px' }}>
<defs>
<linearGradient id="hnsGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#f97316" stopOpacity={0.12} />
<stop offset="100%" stopColor="transparent" />
</linearGradient>
</defs>
{/* Axes */}
<line x1="50" y1="10" x2="50" y2="120" stroke="#21262d" strokeWidth={0.5} />
<line x1="50" y1="120" x2="480" y2="120" stroke="#21262d" strokeWidth={0.5} />
{/* Threshold lines */}
<line x1="50" y1={10 + (1 - 500 / concMax) * 110} x2="480" y2={10 + (1 - 500 / concMax) * 110} stroke="rgba(239,68,68,.2)" strokeWidth={0.5} strokeDasharray="4,3" />
<text x="5" y={10 + (1 - 500 / concMax) * 110 + 4} fill="#f87171" fontSize="7" fontFamily="var(--fM)">500 IDLH</text>
<line x1="50" y1={10 + (1 - 300 / concMax) * 110} x2="480" y2={10 + (1 - 300 / concMax) * 110} stroke="rgba(249,115,22,.2)" strokeWidth={0.5} strokeDasharray="4,3" />
<text x="5" y={10 + (1 - 300 / concMax) * 110 + 4} fill="#fb923c" fontSize="7" fontFamily="var(--fM)">300 ERPG2</text>
{/* Area fill */}
<polygon points={concArea} fill="url(#hnsGrad)" />
{/* Line */}
<polyline points={concPoly} fill="none" stroke="#f97316" strokeWidth={2} />
{/* Data points + labels */}
{D.map((d, i) => (
<g key={d.id}>
<circle cx={concX[i]} cy={concY[i]} r={4} fill={SEV_COLOR[d.severity]} stroke="#0d1117" strokeWidth={2} />
<text x={concX[i] - 6} y={concY[i] - 8} fill={SEV_COLOR[d.severity]} fontSize="7" textAnchor="end" fontFamily="var(--fM)">{d.conc}</text>
<text x={concX[i]} y={134} fill="#8b949e" fontSize="7" textAnchor="middle" fontFamily="var(--fM)">{d.label}</text>
</g>
))}
</svg>
</div>
{/* ── Charts 2 & 3: 2-column grid ── */}
<div className="grid grid-cols-2 gap-[14px]">
{/* Chart 2: 위험 반경 변화 (Multi-line) */}
<div className="rounded-md border border-border bg-bg-3 p-[14px]">
<div className="text-[11px] font-bold mb-2.5">
(km)
</div>
<svg viewBox="0 0 260 100" className="w-full" style={{ height: '85px' }}>
<line x1="30" y1="10" x2="30" y2="85" stroke="#21262d" strokeWidth={0.5} />
<line x1="30" y1="85" x2="230" y2="85" stroke="#21262d" strokeWidth={0.5} />
{/* IDLH line (red solid) */}
<polyline points={radX.map((x, i) => `${x},${idlhY[i]}`).join(' ')} fill="none" stroke="#ef4444" strokeWidth={1.5} />
{/* ERPG-2 line (orange dashed) */}
<polyline points={radX.map((x, i) => `${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 (
<g key={d.id}>
<circle cx={radX[i]} cy={idlhY[i]} r={3} fill={c} stroke="#0d1117" strokeWidth={1.5} />
<text x={radX[i]} y={96} fill="#8b949e" fontSize="6" textAnchor="middle" fontFamily="var(--fM)">{d.label.replace('+', '+')}</text>
</g>
)
})}
{/* Legend */}
<line x1="170" y1="14" x2="185" y2="14" stroke="#ef4444" strokeWidth={1.5} />
<text x="188" y="17" fill="#ef4444" fontSize="6" fontFamily="var(--fK)">IDLH</text>
<line x1="170" y1="24" x2="185" y2="24" stroke="#f97316" strokeWidth={1.5} strokeDasharray="4,2" />
<text x="188" y="27" fill="#f97316" fontSize="6" fontFamily="var(--fK)">ERPG-2</text>
</svg>
</div>
{/* Chart 3: 영향 인구 변화 (Bar) */}
<div className="rounded-md border border-border bg-bg-3 p-[14px]">
<div className="text-[11px] font-bold mb-2.5">
()
</div>
<svg viewBox="0 0 240 100" className="w-full" style={{ height: '85px' }}>
<line x1="30" y1="10" x2="30" y2="85" stroke="#21262d" strokeWidth={0.5} />
<line x1="30" y1="85" x2="230" y2="85" stroke="#21262d" strokeWidth={0.5} />
{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 (
<g key={d.id}>
<rect x={barX[i]} y={y} width={barW} height={h} rx={2} fill={barColor} />
<text x={barX[i] + barW / 2} y={y - 3} fill={color} fontSize="6" textAnchor="middle" fontFamily="var(--fM)">
{d.pop > 0 ? d.pop.toLocaleString() : '0'}
</text>
<text x={barX[i] + barW / 2} y={96} fill="#8b949e" fontSize="6" textAnchor="middle" fontFamily="var(--fM)">{d.label.replace('+', '+')}</text>
</g>
)
})}
</svg>
</div>
</div>
{/* ── Chart 4: 시나리오 비교표 ── */}
<div className="rounded-md border border-border overflow-x-auto bg-bg-3 p-[14px]">
<div className="text-[11px] font-bold mb-2.5">
📋
</div>
<table className="w-full text-[10px] border-collapse">
<thead>
<tr className="bg-bg-0">
{['지표', ...D.map(d => `${d.id} (${d.label})`)].map((h, i) => (
<th key={i} className="text-text-3 border-b border-border text-[9px] px-[10px] py-2" style={{ textAlign: i === 0 ? 'left' : 'center' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{/* 최대농도 */}
<tr className="border-b border-border">
<td className="text-text-2 px-[10px] py-1.5"> (ppm)</td>
{D.map(d => (
<td key={d.id} className="text-center font-mono font-semibold p-1.5" style={{ color: SEV_COLOR[d.severity] }}>{d.conc}</td>
))}
</tr>
{/* IDLH 반경 */}
<tr className="border-b border-border">
<td className="text-text-2 px-[10px] py-1.5">IDLH (km)</td>
{D.map(d => (
<td key={d.id} className="text-center font-mono font-semibold p-1.5" style={{ color: d.idlh > 0 ? '#f87171' : '#22c55e' }}>{d.idlh || 0}</td>
))}
</tr>
{/* ERPG-2 반경 */}
<tr className="border-b border-border">
<td className="text-text-2 px-[10px] py-1.5">ERPG-2 (km)</td>
{D.map(d => (
<td key={d.id} className="text-center font-mono font-semibold p-1.5" style={{ color: d.erpg2 > 0 ? '#f97316' : '#22c55e' }}>{d.erpg2 || 0}</td>
))}
</tr>
{/* 영향인구 */}
<tr className="border-b border-border">
<td className="text-text-2 px-[10px] py-1.5"> ()</td>
{D.map(d => (
<td key={d.id} className="text-center font-mono font-semibold p-1.5" style={{ color: SEV_COLOR[d.severity] }}>{d.pop.toLocaleString()}</td>
))}
</tr>
{/* 풍향/풍속 */}
<tr className="border-b border-border">
<td className="text-text-2 px-[10px] py-1.5"> / </td>
{D.map(d => (
<td key={d.id} className="text-center font-mono text-primary-cyan p-1.5">{d.wind}</td>
))}
</tr>
{/* 위험 등급 */}
<tr>
<td className="text-text-2 px-[10px] py-1.5"> </td>
{D.map(d => (
<td key={d.id} className="text-center font-bold p-1.5" style={{ color: SEV_COLOR[d.severity] }}>{d.severity}</td>
))}
</tr>
</tbody>
</table>
</div>
</div>
)
}
// ─── View 2: Map Overlay ─────────────────────────────────
function ScenarioMapOverlay() {
return (
<div className="flex-1 flex items-center justify-center flex-col gap-4">
<div className="flex items-center justify-center rounded-md border border-border text-text-3 text-[13px] bg-bg-2" style={{
width: '80%', maxWidth: '600px', height: '300px',
}}>
[ ]
</div>
<div className="flex gap-4">
{[
{ 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) => (
<div key={i} className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full opacity-50" style={{ background: item.color }} />
<span className="text-[10px] text-text-2">{item.label}</span>
</div>
))}
</div>
</div>
)
}
// ─── New Scenario Modal ──────────────────────────────────
function NewScenarioModal({ isOpen, onClose, onSubmit }: {
isOpen: boolean; onClose: () => void; onSubmit: (name: string) => void
}) {
const backdropRef = useRef<HTMLDivElement>(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 (
<div ref={backdropRef} className="fixed inset-0 z-[9999] flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(4px)' }}>
<div className="flex flex-col overflow-hidden rounded-[14px] bg-bg-1 border border-border w-[520px] max-h-[calc(100vh-80px)]" style={{ boxShadow: '0 20px 60px rgba(0,0,0,0.5)' }}>
{/* Header */}
<div className="flex items-center gap-3 border-b border-border px-5 py-4">
<div className="flex items-center justify-center text-base w-9 h-9 rounded-[10px]" style={{
background: 'linear-gradient(135deg, rgba(249,115,22,0.2), rgba(239,68,68,0.15))',
border: '1px solid rgba(249,115,22,0.3)',
}}>🧪</div>
<div className="flex-1">
<h2 className="text-[15px] font-bold m-0">
HNS
</h2>
<div className="text-[10px] text-text-3 mt-0.5">
··
</div>
</div>
<button onClick={onClose} className="flex items-center justify-center w-7 h-7 cursor-pointer text-text-3 text-[12px] rounded-sm border border-border bg-bg-3"></button>
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto flex flex-col gap-3.5 px-5 py-4">
{/* 기본 정보 */}
<ModalSection title="기본 정보">
<ModalField label="시나리오명">
<input className="prd-i" value={name} onChange={e => setName(e.target.value)} placeholder="예: 풍향 변화 시나리오" />
</ModalField>
<div className="grid grid-cols-2 gap-2">
<ModalField label="시간 단계">
<select className="prd-i" value={timeStep} onChange={e => setTimeStep(e.target.value)}>
{['T+0h', 'T+1h', 'T+3h', 'T+6h', 'T+12h', 'T+24h'].map(t => <option key={t} value={t}>{t}</option>)}
</select>
</ModalField>
<ModalField label="기준 시각">
<input className="prd-i" type="text" defaultValue="2024-11-03 08:00" />
</ModalField>
</div>
</ModalSection>
{/* 물질·유출 조건 */}
<ModalSection title="물질 · 유출 조건">
<ModalField label="HNS 물질">
<select className="prd-i" value={material} onChange={e => setMaterial(e.target.value)}>
{MATERIALS.map(m => <option key={m.key} value={m.key}>{m.name} ({m.key})</option>)}
</select>
</ModalField>
{/* Material properties card */}
<div className="grid p-2 rounded-sm" style={{
gridTemplateColumns: 'repeat(5, 1fr)', gap: '4px',
background: 'rgba(249,115,22,0.04)',
border: '1px solid rgba(249,115,22,0.15)',
}}>
{[
{ 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) => (
<div key={i} className="text-center">
<div className="text-text-3 text-[8px]">{p.label}</div>
<div className="text-[10px] font-bold text-status-orange font-mono">{p.value}</div>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-2">
<ModalField label="유출 유형">
<select className="prd-i" value={releaseType} onChange={e => setReleaseType(e.target.value)}>
<option value="instant"> </option>
<option value="continuous"> </option>
<option value="semi"></option>
</select>
</ModalField>
<ModalField label="유출량">
<div className="flex gap-1">
<input className="prd-i flex-1" type="number" value={amount} onChange={e => setAmount(e.target.value)} />
<select className="prd-i w-[60px]" value={unit} onChange={e => setUnit(e.target.value)}>
{['t', 'kg', 'm³', 'L'].map(u => <option key={u} value={u}>{u}</option>)}
</select>
</div>
</ModalField>
</div>
</ModalSection>
{/* 기상 조건 */}
<ModalSection title="기상 조건">
<div className="grid grid-cols-3 gap-2">
<ModalField label="풍향">
<select className="prd-i" value={windDir} onChange={e => setWindDir(e.target.value)}>
{['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'].map(d => <option key={d} value={d}>{d}</option>)}
</select>
</ModalField>
<ModalField label="풍속 (m/s)">
<input className="prd-i" type="number" value={windSpeed} onChange={e => setWindSpeed(e.target.value)} step={0.1} />
</ModalField>
<ModalField label="기온 (°C)">
<input className="prd-i" type="number" value={temp} onChange={e => setTemp(e.target.value)} step={0.1} />
</ModalField>
</div>
<div className="grid grid-cols-2 gap-2">
<ModalField label="대기안정도 (Pasquill)">
<select className="prd-i" value={stability} onChange={e => setStability(e.target.value)}>
{['A (매우 불안정)', 'B (불안정)', 'C (약간 불안정)', 'D (중립)', 'E (약간 안정)', 'F (안정)'].map(s => <option key={s[0]} value={s[0]}>{s}</option>)}
</select>
</ModalField>
<ModalField label="확산 모델">
<select className="prd-i" value={model} onChange={e => setModel(e.target.value)}>
{['ALOHA', 'PHAST', 'CALPUFF', 'Lagrangian'].map(m => <option key={m} value={m}>{m}</option>)}
</select>
</ModalField>
</div>
<ModalField label="예측 시간">
<select className="prd-i" value={predTime} onChange={e => setPredTime(e.target.value)}>
{['1', '3', '6', '12', '24', '48'].map(h => <option key={h} value={h}>{h}</option>)}
</select>
</ModalField>
</ModalSection>
</div>
{/* Footer */}
<div className="flex gap-2 border-t border-border px-5 py-[14px]">
<button onClick={onClose} className="flex-1 text-[12px] font-semibold cursor-pointer rounded-md text-text-2 p-[10px] bg-bg-3 border border-border"></button>
<button onClick={handleSubmit} className="cursor-pointer rounded-md text-[12px] font-bold text-white p-[10px]" style={{
flex: 2,
background: 'linear-gradient(135deg, var(--orange), #ef4444)',
border: 'none',
opacity: name.trim() ? 1 : 0.5,
}}>
🧪
</button>
</div>
</div>
</div>
)
}
// ─── Helpers ─────────────────────────────────────────────
function ModalSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<div className="text-[11px] font-bold text-status-orange mb-2 pb-1" style={{ borderBottom: '1px solid rgba(249,115,22,0.15)' }}>{title}</div>
<div className="flex flex-col gap-2">{children}</div>
</div>
)
}
function ModalField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<div className="text-[9px] font-semibold text-text-3 mb-1">{label}</div>
{children}
</div>
)
}