import { useState, useEffect, useCallback, useRef } from 'react'; import { HNSLeftPanel } from './HNSLeftPanel'; import type { HNSInputParams } from './HNSLeftPanel'; import { HNSRightPanel } from './HNSRightPanel'; import { MapView } from '@common/components/map/MapView'; import { HNSAnalysisListTable } from './HNSAnalysisListTable'; import { HNSTheoryView } from './HNSTheoryView'; import { HNSSubstanceView } from './HNSSubstanceView'; import { HNSScenarioView } from './HNSScenarioView'; import { HNSRecalcModal } from './HNSRecalcModal'; import type { RecalcParams } from './HNSRecalcModal'; import { useSubMenu, navigateToTab, setReportGenCategory, setHnsReportPayload } from '@common/hooks/useSubMenu'; import { windDirToCompass } from '../hooks/useWeatherFetch'; import { useAuthStore } from '@common/store/authStore'; import { createHnsAnalysis, saveHnsAnalysis, fetchHnsAnalysis } from '../services/hnsApi'; import { computeDispersion } from '../utils/dispersionEngine'; import { getSubstanceToxicity } from '../utils/toxicityData'; import type { DispersionPoint, DispersionGridResult, DispersionModel, MeteoParams, SourceParams, SimParams, AlgorithmType, } from '../utils/dispersionTypes'; /* ─── HNS 매뉴얼 뷰어 컴포넌트 ─── */ function HNSManualViewer() { const card = 'rounded-md p-4 mb-3' return (
{/* 헤더 */}
📖 해양 HNS 대응 매뉴얼
Marine HNS Response Manual — Bonn Agreement / HELCOM / REMPEC (WestMOPoCo 2024 한국어판)
{/* 목차 카드 그리드 */}
{[ { icon: '📘', title: '1. 서론', desc: 'HNS 정의 · OPRC-HNS 의정서 · HNS 협약 범위 및 목적', color: 'var(--cyan)' }, { icon: '⚖️', title: '2. IMO 협약·의정서·규칙', desc: 'SOLAS · MARPOL · IBC Code · IMDG Code · IGC Code', color: 'var(--cyan)' }, { icon: '🔬', title: '3. HNS 거동 및 유해요소', desc: 'SEBC 거동분류 · MSDS · GESAMP · 물리화학적 특성', color: 'var(--purple)' }, { icon: '🛡️', title: '4. 대비', desc: '위험 평가 · 비상 계획 · 교육훈련 · 장비 비축', color: 'var(--orange)' }, { icon: '🚨', title: '5. 대응', desc: '최초 조치 · 안전구역 · PPE · 모니터링 · 대응 기술', color: 'var(--red)' }, { icon: '🔄', title: '6. 유출 후 관리', desc: '비용 문서화 · 환경 회복 · 사고 검토 · 교훈', color: 'var(--cyan)' }, { icon: '📋', title: '7. 사례연구', desc: '실제 HNS 해양사고 사례 분석 및 교훈', color: 'var(--cyan)' }, { icon: '📊', title: '8. 자료표', desc: '물질별 데이터시트 · AEGL · 노출 한계값', color: 'var(--cyan)' }, ].map(ch => (
{ch.icon}
{ch.title}
{ch.desc}
))}
{/* SEBC 거동 분류 */}
SEBC 거동 분류 (Standard European Behaviour Classification)
물질의 물리적·화학적 특성(용해도, 밀도, 증기압, 점도)에 따라 이론적 거동을 5가지 주요 범주 + 7가지 하위 범주로 분류
{[ { icon: '💨', label: 'G — 가스', desc: '대기 중 확산\n증기압 > 101.3kPa\n예: 암모니아, 염소', color: 'rgba(139,92,246' }, { icon: '🌫️', label: 'E — 증발', desc: '수면→대기 증발\n증기압 > 3kPa\n예: 벤젠, 톨루엔', color: 'rgba(249,115,22' }, { icon: '🟡', label: 'F — 부유', desc: '해수면에 부유\n밀도 < 1.025\n예: 스티렌, 크실렌', color: 'rgba(251,191,36' }, { icon: '💧', label: 'D — 용해', desc: '해수에 용해\n용해도 > 5%\n예: 메탄올, 염산', color: 'rgba(6,182,212' }, { icon: '⬇️', label: 'S — 침강', desc: '해저로 침강\n밀도 > 1.025\n예: EDC, 사염화탄소', color: 'rgba(139,148,158' }, ].map(s => (
{s.icon}
{s.label}
{s.desc}
))}
{/* 출처 */}
출처: Marine HNS Response Manual — Bonn Agreement / HELCOM / REMPEC (WestMOPoCo Project, 2024 한국어판)
번역: 원해민, 이시연, 양보경, 강성길, 이성엽 — KRISO 선박해양플랜트연구소 / NOWPAP MERRAC
원본: Alcaro L., Brandt J., Giraud W., Mannozzi M., Nicolas-Kopec A. (2021) ISBN: 978-2-87893-147-1
) } /* ─── 시간 슬라이더 컴포넌트 ─── */ function DispersionTimeSlider({ currentFrame, totalFrames, isPlaying, onFrameChange, onPlayPause, dt, }: { currentFrame: number; totalFrames: number; isPlaying: boolean; onFrameChange: (frame: number) => void; onPlayPause: () => void; dt: number; }) { const currentTime = (currentFrame + 1) * dt; const endTime = totalFrames * dt; return (
t = {currentTime}s {endTime}s
onFrameChange(parseInt(e.target.value))} className="w-full h-1 accent-[var(--cyan)]" style={{ cursor: 'pointer' }} />
{currentFrame + 1}/{totalFrames}
); } /* ─── 메인 HNSView ─── */ export function HNSView() { const { activeSubTab, setActiveSubTab } = useSubMenu('hns'); const { user } = useAuthStore(); const [incidentCoord, setIncidentCoord] = useState({ lon: 129.3542, lat: 35.4215 }); const [isSelectingLocation, setIsSelectingLocation] = useState(false); const [isRunningPrediction, setIsRunningPrediction] = useState(false); // eslint-disable-next-line @typescript-eslint/no-explicit-any const [dispersionResult, setDispersionResult] = useState(null); const [recalcModalOpen, setRecalcModalOpen] = useState(false); // 확산 엔진 결과 const [heatmapData, setHeatmapData] = useState([]); const [computedResult, setComputedResult] = useState(null); const [allTimeFrames, setAllTimeFrames] = useState([]); const [currentFrame, setCurrentFrame] = useState(0); const [isPuffPlaying, setIsPuffPlaying] = useState(false); // 좌측 패널 입력 파라미터 (state로 관리하여 변경 시 재계산 트리거) const [inputParams, setInputParams] = useState(null); const [loadedParams, setLoadedParams] = useState | null>(null); const hasRunOnce = useRef(false); // 최초 실행 여부 const mapCaptureRef = useRef<(() => string | null) | null>(null); const handleReset = useCallback(() => { setDispersionResult(null); setHeatmapData([]); setComputedResult(null); setAllTimeFrames([]); setCurrentFrame(0); setIsPuffPlaying(false); setInputParams(null); hasRunOnce.current = false; }, []); const handleParamsChange = useCallback((params: HNSInputParams) => { setInputParams(params); }, []); const handleMapClick = (lon: number, lat: number) => { if (isSelectingLocation) { setIncidentCoord({ lon, lat }); setIsSelectingLocation(false); } }; // 시간 애니메이션 (puff/dense_gas) useEffect(() => { if (!isPuffPlaying || allTimeFrames.length === 0) return; const interval = setInterval(() => { setCurrentFrame(prev => { const next = prev + 1; if (next >= allTimeFrames.length) { setIsPuffPlaying(false); return prev; } setHeatmapData(allTimeFrames[next].points); setComputedResult(allTimeFrames[next]); return next; }); }, 300); return () => clearInterval(interval); }, [isPuffPlaying, allTimeFrames]); const handleFrameChange = (frame: number) => { setCurrentFrame(frame); if (allTimeFrames[frame]) { setHeatmapData(allTimeFrames[frame].points); setComputedResult(allTimeFrames[frame]); } }; /** 확산 계산 핵심 로직 (버튼 클릭 + 파라미터 변경 시 공유) */ const runComputation = useCallback((params: HNSInputParams | null, coord: { lon: number; lat: number }) => { const substanceName = params?.substance || '톨루엔 (Toluene)'; const tox = getSubstanceToxicity(substanceName); const weather = params?.weather; const meteo: MeteoParams = { windSpeed: weather?.windSpeed ?? 5.0, windDirDeg: weather?.windDirection ?? 270, stability: weather?.stability ?? 'D', temperature: ((weather?.temperature ?? 15) + 273.15), pressure: 101325, mixingHeight: 800, }; const releaseType = params?.releaseType || '연속 유출'; const source: SourceParams = { Q: params?.emissionRate ?? tox.Q, QTotal: params?.totalRelease ?? tox.QTotal, x0: 0, y0: 0, z0: params?.releaseHeight ?? 0.5, releaseDuration: releaseType === '연속 유출' ? (params?.releaseDuration ?? 300) : 0, molecularWeight: tox.mw, vaporPressure: tox.vaporPressure, densityGas: tox.densityGas, poolRadius: params?.poolRadius ?? tox.poolRadius, }; const sim: SimParams = { xRange: [-100, 10000], yRange: [-2000, 2000], nx: 300, ny: 200, zRef: 1.5, tStart: 0, tEnd: 600, dt: 30, }; const modelType: DispersionModel = releaseType === '연속 유출' ? 'plume' : releaseType === '순간 유출' ? 'puff' : 'dense_gas'; const algo = (params?.algorithm || 'ALOHA (EPA)') as AlgorithmType; if (modelType === 'plume') { const result = computeDispersion({ meteo, source, sim, modelType, originLon: coord.lon, originLat: coord.lat, substanceName, t: sim.dt, algorithm: algo, }); console.log('[HNS] plume 계산 완료:', result.points.length, '포인트, maxConc:', result.maxConcentration.toFixed(2), 'ppm'); setComputedResult(result); setHeatmapData(result.points); setAllTimeFrames([result]); setCurrentFrame(0); setIsPuffPlaying(false); } else { const times: number[] = []; for (let t = sim.dt; t <= sim.tEnd; t += sim.dt) times.push(t); const frames = times.map(t => computeDispersion({ meteo, source, sim, modelType, originLon: coord.lon, originLat: coord.lat, substanceName, t, algorithm: algo, })); console.log('[HNS]', modelType, '계산 완료:', frames.length, '프레임, 첫 프레임:', frames[0].points.length, '포인트'); setAllTimeFrames(frames); setComputedResult(frames[0]); setHeatmapData(frames[0].points); setCurrentFrame(0); setIsPuffPlaying(true); } // AEGL 구역 (MapView 레거시 호환) const resultForZones = modelType === 'plume' ? computeDispersion({ meteo, source, sim, modelType, originLon: coord.lon, originLat: coord.lat, substanceName, t: sim.dt, algorithm: algo, }) : computeDispersion({ meteo, source, sim, modelType, originLon: coord.lon, originLat: coord.lat, substanceName, t: sim.dt * 5, algorithm: algo, }); return { tox, meteo, resultForZones, substanceName }; }, []); const handleRunPrediction = async (paramsOverride?: HNSInputParams | null) => { setIsRunningPrediction(true); try { const params = paramsOverride ?? inputParams; // 1. 계산 먼저 실행 (동기, 히트맵 즉시 표시) const { tox, meteo, resultForZones, substanceName } = runComputation(params, incidentCoord); hasRunOnce.current = true; setDispersionResult({ hnsAnlysSn: 0, zones: [ { level: 'AEGL-3', color: '#ef4444', radius: resultForZones.aeglDistances.aegl3, angle: meteo.windDirDeg }, { level: 'AEGL-2', color: '#f97316', radius: resultForZones.aeglDistances.aegl2, angle: meteo.windDirDeg }, { level: 'AEGL-1', color: '#eab308', radius: resultForZones.aeglDistances.aegl1, angle: meteo.windDirDeg }, ].filter(z => z.radius > 0), timestamp: new Date().toISOString(), windDirection: meteo.windDirDeg, substance: substanceName, concentration: { 'AEGL-3': `${tox.aegl3} ppm`, 'AEGL-2': `${tox.aegl2} ppm`, 'AEGL-1': `${tox.aegl1} ppm`, }, maxConcentration: resultForZones.maxConcentration, }); // 2. 분석 레코드 DB 저장 (비동기, 실패해도 무시) try { const acdntDtm = params?.accidentDate && params?.accidentTime ? `${params.accidentDate}T${params.accidentTime}:00` : params?.accidentDate || undefined; const result = await createHnsAnalysis({ anlysNm: params?.accidentName || `HNS 분석 ${new Date().toLocaleDateString('ko-KR')}`, acdntDtm, lon: incidentCoord.lon, lat: incidentCoord.lat, locNm: `${incidentCoord.lat.toFixed(4)} / ${incidentCoord.lon.toFixed(4)}`, sbstNm: params?.substance, windSpd: params?.weather?.windSpeed, windDir: params?.weather?.windDirection != null ? String(params.weather.windDirection) : undefined, temp: params?.weather?.temperature, humid: params?.weather?.humidity, atmStblCd: params?.weather?.stability, analystNm: user?.name || undefined, }); // DB 저장 성공 시 SN 업데이트 setDispersionResult((prev: Record | null) => prev ? { ...prev, hnsAnlysSn: result.hnsAnlysSn } : prev); } catch { // API 실패 시 무시 (히트맵은 이미 표시됨) } } catch (error) { console.error('대기확산 예측 오류:', error); alert('대기확산 예측 중 오류가 발생했습니다.'); } finally { setIsRunningPrediction(false); } }; /** 분석 결과 저장 */ const handleSave = async () => { if (!dispersionResult || !inputParams || !computedResult) { alert('저장할 분석 결과가 없습니다. 먼저 예측을 실행해주세요.'); return; } const rsltData: Record = { inputParams: { substance: inputParams.substance, releaseType: inputParams.releaseType, emissionRate: inputParams.emissionRate, totalRelease: inputParams.totalRelease, releaseHeight: inputParams.releaseHeight, releaseDuration: inputParams.releaseDuration, poolRadius: inputParams.poolRadius, algorithm: inputParams.algorithm, criteriaModel: inputParams.criteriaModel, accidentDate: inputParams.accidentDate, accidentTime: inputParams.accidentTime, predictionTime: inputParams.predictionTime, accidentName: inputParams.accidentName, }, coord: { lon: incidentCoord.lon, lat: incidentCoord.lat }, zones: dispersionResult.zones, aeglDistances: computedResult.aeglDistances, aeglAreas: computedResult.aeglAreas, maxConcentration: computedResult.maxConcentration, modelType: computedResult.modelType, weather: { windSpeed: inputParams.weather.windSpeed, windDirection: inputParams.weather.windDirection, temperature: inputParams.weather.temperature, humidity: inputParams.weather.humidity, stability: inputParams.weather.stability, }, aegl3: (computedResult.aeglDistances?.aegl3 ?? 0) > 0, aegl2: (computedResult.aeglDistances?.aegl2 ?? 0) > 0, aegl1: (computedResult.aeglDistances?.aegl1 ?? 0) > 0, damageRadius: `${((computedResult.aeglDistances?.aegl1 ?? 0) / 1000).toFixed(1)} km`, }; // 위험등급 자동 판정 let riskCd = 'LOW'; if ((computedResult.aeglDistances?.aegl3 ?? 0) > 0) riskCd = 'CRITICAL'; else if ((computedResult.aeglDistances?.aegl2 ?? 0) > 0) riskCd = 'HIGH'; else if ((computedResult.aeglDistances?.aegl1 ?? 0) > 0) riskCd = 'MEDIUM'; // DB 저장 시도 let savedToDb = false; try { let sn = dispersionResult.hnsAnlysSn as number; if (!sn) { const fcstHrNum = parseInt(inputParams.predictionTime) || 24; const spilQtyVal = inputParams.releaseType === '순간 유출' ? inputParams.totalRelease : inputParams.emissionRate; const anlysNm = inputParams.accidentName || `HNS 분석 ${new Date().toLocaleDateString('ko-KR')}`; const saveAcdntDtm = inputParams.accidentDate && inputParams.accidentTime ? `${inputParams.accidentDate}T${inputParams.accidentTime}:00` : inputParams.accidentDate || undefined; const created = await createHnsAnalysis({ anlysNm, acdntDtm: saveAcdntDtm, lon: incidentCoord.lon, lat: incidentCoord.lat, locNm: `${incidentCoord.lat.toFixed(4)} / ${incidentCoord.lon.toFixed(4)}`, sbstNm: inputParams.substance, spilQty: spilQtyVal, spilUnitCd: inputParams.releaseType === '순간 유출' ? 'g' : 'g/s', fcstHr: fcstHrNum, windSpd: inputParams.weather.windSpeed, windDir: String(inputParams.weather.windDirection), algoCd: inputParams.algorithm, critMdlCd: inputParams.criteriaModel, analystNm: user?.name || undefined, }); sn = created.hnsAnlysSn; setDispersionResult((prev: Record | null) => prev ? { ...prev, hnsAnlysSn: sn } : prev); } await saveHnsAnalysis(sn, { rsltData, execSttsCd: 'COMPLETED', riskCd }); savedToDb = true; } catch { // DB 저장 실패 — localStorage fallback } // localStorage에도 저장 (DB 실패 시 fallback, 성공 시 백업) try { const localKey = 'hns_saved_analyses'; const existing: Record[] = JSON.parse(localStorage.getItem(localKey) || '[]'); const fcstHrLocal = parseInt(inputParams.predictionTime) || 24; const spilQtyLocal = inputParams.releaseType === '순간 유출' ? inputParams.totalRelease : inputParams.emissionRate; const entry = { id: Date.now(), anlysNm: inputParams.accidentName || `HNS 분석 ${new Date().toLocaleDateString('ko-KR')}`, acdntDtm: inputParams.accidentDate && inputParams.accidentTime ? `${inputParams.accidentDate}T${inputParams.accidentTime}:00` : inputParams.accidentDate || null, sbstNm: inputParams.substance, analystNm: user?.name || null, spilQty: spilQtyLocal, spilUnitCd: inputParams.releaseType === '순간 유출' ? 'g' : 'g/s', fcstHr: fcstHrLocal, regDtm: new Date().toISOString(), riskCd, rsltData, }; existing.unshift(entry); // 최대 50건 유지 if (existing.length > 50) existing.length = 50; localStorage.setItem(localKey, JSON.stringify(existing)); } catch { // localStorage 저장 실패 무시 } alert(savedToDb ? '분석 결과가 저장되었습니다.' : '분석 결과가 로컬에 저장되었습니다. (서버 연결 실패)'); }; /** 분석 목록에서 불러오기 (DB 또는 localStorage) */ const handleLoadAnalysis = async (sn: number, localRsltData?: Record) => { try { let rslt: Record; if (localRsltData) { // localStorage에서 직접 전달된 데이터 rslt = localRsltData; } else { // DB에서 조회 const analysis = await fetchHnsAnalysis(sn); if (!analysis.rsltData) { alert('저장된 분석 결과가 없습니다.'); return; } rslt = analysis.rsltData as Record; } const savedParams = rslt.inputParams as Record | undefined; const savedCoord = rslt.coord as { lon: number; lat: number } | undefined; // 좌표 복원 if (savedCoord) { setIncidentCoord(savedCoord); } // 입력 파라미터 복원 → HNSLeftPanel에 전달 if (savedParams) { setLoadedParams({ substance: savedParams.substance as string, releaseType: savedParams.releaseType as HNSInputParams['releaseType'], emissionRate: savedParams.emissionRate as number, totalRelease: savedParams.totalRelease as number, releaseHeight: savedParams.releaseHeight as number, releaseDuration: savedParams.releaseDuration as number, poolRadius: savedParams.poolRadius as number, algorithm: savedParams.algorithm as string, criteriaModel: savedParams.criteriaModel as string, accidentDate: savedParams.accidentDate as string, accidentTime: savedParams.accidentTime as string, predictionTime: savedParams.predictionTime as string, accidentName: savedParams.accidentName as string, }); } // 탭 전환 → analysis setActiveSubTab('analysis'); // 약간의 딜레이 후 계산 실행 (loadedParams → inputParams 동기화 대기) setTimeout(() => { if (savedParams && savedCoord) { const savedWeather = rslt.weather as Record | undefined; const params: HNSInputParams = { substance: (savedParams.substance as string) || '톨루엔 (Toluene)', releaseType: (savedParams.releaseType as HNSInputParams['releaseType']) || '연속 유출', emissionRate: (savedParams.emissionRate as number) || 10, totalRelease: (savedParams.totalRelease as number) || 5000, releaseHeight: (savedParams.releaseHeight as number) || 0.5, releaseDuration: (savedParams.releaseDuration as number) || 300, poolRadius: (savedParams.poolRadius as number) || 5, algorithm: (savedParams.algorithm as string) || 'ALOHA (EPA)', criteriaModel: (savedParams.criteriaModel as string) || 'AEGL', accidentDate: (savedParams.accidentDate as string) || '', accidentTime: (savedParams.accidentTime as string) || '', predictionTime: (savedParams.predictionTime as string) || '24시간', accidentName: (savedParams.accidentName as string) || '', weather: { windSpeed: (savedWeather?.windSpeed as number) ?? 5.0, windDirection: (savedWeather?.windDirection as number) ?? 270, temperature: (savedWeather?.temperature as number) ?? 15, humidity: (savedWeather?.humidity as number) ?? 60, stability: (savedWeather?.stability as string ?? 'D') as HNSInputParams['weather']['stability'], isLoading: false, error: null, lastUpdate: null, }, }; setInputParams(params); try { const { tox, meteo, resultForZones, substanceName } = runComputation(params, savedCoord); hasRunOnce.current = true; setDispersionResult({ hnsAnlysSn: sn, zones: [ { level: 'AEGL-3', color: 'rgba(239,68,68,0.4)', radius: resultForZones.aeglDistances.aegl3, angle: meteo.windDirDeg }, { level: 'AEGL-2', color: 'rgba(249,115,22,0.3)', radius: resultForZones.aeglDistances.aegl2, angle: meteo.windDirDeg }, { level: 'AEGL-1', color: 'rgba(234,179,8,0.25)', radius: resultForZones.aeglDistances.aegl1, angle: meteo.windDirDeg }, ].filter((z: { radius: number }) => z.radius > 0), timestamp: new Date().toISOString(), windDirection: meteo.windDirDeg, substance: substanceName, concentration: { 'AEGL-3': `${tox.aegl3} ppm`, 'AEGL-2': `${tox.aegl2} ppm`, 'AEGL-1': `${tox.aegl1} ppm`, }, maxConcentration: resultForZones.maxConcentration, }); } catch { // 재계산 실패 시 무시 } } }, 200); } catch { alert('분석 불러오기에 실패했습니다.'); } }; /** 보고서 생성 — 실 데이터 수집 + 지도 캡처 후 탭 이동 */ const handleOpenReport = () => { try { let mapImage: string | null = null; try { mapImage = mapCaptureRef.current?.() ?? null; } catch { /* canvas capture 실패 무시 */ } const tox = getSubstanceToxicity(inputParams?.substance || ''); const modelType = computedResult?.modelType; const modelLabel = modelType === 'plume' ? 'Gaussian Plume' : modelType === 'puff' ? 'Gaussian Puff' : modelType === 'dense_gas' ? 'Dense Gas (B-M)' : 'ALOHA'; setHnsReportPayload({ mapImageDataUrl: mapImage, substance: { name: inputParams?.substance || '—', toxicity: `AEGL-2: ${tox.aegl2} ppm / AEGL-3: ${tox.aegl3} ppm`, }, hazard: { aegl3: `${((computedResult?.aeglDistances.aegl3 ?? 0) / 1000).toFixed(1)} km`, aegl2: `${((computedResult?.aeglDistances.aegl2 ?? 0) / 1000).toFixed(1)} km`, aegl1: `${((computedResult?.aeglDistances.aegl1 ?? 0) / 1000).toFixed(1)} km`, }, atm: { model: modelLabel, maxDistance: `${((computedResult?.aeglDistances.aegl1 ?? 0) / 1000).toFixed(1)} km`, }, weather: { windDir: `${windDirToCompass(inputParams?.weather?.windDirection ?? 0)} ${inputParams?.weather?.windDirection ?? 0}°`, windSpeed: `${inputParams?.weather?.windSpeed ?? 0} m/s`, stability: `${inputParams?.weather?.stability ?? 'D'}`, temperature: `${inputParams?.weather?.temperature ?? 0}°C`, }, maxConcentration: `${(computedResult?.maxConcentration ?? 0).toFixed(1)} ppm`, aeglAreas: { aegl1: `${(computedResult?.aeglAreas.aegl1 ?? 0).toFixed(2)} km²`, aegl2: `${(computedResult?.aeglAreas.aegl2 ?? 0).toFixed(2)} km²`, aegl3: `${(computedResult?.aeglAreas.aegl3 ?? 0).toFixed(2)} km²`, }, }); } catch (err) { console.error('[HNS] 보고서 데이터 수집 오류:', err); } setReportGenCategory(1); navigateToTab('reports', 'generate'); }; if (activeSubTab === 'scenario') { return ; } if (activeSubTab === 'manual') { return ; } if (activeSubTab === 'theory') { return ; } if (activeSubTab === 'substance') { return ; } return (
{/* Left Panel - 분석 목록일 때는 숨김 */} {activeSubTab === 'analysis' && ( setIsSelectingLocation(true)} onRunPrediction={handleRunPrediction} isRunningPrediction={isRunningPrediction} onParamsChange={handleParamsChange} onReset={handleReset} loadedParams={loadedParams} /> )} {/* Center - Map/Content Area */}
{activeSubTab === 'list' ? ( setActiveSubTab(typeof v === 'function' ? v(activeSubTab as 'analysis' | 'list') : v)} onSelectAnalysis={handleLoadAnalysis} /> ) : ( <> {/* 시간 슬라이더 (puff/dense_gas 모델용) */} {allTimeFrames.length > 1 && ( setIsPuffPlaying(!isPuffPlaying)} dt={30} /> )} )}
{/* Right Panel - 분석 목록일 때는 숨김 */} {activeSubTab === 'analysis' && ( setRecalcModalOpen(true)} onOpenReport={handleOpenReport} onSave={handleSave} /> )} {/* HNS 재계산 모달 */} setRecalcModalOpen(false)} currentParams={inputParams ? { substance: inputParams.substance, releaseType: inputParams.releaseType, emissionRate: inputParams.emissionRate, totalRelease: inputParams.totalRelease, algorithm: inputParams.algorithm, predictionTime: inputParams.predictionTime, } : null} onSubmit={(recalcP: RecalcParams) => { // 재계산 파라미터를 현재 inputParams에 병합하여 즉시 실행 const merged: HNSInputParams = { ...(inputParams as HNSInputParams), substance: recalcP.substance, releaseType: recalcP.releaseType, emissionRate: recalcP.emissionRate, totalRelease: recalcP.totalRelease, algorithm: recalcP.algorithm, predictionTime: recalcP.predictionTime, }; // 좌측 패널 UI도 동기화 setLoadedParams(merged); setInputParams(merged); // 병합된 파라미터로 즉시 예측 실행 handleRunPrediction(merged); }} />
); }