wing-ops/frontend/src/tabs/hns/components/HNSView.tsx
leedano 3743027ce7 feat(weather): 기상 정보 기상 레이어 업데이트 (#78)
## 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>
2026-03-11 11:14:25 +09:00

772 lines
32 KiB
TypeScript
Executable File

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 (
<div className="flex-1 overflow-y-auto bg-bg-0" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
<div className="px-5 py-4 max-w-[1200px] mx-auto">
{/* 헤더 */}
<div className="flex items-center justify-between mb-4">
<div>
<div className="text-base font-bold">📖 HNS </div>
<div className="text-[10px] text-text-3 mt-0.5">Marine HNS Response Manual Bonn Agreement / HELCOM / REMPEC (WestMOPoCo 2024 )</div>
</div>
</div>
{/* 목차 카드 그리드 */}
<div className="grid mb-5" style={{ gridTemplateColumns: 'repeat(4,1fr)', gap: '10px' }}>
{[
{ 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 => (
<div key={ch.title} className={card} style={{ background: 'var(--bg3)', border: '1px solid var(--bd)', cursor: 'pointer', transition: '.2s' }}>
<div className="text-[20px] mb-1.5">{ch.icon}</div>
<div className="text-[11px] font-bold">{ch.title}</div>
<div className="text-[9px] text-text-3 mt-1 leading-[1.4]">{ch.desc}</div>
</div>
))}
</div>
{/* SEBC 거동 분류 */}
<div className={`${card} bg-bg-3 border border-border`}>
<div className="text-[13px] font-bold mb-2.5">SEBC (Standard European Behaviour Classification)</div>
<div className="text-[9px] text-text-3 mb-2.5 leading-normal"> · (, , , ) 5 + 7 </div>
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(5,1fr)' }}>
{[
{ 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 => (
<div key={s.label} className="text-center px-[6px] py-[10px] rounded-sm" style={{ background: `${s.color},.08)`, border: `1px solid ${s.color},.2)` }}>
<div className="text-[22px] mb-1">{s.icon}</div>
<div className="text-[11px] font-bold" style={{ color: `${s.color},1)` }}>{s.label}</div>
<div className="text-text-3 whitespace-pre-line text-[8px] mt-[3px] leading-[1.3]">{s.desc}</div>
</div>
))}
</div>
</div>
{/* 출처 */}
<div className="text-text-3 rounded-sm bg-bg-3 p-[10px] text-[8px] leading-[1.5]">
<b>:</b> Marine HNS Response Manual Bonn Agreement / HELCOM / REMPEC (WestMOPoCo Project, 2024 )<br />
번역: 원해민, , , , KRISO / NOWPAP MERRAC<br />
원본: Alcaro L., Brandt J., Giraud W., Mannozzi M., Nicolas-Kopec A. (2021) ISBN: 978-2-87893-147-1
</div>
</div>
</div>
)
}
/* ─── 시간 슬라이더 컴포넌트 ─── */
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 (
<div
className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-3 px-4 py-2.5 rounded-lg"
style={{
background: 'rgba(10,22,40,0.92)',
border: '1px solid rgba(6,182,212,0.25)',
backdropFilter: 'blur(8px)',
minWidth: '360px',
}}
>
<button
onClick={onPlayPause}
className="flex items-center justify-center w-7 h-7 rounded-full text-[14px]"
style={{
background: isPlaying ? 'rgba(239,68,68,0.2)' : 'rgba(6,182,212,0.2)',
border: `1px solid ${isPlaying ? 'rgba(239,68,68,0.4)' : 'rgba(6,182,212,0.4)'}`,
cursor: 'pointer',
}}
>
{isPlaying ? '⏸' : '▶'}
</button>
<div className="flex-1 flex flex-col gap-1">
<div className="flex items-center justify-between text-[9px]">
<span className="text-primary-cyan font-mono font-bold">t = {currentTime}s</span>
<span className="text-text-3 font-mono">{endTime}s</span>
</div>
<input
type="range"
min={0}
max={totalFrames - 1}
value={currentFrame}
onChange={(e) => onFrameChange(parseInt(e.target.value))}
className="w-full h-1 accent-[var(--cyan)]"
style={{ cursor: 'pointer' }}
/>
</div>
<div className="text-[9px] text-text-3 font-mono whitespace-nowrap">
{currentFrame + 1}/{totalFrames}
</div>
</div>
);
}
/* ─── 메인 HNSView ─── */
export function HNSView() {
const { activeSubTab, setActiveSubTab } = useSubMenu('hns');
const { user } = useAuthStore();
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null);
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
const [isRunningPrediction, setIsRunningPrediction] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [dispersionResult, setDispersionResult] = useState<any>(null);
const [recalcModalOpen, setRecalcModalOpen] = useState(false);
// 확산 엔진 결과
const [heatmapData, setHeatmapData] = useState<DispersionPoint[]>([]);
const [computedResult, setComputedResult] = useState<DispersionGridResult | null>(null);
const [allTimeFrames, setAllTimeFrames] = useState<DispersionGridResult[]>([]);
const [currentFrame, setCurrentFrame] = useState(0);
const [isPuffPlaying, setIsPuffPlaying] = useState(false);
// 좌측 패널 입력 파라미터 (state로 관리하여 변경 시 재계산 트리거)
const [inputParams, setInputParams] = useState<HNSInputParams | null>(null);
const [loadedParams, setLoadedParams] = useState<Partial<HNSInputParams> | 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);
setIncidentCoord(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;
if (!incidentCoord) {
alert('사고 지점을 먼저 지도에서 선택하세요.');
setIsRunningPrediction(false);
return;
}
// 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<string, unknown> | 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<string, unknown> = {
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<string, unknown> | 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<string, unknown>[] = 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<string, unknown>) => {
try {
let rslt: Record<string, unknown>;
if (localRsltData) {
// localStorage에서 직접 전달된 데이터
rslt = localRsltData;
} else {
// DB에서 조회
const analysis = await fetchHnsAnalysis(sn);
if (!analysis.rsltData) {
alert('저장된 분석 결과가 없습니다.');
return;
}
rslt = analysis.rsltData as Record<string, unknown>;
}
const savedParams = rslt.inputParams as Record<string, unknown> | 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<string, unknown> | 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 <HNSScenarioView />;
}
if (activeSubTab === 'manual') {
return <HNSManualViewer />;
}
if (activeSubTab === 'theory') {
return <HNSTheoryView />;
}
if (activeSubTab === 'substance') {
return <HNSSubstanceView />;
}
return (
<div className="flex flex-1 overflow-hidden">
{/* Left Panel - 분석 목록일 때는 숨김 */}
{activeSubTab === 'analysis' && (
<HNSLeftPanel
activeSubTab={activeSubTab}
onSubTabChange={setActiveSubTab}
incidentCoord={incidentCoord}
onCoordChange={setIncidentCoord}
onMapSelectClick={() => setIsSelectingLocation(true)}
onRunPrediction={handleRunPrediction}
isRunningPrediction={isRunningPrediction}
onParamsChange={handleParamsChange}
onReset={handleReset}
loadedParams={loadedParams}
/>
)}
{/* Center - Map/Content Area */}
<div className="flex-1 relative overflow-hidden">
{activeSubTab === 'list' ? (
<HNSAnalysisListTable onTabChange={(v) => setActiveSubTab(typeof v === 'function' ? v(activeSubTab as 'analysis' | 'list') : v)} onSelectAnalysis={handleLoadAnalysis} />
) : (
<>
<MapView
incidentCoord={incidentCoord ?? undefined}
isSelectingLocation={isSelectingLocation}
onMapClick={handleMapClick}
oilTrajectory={[]}
enabledLayers={new Set()}
dispersionResult={dispersionResult}
dispersionHeatmap={heatmapData}
mapCaptureRef={mapCaptureRef}
/>
{/* 시간 슬라이더 (puff/dense_gas 모델용) */}
{allTimeFrames.length > 1 && (
<DispersionTimeSlider
currentFrame={currentFrame}
totalFrames={allTimeFrames.length}
isPlaying={isPuffPlaying}
onFrameChange={handleFrameChange}
onPlayPause={() => setIsPuffPlaying(!isPuffPlaying)}
dt={30}
/>
)}
</>
)}
</div>
{/* Right Panel - 분석 목록일 때는 숨김 */}
{activeSubTab === 'analysis' && (
<HNSRightPanel
dispersionResult={dispersionResult}
computedResult={computedResult}
weatherData={inputParams?.weather ?? null}
onOpenRecalc={() => setRecalcModalOpen(true)}
onOpenReport={handleOpenReport}
onSave={handleSave}
/>
)}
{/* HNS 재계산 모달 */}
<HNSRecalcModal
isOpen={recalcModalOpen}
onClose={() => 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);
}}
/>
</div>
);
}