## Summary - 기상 맵 컨트롤 컴포넌트 추가 및 KHOA API 연동 개선 - KHOA API 엔드포인트 교체 및 해양예측 오버레이 Canvas 렌더링 전환 ## 변경 파일 - OceanForecastOverlay.tsx - WeatherMapOverlay.tsx - WeatherView.tsx - useOceanForecast.ts - khoaApi.ts - vite.config.ts ## Test plan - [ ] 기상정보 -> 기상 레이어 -> 해황 예보도 클릭 -> 이미지 렌더링 확인 - [ ] 기상정보 -> 기상 레이어 -> 백터 바람 클릭 -> 백터 이미지 렌더링 확인 Co-authored-by: Nan Kyung Lee <nankyunglee@Nanui-Macmini.local> Reviewed-on: #78 Co-authored-by: leedano <dnlee@gcsc.co.kr> Co-committed-by: leedano <dnlee@gcsc.co.kr>
710 lines
32 KiB
TypeScript
Executable File
710 lines
32 KiB
TypeScript
Executable File
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 } | null;
|
|
onCoordChange: (coord: { lon: number; lat: number } | null) => void;
|
|
onMapSelectClick: () => void;
|
|
onRunPrediction: () => void;
|
|
isRunningPrediction: boolean;
|
|
onParamsChange?: (params: HNSInputParams) => void;
|
|
onReset?: () => void;
|
|
loadedParams?: Partial<HNSInputParams> | 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<IncidentListItem[]>([]);
|
|
const [selectedIncidentSn, setSelectedIncidentSn] = useState('');
|
|
|
|
const [accidentName, setAccidentName] = useState('');
|
|
const [accidentDate, setAccidentDate] = useState<string>(() => {
|
|
const now = new Date();
|
|
return now.toISOString().slice(0, 10);
|
|
});
|
|
const [accidentTime, setAccidentTime] = useState<string>(() => {
|
|
const now = new Date();
|
|
return now.toTimeString().slice(0, 5);
|
|
});
|
|
const [predictionTime, setPredictionTime] = useState('24시간');
|
|
const [substance, setSubstance] = useState('톨루엔 (Toluene)');
|
|
const [releaseType, setReleaseType] = useState<ReleaseType>('연속 유출');
|
|
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 ?? 0, incidentCoord?.lon ?? 0, 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<Promise<void> | 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 (
|
|
<div className="w-80 min-w-[320px] flex flex-col h-full bg-bg-1 border-r border-border overflow-hidden">
|
|
{/* Scrollable Content */}
|
|
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border-light scrollbar-track-transparent bg-bg-0">
|
|
{activeSubTab === 'analysis' && (
|
|
<div className="p-4">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-[14px]">
|
|
<div className="flex items-center gap-2.5">
|
|
<div
|
|
className="w-9 h-9 rounded-md flex items-center justify-center text-[18px]"
|
|
style={{
|
|
background: 'linear-gradient(135deg, rgba(249,115,22,0.15), rgba(168,85,247,0.1))',
|
|
border: '1px solid rgba(249,115,22,0.25)',
|
|
}}
|
|
>🧪</div>
|
|
<div>
|
|
<div className="text-[13px] font-bold text-text-2 font-korean">
|
|
HNS 대기확산 예측
|
|
</div>
|
|
<div className="text-[10px] text-text-3">
|
|
ALOHA/CAMEO 기반 대기확산 시뮬레이션
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Single Column Layout */}
|
|
<div className="flex flex-col gap-3">
|
|
|
|
{/* 사고 기본정보 */}
|
|
<div>
|
|
<div className="text-[13px] font-bold text-text-2 font-korean mb-3 flex items-center gap-1.5">
|
|
📋 사고 기본정보
|
|
</div>
|
|
<div className="flex flex-col gap-[6px]">
|
|
|
|
{/* 사고명 직접 입력 */}
|
|
<input
|
|
className="prd-i w-full"
|
|
value={accidentName}
|
|
onChange={(e) => setAccidentName(e.target.value)}
|
|
placeholder="사고명 직접 입력"
|
|
/>
|
|
|
|
{/* 또는 사고 리스트에서 선택 */}
|
|
<ComboBox
|
|
className="prd-i"
|
|
value={selectedIncidentSn}
|
|
onChange={handleSelectIncident}
|
|
placeholder="또는 사고 리스트에서 선택"
|
|
options={incidents.map(inc => ({
|
|
value: String(inc.acdntSn),
|
|
label: `${inc.acdntNm} (${new Date(inc.occrnDtm).toLocaleDateString('ko-KR')})`,
|
|
}))}
|
|
/>
|
|
|
|
{/* 사고 발생 일시 */}
|
|
<div>
|
|
<label className="text-[10px] text-text-3 block mb-0.5">사고 발생 일시</label>
|
|
<div className="grid grid-cols-2 gap-1">
|
|
<input
|
|
className="prd-i"
|
|
type="date"
|
|
value={accidentDate}
|
|
onChange={(e) => setAccidentDate(e.target.value)}
|
|
/>
|
|
<input
|
|
className="prd-i"
|
|
type="time"
|
|
value={accidentTime}
|
|
onChange={(e) => setAccidentTime(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 좌표 + 지도 버튼 */}
|
|
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 1fr auto' }}>
|
|
<input
|
|
className="prd-i flex-1 font-mono"
|
|
type="number"
|
|
step="0.0001"
|
|
value={incidentCoord?.lat.toFixed(4) ?? ''}
|
|
placeholder="위도"
|
|
onChange={(e) => {
|
|
const lat = parseFloat(e.target.value) || 0;
|
|
onCoordChange({ lon: incidentCoord?.lon ?? 0, lat });
|
|
}}
|
|
/>
|
|
<input
|
|
className="prd-i flex-1 font-mono"
|
|
type="number"
|
|
step="0.0001"
|
|
value={incidentCoord?.lon.toFixed(4) ?? ''}
|
|
placeholder="경도"
|
|
onChange={(e) => {
|
|
const lon = parseFloat(e.target.value) || 0;
|
|
onCoordChange({ lat: incidentCoord?.lat ?? 0, lon });
|
|
}}
|
|
/>
|
|
<button className="prd-map-btn" onClick={onMapSelectClick}>
|
|
📍 지도
|
|
</button>
|
|
</div>
|
|
|
|
{/* DMS 표시 */}
|
|
<div className="text-[9px] text-text-3 font-mono border border-border bg-bg-0"
|
|
style={{ padding: '4px 8px', borderRadius: 'var(--rS)' }}>
|
|
{incidentCoord ? `${toDMS(incidentCoord.lat, 'lat')} / ${toDMS(incidentCoord.lon, 'lon')}` : '지도에서 위치를 선택하세요'}
|
|
</div>
|
|
|
|
{/* 유출형태 + 물질명 */}
|
|
<div className="grid grid-cols-2 gap-1">
|
|
<ComboBox
|
|
className="prd-i"
|
|
value={releaseType}
|
|
onChange={(v) => setReleaseType(v as ReleaseType)}
|
|
options={[
|
|
{ value: '연속 유출', label: '연속' },
|
|
{ value: '순간 유출', label: '순간' },
|
|
{ value: '풀(Pool) 증발', label: '풀 증발' },
|
|
]}
|
|
/>
|
|
<ComboBox
|
|
className="prd-i"
|
|
value={substance}
|
|
onChange={handleSubstanceChange}
|
|
options={[
|
|
{ value: '톨루엔 (Toluene)', label: '톨루엔' },
|
|
{ value: '벤젠 (Benzene)', label: '벤젠' },
|
|
{ value: '자일렌 (Xylene)', label: '자일렌' },
|
|
{ value: '스티렌 (Styrene)', label: '스티렌' },
|
|
{ value: '메탄올 (Methanol)', label: '메탄올' },
|
|
{ value: '아세톤 (Acetone)', label: '아세톤' },
|
|
{ value: '염소 (Chlorine)', label: '염소' },
|
|
{ value: '암모니아 (Ammonia)', label: '암모니아' },
|
|
{ value: '염화수소 (HCl)', label: '염화수소' },
|
|
{ value: '황화수소 (H2S)', label: '황화수소' },
|
|
]}
|
|
/>
|
|
</div>
|
|
|
|
{/* 유출량 + 단위 + 예측시간 */}
|
|
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 65px 1fr' }}>
|
|
<input
|
|
className="prd-i font-mono"
|
|
type="number"
|
|
value={releaseType === '순간 유출' ? totalRelease : emissionRate}
|
|
onChange={(e) => releaseType === '순간 유출' ? setTotalRelease(e.target.value) : setEmissionRate(e.target.value)}
|
|
placeholder={releaseType === '순간 유출' ? 'g' : 'g/s'}
|
|
/>
|
|
<ComboBox
|
|
className="prd-i"
|
|
value={releaseType === '순간 유출' ? 'g' : 'g/s'}
|
|
onChange={() => {}}
|
|
options={
|
|
releaseType === '순간 유출'
|
|
? [{ value: 'g', label: 'g' }, { value: 'kg', label: 'kg' }]
|
|
: [{ value: 'g/s', label: 'g/s' }, { value: 'kg/s', label: 'kg/s' }]
|
|
}
|
|
/>
|
|
<ComboBox
|
|
className="prd-i"
|
|
value={predictionTime}
|
|
onChange={setPredictionTime}
|
|
options={[
|
|
{ value: '6시간', label: '6시간' },
|
|
{ value: '12시간', label: '12시간' },
|
|
{ value: '24시간', label: '24시간' },
|
|
{ value: '48시간', label: '48시간' },
|
|
]}
|
|
/>
|
|
</div>
|
|
|
|
{/* 기상 정보 (자동조회) — 내부적으로 사용, UI 숨김 */}
|
|
|
|
{/* 알고리즘 선택 */}
|
|
<div className="grid grid-cols-2 gap-1">
|
|
<div>
|
|
<label className="text-[10px] text-text-3 block mb-0.5">예측 알고리즘</label>
|
|
<ComboBox
|
|
className="prd-i"
|
|
value={algorithm}
|
|
onChange={setAlgorithm}
|
|
options={[
|
|
{ value: 'ALOHA (EPA)', label: 'ALOHA (EPA)' },
|
|
{ value: 'CAMEO', label: 'CAMEO' },
|
|
{ value: 'Gaussian Plume', label: 'Gaussian Plume' },
|
|
{ value: 'AERMOD', label: 'AERMOD' }
|
|
]}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-[10px] text-text-3 block mb-0.5">확산 등급 기준</label>
|
|
<ComboBox
|
|
className="prd-i"
|
|
value={criteriaModel}
|
|
onChange={setCriteriaModel}
|
|
options={[
|
|
{ value: 'AEGL', label: 'AEGL' },
|
|
{ value: 'ERPG', label: 'ERPG' },
|
|
{ value: 'TEEL', label: 'TEEL' },
|
|
{ value: 'PAC', label: 'PAC' }
|
|
]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 모델 파라미터 & 물질 정보 */}
|
|
<div>
|
|
<div className="text-[13px] font-bold text-text-2 font-korean mb-3 flex items-center gap-1.5">
|
|
🧪 {releaseType === '연속 유출' ? 'Plume' : releaseType === '순간 유출' ? 'Puff' : 'Dense Gas'} 파라미터
|
|
</div>
|
|
<div className="flex flex-col gap-[6px]">
|
|
{/* 모델별 입력 파라미터 */}
|
|
<div
|
|
className="p-[10px] rounded-md"
|
|
style={{ background: 'rgba(6,182,212,0.04)', border: '1px solid rgba(6,182,212,0.15)' }}
|
|
>
|
|
|
|
{/* 연속 유출 (Plume): 배출률, 누출지속시간, 누출높이 */}
|
|
{releaseType === '연속 유출' && (
|
|
<div className="flex flex-col gap-[6px]">
|
|
<div className="grid grid-cols-2 gap-1">
|
|
<div>
|
|
<label className="text-[10px] text-text-3 block mb-0.5">배출률 (g/s)</label>
|
|
<input
|
|
className="prd-i w-full font-mono"
|
|
type="number"
|
|
value={emissionRate}
|
|
onChange={(e) => setEmissionRate(e.target.value)}
|
|
step="0.1"
|
|
min="0"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-[10px] text-text-3 block mb-0.5">지속시간 (s)</label>
|
|
<input
|
|
className="prd-i w-full font-mono"
|
|
type="number"
|
|
value={releaseDuration}
|
|
onChange={(e) => setReleaseDuration(e.target.value)}
|
|
step="10"
|
|
min="1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="text-[10px] text-text-3 block mb-0.5">누출 높이 (m)</label>
|
|
<input
|
|
className="prd-i w-full font-mono"
|
|
type="number"
|
|
value={releaseHeight}
|
|
onChange={(e) => setReleaseHeight(e.target.value)}
|
|
step="0.1"
|
|
min="0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 순간 유출 (Puff): 총 누출량, 누출높이 */}
|
|
{releaseType === '순간 유출' && (
|
|
<div className="flex flex-col gap-[6px]">
|
|
<div>
|
|
<label className="text-[10px] text-text-3 block mb-0.5">총 누출량 (g)</label>
|
|
<input
|
|
className="prd-i w-full font-mono"
|
|
type="number"
|
|
value={totalRelease}
|
|
onChange={(e) => setTotalRelease(e.target.value)}
|
|
step="100"
|
|
min="0"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-[10px] text-text-3 block mb-0.5">누출 높이 (m)</label>
|
|
<input
|
|
className="prd-i w-full font-mono"
|
|
type="number"
|
|
value={releaseHeight}
|
|
onChange={(e) => setReleaseHeight(e.target.value)}
|
|
step="0.1"
|
|
min="0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 풀(Pool) 증발 (Dense Gas): 배출률, 풀반경, 누출높이 */}
|
|
{releaseType === '풀(Pool) 증발' && (
|
|
<div className="flex flex-col gap-[6px]">
|
|
<div className="grid grid-cols-2 gap-1">
|
|
<div>
|
|
<label className="text-[10px] text-text-3 block mb-0.5">배출률 (g/s)</label>
|
|
<input
|
|
className="prd-i w-full font-mono"
|
|
type="number"
|
|
value={emissionRate}
|
|
onChange={(e) => setEmissionRate(e.target.value)}
|
|
step="0.1"
|
|
min="0"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-[10px] text-text-3 block mb-0.5">풀 반경 (m)</label>
|
|
<input
|
|
className="prd-i w-full font-mono"
|
|
type="number"
|
|
value={poolRadius}
|
|
onChange={(e) => setPoolRadius(e.target.value)}
|
|
step="0.5"
|
|
min="0.1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="text-[10px] text-text-3 block mb-0.5">누출 높이 (m)</label>
|
|
<input
|
|
className="prd-i w-full font-mono"
|
|
type="number"
|
|
value={releaseHeight}
|
|
onChange={(e) => setReleaseHeight(e.target.value)}
|
|
step="0.1"
|
|
min="0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 모델 설명 */}
|
|
<div className="text-[9px] text-text-3 mt-1 leading-[1.4]">
|
|
{releaseType === '연속 유출' && '정상상태 연속 배출. 바람 방향으로 플룸이 형성됩니다.'}
|
|
{releaseType === '순간 유출' && '한 번에 전량 방출. 시간에 따라 구름이 이동하며 확산됩니다.'}
|
|
{releaseType === '풀(Pool) 증발' && '고밀도 가스가 지표면을 따라 확산됩니다 (Britter-McQuaid 모델).'}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 물질 위험 특성 */}
|
|
<div
|
|
className="p-2 rounded-sm mt-0.5"
|
|
style={{ background: 'rgba(249,115,22,0.05)', border: '1px solid rgba(249,115,22,0.12)' }}
|
|
>
|
|
<div className="text-[10px] font-bold text-status-orange mb-1">
|
|
⚠ 물질 위험 특성
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-[3px] text-[9px]">
|
|
<div className="flex justify-between">
|
|
<span className="text-text-3">분자량</span>
|
|
<span className="font-mono">{tox.mw} g/mol</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-text-3">가스밀도</span>
|
|
<span className="font-mono">{tox.densityGas} kg/m³</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-text-3">증기압</span>
|
|
<span className="font-mono">{tox.vaporPressure} mmHg</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-text-3">IDLH</span>
|
|
<span className="text-status-red font-semibold font-mono">{tox.idlh} ppm</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* AEGL 등급 범례 */}
|
|
<div
|
|
className="p-2 rounded-sm"
|
|
style={{ background: 'rgba(168,85,247,0.05)', border: '1px solid rgba(168,85,247,0.12)' }}
|
|
>
|
|
<div className="text-[10px] font-bold text-primary-purple mb-1">
|
|
📊 확산 등급 기준 (AEGL)
|
|
</div>
|
|
<div className="flex flex-col gap-0.5 text-[9px]">
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-2 h-2 rounded-[2px]" style={{ background: 'rgba(239,68,68,0.7)' }}></div>
|
|
<span className="text-text-3">AEGL-3 (생명위협) — {tox.aegl3} ppm</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-2 h-2 rounded-[2px]" style={{ background: 'rgba(249,115,22,0.7)' }}></div>
|
|
<span className="text-text-3">AEGL-2 (건강피해) — {tox.aegl2} ppm</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-2 h-2 rounded-[2px]" style={{ background: 'rgba(234,179,8,0.7)' }}></div>
|
|
<span className="text-text-3">AEGL-1 (불쾌감) — {tox.aegl1} ppm</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 실행 버튼 */}
|
|
<div className="flex flex-col gap-1 mt-2">
|
|
<button
|
|
className="prd-btn pri"
|
|
style={{ padding: '7px', fontSize: '11px' }}
|
|
onClick={onRunPrediction}
|
|
disabled={isRunningPrediction}
|
|
>
|
|
{isRunningPrediction ? '⏳ 실행 중...' : '🧪 대기확산 예측 실행'}
|
|
</button>
|
|
<button
|
|
className="prd-btn sec"
|
|
style={{ padding: '7px', fontSize: '11px' }}
|
|
onClick={handleReset}
|
|
>
|
|
초기화
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeSubTab === 'list' && (
|
|
<div className="p-4">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-2.5 mb-[14px]">
|
|
<div
|
|
className="w-9 h-9 rounded-md flex items-center justify-center text-[18px]"
|
|
style={{
|
|
background: 'linear-gradient(135deg, rgba(6,182,212,0.15), rgba(168,85,247,0.1))',
|
|
border: '1px solid rgba(6,182,212,0.25)',
|
|
}}
|
|
>📋</div>
|
|
<div>
|
|
<div className="text-[13px] font-bold text-text-2 font-korean">
|
|
분석 목록
|
|
</div>
|
|
<div className="text-[10px] text-text-3">
|
|
저장된 대기확산 예측 결과
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 필터 섹션 */}
|
|
<div className="bg-bg-3 border border-border rounded-md p-[14px] mb-3">
|
|
<div className="text-[13px] font-bold text-text-2 font-korean mb-3 flex items-center gap-1.5">
|
|
🔍 필터
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
{/* 기간 선택 */}
|
|
<div>
|
|
<label className="text-[10px] text-text-3 block mb-0.5">기간</label>
|
|
<ComboBox
|
|
value="최근 7일"
|
|
onChange={() => {}}
|
|
options={[
|
|
{ value: '오늘', label: '오늘' },
|
|
{ value: '최근 7일', label: '최근 7일' },
|
|
{ value: '최근 30일', label: '최근 30일' },
|
|
{ value: '전체', label: '전체' }
|
|
]}
|
|
/>
|
|
</div>
|
|
|
|
{/* 물질 분류 */}
|
|
<div>
|
|
<label className="text-[10px] text-text-3 block mb-0.5">물질 분류</label>
|
|
<ComboBox
|
|
value="전체"
|
|
onChange={() => {}}
|
|
options={[
|
|
{ value: '전체', label: '전체' },
|
|
{ value: '유독성 액체', label: '유독성 액체' },
|
|
{ value: '유독성 기체', label: '유독성 기체' },
|
|
{ value: '인화성 액체', label: '인화성 액체' },
|
|
{ value: '인화성 기체', label: '인화성 기체' }
|
|
]}
|
|
/>
|
|
</div>
|
|
|
|
{/* 위험도 */}
|
|
<div>
|
|
<label className="text-[10px] text-text-3 block mb-0.5">위험도</label>
|
|
<ComboBox
|
|
value="전체"
|
|
onChange={() => {}}
|
|
options={[
|
|
{ value: '전체', label: '전체' },
|
|
{ value: 'AEGL-3', label: 'AEGL-3' },
|
|
{ value: 'AEGL-2', label: 'AEGL-2' },
|
|
{ value: 'AEGL-1', label: 'AEGL-1' }
|
|
]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 통계 요약 */}
|
|
<div className="bg-bg-3 border border-border rounded-md p-[14px]">
|
|
<div className="text-[13px] font-bold text-text-2 font-korean mb-3 flex items-center gap-1.5">
|
|
📊 통계
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex justify-between items-center p-2 bg-bg-0 rounded">
|
|
<span className="text-[10px] text-text-3">전체 분석</span>
|
|
<span className="text-sm font-bold text-primary-cyan font-mono">8건</span>
|
|
</div>
|
|
<div className="flex justify-between items-center p-2 bg-bg-0 rounded">
|
|
<span className="text-[10px] text-text-3">고위험 (AEGL-3)</span>
|
|
<span className="text-sm font-bold text-status-red font-mono">3건</span>
|
|
</div>
|
|
<div className="flex justify-between items-center p-2 bg-bg-0 rounded">
|
|
<span className="text-[10px] text-text-3">중위험 (AEGL-2)</span>
|
|
<span className="text-sm font-bold text-status-orange font-mono">5건</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|