release: 2026-03-13 (51건 커밋) #90

병합
jhkang develop 에서 main 로 18 commits 를 머지했습니다 2026-03-13 14:58:35 +09:00
16개의 변경된 파일489개의 추가작업 그리고 104개의 파일을 삭제
Showing only changes of commit a8ba29fd4c - Show all commits

파일 보기

@ -18,6 +18,7 @@ interface PredictionAnalysis {
backtrackStatus: string;
analyst: string;
officeName: string;
acdntSttsCd: string;
}
interface PredictionDetail {
@ -129,6 +130,7 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<Prediction
SELECT
A.ACDNT_SN,
A.ACDNT_NM,
A.ACDNT_STTS_CD,
A.OCCRN_DTM,
A.LAT,
A.LNG,
@ -186,6 +188,7 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<Prediction
backtrackStatus: String(row['backtrack_status'] ?? 'pending').toLowerCase(),
analyst: String(row['analyst_nm'] ?? ''),
officeName: String(row['office_nm'] ?? ''),
acdntSttsCd: String(row['acdnt_stts_cd'] ?? 'ACTIVE'),
}));
}

파일 보기

@ -47,6 +47,30 @@ const UNIT_MAP: Record<string, string> = {
'kL': 'KL', 'ton': 'TON', 'barrel': 'BBL',
}
// ============================================================
// 신규 생성된 ACDNT/SPIL_DATA/PRED_EXEC 롤백 헬퍼
// Python 호출 실패 시 이번 요청에서 생성된 레코드만 삭제한다.
// ============================================================
async function rollbackNewRecords(
predExecSn: number | null,
newSpilDataSn: number | null,
newAcdntSn: number | null
): Promise<void> {
try {
if (predExecSn !== null) {
await wingPool.query('DELETE FROM wing.PRED_EXEC WHERE PRED_EXEC_SN=$1', [predExecSn])
}
if (newSpilDataSn !== null) {
await wingPool.query('DELETE FROM wing.SPIL_DATA WHERE SPIL_DATA_SN=$1', [newSpilDataSn])
}
if (newAcdntSn !== null) {
await wingPool.query('DELETE FROM wing.ACDNT WHERE ACDNT_SN=$1', [newAcdntSn])
}
} catch (cleanupErr) {
console.error('[simulation] 롤백 실패:', cleanupErr)
}
}
// ============================================================
// POST /api/simulation/run
// 확산 시뮬레이션 실행 (OpenDrift)
@ -92,9 +116,30 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
return res.status(400).json({ error: '사고명은 200자 이내여야 합니다.' })
}
// 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 파일이 준비되지 않았습니다.',
})
}
} catch {
// Python 서버 미기동 — 5번에서 처리
}
// 1-B. acdntSn 미제공 시 ACDNT + SPIL_DATA 생성
let resolvedAcdntSn: number | null = rawAcdntSn ? Number(rawAcdntSn) : null
let resolvedSpilDataSn: number | null = null
// 이번 요청에서 신규 생성된 레코드 추적 (Python 실패 시 롤백 대상)
let newlyCreatedAcdntSn: number | null = null
let newlyCreatedSpilDataSn: number | null = null
if (!resolvedAcdntSn && acdntNm) {
try {
@ -116,6 +161,7 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
[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)
@ -131,30 +177,13 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
]
)
resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number
newlyCreatedSpilDataSn = resolvedSpilDataSn
} catch (dbErr) {
console.error('[simulation] ACDNT/SPIL_DATA INSERT 실패:', dbErr)
return res.status(500).json({ error: '사고 정보 생성 실패' })
}
}
// 2. Python NC 파일 존재 여부 확인
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번에서 처리
}
// 3. 기존 사고의 경우 SPIL_DATA_SN 조회
if (resolvedAcdntSn && !resolvedSpilDataSn) {
try {
@ -215,6 +244,7 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
`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 res.status(503).json({ error: errData.error || '분석 서버가 사용 중입니다. 잠시 후 재시도해 주세요.' })
}
@ -229,6 +259,7 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG='Python 분석 서버에 연결할 수 없습니다.', CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$1`,
[predExecSn]
)
await rollbackNewRecords(predExecSn, newlyCreatedSpilDataSn, newlyCreatedAcdntSn)
return res.status(503).json({ error: 'Python 분석 서버에 연결할 수 없습니다.' })
}

