diff --git a/backend/src/prediction/predictionService.ts b/backend/src/prediction/predictionService.ts index bdf86a5..0769ea7 100644 --- a/backend/src/prediction/predictionService.ts +++ b/backend/src/prediction/predictionService.ts @@ -18,6 +18,7 @@ interface PredictionAnalysis { backtrackStatus: string; analyst: string; officeName: string; + acdntSttsCd: string; } interface PredictionDetail { @@ -129,6 +130,7 @@ export async function listAnalyses(input: ListAnalysesInput): Promise = { 'kL': 'KL', 'ton': 'TON', 'barrel': 'BBL', } +// ============================================================ +// 신규 생성된 ACDNT/SPIL_DATA/PRED_EXEC 롤백 헬퍼 +// Python 호출 실패 시 이번 요청에서 생성된 레코드만 삭제한다. +// ============================================================ +async function rollbackNewRecords( + predExecSn: number | null, + newSpilDataSn: number | null, + newAcdntSn: number | null +): Promise { + try { + if (predExecSn !== null) { + await wingPool.query('DELETE FROM wing.PRED_EXEC WHERE PRED_EXEC_SN=$1', [predExecSn]) + } + if (newSpilDataSn !== null) { + await wingPool.query('DELETE FROM wing.SPIL_DATA WHERE SPIL_DATA_SN=$1', [newSpilDataSn]) + } + if (newAcdntSn !== null) { + await wingPool.query('DELETE FROM wing.ACDNT WHERE ACDNT_SN=$1', [newAcdntSn]) + } + } catch (cleanupErr) { + console.error('[simulation] 롤백 실패:', cleanupErr) + } +} + // ============================================================ // POST /api/simulation/run // 확산 시뮬레이션 실행 (OpenDrift) @@ -92,9 +116,30 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => { return res.status(400).json({ error: '사고명은 200자 이내여야 합니다.' }) } + // 2. Python NC 파일 존재 여부 확인 (ACDNT 생성 전에 수행하여 고아 레코드 방지) + try { + const checkRes = await fetch(`${PYTHON_API_URL}/check-nc`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ lat, lon, startTime }), + signal: AbortSignal.timeout(5000), + }) + if (!checkRes.ok) { + return res.status(409).json({ + error: '해당 좌표의 해양 기상 데이터가 없습니다.', + message: 'NC 파일이 준비되지 않았습니다.', + }) + } + } catch { + // Python 서버 미기동 — 5번에서 처리 + } + // 1-B. acdntSn 미제공 시 ACDNT + SPIL_DATA 생성 let resolvedAcdntSn: number | null = rawAcdntSn ? Number(rawAcdntSn) : null let resolvedSpilDataSn: number | null = null + // 이번 요청에서 신규 생성된 레코드 추적 (Python 실패 시 롤백 대상) + let newlyCreatedAcdntSn: number | null = null + let newlyCreatedSpilDataSn: number | null = null if (!resolvedAcdntSn && acdntNm) { try { @@ -116,6 +161,7 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => { [acdntNm.trim(), occrn, lat, lon] ) resolvedAcdntSn = acdntRes.rows[0].acdnt_sn as number + newlyCreatedAcdntSn = resolvedAcdntSn const spilRes = await wingPool.query( `INSERT INTO wing.SPIL_DATA (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, REG_DTM) @@ -131,30 +177,13 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => { ] ) resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number + newlyCreatedSpilDataSn = resolvedSpilDataSn } catch (dbErr) { console.error('[simulation] ACDNT/SPIL_DATA INSERT 실패:', dbErr) return res.status(500).json({ error: '사고 정보 생성 실패' }) } } - // 2. Python NC 파일 존재 여부 확인 - try { - const checkRes = await fetch(`${PYTHON_API_URL}/check-nc`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ lat, lon, startTime }), - signal: AbortSignal.timeout(5000), - }) - if (!checkRes.ok) { - return res.status(409).json({ - error: '해당 좌표의 해양 기상 데이터가 없습니다.', - message: 'NC 파일이 준비되지 않았습니다.', - }) - } - } catch { - // Python 서버 미기동 — 5번에서 처리 - } - // 3. 기존 사고의 경우 SPIL_DATA_SN 조회 if (resolvedAcdntSn && !resolvedSpilDataSn) { try { @@ -215,6 +244,7 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => { `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`, [errData.error || '분석 서버 포화', predExecSn] ) + await rollbackNewRecords(predExecSn, newlyCreatedSpilDataSn, newlyCreatedAcdntSn) return res.status(503).json({ error: errData.error || '분석 서버가 사용 중입니다. 잠시 후 재시도해 주세요.' }) } @@ -229,6 +259,7 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => { `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG='Python 분석 서버에 연결할 수 없습니다.', CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$1`, [predExecSn] ) + await rollbackNewRecords(predExecSn, newlyCreatedSpilDataSn, newlyCreatedAcdntSn) return res.status(503).json({ error: 'Python 분석 서버에 연결할 수 없습니다.' }) } diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index a6ab4a4..8ff6c7f 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,12 @@ ## [Unreleased] +### 추가 +- 시뮬레이션 에러 모달 추가 + +### 변경 +- 보고서 해안부착 현황 개선 + ## [2026-03-11.2] ### 추가 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7e2e1d6..d2534ec 100755 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,6 +25,7 @@ "@vis.gl/react-maplibre": "^8.1.0", "axios": "^1.13.5", "emoji-mart": "^5.6.0", + "exifr": "^7.1.3", "hls.js": "^1.6.15", "jszip": "^3.10.1", "lucide-react": "^0.564.0", @@ -3848,6 +3849,12 @@ "node": ">=0.10.0" } }, + "node_modules/exifr": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz", + "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==", + "license": "MIT" + }, "node_modules/extend-shallow": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 325dc86..14c3f0d 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "@vis.gl/react-maplibre": "^8.1.0", "axios": "^1.13.5", "emoji-mart": "^5.6.0", + "exifr": "^7.1.3", "hls.js": "^1.6.15", "jszip": "^3.10.1", "lucide-react": "^0.564.0", diff --git a/frontend/src/common/components/layout/TopBar.tsx b/frontend/src/common/components/layout/TopBar.tsx index 5695c75..a1db12e 100755 --- a/frontend/src/common/components/layout/TopBar.tsx +++ b/frontend/src/common/components/layout/TopBar.tsx @@ -73,6 +73,25 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) { ) })} + + {/* 실시간 상황관리 */} + diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 1e7b7e2..7ebebe0 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -189,6 +189,11 @@ interface MapViewProps { mapCaptureRef?: React.MutableRefObject<(() => string | null) | null> onIncidentFlyEnd?: () => void flyToIncident?: { lon: number; lat: number } + showCurrent?: boolean + showWind?: boolean + showBeached?: boolean + showTimeLabel?: boolean + simulationStartTime?: string } // deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved) @@ -311,6 +316,11 @@ export function MapView({ mapCaptureRef, onIncidentFlyEnd, flyToIncident, + showCurrent = true, + showWind = true, + showBeached = false, + showTimeLabel = false, + simulationStartTime, }: MapViewProps) { const { mapToggles } = useMapStore() const isControlled = externalCurrentTime !== undefined @@ -393,8 +403,10 @@ export function MapView({ getPosition: (d: (typeof visibleParticles)[0]) => [d.lon, d.lat], getRadius: 3, getFillColor: (d: (typeof visibleParticles)[0]) => { - // 1순위: stranded 입자 → 빨간색 - if (d.stranded === 1) return [239, 68, 68, 220] as [number, number, number, number] + // 1순위: stranded 입자 → showBeached=true 시 빨간색, false 시 회색 + if (d.stranded === 1) return showBeached + ? [239, 68, 68, 220] as [number, number, number, number] + : [130, 130, 130, 70] as [number, number, number, number] // 2순위: 현재 활성 스텝 → 모델 기본 색상 if (d.time === activeStep) { const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift' @@ -427,7 +439,7 @@ export function MapView({ } }, updateTriggers: { - getFillColor: [selectedModels, currentTime], + getFillColor: [selectedModels, currentTime, showBeached], }, }) ) @@ -782,8 +794,39 @@ export function MapView({ ) } + // --- 시간 표시 라벨 (TextLayer) --- + if (visibleCenters.length > 0 && showTimeLabel) { + const baseTime = simulationStartTime ? new Date(simulationStartTime) : null; + const pad = (n: number) => String(n).padStart(2, '0'); + result.push( + new TextLayer({ + id: 'time-labels', + data: visibleCenters, + getPosition: (d: (typeof visibleCenters)[0]) => [d.lon, d.lat], + getText: (d: (typeof visibleCenters)[0]) => { + if (baseTime) { + const dt = new Date(baseTime.getTime() + d.time * 3600 * 1000); + return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; + } + return `+${d.time}h`; + }, + getSize: 12, + getColor: [255, 220, 50, 220] as [number, number, number, number], + getPixelOffset: [0, 16] as [number, number], + fontWeight: 'bold', + outlineWidth: 2, + outlineColor: [15, 21, 36, 200] as [number, number, number, number], + billboard: true, + sizeUnits: 'pixels' as const, + updateTriggers: { + getText: [simulationStartTime, currentTime], + }, + }) + ) + } + // --- 바람 화살표 (TextLayer) --- - if (incidentCoord && windData.length > 0) { + if (incidentCoord && windData.length > 0 && showWind) { type ArrowPoint = { lon: number; lat: number; bearing: number; speed: number } const activeWindStep = windData[currentTime] ?? windData[0] ?? [] @@ -829,6 +872,7 @@ export function MapView({ boomLines, isDrawingBoom, drawingPoints, dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay, sensitiveResources, centerPoints, windData, + showWind, showBeached, showTimeLabel, simulationStartTime, ]) // 3D 모드에 따른 지도 스타일 전환 @@ -887,7 +931,7 @@ export function MapView({ {/* 해류 파티클 오버레이 */} - {hydrData.length > 0 && ( + {hydrData.length > 0 && showCurrent && ( )} diff --git a/frontend/src/common/hooks/useSubMenu.ts b/frontend/src/common/hooks/useSubMenu.ts index 34e8ed6..121d221 100755 --- a/frontend/src/common/hooks/useSubMenu.ts +++ b/frontend/src/common/hooks/useSubMenu.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useSyncExternalStore } from 'react' import type { MainTab } from '../types/navigation' import { useAuthStore } from '@common/store/authStore' import { API_BASE_URL } from '@common/services/api' @@ -38,7 +38,7 @@ const subMenuConfigs: Record = { ], aerial: [ { id: 'media', label: '영상사진관리', icon: '📷' }, - { id: 'analysis', label: '유출유면적분석', icon: '🧩' }, + { id: 'analysis', label: '영상사진합성', icon: '🧩' }, { id: 'realtime', label: '실시간드론', icon: '🛸' }, { id: 'sensor', label: '오염/선박3D분석', icon: '🔍' }, { id: 'satellite', label: '위성요청', icon: '🛰' }, @@ -91,17 +91,10 @@ function subscribe(listener: () => void) { } export function useSubMenu(mainTab: MainTab) { - const [activeSubTab, setActiveSubTabLocal] = useState(subMenuState[mainTab]) + const activeSubTab = useSyncExternalStore(subscribe, () => subMenuState[mainTab]) const isAuthenticated = useAuthStore((s) => s.isAuthenticated) const hasPermission = useAuthStore((s) => s.hasPermission) - useEffect(() => { - const unsubscribe = subscribe(() => { - setActiveSubTabLocal(subMenuState[mainTab]) - }) - return unsubscribe - }, [mainTab]) - const setActiveSubTab = (subTab: string) => { setSubTab(mainTab, subTab) } diff --git a/frontend/src/tabs/aerial/components/OilAreaAnalysis.tsx b/frontend/src/tabs/aerial/components/OilAreaAnalysis.tsx index 47c0bc2..7567dd7 100644 --- a/frontend/src/tabs/aerial/components/OilAreaAnalysis.tsx +++ b/frontend/src/tabs/aerial/components/OilAreaAnalysis.tsx @@ -1,20 +1,29 @@ import { useState, useRef, useEffect, useCallback } from 'react'; +import * as exifr from 'exifr'; import { stitchImages } from '../services/aerialApi'; import { analyzeImage } from '@tabs/prediction/services/predictionApi'; import { setPendingImageAnalysis } from '@common/utils/imageAnalysisSignal'; import { navigateToTab } from '@common/hooks/useSubMenu'; +import { decimalToDMS } from '@common/utils/coordinates'; const MAX_IMAGES = 6; +interface GpsInfo { + lat: number | null; + lon: number | null; +} + export function OilAreaAnalysis() { const [selectedFiles, setSelectedFiles] = useState([]); const [previewUrls, setPreviewUrls] = useState([]); + const [imageGpsInfos, setImageGpsInfos] = useState<(GpsInfo | undefined)[]>([]); const [stitchedBlob, setStitchedBlob] = useState(null); const [stitchedPreviewUrl, setStitchedPreviewUrl] = useState(null); const [isStitching, setIsStitching] = useState(false); const [isAnalyzing, setIsAnalyzing] = useState(false); const [error, setError] = useState(null); const fileInputRef = useRef(null); + const processedFilesRef = useRef>(new Set()); // Object URL 메모리 누수 방지 — 언마운트 시 전체 revoke useEffect(() => { @@ -25,6 +34,34 @@ export function OilAreaAnalysis() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // 선택된 파일이 바뀔 때마다 새 파일의 EXIF GPS 추출 + useEffect(() => { + selectedFiles.forEach((file, i) => { + if (processedFilesRef.current.has(file)) return; + processedFilesRef.current.add(file); + + exifr.gps(file) + .then(gps => { + setImageGpsInfos(prev => { + const updated = [...prev]; + while (updated.length <= i) updated.push(undefined); + updated[i] = gps + ? { lat: gps.latitude, lon: gps.longitude } + : { lat: null, lon: null }; + return updated; + }); + }) + .catch(() => { + setImageGpsInfos(prev => { + const updated = [...prev]; + while (updated.length <= i) updated.push(undefined); + updated[i] = { lat: null, lon: null }; + return updated; + }); + }); + }); + }, [selectedFiles]); + const handleFileSelect = useCallback((e: React.ChangeEvent) => { setError(null); const incoming = Array.from(e.target.files ?? []); @@ -56,6 +93,7 @@ export function OilAreaAnalysis() { URL.revokeObjectURL(prev[idx]); return prev.filter((_, i) => i !== idx); }); + setImageGpsInfos(prev => prev.filter((_, i) => i !== idx)); // 합성 결과 초기화 (선택 파일이 바뀌었으므로) setStitchedBlob(null); if (stitchedPreviewUrl) { @@ -112,7 +150,7 @@ export function OilAreaAnalysis() {
{/* ── Left Panel ── */}
-
🧩 유출유면적분석
+
🧩 영상사진합성
드론 사진을 합성하여 유출유 확산 면적과 기름 양을 산정합니다.
@@ -201,15 +239,34 @@ export function OilAreaAnalysis() { {Array.from({ length: MAX_IMAGES }).map((_, i) => (
{previewUrls[i] ? ( - {selectedFiles[i]?.name + <> +
+ {selectedFiles[i]?.name +
+
+
+ {selectedFiles[i]?.name} +
+ {imageGpsInfos[i] === undefined ? ( +
GPS 읽는 중...
+ ) : imageGpsInfos[i]?.lat !== null ? ( +
+ {decimalToDMS(imageGpsInfos[i]!.lat!, true)}
+ {decimalToDMS(imageGpsInfos[i]!.lon!, false)} +
+ ) : ( +
GPS 정보 없음
+ )} +
+ ) : (
{i + 1} diff --git a/frontend/src/tabs/prediction/components/LeftPanel.tsx b/frontend/src/tabs/prediction/components/LeftPanel.tsx index 40260b7..cc5b1fc 100755 --- a/frontend/src/tabs/prediction/components/LeftPanel.tsx +++ b/frontend/src/tabs/prediction/components/LeftPanel.tsx @@ -53,7 +53,7 @@ export function LeftPanel({ predictionInput: true, incident: false, impactResources: false, - infoLayer: true, + infoLayer: false, oilBoom: false, }) @@ -112,45 +112,73 @@ export function LeftPanel({
{expandedSections.incident && ( -
- {/* Status Badge */} -
- - 진행중 -
+ selectedAnalysis ? ( +
+ {/* Status Badge */} + {(() => { + const statusMap: Record = { + ACTIVE: { + label: '진행중', + style: 'bg-[rgba(239,68,68,0.15)] text-status-red border border-[rgba(239,68,68,0.3)]', + dot: 'bg-status-red animate-pulse', + }, + INVESTIGATING: { + label: '조사중', + style: 'bg-[rgba(249,115,22,0.15)] text-status-orange border border-[rgba(249,115,22,0.3)]', + dot: 'bg-status-orange animate-pulse', + }, + CLOSED: { + label: '종료', + style: 'bg-[rgba(100,116,139,0.15)] text-text-3 border border-[rgba(100,116,139,0.3)]', + dot: 'bg-text-3', + }, + } + const s = statusMap[selectedAnalysis.acdntSttsCd] ?? statusMap['ACTIVE'] + return ( +
+ + {s.label} +
+ ) + })()} - {/* Info Grid */} -
-
- 사고코드 - {selectedAnalysis ? `INC-2025-${String(selectedAnalysis.id).padStart(4, '0')}` : 'INC-2025-0042'} -
-
- 사고명 - {selectedAnalysis?.name || '씨프린스호'} -
-
- 사고일시 - {selectedAnalysis?.occurredAt || '2025-02-10 06:30'} -
-
- 유종 - {selectedAnalysis?.oilType || 'BUNKER_C'} -
-
- 유출량 - {selectedAnalysis ? `${selectedAnalysis.volume.toFixed(2)} kl` : '350.00 kl'} -
-
- 담당자 - {selectedAnalysis?.analyst || '남해청, 방재과'} -
-
- 위치 - {selectedAnalysis?.location || '여수 돌산 남방 5NM'} + {/* Info Grid */} +
+
+ 사고코드 + {selectedAnalysis.acdntSn} +
+
+ 사고명 + {selectedAnalysis.acdntNm || '—'} +
+
+ 사고일시 + {selectedAnalysis.occurredAt ? selectedAnalysis.occurredAt.slice(0, 16) : '—'} +
+
+ 유종 + {selectedAnalysis.oilType || '—'} +
+
+ 유출량 + {selectedAnalysis.volume != null ? `${selectedAnalysis.volume.toFixed(2)} kl` : '—'} +
+
+ 담당자 + {selectedAnalysis.analyst || '—'} +
+
+ 위치 + {selectedAnalysis.location || '—'} +
-
+ ) : ( +
+

선택된 사고정보가 없습니다.

+
+ ) )}
diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index 10ed147..41f2ba9 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -16,6 +16,7 @@ import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAna import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, SimulationRunResponse, SimulationSummary, WindPoint } from '../services/predictionApi' import { useSimulationStatus } from '../hooks/useSimulationStatus' import SimulationLoadingOverlay from './SimulationLoadingOverlay' +import SimulationErrorModal from './SimulationErrorModal' import { api } from '@common/services/api' import { generateAIBoomLines } from '@common/utils/geo' import { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal' @@ -35,6 +36,13 @@ export interface SensitiveResource { arrivalTimeH: number } +export interface DisplayControls { + showCurrent: boolean; // 유향/유속 + showWind: boolean; // 풍향/풍속 + showBeached: boolean; // 해안부착 + showTimeLabel: boolean; // 시간 표시 +} + const DEMO_SENSITIVE_RESOURCES: SensitiveResource[] = [ { id: 'bc-1', name: '종포 해수욕장', type: 'beach', lat: 34.728, lon: 127.679, radiusM: 350, arrivalTimeH: 1 }, { id: 'aq-1', name: '국동 전복 양식장', type: 'aquaculture', lat: 34.718, lon: 127.672, radiusM: 500, arrivalTimeH: 3 }, @@ -114,7 +122,8 @@ export function OilSpillView() { const [windData, setWindData] = useState([]) const [hydrData, setHydrData] = useState<(HydrDataStep | null)[]>([]) const [isRunningSimulation, setIsRunningSimulation] = useState(false) - const [selectedModels, setSelectedModels] = useState>(new Set(['KOSPS'])) + const [simulationError, setSimulationError] = useState(null) + const [selectedModels, setSelectedModels] = useState>(new Set(['OpenDrift'])) const [predictionTime, setPredictionTime] = useState(48) const [accidentTime, setAccidentTime] = useState('') const [spillType, setSpillType] = useState('연속') @@ -142,6 +151,14 @@ export function OilSpillView() { const [layerOpacity, setLayerOpacity] = useState(50) const [layerBrightness, setLayerBrightness] = useState(50) + // 표시 정보 제어 + const [displayControls, setDisplayControls] = useState({ + showCurrent: true, + showWind: true, + showBeached: false, + showTimeLabel: false, + }) + // 타임라인 플레이어 상태 const [isPlaying, setIsPlaying] = useState(false) const [currentStep, setCurrentStep] = useState(0) // 현재 시간값 (시간 단위) @@ -373,6 +390,7 @@ export function OilSpillView() { if (simStatus.status === 'ERROR') { setIsRunningSimulation(false); setCurrentExecSn(null); + setSimulationError(simStatus.error ?? '시뮬레이션 처리 중 오류가 발생했습니다.'); } }, [simStatus, incidentCoord, algorithmSettings]); @@ -598,9 +616,12 @@ export function OilSpillView() { setIncidentName(''); } // setIsRunningSimulation(false)는 폴링 결과 useEffect에서 처리 - } catch { + } catch (err) { setIsRunningSimulation(false); - // 503 등 에러 시 상태 복원 (에러 메시지 표시는 향후 토스트로 처리) + const msg = + (err as { message?: string })?.message + ?? '시뮬레이션 실행 중 오류가 발생했습니다.'; + setSimulationError(msg); } } @@ -748,6 +769,11 @@ export function OilSpillView() { totalFrames: TOTAL_REPLAY_FRAMES, incidentCoord, } : undefined} + showCurrent={displayControls.showCurrent} + showWind={displayControls.showWind} + showBeached={displayControls.showBeached} + showTimeLabel={displayControls.showTimeLabel} + simulationStartTime={accidentTime || undefined} /> {/* 타임라인 플레이어 (리플레이 비활성 시) */} @@ -932,7 +958,7 @@ export function OilSpillView() {
{/* Right Panel */} - {activeSubTab === 'analysis' && setRecalcModalOpen(true)} onOpenReport={handleOpenReport} detail={analysisDetail} summary={simulationSummary} />} + {activeSubTab === 'analysis' && setRecalcModalOpen(true)} onOpenReport={handleOpenReport} detail={analysisDetail} summary={simulationSummary} displayControls={displayControls} onDisplayControlsChange={setDisplayControls} />} {/* 확산 예측 실행 중 로딩 오버레이 */} {isRunningSimulation && ( @@ -942,6 +968,14 @@ export function OilSpillView() { /> )} + {/* 확산 예측 에러 팝업 */} + {simulationError && ( + setSimulationError(null)} + /> + )} + {/* 재계산 모달 */} {/* Model Selection (다중 선택) */} + {/* TODO: 현재 OpenDrift만 구동 가능. KOSPS·POSEIDON·앙상블은 엔진 연동 완료 후 활성화 예정 */}
+ {/* 임시 비활성화 — OpenDrift만 구동 가능 (KOSPS 엔진 미연동) + { id: 'KOSPS' as PredictionModel, color: 'var(--cyan)' }, */} + {/* 임시 비활성화 — OpenDrift만 구동 가능 (POSEIDON 엔진 미연동) + { id: 'POSEIDON' as PredictionModel, color: 'var(--red)' }, */} {([ - { id: 'KOSPS' as PredictionModel, color: 'var(--cyan)' }, - { id: 'POSEIDON' as PredictionModel, color: 'var(--red)' }, { id: 'OpenDrift' as PredictionModel, color: 'var(--blue)' }, ] as const).map(m => (
))} + {/* 임시 비활성화 — OpenDrift만 구동 가능 (앙상블은 모든 모델 연동 후 활성화 예정)
{ @@ -415,8 +418,16 @@ const PredictionInputSection = ({ 앙상블
+ */}
+ {/* 모델 미선택 경고 */} + {selectedModels.size === 0 && ( +

+ ⚠ 예측 모델을 하나 이상 선택하세요. +

+ )} + {/* Run Button */} +
+
+ ); +}; + +export default SimulationErrorModal; diff --git a/frontend/src/tabs/prediction/services/predictionApi.ts b/frontend/src/tabs/prediction/services/predictionApi.ts index 308d24d..0b3994e 100644 --- a/frontend/src/tabs/prediction/services/predictionApi.ts +++ b/frontend/src/tabs/prediction/services/predictionApi.ts @@ -18,6 +18,7 @@ export interface PredictionAnalysis { backtrackStatus: string; analyst: string; officeName: string; + acdntSttsCd: string; } export interface PredictionDetail { diff --git a/frontend/src/tabs/reports/components/ReportGenerator.tsx b/frontend/src/tabs/reports/components/ReportGenerator.tsx index 9e780fd..c478d59 100644 --- a/frontend/src/tabs/reports/components/ReportGenerator.tsx +++ b/frontend/src/tabs/reports/components/ReportGenerator.tsx @@ -343,13 +343,26 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
)} - {sec.id === 'oil-coastal' && ( -

- 최초 부착시간: {oilPayload?.coastal?.firstTime ?? sampleOilData.coastal.firstTime} - {' / '} - 부착 해안길이: {oilPayload?.pollution.coastLength || sampleOilData.coastal.coastLength} -

- )} + {sec.id === 'oil-coastal' && (() => { + const coastLength = oilPayload?.pollution.coastLength; + const hasNoCoastal = oilPayload && ( + !coastLength || coastLength === '—' || coastLength.startsWith('0.00') + ); + if (hasNoCoastal) { + return ( +

+ 시뮬레이션 결과 유출유의 해안 부착이 없습니다. +

+ ); + } + return ( +

+ 최초 부착시간: {oilPayload?.coastal?.firstTime ?? sampleOilData.coastal.firstTime} + {' / '} + 부착 해안길이: {coastLength || sampleOilData.coastal.coastLength} +

+ ); + })()} {sec.id === 'oil-defense' && (

방제자원 배치 계획에 따른 전략을 수립합니다.