840 lines
42 KiB
TypeScript
Executable File
840 lines
42 KiB
TypeScript
Executable File
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>
|
||
)
|
||
}
|