wing-ops/frontend/src/tabs/hns/components/HNSScenarioView.tsx
htlee ff085252b0 feat(phase4): Board/HNS/Prediction/Aerial/Rescue Mock → API 전환
- Board: 매뉴얼 CRUD + 첨부파일 API (012_board_ext.sql)
- HNS: 분석 CRUD 5개 API (013_hns_analysis.sql)
- Prediction: 분석/역추적/오일펜스 7개 API (014_prediction.sql)
- Aerial: 미디어/CCTV/위성 6개 API + PostGIS (015_aerial.sql)
- Rescue: 구난 작전/시나리오 3개 API + JSONB (016_rescue.sql)
- backtrackMockData.ts 삭제

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

922 lines
47 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 style={{ display: 'flex', flexDirection: 'column', flex: 1, width: '100%', height: '100%', overflow: 'hidden', background: 'var(--bg0)' }}>
{/* Header */}
<div style={{
padding: '14px 20px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
borderBottom: '1px solid var(--bd)', flexShrink: 0, background: 'var(--bg1)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{ fontSize: '16px' }}>📊</span>
<div>
<div style={{ fontSize: '14px', fontWeight: 700, fontFamily: 'var(--fK)', color: 'var(--t1)' }}>
HNS
</div>
<div style={{ fontSize: '10px', color: 'var(--t3)', fontFamily: 'var(--fK)' }}>
· ·
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<select
value={selectedIncident}
onChange={(e) => setSelectedIncident(Number(e.target.value))}
className="prd-i"
style={{ width: '280px', fontSize: '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)}
style={{
padding: '6px 14px', background: 'rgba(249,115,22,0.12)',
border: '1px solid rgba(249,115,22,0.3)', borderRadius: '6px',
color: 'var(--orange)', fontSize: '11px', fontWeight: 700,
fontFamily: 'var(--fK)', cursor: 'pointer', whiteSpace: 'nowrap',
}}
>
+
</button>
</div>
</div>
{/* Body: Left list + Right detail */}
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* ── Left: Scenario List ── */}
<div style={{
width: '370px', minWidth: '370px', borderRight: '1px solid var(--bd)',
display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg1)',
}}>
<div style={{
padding: '10px 14px', borderBottom: '1px solid var(--bd)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<span style={{ fontSize: '11px', fontWeight: 700, color: 'var(--t3)', fontFamily: 'var(--fK)' }}>
</span>
<div style={{ display: 'flex', gap: '4px' }}>
{['시간순', '위험도순'].map((label, i) => (
<button key={i} style={{
padding: '3px 8px', fontSize: '9px', fontWeight: 600,
borderRadius: '4px', cursor: 'pointer', fontFamily: 'var(--fK)',
border: '1px solid var(--bd)', 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 style={{
flex: 1, overflowY: 'auto', padding: '8px',
display: 'flex', flexDirection: 'column', gap: '6px',
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 style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '6px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<input
type="checkbox"
checked={checked.has(idx)}
onChange={() => toggleCheck(idx)}
onClick={(e) => e.stopPropagation()}
style={{ accentColor: 'var(--orange)' }}
/>
<span style={{ fontSize: '12px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)' }}>
{scn.id} {scn.name}
</span>
</div>
<span style={{
padding: '2px 8px', background: sev.bg, borderRadius: '8px',
fontSize: '8px', fontWeight: 700, color: sev.color,
}}>
{scn.severity}
</span>
</div>
{/* Time row */}
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '6px' }}>
<span style={{
padding: '2px 6px', background: 'rgba(249,115,22,0.1)', borderRadius: '3px',
fontSize: '9px', color: 'var(--orange)', fontWeight: 700, fontFamily: 'var(--fM)',
}}>
{scn.timeStep}
</span>
<span style={{ fontSize: '9px', color: 'var(--t3)', fontFamily: 'var(--fM)' }}>{scn.datetime}</span>
<span style={{ marginLeft: 'auto', fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fK)' }}>{scn.wind}</span>
</div>
{/* Metrics grid */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: '4px', fontSize: '8px', fontFamily: 'var(--fM)' }}>
{[
{ 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} style={{ textAlign: 'center', padding: '3px', background: 'var(--bg0)', borderRadius: '3px' }}>
<div style={{ color: 'var(--t3)', fontFamily: 'var(--fK)', fontSize: '7px' }}>{m.label}</div>
<div style={{ color: m.color, fontWeight: 700 }}>{m.value}</div>
</div>
))}
</div>
{/* Description */}
<div style={{
marginTop: '6px', fontSize: '8px', color: 'var(--t2)',
fontFamily: 'var(--fK)', lineHeight: 1.4,
}}>
{scn.description}
</div>
</div>
)
})}
</div>
{/* Bottom buttons */}
<div style={{
padding: '10px 14px', borderTop: '1px solid var(--bd)',
display: 'flex', gap: '8px',
}}>
<button
onClick={() => setActiveView(1)}
style={{
flex: 1, padding: '8px', borderRadius: '6px', cursor: 'pointer',
background: 'rgba(249,115,22,0.1)', border: '1px solid rgba(249,115,22,0.3)',
color: 'var(--orange)', fontSize: '11px', fontWeight: 700, fontFamily: 'var(--fK)',
}}
>
📊
</button>
<button style={{
padding: '8px 14px', borderRadius: '6px', cursor: 'pointer',
background: 'var(--bg3)', border: '1px solid var(--bd)',
color: 'var(--t2)', fontSize: '11px', fontWeight: 600, fontFamily: 'var(--fK)',
}}>
📄
</button>
</div>
</div>
{/* ── Right: Detail Views ── */}
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* View Tabs */}
<div style={{
display: 'flex', borderBottom: '1px solid var(--bd)', flexShrink: 0,
padding: '0 16px', background: 'var(--bg1)',
}}>
{['📋 시나리오 상세', '📊 비교 차트', '🗺 확산범위 오버레이'].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 style={{ flex: 1, overflowY: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '14px', scrollbarWidth: 'thin' }}>
{/* Hero card */}
<div 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)', borderRadius: '10px',
padding: '16px', position: 'relative', overflow: 'hidden',
}}>
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, #f97316, #ef4444, #a855f7)' }} />
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<span style={{ fontSize: '14px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)' }}>
{scenario.id} {scenario.name}
</span>
<span style={{
padding: '2px 8px', borderRadius: '8px', fontSize: '9px', fontWeight: 700,
background: SEVERITY_STYLE[scenario.severity].bg, color: SEVERITY_STYLE[scenario.severity].color,
}}>
{scenario.severity}
</span>
<span style={{ marginLeft: 'auto', fontSize: '10px', color: 'var(--t3)', fontFamily: 'var(--fM)' }}>
{scenario.datetime}
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: '8px' }}>
{[
{ 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} style={{
background: 'rgba(0,0,0,0.15)', borderRadius: '6px', padding: '8px', textAlign: 'center',
}}>
<div style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fK)' }}>{m.label}</div>
<div style={{ fontSize: '16px', fontWeight: 700, color: m.color, fontFamily: 'var(--fM)', whiteSpace: 'pre-line', lineHeight: 1.2, marginTop: '2px' }}>{m.value}</div>
</div>
))}
</div>
</div>
{/* Two-column section */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
{/* Threat Zones */}
<div style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: '8px', padding: '14px' }}>
<h4 style={{ fontSize: '12px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)', marginBottom: '10px' }}>
</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{[
{ 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} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 8px', background: 'var(--bg0)', borderRadius: '4px', borderLeft: `3px solid ${z.color}` }}>
<span style={{ fontSize: '10px', color: 'var(--t2)', fontFamily: 'var(--fK)' }}>{z.label}</span>
<span style={{ fontSize: '11px', fontWeight: 700, color: z.color, fontFamily: 'var(--fM)' }}>{z.value}</span>
</div>
))}
</div>
</div>
{/* Actions */}
<div style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: '8px', padding: '14px' }}>
<h4 style={{ fontSize: '12px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)', marginBottom: '10px' }}>
🛡
</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
{scenario.actions.map((action, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'flex-start', gap: '6px',
padding: '5px 8px', background: 'var(--bg0)', borderRadius: '4px',
fontSize: '10px', color: 'var(--t2)', fontFamily: 'var(--fK)', lineHeight: 1.4,
}}>
<span style={{ color: 'var(--orange)', fontWeight: 700, flexShrink: 0 }}></span>
{action}
</div>
))}
</div>
</div>
</div>
{/* Weather */}
<div style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: '8px', padding: '14px' }}>
<h4 style={{ fontSize: '12px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)', marginBottom: '10px' }}>
🌊
</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: '8px' }}>
{[
{ 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} style={{ textAlign: 'center', padding: '8px', background: 'var(--bg0)', borderRadius: '6px' }}>
<div style={{ fontSize: '14px', marginBottom: '2px' }}>{w.icon}</div>
<div style={{ fontSize: '12px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fM)' }}>{w.value}</div>
<div style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fK)', marginTop: '2px' }}>{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 style={{ flex: 1, overflowY: 'auto', padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: '14px', scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
{/* Title */}
<div style={{ fontSize: '13px', fontWeight: 700, fontFamily: 'var(--fK)', color: 'var(--t1)', marginBottom: '2px' }}>
📊
</div>
{/* ── Chart 1: 최대 지표면 농도 추이 (Line + Area) ── */}
<div style={{ background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: '8px', padding: '14px' }}>
<div style={{ fontSize: '11px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)', marginBottom: '10px' }}>
(ppm)
</div>
<svg viewBox="0 0 500 140" style={{ width: '100%', 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 style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
{/* Chart 2: 위험 반경 변화 (Multi-line) */}
<div style={{ background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: '8px', padding: '14px' }}>
<div style={{ fontSize: '11px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)', marginBottom: '10px' }}>
(km)
</div>
<svg viewBox="0 0 260 100" style={{ width: '100%', 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 style={{ background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: '8px', padding: '14px' }}>
<div style={{ fontSize: '11px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)', marginBottom: '10px' }}>
()
</div>
<svg viewBox="0 0 240 100" style={{ width: '100%', 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 style={{ background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: '8px', padding: '14px', overflowX: 'auto' }}>
<div style={{ fontSize: '11px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)', marginBottom: '10px' }}>
📋
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '10px', fontFamily: 'var(--fK)' }}>
<thead>
<tr style={{ background: 'var(--bg0)' }}>
{['지표', ...D.map(d => `${d.id} (${d.label})`)].map((h, i) => (
<th key={i} style={{
padding: '8px 10px', textAlign: i === 0 ? 'left' : 'center',
color: 'var(--t3)', fontSize: '9px', borderBottom: '1px solid var(--bd)',
}}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{/* 최대농도 */}
<tr style={{ borderBottom: '1px solid var(--bd)' }}>
<td style={{ padding: '6px 10px', color: 'var(--t2)' }}> (ppm)</td>
{D.map(d => (
<td key={d.id} style={{ padding: '6px', textAlign: 'center', fontFamily: 'var(--fM)', color: SEV_COLOR[d.severity], fontWeight: 600 }}>{d.conc}</td>
))}
</tr>
{/* IDLH 반경 */}
<tr style={{ borderBottom: '1px solid var(--bd)' }}>
<td style={{ padding: '6px 10px', color: 'var(--t2)' }}>IDLH (km)</td>
{D.map(d => (
<td key={d.id} style={{ padding: '6px', textAlign: 'center', fontFamily: 'var(--fM)', color: d.idlh > 0 ? '#f87171' : '#22c55e', fontWeight: 600 }}>{d.idlh || 0}</td>
))}
</tr>
{/* ERPG-2 반경 */}
<tr style={{ borderBottom: '1px solid var(--bd)' }}>
<td style={{ padding: '6px 10px', color: 'var(--t2)' }}>ERPG-2 (km)</td>
{D.map(d => (
<td key={d.id} style={{ padding: '6px', textAlign: 'center', fontFamily: 'var(--fM)', color: d.erpg2 > 0 ? '#f97316' : '#22c55e', fontWeight: 600 }}>{d.erpg2 || 0}</td>
))}
</tr>
{/* 영향인구 */}
<tr style={{ borderBottom: '1px solid var(--bd)' }}>
<td style={{ padding: '6px 10px', color: 'var(--t2)' }}> ()</td>
{D.map(d => (
<td key={d.id} style={{ padding: '6px', textAlign: 'center', fontFamily: 'var(--fM)', color: SEV_COLOR[d.severity], fontWeight: 600 }}>{d.pop.toLocaleString()}</td>
))}
</tr>
{/* 풍향/풍속 */}
<tr style={{ borderBottom: '1px solid var(--bd)' }}>
<td style={{ padding: '6px 10px', color: 'var(--t2)' }}> / </td>
{D.map(d => (
<td key={d.id} style={{ padding: '6px', textAlign: 'center', fontFamily: 'var(--fM)', color: 'var(--cyan)' }}>{d.wind}</td>
))}
</tr>
{/* 위험 등급 */}
<tr>
<td style={{ padding: '6px 10px', color: 'var(--t2)' }}> </td>
{D.map(d => (
<td key={d.id} style={{ padding: '6px', textAlign: 'center', color: SEV_COLOR[d.severity], fontWeight: 700 }}>{d.severity}</td>
))}
</tr>
</tbody>
</table>
</div>
</div>
)
}
// ─── View 2: Map Overlay ─────────────────────────────────
function ScenarioMapOverlay() {
return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: '16px' }}>
<div style={{
width: '80%', maxWidth: '600px', height: '300px',
background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: '10px',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--t3)', fontSize: '13px', fontFamily: 'var(--fK)',
}}>
[ ]
</div>
<div style={{ display: 'flex', gap: '16px' }}>
{[
{ 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} style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ width: '12px', height: '12px', borderRadius: '50%', background: item.color, opacity: 0.5 }} />
<span style={{ fontSize: '10px', color: 'var(--t2)', fontFamily: 'var(--fK)' }}>{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} style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{
width: '520px', maxHeight: 'calc(100vh - 80px)',
background: 'var(--bg1)', border: '1px solid var(--bd)',
borderRadius: '14px', overflow: 'hidden',
display: 'flex', flexDirection: 'column',
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
}}>
{/* Header */}
<div style={{
padding: '16px 20px', borderBottom: '1px solid var(--bd)',
display: 'flex', alignItems: 'center', gap: '12px',
}}>
<div style={{
width: '36px', height: '36px', borderRadius: '10px',
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)',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '16px',
}}>🧪</div>
<div style={{ flex: 1 }}>
<h2 style={{ fontSize: '15px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)', margin: 0 }}>
HNS
</h2>
<div style={{ fontSize: '10px', color: 'var(--t3)', fontFamily: 'var(--fK)', marginTop: '2px' }}>
··
</div>
</div>
<button onClick={onClose} style={{
width: '28px', height: '28px', borderRadius: '6px',
border: '1px solid var(--bd)', background: 'var(--bg3)',
color: 'var(--t3)', fontSize: '12px', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}></button>
</div>
{/* Scrollable content */}
<div style={{
flex: 1, overflowY: 'auto', padding: '16px 20px',
display: 'flex', flexDirection: 'column', gap: '14px',
}}>
{/* 기본 정보 */}
<ModalSection title="기본 정보">
<ModalField label="시나리오명">
<input className="prd-i" value={name} onChange={e => setName(e.target.value)} placeholder="예: 풍향 변화 시나리오" />
</ModalField>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
<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 style={{
display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: '4px',
padding: '8px', background: 'rgba(249,115,22,0.04)',
border: '1px solid rgba(249,115,22,0.15)', borderRadius: '6px',
}}>
{[
{ 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} style={{ textAlign: 'center' }}>
<div style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fK)' }}>{p.label}</div>
<div style={{ fontSize: '10px', fontWeight: 700, color: 'var(--orange)', fontFamily: 'var(--fM)' }}>{p.value}</div>
</div>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
<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 style={{ display: 'flex', gap: '4px' }}>
<input className="prd-i" type="number" value={amount} onChange={e => setAmount(e.target.value)} style={{ flex: 1 }} />
<select className="prd-i" value={unit} onChange={e => setUnit(e.target.value)} style={{ width: '60px' }}>
{['t', 'kg', 'm³', 'L'].map(u => <option key={u} value={u}>{u}</option>)}
</select>
</div>
</ModalField>
</div>
</ModalSection>
{/* 기상 조건 */}
<ModalSection title="기상 조건">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '8px' }}>
<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 style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
<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 style={{ padding: '14px 20px', borderTop: '1px solid var(--bd)', display: 'flex', gap: '8px' }}>
<button onClick={onClose} style={{
flex: 1, padding: '10px', fontSize: '12px', fontWeight: 600,
fontFamily: 'var(--fK)', borderRadius: '8px', cursor: 'pointer',
background: 'var(--bg3)', border: '1px solid var(--bd)', color: 'var(--t2)',
}}></button>
<button onClick={handleSubmit} style={{
flex: 2, padding: '10px', fontSize: '12px', fontWeight: 700,
fontFamily: 'var(--fK)', borderRadius: '8px', cursor: 'pointer',
background: 'linear-gradient(135deg, var(--orange), #ef4444)',
border: 'none', color: '#fff',
opacity: name.trim() ? 1 : 0.5,
}}>
🧪
</button>
</div>
</div>
</div>
)
}
// ─── Helpers ─────────────────────────────────────────────
function ModalSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<div style={{
fontSize: '11px', fontWeight: 700, color: 'var(--orange)',
fontFamily: 'var(--fK)', marginBottom: '8px',
paddingBottom: '4px', borderBottom: '1px solid rgba(249,115,22,0.15)',
}}>{title}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>{children}</div>
</div>
)
}
function ModalField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<div style={{ fontSize: '9px', fontWeight: 600, color: 'var(--t3)', fontFamily: 'var(--fK)', marginBottom: '4px' }}>{label}</div>
{children}
</div>
)
}