Merge pull request 'feat(prediction): 다중 모델 시뮬레이션 지원 (OpenDrift + POSEIDON)' (#94) from release/2026-03-16-notes into develop

This commit is contained in:
jhkang 2026-03-17 18:38:01 +09:00
커밋 20890fe8a9
13개의 변경된 파일711개의 추가작업 그리고 246개의 파일을 삭제

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueries } from '@tanstack/react-query';
import { api } from '@common/services/api';
import type { SimulationStatusResponse } from '../services/predictionApi';
@ -14,3 +14,73 @@ export const useSimulationStatus = (execSn: number | null) => {
},
});
};
export interface ModelExecRef {
model: string;
execSn: number;
}
interface MultiSimulationStatus {
allDone: boolean;
anyError: boolean;
isLoading: boolean;
results: Map<string, SimulationStatusResponse>;
errors: Map<string, string>;
}
export const useMultiSimulationStatus = (execSns: ModelExecRef[]): MultiSimulationStatus => {
const queries = useQueries({
queries: execSns.map(({ model, execSn }) => ({
queryKey: ['simulationStatus', execSn],
queryFn: () =>
api.get<SimulationStatusResponse>(`/simulation/status/${execSn}`).then(r => r.data),
enabled: execSns.length > 0,
refetchInterval: (query: { state: { data?: SimulationStatusResponse } }) => {
const status = query.state.data?.status;
if (status === 'DONE' || status === 'ERROR') return false;
return 3000;
},
meta: { model },
})),
});
if (execSns.length === 0) {
return {
allDone: false,
anyError: false,
isLoading: false,
results: new Map(),
errors: new Map(),
};
}
const results = new Map<string, SimulationStatusResponse>();
const errors = new Map<string, string>();
execSns.forEach(({ model }, index) => {
const query = queries[index];
if (query.data) {
results.set(model, query.data);
}
if (query.error) {
const err = query.error;
errors.set(model, err instanceof Error ? err.message : String(err));
}
});
const allDone =
execSns.length > 0 &&
execSns.every((_, index) => {
const status = queries[index].data?.status;
return status === 'DONE' || status === 'ERROR';
});
const anyError = execSns.some((_, index) => {
const status = queries[index].data?.status;
return status === 'ERROR' || queries[index].isError;
});
const isLoading = execSns.some((_, index) => queries[index].isLoading);
return { allDone, anyError, isLoading, results, errors };
};

파일 보기

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