515 lines
17 KiB
TypeScript
515 lines
17 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { fetchHnsAnalyses } from '../services/hnsApi';
|
|
import type { HnsAnalysisItem, HnsScenario, HnsMaterial } from '@interfaces/hns/HnsInterface';
|
|
import type { Severity } from '@/types/hns/HnsType';
|
|
import { ScenarioDetail } from './contents/ScenarioDetail';
|
|
import { ScenarioComparison } from './contents/ScenarioComparison';
|
|
import { ScenarioMapOverlay } from './contents/ScenarioMapOverlay';
|
|
import { NewScenarioModal } from './contents/NewScenarioModal';
|
|
/* eslint-disable react-refresh/only-export-components */
|
|
|
|
// ─── Types ──────────────────────────────────────────────
|
|
type ViewTab = 0 | 1 | 2;
|
|
|
|
export const SEVERITY_STYLE: Record<Severity, { bg: string; color: string }> = {
|
|
CRITICAL: { bg: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' },
|
|
HIGH: { bg: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' },
|
|
MEDIUM: { bg: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' },
|
|
RESOLVED: { bg: 'rgba(34,197,94,0.15)', color: 'var(--color-success)' },
|
|
};
|
|
|
|
// ─── 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: ['전 구역 대피 해제', '잔류 오염 최종 모니터링', '사후 환경 평가 실시'],
|
|
},
|
|
];
|
|
|
|
export 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-title-4 font-bold">HNS 대기확산 시나리오 관리</div>
|
|
<div className="text-label-2 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-label-2"
|
|
>
|
|
{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-semibold text-color-accent text-label-2 px-[14px] py-1.5 rounded-sm"
|
|
style={{
|
|
border: '1px solid rgba(6,182,212,.3)',
|
|
background: 'rgba(6,182,212,.08)',
|
|
}}
|
|
>
|
|
+ 신규 시나리오
|
|
</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-label-2 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-caption font-semibold rounded-sm border border-stroke ${
|
|
i === 0
|
|
? 'bg-[rgba(6,182,212,0.08)] text-color-accent'
|
|
: 'bg-bg-card text-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-accent)' }}
|
|
/>
|
|
<span className="text-label-1 font-bold">
|
|
{scn.id} {scn.name}
|
|
</span>
|
|
</div>
|
|
<span
|
|
className="font-bold px-2 py-[2px] rounded-lg text-caption"
|
|
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-accent text-caption px-1.5 py-[2px] rounded-[3px]"
|
|
style={{ background: 'rgba(6,182,212,0.1)' }}
|
|
>
|
|
{scn.timeStep}
|
|
</span>
|
|
<span className="text-caption text-fg-disabled font-mono">{scn.datetime}</span>
|
|
<span className="ml-auto text-fg-disabled text-caption">{scn.wind}</span>
|
|
</div>
|
|
|
|
{/* Metrics grid */}
|
|
<div className="grid grid-cols-4 gap-1 font-mono text-caption">
|
|
{[
|
|
{ label: '최대농도', value: scn.maxConc, color: 'var(--color-accent)' },
|
|
{ label: 'IDLH반경', value: scn.idlhRadius, color: 'var(--color-accent)' },
|
|
{ label: 'ERPG-2', value: scn.erpg2, color: 'var(--color-accent)' },
|
|
{ label: '영향인구', value: scn.population, color: 'var(--color-accent)' },
|
|
].map((m, i) => (
|
|
<div key={i} className="text-center p-[3px] bg-bg-base rounded-[3px]">
|
|
<div className="text-fg-disabled text-caption">{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-caption 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-semibold text-fg-sub text-label-2 p-2 rounded-sm bg-bg-card border border-stroke hover:bg-color-accent hover:text-fg"
|
|
>
|
|
선택 시나리오 비교
|
|
</button>
|
|
<button className="cursor-pointer font-semibold text-fg-sub text-label-2 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>
|
|
);
|
|
}
|