release: 2026-03-17 (3건 커밋) #96

병합
jhkang develop 에서 main 로 5 commits 를 머지했습니다 2026-03-17 18:42:01 +09:00
13개의 변경된 파일713개의 추가작업 그리고 246개의 파일을 삭제

파일 보기

@ -442,8 +442,14 @@ interface TrajectoryTimeStep {
hydr_grid?: TrajectoryHydrGrid; hydr_grid?: TrajectoryHydrGrid;
} }
// ALGO_CD → 프론트엔드 모델명 매핑
const ALGO_CD_TO_MODEL: Record<string, string> = {
'OPENDRIFT': 'OpenDrift',
'POSEIDON': 'POSEIDON',
};
interface TrajectoryResult { interface TrajectoryResult {
trajectory: Array<{ lat: number; lon: number; time: number; particle: number; stranded?: 0 | 1 }>; trajectory: Array<{ lat: number; lon: number; time: number; particle: number; stranded?: 0 | 1; model: string }>;
summary: { summary: {
remainingVolume: number; remainingVolume: number;
weatheredVolume: number; weatheredVolume: number;
@ -451,12 +457,12 @@ interface TrajectoryResult {
beachedVolume: number; beachedVolume: number;
pollutionCoastLength: number; pollutionCoastLength: number;
}; };
centerPoints: Array<{ lat: number; lon: number; time: number }>; centerPoints: Array<{ lat: number; lon: number; time: number; model: string }>;
windData: TrajectoryWindPoint[][]; windData: TrajectoryWindPoint[][];
hydrData: ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[]; hydrData: ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[];
} }
function transformTrajectoryResult(rawResult: TrajectoryTimeStep[]): TrajectoryResult { function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: string): TrajectoryResult {
const trajectory = rawResult.flatMap((step, stepIdx) => const trajectory = rawResult.flatMap((step, stepIdx) =>
step.particles.map((p, i) => ({ step.particles.map((p, i) => ({
lat: p.lat, lat: p.lat,
@ -464,6 +470,7 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[]): TrajectoryR
time: stepIdx, time: stepIdx,
particle: i, particle: i,
stranded: p.stranded, stranded: p.stranded,
model,
})) }))
); );
const lastStep = rawResult[rawResult.length - 1]; const lastStep = rawResult[rawResult.length - 1];
@ -477,10 +484,10 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[]): TrajectoryR
const centerPoints = rawResult const centerPoints = rawResult
.map((step, stepIdx) => .map((step, stepIdx) =>
step.center_lat != null && step.center_lon != null step.center_lat != null && step.center_lon != null
? { lat: step.center_lat, lon: step.center_lon, time: stepIdx } ? { lat: step.center_lat, lon: step.center_lon, time: stepIdx, model }
: null : null
) )
.filter((p): p is { lat: number; lon: number; time: number } => p !== null); .filter((p): p is { lat: number; lon: number; time: number; model: string } => p !== null);
const windData = rawResult.map((step) => step.wind_data ?? []); const windData = rawResult.map((step) => step.wind_data ?? []);
const hydrData = rawResult.map((step) => const hydrData = rawResult.map((step) =>
step.hydr_data && step.hydr_grid step.hydr_data && step.hydr_grid
@ -491,14 +498,52 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[]): TrajectoryR
} }
export async function getAnalysisTrajectory(acdntSn: number): Promise<TrajectoryResult | null> { export async function getAnalysisTrajectory(acdntSn: number): Promise<TrajectoryResult | null> {
// 완료된 모든 모델(OPENDRIFT, POSEIDON) 결과 조회
const sql = ` const sql = `
SELECT RSLT_DATA FROM wing.PRED_EXEC SELECT ALGO_CD, RSLT_DATA, CMPL_DTM FROM wing.PRED_EXEC
WHERE ACDNT_SN = $1 AND ALGO_CD = 'OPENDRIFT' AND EXEC_STTS_CD = 'COMPLETED' WHERE ACDNT_SN = $1
ORDER BY CMPL_DTM DESC LIMIT 1 AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
AND EXEC_STTS_CD = 'COMPLETED'
ORDER BY CMPL_DTM DESC
`; `;
const { rows } = await wingPool.query(sql, [acdntSn]); const { rows } = await wingPool.query(sql, [acdntSn]);
if (rows.length === 0 || !rows[0].rslt_data) return null; if (rows.length === 0) return null;
return transformTrajectoryResult(rows[0].rslt_data as TrajectoryTimeStep[]);
// 모든 모델의 파티클을 하나의 배열로 병합
let mergedTrajectory: TrajectoryResult['trajectory'] = [];
let allCenterPoints: TrajectoryResult['centerPoints'] = [];
// summary/windData/hydrData: 가장 최근 완료된 OpenDrift 기준, 없으면 POSEIDON 기준
let baseResult: TrajectoryResult | null = null;
// OpenDrift 우선, 없으면 POSEIDON 선택 (ORDER BY CMPL_DTM DESC이므로 첫 번째 행이 가장 최근)
const opendriftRow = (rows as Array<Record<string, unknown>>).find((r) => r['algo_cd'] === 'OPENDRIFT');
const poseidonRow = (rows as Array<Record<string, unknown>>).find((r) => r['algo_cd'] === 'POSEIDON');
const baseRow = opendriftRow ?? poseidonRow ?? null;
for (const row of rows as Array<Record<string, unknown>>) {
if (!row['rslt_data']) continue;
const algoCd = String(row['algo_cd'] ?? '');
const modelName = ALGO_CD_TO_MODEL[algoCd] ?? algoCd;
const parsed = transformTrajectoryResult(row['rslt_data'] as TrajectoryTimeStep[], modelName);
mergedTrajectory = mergedTrajectory.concat(parsed.trajectory);
allCenterPoints = allCenterPoints.concat(parsed.centerPoints);
// 기준 행의 결과를 baseResult로 사용
if (row === baseRow) {
baseResult = parsed;
}
}
if (!baseResult) return null;
return {
trajectory: mergedTrajectory,
summary: baseResult.summary,
centerPoints: allCenterPoints,
windData: baseResult.windData,
hydrData: baseResult.hydrData,
};
} }
export async function listBoomLines(acdntSn: number): Promise<BoomLineItem[]> { export async function listBoomLines(acdntSn: number): Promise<BoomLineItem[]> {

파일 보기

@ -11,6 +11,7 @@ import {
const router = Router() const router = Router()
const PYTHON_API_URL = process.env.PYTHON_API_URL ?? 'http://localhost:5003' const PYTHON_API_URL = process.env.PYTHON_API_URL ?? 'http://localhost:5003'
const POSEIDON_API_URL = process.env.POSEIDON_API_URL ?? 'http://localhost:5004'
const POLL_INTERVAL_MS = 3000 const POLL_INTERVAL_MS = 3000
const POLL_TIMEOUT_MS = 30 * 60 * 1000 // 30분 const POLL_TIMEOUT_MS = 30 * 60 * 1000 // 30분
@ -71,20 +72,38 @@ async function rollbackNewRecords(
} }
} }
// 모델명 → ALGO_CD 매핑
const MODEL_ALGO_CD_MAP: Record<string, string> = {
'OpenDrift': 'OPENDRIFT',
'POSEIDON': 'POSEIDON',
}
// 모델명 → API URL 매핑
const MODEL_API_URL_MAP: Record<string, string> = {
'OpenDrift': PYTHON_API_URL,
'POSEIDON': POSEIDON_API_URL,
}
// ============================================================ // ============================================================
// POST /api/simulation/run // POST /api/simulation/run
// 확산 시뮬레이션 실행 (OpenDrift) // 확산 시뮬레이션 실행 (다중 모델 지원: OpenDrift, POSEIDON)
// ============================================================ // ============================================================
/** /**
* OpenDrift . * (OpenDrift, POSEIDON) .
* Python FastAPI job_id를 * PRED_EXEC Python API에 .
* DB에 . * KOSPS PRED_EXEC INSERT(PENDING) API .
* execSn으로 GET /status/:execSn을 . * execSns execSn으로 GET /status/:execSn을 .
*/ */
router.post('/run', requireAuth, async (req: Request, res: Response) => { router.post('/run', requireAuth, async (req: Request, res: Response) => {
try { try {
const { acdntSn: rawAcdntSn, acdntNm, spillUnit, spillTypeCd, const { acdntSn: rawAcdntSn, acdntNm, spillUnit, spillTypeCd,
lat, lon, runTime, matTy, matVol, spillTime, startTime } = req.body lat, lon, runTime, matTy, matVol, spillTime, startTime,
models: rawModels } = req.body
// 실행할 모델 목록 (기본값: OpenDrift)
const requestedModels: string[] = Array.isArray(rawModels) && rawModels.length > 0
? (rawModels as string[])
: ['OpenDrift']
// 1. 필수 파라미터 검증 // 1. 필수 파라미터 검증
if (lat === undefined || lon === undefined || runTime === undefined) { if (lat === undefined || lon === undefined || runTime === undefined) {
@ -117,21 +136,24 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
} }
// 2. Python NC 파일 존재 여부 확인 (ACDNT 생성 전에 수행하여 고아 레코드 방지) // 2. Python NC 파일 존재 여부 확인 (ACDNT 생성 전에 수행하여 고아 레코드 방지)
try { // OpenDrift 모델이 포함된 경우에만 check-nc 수행
const checkRes = await fetch(`${PYTHON_API_URL}/check-nc`, { if (requestedModels.includes('OpenDrift')) {
method: 'POST', try {
headers: { 'Content-Type': 'application/json' }, const checkRes = await fetch(`${PYTHON_API_URL}/check-nc`, {
body: JSON.stringify({ lat, lon, startTime }), method: 'POST',
signal: AbortSignal.timeout(5000), headers: { 'Content-Type': 'application/json' },
}) body: JSON.stringify({ lat, lon, startTime }),
if (!checkRes.ok) { signal: AbortSignal.timeout(5000),
return res.status(409).json({
error: '해당 좌표의 해양 기상 데이터가 없습니다.',
message: 'NC 파일이 준비되지 않았습니다.',
}) })
if (!checkRes.ok) {
return res.status(409).json({
error: '해당 좌표의 해양 기상 데이터가 없습니다.',
message: 'NC 파일이 준비되지 않았습니다.',
})
}
} catch {
// Python 서버 미기동 — 5번에서 처리
} }
} catch {
// Python 서버 미기동 — 5번에서 처리
} }
// 1-B. acdntSn 미제공 시 ACDNT + SPIL_DATA 생성 // 1-B. acdntSn 미제공 시 ACDNT + SPIL_DATA 생성
@ -199,83 +221,131 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
} }
} }
// 4. PRED_EXEC INSERT (PENDING) — ACDNT_SN 포함 (NOT NULL FK)
const execNm = `EXPC_${Date.now()}`
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, 'OPENDRIFT', 'PENDING', $3, NOW())
RETURNING PRED_EXEC_SN`,
[resolvedAcdntSn, resolvedSpilDataSn, execNm]
)
predExecSn = insertRes.rows[0].pred_exec_sn as number
} catch (dbErr) {
console.error('[simulation] PRED_EXEC INSERT 실패:', dbErr)
return res.status(500).json({ error: '분석 기록 생성 실패' })
}
// matTy 변환: 한국어 유종 → OpenDrift 유종 코드 // matTy 변환: 한국어 유종 → OpenDrift 유종 코드
// 매핑 대상이 아니면 원본 값 그대로 사용 (영문 직접 입력 대응) // 매핑 대상이 아니면 원본 값 그대로 사용 (영문 직접 입력 대응)
const odMatTy = matTy !== undefined ? (OIL_TYPE_MAP[matTy as string] ?? (matTy as string)) : undefined const odMatTy = matTy !== undefined ? (OIL_TYPE_MAP[matTy as string] ?? (matTy as string)) : undefined
// 5. Python /run-model 호출 // 4. 각 모델별 PRED_EXEC INSERT 및 API 호출 (병렬)
let jobId: string // KOSPS: PRED_EXEC PENDING 생성만 하고 배열에서 제외 (외부 API 미연동)
try { const execNmBase = `EXPC_${Date.now()}`
const pythonRes = await fetch(`${PYTHON_API_URL}/run-model`, { const execSns: Array<{ model: string; execSn: number }> = []
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
lat,
lon,
startTime,
runTime,
matTy: odMatTy,
matVol,
spillTime,
name: execNm,
}),
signal: AbortSignal.timeout(10000),
})
if (pythonRes.status === 503) { // KOSPS 처리: PRED_EXEC INSERT(PENDING)만 수행
const errData = await pythonRes.json() as { error?: string } if (requestedModels.includes('KOSPS')) {
await wingPool.query( try {
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`, const kospsExecNm = `${execNmBase}_KOSPS`
[errData.error || '분석 서버 포화', predExecSn] 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]
) )
await rollbackNewRecords(predExecSn, newlyCreatedSpilDataSn, newlyCreatedAcdntSn) execSns.push({ model: 'KOSPS', execSn: insertRes.rows[0].pred_exec_sn as number })
return res.status(503).json({ error: errData.error || '분석 서버가 사용 중입니다. 잠시 후 재시도해 주세요.' }) } catch (dbErr) {
console.error('[simulation] KOSPS PRED_EXEC INSERT 실패:', dbErr)
} }
if (!pythonRes.ok) {
throw new Error(`Python 서버 응답 오류: ${pythonRes.status}`)
}
const pythonData = await pythonRes.json() as { job_id: string }
jobId = pythonData.job_id
} catch {
await wingPool.query(
`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 분석 서버에 연결할 수 없습니다.' })
} }
// 6. RUNNING 업데이트 // API 연동 모델 필터링 (KOSPS 제외)
await wingPool.query( const apiModels = requestedModels.filter((m) => m !== 'KOSPS' && MODEL_ALGO_CD_MAP[m] !== undefined)
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='RUNNING' WHERE PRED_EXEC_SN=$1`,
[predExecSn] // 각 모델에 대해 PRED_EXEC INSERT → /run-model 호출
await Promise.all(
apiModels.map(async (model) => {
const algoCd = MODEL_ALGO_CD_MAP[model]
const apiUrl = MODEL_API_URL_MAP[model]
const execNm = `${execNmBase}_${algoCd}`
// PRED_EXEC INSERT (PENDING)
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] ${model} PRED_EXEC INSERT 실패:`, dbErr)
return
}
execSns.push({ model, execSn: predExecSn })
// Python /run-model 호출
let jobId: string
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(10000),
})
if (pythonRes.status === 503) {
const errData = await pythonRes.json() as { error?: string }
await wingPool.query(
`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
}
if (!pythonRes.ok) {
throw new Error(`Python 서버 응답 오류: ${pythonRes.status}`)
}
const pythonData = await pythonRes.json() as { job_id: string }
jobId = pythonData.job_id
} catch {
await wingPool.query(
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG='Python 분석 서버에 연결할 수 없습니다.', CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$1`,
[predExecSn]
)
// 이 모델의 PRED_EXEC만 롤백 (다른 모델은 계속 진행)
await rollbackNewRecords(predExecSn, null, null)
return
}
// RUNNING 업데이트
await wingPool.query(
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='RUNNING' WHERE PRED_EXEC_SN=$1`,
[predExecSn]
)
// 백그라운드 폴링 시작
pollAndSaveModel(jobId, predExecSn, apiUrl, algoCd).catch((err: unknown) =>
console.error(`[simulation] ${model} pollAndSaveModel 오류:`, err)
)
})
) )
// 7. 즉시 응답 (프론트엔드는 execSn으로 폴링, acdntSn은 신규 생성 사고 추적용) // ACDNT/SPIL_DATA가 신규 생성됐으나 모든 모델이 실패한 경우 롤백
res.json({ success: true, execSn: predExecSn, acdntSn: resolvedAcdntSn, status: 'RUNNING' }) const hasRunning = execSns.some(({ model }) => model !== 'KOSPS')
if (!hasRunning && newlyCreatedAcdntSn !== null) {
await rollbackNewRecords(null, newlyCreatedSpilDataSn, newlyCreatedAcdntSn)
return res.status(503).json({ error: '분석 서버에 연결할 수 없습니다.' })
}
// 8. 백그라운드 폴링 시작 // 즉시 응답 (하위 호환을 위해 execSn도 포함)
pollAndSave(jobId, predExecSn).catch((err: unknown) => res.json({
console.error('[simulation] pollAndSave 오류:', err) success: true,
) execSns,
execSn: execSns[0]?.execSn ?? 0,
acdntSn: resolvedAcdntSn,
status: 'RUNNING',
})
} catch { } catch {
res.status(500).json({ error: '시뮬레이션 실행 실패', message: '서버 내부 오류가 발생했습니다.' }) res.status(500).json({ error: '시뮬레이션 실행 실패', message: '서버 내부 오류가 발생했습니다.' })
} }
@ -297,7 +367,7 @@ router.get('/status/:execSn', requireAuth, async (req: Request, res: Response) =
try { try {
const result = await wingPool.query( const result = await wingPool.query(
`SELECT pe.EXEC_STTS_CD, pe.RSLT_DATA, pe.ERR_MSG, pe.BGNG_DTM, sd.FCST_HR, `SELECT pe.EXEC_STTS_CD, pe.RSLT_DATA, pe.ERR_MSG, pe.BGNG_DTM, pe.ALGO_CD, sd.FCST_HR,
( (
SELECT AVG(hist.REQD_SEC::FLOAT / hsd.FCST_HR) SELECT AVG(hist.REQD_SEC::FLOAT / hsd.FCST_HR)
FROM wing.PRED_EXEC hist FROM wing.PRED_EXEC hist
@ -328,7 +398,9 @@ router.get('/status/:execSn', requireAuth, async (req: Request, res: Response) =
const status = statusMap[dbStatus] ?? dbStatus const status = statusMap[dbStatus] ?? dbStatus
if (status === 'DONE' && row.rslt_data) { if (status === 'DONE' && row.rslt_data) {
const { trajectory, summary, centerPoints, windData, hydrData } = transformResult(row.rslt_data as PythonTimeStep[]) const algoCd = String(row.algo_cd ?? '')
const modelName = ALGO_CD_TO_MODEL_NAME[algoCd] ?? algoCd
const { trajectory, summary, centerPoints, windData, hydrData } = transformResult(row.rslt_data as PythonTimeStep[], modelName)
return res.json({ status, trajectory, summary, centerPoints, windData, hydrData }) return res.json({ status, trajectory, summary, centerPoints, windData, hydrData })
} }
@ -356,14 +428,14 @@ router.get('/status/:execSn', requireAuth, async (req: Request, res: Response) =
// ============================================================ // ============================================================
// 백그라운드 폴링 // 백그라운드 폴링
// ============================================================ // ============================================================
async function pollAndSave(jobId: string, execSn: number): Promise<void> { async function pollAndSaveModel(jobId: string, execSn: number, apiUrl: string, algoCode: string): Promise<void> {
const deadline = Date.now() + POLL_TIMEOUT_MS const deadline = Date.now() + POLL_TIMEOUT_MS
while (Date.now() < deadline) { while (Date.now() < deadline) {
await new Promise<void>(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) await new Promise<void>(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
try { try {
const pollRes = await fetch(`${PYTHON_API_URL}/status/${jobId}`, { const pollRes = await fetch(`${apiUrl}/status/${jobId}`, {
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),
}) })
if (!pollRes.ok) continue if (!pollRes.ok) continue
@ -446,7 +518,13 @@ interface PythonStatusResponse {
error?: string error?: string
} }
function transformResult(rawResult: PythonTimeStep[]) { // ALGO_CD → 프론트엔드 모델명 매핑
const ALGO_CD_TO_MODEL_NAME: Record<string, string> = {
'OPENDRIFT': 'OpenDrift',
'POSEIDON': 'POSEIDON',
}
function transformResult(rawResult: PythonTimeStep[], model: string) {
const trajectory = rawResult.flatMap((step, stepIdx) => const trajectory = rawResult.flatMap((step, stepIdx) =>
step.particles.map((p, i) => ({ step.particles.map((p, i) => ({
lat: p.lat, lat: p.lat,
@ -467,10 +545,10 @@ function transformResult(rawResult: PythonTimeStep[]) {
const centerPoints = rawResult const centerPoints = rawResult
.map((step, stepIdx) => .map((step, stepIdx) =>
step.center_lat != null && step.center_lon != null step.center_lat != null && step.center_lon != null
? { lat: step.center_lat, lon: step.center_lon, time: stepIdx } ? { lat: step.center_lat, lon: step.center_lon, time: stepIdx, model }
: null : null
) )
.filter((p): p is { lat: number; lon: number; time: number } => p !== null) .filter((p): p is { lat: number; lon: number; time: number; model: string } => p !== null)
const windData = rawResult.map((step) => step.wind_data ?? []) const windData = rawResult.map((step) => step.wind_data ?? [])
const hydrData = rawResult.map((step) => const hydrData = rawResult.map((step) =>
step.hydr_data && step.hydr_grid step.hydr_data && step.hydr_grid

파일 보기

@ -4,6 +4,11 @@
## [Unreleased] ## [Unreleased]
## [2026-03-17]
### 추가
- 다중 모델 시뮬레이션 지원 (OpenDrift + POSEIDON 병렬 실행 및 결과 병합)
## [2026-03-16] ## [2026-03-16]
### 추가 ### 추가

파일 보기

@ -78,7 +78,7 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
{/* 실시간 상황관리 */} {/* 실시간 상황관리 */}
<button <button
onClick={() => window.open(import.meta.env.VITE_SITUATIONAL_URL ?? 'http://localhost:5174', '_blank')} onClick={() => window.open(import.meta.env.VITE_SITUATIONAL_URL ?? 'https://kcg.gc-si.dev', '_blank')}
className={` className={`
px-2.5 xl:px-4 py-2 rounded-sm text-[13px] transition-all duration-200 px-2.5 xl:px-4 py-2 rounded-sm text-[13px] transition-all duration-200
font-korean tracking-[0.2px] font-semibold font-korean tracking-[0.2px] font-semibold

파일 보기

@ -321,7 +321,7 @@ interface MapViewProps {
incidentCoord?: { lon: number; lat: number } incidentCoord?: { lon: number; lat: number }
isSelectingLocation?: boolean isSelectingLocation?: boolean
onMapClick?: (lon: number, lat: number) => void onMapClick?: (lon: number, lat: number) => void
oilTrajectory?: Array<{ lat: number; lon: number; time: number; particle?: number; model?: PredictionModel; stranded?: 0 | 1 }> oilTrajectory?: Array<{ lat: number; lon: number; time: number; particle?: number; model?: string; stranded?: 0 | 1 }>
selectedModels?: Set<PredictionModel> selectedModels?: Set<PredictionModel>
dispersionResult?: DispersionResult | null dispersionResult?: DispersionResult | null
dispersionHeatmap?: Array<{ lon: number; lat: number; concentration: number }> dispersionHeatmap?: Array<{ lon: number; lat: number; concentration: number }>
@ -341,7 +341,7 @@ interface MapViewProps {
sensitiveResources?: SensitiveResource[] sensitiveResources?: SensitiveResource[]
flyToTarget?: { lng: number; lat: number; zoom?: number } | null flyToTarget?: { lng: number; lat: number; zoom?: number } | null
fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null
centerPoints?: Array<{ lat: number; lon: number; time: number }> centerPoints?: Array<{ lat: number; lon: number; time: number; model?: string }>
windData?: Array<Array<{ lat: number; lon: number; wind_speed: number; wind_direction: number }>> windData?: Array<Array<{ lat: number; lon: number; wind_speed: number; wind_direction: number }>>
hydrData?: (HydrDataStep | null)[] hydrData?: (HydrDataStep | null)[]
// 외부 플레이어 제어 (prediction 하단 바에서 제어할 때 사용) // 외부 플레이어 제어 (prediction 하단 바에서 제어할 때 사용)
@ -414,14 +414,15 @@ function MapPitchController({ threeD }: { threeD: boolean }) {
} }
// 사고 지점 변경 시 지도 이동 (Map 내부 컴포넌트) // 사고 지점 변경 시 지도 이동 (Map 내부 컴포넌트)
function MapFlyToIncident({ lon, lat, onFlyEnd }: { lon?: number; lat?: number; onFlyEnd?: () => void }) { function MapFlyToIncident({ coord, onFlyEnd }: { coord?: { lon: number; lat: number }; onFlyEnd?: () => void }) {
const { current: map } = useMap() const { current: map } = useMap()
const onFlyEndRef = useRef(onFlyEnd) const onFlyEndRef = useRef(onFlyEnd)
useEffect(() => { onFlyEndRef.current = onFlyEnd }, [onFlyEnd]) useEffect(() => { onFlyEndRef.current = onFlyEnd }, [onFlyEnd])
useEffect(() => { useEffect(() => {
if (!map || lon == null || lat == null) return if (!map || !coord) return
const { lon, lat } = coord
const doFly = () => { const doFly = () => {
map.flyTo({ center: [lon, lat], zoom: 11, duration: 1200 }) map.flyTo({ center: [lon, lat], zoom: 11, duration: 1200 })
map.once('moveend', () => onFlyEndRef.current?.()) map.once('moveend', () => onFlyEndRef.current?.())
@ -432,7 +433,7 @@ function MapFlyToIncident({ lon, lat, onFlyEnd }: { lon?: number; lat?: number;
} else { } else {
map.once('load', doFly) map.once('load', doFly)
} }
}, [lon, lat, map]) }, [coord, map]) // 객체 참조 추적: 같은 좌표라도 새 객체면 effect 재실행
return null return null
} }
@ -596,15 +597,13 @@ export function MapView({
getPosition: (d: (typeof visibleParticles)[0]) => [d.lon, d.lat], getPosition: (d: (typeof visibleParticles)[0]) => [d.lon, d.lat],
getRadius: 3, getRadius: 3,
getFillColor: (d: (typeof visibleParticles)[0]) => { getFillColor: (d: (typeof visibleParticles)[0]) => {
// 1순위: stranded 입자 → showBeached=true 시 빨간색, false 시 회색 const modelKey = (d.model || Array.from(selectedModels)[0] || 'OpenDrift') as PredictionModel
// 1순위: stranded 입자 → showBeached=true 시 모델 색, false 시 회색
if (d.stranded === 1) return showBeached if (d.stranded === 1) return showBeached
? [239, 68, 68, 220] as [number, number, number, number] ? hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180)
: [130, 130, 130, 70] as [number, number, number, number] : [130, 130, 130, 70] as [number, number, number, number]
// 2순위: 현재 활성 스텝 → 모델 기본 색상 // 2순위: 현재 활성 스텝 → 모델 기본 색상
if (d.time === activeStep) { if (d.time === activeStep) return hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180)
const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift'
return hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180)
}
// 3순위: 과거 스텝 → 회색 + 투명 // 3순위: 과거 스텝 → 회색 + 투명
return [130, 130, 130, 70] as [number, number, number, number] return [130, 130, 130, 70] as [number, number, number, number]
}, },
@ -638,6 +637,31 @@ export function MapView({
) )
} }
// --- 육지부착 hollow ring (stranded 모양 구분) ---
const strandedParticles = showBeached ? visibleParticles.filter(p => p.stranded === 1) : []
if (strandedParticles.length > 0) {
result.push(
new ScatterplotLayer({
id: 'oil-stranded-ring',
data: strandedParticles,
getPosition: (d: (typeof strandedParticles)[0]) => [d.lon, d.lat],
stroked: true,
filled: false,
getLineColor: (d: (typeof strandedParticles)[0]) => {
const modelKey = (d.model || Array.from(selectedModels)[0] || 'OpenDrift') as PredictionModel
return hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 255)
},
lineWidthMinPixels: 2,
getRadius: 4,
radiusMinPixels: 5,
radiusMaxPixels: 8,
updateTriggers: {
getLineColor: [selectedModels],
},
})
)
}
// --- 오일펜스 라인 (PathLayer) --- // --- 오일펜스 라인 (PathLayer) ---
if (boomLines.length > 0) { if (boomLines.length > 0) {
result.push( result.push(
@ -1042,65 +1066,74 @@ export function MapView({
) )
} }
// --- 입자 중심점 이동 경로 (PathLayer + ScatterplotLayer) --- // --- 입자 중심점 이동 경로 (모델별 PathLayer + ScatterplotLayer) ---
const visibleCenters = centerPoints.filter(p => p.time <= currentTime) const visibleCenters = centerPoints.filter(p => p.time <= currentTime)
if (visibleCenters.length >= 2) {
result.push(
new PathLayer({
id: 'center-path',
data: [{ path: visibleCenters.map(p => [p.lon, p.lat] as [number, number]) }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: (lightMode ? [0, 60, 150, 230] : [255, 220, 50, 200]) as [number, number, number, number],
getWidth: 2,
widthMinPixels: 2,
widthMaxPixels: 4,
})
)
}
if (visibleCenters.length > 0) { if (visibleCenters.length > 0) {
result.push( // 모델별 그룹핑 (Record 사용 — Map 컴포넌트와 이름 충돌 회피)
new ScatterplotLayer({ const modelGroups: Record<string, typeof visibleCenters> = {}
id: 'center-points', visibleCenters.forEach(p => {
data: visibleCenters, const key = p.model || 'OpenDrift'
getPosition: (d: (typeof visibleCenters)[0]) => [d.lon, d.lat], if (!modelGroups[key]) modelGroups[key] = []
getRadius: 5, modelGroups[key].push(p)
getFillColor: (lightMode ? [0, 60, 150, 230] : [255, 220, 50, 230]) as [number, number, number, number], })
radiusMinPixels: 4,
radiusMaxPixels: 8,
pickable: false,
})
)
}
// --- 시간 표시 라벨 (TextLayer) --- Object.entries(modelGroups).forEach(([model, points]) => {
if (visibleCenters.length > 0 && showTimeLabel) { const modelColor = hexToRgba(MODEL_COLORS[model as PredictionModel] || '#06b6d4', 210)
const baseTime = simulationStartTime ? new Date(simulationStartTime) : null; if (points.length >= 2) {
const pad = (n: number) => String(n).padStart(2, '0'); result.push(
result.push( new PathLayer({
new TextLayer({ id: `center-path-${model}`,
id: 'time-labels', data: [{ path: points.map((p: { lon: number; lat: number }) => [p.lon, p.lat] as [number, number]) }],
data: visibleCenters, getPath: (d: { path: [number, number][] }) => d.path,
getPosition: (d: (typeof visibleCenters)[0]) => [d.lon, d.lat], getColor: modelColor,
getText: (d: (typeof visibleCenters)[0]) => { getWidth: 2,
if (baseTime) { widthMinPixels: 2,
const dt = new Date(baseTime.getTime() + d.time * 3600 * 1000); widthMaxPixels: 4,
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; })
} )
return `+${d.time}h`; }
}, result.push(
getSize: 12, new ScatterplotLayer({
getColor: (lightMode ? [20, 40, 100, 240] : [255, 220, 50, 220]) as [number, number, number, number], id: `center-points-${model}`,
getPixelOffset: [0, 16] as [number, number], data: points,
fontWeight: 'bold', getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat],
outlineWidth: 2, getRadius: 5,
outlineColor: (lightMode ? [255, 255, 255, 180] : [15, 21, 36, 200]) as [number, number, number, number], getFillColor: modelColor,
billboard: true, radiusMinPixels: 4,
sizeUnits: 'pixels' as const, radiusMaxPixels: 8,
updateTriggers: { pickable: false,
getText: [simulationStartTime, currentTime], })
}, )
}) if (showTimeLabel) {
) const baseTime = simulationStartTime ? new Date(simulationStartTime) : null;
const pad = (n: number) => String(n).padStart(2, '0');
result.push(
new TextLayer({
id: `time-labels-${model}`,
data: points,
getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat],
getText: (d: { time: number }) => {
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: hexToRgba(MODEL_COLORS[model as PredictionModel] || '#06b6d4', 240),
getPixelOffset: [0, 16] as [number, number],
fontWeight: 'bold',
outlineWidth: 2,
outlineColor: (lightMode ? [255, 255, 255, 180] : [15, 21, 36, 200]) as [number, number, number, number],
billboard: true,
sizeUnits: 'pixels' as const,
updateTriggers: {
getText: [simulationStartTime, currentTime],
},
})
)
}
})
} }
// --- 바람 화살표 (TextLayer) --- // --- 바람 화살표 (TextLayer) ---
@ -1177,7 +1210,7 @@ export function MapView({
{/* 3D 모드 pitch 제어 */} {/* 3D 모드 pitch 제어 */}
<MapPitchController threeD={mapToggles.threeD} /> <MapPitchController threeD={mapToggles.threeD} />
{/* 사고 지점 변경 시 지도 이동 */} {/* 사고 지점 변경 시 지도 이동 */}
<MapFlyToIncident lon={flyToIncident?.lon} lat={flyToIncident?.lat} onFlyEnd={onIncidentFlyEnd} /> <MapFlyToIncident coord={flyToIncident} onFlyEnd={onIncidentFlyEnd} />
{/* 외부에서 flyTo 트리거 */} {/* 외부에서 flyTo 트리거 */}
<FlyToController flyToTarget={flyToTarget} /> <FlyToController flyToTarget={flyToTarget} />
{/* 예측 완료 시 궤적 전체 범위로 fitBounds */} {/* 예측 완료 시 궤적 전체 범위로 fitBounds */}

파일 보기

@ -8,15 +8,59 @@ import { decimalToDMS } from '@common/utils/coordinates';
const MAX_IMAGES = 6; const MAX_IMAGES = 6;
interface GpsInfo { interface ImageExif {
lat: number | null; lat: number | null;
lon: number | null; lon: number | null;
altitude: number | null;
make: string | null;
model: string | null;
dateTime: Date | string | null;
exposureTime: number | null;
fNumber: number | null;
iso: number | null;
focalLength: number | null;
imageWidth: number | null;
imageHeight: number | null;
}
function formatFileSize(bytes?: number): string | null {
if (bytes == null) return null;
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDateTime(dt: Date | string | null): string | null {
if (!dt) return null;
if (dt instanceof Date) {
return dt.toLocaleString('ko-KR', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
});
}
return String(dt);
}
interface MetaRowProps {
label: string;
value: string | null | undefined;
}
function MetaRow({ label, value }: MetaRowProps) {
if (value == null) return null;
return (
<div className="flex justify-between gap-2 py-0.5 border-b border-border/40 last:border-0 font-korean">
<span className="text-text-3 shrink-0">{label}</span>
<span className="text-text-1 text-right break-all">{value}</span>
</div>
);
} }
export function OilAreaAnalysis() { export function OilAreaAnalysis() {
const [selectedFiles, setSelectedFiles] = useState<File[]>([]); const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [previewUrls, setPreviewUrls] = useState<string[]>([]); const [previewUrls, setPreviewUrls] = useState<string[]>([]);
const [imageGpsInfos, setImageGpsInfos] = useState<(GpsInfo | undefined)[]>([]); const [imageExifs, setImageExifs] = useState<(ImageExif | undefined)[]>([]);
const [selectedImageIndex, setSelectedImageIndex] = useState<number | null>(null);
const [stitchedBlob, setStitchedBlob] = useState<Blob | null>(null); const [stitchedBlob, setStitchedBlob] = useState<Blob | null>(null);
const [stitchedPreviewUrl, setStitchedPreviewUrl] = useState<string | null>(null); const [stitchedPreviewUrl, setStitchedPreviewUrl] = useState<string | null>(null);
const [isStitching, setIsStitching] = useState(false); const [isStitching, setIsStitching] = useState(false);
@ -34,28 +78,45 @@ export function OilAreaAnalysis() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// 선택된 파일이 바뀔 때마다 새 파일의 EXIF GPS 추출 // 선택된 파일이 바뀔 때마다 새 파일의 EXIF 전체 추출
useEffect(() => { useEffect(() => {
selectedFiles.forEach((file, i) => { selectedFiles.forEach((file, i) => {
if (processedFilesRef.current.has(file)) return; if (processedFilesRef.current.has(file)) return;
processedFilesRef.current.add(file); processedFilesRef.current.add(file);
exifr.gps(file) exifr.parse(file, { gps: true, exif: true, ifd0: true, translateValues: false })
.then(gps => { .then(exif => {
setImageGpsInfos(prev => { const info: ImageExif = {
lat: exif?.latitude ?? null,
lon: exif?.longitude ?? null,
altitude: exif?.GPSAltitude ?? null,
make: exif?.Make ?? null,
model: exif?.Model ?? null,
dateTime: exif?.DateTimeOriginal ?? null,
exposureTime: exif?.ExposureTime ?? null,
fNumber: exif?.FNumber ?? null,
iso: exif?.ISO ?? null,
focalLength: exif?.FocalLength ?? null,
imageWidth: exif?.ImageWidth ?? exif?.ExifImageWidth ?? null,
imageHeight: exif?.ImageHeight ?? exif?.ExifImageHeight ?? null,
};
setImageExifs(prev => {
const updated = [...prev]; const updated = [...prev];
while (updated.length <= i) updated.push(undefined); while (updated.length <= i) updated.push(undefined);
updated[i] = gps updated[i] = info;
? { lat: gps.latitude, lon: gps.longitude }
: { lat: null, lon: null };
return updated; return updated;
}); });
}) })
.catch(() => { .catch(() => {
setImageGpsInfos(prev => { setImageExifs(prev => {
const updated = [...prev]; const updated = [...prev];
while (updated.length <= i) updated.push(undefined); while (updated.length <= i) updated.push(undefined);
updated[i] = { lat: null, lon: null }; updated[i] = {
lat: null, lon: null, altitude: null,
make: null, model: null, dateTime: null,
exposureTime: null, fNumber: null, iso: null,
focalLength: null, imageWidth: null, imageHeight: null,
};
return updated; return updated;
}); });
}); });
@ -93,7 +154,13 @@ export function OilAreaAnalysis() {
URL.revokeObjectURL(prev[idx]); URL.revokeObjectURL(prev[idx]);
return prev.filter((_, i) => i !== idx); return prev.filter((_, i) => i !== idx);
}); });
setImageGpsInfos(prev => prev.filter((_, i) => i !== idx)); setImageExifs(prev => prev.filter((_, i) => i !== idx));
setSelectedImageIndex(prev => {
if (prev === null) return null;
if (prev === idx) return null;
if (prev > idx) return prev - 1;
return prev;
});
// 합성 결과 초기화 (선택 파일이 바뀌었으므로) // 합성 결과 초기화 (선택 파일이 바뀌었으므로)
setStitchedBlob(null); setStitchedBlob(null);
if (stitchedPreviewUrl) { if (stitchedPreviewUrl) {
@ -180,21 +247,74 @@ export function OilAreaAnalysis() {
<div className="text-[11px] font-bold mb-1.5 font-korean"> </div> <div className="text-[11px] font-bold mb-1.5 font-korean"> </div>
<div className="flex flex-col gap-1 mb-3"> <div className="flex flex-col gap-1 mb-3">
{selectedFiles.map((file, i) => ( {selectedFiles.map((file, i) => (
<div <div key={`${file.name}-${i}`}>
key={`${file.name}-${i}`} <div
className="flex items-center gap-2 px-2 py-1.5 bg-bg-3 border border-border rounded-sm text-[11px] font-korean" className={`flex items-center gap-2 px-2 py-1.5 bg-bg-3 border rounded-sm text-[11px] font-korean cursor-pointer transition-colors
> ${selectedImageIndex === i ? 'border-primary-cyan' : 'border-border'}`}
<span className="text-primary-cyan">📷</span> onClick={() => setSelectedImageIndex(i)}
<span className="flex-1 truncate text-text-1">{file.name}</span>
<button
onClick={() => handleRemoveFile(i)}
disabled={isStitching || isAnalyzing}
className="text-text-3 hover:text-status-red transition-colors cursor-pointer
disabled:opacity-40 disabled:cursor-not-allowed ml-1 shrink-0"
title="제거"
> >
<span className="text-primary-cyan">📷</span>
</button> <span className="flex-1 truncate text-text-1">{file.name}</span>
<button
onClick={e => { e.stopPropagation(); handleRemoveFile(i); }}
disabled={isStitching || isAnalyzing}
className="text-text-3 hover:text-status-red transition-colors cursor-pointer
disabled:opacity-40 disabled:cursor-not-allowed ml-1 shrink-0"
title="제거"
>
</button>
</div>
{selectedImageIndex === i && imageExifs[i] !== undefined && (
<div className="mt-1 mb-1 px-2 py-1.5 bg-bg-0 border border-border/60 rounded-sm text-[11px] font-korean">
<MetaRow label="파일 크기" value={formatFileSize(file.size)} />
<MetaRow
label="해상도"
value={imageExifs[i]!.imageWidth && imageExifs[i]!.imageHeight
? `${imageExifs[i]!.imageWidth} × ${imageExifs[i]!.imageHeight}`
: null}
/>
<MetaRow label="촬영일시" value={formatDateTime(imageExifs[i]!.dateTime)} />
<MetaRow
label="장비"
value={imageExifs[i]!.make || imageExifs[i]!.model
? [imageExifs[i]!.make, imageExifs[i]!.model].filter(Boolean).join(' ')
: null}
/>
<MetaRow
label="위도"
value={imageExifs[i]!.lat !== null ? decimalToDMS(imageExifs[i]!.lat!, true) : null}
/>
<MetaRow
label="경도"
value={imageExifs[i]!.lon !== null ? decimalToDMS(imageExifs[i]!.lon!, false) : null}
/>
<MetaRow
label="고도"
value={imageExifs[i]!.altitude !== null ? `${imageExifs[i]!.altitude!.toFixed(1)} m` : null}
/>
<MetaRow
label="셔터속도"
value={imageExifs[i]!.exposureTime
? imageExifs[i]!.exposureTime! < 1
? `1/${Math.round(1 / imageExifs[i]!.exposureTime!)}s`
: `${imageExifs[i]!.exposureTime}s`
: null}
/>
<MetaRow
label="조리개"
value={imageExifs[i]!.fNumber ? `f/${imageExifs[i]!.fNumber}` : null}
/>
<MetaRow
label="ISO"
value={imageExifs[i]!.iso ? String(imageExifs[i]!.iso) : null}
/>
<MetaRow
label="초점거리"
value={imageExifs[i]!.focalLength ? `${imageExifs[i]!.focalLength} mm` : null}
/>
</div>
)}
</div> </div>
))} ))}
</div> </div>
@ -239,8 +359,11 @@ export function OilAreaAnalysis() {
{Array.from({ length: MAX_IMAGES }).map((_, i) => ( {Array.from({ length: MAX_IMAGES }).map((_, i) => (
<div <div
key={i} key={i}
className="bg-bg-3 border border-border rounded-sm overflow-hidden flex flex-col" className={`bg-bg-3 border rounded-sm overflow-hidden flex flex-col transition-colors
${previewUrls[i] ? 'cursor-pointer' : ''}
${selectedImageIndex === i ? 'border-primary-cyan' : 'border-border'}`}
style={{ height: '300px' }} style={{ height: '300px' }}
onClick={() => { if (previewUrls[i]) setSelectedImageIndex(i); }}
> >
{previewUrls[i] ? ( {previewUrls[i] ? (
<> <>
@ -255,12 +378,12 @@ export function OilAreaAnalysis() {
<div className="text-[10px] text-text-2 truncate font-korean flex-1 min-w-0"> <div className="text-[10px] text-text-2 truncate font-korean flex-1 min-w-0">
{selectedFiles[i]?.name} {selectedFiles[i]?.name}
</div> </div>
{imageGpsInfos[i] === undefined ? ( {imageExifs[i] === undefined ? (
<div className="text-[10px] text-text-3 font-korean shrink-0">GPS ...</div> <div className="text-[10px] text-text-3 font-korean shrink-0">GPS ...</div>
) : imageGpsInfos[i]?.lat !== null ? ( ) : imageExifs[i]?.lat !== null ? (
<div className="text-[10px] text-primary-cyan font-mono leading-tight text-right shrink-0"> <div className="text-[10px] text-primary-cyan font-mono leading-tight text-right shrink-0">
{decimalToDMS(imageGpsInfos[i]!.lat!, true)}<br /> {decimalToDMS(imageExifs[i]!.lat!, true)}<br />
{decimalToDMS(imageGpsInfos[i]!.lon!, false)} {decimalToDMS(imageExifs[i]!.lon!, false)}
</div> </div>
) : ( ) : (
<div className="text-[10px] text-text-3 font-korean shrink-0">GPS </div> <div className="text-[10px] text-text-3 font-korean shrink-0">GPS </div>

파일 보기

@ -20,6 +20,9 @@ export function LeftPanel({
isRunningSimulation, isRunningSimulation,
selectedModels, selectedModels,
onModelsChange, onModelsChange,
visibleModels,
onVisibleModelsChange,
hasResults,
predictionTime, predictionTime,
onPredictionTimeChange, onPredictionTimeChange,
spillType, spillType,
@ -82,6 +85,9 @@ export function LeftPanel({
isRunningSimulation={isRunningSimulation} isRunningSimulation={isRunningSimulation}
selectedModels={selectedModels} selectedModels={selectedModels}
onModelsChange={onModelsChange} onModelsChange={onModelsChange}
visibleModels={visibleModels}
onVisibleModelsChange={onVisibleModelsChange}
hasResults={hasResults}
predictionTime={predictionTime} predictionTime={predictionTime}
onPredictionTimeChange={onPredictionTimeChange} onPredictionTimeChange={onPredictionTimeChange}
spillType={spillType} spillType={spillType}

파일 보기

@ -14,7 +14,8 @@ import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip,
import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack' import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'
import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory } from '../services/predictionApi' import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory } from '../services/predictionApi'
import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, SimulationRunResponse, SimulationSummary, WindPoint } from '../services/predictionApi' import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, SimulationRunResponse, SimulationSummary, WindPoint } from '../services/predictionApi'
import { useSimulationStatus } from '../hooks/useSimulationStatus' import { useMultiSimulationStatus } from '../hooks/useSimulationStatus'
import type { ModelExecRef } from '../hooks/useSimulationStatus'
import SimulationLoadingOverlay from './SimulationLoadingOverlay' import SimulationLoadingOverlay from './SimulationLoadingOverlay'
import SimulationErrorModal from './SimulationErrorModal' import SimulationErrorModal from './SimulationErrorModal'
import { api } from '@common/services/api' import { api } from '@common/services/api'
@ -119,11 +120,13 @@ export function OilSpillView() {
const [isSelectingLocation, setIsSelectingLocation] = useState(false) const [isSelectingLocation, setIsSelectingLocation] = useState(false)
const [oilTrajectory, setOilTrajectory] = useState<OilParticle[]>([]) const [oilTrajectory, setOilTrajectory] = useState<OilParticle[]>([])
const [centerPoints, setCenterPoints] = useState<CenterPoint[]>([]) const [centerPoints, setCenterPoints] = useState<CenterPoint[]>([])
const [windData, setWindData] = useState<WindPoint[][]>([]) const [windDataByModel, setWindDataByModel] = useState<Record<string, WindPoint[][]>>({})
const [hydrData, setHydrData] = useState<(HydrDataStep | null)[]>([]) const [hydrDataByModel, setHydrDataByModel] = useState<Record<string, (HydrDataStep | null)[]>>({})
const [windHydrModel, setWindHydrModel] = useState<string>('OpenDrift')
const [isRunningSimulation, setIsRunningSimulation] = useState(false) const [isRunningSimulation, setIsRunningSimulation] = useState(false)
const [simulationError, setSimulationError] = useState<string | null>(null) const [simulationError, setSimulationError] = useState<string | null>(null)
const [selectedModels, setSelectedModels] = useState<Set<PredictionModel>>(new Set(['OpenDrift'])) const [selectedModels, setSelectedModels] = useState<Set<PredictionModel>>(new Set(['OpenDrift']))
const [visibleModels, setVisibleModels] = useState<Set<PredictionModel>>(new Set(['OpenDrift']))
const [predictionTime, setPredictionTime] = useState(48) const [predictionTime, setPredictionTime] = useState(48)
const [accidentTime, setAccidentTime] = useState<string>('') const [accidentTime, setAccidentTime] = useState<string>('')
const [spillType, setSpillType] = useState('연속') const [spillType, setSpillType] = useState('연속')
@ -154,7 +157,7 @@ export function OilSpillView() {
// 표시 정보 제어 // 표시 정보 제어
const [displayControls, setDisplayControls] = useState<DisplayControls>({ const [displayControls, setDisplayControls] = useState<DisplayControls>({
showCurrent: true, showCurrent: true,
showWind: true, showWind: false,
showBeached: false, showBeached: false,
showTimeLabel: false, showTimeLabel: false,
}) })
@ -188,9 +191,9 @@ export function OilSpillView() {
// 재계산 상태 // 재계산 상태
const [recalcModalOpen, setRecalcModalOpen] = useState(false) const [recalcModalOpen, setRecalcModalOpen] = useState(false)
const [currentExecSn, setCurrentExecSn] = useState<number | null>(null) const [pendingExecSns, setPendingExecSns] = useState<ModelExecRef[]>([])
const [simulationSummary, setSimulationSummary] = useState<SimulationSummary | null>(null) const [simulationSummary, setSimulationSummary] = useState<SimulationSummary | null>(null)
const { data: simStatus } = useSimulationStatus(currentExecSn) const { allDone: simAllDone, anyError: simAnyError, results: simResults, errors: simErrors } = useMultiSimulationStatus(pendingExecSns)
// 오염분석 상태 // 오염분석 상태
const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon') const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon')
@ -389,34 +392,82 @@ export function OilSpillView() {
} }
}, []) }, [])
// 시뮬레이션 폴링 결과 처리 // 시뮬레이션 폴링 결과 처리 (다중 모델)
useEffect(() => { useEffect(() => {
if (!simStatus) return; if (pendingExecSns.length === 0) return;
if (simStatus.status === 'DONE' && simStatus.trajectory) {
// eslint-disable-next-line react-hooks/set-state-in-effect if (simAllDone) {
setOilTrajectory(simStatus.trajectory); // 모든 모델의 trajectory 병합 (model 필드 포함)
setSimulationSummary(simStatus.summary ?? null); const merged: OilParticle[] = [];
setCenterPoints(simStatus.centerPoints ?? []); let latestSummary: SimulationSummary | null = null;
setWindData(simStatus.windData ?? []); let latestCenterPoints: CenterPoint[] = [];
setHydrData(simStatus.hydrData ?? []); const newWindDataByModel: Record<string, WindPoint[][]> = {};
setIsRunningSimulation(false); const newHydrDataByModel: Record<string, (HydrDataStep | null)[]> = {};
setCurrentExecSn(null);
// AI 방어선 자동 생성 simResults.forEach((statusData, model) => {
if (incidentCoord) { if (statusData.trajectory) {
const booms = generateAIBoomLines(simStatus.trajectory, incidentCoord, algorithmSettings); const withModel = statusData.trajectory.map(p => ({ ...p, model }));
setBoomLines(booms); 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<PredictionModel>(
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 });
}
} }
setSensitiveResources(DEMO_SENSITIVE_RESOURCES);
// 새 시뮬레이션 완료 시 flyTo 없으므로 즉시 재생
setCurrentStep(0);
setIsPlaying(true);
}
if (simStatus.status === 'ERROR') {
setIsRunningSimulation(false); setIsRunningSimulation(false);
setCurrentExecSn(null); setPendingExecSns([]);
setSimulationError(simStatus.error ?? '시뮬레이션 처리 중 오류가 발생했습니다.');
} }
}, [simStatus, incidentCoord, algorithmSettings]);
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 변경 시 플레이어 스텝 초기화 (재생은 각 경로에서 별도 처리) // trajectory 변경 시 플레이어 스텝 초기화 (재생은 각 경로에서 별도 처리)
useEffect(() => { useEffect(() => {
@ -435,6 +486,17 @@ export function OilSpillView() {
const maxTime = timeSteps[timeSteps.length - 1] ?? predictionTime; const maxTime = timeSteps[timeSteps.length - 1] ?? predictionTime;
// 유향유속/풍향풍속 데이터 — 선택한 모델 기준으로 파생
const windHydrModelOptions = useMemo(() => Array.from(visibleModels), [visibleModels])
const windData = useMemo(
() => windDataByModel[windHydrModel] ?? [],
[windDataByModel, windHydrModel]
)
const hydrData = useMemo(
() => hydrDataByModel[windHydrModel] ?? [],
[hydrDataByModel, windHydrModel]
)
useEffect(() => { useEffect(() => {
if (!isPlaying || timeSteps.length === 0) return; if (!isPlaying || timeSteps.length === 0) return;
if (currentStep >= maxTime) { if (currentStep >= maxTime) {
@ -482,6 +544,7 @@ export function OilSpillView() {
if (analysis.poseidonStatus !== 'pending') models.add('POSEIDON') if (analysis.poseidonStatus !== 'pending') models.add('POSEIDON')
if (analysis.opendriftStatus !== 'pending') models.add('OpenDrift') if (analysis.opendriftStatus !== 'pending') models.add('OpenDrift')
setSelectedModels(models) setSelectedModels(models)
setVisibleModels(models)
// 분석 상세 로딩 (선박/기상 정보) // 분석 상세 로딩 (선박/기상 정보)
try { try {
const detail = await fetchPredictionDetail(analysis.acdntSn) const detail = await fetchPredictionDetail(analysis.acdntSn)
@ -505,8 +568,9 @@ export function OilSpillView() {
setOilTrajectory(trajectory) setOilTrajectory(trajectory)
if (summary) setSimulationSummary(summary) if (summary) setSimulationSummary(summary)
setCenterPoints(cp ?? []) setCenterPoints(cp ?? [])
setWindData(wd ?? []) setWindDataByModel(wd && wd.length > 0 ? { 'OpenDrift': wd } : {})
setHydrData(hd ?? []) setHydrDataByModel(hd && hd.length > 0 ? { 'OpenDrift': hd } : {})
setWindHydrModel('OpenDrift')
if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings)) if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings))
setSensitiveResources(DEMO_SENSITIVE_RESOURCES) setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생 // incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
@ -622,6 +686,7 @@ export function OilSpillView() {
backtrackStatus: 'pending', backtrackStatus: 'pending',
analyst: '', analyst: '',
officeName: '', officeName: '',
acdntSttsCd: 'ACTIVE',
}) })
}, []) }, [])
@ -663,8 +728,12 @@ export function OilSpillView() {
payload.spillTypeCd = spillType; payload.spillTypeCd = spillType;
} }
payload.models = Array.from(selectedModels);
const { data } = await api.post<SimulationRunResponse>('/simulation/run', payload); const { data } = await api.post<SimulationRunResponse>('/simulation/run', payload);
setCurrentExecSn(data.execSn); setPendingExecSns(
data.execSns ?? (data.execSn ? [{ model: 'OpenDrift', execSn: data.execSn }] : [])
);
// 직접 입력으로 신규 생성된 경우: selectedAnalysis 갱신 + incidentName 초기화 // 직접 입력으로 신규 생성된 경우: selectedAnalysis 갱신 + incidentName 초기화
if (data.acdntSn && isDirectInput) { if (data.acdntSn && isDirectInput) {
@ -782,6 +851,9 @@ export function OilSpillView() {
isRunningSimulation={isRunningSimulation} isRunningSimulation={isRunningSimulation}
selectedModels={selectedModels} selectedModels={selectedModels}
onModelsChange={setSelectedModels} onModelsChange={setSelectedModels}
visibleModels={visibleModels}
onVisibleModelsChange={setVisibleModels}
hasResults={oilTrajectory.length > 0}
predictionTime={predictionTime} predictionTime={predictionTime}
onPredictionTimeChange={setPredictionTime} onPredictionTimeChange={setPredictionTime}
spillType={spillType} spillType={spillType}
@ -829,7 +901,7 @@ export function OilSpillView() {
flyToIncident={flyToCoord} flyToIncident={flyToCoord}
isSelectingLocation={isSelectingLocation || isDrawingBoom || drawAnalysisMode === 'polygon'} isSelectingLocation={isSelectingLocation || isDrawingBoom || drawAnalysisMode === 'polygon'}
onMapClick={handleMapClick} onMapClick={handleMapClick}
oilTrajectory={oilTrajectory} oilTrajectory={oilTrajectory.filter(p => visibleModels.has((p.model || 'OpenDrift') as PredictionModel))}
selectedModels={selectedModels} selectedModels={selectedModels}
boomLines={boomLines} boomLines={boomLines}
isDrawingBoom={isDrawingBoom} isDrawingBoom={isDrawingBoom}
@ -838,7 +910,7 @@ export function OilSpillView() {
layerBrightness={layerBrightness} layerBrightness={layerBrightness}
sensitiveResources={sensitiveResources} sensitiveResources={sensitiveResources}
lightMode lightMode
centerPoints={centerPoints} centerPoints={centerPoints.filter(p => visibleModels.has((p.model || 'OpenDrift') as PredictionModel))}
windData={windData} windData={windData}
hydrData={hydrData} hydrData={hydrData}
flyToTarget={flyToTarget} flyToTarget={flyToTarget}
@ -1055,6 +1127,9 @@ export function OilSpillView() {
summary={simulationSummary} summary={simulationSummary}
displayControls={displayControls} displayControls={displayControls}
onDisplayControlsChange={setDisplayControls} onDisplayControlsChange={setDisplayControls}
windHydrModel={windHydrModel}
windHydrModelOptions={windHydrModelOptions}
onWindHydrModelChange={setWindHydrModel}
analysisTab={analysisTab} analysisTab={analysisTab}
onSwitchAnalysisTab={setAnalysisTab} onSwitchAnalysisTab={setAnalysisTab}
drawAnalysisMode={drawAnalysisMode} drawAnalysisMode={drawAnalysisMode}
@ -1074,8 +1149,8 @@ export function OilSpillView() {
{/* 확산 예측 실행 중 로딩 오버레이 */} {/* 확산 예측 실행 중 로딩 오버레이 */}
{isRunningSimulation && ( {isRunningSimulation && (
<SimulationLoadingOverlay <SimulationLoadingOverlay
status={simStatus?.status === 'RUNNING' ? 'RUNNING' : 'PENDING'} status="RUNNING"
progress={simStatus?.progress} progress={undefined}
/> />
)} )}

파일 보기

@ -18,6 +18,9 @@ interface PredictionInputSectionProps {
isRunningSimulation: boolean isRunningSimulation: boolean
selectedModels: Set<PredictionModel> selectedModels: Set<PredictionModel>
onModelsChange: (models: Set<PredictionModel>) => void onModelsChange: (models: Set<PredictionModel>) => void
visibleModels?: Set<PredictionModel>
onVisibleModelsChange?: (models: Set<PredictionModel>) => void
hasResults?: boolean
predictionTime: number predictionTime: number
onPredictionTimeChange: (time: number) => void onPredictionTimeChange: (time: number) => void
spillType: string spillType: string
@ -46,6 +49,9 @@ const PredictionInputSection = ({
isRunningSimulation, isRunningSimulation,
selectedModels, selectedModels,
onModelsChange, onModelsChange,
visibleModels,
onVisibleModelsChange,
hasResults,
predictionTime, predictionTime,
onPredictionTimeChange, onPredictionTimeChange,
spillType, spillType,
@ -387,19 +393,21 @@ const PredictionInputSection = ({
] as const).map(m => ( ] as const).map(m => (
<div <div
key={m.id} key={m.id}
className={`prd-mc ${selectedModels.has(m.id) ? 'on' : ''} cursor-pointer`} className={`prd-mc ${(hasResults ? (visibleModels ?? selectedModels) : selectedModels).has(m.id) ? 'on' : ''} cursor-pointer`}
onClick={() => { onClick={() => {
if (!m.ready) { if (!m.ready) {
alert(`${m.id} 모델은 현재 준비중입니다.`) alert(`${m.id} 모델은 현재 준비중입니다.`)
return return
} }
const next = new Set(selectedModels) if (hasResults && onVisibleModelsChange) {
if (next.has(m.id)) { const next = new Set(visibleModels ?? selectedModels)
next.delete(m.id) if (next.has(m.id)) { next.delete(m.id) } else { next.add(m.id) }
onVisibleModelsChange(next)
} else { } else {
next.add(m.id) const next = new Set(selectedModels)
if (next.has(m.id)) { next.delete(m.id) } else { next.add(m.id) }
onModelsChange(next)
} }
onModelsChange(next)
}} }}
> >
<span className="prd-md" style={{ background: m.color }} /> <span className="prd-md" style={{ background: m.color }} />

파일 보기

@ -18,6 +18,9 @@ interface RightPanelProps {
summary?: SimulationSummary | null summary?: SimulationSummary | null
displayControls?: DisplayControls displayControls?: DisplayControls
onDisplayControlsChange?: (controls: DisplayControls) => void onDisplayControlsChange?: (controls: DisplayControls) => void
windHydrModel?: string
windHydrModelOptions?: string[]
onWindHydrModelChange?: (model: string) => void
analysisTab?: 'polygon' | 'circle' analysisTab?: 'polygon' | 'circle'
onSwitchAnalysisTab?: (tab: 'polygon' | 'circle') => void onSwitchAnalysisTab?: (tab: 'polygon' | 'circle') => void
drawAnalysisMode?: 'polygon' | null drawAnalysisMode?: 'polygon' | null
@ -36,6 +39,7 @@ interface RightPanelProps {
export function RightPanel({ export function RightPanel({
onOpenBacktrack, onOpenRecalc, onOpenReport, detail, summary, onOpenBacktrack, onOpenRecalc, onOpenReport, detail, summary,
displayControls, onDisplayControlsChange, displayControls, onDisplayControlsChange,
windHydrModel, windHydrModelOptions = [], onWindHydrModelChange,
analysisTab = 'polygon', onSwitchAnalysisTab, analysisTab = 'polygon', onSwitchAnalysisTab,
drawAnalysisMode, analysisPolygonPoints = [], drawAnalysisMode, analysisPolygonPoints = [],
circleRadiusNm = 5, onCircleRadiusChange, circleRadiusNm = 5, onCircleRadiusChange,
@ -85,6 +89,20 @@ export function RightPanel({
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showTimeLabel: v })} onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showTimeLabel: v })}
> </ControlledCheckbox> > </ControlledCheckbox>
</div> </div>
{windHydrModelOptions.length > 1 && (
<div className="flex items-center gap-2 mt-1.5">
<span className="text-[9px] text-text-3 font-korean whitespace-nowrap"> </span>
<select
value={windHydrModel}
onChange={e => onWindHydrModelChange?.(e.target.value)}
className="flex-1 text-[9px] bg-bg-3 border border-border rounded px-1 py-0.5 text-text-2 font-korean"
>
{windHydrModelOptions.map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
)}
</Section> </Section>
{/* 오염분석 */} {/* 오염분석 */}

파일 보기

@ -17,6 +17,9 @@ export interface LeftPanelProps {
isRunningSimulation: boolean isRunningSimulation: boolean
selectedModels: Set<PredictionModel> selectedModels: Set<PredictionModel>
onModelsChange: (models: Set<PredictionModel>) => void onModelsChange: (models: Set<PredictionModel>) => void
visibleModels?: Set<PredictionModel>
onVisibleModelsChange?: (models: Set<PredictionModel>) => void
hasResults?: boolean
predictionTime: number predictionTime: number
onPredictionTimeChange: (time: number) => void onPredictionTimeChange: (time: number) => void
spillType: string spillType: string

파일 보기

@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery, useQueries } from '@tanstack/react-query';
import { api } from '@common/services/api'; import { api } from '@common/services/api';
import type { SimulationStatusResponse } from '../services/predictionApi'; import type { SimulationStatusResponse } from '../services/predictionApi';
@ -14,3 +14,73 @@ export const useSimulationStatus = (execSn: number | null) => {
}, },
}); });
}; };
export interface ModelExecRef {
model: string;
execSn: number;
}
interface MultiSimulationStatus {
allDone: boolean;
anyError: boolean;
isLoading: boolean;
results: Map<string, SimulationStatusResponse>;
errors: Map<string, string>;
}
export const useMultiSimulationStatus = (execSns: ModelExecRef[]): MultiSimulationStatus => {
const queries = useQueries({
queries: execSns.map(({ model, execSn }) => ({
queryKey: ['simulationStatus', execSn],
queryFn: () =>
api.get<SimulationStatusResponse>(`/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<string, SimulationStatusResponse>();
const errors = new Map<string, string>();
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 };
};

파일 보기

@ -123,7 +123,8 @@ export const createBacktrack = async (input: {
export interface SimulationRunResponse { export interface SimulationRunResponse {
success: boolean; success: boolean;
execSn: number; execSn: number; // 하위 호환 유지 (첫 번째 모델의 execSn)
execSns: Array<{ model: string; execSn: number }>;
acdntSn: number | null; acdntSn: number | null;
status: 'RUNNING'; status: 'RUNNING';
} }
@ -152,6 +153,7 @@ export interface CenterPoint {
lat: number; lat: number;
lon: number; lon: number;
time: number; time: number;
model?: string;
} }
export interface OilParticle { export interface OilParticle {
@ -160,6 +162,7 @@ export interface OilParticle {
time: number; time: number;
particle?: number; particle?: number;
stranded?: 0 | 1; stranded?: 0 | 1;
model?: string;
} }
export interface SimulationSummary { export interface SimulationSummary {