From c7c7537dbb5351d8dad3b59d1e047fa67d022d6f Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Wed, 18 Mar 2026 13:25:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(prediction):=20trajectory=20API=EC=97=90?= =?UTF-8?q?=20=EB=AA=A8=EB=8D=B8=EB=B3=84=20windData/hydrData=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/prediction/predictionService.ts | 31 +- backend/src/routes/simulation.ts | 346 ++++++++++++++++++ backend/src/settings/settingsService.ts | 26 +- frontend/src/App.tsx | 2 + .../src/common/components/layout/TopBar.tsx | 62 ++-- .../src/common/components/map/MapView.tsx | 59 ++- frontend/src/common/types/navigation.ts | 2 +- .../prediction/components/OilSpillView.tsx | 276 ++++++++------ .../components/PredictionInputSection.tsx | 14 +- .../prediction/hooks/useSimulationStatus.ts | 86 ----- .../tabs/prediction/services/predictionApi.ts | 23 +- 11 files changed, 660 insertions(+), 267 deletions(-) delete mode 100644 frontend/src/tabs/prediction/hooks/useSimulationStatus.ts diff --git a/backend/src/prediction/predictionService.ts b/backend/src/prediction/predictionService.ts index 06fdc9f..8ff7458 100644 --- a/backend/src/prediction/predictionService.ts +++ b/backend/src/prediction/predictionService.ts @@ -448,7 +448,7 @@ const ALGO_CD_TO_MODEL: Record = { 'POSEIDON': 'POSEIDON', }; -interface TrajectoryResult { +interface SingleModelTrajectoryResult { trajectory: Array<{ lat: number; lon: number; time: number; particle: number; stranded?: 0 | 1; model: string }>; summary: { remainingVolume: number; @@ -462,7 +462,21 @@ interface TrajectoryResult { hydrData: ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[]; } -function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: string): TrajectoryResult { +interface TrajectoryResult { + trajectory: Array<{ lat: number; lon: number; time: number; particle: number; stranded?: 0 | 1; model: string }>; + summary: { + remainingVolume: number; + weatheredVolume: number; + pollutionArea: number; + beachedVolume: number; + pollutionCoastLength: number; + }; + centerPoints: Array<{ lat: number; lon: number; time: number; model: string }>; + windDataByModel: Record; + hydrDataByModel: Record; +} + +function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: string): SingleModelTrajectoryResult { const trajectory = rawResult.flatMap((step, stepIdx) => step.particles.map((p, i) => ({ lat: p.lat, @@ -513,8 +527,10 @@ export async function getAnalysisTrajectory(acdntSn: number): Promise = {}; + const hydrDataByModel: Record = {}; // OpenDrift 우선, 없으면 POSEIDON 선택 (ORDER BY CMPL_DTM DESC이므로 첫 번째 행이 가장 최근) const opendriftRow = (rows as Array>).find((r) => r['algo_cd'] === 'OPENDRIFT'); @@ -528,8 +544,9 @@ export async function getAnalysisTrajectory(acdntSn: number): Promise { + try { + const { acdntSn: rawAcdntSn, acdntNm, spillUnit, spillTypeCd, + lat, lon, runTime, matTy, matVol, spillTime, startTime, + models: rawModels } = req.body + + let requestedModels: string[] = Array.isArray(rawModels) && rawModels.length > 0 + ? (rawModels as string[]) + : ['OpenDrift'] + + // 1. 필수 파라미터 검증 + if (lat === undefined || lon === undefined || runTime === undefined) { + return res.status(400).json({ error: '필수 파라미터 누락', required: ['lat', 'lon', 'runTime'] }) + } + if (!isValidLatitude(lat)) { + return res.status(400).json({ error: '유효하지 않은 위도', message: '위도는 -90~90 범위여야 합니다.' }) + } + if (!isValidLongitude(lon)) { + return res.status(400).json({ error: '유효하지 않은 경도', message: '경도는 -180~180 범위여야 합니다.' }) + } + if (!isValidNumber(runTime, 1, 720)) { + return res.status(400).json({ error: '유효하지 않은 예측 시간', message: '예측 시간은 1~720 범위여야 합니다.' }) + } + if (matVol !== undefined && !isValidNumber(matVol, 0, 1000000)) { + return res.status(400).json({ error: '유효하지 않은 유출량' }) + } + if (matTy !== undefined && (typeof matTy !== 'string' || !isValidStringLength(matTy, 50))) { + return res.status(400).json({ error: '유효하지 않은 유종' }) + } + if (!rawAcdntSn && (!acdntNm || typeof acdntNm !== 'string' || !acdntNm.trim())) { + return res.status(400).json({ error: '사고를 선택하거나 사고명을 입력해야 합니다.' }) + } + if (acdntNm && (typeof acdntNm !== 'string' || !isValidStringLength(acdntNm, 200))) { + return res.status(400).json({ error: '사고명은 200자 이내여야 합니다.' }) + } + + // 2. NC 파일 존재 여부 확인 + if (requestedModels.includes('OpenDrift')) { + 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) { + // NC 파일 없으면 OpenDrift만 제외, 나머지 모델(POSEIDON 등)은 계속 진행 + requestedModels = requestedModels.filter(m => m !== 'OpenDrift') + if (requestedModels.length === 0) { + return res.status(409).json({ + error: '해당 좌표의 해양 기상 데이터가 없습니다.', + message: 'NC 파일이 준비되지 않았습니다.', + }) + } + } + } catch { + // Python 서버 미기동 — 이후 단계에서 처리 + } + } + + // 3. ACDNT/SPIL_DATA 생성 또는 조회 + let resolvedAcdntSn: number | null = rawAcdntSn ? Number(rawAcdntSn) : null + let resolvedSpilDataSn: number | null = null + let newlyCreatedAcdntSn: number | null = null + let newlyCreatedSpilDataSn: number | null = null + + if (!resolvedAcdntSn && acdntNm) { + try { + const occrn = startTime ?? new Date().toISOString() + const acdntRes = await wingPool.query( + `INSERT INTO wing.ACDNT + (ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, OCCRN_DTM, LAT, LNG, ACDNT_STTS_CD, USE_YN, REG_DTM) + VALUES ( + 'INC-' || EXTRACT(YEAR FROM NOW())::TEXT || '-' || + LPAD( + (SELECT COALESCE(MAX(CAST(SPLIT_PART(ACDNT_CD, '-', 3) AS INTEGER)), 0) + 1 + FROM wing.ACDNT + WHERE ACDNT_CD LIKE 'INC-' || EXTRACT(YEAR FROM NOW())::TEXT || '-%')::TEXT, + 4, '0' + ), + $1, '유류유출', $2, $3, $4, 'ACTIVE', 'Y', NOW() + ) + RETURNING ACDNT_SN`, + [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) + VALUES ($1, $2, $3, $4, $5, $6, NOW()) + RETURNING SPIL_DATA_SN`, + [ + resolvedAcdntSn, + OIL_DB_CODE_MAP[matTy as string] ?? 'BUNKER_C', + matVol ?? 0, + UNIT_MAP[spillUnit as string] ?? 'KL', + SPIL_TYPE_MAP[spillTypeCd as string] ?? 'CONTINUOUS', + runTime, + ] + ) + resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number + newlyCreatedSpilDataSn = resolvedSpilDataSn + } catch (dbErr) { + console.error('[simulation/run-model] ACDNT/SPIL_DATA INSERT 실패:', dbErr) + return res.status(500).json({ error: '사고 정보 생성 실패' }) + } + } + + if (resolvedAcdntSn && !resolvedSpilDataSn) { + try { + const spilRes = await wingPool.query( + `SELECT SPIL_DATA_SN FROM wing.SPIL_DATA WHERE ACDNT_SN = $1 ORDER BY SPIL_DATA_SN DESC LIMIT 1`, + [resolvedAcdntSn] + ) + if (spilRes.rows.length > 0) { + resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number + } + } catch (dbErr) { + console.error('[simulation/run-model] SPIL_DATA 조회 실패:', dbErr) + } + } + + const odMatTy = matTy !== undefined ? (OIL_TYPE_MAP[matTy as string] ?? (matTy as string)) : undefined + const execNmBase = `EXPC_${Date.now()}` + + // KOSPS: PRED_EXEC INSERT(PENDING)만 수행 + const execSns: Array<{ model: string; execSn: number }> = [] + if (requestedModels.includes('KOSPS')) { + try { + const kospsExecNm = `${execNmBase}_KOSPS` + const insertRes = await wingPool.query( + `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, BGNG_DTM) + VALUES ($1, $2, 'KOSPS', 'PENDING', $3, NOW()) + RETURNING PRED_EXEC_SN`, + [resolvedAcdntSn, resolvedSpilDataSn, kospsExecNm] + ) + execSns.push({ model: 'KOSPS', execSn: insertRes.rows[0].pred_exec_sn as number }) + } catch (dbErr) { + console.error('[simulation/run-model] KOSPS PRED_EXEC INSERT 실패:', dbErr) + } + } + + // 4. API 연동 모델 시작 및 완료 대기 (병렬) + const apiModels = requestedModels.filter((m) => m !== 'KOSPS' && MODEL_ALGO_CD_MAP[m] !== undefined) + + interface SyncModelResult { + model: string + execSn: number + status: 'DONE' | 'ERROR' + trajectory?: ReturnType['trajectory'] + summary?: ReturnType['summary'] + centerPoints?: ReturnType['centerPoints'] + windData?: ReturnType['windData'] + hydrData?: ReturnType['hydrData'] + error?: string + } + + const modelResults = await Promise.all( + apiModels.map(async (model): Promise => { + const algoCd = MODEL_ALGO_CD_MAP[model] + const apiUrl = MODEL_API_URL_MAP[model] + const execNm = `${execNmBase}_${algoCd}` + + // PRED_EXEC INSERT + let predExecSn: number + try { + const insertRes = await wingPool.query( + `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, BGNG_DTM) + VALUES ($1, $2, $3, 'PENDING', $4, NOW()) + RETURNING PRED_EXEC_SN`, + [resolvedAcdntSn, resolvedSpilDataSn, algoCd, execNm] + ) + predExecSn = insertRes.rows[0].pred_exec_sn as number + } catch (dbErr) { + console.error(`[simulation/run-model] ${model} PRED_EXEC INSERT 실패:`, dbErr) + return { model, execSn: 0, status: 'ERROR', error: 'DB 오류' } + } + + execSns.push({ model, execSn: predExecSn }) + + // Python /run-model 호출 + let jobId: string | undefined + try { + const pythonRes = await fetch(`${apiUrl}/run-model`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ lat, lon, startTime, runTime, matTy: odMatTy, matVol, spillTime, name: execNm }), + signal: AbortSignal.timeout(POLL_TIMEOUT_MS), + }) + + if (pythonRes.status === 503) { + const errData = await pythonRes.json() as { error?: string } + const errMsg = errData.error || '분석 서버 포화' + await wingPool.query( + `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`, + [errMsg, predExecSn] + ) + return { model, execSn: predExecSn, status: 'ERROR', error: errMsg } + } + + if (!pythonRes.ok) { + throw new Error(`Python 서버 응답 오류: ${pythonRes.status}`) + } + + const pythonData = await pythonRes.json() as { + success?: boolean; + result?: PythonTimeStep[]; + job_id?: string; + error?: string; + message?: string; + error_code?: number; + } + + // 동기 성공 응답 (OpenDrift & POSEIDON 공통) + if (Array.isArray(pythonData.result)) { + await wingPool.query( + `UPDATE wing.PRED_EXEC + SET EXEC_STTS_CD='COMPLETED', RSLT_DATA=$1, + CMPL_DTM=NOW(), REQD_SEC=EXTRACT(EPOCH FROM (NOW() - BGNG_DTM))::INTEGER + WHERE PRED_EXEC_SN=$2`, + [JSON.stringify(pythonData.result), predExecSn] + ) + const { trajectory, summary, centerPoints, windData, hydrData } = + transformResult(pythonData.result, model) + return { model, execSn: predExecSn, status: 'DONE', trajectory, summary, centerPoints, windData, hydrData } + } + + // 비동기 응답 (하위 호환) + if (pythonData.job_id) { + jobId = pythonData.job_id + } else { + // 오류 응답 (success: false, HTTP 200) + const errMsg = pythonData.error || pythonData.message || '분석 오류' + await wingPool.query( + `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`, + [errMsg, predExecSn] + ) + return { model, execSn: predExecSn, status: 'ERROR', error: errMsg } + } + } catch (fetchErr) { + const errMsg = 'Python 분석 서버에 연결할 수 없습니다.' + await wingPool.query( + `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`, + [errMsg, predExecSn] + ) + return { model, execSn: predExecSn, status: 'ERROR', error: errMsg } + } + + // RUNNING 업데이트 (비동기 폴링 경로) + await wingPool.query( + `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='RUNNING' WHERE PRED_EXEC_SN=$1`, + [predExecSn] + ) + + // 결과 동기 대기 + try { + const rawResult = await runModelSync(jobId!, predExecSn, apiUrl) + const { trajectory, summary, centerPoints, windData, hydrData } = transformResult(rawResult, model) + return { model, execSn: predExecSn, status: 'DONE', trajectory, summary, centerPoints, windData, hydrData } + } catch (syncErr) { + return { model, execSn: predExecSn, status: 'ERROR', error: (syncErr as Error).message } + } + }) + ) + + // 모든 모델이 실패하고 신규 생성한 ACDNT가 있으면 롤백 + const hasSuccess = modelResults.some((r) => r.status === 'DONE') + if (!hasSuccess && newlyCreatedAcdntSn !== null) { + for (const r of modelResults) { + if (r.execSn) await rollbackNewRecords(r.execSn, null, null) + } + await rollbackNewRecords(null, newlyCreatedSpilDataSn, newlyCreatedAcdntSn) + return res.status(503).json({ error: '분석 서버에 연결할 수 없습니다.' }) + } + + res.json({ + success: true, + acdntSn: resolvedAcdntSn, + execSns: [...execSns, ...modelResults.map(({ model, execSn }) => ({ model, execSn }))], + results: modelResults, + }) + } catch { + res.status(500).json({ error: '시뮬레이션 실행 실패', message: '서버 내부 오류가 발생했습니다.' }) + } +}) + // ============================================================ // 백그라운드 폴링 // ============================================================ @@ -474,6 +769,57 @@ async function pollAndSaveModel(jobId: string, execSn: number, apiUrl: string, a ) } +// ============================================================ +// 동기 폴링: Python 결과 대기 후 반환 +// ============================================================ +async function runModelSync(jobId: string, execSn: number, apiUrl: string): Promise { + const deadline = Date.now() + POLL_TIMEOUT_MS + + while (Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) + + let data: PythonStatusResponse + try { + const pollRes = await fetch(`${apiUrl}/status/${jobId}`, { + signal: AbortSignal.timeout(5000), + }) + if (!pollRes.ok) continue + data = await pollRes.json() as PythonStatusResponse + } catch { + // 네트워크 오류 — 재시도 + continue + } + + if (data.status === 'DONE' && data.result) { + await wingPool.query( + `UPDATE wing.PRED_EXEC + SET EXEC_STTS_CD='COMPLETED', + RSLT_DATA=$1, + CMPL_DTM=NOW(), + REQD_SEC=EXTRACT(EPOCH FROM (NOW() - BGNG_DTM))::INTEGER + WHERE PRED_EXEC_SN=$2`, + [JSON.stringify(data.result), execSn] + ) + return data.result + } + + if (data.status === 'ERROR') { + const errMsg = data.error ?? '분석 오류' + await wingPool.query( + `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`, + [errMsg, execSn] + ) + throw new Error(errMsg) + } + } + + await wingPool.query( + `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG='분석 시간 초과 (30분)', CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$1`, + [execSn] + ) + throw new Error('분석 시간 초과 (30분)') +} + // ============================================================ // 타입 및 결과 변환 // ============================================================ diff --git a/backend/src/settings/settingsService.ts b/backend/src/settings/settingsService.ts index acb601b..ec33ad7 100644 --- a/backend/src/settings/settingsService.ts +++ b/backend/src/settings/settingsService.ts @@ -93,6 +93,7 @@ const DEFAULT_MENU_CONFIG: MenuConfigItem[] = [ { id: 'board', label: '게시판', icon: '📌', enabled: true, order: 8 }, { id: 'weather', label: '기상정보', icon: '⛅', enabled: true, order: 9 }, { id: 'incidents', label: '통합조회', icon: '🔍', enabled: true, order: 10 }, + { id: 'monitor', label: '실시간 상황관리', icon: '🛰', enabled: true, order: 11 }, ] const VALID_MENU_IDS = DEFAULT_MENU_CONFIG.map(m => m.id) @@ -103,18 +104,23 @@ export async function getMenuConfig(): Promise { try { const parsed = JSON.parse(val) as MenuConfigItem[] - const defaultMap = new Map(DEFAULT_MENU_CONFIG.map(m => [m.id, m])) + const dbMap = new Map( + parsed + .filter(item => VALID_MENU_IDS.includes(item.id)) + .map(item => [item.id, item]) + ) - return parsed - .filter(item => VALID_MENU_IDS.includes(item.id)) - .map(item => { - const defaults = defaultMap.get(item.id)! + // DEFAULT 기준으로 머지 (DB에 없는 항목은 기본값 사용) + return DEFAULT_MENU_CONFIG + .map(defaultItem => { + const dbItem = dbMap.get(defaultItem.id) + if (!dbItem) return defaultItem return { - id: item.id, - label: item.label || defaults.label, - icon: item.icon || defaults.icon, - enabled: item.enabled, - order: item.order, + id: dbItem.id, + label: dbItem.label || defaultItem.label, + icon: dbItem.icon || defaultItem.icon, + enabled: dbItem.enabled, + order: dbItem.order, } }) .sort((a, b) => a.order - b.order) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1f148c4..eb46896 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -97,6 +97,8 @@ function App() { return case 'rescue': return + case 'monitor': + return null default: return
준비 중입니다...
} diff --git a/frontend/src/common/components/layout/TopBar.tsx b/frontend/src/common/components/layout/TopBar.tsx index d82ceb0..d5baa62 100755 --- a/frontend/src/common/components/layout/TopBar.tsx +++ b/frontend/src/common/components/layout/TopBar.tsx @@ -50,50 +50,54 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
{tabs.map((tab) => { const isIncident = tab.id === 'incidents' + const isMonitor = tab.id === 'monitor' + const handleClick = () => { + if (isMonitor) { + window.open(import.meta.env.VITE_SITUATIONAL_URL ?? 'https://kcg.gc-si.dev', '_blank') + } else { + onTabChange(tab.id as MainTab) + } + } return ( ) })} - - {/* 실시간 상황관리 */} -
diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 5e0f2e2..fb3bd80 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -399,6 +399,36 @@ function FitBoundsController({ fitBoundsTarget }: { fitBoundsTarget?: { north: n return null } +// Map 중앙 좌표 + 줌 추적 컴포넌트 (Map 내부에서 useMap() 사용) +function MapCenterTracker({ + onCenterChange, +}: { + onCenterChange: (lat: number, lng: number, zoom: number) => void; +}) { + const { current: map } = useMap() + + useEffect(() => { + if (!map) return + + const update = () => { + const center = map.getCenter() + const zoom = map.getZoom() + onCenterChange(center.lat, center.lng, zoom) + } + + update() + map.on('move', update) + map.on('zoom', update) + + return () => { + map.off('move', update) + map.off('zoom', update) + } + }, [map, onCenterChange]) + + return null +} + // 3D 모드 pitch/bearing 제어 컴포넌트 (Map 내부에서 useMap() 사용) function MapPitchController({ threeD }: { threeD: boolean }) { const { current: map } = useMap() @@ -519,12 +549,19 @@ export function MapView({ const { mapToggles } = useMapStore() const isControlled = externalCurrentTime !== undefined const [currentPosition, setCurrentPosition] = useState<[number, number]>(DEFAULT_CENTER) + const [mapCenter, setMapCenter] = useState<[number, number]>(DEFAULT_CENTER) + const [mapZoom, setMapZoom] = useState(DEFAULT_ZOOM) const [internalCurrentTime, setInternalCurrentTime] = useState(0) const [isPlaying, setIsPlaying] = useState(false) const [playbackSpeed, setPlaybackSpeed] = useState(1) const [popupInfo, setPopupInfo] = useState(null) const currentTime = isControlled ? externalCurrentTime : internalCurrentTime + const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => { + setMapCenter([lat, lng]) + setMapZoom(zoom) + }, []) + const handleMapClick = useCallback((e: MapLayerMouseEvent) => { const { lng, lat } = e.lngLat setCurrentPosition([lat, lng]) @@ -1207,6 +1244,8 @@ export function MapView({ > {/* 지도 캡처 셋업 */} {mapCaptureRef && } + {/* 지도 중앙 좌표 + 줌 추적 */} + {/* 3D 모드 pitch 제어 */} {/* 사고 지점 변경 시 지도 이동 */} @@ -1303,7 +1342,8 @@ export function MapView({ {/* 좌표 표시 */} {showOverlays && } {/* 타임라인 컨트롤 (외부 제어 모드에서는 숨김 — 하단 플레이어가 대신 담당) */} @@ -1499,16 +1539,23 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], select } // 좌표 표시 -function CoordinateDisplay({ position }: { position: [number, number] }) { +function CoordinateDisplay({ position, zoom }: { position: [number, number]; zoom: number }) { const [lat, lng] = position const latDirection = lat >= 0 ? 'N' : 'S' const lngDirection = lng >= 0 ? 'E' : 'W' + // MapLibre 줌 → 축척 변환 (96 DPI 기준) + const metersPerPixel = (40075016.686 * Math.cos((lat * Math.PI) / 180)) / (256 * Math.pow(2, zoom)) + const scaleRatio = Math.round(metersPerPixel * (96 / 0.0254)) + const scaleLabel = scaleRatio >= 1000000 + ? `1:${(scaleRatio / 1000000).toFixed(1)}M` + : `1:${scaleRatio.toLocaleString()}` + return (
위도 {Math.abs(lat).toFixed(4)}°{latDirection} 경도 {Math.abs(lng).toFixed(4)}°{lngDirection} - 축척 1:50,000 + 축척 {scaleLabel}
) } @@ -1585,7 +1632,11 @@ function TimelineControl({
{/* eslint-disable-next-line react-hooks/purity */} -
+{currentTime.toFixed(0)}h — {new Date(Date.now() + currentTime * 3600000).toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} KST
+
+{currentTime.toFixed(0)}h — {(() => { + const base = simulationStartTime ? new Date(simulationStartTime) : new Date(); + const d = new Date(base.getTime() + currentTime * 3600 * 1000); + return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')} KST`; + })()}
진행률{progressPercent.toFixed(0)}%
속도{playbackSpeed}×
diff --git a/frontend/src/common/types/navigation.ts b/frontend/src/common/types/navigation.ts index a3c3c57..fd9f6f8 100644 --- a/frontend/src/common/types/navigation.ts +++ b/frontend/src/common/types/navigation.ts @@ -1 +1 @@ -export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'admin'; +export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'monitor' | 'admin'; diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index ec6b944..c7fc4fd 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -13,9 +13,7 @@ import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } fr import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack' import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack' import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory } from '../services/predictionApi' -import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, SimulationRunResponse, SimulationSummary, WindPoint } from '../services/predictionApi' -import { useMultiSimulationStatus } from '../hooks/useSimulationStatus' -import type { ModelExecRef } from '../hooks/useSimulationStatus' +import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, RunModelSyncResponse, SimulationSummary, WindPoint } from '../services/predictionApi' import SimulationLoadingOverlay from './SimulationLoadingOverlay' import SimulationErrorModal from './SimulationErrorModal' import { api } from '@common/services/api' @@ -124,6 +122,8 @@ export function OilSpillView() { const [hydrDataByModel, setHydrDataByModel] = useState>({}) const [windHydrModel, setWindHydrModel] = useState('OpenDrift') const [isRunningSimulation, setIsRunningSimulation] = useState(false) + const [simulationProgress, setSimulationProgress] = useState(0) + const progressTimerRef = useRef | null>(null) const [simulationError, setSimulationError] = useState(null) const [selectedModels, setSelectedModels] = useState>(new Set(['OpenDrift'])) const [visibleModels, setVisibleModels] = useState>(new Set(['OpenDrift'])) @@ -191,9 +191,7 @@ export function OilSpillView() { // 재계산 상태 const [recalcModalOpen, setRecalcModalOpen] = useState(false) - const [pendingExecSns, setPendingExecSns] = useState([]) const [simulationSummary, setSimulationSummary] = useState(null) - const { allDone: simAllDone, anyError: simAnyError, results: simResults, errors: simErrors } = useMultiSimulationStatus(pendingExecSns) // 오염분석 상태 const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon') @@ -392,91 +390,30 @@ export function OilSpillView() { } }, []) - // 시뮬레이션 폴링 결과 처리 (다중 모델) - useEffect(() => { - if (pendingExecSns.length === 0) return; - - if (simAllDone) { - // 모든 모델의 trajectory 병합 (model 필드 포함) - const merged: OilParticle[] = []; - let latestSummary: SimulationSummary | null = null; - let latestCenterPoints: CenterPoint[] = []; - const newWindDataByModel: Record = {}; - const newHydrDataByModel: Record = {}; - - simResults.forEach((statusData, model) => { - if (statusData.trajectory) { - const withModel = statusData.trajectory.map(p => ({ ...p, model })); - merged.push(...withModel); - } - // summary는 OpenDrift 우선, 없으면 다른 모델 - if (model === 'OpenDrift' || !latestSummary) { - if (statusData.summary) latestSummary = statusData.summary; - } - // windData/hydrData는 모델별로 저장 - if (statusData.windData) newWindDataByModel[model] = statusData.windData; - if (statusData.hydrData) newHydrDataByModel[model] = statusData.hydrData; - // centerPoints는 모든 모델 누적 (model 필드 포함 보장) - if (statusData.centerPoints) { - const withModel = statusData.centerPoints.map(p => ({ ...p, model })); - latestCenterPoints = [...latestCenterPoints, ...withModel]; - } - }); - - if (merged.length > 0) { - setOilTrajectory(merged); - const doneModels = new Set( - Array.from(simResults.entries()) - .filter(([, s]) => s.trajectory && s.trajectory.length > 0) - .map(([m]) => m as PredictionModel) - ) - setVisibleModels(doneModels) - setSimulationSummary(latestSummary); - setCenterPoints(latestCenterPoints); - - // 데이터가 없는 모델에 OpenDrift(또는 첫 번째 보유 모델) 데이터 복사 - const refWindData = newWindDataByModel['OpenDrift'] ?? Object.values(newWindDataByModel)[0]; - const refHydrData = newHydrDataByModel['OpenDrift'] ?? Object.values(newHydrDataByModel)[0]; - doneModels.forEach(model => { - if (!newWindDataByModel[model] && refWindData) newWindDataByModel[model] = refWindData; - if (!newHydrDataByModel[model] && refHydrData) newHydrDataByModel[model] = refHydrData; - }); - - setWindDataByModel(newWindDataByModel); - setHydrDataByModel(newHydrDataByModel); - setWindHydrModel('OpenDrift'); - if (incidentCoord) { - const booms = generateAIBoomLines(merged, incidentCoord, algorithmSettings); - setBoomLines(booms); - } - setSensitiveResources(DEMO_SENSITIVE_RESOURCES); - setCurrentStep(0); - setIsPlaying(true); - if (incidentCoord) { - setFlyToCoord({ lon: incidentCoord.lon, lat: incidentCoord.lat }); - } - } - setIsRunningSimulation(false); - setPendingExecSns([]); - } - - if (simAnyError) { - setIsRunningSimulation(false); - setPendingExecSns([]); - const errorMessages = Array.from(simErrors.values()).join('; '); - setSimulationError(errorMessages || '시뮬레이션 처리 중 오류가 발생했습니다.'); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [simAllDone, simAnyError, simResults, simErrors, pendingExecSns.length, incidentCoord, algorithmSettings]); // trajectory 변경 시 플레이어 스텝 초기화 (재생은 각 경로에서 별도 처리) useEffect(() => { if (oilTrajectory.length > 0) { - // eslint-disable-next-line react-hooks/set-state-in-effect setCurrentStep(0); } }, [oilTrajectory.length]); + useEffect(() => { + return () => { + if (progressTimerRef.current) clearInterval(progressTimerRef.current); + }; + }, []); + + // windHydrModel이 visibleModels에 없으면 자동으로 적절한 모델로 전환 + useEffect(() => { + if (visibleModels.size === 0) return; + if (!visibleModels.has(windHydrModel as PredictionModel)) { + const preferred: PredictionModel[] = ['OpenDrift', 'POSEIDON', 'KOSPS']; + const next = preferred.find(m => visibleModels.has(m)) ?? Array.from(visibleModels)[0]; + setWindHydrModel(next); + } + }, [visibleModels, windHydrModel]); + // 플레이어 재생 애니메이션 (1x = 1초/스텝, 2x = 0.5초/스텝, 4x = 0.25초/스텝) const timeSteps = useMemo(() => { if (oilTrajectory.length === 0) return []; @@ -500,7 +437,6 @@ export function OilSpillView() { useEffect(() => { if (!isPlaying || timeSteps.length === 0) return; if (currentStep >= maxTime) { - // eslint-disable-next-line react-hooks/set-state-in-effect setIsPlaying(false); return; } @@ -560,17 +496,18 @@ export function OilSpillView() { : incidentCoord const demoModels = Array.from(models.size > 0 ? models : new Set(['KOSPS'])) - // OpenDrift 완료된 경우 실제 궤적 로드, 없으면 데모로 fallback - if (analysis.opendriftStatus === 'completed') { + // 완료된 모델이 있는 경우 실제 궤적 로드, 없으면 데모로 fallback + const hasCompletedModel = + analysis.opendriftStatus === 'completed' || analysis.poseidonStatus === 'completed'; + if (hasCompletedModel) { try { - const { trajectory, summary, centerPoints: cp, windData: wd, hydrData: hd } = await fetchAnalysisTrajectory(analysis.acdntSn) + const { trajectory, summary, centerPoints: cp, windDataByModel: wdByModel, hydrDataByModel: hdByModel } = await fetchAnalysisTrajectory(analysis.acdntSn) if (trajectory && trajectory.length > 0) { setOilTrajectory(trajectory) if (summary) setSimulationSummary(summary) setCenterPoints(cp ?? []) - setWindDataByModel(wd && wd.length > 0 ? { 'OpenDrift': wd } : {}) - setHydrDataByModel(hd && hd.length > 0 ? { 'OpenDrift': hd } : {}) - setWindHydrModel('OpenDrift') + setWindDataByModel(wdByModel ?? {}); + setHydrDataByModel(hdByModel ?? {}); if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings)) setSensitiveResources(DEMO_SENSITIVE_RESOURCES) // incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생 @@ -586,7 +523,9 @@ export function OilSpillView() { } } - // 데모 궤적 생성 (fallback) + // 데모 궤적 생성 (fallback) — stale wind/current 데이터 초기화 + setWindDataByModel({}) + setHydrDataByModel({}) const demoTrajectory = generateDemoTrajectory(coord ?? { lat: 37.39, lon: 126.64 }, demoModels, parseInt(analysis.duration) || 48) setOilTrajectory(demoTrajectory) if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings)) @@ -690,50 +629,81 @@ export function OilSpillView() { }) }, []) - const handleRunSimulation = async () => { + const startProgressTimer = useCallback((runTimeHours: number) => { + const expectedMs = runTimeHours * 6000; + const startTime = Date.now(); + progressTimerRef.current = setInterval(() => { + const elapsed = Date.now() - startTime; + setSimulationProgress(Math.min(90, Math.round((elapsed / expectedMs) * 90))); + }, 500); + }, []); + + const stopProgressTimer = useCallback((completed: boolean) => { + if (progressTimerRef.current) { + clearInterval(progressTimerRef.current); + progressTimerRef.current = null; + } + if (completed) { + setSimulationProgress(100); + setTimeout(() => setSimulationProgress(0), 800); + } else { + setSimulationProgress(0); + } + }, []); + + const handleRunSimulation = async (overrides?: { + models?: Set; + oilType?: string; + spillAmount?: number; + spillType?: string; + predictionTime?: number; + incidentCoord?: { lat: number; lon: number } | null; + }) => { // incidentName이 있으면 직접 입력 모드 — 기존 selectedAnalysis.acdntSn 무시하고 새 사고 생성 const isDirectInput = incidentName.trim().length > 0; const existingAcdntSn = isDirectInput ? undefined : (selectedAnalysis?.acdntSn ?? analysisDetail?.acdnt?.acdntSn); - // 선택 모드인데 사고도 없으면 실행 불가, 직접 입력 모드인데 사고명 없으면 실행 불가 - if (!isDirectInput && !existingAcdntSn) { - return; - } - if (!incidentCoord) { - return; - } + const effectiveCoord = overrides?.incidentCoord ?? incidentCoord; + if (!isDirectInput && !existingAcdntSn) return; + if (!effectiveCoord) return; + + const effectiveOilType = overrides?.oilType ?? oilType; + const effectiveSpillAmount = overrides?.spillAmount ?? spillAmount; + const effectiveSpillType = overrides?.spillType ?? spillType; + const effectivePredictionTime = overrides?.predictionTime ?? predictionTime; + const effectiveModels = overrides?.models ?? selectedModels; setIsRunningSimulation(true); setSimulationSummary(null); + startProgressTimer(effectivePredictionTime); + let simulationSucceeded = false; try { const payload: Record = { acdntSn: existingAcdntSn, - lat: incidentCoord.lat, - lon: incidentCoord.lon, - runTime: predictionTime, - matTy: oilType, - matVol: spillAmount, - spillTime: spillType === '연속' ? predictionTime : 0, + lat: effectiveCoord.lat, + lon: effectiveCoord.lon, + runTime: effectivePredictionTime, + matTy: effectiveOilType, + matVol: effectiveSpillAmount, + spillTime: effectiveSpillType === '연속' ? effectivePredictionTime : 0, startTime: accidentTime ? `${accidentTime}:00` : analysisDetail?.acdnt?.occurredAt, + models: Array.from(effectiveModels), }; - // 직접 입력 모드: 백엔드에서 ACDNT + SPIL_DATA 생성에 필요한 필드 추가 if (isDirectInput) { payload.acdntNm = incidentName.trim(); payload.spillUnit = spillUnit; payload.spillTypeCd = spillType; } - payload.models = Array.from(selectedModels); - - const { data } = await api.post('/simulation/run', payload); - setPendingExecSns( - data.execSns ?? (data.execSn ? [{ model: 'OpenDrift', execSn: data.execSn }] : []) - ); + // 동기 방식: 예측 완료 시 결과를 직접 반환 (최대 35분 대기) + const { data } = await api.post('/simulation/run-model', payload, { + timeout: 35 * 60 * 1000, + }); // 직접 입력으로 신규 생성된 경우: selectedAnalysis 갱신 + incidentName 초기화 if (data.acdntSn && isDirectInput) { @@ -747,8 +717,8 @@ export function OilSpillView() { oilType, volume: spillAmount, location: '', - lat: incidentCoord.lat, - lon: incidentCoord.lon, + lat: effectiveCoord.lat, + lon: effectiveCoord.lon, kospsStatus: 'pending', poseidonStatus: 'pending', opendriftStatus: 'pending', @@ -756,16 +726,76 @@ export function OilSpillView() { analyst: '', officeName: '', } as Analysis); - // 다음 실행 시 동일 사고 재생성 방지 — 이후에는 selectedAnalysis.acdntSn 사용 setIncidentName(''); } - // setIsRunningSimulation(false)는 폴링 결과 useEffect에서 처리 + + // 결과 처리 + const merged: OilParticle[] = []; + let latestSummary: SimulationSummary | null = null; + let latestCenterPoints: CenterPoint[] = []; + const newWindDataByModel: Record = {}; + const newHydrDataByModel: Record = {}; + const errors: string[] = []; + + data.results.forEach(({ model, status, trajectory, summary, centerPoints, windData, hydrData, error }) => { + if (status === 'ERROR') { + errors.push(error || `${model} 분석 중 오류가 발생했습니다.`); + return; + } + if (trajectory) { + merged.push(...trajectory.map(p => ({ ...p, model }))); + } + if (model === 'OpenDrift' || !latestSummary) { + if (summary) latestSummary = summary; + } + if (windData) newWindDataByModel[model] = windData; + if (hydrData) newHydrDataByModel[model] = hydrData; + if (centerPoints) { + latestCenterPoints = [...latestCenterPoints, ...centerPoints.map(p => ({ ...p, model }))]; + } + }); + + if (merged.length > 0) { + setOilTrajectory(merged); + const doneModels = new Set( + data.results + .filter(r => r.status === 'DONE' && r.trajectory && r.trajectory.length > 0) + .map(r => r.model as PredictionModel) + ); + setVisibleModels(doneModels); + setSimulationSummary(latestSummary); + setCenterPoints(latestCenterPoints); + + const refWindData = newWindDataByModel['OpenDrift'] ?? Object.values(newWindDataByModel)[0]; + const refHydrData = newHydrDataByModel['OpenDrift'] ?? Object.values(newHydrDataByModel)[0]; + doneModels.forEach(model => { + if (!newWindDataByModel[model] && refWindData) newWindDataByModel[model] = refWindData; + if (!newHydrDataByModel[model] && refHydrData) newHydrDataByModel[model] = refHydrData; + }); + + setWindDataByModel(newWindDataByModel); + setHydrDataByModel(newHydrDataByModel); + const booms = generateAIBoomLines(merged, effectiveCoord, algorithmSettings); + setBoomLines(booms); + setSensitiveResources(DEMO_SENSITIVE_RESOURCES); + setCurrentStep(0); + setIsPlaying(true); + setFlyToCoord({ lon: effectiveCoord.lon, lat: effectiveCoord.lat }); + } + + if (errors.length > 0 && merged.length === 0) { + setSimulationError(errors.join('; ')); + } else { + simulationSucceeded = true; + } } catch (err) { - setIsRunningSimulation(false); const msg = (err as { message?: string })?.message ?? '시뮬레이션 실행 중 오류가 발생했습니다.'; setSimulationError(msg); + } finally { + stopProgressTimer(simulationSucceeded); + setIsRunningSimulation(false); } } @@ -1077,7 +1107,8 @@ export function OilSpillView() {
+{currentStep}h — {(() => { - const d = new Date(); d.setHours(d.getHours() + currentStep); + const base = accidentTime ? new Date(accidentTime) : new Date(); + const d = new Date(base.getTime() + currentStep * 3600 * 1000); return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')} KST`; })()}
@@ -1150,7 +1181,7 @@ export function OilSpillView() { {isRunningSimulation && ( )} @@ -1179,7 +1210,14 @@ export function OilSpillView() { setPredictionTime(params.predictionTime) setIncidentCoord(params.incidentCoord) setSelectedModels(params.selectedModels) - handleRunSimulation() + handleRunSimulation({ + models: params.selectedModels, + oilType: params.oilType, + spillAmount: params.spillAmount, + spillType: params.spillType, + predictionTime: params.predictionTime, + incidentCoord: params.incidentCoord, + }) }} /> diff --git a/frontend/src/tabs/prediction/components/PredictionInputSection.tsx b/frontend/src/tabs/prediction/components/PredictionInputSection.tsx index a27c543..c8d7a57 100644 --- a/frontend/src/tabs/prediction/components/PredictionInputSection.tsx +++ b/frontend/src/tabs/prediction/components/PredictionInputSection.tsx @@ -49,7 +49,6 @@ const PredictionInputSection = ({ isRunningSimulation, selectedModels, onModelsChange, - visibleModels, onVisibleModelsChange, hasResults, predictionTime, @@ -393,20 +392,17 @@ const PredictionInputSection = ({ ] as const).map(m => (
{ if (!m.ready) { alert(`${m.id} 모델은 현재 준비중입니다.`) return } + const next = new Set(selectedModels) + if (next.has(m.id)) { next.delete(m.id) } else { next.add(m.id) } + onModelsChange(next) if (hasResults && onVisibleModelsChange) { - const next = new Set(visibleModels ?? selectedModels) - if (next.has(m.id)) { next.delete(m.id) } else { next.add(m.id) } - onVisibleModelsChange(next) - } else { - const next = new Set(selectedModels) - if (next.has(m.id)) { next.delete(m.id) } else { next.add(m.id) } - onModelsChange(next) + onVisibleModelsChange(new Set(next)) } }} > diff --git a/frontend/src/tabs/prediction/hooks/useSimulationStatus.ts b/frontend/src/tabs/prediction/hooks/useSimulationStatus.ts deleted file mode 100644 index 5775b3f..0000000 --- a/frontend/src/tabs/prediction/hooks/useSimulationStatus.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { useQuery, useQueries } from '@tanstack/react-query'; -import { api } from '@common/services/api'; -import type { SimulationStatusResponse } from '../services/predictionApi'; - -export const useSimulationStatus = (execSn: number | null) => { - return useQuery({ - queryKey: ['simulationStatus', execSn], - queryFn: () => api.get(`/simulation/status/${execSn}`).then(r => r.data), - enabled: execSn !== null, - refetchInterval: (query) => { - const status = query.state.data?.status; - if (status === 'DONE' || status === 'ERROR') return false; - return 3000; - }, - }); -}; - -export interface ModelExecRef { - model: string; - execSn: number; -} - -interface MultiSimulationStatus { - allDone: boolean; - anyError: boolean; - isLoading: boolean; - results: Map; - errors: Map; -} - -export const useMultiSimulationStatus = (execSns: ModelExecRef[]): MultiSimulationStatus => { - const queries = useQueries({ - queries: execSns.map(({ model, execSn }) => ({ - queryKey: ['simulationStatus', execSn], - queryFn: () => - api.get(`/simulation/status/${execSn}`).then(r => r.data), - enabled: execSns.length > 0, - refetchInterval: (query: { state: { data?: SimulationStatusResponse } }) => { - const status = query.state.data?.status; - if (status === 'DONE' || status === 'ERROR') return false; - return 3000; - }, - meta: { model }, - })), - }); - - if (execSns.length === 0) { - return { - allDone: false, - anyError: false, - isLoading: false, - results: new Map(), - errors: new Map(), - }; - } - - const results = new Map(); - const errors = new Map(); - - execSns.forEach(({ model }, index) => { - const query = queries[index]; - if (query.data) { - results.set(model, query.data); - } - if (query.error) { - const err = query.error; - errors.set(model, err instanceof Error ? err.message : String(err)); - } - }); - - const allDone = - execSns.length > 0 && - execSns.every((_, index) => { - const status = queries[index].data?.status; - return status === 'DONE' || status === 'ERROR'; - }); - - const anyError = execSns.some((_, index) => { - const status = queries[index].data?.status; - return status === 'ERROR' || queries[index].isError; - }); - - const isLoading = execSns.some((_, index) => queries[index].isLoading); - - return { allDone, anyError, isLoading, results, errors }; -}; diff --git a/frontend/src/tabs/prediction/services/predictionApi.ts b/frontend/src/tabs/prediction/services/predictionApi.ts index 7062ce7..6beea9a 100644 --- a/frontend/src/tabs/prediction/services/predictionApi.ts +++ b/frontend/src/tabs/prediction/services/predictionApi.ts @@ -184,12 +184,31 @@ export interface SimulationStatusResponse { error?: string; } +export interface RunModelSyncResult { + model: string; + execSn: number; + status: 'DONE' | 'ERROR'; + trajectory?: OilParticle[]; + summary?: SimulationSummary; + centerPoints?: CenterPoint[]; + windData?: WindPoint[][]; + hydrData?: (HydrDataStep | null)[]; + error?: string; +} + +export interface RunModelSyncResponse { + success: boolean; + acdntSn: number | null; + execSns: Array<{ model: string; execSn: number }>; + results: RunModelSyncResult[]; +} + export interface TrajectoryResponse { trajectory: OilParticle[] | null; summary: SimulationSummary | null; centerPoints?: CenterPoint[]; - windData?: WindPoint[][]; - hydrData?: (HydrDataStep | null)[]; + windDataByModel?: Record; + hydrDataByModel?: Record; } export const fetchAnalysisTrajectory = async (acdntSn: number): Promise => {