wing-ops/frontend/src/components/hns/components/HNSScenarioView.tsx
leedano 38d931db65 refactor(mpa): 탭 디렉토리를 MPA 컴포넌트 구조로 재편
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 17:38:49 +09:00

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>
);
}