release: 2026-03-17 (3건 커밋) #96
@ -442,8 +442,14 @@ interface TrajectoryTimeStep {
|
||||
hydr_grid?: TrajectoryHydrGrid;
|
||||
}
|
||||
|
||||
// ALGO_CD → 프론트엔드 모델명 매핑
|
||||
const ALGO_CD_TO_MODEL: Record<string, string> = {
|
||||
'OPENDRIFT': 'OpenDrift',
|
||||
'POSEIDON': 'POSEIDON',
|
||||
};
|
||||
|
||||
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: {
|
||||
remainingVolume: number;
|
||||
weatheredVolume: number;
|
||||
@ -451,12 +457,12 @@ interface TrajectoryResult {
|
||||
beachedVolume: number;
|
||||
pollutionCoastLength: number;
|
||||
};
|
||||
centerPoints: Array<{ lat: number; lon: number; time: number }>;
|
||||
centerPoints: Array<{ lat: number; lon: number; time: number; model: string }>;
|
||||
windData: TrajectoryWindPoint[][];
|
||||
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) =>
|
||||
step.particles.map((p, i) => ({
|
||||
lat: p.lat,
|
||||
@ -464,6 +470,7 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[]): TrajectoryR
|
||||
time: stepIdx,
|
||||
particle: i,
|
||||
stranded: p.stranded,
|
||||
model,
|
||||
}))
|
||||
);
|
||||
const lastStep = rawResult[rawResult.length - 1];
|
||||
@ -477,10 +484,10 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[]): TrajectoryR
|
||||
const centerPoints = rawResult
|
||||
.map((step, stepIdx) =>
|
||||
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
|
||||
)
|
||||
.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 hydrData = rawResult.map((step) =>
|
||||
step.hydr_data && step.hydr_grid
|
||||
@ -491,14 +498,52 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[]): TrajectoryR
|
||||
}
|
||||
|
||||
export async function getAnalysisTrajectory(acdntSn: number): Promise<TrajectoryResult | null> {
|
||||
// 완료된 모든 모델(OPENDRIFT, POSEIDON) 결과 조회
|
||||
const sql = `
|
||||
SELECT RSLT_DATA FROM wing.PRED_EXEC
|
||||
WHERE ACDNT_SN = $1 AND ALGO_CD = 'OPENDRIFT' AND EXEC_STTS_CD = 'COMPLETED'
|
||||
ORDER BY CMPL_DTM DESC LIMIT 1
|
||||
SELECT ALGO_CD, RSLT_DATA, CMPL_DTM FROM wing.PRED_EXEC
|
||||
WHERE ACDNT_SN = $1
|
||||
AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
|
||||
AND EXEC_STTS_CD = 'COMPLETED'
|
||||
ORDER BY CMPL_DTM DESC
|
||||
`;
|
||||
const { rows } = await wingPool.query(sql, [acdntSn]);
|
||||
if (rows.length === 0 || !rows[0].rslt_data) return null;
|
||||
return transformTrajectoryResult(rows[0].rslt_data as TrajectoryTimeStep[]);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
// 모든 모델의 파티클을 하나의 배열로 병합
|
||||
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[]> {
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
const router = Router()
|
||||
|
||||
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_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
|
||||
// 확산 시뮬레이션 실행 (OpenDrift)
|
||||
// 확산 시뮬레이션 실행 (다중 모델 지원: OpenDrift, POSEIDON)
|
||||
// ============================================================
|
||||
/**
|
||||
* OpenDrift 확산 시뮬레이션을 실행한다.
|
||||
* Python FastAPI 서버에 작업을 제출하고 job_id를 받아
|
||||
* 백그라운드에서 폴링하며 결과를 DB에 저장한다.
|
||||
* 프론트엔드는 execSn으로 GET /status/:execSn을 폴링하여 결과를 수신한다.
|
||||
* 선택된 모델(OpenDrift, POSEIDON)로 확산 시뮬레이션을 실행한다.
|
||||
* 각 모델에 대해 PRED_EXEC 레코드를 별도 생성하고 Python API에 병렬 제출한다.
|
||||
* KOSPS 모델은 PRED_EXEC INSERT(PENDING)만 수행하고 외부 API 연동은 하지 않는다.
|
||||
* 프론트엔드는 execSns 배열의 각 execSn으로 GET /status/:execSn을 폴링하여 결과를 수신한다.
|
||||
*/
|
||||
router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
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. 필수 파라미터 검증
|
||||
if (lat === undefined || lon === undefined || runTime === undefined) {
|
||||
@ -117,21 +136,24 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
// 2. Python NC 파일 존재 여부 확인 (ACDNT 생성 전에 수행하여 고아 레코드 방지)
|
||||
try {
|
||||
const checkRes = await fetch(`${PYTHON_API_URL}/check-nc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ lat, lon, startTime }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
if (!checkRes.ok) {
|
||||
return res.status(409).json({
|
||||
error: '해당 좌표의 해양 기상 데이터가 없습니다.',
|
||||
message: 'NC 파일이 준비되지 않았습니다.',
|
||||
// OpenDrift 모델이 포함된 경우에만 check-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) {
|
||||
return res.status(409).json({
|
||||
error: '해당 좌표의 해양 기상 데이터가 없습니다.',
|
||||
message: 'NC 파일이 준비되지 않았습니다.',
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Python 서버 미기동 — 5번에서 처리
|
||||
}
|
||||
} catch {
|
||||
// Python 서버 미기동 — 5번에서 처리
|
||||
}
|
||||
|
||||
// 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 유종 코드
|
||||
// 매핑 대상이 아니면 원본 값 그대로 사용 (영문 직접 입력 대응)
|
||||
const odMatTy = matTy !== undefined ? (OIL_TYPE_MAP[matTy as string] ?? (matTy as string)) : undefined
|
||||
|
||||
// 5. Python /run-model 호출
|
||||
let jobId: string
|
||||
try {
|
||||
const pythonRes = await fetch(`${PYTHON_API_URL}/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),
|
||||
})
|
||||
// 4. 각 모델별 PRED_EXEC INSERT 및 API 호출 (병렬)
|
||||
// KOSPS: PRED_EXEC PENDING 생성만 하고 배열에서 제외 (외부 API 미연동)
|
||||
const execNmBase = `EXPC_${Date.now()}`
|
||||
const execSns: Array<{ model: string; execSn: number }> = []
|
||||
|
||||
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]
|
||||
// KOSPS 처리: PRED_EXEC INSERT(PENDING)만 수행
|
||||
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]
|
||||
)
|
||||
await rollbackNewRecords(predExecSn, newlyCreatedSpilDataSn, newlyCreatedAcdntSn)
|
||||
return res.status(503).json({ error: errData.error || '분석 서버가 사용 중입니다. 잠시 후 재시도해 주세요.' })
|
||||
execSns.push({ model: 'KOSPS', execSn: insertRes.rows[0].pred_exec_sn as number })
|
||||
} 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 업데이트
|
||||
await wingPool.query(
|
||||
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='RUNNING' WHERE PRED_EXEC_SN=$1`,
|
||||
[predExecSn]
|
||||
// API 연동 모델 필터링 (KOSPS 제외)
|
||||
const apiModels = requestedModels.filter((m) => m !== 'KOSPS' && MODEL_ALGO_CD_MAP[m] !== undefined)
|
||||
|
||||
// 각 모델에 대해 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은 신규 생성 사고 추적용)
|
||||
res.json({ success: true, execSn: predExecSn, acdntSn: resolvedAcdntSn, status: 'RUNNING' })
|
||||
// ACDNT/SPIL_DATA가 신규 생성됐으나 모든 모델이 실패한 경우 롤백
|
||||
const hasRunning = execSns.some(({ model }) => model !== 'KOSPS')
|
||||
if (!hasRunning && newlyCreatedAcdntSn !== null) {
|
||||
await rollbackNewRecords(null, newlyCreatedSpilDataSn, newlyCreatedAcdntSn)
|
||||
return res.status(503).json({ error: '분석 서버에 연결할 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 8. 백그라운드 폴링 시작
|
||||
pollAndSave(jobId, predExecSn).catch((err: unknown) =>
|
||||
console.error('[simulation] pollAndSave 오류:', err)
|
||||
)
|
||||
// 즉시 응답 (하위 호환을 위해 execSn도 포함)
|
||||
res.json({
|
||||
success: true,
|
||||
execSns,
|
||||
execSn: execSns[0]?.execSn ?? 0,
|
||||
acdntSn: resolvedAcdntSn,
|
||||
status: 'RUNNING',
|
||||
})
|
||||
} catch {
|
||||
res.status(500).json({ error: '시뮬레이션 실행 실패', message: '서버 내부 오류가 발생했습니다.' })
|
||||
}
|
||||
@ -297,7 +367,7 @@ router.get('/status/:execSn', requireAuth, async (req: Request, res: Response) =
|
||||
|
||||
try {
|
||||
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)
|
||||
FROM wing.PRED_EXEC hist
|
||||
@ -328,7 +398,9 @@ router.get('/status/:execSn', requireAuth, async (req: Request, res: Response) =
|
||||
const status = statusMap[dbStatus] ?? dbStatus
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise<void>(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
|
||||
|
||||
try {
|
||||
const pollRes = await fetch(`${PYTHON_API_URL}/status/${jobId}`, {
|
||||
const pollRes = await fetch(`${apiUrl}/status/${jobId}`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
if (!pollRes.ok) continue
|
||||
@ -446,7 +518,13 @@ interface PythonStatusResponse {
|
||||
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) =>
|
||||
step.particles.map((p, i) => ({
|
||||
lat: p.lat,
|
||||
@ -467,10 +545,10 @@ function transformResult(rawResult: PythonTimeStep[]) {
|
||||
const centerPoints = rawResult
|
||||
.map((step, stepIdx) =>
|
||||
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
|
||||
)
|
||||
.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 hydrData = rawResult.map((step) =>
|
||||
step.hydr_data && step.hydr_grid
|
||||
|
||||
@ -4,6 +4,11 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-03-17]
|
||||
|
||||
### 추가
|
||||
- 다중 모델 시뮬레이션 지원 (OpenDrift + POSEIDON 병렬 실행 및 결과 병합)
|
||||
|
||||
## [2026-03-16]
|
||||
|
||||
### 추가
|
||||
|
||||
@ -78,7 +78,7 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
|
||||
|
||||
{/* 실시간 상황관리 */}
|
||||
<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={`
|
||||
px-2.5 xl:px-4 py-2 rounded-sm text-[13px] transition-all duration-200
|
||||
font-korean tracking-[0.2px] font-semibold
|
||||
|
||||
@ -321,7 +321,7 @@ interface MapViewProps {
|
||||
incidentCoord?: { lon: number; lat: number }
|
||||
isSelectingLocation?: boolean
|
||||
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>
|
||||
dispersionResult?: DispersionResult | null
|
||||
dispersionHeatmap?: Array<{ lon: number; lat: number; concentration: number }>
|
||||
@ -341,7 +341,7 @@ interface MapViewProps {
|
||||
sensitiveResources?: SensitiveResource[]
|
||||
flyToTarget?: { lng: number; lat: number; zoom?: 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 }>>
|
||||
hydrData?: (HydrDataStep | null)[]
|
||||
// 외부 플레이어 제어 (prediction 하단 바에서 제어할 때 사용)
|
||||
@ -414,14 +414,15 @@ function MapPitchController({ threeD }: { threeD: boolean }) {
|
||||
}
|
||||
|
||||
// 사고 지점 변경 시 지도 이동 (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 onFlyEndRef = useRef(onFlyEnd)
|
||||
useEffect(() => { onFlyEndRef.current = onFlyEnd }, [onFlyEnd])
|
||||
|
||||
useEffect(() => {
|
||||
if (!map || lon == null || lat == null) return
|
||||
if (!map || !coord) return
|
||||
|
||||
const { lon, lat } = coord
|
||||
const doFly = () => {
|
||||
map.flyTo({ center: [lon, lat], zoom: 11, duration: 1200 })
|
||||
map.once('moveend', () => onFlyEndRef.current?.())
|
||||
@ -432,7 +433,7 @@ function MapFlyToIncident({ lon, lat, onFlyEnd }: { lon?: number; lat?: number;
|
||||
} else {
|
||||
map.once('load', doFly)
|
||||
}
|
||||
}, [lon, lat, map])
|
||||
}, [coord, map]) // 객체 참조 추적: 같은 좌표라도 새 객체면 effect 재실행
|
||||
|
||||
return null
|
||||
}
|
||||
@ -596,15 +597,13 @@ export function MapView({
|
||||
getPosition: (d: (typeof visibleParticles)[0]) => [d.lon, d.lat],
|
||||
getRadius: 3,
|
||||
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
|
||||
? [239, 68, 68, 220] as [number, number, number, number]
|
||||
? hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180)
|
||||
: [130, 130, 130, 70] as [number, number, number, number]
|
||||
// 2순위: 현재 활성 스텝 → 모델 기본 색상
|
||||
if (d.time === activeStep) {
|
||||
const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift'
|
||||
return hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180)
|
||||
}
|
||||
if (d.time === activeStep) return hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180)
|
||||
// 3순위: 과거 스텝 → 회색 + 투명
|
||||
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) ---
|
||||
if (boomLines.length > 0) {
|
||||
result.push(
|
||||
@ -1042,65 +1066,74 @@ export function MapView({
|
||||
)
|
||||
}
|
||||
|
||||
// --- 입자 중심점 이동 경로 (PathLayer + ScatterplotLayer) ---
|
||||
// --- 입자 중심점 이동 경로 (모델별 PathLayer + ScatterplotLayer) ---
|
||||
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) {
|
||||
result.push(
|
||||
new ScatterplotLayer({
|
||||
id: 'center-points',
|
||||
data: visibleCenters,
|
||||
getPosition: (d: (typeof visibleCenters)[0]) => [d.lon, d.lat],
|
||||
getRadius: 5,
|
||||
getFillColor: (lightMode ? [0, 60, 150, 230] : [255, 220, 50, 230]) as [number, number, number, number],
|
||||
radiusMinPixels: 4,
|
||||
radiusMaxPixels: 8,
|
||||
pickable: false,
|
||||
})
|
||||
)
|
||||
}
|
||||
// 모델별 그룹핑 (Record 사용 — Map 컴포넌트와 이름 충돌 회피)
|
||||
const modelGroups: Record<string, typeof visibleCenters> = {}
|
||||
visibleCenters.forEach(p => {
|
||||
const key = p.model || 'OpenDrift'
|
||||
if (!modelGroups[key]) modelGroups[key] = []
|
||||
modelGroups[key].push(p)
|
||||
})
|
||||
|
||||
// --- 시간 표시 라벨 (TextLayer) ---
|
||||
if (visibleCenters.length > 0 && showTimeLabel) {
|
||||
const baseTime = simulationStartTime ? new Date(simulationStartTime) : null;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
result.push(
|
||||
new TextLayer({
|
||||
id: 'time-labels',
|
||||
data: visibleCenters,
|
||||
getPosition: (d: (typeof visibleCenters)[0]) => [d.lon, d.lat],
|
||||
getText: (d: (typeof visibleCenters)[0]) => {
|
||||
if (baseTime) {
|
||||
const dt = new Date(baseTime.getTime() + d.time * 3600 * 1000);
|
||||
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
|
||||
}
|
||||
return `+${d.time}h`;
|
||||
},
|
||||
getSize: 12,
|
||||
getColor: (lightMode ? [20, 40, 100, 240] : [255, 220, 50, 220]) as [number, number, number, number],
|
||||
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],
|
||||
},
|
||||
})
|
||||
)
|
||||
Object.entries(modelGroups).forEach(([model, points]) => {
|
||||
const modelColor = hexToRgba(MODEL_COLORS[model as PredictionModel] || '#06b6d4', 210)
|
||||
if (points.length >= 2) {
|
||||
result.push(
|
||||
new PathLayer({
|
||||
id: `center-path-${model}`,
|
||||
data: [{ path: points.map((p: { lon: number; lat: number }) => [p.lon, p.lat] as [number, number]) }],
|
||||
getPath: (d: { path: [number, number][] }) => d.path,
|
||||
getColor: modelColor,
|
||||
getWidth: 2,
|
||||
widthMinPixels: 2,
|
||||
widthMaxPixels: 4,
|
||||
})
|
||||
)
|
||||
}
|
||||
result.push(
|
||||
new ScatterplotLayer({
|
||||
id: `center-points-${model}`,
|
||||
data: points,
|
||||
getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat],
|
||||
getRadius: 5,
|
||||
getFillColor: modelColor,
|
||||
radiusMinPixels: 4,
|
||||
radiusMaxPixels: 8,
|
||||
pickable: false,
|
||||
})
|
||||
)
|
||||
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) ---
|
||||
@ -1177,7 +1210,7 @@ export function MapView({
|
||||
{/* 3D 모드 pitch 제어 */}
|
||||
<MapPitchController threeD={mapToggles.threeD} />
|
||||
{/* 사고 지점 변경 시 지도 이동 */}
|
||||
<MapFlyToIncident lon={flyToIncident?.lon} lat={flyToIncident?.lat} onFlyEnd={onIncidentFlyEnd} />
|
||||
<MapFlyToIncident coord={flyToIncident} onFlyEnd={onIncidentFlyEnd} />
|
||||
{/* 외부에서 flyTo 트리거 */}
|
||||
<FlyToController flyToTarget={flyToTarget} />
|
||||
{/* 예측 완료 시 궤적 전체 범위로 fitBounds */}
|
||||
|
||||
@ -8,15 +8,59 @@ import { decimalToDMS } from '@common/utils/coordinates';
|
||||
|
||||
const MAX_IMAGES = 6;
|
||||
|
||||
interface GpsInfo {
|
||||
interface ImageExif {
|
||||
lat: 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() {
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
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 [stitchedPreviewUrl, setStitchedPreviewUrl] = useState<string | null>(null);
|
||||
const [isStitching, setIsStitching] = useState(false);
|
||||
@ -34,28 +78,45 @@ export function OilAreaAnalysis() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 선택된 파일이 바뀔 때마다 새 파일의 EXIF GPS 추출
|
||||
// 선택된 파일이 바뀔 때마다 새 파일의 EXIF 전체 추출
|
||||
useEffect(() => {
|
||||
selectedFiles.forEach((file, i) => {
|
||||
if (processedFilesRef.current.has(file)) return;
|
||||
processedFilesRef.current.add(file);
|
||||
|
||||
exifr.gps(file)
|
||||
.then(gps => {
|
||||
setImageGpsInfos(prev => {
|
||||
exifr.parse(file, { gps: true, exif: true, ifd0: true, translateValues: false })
|
||||
.then(exif => {
|
||||
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];
|
||||
while (updated.length <= i) updated.push(undefined);
|
||||
updated[i] = gps
|
||||
? { lat: gps.latitude, lon: gps.longitude }
|
||||
: { lat: null, lon: null };
|
||||
updated[i] = info;
|
||||
return updated;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setImageGpsInfos(prev => {
|
||||
setImageExifs(prev => {
|
||||
const updated = [...prev];
|
||||
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;
|
||||
});
|
||||
});
|
||||
@ -93,7 +154,13 @@ export function OilAreaAnalysis() {
|
||||
URL.revokeObjectURL(prev[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);
|
||||
if (stitchedPreviewUrl) {
|
||||
@ -180,21 +247,74 @@ export function OilAreaAnalysis() {
|
||||
<div className="text-[11px] font-bold mb-1.5 font-korean">선택된 이미지</div>
|
||||
<div className="flex flex-col gap-1 mb-3">
|
||||
{selectedFiles.map((file, i) => (
|
||||
<div
|
||||
key={`${file.name}-${i}`}
|
||||
className="flex items-center gap-2 px-2 py-1.5 bg-bg-3 border border-border rounded-sm text-[11px] font-korean"
|
||||
>
|
||||
<span className="text-primary-cyan">📷</span>
|
||||
<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="제거"
|
||||
<div key={`${file.name}-${i}`}>
|
||||
<div
|
||||
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'}`}
|
||||
onClick={() => setSelectedImageIndex(i)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<span className="text-primary-cyan">📷</span>
|
||||
<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>
|
||||
@ -239,8 +359,11 @@ export function OilAreaAnalysis() {
|
||||
{Array.from({ length: MAX_IMAGES }).map((_, i) => (
|
||||
<div
|
||||
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' }}
|
||||
onClick={() => { if (previewUrls[i]) setSelectedImageIndex(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">
|
||||
{selectedFiles[i]?.name}
|
||||
</div>
|
||||
{imageGpsInfos[i] === undefined ? (
|
||||
{imageExifs[i] === undefined ? (
|
||||
<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">
|
||||
{decimalToDMS(imageGpsInfos[i]!.lat!, true)}<br />
|
||||
{decimalToDMS(imageGpsInfos[i]!.lon!, false)}
|
||||
{decimalToDMS(imageExifs[i]!.lat!, true)}<br />
|
||||
{decimalToDMS(imageExifs[i]!.lon!, false)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-text-3 font-korean shrink-0">GPS 정보 없음</div>
|
||||
|
||||
@ -20,6 +20,9 @@ export function LeftPanel({
|
||||
isRunningSimulation,
|
||||
selectedModels,
|
||||
onModelsChange,
|
||||
visibleModels,
|
||||
onVisibleModelsChange,
|
||||
hasResults,
|
||||
predictionTime,
|
||||
onPredictionTimeChange,
|
||||
spillType,
|
||||
@ -82,6 +85,9 @@ export function LeftPanel({
|
||||
isRunningSimulation={isRunningSimulation}
|
||||
selectedModels={selectedModels}
|
||||
onModelsChange={onModelsChange}
|
||||
visibleModels={visibleModels}
|
||||
onVisibleModelsChange={onVisibleModelsChange}
|
||||
hasResults={hasResults}
|
||||
predictionTime={predictionTime}
|
||||
onPredictionTimeChange={onPredictionTimeChange}
|
||||
spillType={spillType}
|
||||
|
||||
@ -14,7 +14,8 @@ import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip,
|
||||
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 { useSimulationStatus } from '../hooks/useSimulationStatus'
|
||||
import { useMultiSimulationStatus } from '../hooks/useSimulationStatus'
|
||||
import type { ModelExecRef } from '../hooks/useSimulationStatus'
|
||||
import SimulationLoadingOverlay from './SimulationLoadingOverlay'
|
||||
import SimulationErrorModal from './SimulationErrorModal'
|
||||
import { api } from '@common/services/api'
|
||||
@ -119,11 +120,13 @@ export function OilSpillView() {
|
||||
const [isSelectingLocation, setIsSelectingLocation] = useState(false)
|
||||
const [oilTrajectory, setOilTrajectory] = useState<OilParticle[]>([])
|
||||
const [centerPoints, setCenterPoints] = useState<CenterPoint[]>([])
|
||||
const [windData, setWindData] = useState<WindPoint[][]>([])
|
||||
const [hydrData, setHydrData] = useState<(HydrDataStep | null)[]>([])
|
||||
const [windDataByModel, setWindDataByModel] = useState<Record<string, WindPoint[][]>>({})
|
||||
const [hydrDataByModel, setHydrDataByModel] = useState<Record<string, (HydrDataStep | null)[]>>({})
|
||||
const [windHydrModel, setWindHydrModel] = useState<string>('OpenDrift')
|
||||
const [isRunningSimulation, setIsRunningSimulation] = useState(false)
|
||||
const [simulationError, setSimulationError] = useState<string | null>(null)
|
||||
const [selectedModels, setSelectedModels] = useState<Set<PredictionModel>>(new Set(['OpenDrift']))
|
||||
const [visibleModels, setVisibleModels] = useState<Set<PredictionModel>>(new Set(['OpenDrift']))
|
||||
const [predictionTime, setPredictionTime] = useState(48)
|
||||
const [accidentTime, setAccidentTime] = useState<string>('')
|
||||
const [spillType, setSpillType] = useState('연속')
|
||||
@ -154,7 +157,7 @@ export function OilSpillView() {
|
||||
// 표시 정보 제어
|
||||
const [displayControls, setDisplayControls] = useState<DisplayControls>({
|
||||
showCurrent: true,
|
||||
showWind: true,
|
||||
showWind: false,
|
||||
showBeached: false,
|
||||
showTimeLabel: false,
|
||||
})
|
||||
@ -188,9 +191,9 @@ export function OilSpillView() {
|
||||
|
||||
// 재계산 상태
|
||||
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 { data: simStatus } = useSimulationStatus(currentExecSn)
|
||||
const { allDone: simAllDone, anyError: simAnyError, results: simResults, errors: simErrors } = useMultiSimulationStatus(pendingExecSns)
|
||||
|
||||
// 오염분석 상태
|
||||
const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon')
|
||||
@ -389,34 +392,82 @@ export function OilSpillView() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 시뮬레이션 폴링 결과 처리
|
||||
// 시뮬레이션 폴링 결과 처리 (다중 모델)
|
||||
useEffect(() => {
|
||||
if (!simStatus) return;
|
||||
if (simStatus.status === 'DONE' && simStatus.trajectory) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setOilTrajectory(simStatus.trajectory);
|
||||
setSimulationSummary(simStatus.summary ?? null);
|
||||
setCenterPoints(simStatus.centerPoints ?? []);
|
||||
setWindData(simStatus.windData ?? []);
|
||||
setHydrData(simStatus.hydrData ?? []);
|
||||
setIsRunningSimulation(false);
|
||||
setCurrentExecSn(null);
|
||||
// AI 방어선 자동 생성
|
||||
if (incidentCoord) {
|
||||
const booms = generateAIBoomLines(simStatus.trajectory, incidentCoord, algorithmSettings);
|
||||
setBoomLines(booms);
|
||||
if (pendingExecSns.length === 0) return;
|
||||
|
||||
if (simAllDone) {
|
||||
// 모든 모델의 trajectory 병합 (model 필드 포함)
|
||||
const merged: OilParticle[] = [];
|
||||
let latestSummary: SimulationSummary | null = null;
|
||||
let latestCenterPoints: CenterPoint[] = [];
|
||||
const newWindDataByModel: Record<string, WindPoint[][]> = {};
|
||||
const newHydrDataByModel: Record<string, (HydrDataStep | null)[]> = {};
|
||||
|
||||
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<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);
|
||||
setCurrentExecSn(null);
|
||||
setSimulationError(simStatus.error ?? '시뮬레이션 처리 중 오류가 발생했습니다.');
|
||||
setPendingExecSns([]);
|
||||
}
|
||||
}, [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 변경 시 플레이어 스텝 초기화 (재생은 각 경로에서 별도 처리)
|
||||
useEffect(() => {
|
||||
@ -435,6 +486,17 @@ export function OilSpillView() {
|
||||
|
||||
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(() => {
|
||||
if (!isPlaying || timeSteps.length === 0) return;
|
||||
if (currentStep >= maxTime) {
|
||||
@ -482,6 +544,7 @@ export function OilSpillView() {
|
||||
if (analysis.poseidonStatus !== 'pending') models.add('POSEIDON')
|
||||
if (analysis.opendriftStatus !== 'pending') models.add('OpenDrift')
|
||||
setSelectedModels(models)
|
||||
setVisibleModels(models)
|
||||
// 분석 상세 로딩 (선박/기상 정보)
|
||||
try {
|
||||
const detail = await fetchPredictionDetail(analysis.acdntSn)
|
||||
@ -505,8 +568,9 @@ export function OilSpillView() {
|
||||
setOilTrajectory(trajectory)
|
||||
if (summary) setSimulationSummary(summary)
|
||||
setCenterPoints(cp ?? [])
|
||||
setWindData(wd ?? [])
|
||||
setHydrData(hd ?? [])
|
||||
setWindDataByModel(wd && wd.length > 0 ? { 'OpenDrift': wd } : {})
|
||||
setHydrDataByModel(hd && hd.length > 0 ? { 'OpenDrift': hd } : {})
|
||||
setWindHydrModel('OpenDrift')
|
||||
if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings))
|
||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
||||
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
||||
@ -622,6 +686,7 @@ export function OilSpillView() {
|
||||
backtrackStatus: 'pending',
|
||||
analyst: '',
|
||||
officeName: '',
|
||||
acdntSttsCd: 'ACTIVE',
|
||||
})
|
||||
}, [])
|
||||
|
||||
@ -663,8 +728,12 @@ export function OilSpillView() {
|
||||
payload.spillTypeCd = spillType;
|
||||
}
|
||||
|
||||
payload.models = Array.from(selectedModels);
|
||||
|
||||
const { data } = await api.post<SimulationRunResponse>('/simulation/run', payload);
|
||||
setCurrentExecSn(data.execSn);
|
||||
setPendingExecSns(
|
||||
data.execSns ?? (data.execSn ? [{ model: 'OpenDrift', execSn: data.execSn }] : [])
|
||||
);
|
||||
|
||||
// 직접 입력으로 신규 생성된 경우: selectedAnalysis 갱신 + incidentName 초기화
|
||||
if (data.acdntSn && isDirectInput) {
|
||||
@ -782,6 +851,9 @@ export function OilSpillView() {
|
||||
isRunningSimulation={isRunningSimulation}
|
||||
selectedModels={selectedModels}
|
||||
onModelsChange={setSelectedModels}
|
||||
visibleModels={visibleModels}
|
||||
onVisibleModelsChange={setVisibleModels}
|
||||
hasResults={oilTrajectory.length > 0}
|
||||
predictionTime={predictionTime}
|
||||
onPredictionTimeChange={setPredictionTime}
|
||||
spillType={spillType}
|
||||
@ -829,7 +901,7 @@ export function OilSpillView() {
|
||||
flyToIncident={flyToCoord}
|
||||
isSelectingLocation={isSelectingLocation || isDrawingBoom || drawAnalysisMode === 'polygon'}
|
||||
onMapClick={handleMapClick}
|
||||
oilTrajectory={oilTrajectory}
|
||||
oilTrajectory={oilTrajectory.filter(p => visibleModels.has((p.model || 'OpenDrift') as PredictionModel))}
|
||||
selectedModels={selectedModels}
|
||||
boomLines={boomLines}
|
||||
isDrawingBoom={isDrawingBoom}
|
||||
@ -838,7 +910,7 @@ export function OilSpillView() {
|
||||
layerBrightness={layerBrightness}
|
||||
sensitiveResources={sensitiveResources}
|
||||
lightMode
|
||||
centerPoints={centerPoints}
|
||||
centerPoints={centerPoints.filter(p => visibleModels.has((p.model || 'OpenDrift') as PredictionModel))}
|
||||
windData={windData}
|
||||
hydrData={hydrData}
|
||||
flyToTarget={flyToTarget}
|
||||
@ -1055,6 +1127,9 @@ export function OilSpillView() {
|
||||
summary={simulationSummary}
|
||||
displayControls={displayControls}
|
||||
onDisplayControlsChange={setDisplayControls}
|
||||
windHydrModel={windHydrModel}
|
||||
windHydrModelOptions={windHydrModelOptions}
|
||||
onWindHydrModelChange={setWindHydrModel}
|
||||
analysisTab={analysisTab}
|
||||
onSwitchAnalysisTab={setAnalysisTab}
|
||||
drawAnalysisMode={drawAnalysisMode}
|
||||
@ -1074,8 +1149,8 @@ export function OilSpillView() {
|
||||
{/* 확산 예측 실행 중 로딩 오버레이 */}
|
||||
{isRunningSimulation && (
|
||||
<SimulationLoadingOverlay
|
||||
status={simStatus?.status === 'RUNNING' ? 'RUNNING' : 'PENDING'}
|
||||
progress={simStatus?.progress}
|
||||
status="RUNNING"
|
||||
progress={undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -18,6 +18,9 @@ interface PredictionInputSectionProps {
|
||||
isRunningSimulation: boolean
|
||||
selectedModels: Set<PredictionModel>
|
||||
onModelsChange: (models: Set<PredictionModel>) => void
|
||||
visibleModels?: Set<PredictionModel>
|
||||
onVisibleModelsChange?: (models: Set<PredictionModel>) => void
|
||||
hasResults?: boolean
|
||||
predictionTime: number
|
||||
onPredictionTimeChange: (time: number) => void
|
||||
spillType: string
|
||||
@ -46,6 +49,9 @@ const PredictionInputSection = ({
|
||||
isRunningSimulation,
|
||||
selectedModels,
|
||||
onModelsChange,
|
||||
visibleModels,
|
||||
onVisibleModelsChange,
|
||||
hasResults,
|
||||
predictionTime,
|
||||
onPredictionTimeChange,
|
||||
spillType,
|
||||
@ -387,19 +393,21 @@ const PredictionInputSection = ({
|
||||
] as const).map(m => (
|
||||
<div
|
||||
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={() => {
|
||||
if (!m.ready) {
|
||||
alert(`${m.id} 모델은 현재 준비중입니다.`)
|
||||
return
|
||||
}
|
||||
const next = new Set(selectedModels)
|
||||
if (next.has(m.id)) {
|
||||
next.delete(m.id)
|
||||
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 {
|
||||
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 }} />
|
||||
|
||||
@ -18,6 +18,9 @@ interface RightPanelProps {
|
||||
summary?: SimulationSummary | null
|
||||
displayControls?: DisplayControls
|
||||
onDisplayControlsChange?: (controls: DisplayControls) => void
|
||||
windHydrModel?: string
|
||||
windHydrModelOptions?: string[]
|
||||
onWindHydrModelChange?: (model: string) => void
|
||||
analysisTab?: 'polygon' | 'circle'
|
||||
onSwitchAnalysisTab?: (tab: 'polygon' | 'circle') => void
|
||||
drawAnalysisMode?: 'polygon' | null
|
||||
@ -36,6 +39,7 @@ interface RightPanelProps {
|
||||
export function RightPanel({
|
||||
onOpenBacktrack, onOpenRecalc, onOpenReport, detail, summary,
|
||||
displayControls, onDisplayControlsChange,
|
||||
windHydrModel, windHydrModelOptions = [], onWindHydrModelChange,
|
||||
analysisTab = 'polygon', onSwitchAnalysisTab,
|
||||
drawAnalysisMode, analysisPolygonPoints = [],
|
||||
circleRadiusNm = 5, onCircleRadiusChange,
|
||||
@ -85,6 +89,20 @@ export function RightPanel({
|
||||
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showTimeLabel: v })}
|
||||
>시간 표시</ControlledCheckbox>
|
||||
</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>
|
||||
|
||||
{/* 오염분석 */}
|
||||
|
||||
@ -17,6 +17,9 @@ export interface LeftPanelProps {
|
||||
isRunningSimulation: boolean
|
||||
selectedModels: Set<PredictionModel>
|
||||
onModelsChange: (models: Set<PredictionModel>) => void
|
||||
visibleModels?: Set<PredictionModel>
|
||||
onVisibleModelsChange?: (models: Set<PredictionModel>) => void
|
||||
hasResults?: boolean
|
||||
predictionTime: number
|
||||
onPredictionTimeChange: (time: number) => void
|
||||
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 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 {
|
||||
success: boolean;
|
||||
execSn: number;
|
||||
execSn: number; // 하위 호환 유지 (첫 번째 모델의 execSn)
|
||||
execSns: Array<{ model: string; execSn: number }>;
|
||||
acdntSn: number | null;
|
||||
status: 'RUNNING';
|
||||
}
|
||||
@ -152,6 +153,7 @@ export interface CenterPoint {
|
||||
lat: number;
|
||||
lon: number;
|
||||
time: number;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface OilParticle {
|
||||
@ -160,6 +162,7 @@ export interface OilParticle {
|
||||
time: number;
|
||||
particle?: number;
|
||||
stranded?: 0 | 1;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface SimulationSummary {
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user