wing-ops/frontend/src/tabs/hns/components/HNSScenarioView.tsx

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-base">
{/* Header */}
<div className="flex items-center justify-between shrink-0 border-b border-stroke px-5 py-[14px] bg-bg-surface">
<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-fg-disabled">
· ·
</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-color-warning 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-stroke bg-bg-surface" style={{ width: '370px', minWidth: '370px' }}>
<div className="flex items-center justify-between border-b border-stroke px-[14px] py-2.5">
<span className="text-[11px] font-bold text-fg-disabled">
</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-stroke" style={{
background: i === 0 ? 'rgba(249,115,22,0.08)' : 'var(--bg-card)',
color: i === 0 ? 'var(--color-warning)' : 'var(--fg-disabled)',
}}>
{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(--stroke-light) 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(--color-warning)' }}
/>
<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-color-warning 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-fg-disabled font-mono">{scn.datetime}</span>
<span className="ml-auto text-fg-disabled 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-base rounded-[3px]">
<div className="text-fg-disabled text-[7px]">{m.label}</div>
<div className="font-bold" style={{ color: m.color }}>{m.value}</div>
</div>
))}
</div>
{/* Description */}
<div className="text-fg-sub mt-1.5 text-[8px] leading-[1.4]">
{scn.description}
</div>
</div>
)
})}
</div>
{/* Bottom buttons */}
<div className="flex gap-2 border-t border-stroke px-[14px] py-2.5">
<button
onClick={() => setActiveView(1)}
className="flex-1 cursor-pointer font-bold text-color-warning 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-fg-sub text-[11px] px-[14px] py-2 rounded-sm bg-bg-card border border-stroke">
📄
</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-stroke shrink-0 px-4 bg-bg-surface">
{['📋 시나리오 상세', '📊 비교 차트', '🗺 확산범위 오버레이'].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-fg-disabled 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(--color-accent)' },
{ label: '영향인구', value: d.population, color: '#f87171' },
{ label: '유출량', value: d.spillAmount, color: 'var(--color-warning)' },
].map((m, i) => (
<div key={i} className="text-center rounded-sm p-2" style={{ background: 'rgba(0,0,0,0.15)' }}>
<div className="text-fg-disabled 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-stroke bg-bg-elevated 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-base rounded-sm" style={{ padding: '6px 8px', borderLeft: `3px solid ${z.color}` }}>
<span className="text-[10px] text-fg-sub">{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-stroke bg-bg-elevated 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-fg-sub bg-bg-base rounded-sm leading-[1.4] py-[5px] px-2">
<span className="text-color-warning font-bold shrink-0"></span>
{action}
</div>
))}
</div>
</div>
</div>
{/* Weather */}
<div className="rounded-md border border-stroke bg-bg-elevated 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-base">
<div className="text-sm mb-0.5">{w.icon}</div>
<div className="text-[12px] font-bold font-mono">{w.value}</div>
<div className="text-fg-disabled 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(--stroke-light) transparent' }}>
{/* Title */}
<div className="text-[13px] font-bold mb-0.5">
📊
</div>
{/* ── Chart 1: 최대 지표면 농도 추이 (Line + Area) ── */}
<div className="rounded-md border border-stroke bg-bg-card 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(--font-mono)">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(--font-mono)">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(--font-mono)">{d.conc}</text>
<text x={concX[i]} y={134} fill="#8b949e" fontSize="7" textAnchor="middle" fontFamily="var(--font-mono)">{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-stroke bg-bg-card 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(--font-mono)">{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(--font-korean)">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(--font-korean)">ERPG-2</text>
</svg>
</div>
{/* Chart 3: 영향 인구 변화 (Bar) */}
<div className="rounded-md border border-stroke bg-bg-card 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(--font-mono)">
{d.pop > 0 ? d.pop.toLocaleString() : '0'}
</text>
<text x={barX[i] + barW / 2} y={96} fill="#8b949e" fontSize="6" textAnchor="middle" fontFamily="var(--font-mono)">{d.label.replace('+', '+')}</text>
</g>
)
})}
</svg>
</div>
</div>
{/* ── Chart 4: 시나리오 비교표 ── */}
<div className="rounded-md border border-stroke overflow-x-auto bg-bg-card 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-base">
{['지표', ...D.map(d => `${d.id} (${d.label})`)].map((h, i) => (
<th key={i} className="text-fg-disabled border-b border-stroke text-[9px] px-[10px] py-2" style={{ textAlign: i === 0 ? 'left' : 'center' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{/* 최대농도 */}
<tr className="border-b border-stroke">
<td className="text-fg-sub 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-stroke">
<td className="text-fg-sub 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-stroke">
<td className="text-fg-sub 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-stroke">
<td className="text-fg-sub 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-stroke">
<td className="text-fg-sub px-[10px] py-1.5"> / </td>
{D.map(d => (
<td key={d.id} className="text-center font-mono text-color-accent p-1.5">{d.wind}</td>
))}
</tr>
{/* 위험 등급 */}
<tr>
<td className="text-fg-sub 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-stroke text-fg-disabled text-[13px] bg-bg-elevated" 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-fg-sub">{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-surface border border-stroke 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-stroke 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-fg-disabled mt-0.5">
··
</div>
</div>
<button onClick={onClose} className="flex items-center justify-center w-7 h-7 cursor-pointer text-fg-disabled text-[12px] rounded-sm border border-stroke bg-bg-card"></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-fg-disabled text-[8px]">{p.label}</div>
<div className="text-[10px] font-bold text-color-warning 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-stroke px-5 py-[14px]">
<button onClick={onClose} className="flex-1 text-[12px] font-semibold cursor-pointer rounded-md text-fg-sub p-[10px] bg-bg-card border border-stroke"></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(--color-warning), #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-color-warning 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-fg-disabled mb-1">{label}</div>
{children}
</div>
)
}