파일 보기

@ -5,8 +5,12 @@
## [Unreleased]
### 추가
- 시뮬레이션 에러 모달 추가
- 해류 캔버스 파티클 레이어 추가
### 변경
- 보고서 해안부착 현황 개선
## [2026-03-11.2]
### 추가

파일 보기

@ -25,6 +25,7 @@
"@vis.gl/react-maplibre": "^8.1.0",
"axios": "^1.13.5",
"emoji-mart": "^5.6.0",
"exifr": "^7.1.3",
"hls.js": "^1.6.15",
"jszip": "^3.10.1",
"lucide-react": "^0.564.0",
@ -3848,6 +3849,12 @@
"node": ">=0.10.0"
}
},
"node_modules/exifr": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
"integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==",
"license": "MIT"
},
"node_modules/extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",

파일 보기

@ -27,6 +27,7 @@
"@vis.gl/react-maplibre": "^8.1.0",
"axios": "^1.13.5",
"emoji-mart": "^5.6.0",
"exifr": "^7.1.3",
"hls.js": "^1.6.15",
"jszip": "^3.10.1",
"lucide-react": "^0.564.0",

파일 보기

@ -73,6 +73,25 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
</button>
)
})}
{/* 실시간 상황관리 */}
<button
onClick={() => window.open(import.meta.env.VITE_SITUATIONAL_URL ?? 'http://localhost:5174', '_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>

파일 보기

@ -189,6 +189,11 @@ interface MapViewProps {
mapCaptureRef?: React.MutableRefObject<(() => string | null) | null>
onIncidentFlyEnd?: () => void
flyToIncident?: { lon: number; lat: number }
showCurrent?: boolean
showWind?: boolean
showBeached?: boolean
showTimeLabel?: boolean
simulationStartTime?: string
}
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved)
@ -311,6 +316,11 @@ export function MapView({
mapCaptureRef,
onIncidentFlyEnd,
flyToIncident,
showCurrent = true,
showWind = true,
showBeached = false,
showTimeLabel = false,
simulationStartTime,
}: MapViewProps) {
const { mapToggles } = useMapStore()
const isControlled = externalCurrentTime !== undefined
@ -393,8 +403,10 @@ export function MapView({
getPosition: (d: (typeof visibleParticles)[0]) => [d.lon, d.lat],
getRadius: 3,
getFillColor: (d: (typeof visibleParticles)[0]) => {
// 1순위: stranded 입자 → 빨간색
if (d.stranded === 1) return [239, 68, 68, 220] as [number, number, number, number]
// 1순위: stranded 입자 → showBeached=true 시 빨간색, false 시 회색
if (d.stranded === 1) return showBeached
? [239, 68, 68, 220] as [number, number, number, number]
: [130, 130, 130, 70] as [number, number, number, number]
// 2순위: 현재 활성 스텝 → 모델 기본 색상
if (d.time === activeStep) {
const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift'
@ -427,7 +439,7 @@ export function MapView({
}
},
updateTriggers: {
getFillColor: [selectedModels, currentTime],
getFillColor: [selectedModels, currentTime, showBeached],
},
})
)
@ -782,8 +794,39 @@ export function MapView({
)
}
// --- 시간 표시 라벨 (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: [255, 220, 50, 220] as [number, number, number, number],
getPixelOffset: [0, 16] as [number, number],
fontWeight: 'bold',
outlineWidth: 2,
outlineColor: [15, 21, 36, 200] as [number, number, number, number],
billboard: true,
sizeUnits: 'pixels' as const,
updateTriggers: {
getText: [simulationStartTime, currentTime],
},
})
)
}
// --- 바람 화살표 (TextLayer) ---
if (incidentCoord && windData.length > 0) {
if (incidentCoord && windData.length > 0 && showWind) {
type ArrowPoint = { lon: number; lat: number; bearing: number; speed: number }
const activeWindStep = windData[currentTime] ?? windData[0] ?? []
@ -829,6 +872,7 @@ export function MapView({
boomLines, isDrawingBoom, drawingPoints,
dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay,
sensitiveResources, centerPoints, windData,
showWind, showBeached, showTimeLabel, simulationStartTime,
])
// 3D 모드에 따른 지도 스타일 전환
@ -887,7 +931,7 @@ export function MapView({
<DeckGLOverlay layers={deckLayers} />
{/* 해류 파티클 오버레이 */}
{hydrData.length > 0 && (
{hydrData.length > 0 && showCurrent && (
<HydrParticleOverlay hydrStep={hydrData[currentTime] ?? null} />
)}

파일 보기

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useSyncExternalStore } from 'react'
import type { MainTab } from '../types/navigation'
import { useAuthStore } from '@common/store/authStore'
import { API_BASE_URL } from '@common/services/api'
@ -38,7 +38,7 @@ const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
],
aerial: [
{ id: 'media', label: '영상사진관리', icon: '📷' },
{ id: 'analysis', label: '유출유면적분석', icon: '🧩' },
{ id: 'analysis', label: '영상사진합성', icon: '🧩' },
{ id: 'realtime', label: '실시간드론', icon: '🛸' },
{ id: 'sensor', label: '오염/선박3D분석', icon: '🔍' },
{ id: 'satellite', label: '위성요청', icon: '🛰' },
@ -91,17 +91,10 @@ function subscribe(listener: () => void) {
}
export function useSubMenu(mainTab: MainTab) {
const [activeSubTab, setActiveSubTabLocal] = useState(subMenuState[mainTab])
const activeSubTab = useSyncExternalStore(subscribe, () => subMenuState[mainTab])
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const hasPermission = useAuthStore((s) => s.hasPermission)
useEffect(() => {
const unsubscribe = subscribe(() => {
setActiveSubTabLocal(subMenuState[mainTab])
})
return unsubscribe
}, [mainTab])
const setActiveSubTab = (subTab: string) => {
setSubTab(mainTab, subTab)
}

파일 보기

@ -1,20 +1,29 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import * as exifr from 'exifr';
import { stitchImages } from '../services/aerialApi';
import { analyzeImage } from '@tabs/prediction/services/predictionApi';
import { setPendingImageAnalysis } from '@common/utils/imageAnalysisSignal';
import { navigateToTab } from '@common/hooks/useSubMenu';
import { decimalToDMS } from '@common/utils/coordinates';
const MAX_IMAGES = 6;
interface GpsInfo {
lat: number | null;
lon: number | null;
}
export function OilAreaAnalysis() {
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
const [imageGpsInfos, setImageGpsInfos] = useState<(GpsInfo | undefined)[]>([]);
const [stitchedBlob, setStitchedBlob] = useState<Blob | null>(null);
const [stitchedPreviewUrl, setStitchedPreviewUrl] = useState<string | null>(null);
const [isStitching, setIsStitching] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const processedFilesRef = useRef<Set<File>>(new Set());
// Object URL 메모리 누수 방지 — 언마운트 시 전체 revoke
useEffect(() => {
@ -25,6 +34,34 @@ export function OilAreaAnalysis() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 선택된 파일이 바뀔 때마다 새 파일의 EXIF GPS 추출
useEffect(() => {
selectedFiles.forEach((file, i) => {
if (processedFilesRef.current.has(file)) return;
processedFilesRef.current.add(file);
exifr.gps(file)
.then(gps => {
setImageGpsInfos(prev => {
const updated = [...prev];
while (updated.length <= i) updated.push(undefined);
updated[i] = gps
? { lat: gps.latitude, lon: gps.longitude }
: { lat: null, lon: null };
return updated;
});
})
.catch(() => {
setImageGpsInfos(prev => {
const updated = [...prev];
while (updated.length <= i) updated.push(undefined);
updated[i] = { lat: null, lon: null };
return updated;
});
});
});
}, [selectedFiles]);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setError(null);
const incoming = Array.from(e.target.files ?? []);
@ -56,6 +93,7 @@ export function OilAreaAnalysis() {
URL.revokeObjectURL(prev[idx]);
return prev.filter((_, i) => i !== idx);
});
setImageGpsInfos(prev => prev.filter((_, i) => i !== idx));
// 합성 결과 초기화 (선택 파일이 바뀌었으므로)
setStitchedBlob(null);
if (stitchedPreviewUrl) {
@ -112,7 +150,7 @@ export function OilAreaAnalysis() {
<div className="flex gap-5 h-full overflow-hidden">
{/* ── Left Panel ── */}
<div className="w-[280px] min-w-[280px] flex flex-col overflow-y-auto scrollbar-thin">
<div className="text-sm font-bold mb-1 font-korean">🧩 </div>
<div className="text-sm font-bold mb-1 font-korean">🧩 </div>
<div className="text-[11px] text-text-3 mb-4 font-korean">
.
</div>
@ -201,15 +239,34 @@ export function OilAreaAnalysis() {
{Array.from({ length: MAX_IMAGES }).map((_, i) => (
<div
key={i}
className="bg-bg-3 border border-border rounded-sm overflow-hidden"
className="bg-bg-3 border border-border rounded-sm overflow-hidden flex flex-col"
style={{ height: '300px' }}
>
{previewUrls[i] ? (
<img
src={previewUrls[i]}
alt={selectedFiles[i]?.name ?? ''}
className="w-full h-full object-cover"
/>
<>
<div className="flex-1 min-h-0 overflow-hidden">
<img
src={previewUrls[i]}
alt={selectedFiles[i]?.name ?? ''}
className="w-full h-full object-cover"
/>
</div>
<div className="px-2 py-1 bg-bg-0 border-t border-border shrink-0 flex items-start justify-between gap-1">
<div className="text-[10px] text-text-2 truncate font-korean flex-1 min-w-0">
{selectedFiles[i]?.name}
</div>
{imageGpsInfos[i] === undefined ? (
<div className="text-[10px] text-text-3 font-korean shrink-0">GPS ...</div>
) : imageGpsInfos[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)}
</div>
) : (
<div className="text-[10px] text-text-3 font-korean shrink-0">GPS </div>
)}
</div>
</>
) : (
<div className="flex items-center justify-center h-full text-text-3 text-lg font-mono opacity-20">
{i + 1}

파일 보기

@ -53,7 +53,7 @@ export function LeftPanel({
predictionInput: true,
incident: false,
impactResources: false,
infoLayer: true,
infoLayer: false,
oilBoom: false,
})
@ -112,45 +112,73 @@ export function LeftPanel({
</div>
{expandedSections.incident && (
<div className="px-4 pb-4 space-y-3">
{/* Status Badge */}
<div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-semibold bg-[rgba(239,68,68,0.15)] text-status-red border border-[rgba(239,68,68,0.3)]">
<span className="w-1.5 h-1.5 rounded-full bg-status-red animate-pulse" />
</div>
selectedAnalysis ? (
<div className="px-4 pb-4 space-y-3">
{/* Status Badge */}
{(() => {
const statusMap: Record<string, { label: string; style: string; dot: string }> = {
ACTIVE: {
label: '진행중',
style: 'bg-[rgba(239,68,68,0.15)] text-status-red border border-[rgba(239,68,68,0.3)]',
dot: 'bg-status-red animate-pulse',
},
INVESTIGATING: {
label: '조사중',
style: 'bg-[rgba(249,115,22,0.15)] text-status-orange border border-[rgba(249,115,22,0.3)]',
dot: 'bg-status-orange animate-pulse',
},
CLOSED: {
label: '종료',
style: 'bg-[rgba(100,116,139,0.15)] text-text-3 border border-[rgba(100,116,139,0.3)]',
dot: 'bg-text-3',
},
}
const s = statusMap[selectedAnalysis.acdntSttsCd] ?? statusMap['ACTIVE']
return (
<div className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-semibold ${s.style}`}>
<span className={`w-1.5 h-1.5 rounded-full ${s.dot}`} />
{s.label}
</div>
)
})()}
{/* Info Grid */}
<div className="grid gap-1">
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis ? `INC-2025-${String(selectedAnalysis.id).padStart(4, '0')}` : 'INC-2025-0042'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis?.name || '씨프린스호'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis?.occurredAt || '2025-02-10 06:30'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis?.oilType || 'BUNKER_C'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis ? `${selectedAnalysis.volume.toFixed(2)} kl` : '350.00 kl'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis?.analyst || '남해청, 방재과'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-status-orange font-semibold font-korean">{selectedAnalysis?.location || '여수 돌산 남방 5NM'}</span>
{/* Info Grid */}
<div className="grid gap-1">
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.acdntSn}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis.acdntNm || '—'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.occurredAt ? selectedAnalysis.occurredAt.slice(0, 16) : '—'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis.oilType || '—'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.volume != null ? `${selectedAnalysis.volume.toFixed(2)} kl` : '—'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis.analyst || '—'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-status-orange font-semibold font-korean">{selectedAnalysis.location || '—'}</span>
</div>
</div>
</div>
</div>
) : (
<div className="px-4 pb-4">
<p className="text-[11px] text-text-3 font-korean text-center py-2"> .</p>
</div>
)
)}
</div>

파일 보기

@ -16,6 +16,7 @@ import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAna
import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, SimulationRunResponse, SimulationSummary, WindPoint } from '../services/predictionApi'
import { useSimulationStatus } from '../hooks/useSimulationStatus'
import SimulationLoadingOverlay from './SimulationLoadingOverlay'
import SimulationErrorModal from './SimulationErrorModal'
import { api } from '@common/services/api'
import { generateAIBoomLines } from '@common/utils/geo'
import { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal'
@ -35,6 +36,13 @@ export interface SensitiveResource {
arrivalTimeH: number
}
export interface DisplayControls {
showCurrent: boolean; // 유향/유속
showWind: boolean; // 풍향/풍속
showBeached: boolean; // 해안부착
showTimeLabel: boolean; // 시간 표시
}
const DEMO_SENSITIVE_RESOURCES: SensitiveResource[] = [
{ id: 'bc-1', name: '종포 해수욕장', type: 'beach', lat: 34.728, lon: 127.679, radiusM: 350, arrivalTimeH: 1 },
{ id: 'aq-1', name: '국동 전복 양식장', type: 'aquaculture', lat: 34.718, lon: 127.672, radiusM: 500, arrivalTimeH: 3 },
@ -114,7 +122,8 @@ export function OilSpillView() {
const [windData, setWindData] = useState<WindPoint[][]>([])
const [hydrData, setHydrData] = useState<(HydrDataStep | null)[]>([])
const [isRunningSimulation, setIsRunningSimulation] = useState(false)
const [selectedModels, setSelectedModels] = useState<Set<PredictionModel>>(new Set(['KOSPS']))
const [simulationError, setSimulationError] = useState<string | null>(null)
const [selectedModels, setSelectedModels] = useState<Set<PredictionModel>>(new Set(['OpenDrift']))
const [predictionTime, setPredictionTime] = useState(48)
const [accidentTime, setAccidentTime] = useState<string>('')
const [spillType, setSpillType] = useState('연속')
@ -142,6 +151,14 @@ export function OilSpillView() {
const [layerOpacity, setLayerOpacity] = useState(50)
const [layerBrightness, setLayerBrightness] = useState(50)
// 표시 정보 제어
const [displayControls, setDisplayControls] = useState<DisplayControls>({
showCurrent: true,
showWind: true,
showBeached: false,
showTimeLabel: false,
})
// 타임라인 플레이어 상태
const [isPlaying, setIsPlaying] = useState(false)
const [currentStep, setCurrentStep] = useState(0) // 현재 시간값 (시간 단위)
@ -373,6 +390,7 @@ export function OilSpillView() {
if (simStatus.status === 'ERROR') {
setIsRunningSimulation(false);
setCurrentExecSn(null);
setSimulationError(simStatus.error ?? '시뮬레이션 처리 중 오류가 발생했습니다.');
}
}, [simStatus, incidentCoord, algorithmSettings]);
@ -598,9 +616,12 @@ export function OilSpillView() {
setIncidentName('');
}
// setIsRunningSimulation(false)는 폴링 결과 useEffect에서 처리
} catch {
} catch (err) {
setIsRunningSimulation(false);
// 503 등 에러 시 상태 복원 (에러 메시지 표시는 향후 토스트로 처리)
const msg =
(err as { message?: string })?.message
?? '시뮬레이션 실행 중 오류가 발생했습니다.';
setSimulationError(msg);
}
}
@ -748,6 +769,11 @@ export function OilSpillView() {
totalFrames: TOTAL_REPLAY_FRAMES,
incidentCoord,
} : undefined}
showCurrent={displayControls.showCurrent}
showWind={displayControls.showWind}
showBeached={displayControls.showBeached}
showTimeLabel={displayControls.showTimeLabel}
simulationStartTime={accidentTime || undefined}
/>
{/* 타임라인 플레이어 (리플레이 비활성 시) */}
@ -932,7 +958,7 @@ export function OilSpillView() {
</div>
{/* Right Panel */}
{activeSubTab === 'analysis' && <RightPanel onOpenBacktrack={handleOpenBacktrack} onOpenRecalc={() => setRecalcModalOpen(true)} onOpenReport={handleOpenReport} detail={analysisDetail} summary={simulationSummary} />}
{activeSubTab === 'analysis' && <RightPanel onOpenBacktrack={handleOpenBacktrack} onOpenRecalc={() => setRecalcModalOpen(true)} onOpenReport={handleOpenReport} detail={analysisDetail} summary={simulationSummary} displayControls={displayControls} onDisplayControlsChange={setDisplayControls} />}
{/* 확산 예측 실행 중 로딩 오버레이 */}
{isRunningSimulation && (
@ -942,6 +968,14 @@ export function OilSpillView() {
/>
)}
{/* 확산 예측 에러 팝업 */}
{simulationError && (
<SimulationErrorModal
message={simulationError}
onClose={() => setSimulationError(null)}
/>
)}
{/* 재계산 모달 */}
<RecalcModal
isOpen={recalcModalOpen}

파일 보기

@ -1,7 +1,6 @@
import { useState, useRef } from 'react'
import { decimalToDMS } from '@common/utils/coordinates'
import { ComboBox } from '@common/components/ui/ComboBox'
import { ALL_MODELS } from './OilSpillView'
import type { PredictionModel } from './OilSpillView'
import { analyzeImage } from '../services/predictionApi'
import type { ImageAnalyzeResult } from '../services/predictionApi'
@ -379,10 +378,13 @@ const PredictionInputSection = ({
<div className="h-px bg-border my-0.5" />
{/* Model Selection (다중 선택) */}
{/* TODO: 현재 OpenDrift만 구동 가능. KOSPS·POSEIDON·앙상블은 엔진 연동 완료 후 활성화 예정 */}
<div className="flex flex-wrap gap-[3px]">
{/* OpenDrift (KOSPS )
{ id: 'KOSPS' as PredictionModel, color: 'var(--cyan)' }, */}
{/* OpenDrift (POSEIDON )
{ id: 'POSEIDON' as PredictionModel, color: 'var(--red)' }, */}
{([
{ id: 'KOSPS' as PredictionModel, color: 'var(--cyan)' },
{ id: 'POSEIDON' as PredictionModel, color: 'var(--red)' },
{ id: 'OpenDrift' as PredictionModel, color: 'var(--blue)' },
] as const).map(m => (
<div
@ -402,6 +404,7 @@ const PredictionInputSection = ({
{m.id}
</div>
))}
{/* OpenDrift ( )
<div
className={`prd-mc ${selectedModels.size === ALL_MODELS.length ? 'on' : ''} cursor-pointer`}
onClick={() => {
@ -415,8 +418,16 @@ const PredictionInputSection = ({
<span className="prd-md" style={{ background: 'var(--purple)' }} />
</div>
*/}
</div>
{/* 모델 미선택 경고 */}
{selectedModels.size === 0 && (
<p className="text-[10px] text-status-red font-korean">
.
</p>
)}
{/* Run Button */}
<button
className="prd-btn pri mt-0.5"

파일 보기

@ -1,7 +1,8 @@
import { useState } from 'react'
import type { PredictionDetail, SimulationSummary } from '../services/predictionApi'
import type { DisplayControls } from './OilSpillView'
export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail, summary }: { onOpenBacktrack?: () => void; onOpenRecalc?: () => void; onOpenReport?: () => void; detail?: PredictionDetail | null; summary?: SimulationSummary | null }) {
export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail, summary, displayControls, onDisplayControlsChange }: { onOpenBacktrack?: () => void; onOpenRecalc?: () => void; onOpenReport?: () => void; detail?: PredictionDetail | null; summary?: SimulationSummary | null; displayControls?: DisplayControls; onDisplayControlsChange?: (controls: DisplayControls) => void }) {
const vessel = detail?.vessels?.[0]
const vessel2 = detail?.vessels?.[1]
const spill = detail?.spill
@ -24,12 +25,25 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail
{/* 표시 정보 제어 */}
<Section title="표시 정보 제어">
<div className="grid grid-cols-2 gap-x-2.5 gap-y-1">
<CheckboxLabel checked>/</CheckboxLabel>
<CheckboxLabel checked>/</CheckboxLabel>
<CheckboxLabel></CheckboxLabel>
<CheckboxLabel></CheckboxLabel>
<CheckboxLabel> </CheckboxLabel>
<CheckboxLabel></CheckboxLabel>
<ControlledCheckbox
checked={displayControls?.showCurrent ?? true}
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showCurrent: v })}
>/</ControlledCheckbox>
<ControlledCheckbox
checked={displayControls?.showWind ?? true}
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showWind: v })}
>/</ControlledCheckbox>
<ControlledCheckbox
checked={displayControls?.showBeached ?? false}
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showBeached: v })}
></ControlledCheckbox>
<ControlledCheckbox checked={false} onChange={() => {}} disabled>
</ControlledCheckbox>
<ControlledCheckbox
checked={displayControls?.showTimeLabel ?? false}
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showTimeLabel: v })}
> </ControlledCheckbox>
</div>
</Section>
@ -220,18 +234,33 @@ function Section({
)
}
function CheckboxLabel({ checked, children }: { checked?: boolean; children: string }) {
function ControlledCheckbox({
checked,
onChange,
children,
disabled = false,
}: {
checked: boolean;
onChange: (v: boolean) => void;
children: string;
disabled?: boolean;
}) {
return (
<label className="flex items-center gap-1.5 text-[10px] text-text-2 font-korean cursor-pointer">
<label
className={`flex items-center gap-1.5 text-[10px] font-korean cursor-pointer ${
disabled ? 'text-text-3 cursor-not-allowed opacity-40' : 'text-text-2'
}`}
>
<input
type="checkbox"
defaultChecked={checked}
className="w-[13px] h-[13px]"
className="accent-[var(--cyan)]"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="w-[13px] h-[13px] accent-[var(--cyan)]"
/>
{children}
</label>
)
);
}
function StatBox({

파일 보기

@ -0,0 +1,110 @@
interface SimulationErrorModalProps {
message: string;
onClose: () => void;
}
const SimulationErrorModal = ({ message, onClose }: SimulationErrorModalProps) => {
return (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 50,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(10, 14, 26, 0.75)',
backdropFilter: 'blur(4px)',
}}
>
<div
style={{
width: 360,
background: 'var(--bg1)',
border: '1px solid rgba(239, 68, 68, 0.35)',
borderRadius: 'var(--rM)',
padding: '28px 24px',
display: 'flex',
flexDirection: 'column',
gap: 16,
}}
>
{/* 아이콘 + 제목 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div
style={{
width: 36,
height: 36,
borderRadius: '50%',
background: 'rgba(239, 68, 68, 0.12)',
border: '1px solid rgba(239, 68, 68, 0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"
fill="rgb(239, 68, 68)"
opacity="0.9"
/>
</svg>
</div>
<div>
<div style={{ color: 'var(--t1)', fontSize: 14, fontWeight: 600 }}>
</div>
<div style={{ color: 'var(--t3)', fontSize: 12, marginTop: 2 }}>
</div>
</div>
</div>
{/* 에러 메시지 */}
<div
style={{
background: 'rgba(239, 68, 68, 0.06)',
border: '1px solid rgba(239, 68, 68, 0.2)',
borderRadius: 'var(--rS)',
padding: '10px 14px',
color: 'rgb(252, 165, 165)',
fontSize: 13,
lineHeight: 1.6,
wordBreak: 'break-word',
}}
>
{message}
</div>
{/* 확인 버튼 */}
<button
onClick={onClose}
style={{
marginTop: 4,
padding: '8px 0',
background: 'rgba(239, 68, 68, 0.15)',
border: '1px solid rgba(239, 68, 68, 0.35)',
borderRadius: 'var(--rS)',
color: 'rgb(252, 165, 165)',
fontSize: 13,
fontWeight: 600,
cursor: 'pointer',
transition: 'background 0.15s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = 'rgba(239, 68, 68, 0.25)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = 'rgba(239, 68, 68, 0.15)';
}}
>
</button>
</div>
</div>
);
};
export default SimulationErrorModal;

파일 보기

@ -18,6 +18,7 @@ export interface PredictionAnalysis {
backtrackStatus: string;
analyst: string;
officeName: string;
acdntSttsCd: string;
}
export interface PredictionDetail {

파일 보기

@ -343,13 +343,26 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
</div>
</>
)}
{sec.id === 'oil-coastal' && (
<p className="text-[12px] text-text-2 font-korean">
: <span className="font-semibold text-text-1">{oilPayload?.coastal?.firstTime ?? sampleOilData.coastal.firstTime}</span>
{' / '}
: <span className="font-semibold text-text-1">{oilPayload?.pollution.coastLength || sampleOilData.coastal.coastLength}</span>
</p>
)}
{sec.id === 'oil-coastal' && (() => {
const coastLength = oilPayload?.pollution.coastLength;
const hasNoCoastal = oilPayload && (
!coastLength || coastLength === '—' || coastLength.startsWith('0.00')
);
if (hasNoCoastal) {
return (
<p className="text-[12px] text-text-2 font-korean">
<span className="font-semibold text-text-1"> </span>.
</p>
);
}
return (
<p className="text-[12px] text-text-2 font-korean">
: <span className="font-semibold text-text-1">{oilPayload?.coastal?.firstTime ?? sampleOilData.coastal.firstTime}</span>
{' / '}
: <span className="font-semibold text-text-1">{coastLength || sampleOilData.coastal.coastLength}</span>
</p>
);
})()}
{sec.id === 'oil-defense' && (
<div className="text-[12px] text-text-3 font-korean">
<p className="mb-2"> .</p>