feat(prediction): trajectory API에 모델별 windData/hydrData 분리 반환

This commit is contained in:
jeonghyo.k 2026-03-18 13:25:21 +09:00
부모 33155e0f87
커밋 c7c7537dbb
11개의 변경된 파일660개의 추가작업 그리고 267개의 파일을 삭제

파일 보기

@ -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]))
const dbMap = new Map(
parsed
.filter(item => VALID_MENU_IDS.includes(item.id))
.map(item => [item.id, item])
)
return parsed
.filter(item => VALID_MENU_IDS.includes(item.id))
.map(item => {
const defaults = defaultMap.get(item.id)!
// DEFAULT 기준으로 머지 (DB에 없는 항목은 기본값 사용)
return DEFAULT_MENU_CONFIG
.map(defaultItem => {
const dbItem = dbMap.get(defaultItem.id)
if (!dbItem) return defaultItem
return {
id: item.id,
label: item.label || defaults.label,
icon: item.icon || defaults.icon,
enabled: item.enabled,
order: item.order,
id: dbItem.id,
label: dbItem.label || defaultItem.label,
icon: dbItem.icon || defaultItem.icon,
enabled: dbItem.enabled,
order: dbItem.order,
}
})
.sort((a, b) => a.order - b.order)

파일 보기

@ -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,50 +50,54 @@ 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
? 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)]'
: isIncident
? 'text-[#818cf8] hover:text-[#a5b4fc] hover:bg-[rgba(99,102,241,0.1)]'
: 'text-[#c8d6e5] hover:text-white hover:bg-[rgba(255,255,255,0.08)]'
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)]'
: isIncident
? 'text-[#818cf8] hover:text-[#a5b4fc] hover:bg-[rgba(99,102,241,0.1)]'
: 'text-[#c8d6e5] hover:text-white hover:bg-[rgba(255,255,255,0.08)]'
}
`}
>
<span className="xl:hidden text-[16px] leading-none">{tab.icon}</span>
<span className="hidden xl:inline">{tab.label}</span>
{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
}
const next = new Set(selectedModels)
if (next.has(m.id)) { next.delete(m.id) } else { next.add(m.id) }
onModelsChange(next)
if (hasResults && onVisibleModelsChange) {
const next = new Set(visibleModels ?? selectedModels)
if (next.has(m.id)) { next.delete(m.id) } else { next.add(m.id) }
onVisibleModelsChange(next)
} else {
const next = new Set(selectedModels)
if (next.has(m.id)) { next.delete(m.id) } else { next.add(m.id) }
onModelsChange(next)
onVisibleModelsChange(new Set(next))
}
}}
>

파일 보기

@ -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> => {