Merge pull request 'release: 2026-03-17 (3건 커밋)' (#96) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 1m19s
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 1m19s
This commit is contained in:
커밋
6b9ed4e06e
@ -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,6 +136,8 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Python NC 파일 존재 여부 확인 (ACDNT 생성 전에 수행하여 고아 레코드 방지)
|
// 2. Python NC 파일 존재 여부 확인 (ACDNT 생성 전에 수행하여 고아 레코드 방지)
|
||||||
|
// OpenDrift 모델이 포함된 경우에만 check-nc 수행
|
||||||
|
if (requestedModels.includes('OpenDrift')) {
|
||||||
try {
|
try {
|
||||||
const checkRes = await fetch(`${PYTHON_API_URL}/check-nc`, {
|
const checkRes = await fetch(`${PYTHON_API_URL}/check-nc`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -133,6 +154,7 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
|||||||
} catch {
|
} catch {
|
||||||
// Python 서버 미기동 — 5번에서 처리
|
// Python 서버 미기동 — 5번에서 처리
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1-B. acdntSn 미제공 시 ACDNT + SPIL_DATA 생성
|
// 1-B. acdntSn 미제공 시 ACDNT + SPIL_DATA 생성
|
||||||
let resolvedAcdntSn: number | null = rawAcdntSn ? Number(rawAcdntSn) : null
|
let resolvedAcdntSn: number | null = rawAcdntSn ? Number(rawAcdntSn) : null
|
||||||
@ -199,30 +221,62 @@ 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 호출 (병렬)
|
||||||
|
// KOSPS: PRED_EXEC PENDING 생성만 하고 배열에서 제외 (외부 API 미연동)
|
||||||
|
const execNmBase = `EXPC_${Date.now()}`
|
||||||
|
const execSns: Array<{ model: string; execSn: number }> = []
|
||||||
|
|
||||||
|
// 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]
|
||||||
|
)
|
||||||
|
execSns.push({ model: 'KOSPS', execSn: insertRes.rows[0].pred_exec_sn as number })
|
||||||
|
} catch (dbErr) {
|
||||||
|
console.error('[simulation] KOSPS PRED_EXEC INSERT 실패:', dbErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
let jobId: string
|
||||||
try {
|
try {
|
||||||
const pythonRes = await fetch(`${PYTHON_API_URL}/run-model`, {
|
const pythonRes = await fetch(`${apiUrl}/run-model`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@ -245,7 +299,7 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
|||||||
[errData.error || '분석 서버 포화', predExecSn]
|
[errData.error || '분석 서버 포화', predExecSn]
|
||||||
)
|
)
|
||||||
await rollbackNewRecords(predExecSn, newlyCreatedSpilDataSn, newlyCreatedAcdntSn)
|
await rollbackNewRecords(predExecSn, newlyCreatedSpilDataSn, newlyCreatedAcdntSn)
|
||||||
return res.status(503).json({ error: errData.error || '분석 서버가 사용 중입니다. 잠시 후 재시도해 주세요.' })
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pythonRes.ok) {
|
if (!pythonRes.ok) {
|
||||||
@ -259,23 +313,39 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
|||||||
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG='Python 분석 서버에 연결할 수 없습니다.', CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$1`,
|
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG='Python 분석 서버에 연결할 수 없습니다.', CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$1`,
|
||||||
[predExecSn]
|
[predExecSn]
|
||||||
)
|
)
|
||||||
await rollbackNewRecords(predExecSn, newlyCreatedSpilDataSn, newlyCreatedAcdntSn)
|
// 이 모델의 PRED_EXEC만 롤백 (다른 모델은 계속 진행)
|
||||||
return res.status(503).json({ error: 'Python 분석 서버에 연결할 수 없습니다.' })
|
await rollbackNewRecords(predExecSn, null, null)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. RUNNING 업데이트
|
// RUNNING 업데이트
|
||||||
await wingPool.query(
|
await wingPool.query(
|
||||||
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='RUNNING' WHERE PRED_EXEC_SN=$1`,
|
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='RUNNING' WHERE PRED_EXEC_SN=$1`,
|
||||||
[predExecSn]
|
[predExecSn]
|
||||||
)
|
)
|
||||||
|
|
||||||
// 7. 즉시 응답 (프론트엔드는 execSn으로 폴링, acdntSn은 신규 생성 사고 추적용)
|
// 백그라운드 폴링 시작
|
||||||
res.json({ success: true, execSn: predExecSn, acdntSn: resolvedAcdntSn, status: 'RUNNING' })
|
pollAndSaveModel(jobId, predExecSn, apiUrl, algoCd).catch((err: unknown) =>
|
||||||
|
console.error(`[simulation] ${model} pollAndSaveModel 오류:`, err)
|
||||||
// 8. 백그라운드 폴링 시작
|
|
||||||
pollAndSave(jobId, predExecSn).catch((err: unknown) =>
|
|
||||||
console.error('[simulation] pollAndSave 오류:', err)
|
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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: '분석 서버에 연결할 수 없습니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 즉시 응답 (하위 호환을 위해 execSn도 포함)
|
||||||
|
res.json({
|
||||||
|
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,46 +1066,53 @@ 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) {
|
if (visibleCenters.length > 0) {
|
||||||
|
// 모델별 그룹핑 (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)
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.entries(modelGroups).forEach(([model, points]) => {
|
||||||
|
const modelColor = hexToRgba(MODEL_COLORS[model as PredictionModel] || '#06b6d4', 210)
|
||||||
|
if (points.length >= 2) {
|
||||||
result.push(
|
result.push(
|
||||||
new PathLayer({
|
new PathLayer({
|
||||||
id: 'center-path',
|
id: `center-path-${model}`,
|
||||||
data: [{ path: visibleCenters.map(p => [p.lon, p.lat] as [number, number]) }],
|
data: [{ path: points.map((p: { lon: number; lat: number }) => [p.lon, p.lat] as [number, number]) }],
|
||||||
getPath: (d: { path: [number, number][] }) => d.path,
|
getPath: (d: { path: [number, number][] }) => d.path,
|
||||||
getColor: (lightMode ? [0, 60, 150, 230] : [255, 220, 50, 200]) as [number, number, number, number],
|
getColor: modelColor,
|
||||||
getWidth: 2,
|
getWidth: 2,
|
||||||
widthMinPixels: 2,
|
widthMinPixels: 2,
|
||||||
widthMaxPixels: 4,
|
widthMaxPixels: 4,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (visibleCenters.length > 0) {
|
|
||||||
result.push(
|
result.push(
|
||||||
new ScatterplotLayer({
|
new ScatterplotLayer({
|
||||||
id: 'center-points',
|
id: `center-points-${model}`,
|
||||||
data: visibleCenters,
|
data: points,
|
||||||
getPosition: (d: (typeof visibleCenters)[0]) => [d.lon, d.lat],
|
getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat],
|
||||||
getRadius: 5,
|
getRadius: 5,
|
||||||
getFillColor: (lightMode ? [0, 60, 150, 230] : [255, 220, 50, 230]) as [number, number, number, number],
|
getFillColor: modelColor,
|
||||||
radiusMinPixels: 4,
|
radiusMinPixels: 4,
|
||||||
radiusMaxPixels: 8,
|
radiusMaxPixels: 8,
|
||||||
pickable: false,
|
pickable: false,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
if (showTimeLabel) {
|
||||||
|
|
||||||
// --- 시간 표시 라벨 (TextLayer) ---
|
|
||||||
if (visibleCenters.length > 0 && showTimeLabel) {
|
|
||||||
const baseTime = simulationStartTime ? new Date(simulationStartTime) : null;
|
const baseTime = simulationStartTime ? new Date(simulationStartTime) : null;
|
||||||
const pad = (n: number) => String(n).padStart(2, '0');
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
result.push(
|
result.push(
|
||||||
new TextLayer({
|
new TextLayer({
|
||||||
id: 'time-labels',
|
id: `time-labels-${model}`,
|
||||||
data: visibleCenters,
|
data: points,
|
||||||
getPosition: (d: (typeof visibleCenters)[0]) => [d.lon, d.lat],
|
getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat],
|
||||||
getText: (d: (typeof visibleCenters)[0]) => {
|
getText: (d: { time: number }) => {
|
||||||
if (baseTime) {
|
if (baseTime) {
|
||||||
const dt = new Date(baseTime.getTime() + d.time * 3600 * 1000);
|
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 `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
|
||||||
@ -1089,7 +1120,7 @@ export function MapView({
|
|||||||
return `+${d.time}h`;
|
return `+${d.time}h`;
|
||||||
},
|
},
|
||||||
getSize: 12,
|
getSize: 12,
|
||||||
getColor: (lightMode ? [20, 40, 100, 240] : [255, 220, 50, 220]) as [number, number, number, number],
|
getColor: hexToRgba(MODEL_COLORS[model as PredictionModel] || '#06b6d4', 240),
|
||||||
getPixelOffset: [0, 16] as [number, number],
|
getPixelOffset: [0, 16] as [number, number],
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
outlineWidth: 2,
|
outlineWidth: 2,
|
||||||
@ -1102,6 +1133,8 @@ export function MapView({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// --- 바람 화살표 (TextLayer) ---
|
// --- 바람 화살표 (TextLayer) ---
|
||||||
if (incidentCoord && windData.length > 0 && showWind) {
|
if (incidentCoord && windData.length > 0 && showWind) {
|
||||||
@ -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,14 +247,16 @@ 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 key={`${file.name}-${i}`}>
|
||||||
<div
|
<div
|
||||||
key={`${file.name}-${i}`}
|
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
|
||||||
className="flex items-center gap-2 px-2 py-1.5 bg-bg-3 border border-border rounded-sm text-[11px] font-korean"
|
${selectedImageIndex === i ? 'border-primary-cyan' : 'border-border'}`}
|
||||||
|
onClick={() => setSelectedImageIndex(i)}
|
||||||
>
|
>
|
||||||
<span className="text-primary-cyan">📷</span>
|
<span className="text-primary-cyan">📷</span>
|
||||||
<span className="flex-1 truncate text-text-1">{file.name}</span>
|
<span className="flex-1 truncate text-text-1">{file.name}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemoveFile(i)}
|
onClick={e => { e.stopPropagation(); handleRemoveFile(i); }}
|
||||||
disabled={isStitching || isAnalyzing}
|
disabled={isStitching || isAnalyzing}
|
||||||
className="text-text-3 hover:text-status-red transition-colors cursor-pointer
|
className="text-text-3 hover:text-status-red transition-colors cursor-pointer
|
||||||
disabled:opacity-40 disabled:cursor-not-allowed ml-1 shrink-0"
|
disabled:opacity-40 disabled:cursor-not-allowed ml-1 shrink-0"
|
||||||
@ -196,6 +265,57 @@ export function OilAreaAnalysis() {
|
|||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
</>
|
</>
|
||||||
@ -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 (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) {
|
if (incidentCoord) {
|
||||||
const booms = generateAIBoomLines(simStatus.trajectory, incidentCoord, algorithmSettings);
|
const booms = generateAIBoomLines(merged, incidentCoord, algorithmSettings);
|
||||||
setBoomLines(booms);
|
setBoomLines(booms);
|
||||||
}
|
}
|
||||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES);
|
setSensitiveResources(DEMO_SENSITIVE_RESOURCES);
|
||||||
// 새 시뮬레이션 완료 시 flyTo 없으므로 즉시 재생
|
|
||||||
setCurrentStep(0);
|
setCurrentStep(0);
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
|
if (incidentCoord) {
|
||||||
|
setFlyToCoord({ lon: incidentCoord.lon, lat: incidentCoord.lat });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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 {
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user