- 11개 탭 디렉토리 생성: tabs/{prediction,hns,rescue,weather,incidents,aerial,board,reports,assets,scat,admin}/
- 51개 컴포넌트를 역할 기반(views/, analysis/, layout/) → 탭 기반(tabs/) 구조로 이동
- weather 탭에 전용 hooks/, services/ 포함
- incidents 탭에 전용 services/ 포함
- 공통 지도 컴포넌트(MapView, BacktrackReplay)를 common/components/map/으로 이동
- 각 탭에 index.ts 생성하여 View 컴포넌트 re-export
- App.tsx import를 @tabs/ alias 사용으로 변경
- 전체 import 경로 수정 (탭 내부 상대경로, 외부 @common/ alias)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
912 lines
47 KiB
TypeScript
Executable File
912 lines
47 KiB
TypeScript
Executable File
import { useState, useRef, useEffect } from 'react'
|
||
|
||
// ─── 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 INCIDENTS = [
|
||
'HNS-2024-041 · 울산 온산항 톨루엔 유출',
|
||
'HNS-2024-039 · 여수 암모니아 누출',
|
||
'HNS-2024-035 · 부산 수소 추진선 폭발',
|
||
'HNS-2024-033 · 인천항 메탄올 유출',
|
||
]
|
||
|
||
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 [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)
|
||
|
||
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.map((inc, i) => <option key={i} value={i}>{inc}</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>
|
||
)
|
||
}
|