import { useState, useEffect, useRef } from 'react'; import { ComboBox } from '@common/components/ui/ComboBox'; import { useWeatherFetch } from '../hooks/useWeatherFetch'; import { getSubstanceToxicity } from '../utils/toxicityData'; import type { WeatherFetchResult, ReleaseType } from '../utils/dispersionTypes'; import { fetchIncidentsRaw } from '@tabs/incidents/services/incidentsApi'; import type { IncidentListItem } from '@tabs/incidents/services/incidentsApi'; /** HNS 분석 입력 파라미터 (부모에 전달) */ export interface HNSInputParams { substance: string; releaseType: ReleaseType; /** 배출률 (g/s) — Plume, Dense Gas */ emissionRate: number; /** 총 누출량 (g) — Puff */ totalRelease: number; /** 누출 높이 (m) — 전 모델 */ releaseHeight: number; /** 누출 지속시간 (s) — Plume */ releaseDuration: number; /** 액체풀 반경 (m) — Dense Gas */ poolRadius: number; algorithm: string; criteriaModel: string; weather: WeatherFetchResult; /** 사고 발생일 (YYYY-MM-DD) */ accidentDate: string; /** 사고 발생시각 (HH:mm) */ accidentTime: string; /** 예측시간 (예: '24시간') */ predictionTime: string; /** 사고명 (직접 입력 또는 사고 리스트 선택) */ accidentName: string; } interface HNSLeftPanelProps { activeSubTab: 'analysis' | 'list'; onSubTabChange: (tab: 'analysis' | 'list') => void; incidentCoord: { lon: number; lat: number }; onCoordChange: (coord: { lon: number; lat: number }) => void; onMapSelectClick: () => void; onRunPrediction: () => void; isRunningPrediction: boolean; onParamsChange?: (params: HNSInputParams) => void; onReset?: () => void; loadedParams?: Partial | null; } /** 십진 좌표 → 도분초 변환 */ function toDMS(decimal: number, type: 'lat' | 'lon'): string { const abs = Math.abs(decimal); const d = Math.floor(abs); const m = Math.floor((abs - d) * 60); const s = ((abs - d - m / 60) * 3600).toFixed(2); const dir = type === 'lat' ? (decimal >= 0 ? 'N' : 'S') : (decimal >= 0 ? 'E' : 'W'); return `${d}\u00B0 ${m}' ${s}" ${dir}`; } export function HNSLeftPanel({ activeSubTab, onSubTabChange: _onSubTabChange, // eslint-disable-line @typescript-eslint/no-unused-vars incidentCoord, onCoordChange, onMapSelectClick, onRunPrediction, isRunningPrediction, onParamsChange, onReset, loadedParams, }: HNSLeftPanelProps) { const [incidents, setIncidents] = useState([]); const [selectedIncidentSn, setSelectedIncidentSn] = useState(''); const [accidentName, setAccidentName] = useState(''); const [accidentDate, setAccidentDate] = useState(() => { const now = new Date(); return now.toISOString().slice(0, 10); }); const [accidentTime, setAccidentTime] = useState(() => { const now = new Date(); return now.toTimeString().slice(0, 5); }); const [predictionTime, setPredictionTime] = useState('24시간'); const [substance, setSubstance] = useState('톨루엔 (Toluene)'); const [releaseType, setReleaseType] = useState('연속 유출'); const [emissionRate, setEmissionRate] = useState(''); // g/s (Plume, Dense Gas) const [totalRelease, setTotalRelease] = useState(''); // g (Puff) const [releaseHeight, setReleaseHeight] = useState('0.5'); // m const [releaseDuration, setReleaseDuration] = useState('300'); // s (Plume) const [poolRadius, setPoolRadius] = useState(''); // m (Dense Gas) const [algorithm, setAlgorithm] = useState('ALOHA (EPA)'); const [criteriaModel, setCriteriaModel] = useState('AEGL'); // 불러오기 시 입력값 복원 useEffect(() => { if (!loadedParams) return; queueMicrotask(() => { if (loadedParams.substance) setSubstance(loadedParams.substance); if (loadedParams.releaseType) setReleaseType(loadedParams.releaseType); if (loadedParams.emissionRate != null) setEmissionRate(String(loadedParams.emissionRate)); if (loadedParams.totalRelease != null) setTotalRelease(String(loadedParams.totalRelease)); if (loadedParams.releaseHeight != null) setReleaseHeight(String(loadedParams.releaseHeight)); if (loadedParams.releaseDuration != null) setReleaseDuration(String(loadedParams.releaseDuration)); if (loadedParams.poolRadius != null) setPoolRadius(String(loadedParams.poolRadius)); if (loadedParams.algorithm) setAlgorithm(loadedParams.algorithm); if (loadedParams.criteriaModel) setCriteriaModel(loadedParams.criteriaModel); if (loadedParams.accidentDate) setAccidentDate(loadedParams.accidentDate); if (loadedParams.accidentTime) setAccidentTime(loadedParams.accidentTime); if (loadedParams.predictionTime) setPredictionTime(loadedParams.predictionTime); if (loadedParams.accidentName) setAccidentName(loadedParams.accidentName); }); }, [loadedParams]); // 기상정보 자동조회 (사고 발생 일시 기반) const weather = useWeatherFetch(incidentCoord.lat, incidentCoord.lon, accidentDate, accidentTime); // 물질 독성 정보 const tox = getSubstanceToxicity(substance); // 물질 변경 시 기본값 동기화 (핸들러에서 직접) const handleSubstanceChange = (value: string) => { setSubstance(value); const newTox = getSubstanceToxicity(value); setEmissionRate(String(newTox.Q)); setTotalRelease(String(newTox.QTotal)); setPoolRadius(String(newTox.poolRadius)); }; // 사고 목록 불러오기 (마운트 시 1회, ref 초기화 패턴) const incidentsPromiseRef = useRef | null>(null); if (incidentsPromiseRef.current == null) { incidentsPromiseRef.current = fetchIncidentsRaw() .then(data => setIncidents(data)) .catch(() => setIncidents([])); } // 사고 선택 시 필드 자동 채움 const handleSelectIncident = (snStr: string) => { setSelectedIncidentSn(snStr); const sn = parseInt(snStr); const incident = incidents.find(i => i.acdntSn === sn); if (!incident) return; setAccidentName(incident.acdntNm); if (incident.lat && incident.lng) { onCoordChange({ lat: incident.lat, lon: incident.lng }); } }; // 파라미터 변경 시 부모에 통지 useEffect(() => { if (onParamsChange) { onParamsChange({ substance, releaseType, emissionRate: parseFloat(emissionRate) || tox.Q, totalRelease: parseFloat(totalRelease) || tox.QTotal, releaseHeight: parseFloat(releaseHeight) || 0.5, releaseDuration: parseFloat(releaseDuration) || 300, poolRadius: parseFloat(poolRadius) || tox.poolRadius, algorithm, criteriaModel, weather, accidentDate, accidentTime, predictionTime, accidentName, }); } }, [substance, releaseType, emissionRate, totalRelease, releaseHeight, releaseDuration, poolRadius, algorithm, criteriaModel, weather, onParamsChange, tox.Q, tox.QTotal, tox.poolRadius, accidentDate, accidentTime, predictionTime, accidentName]); const handleReset = () => { setSelectedIncidentSn(''); setAccidentName(''); const now = new Date(); setAccidentDate(now.toISOString().slice(0, 10)); setAccidentTime(now.toTimeString().slice(0, 5)); setPredictionTime('24시간'); setSubstance('톨루엔 (Toluene)'); setReleaseType('연속 유출'); const defaultTox = getSubstanceToxicity('톨루엔 (Toluene)'); setEmissionRate(String(defaultTox.Q)); setTotalRelease(String(defaultTox.QTotal)); setReleaseHeight('0.5'); setReleaseDuration('300'); setPoolRadius(String(defaultTox.poolRadius)); setAlgorithm('ALOHA (EPA)'); setCriteriaModel('AEGL'); onCoordChange({ lon: 129.3542, lat: 35.4215 }); onReset?.(); }; return (
{/* Scrollable Content */}
{activeSubTab === 'analysis' && (
{/* Header */}
🧪
HNS 대기확산 예측
ALOHA/CAMEO 기반 대기확산 시뮬레이션
{/* Single Column Layout */}
{/* 사고 기본정보 */}
📋 사고 기본정보
{/* 사고명 직접 입력 */} setAccidentName(e.target.value)} placeholder="사고명 직접 입력" /> {/* 또는 사고 리스트에서 선택 */} ({ value: String(inc.acdntSn), label: `${inc.acdntNm} (${new Date(inc.occrnDtm).toLocaleDateString('ko-KR')})`, }))} /> {/* 사고 발생 일시 */}
setAccidentDate(e.target.value)} /> setAccidentTime(e.target.value)} />
{/* 좌표 + 지도 버튼 */}
onCoordChange({ ...incidentCoord, lat: parseFloat(e.target.value) || 0 })} /> onCoordChange({ ...incidentCoord, lon: parseFloat(e.target.value) || 0 })} />
{/* DMS 표시 */}
{toDMS(incidentCoord.lat, 'lat')} / {toDMS(incidentCoord.lon, 'lon')}
{/* 유출형태 + 물질명 */}
setReleaseType(v as ReleaseType)} options={[ { value: '연속 유출', label: '연속' }, { value: '순간 유출', label: '순간' }, { value: '풀(Pool) 증발', label: '풀 증발' }, ]} />
{/* 유출량 + 단위 + 예측시간 */}
releaseType === '순간 유출' ? setTotalRelease(e.target.value) : setEmissionRate(e.target.value)} placeholder={releaseType === '순간 유출' ? 'g' : 'g/s'} /> {}} options={ releaseType === '순간 유출' ? [{ value: 'g', label: 'g' }, { value: 'kg', label: 'kg' }] : [{ value: 'g/s', label: 'g/s' }, { value: 'kg/s', label: 'kg/s' }] } />
{/* 기상 정보 (자동조회) — 내부적으로 사용, UI 숨김 */} {/* 알고리즘 선택 */}
{/* 모델 파라미터 & 물질 정보 */}
🧪 {releaseType === '연속 유출' ? 'Plume' : releaseType === '순간 유출' ? 'Puff' : 'Dense Gas'} 파라미터
{/* 모델별 입력 파라미터 */}
{/* 연속 유출 (Plume): 배출률, 누출지속시간, 누출높이 */} {releaseType === '연속 유출' && (
setEmissionRate(e.target.value)} step="0.1" min="0" />
setReleaseDuration(e.target.value)} step="10" min="1" />
setReleaseHeight(e.target.value)} step="0.1" min="0" />
)} {/* 순간 유출 (Puff): 총 누출량, 누출높이 */} {releaseType === '순간 유출' && (
setTotalRelease(e.target.value)} step="100" min="0" />
setReleaseHeight(e.target.value)} step="0.1" min="0" />
)} {/* 풀(Pool) 증발 (Dense Gas): 배출률, 풀반경, 누출높이 */} {releaseType === '풀(Pool) 증발' && (
setEmissionRate(e.target.value)} step="0.1" min="0" />
setPoolRadius(e.target.value)} step="0.5" min="0.1" />
setReleaseHeight(e.target.value)} step="0.1" min="0" />
)} {/* 모델 설명 */}
{releaseType === '연속 유출' && '정상상태 연속 배출. 바람 방향으로 플룸이 형성됩니다.'} {releaseType === '순간 유출' && '한 번에 전량 방출. 시간에 따라 구름이 이동하며 확산됩니다.'} {releaseType === '풀(Pool) 증발' && '고밀도 가스가 지표면을 따라 확산됩니다 (Britter-McQuaid 모델).'}
{/* 물질 위험 특성 */}
⚠ 물질 위험 특성
분자량 {tox.mw} g/mol
가스밀도 {tox.densityGas} kg/m³
증기압 {tox.vaporPressure} mmHg
IDLH {tox.idlh} ppm
{/* AEGL 등급 범례 */}
📊 확산 등급 기준 (AEGL)
AEGL-3 (생명위협) — {tox.aegl3} ppm
AEGL-2 (건강피해) — {tox.aegl2} ppm
AEGL-1 (불쾌감) — {tox.aegl1} ppm
{/* 실행 버튼 */}
)} {activeSubTab === 'list' && (
{/* Header */}
📋
분석 목록
저장된 대기확산 예측 결과
{/* 필터 섹션 */}
🔍 필터
{/* 기간 선택 */}
{}} options={[ { value: '오늘', label: '오늘' }, { value: '최근 7일', label: '최근 7일' }, { value: '최근 30일', label: '최근 30일' }, { value: '전체', label: '전체' } ]} />
{/* 물질 분류 */}
{}} options={[ { value: '전체', label: '전체' }, { value: '유독성 액체', label: '유독성 액체' }, { value: '유독성 기체', label: '유독성 기체' }, { value: '인화성 액체', label: '인화성 액체' }, { value: '인화성 기체', label: '인화성 기체' } ]} />
{/* 위험도 */}
{}} options={[ { value: '전체', label: '전체' }, { value: 'AEGL-3', label: 'AEGL-3' }, { value: 'AEGL-2', label: 'AEGL-2' }, { value: 'AEGL-1', label: 'AEGL-1' } ]} />
{/* 통계 요약 */}
📊 통계
전체 분석 8건
고위험 (AEGL-3) 3건
중위험 (AEGL-2) 5건
)}
); }