wing-ops/frontend/src/tabs/prediction/components/OilSpillView.tsx
htlee ff085252b0 feat(phase4): Board/HNS/Prediction/Aerial/Rescue Mock → API 전환
- Board: 매뉴얼 CRUD + 첨부파일 API (012_board_ext.sql)
- HNS: 분석 CRUD 5개 API (013_hns_analysis.sql)
- Prediction: 분석/역추적/오일펜스 7개 API (014_prediction.sql)
- Aerial: 미디어/CCTV/위성 6개 API + PostGIS (015_aerial.sql)
- Rescue: 구난 작전/시나리오 3개 API + JSONB (016_rescue.sql)
- backtrackMockData.ts 삭제

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 01:17:10 +09:00

569 lines
25 KiB
TypeScript
Executable File
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback } from 'react'
import { LeftPanel } from './LeftPanel'
import { RightPanel } from './RightPanel'
import { MapView } from '@common/components/map/MapView'
import { AnalysisListTable, type Analysis } from './AnalysisListTable'
import { OilSpillTheoryView } from './OilSpillTheoryView'
import { BoomDeploymentTheoryView } from './BoomDeploymentTheoryView'
import { BacktrackModal } from './BacktrackModal'
import { RecalcModal } from './RecalcModal'
import { BacktrackReplayBar } from '@common/components/map/BacktrackReplayBar'
import { useSubMenu, navigateToTab, setReportGenCategory } from '@common/hooks/useSubMenu'
import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine'
import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack'
import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'
import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail } from '../services/predictionApi'
import type { PredictionDetail } from '../services/predictionApi'
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
// eslint-disable-next-line react-refresh/only-export-components
export const ALL_MODELS: PredictionModel[] = ['KOSPS', 'POSEIDON', 'OpenDrift']
export function OilSpillView() {
const { activeSubTab, setActiveSubTab } = useSubMenu('prediction')
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set())
const [incidentCoord, setIncidentCoord] = useState({ lon: 127.6845, lat: 34.7312 })
const [isSelectingLocation, setIsSelectingLocation] = useState(false)
const [oilTrajectory, setOilTrajectory] = useState<Array<{ lat: number; lon: number; time: number; particle?: number; model?: PredictionModel }>>([])
const [isRunningSimulation, setIsRunningSimulation] = useState(false)
const [selectedModels, setSelectedModels] = useState<Set<PredictionModel>>(new Set(['KOSPS']))
const [predictionTime, setPredictionTime] = useState(48)
const [spillType, setSpillType] = useState('연속')
const [oilType, setOilType] = useState('벙커C유')
const [spillAmount, setSpillAmount] = useState(100)
// 오일펜스 배치 상태
const [boomLines, setBoomLines] = useState<BoomLine[]>([])
const [algorithmSettings, setAlgorithmSettings] = useState<AlgorithmSettings>({
currentOrthogonalCorrection: 15,
safetyMarginMinutes: 60,
minContainmentEfficiency: 80,
waveHeightCorrectionFactor: 1.0,
})
const [isDrawingBoom, setIsDrawingBoom] = useState(false)
const [drawingPoints, setDrawingPoints] = useState<BoomLineCoord[]>([])
const [containmentResult, setContainmentResult] = useState<ContainmentResult | null>(null)
// 레이어 스타일 (투명도 / 밝기)
const [layerOpacity, setLayerOpacity] = useState(50)
const [layerBrightness, setLayerBrightness] = useState(50)
// 타임라인 플레이어 상태
const [isPlaying, setIsPlaying] = useState(false)
const [timelinePosition, setTimelinePosition] = useState(25) // 0~100%
const [playSpeed, setPlaySpeed] = useState(1)
// 역추적 상태
const [backtrackModalOpen, setBacktrackModalOpen] = useState(false)
const [backtrackPhase, setBacktrackPhase] = useState<BacktrackPhase>('conditions')
const [backtrackVessels, setBacktrackVessels] = useState<BacktrackVessel[]>([])
const [isReplayActive, setIsReplayActive] = useState(false)
const [isReplayPlaying, setIsReplayPlaying] = useState(false)
const [replayFrame, setReplayFrame] = useState(0)
const [replaySpeed, setReplaySpeed] = useState(1)
// 선택된 분석 (목록에서 클릭 시)
const [selectedAnalysis, setSelectedAnalysis] = useState<Analysis | null>(null)
// 분석 상세 (API에서 가져온 선박/기상 정보)
const [analysisDetail, setAnalysisDetail] = useState<PredictionDetail | null>(null)
// 역추적 API 데이터
const [backtrackConditions, setBacktrackConditions] = useState<BacktrackConditions>({
estimatedSpillTime: '', analysisRange: '±12시간', searchRadius: '10 NM',
spillLocation: { lat: 34.7312, lon: 127.6845 }, totalVessels: 0,
})
const [replayShips, setReplayShips] = useState<ReplayShip[]>([])
const [collisionEvent, setCollisionEvent] = useState<CollisionEvent | null>(null)
// 재계산 상태
const [recalcModalOpen, setRecalcModalOpen] = useState(false)
const handleToggleLayer = (layerId: string, enabled: boolean) => {
setEnabledLayers(prev => {
const newSet = new Set(prev)
if (enabled) {
newSet.add(layerId)
} else {
newSet.delete(layerId)
}
return newSet
})
}
// 역추적: API에서 기존 결과 로딩
const loadBacktrackData = useCallback(async (acdntSn: number) => {
try {
const bt = await fetchBacktrackByAcdnt(acdntSn)
if (bt && bt.execSttsCd === 'completed' && bt.rsltData) {
const rslt = bt.rsltData as Record<string, unknown>
if (Array.isArray(rslt.vessels)) {
setBacktrackVessels(rslt.vessels as BacktrackVessel[])
}
if (Array.isArray(rslt.replayShips)) {
setReplayShips(rslt.replayShips as ReplayShip[])
}
if (rslt.collisionEvent) {
setCollisionEvent(rslt.collisionEvent as CollisionEvent)
}
setBacktrackConditions({
estimatedSpillTime: bt.estSpilDtm ? new Date(bt.estSpilDtm).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '',
analysisRange: bt.anlysRange || '±12시간',
searchRadius: bt.srchRadiusNm ? `${bt.srchRadiusNm} NM` : '10 NM',
spillLocation: { lat: bt.lat || incidentCoord.lat, lon: bt.lon || incidentCoord.lon },
totalVessels: bt.totalVessels || 0,
})
setBacktrackPhase('results')
return
}
} catch (err) {
console.error('[prediction] 역추적 데이터 로딩 실패:', err)
}
// 기존 결과 없으면 conditions 상태 유지
setBacktrackPhase('conditions')
setBacktrackVessels([])
setReplayShips([])
setCollisionEvent(null)
}, [incidentCoord])
// 역추적 핸들러
const handleOpenBacktrack = () => {
setBacktrackModalOpen(true)
setBacktrackConditions(prev => ({
...prev,
spillLocation: incidentCoord,
}))
if (selectedAnalysis) {
loadBacktrackData(selectedAnalysis.acdntSn)
} else {
setBacktrackPhase('conditions')
setBacktrackVessels([])
}
}
const handleRunBacktrackAnalysis = async () => {
setBacktrackPhase('analyzing')
try {
if (selectedAnalysis) {
const { backtrackSn } = await createBacktrack({
acdntSn: selectedAnalysis.acdntSn,
lon: incidentCoord.lon,
lat: incidentCoord.lat,
})
// 생성 후 기존 결과 로딩 (시드 데이터 또는 엔진 처리 결과)
const bt = await fetchBacktrackByAcdnt(selectedAnalysis.acdntSn)
if (bt && bt.rsltData) {
const rslt = bt.rsltData as Record<string, unknown>
if (Array.isArray(rslt.vessels)) {
setBacktrackVessels(rslt.vessels as BacktrackVessel[])
}
if (Array.isArray(rslt.replayShips)) {
setReplayShips(rslt.replayShips as ReplayShip[])
}
if (rslt.collisionEvent) {
setCollisionEvent(rslt.collisionEvent as CollisionEvent)
}
setBacktrackConditions(prev => ({
...prev,
totalVessels: bt.totalVessels || 0,
}))
setBacktrackPhase('results')
} else {
// 엔진 미구현 — PENDING 상태, 일단 빈 결과
console.info('[prediction] 역추적 생성 완료 (SN:', backtrackSn, '), 엔진 미구현')
setBacktrackPhase('conditions')
}
}
} catch (err) {
console.error('[prediction] 역추적 분석 실패:', err)
setBacktrackPhase('conditions')
}
}
const handleStartReplay = () => {
setBacktrackModalOpen(false)
setIsReplayActive(true)
setReplayFrame(0)
setIsReplayPlaying(false)
}
const handleCloseReplay = () => {
setIsReplayActive(false)
setIsReplayPlaying(false)
setReplayFrame(0)
}
// 역추적 리플레이 애니메이션
useEffect(() => {
if (!isReplayPlaying) return
if (replayFrame >= TOTAL_REPLAY_FRAMES) {
setIsReplayPlaying(false)
return
}
const interval = setInterval(() => {
setReplayFrame(prev => {
const next = prev + 1
if (next >= TOTAL_REPLAY_FRAMES) {
setIsReplayPlaying(false)
return TOTAL_REPLAY_FRAMES
}
return next
})
}, 50 / replaySpeed)
return () => clearInterval(interval)
}, [isReplayPlaying, replayFrame, replaySpeed])
// 분석 목록에서 사고명 클릭 시
const handleSelectAnalysis = async (analysis: Analysis) => {
setSelectedAnalysis(analysis)
if (analysis.lon != null && analysis.lat != null) {
setIncidentCoord({ lon: analysis.lon, lat: analysis.lat })
}
// 유종 매핑
const oilTypeMap: Record<string, string> = {
'BUNKER_C': '벙커C유', 'DIESEL': '경유', 'CRUDE_OIL': '원유', 'LUBE_OIL': '윤활유',
}
setOilType(oilTypeMap[analysis.oilType] || '벙커C유')
setSpillAmount(analysis.volume ?? 100)
setPredictionTime(parseInt(analysis.duration) || 48)
// 모델 상태에 따라 선택 모델 설정
const models = new Set<PredictionModel>()
if (analysis.kospsStatus !== 'pending') models.add('KOSPS')
if (analysis.poseidonStatus !== 'pending') models.add('POSEIDON')
if (analysis.opendriftStatus !== 'pending') models.add('OpenDrift')
setSelectedModels(models)
// 분석 상세 로딩 (선박/기상 정보)
try {
const detail = await fetchPredictionDetail(analysis.acdntSn)
setAnalysisDetail(detail)
} catch (err) {
console.error('[prediction] 분석 상세 로딩 실패:', err)
}
// 분석 화면으로 전환
setActiveSubTab('analysis')
}
const handleMapClick = (lon: number, lat: number) => {
if (isDrawingBoom) {
setDrawingPoints(prev => [...prev, { lat, lon }])
} else {
setIncidentCoord({ lon, lat })
setIsSelectingLocation(false)
}
}
const handleRunSimulation = async () => {
if (selectedModels.size === 0) return
setIsRunningSimulation(true)
try {
const models = Array.from(selectedModels)
const results = await Promise.all(
models.map(async (model) => {
const response = await fetch('http://localhost:3001/api/simulation/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
lat: incidentCoord.lat,
lon: incidentCoord.lon,
duration_hours: predictionTime,
oil_type: oilType,
spill_amount: spillAmount,
spill_type: spillType
})
})
if (!response.ok) {
throw new Error(`API 오류 (${model}): ${response.status}`)
}
const data = await response.json()
return (data.trajectory as Array<{ lat: number; lon: number; time: number; particle?: number }>)
.map(p => ({ ...p, model }))
})
)
setOilTrajectory(results.flat())
} catch (error) {
console.error('시뮬레이션 실행 오류:', error)
alert('시뮬레이션 실행 중 오류가 발생했습니다.')
} finally {
setIsRunningSimulation(false)
}
}
return (
<div className="flex flex-1 overflow-hidden">
{/* Left Sidebar */}
{activeSubTab === 'analysis' && (
<LeftPanel
selectedAnalysis={selectedAnalysis}
enabledLayers={enabledLayers}
onToggleLayer={handleToggleLayer}
incidentCoord={incidentCoord}
onCoordChange={setIncidentCoord}
onMapSelectClick={() => setIsSelectingLocation(true)}
onRunSimulation={handleRunSimulation}
isRunningSimulation={isRunningSimulation}
selectedModels={selectedModels}
onModelsChange={setSelectedModels}
predictionTime={predictionTime}
onPredictionTimeChange={setPredictionTime}
spillType={spillType}
onSpillTypeChange={setSpillType}
oilType={oilType}
onOilTypeChange={setOilType}
spillAmount={spillAmount}
onSpillAmountChange={setSpillAmount}
boomLines={boomLines}
onBoomLinesChange={setBoomLines}
oilTrajectory={oilTrajectory}
algorithmSettings={algorithmSettings}
onAlgorithmSettingsChange={setAlgorithmSettings}
isDrawingBoom={isDrawingBoom}
onDrawingBoomChange={setIsDrawingBoom}
drawingPoints={drawingPoints}
onDrawingPointsChange={setDrawingPoints}
containmentResult={containmentResult}
onContainmentResultChange={setContainmentResult}
layerOpacity={layerOpacity}
onLayerOpacityChange={setLayerOpacity}
layerBrightness={layerBrightness}
onLayerBrightnessChange={setLayerBrightness}
/>
)}
{/* Center - Map/Content Area */}
<div className="flex-1 relative overflow-hidden">
{activeSubTab === 'list' ? (
<AnalysisListTable onTabChange={setActiveSubTab} onSelectAnalysis={handleSelectAnalysis} />
) : activeSubTab === 'theory' ? (
<OilSpillTheoryView />
) : activeSubTab === 'boom-theory' ? (
<BoomDeploymentTheoryView />
) : (
<>
<MapView
enabledLayers={enabledLayers}
incidentCoord={incidentCoord}
isSelectingLocation={isSelectingLocation || isDrawingBoom}
onMapClick={handleMapClick}
oilTrajectory={oilTrajectory}
selectedModels={selectedModels}
boomLines={boomLines}
isDrawingBoom={isDrawingBoom}
drawingPoints={drawingPoints}
layerOpacity={layerOpacity}
layerBrightness={layerBrightness}
backtrackReplay={isReplayActive && replayShips.length > 0 ? {
isActive: true,
ships: replayShips,
collisionEvent: collisionEvent || undefined,
replayFrame,
totalFrames: TOTAL_REPLAY_FRAMES,
incidentCoord,
} : undefined}
/>
{/* 타임라인 플레이어 (리플레이 비활성 시) */}
{!isReplayActive && <div style={{
position: 'absolute', bottom: 0, left: 0, right: 0, height: '72px',
background: 'rgba(15,21,36,0.95)', backdropFilter: 'blur(16px)',
borderTop: '1px solid var(--bd)',
display: 'flex', alignItems: 'center', padding: '0 20px', gap: '16px', zIndex: 1100
}}>
{/* 컨트롤 버튼 */}
<div style={{ display: 'flex', gap: '4px', flexShrink: 0 }}>
{[
{ icon: '⏮', action: () => setTimelinePosition(0) },
{ icon: '◀', action: () => setTimelinePosition(Math.max(0, timelinePosition - 100 / 12)) },
].map((btn, i) => (
<button key={i} onClick={btn.action} style={{
width: '34px', height: '34px', borderRadius: 'var(--rS, 4px)',
border: '1px solid var(--bd)', background: 'var(--bg3)', color: 'var(--t2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: '14px', transition: '0.2s'
}}>{btn.icon}</button>
))}
<button onClick={() => setIsPlaying(!isPlaying)} style={{
width: '34px', height: '34px', borderRadius: 'var(--rS, 4px)',
border: isPlaying ? '1px solid var(--cyan)' : '1px solid var(--bd)',
background: isPlaying ? 'var(--cyan)' : 'var(--bg3)',
color: isPlaying ? 'var(--bg0)' : 'var(--t2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: '14px', transition: '0.2s'
}}>{isPlaying ? '⏸' : '▶'}</button>
{[
{ icon: '▶▶', action: () => setTimelinePosition(Math.min(100, timelinePosition + 100 / 12)) },
{ icon: '⏭', action: () => setTimelinePosition(100) },
].map((btn, i) => (
<button key={i} onClick={btn.action} style={{
width: '34px', height: '34px', borderRadius: 'var(--rS, 4px)',
border: '1px solid var(--bd)', background: 'var(--bg3)', color: 'var(--t2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: '12px', transition: '0.2s'
}}>{btn.icon}</button>
))}
<div style={{ width: '8px' }} />
<button onClick={() => setPlaySpeed(playSpeed >= 4 ? 1 : playSpeed * 2)} style={{
width: '34px', height: '34px', borderRadius: 'var(--rS, 4px)',
border: '1px solid var(--bd)', background: 'var(--bg3)', color: 'var(--t2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: '11px', fontWeight: 600, fontFamily: 'var(--fM)', transition: '0.2s'
}}>{playSpeed}×</button>
</div>
{/* 타임라인 슬라이더 */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '6px' }}>
{/* 시간 라벨 */}
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '0 4px' }}>
{['0h', '6h', '12h', '18h', '24h', '36h', '48h', '60h', '72h'].map((label, i) => {
const pos = [0, 8.33, 16.67, 25, 33.33, 50, 66.67, 83.33, 100][i]
const isActive = Math.abs(timelinePosition - pos) < 5
return (
<span key={label} style={{
fontSize: '10px', fontFamily: 'var(--fM)',
color: isActive ? 'var(--cyan)' : 'var(--t3)',
fontWeight: isActive ? 600 : 400, cursor: 'pointer'
}} onClick={() => setTimelinePosition(pos)}>{label}</span>
)
})}
</div>
{/* 슬라이더 트랙 */}
<div style={{ position: 'relative', height: '24px', display: 'flex', alignItems: 'center' }}>
{/* 트랙 레일 */}
<div
style={{ width: '100%', height: '4px', background: 'var(--bd)', borderRadius: '2px', position: 'relative', cursor: 'pointer' }}
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
setTimelinePosition(Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100)))
}}
>
{/* 진행 바 */}
<div style={{
position: 'absolute', top: 0, left: 0,
width: `${timelinePosition}%`, height: '100%',
background: 'linear-gradient(90deg, var(--cyan), var(--blue))',
borderRadius: '2px', transition: 'width 0.15s'
}} />
{/* 주요 마커 */}
{[0, 16.67, 33.33, 50, 66.67, 83.33, 100].map((pos) => (
<div key={`mj-${pos}`} style={{
position: 'absolute', width: '2px', height: '14px',
background: 'var(--t3)', top: '-5px', left: `${pos}%`
}} />
))}
{/* 보조 마커 */}
{[8.33, 25].map((pos) => (
<div key={`mn-${pos}`} style={{
position: 'absolute', width: '2px', height: '10px',
background: 'var(--bdL)', top: '-3px', left: `${pos}%`
}} />
))}
{/* 방어선 설치 이벤트 마커 */}
{boomLines.length > 0 && [
{ pos: 4.2, label: '1차 방어선 설치 (+3h)' },
{ pos: 8.3, label: '2차 방어선 설치 (+6h)' },
{ pos: 12.5, label: '3차 방어선 설치 (+9h)' },
].slice(0, boomLines.length).map((bm, i) => (
<div key={`bm-${i}`} title={bm.label} style={{
position: 'absolute', top: '-18px', left: `${bm.pos}%`,
transform: 'translateX(-50%)', fontSize: '12px', cursor: 'pointer',
filter: 'drop-shadow(0 0 4px rgba(245,158,11,0.5))'
}}>🛡</div>
))}
</div>
{/* 드래그 핸들 */}
<div style={{
position: 'absolute', left: `${timelinePosition}%`, top: '50%',
transform: 'translate(-50%, -50%)',
width: '16px', height: '16px',
background: 'var(--cyan)', border: '3px solid var(--bg0)',
borderRadius: '50%', cursor: 'grab',
boxShadow: '0 0 10px rgba(6,182,212,0.4)', zIndex: 2,
transition: 'left 0.15s'
}} />
</div>
</div>
{/* 시간 정보 */}
<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)' }}>
+{Math.round(timelinePosition * 72 / 100)}h {(() => {
const d = new Date(); d.setHours(d.getHours() + Math.round(timelinePosition * 72 / 100))
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 style={{ display: 'flex', gap: '14px' }}>
{[
{ label: '풍화율', value: `${Math.min(99, Math.round(timelinePosition * 0.4))}%`, color: 'var(--t1)' },
{ label: '면적', value: `${(timelinePosition * 0.08).toFixed(1)} km²`, color: 'var(--t1)' },
{ label: '차단율', value: boomLines.length > 0 ? `${Math.min(95, 70 + Math.round(timelinePosition * 0.2))}%` : '—', color: 'var(--boom)' },
].map((s, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px' }}>
<span style={{ color: 'var(--t3)', fontFamily: 'var(--fK)' }}>{s.label}</span>
<span style={{ color: s.color, fontWeight: 600, fontFamily: 'var(--fM)' }}>{s.value}</span>
</div>
))}
</div>
</div>
</div>}
{/* 역추적 리플레이 바 */}
{isReplayActive && (
<BacktrackReplayBar
isPlaying={isReplayPlaying}
replayFrame={replayFrame}
totalFrames={TOTAL_REPLAY_FRAMES}
replaySpeed={replaySpeed}
onTogglePlay={() => setIsReplayPlaying(!isReplayPlaying)}
onSeek={setReplayFrame}
onSpeedChange={setReplaySpeed}
onClose={handleCloseReplay}
replayShips={replayShips}
collisionEvent={collisionEvent || undefined}
/>
)}
</>
)}
</div>
{/* Right Panel */}
{activeSubTab === 'analysis' && <RightPanel onOpenBacktrack={handleOpenBacktrack} onOpenRecalc={() => setRecalcModalOpen(true)} onOpenReport={() => { setReportGenCategory(0); navigateToTab('reports', 'generate') }} detail={analysisDetail} />}
{/* 재계산 모달 */}
<RecalcModal
isOpen={recalcModalOpen}
onClose={() => setRecalcModalOpen(false)}
oilType={oilType}
spillAmount={spillAmount}
spillType={spillType}
predictionTime={predictionTime}
incidentCoord={incidentCoord}
selectedModels={selectedModels}
onSubmit={(params) => {
setOilType(params.oilType)
setSpillAmount(params.spillAmount)
setSpillType(params.spillType)
setPredictionTime(params.predictionTime)
setIncidentCoord(params.incidentCoord)
setSelectedModels(params.selectedModels)
handleRunSimulation()
}}
/>
{/* 역추적 모달 */}
<BacktrackModal
isOpen={backtrackModalOpen}
onClose={() => setBacktrackModalOpen(false)}
phase={backtrackPhase}
conditions={backtrackConditions}
vessels={backtrackVessels}
onRunAnalysis={handleRunBacktrackAnalysis}
onStartReplay={handleStartReplay}
/>
</div>
)
}