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