wing-ops/frontend/src/tabs/prediction/components/OilSpillView.tsx

1278 lines
54 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, useMemo, useRef } 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, setOilReportPayload, type OilReportPayload } 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, fetchAnalysisTrajectory } from '../services/predictionApi'
import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, RunModelSyncResponse, SimulationSummary, WindPoint } from '../services/predictionApi'
import SimulationLoadingOverlay from './SimulationLoadingOverlay'
import SimulationErrorModal from './SimulationErrorModal'
import { api } from '@common/services/api'
import { generateAIBoomLines, haversineDistance, pointInPolygon, polygonAreaKm2, circleAreaKm2 } from '@common/utils/geo'
import { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal'
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
// ---------------------------------------------------------------------------
// 민감자원 타입 + 데모 데이터
// ---------------------------------------------------------------------------
export interface SensitiveResource {
id: string
name: string
type: 'aquaculture' | 'beach' | 'ecology' | 'intake'
lat: number
lon: number
radiusM: number
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 },
{ id: 'ec-1', name: '여자만 습지보호구역', type: 'ecology', lat: 34.758, lon: 127.614, radiusM: 1200, arrivalTimeH: 6 },
{ id: 'aq-2', name: '화태도 김 양식장', type: 'aquaculture', lat: 34.648, lon: 127.652, radiusM: 800, arrivalTimeH: 10 },
{ id: 'aq-3', name: '개도 해안 양식장', type: 'aquaculture', lat: 34.612, lon: 127.636, radiusM: 600, arrivalTimeH: 18 },
]
// ---------------------------------------------------------------------------
// 데모 궤적 생성 (seeded PRNG — deterministic)
// ---------------------------------------------------------------------------
function mulberry32(seed: number) {
return () => {
seed |= 0; seed = seed + 0x6D2B79F5 | 0
let t = Math.imul(seed ^ seed >>> 15, 1 | seed)
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t
return ((t ^ t >>> 14) >>> 0) / 4294967296
}
}
const DEG2RAD = Math.PI / 180
function generateDemoTrajectory(
incident: { lat: number; lon: number },
models: PredictionModel[],
durationHours: number
): Array<{ lat: number; lon: number; time: number; particle: number; model: PredictionModel }> {
const result: Array<{ lat: number; lon: number; time: number; particle: number; model: PredictionModel }> = []
const PARTICLES_PER_MODEL = 60
const TIME_STEP = 3 // hours
const modelParams: Record<PredictionModel, { bearing: number; speed: number; spread: number; seed: number }> = {
KOSPS: { bearing: 200, speed: 0.003, spread: 0.008, seed: 42 },
POSEIDON: { bearing: 210, speed: 0.0025, spread: 0.01, seed: 137 },
OpenDrift: { bearing: 190, speed: 0.0035, spread: 0.006, seed: 271 },
}
for (const model of models) {
const p = modelParams[model]
const rng = mulberry32(p.seed)
for (let pid = 0; pid < PARTICLES_PER_MODEL; pid++) {
const particleAngleOffset = (rng() - 0.5) * 40 // ±20°
const particleSpeedFactor = 0.7 + rng() * 0.6 // 0.7~1.3
for (let t = 0; t <= durationHours; t += TIME_STEP) {
const timeFactor = t / durationHours
const bearing = (p.bearing + particleAngleOffset) * DEG2RAD
const dist = p.speed * t * particleSpeedFactor
const turbLat = (rng() - 0.5) * p.spread * timeFactor
const turbLon = (rng() - 0.5) * p.spread * timeFactor
const lat = incident.lat + dist * Math.cos(bearing) + turbLat
const lon = incident.lon + dist * Math.sin(bearing) / Math.cos(incident.lat * DEG2RAD) + turbLon
result.push({ lat, lon, time: t, particle: pid, model })
}
}
}
return result
}
// 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: number; lat: number } | null>(null)
const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined)
const flyToTarget = null
const fitBoundsTarget = null
const [isSelectingLocation, setIsSelectingLocation] = useState(false)
const [oilTrajectory, setOilTrajectory] = useState<OilParticle[]>([])
const [centerPoints, setCenterPoints] = useState<CenterPoint[]>([])
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 [simulationProgress, setSimulationProgress] = useState(0)
const progressTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const [simulationError, setSimulationError] = useState<string | null>(null)
const [selectedModels, setSelectedModels] = useState<Set<PredictionModel>>(new Set(['OpenDrift']))
const [visibleModels, setVisibleModels] = useState<Set<PredictionModel>>(new Set(['OpenDrift']))
const [predictionTime, setPredictionTime] = useState(48)
const [accidentTime, setAccidentTime] = useState<string>('')
const [spillType, setSpillType] = useState('연속')
const [oilType, setOilType] = useState('벙커C유')
const [spillAmount, setSpillAmount] = useState(100)
const [incidentName, setIncidentName] = useState('')
const [spillUnit, setSpillUnit] = useState('kL')
// 민감자원
const [sensitiveResources, setSensitiveResources] = useState<SensitiveResource[]>([])
// 오일펜스 배치 상태
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 [displayControls, setDisplayControls] = useState<DisplayControls>({
showCurrent: true,
showWind: false,
showBeached: false,
showTimeLabel: false,
})
// 타임라인 플레이어 상태
const [isPlaying, setIsPlaying] = useState(false)
const [currentStep, setCurrentStep] = useState(0) // 현재 시간값 (시간 단위)
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: 37.3883, lon: 126.6435 }, totalVessels: 0,
})
const [replayShips, setReplayShips] = useState<ReplayShip[]>([])
const [collisionEvent, setCollisionEvent] = useState<CollisionEvent | null>(null)
// 재계산 상태
const [recalcModalOpen, setRecalcModalOpen] = useState(false)
const [simulationSummary, setSimulationSummary] = useState<SimulationSummary | null>(null)
const [summaryByModel, setSummaryByModel] = useState<Record<string, SimulationSummary>>({})
const [stepSummariesByModel, setStepSummariesByModel] = useState<Record<string, SimulationSummary[]>>({})
// 오염분석 상태
const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon')
const [drawAnalysisMode, setDrawAnalysisMode] = useState<'polygon' | null>(null)
const [analysisPolygonPoints, setAnalysisPolygonPoints] = useState<{ lat: number; lon: number }[]>([])
const [circleRadiusNm, setCircleRadiusNm] = useState<number>(5)
const [analysisResult, setAnalysisResult] = useState<{ area: number; particleCount: number; particlePercent: number; sensitiveCount: number } | null>(null)
// 원 분석용 derived 값 (state 아님)
const analysisCircleCenter = analysisTab === 'circle' && incidentCoord ? incidentCoord : null
const analysisCircleRadiusM = circleRadiusNm * 1852
// 분석 탭 초기 진입 시 기본 데모 자동 표시
useEffect(() => {
if (activeSubTab === 'analysis' && oilTrajectory.length === 0 && !selectedAnalysis) {
const models = Array.from(selectedModels.size > 0 ? selectedModels : new Set<PredictionModel>(['OpenDrift']))
const coord = incidentCoord ?? { lat: 37.39, lon: 126.64 }
const demoTrajectory = generateDemoTrajectory(coord, models, predictionTime)
setOilTrajectory(demoTrajectory)
const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings)
setBoomLines(demoBooms)
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeSubTab])
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 || 0, lon: bt.lon || incidentCoord?.lon || 0 },
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 ?? prev.spillLocation,
}))
if (selectedAnalysis) {
loadBacktrackData(selectedAnalysis.acdntSn)
} else {
setBacktrackPhase('conditions')
setBacktrackVessels([])
}
}
const handleRunBacktrackAnalysis = async () => {
if (!incidentCoord) return
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
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, replaySpeed])
// flyTo 완료 후 재생 대기 플래그
const pendingPlayRef = useRef(false)
// 항공 이미지 분석 완료 후 자동실행 플래그
const pendingAutoRunRef = useRef(false)
// 마운트 시 이미지 분석 시그널 확인 (유출유면적분석 탭에서 이동한 경우)
useEffect(() => {
const pending = consumePendingImageAnalysis()
if (!pending) return
handleImageAnalysisResult({
acdntSn: pending.acdntSn,
lat: pending.lat,
lon: pending.lon,
oilType: pending.oilType,
area: pending.area,
volume: pending.volume,
fileId: pending.fileId,
occurredAt: pending.occurredAt,
})
if (pending.autoRun) pendingAutoRunRef.current = true
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// incidentCoord 업데이트 후 시뮬레이션 자동실행
useEffect(() => {
if (pendingAutoRunRef.current && incidentCoord) {
pendingAutoRunRef.current = false
handleRunSimulation()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [incidentCoord])
const handleFlyEnd = useCallback(() => {
setFlyToCoord(undefined)
if (pendingPlayRef.current) {
pendingPlayRef.current = false
setIsPlaying(true)
}
}, [])
// trajectory 변경 시 플레이어 스텝 초기화 (재생은 각 경로에서 별도 처리)
useEffect(() => {
if (oilTrajectory.length > 0) {
setCurrentStep(0);
}
}, [oilTrajectory.length]);
useEffect(() => {
return () => {
if (progressTimerRef.current) clearInterval(progressTimerRef.current);
};
}, []);
// visibleModels 변경 시 windHydrModel 동기화
useEffect(() => {
if (visibleModels.size === 0) return;
if (visibleModels.size === 1) {
// 단일 모델 → 항상 해당 모델로 동기화
setWindHydrModel(Array.from(visibleModels)[0]);
} else if (!visibleModels.has(windHydrModel as PredictionModel)) {
// 다중 모델이지만 현재 선택이 사라진 경우 → fallback
const preferred: PredictionModel[] = ['OpenDrift', 'POSEIDON', 'KOSPS'];
const next = preferred.find(m => visibleModels.has(m)) ?? Array.from(visibleModels)[0];
setWindHydrModel(next);
}
}, [visibleModels]);
// 플레이어 재생 애니메이션 (1x = 1초/스텝, 2x = 0.5초/스텝, 4x = 0.25초/스텝)
const timeSteps = useMemo(() => {
if (oilTrajectory.length === 0) return [];
const unique = [...new Set(oilTrajectory.map(p => p.time))].sort((a, b) => a - b);
return unique;
}, [oilTrajectory]);
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) {
setIsPlaying(false);
return;
}
const ms = 1000 / playSpeed;
const id = setInterval(() => {
setCurrentStep(prev => {
const idx = timeSteps.indexOf(prev);
if (idx < 0 || idx >= timeSteps.length - 1) {
setIsPlaying(false);
return timeSteps[timeSteps.length - 1];
}
return timeSteps[idx + 1];
});
}, ms);
return () => clearInterval(id);
}, [isPlaying, currentStep, playSpeed, timeSteps, maxTime]);
// 분석 목록에서 사고명 클릭 시
const handleSelectAnalysis = async (analysis: Analysis) => {
setIsPlaying(false)
setCurrentStep(0)
setSelectedAnalysis(analysis)
setCenterPoints([])
if (analysis.occurredAt) {
setAccidentTime(analysis.occurredAt.slice(0, 16))
}
if (analysis.lon != null && analysis.lat != null) {
setIncidentCoord({ lon: analysis.lon, lat: analysis.lat })
setFlyToCoord({ 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)
setVisibleModels(models)
// 분석 상세 로딩 (선박/기상 정보)
try {
const detail = await fetchPredictionDetail(analysis.acdntSn)
setAnalysisDetail(detail)
} catch (err) {
console.error('[prediction] 분석 상세 로딩 실패:', err)
}
// 분석 화면으로 전환
setActiveSubTab('analysis')
const coord = (analysis.lon != null && analysis.lat != null)
? { lon: analysis.lon, lat: analysis.lat }
: incidentCoord
const demoModels = Array.from(models.size > 0 ? models : new Set<PredictionModel>(['KOSPS']))
// 완료된 모델이 있는 경우 실제 궤적 로드, 없으면 데모로 fallback
const hasCompletedModel =
analysis.opendriftStatus === 'completed' || analysis.poseidonStatus === 'completed';
if (hasCompletedModel) {
try {
const { trajectory, summary, centerPoints: cp, windDataByModel: wdByModel, hydrDataByModel: hdByModel, summaryByModel: sbModel, stepSummariesByModel: stepSbModel } = await fetchAnalysisTrajectory(analysis.acdntSn)
if (trajectory && trajectory.length > 0) {
setOilTrajectory(trajectory)
if (summary) setSimulationSummary(summary)
setCenterPoints(cp ?? [])
setWindDataByModel(wdByModel ?? {});
setHydrDataByModel(hdByModel ?? {});
if (sbModel) setSummaryByModel(sbModel);
if (stepSbModel) setStepSummariesByModel(stepSbModel);
if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings))
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
pendingPlayRef.current = true
} else {
setIsPlaying(true)
}
return
}
} catch (err) {
console.error('[prediction] trajectory 로딩 실패, 데모로 fallback:', err)
}
}
// 데모 궤적 생성 (fallback) — stale wind/current 데이터 초기화
setWindDataByModel({})
setHydrDataByModel({})
setSummaryByModel({})
setStepSummariesByModel({})
const demoTrajectory = generateDemoTrajectory(coord ?? { lat: 37.39, lon: 126.64 }, demoModels, parseInt(analysis.duration) || 48)
setOilTrajectory(demoTrajectory)
if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings))
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
pendingPlayRef.current = true
} else {
setIsPlaying(true)
}
}
const handleMapClick = (lon: number, lat: number) => {
if (isDrawingBoom) {
setDrawingPoints(prev => [...prev, { lat, lon }])
} else if (drawAnalysisMode === 'polygon') {
setAnalysisPolygonPoints(prev => [...prev, { lat, lon }])
} else {
setIncidentCoord({ lon, lat })
setIsSelectingLocation(false)
}
}
const handleStartPolygonDraw = () => {
setDrawAnalysisMode('polygon')
setAnalysisPolygonPoints([])
setAnalysisResult(null)
}
const handleRunPolygonAnalysis = () => {
if (analysisPolygonPoints.length < 3) return
const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1
const inside = currentParticles.filter(p => pointInPolygon({ lat: p.lat, lon: p.lon }, analysisPolygonPoints)).length
const sensitiveCount = sensitiveResources.filter(r => pointInPolygon({ lat: r.lat, lon: r.lon }, analysisPolygonPoints)).length
setAnalysisResult({
area: polygonAreaKm2(analysisPolygonPoints),
particleCount: inside,
particlePercent: Math.round((inside / totalIds) * 100),
sensitiveCount,
})
setDrawAnalysisMode(null)
}
const handleRunCircleAnalysis = () => {
if (!incidentCoord) return
const radiusM = circleRadiusNm * 1852
const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1
const inside = currentParticles.filter(p =>
haversineDistance({ lat: incidentCoord.lat, lon: incidentCoord.lon }, { lat: p.lat, lon: p.lon }) <= radiusM
).length
const sensitiveCount = sensitiveResources.filter(r =>
haversineDistance({ lat: incidentCoord.lat, lon: incidentCoord.lon }, { lat: r.lat, lon: r.lon }) <= radiusM
).length
setAnalysisResult({
area: circleAreaKm2(radiusM),
particleCount: inside,
particlePercent: Math.round((inside / totalIds) * 100),
sensitiveCount,
})
}
const handleCancelAnalysis = () => {
setDrawAnalysisMode(null)
setAnalysisPolygonPoints([])
}
const handleClearAnalysis = () => {
setDrawAnalysisMode(null)
setAnalysisPolygonPoints([])
setAnalysisResult(null)
}
const handleImageAnalysisResult = useCallback((result: ImageAnalyzeResult) => {
setIncidentCoord({ lat: result.lat, lon: result.lon })
setFlyToCoord({ lat: result.lat, lon: result.lon })
setAccidentTime(result.occurredAt.slice(0, 16))
setOilType(result.oilType)
setSpillAmount(parseFloat(result.volume.toFixed(4)))
setSpillUnit('kL')
setSelectedAnalysis({
acdntSn: result.acdntSn,
acdntNm: '',
occurredAt: result.occurredAt,
analysisDate: '',
requestor: '',
duration: '48',
oilType: result.oilType,
volume: result.volume,
location: '',
lat: result.lat,
lon: result.lon,
kospsStatus: 'pending',
poseidonStatus: 'pending',
opendriftStatus: 'pending',
backtrackStatus: 'pending',
analyst: '',
officeName: '',
acdntSttsCd: 'ACTIVE',
})
}, [])
const startProgressTimer = useCallback((runTimeHours: number) => {
const expectedMs = runTimeHours * 6000;
const startTime = Date.now();
progressTimerRef.current = setInterval(() => {
const elapsed = Date.now() - startTime;
setSimulationProgress(Math.min(90, Math.round((elapsed / expectedMs) * 90)));
}, 500);
}, []);
const stopProgressTimer = useCallback((completed: boolean) => {
if (progressTimerRef.current) {
clearInterval(progressTimerRef.current);
progressTimerRef.current = null;
}
if (completed) {
setSimulationProgress(100);
setTimeout(() => setSimulationProgress(0), 800);
} else {
setSimulationProgress(0);
}
}, []);
const handleRunSimulation = async (overrides?: {
models?: Set<PredictionModel>;
oilType?: string;
spillAmount?: number;
spillType?: string;
predictionTime?: number;
incidentCoord?: { lat: number; lon: number } | null;
}) => {
// incidentName이 있으면 직접 입력 모드 — 기존 selectedAnalysis.acdntSn 무시하고 새 사고 생성
const isDirectInput = incidentName.trim().length > 0;
const existingAcdntSn = isDirectInput
? undefined
: (selectedAnalysis?.acdntSn ?? analysisDetail?.acdnt?.acdntSn);
const effectiveCoord = overrides?.incidentCoord ?? incidentCoord;
if (!isDirectInput && !existingAcdntSn) return;
if (!effectiveCoord) return;
const effectiveOilType = overrides?.oilType ?? oilType;
const effectiveSpillAmount = overrides?.spillAmount ?? spillAmount;
const effectiveSpillType = overrides?.spillType ?? spillType;
const effectivePredictionTime = overrides?.predictionTime ?? predictionTime;
const effectiveModels = overrides?.models ?? selectedModels;
setIsRunningSimulation(true);
setSimulationSummary(null);
startProgressTimer(effectivePredictionTime);
let simulationSucceeded = false;
try {
const payload: Record<string, unknown> = {
acdntSn: existingAcdntSn,
lat: effectiveCoord.lat,
lon: effectiveCoord.lon,
runTime: effectivePredictionTime,
matTy: effectiveOilType,
matVol: effectiveSpillAmount,
spillTime: effectiveSpillType === '연속' ? effectivePredictionTime : 0,
startTime: accidentTime
? `${accidentTime}:00`
: analysisDetail?.acdnt?.occurredAt,
models: Array.from(effectiveModels),
};
if (isDirectInput) {
payload.acdntNm = incidentName.trim();
payload.spillUnit = spillUnit;
payload.spillTypeCd = spillType;
}
// 동기 방식: 예측 완료 시 결과를 직접 반환 (최대 35분 대기)
const { data } = await api.post<RunModelSyncResponse>('/simulation/run-model', payload, {
timeout: 35 * 60 * 1000,
});
// 직접 입력으로 신규 생성된 경우: selectedAnalysis 갱신 + incidentName 초기화
if (data.acdntSn && isDirectInput) {
setSelectedAnalysis({
acdntSn: data.acdntSn,
acdntNm: incidentName.trim(),
occurredAt: accidentTime ? `${accidentTime}:00` : '',
analysisDate: new Date().toISOString(),
requestor: '',
duration: String(predictionTime),
oilType,
volume: spillAmount,
location: '',
lat: effectiveCoord.lat,
lon: effectiveCoord.lon,
kospsStatus: 'pending',
poseidonStatus: 'pending',
opendriftStatus: 'pending',
backtrackStatus: 'pending',
analyst: '',
officeName: '',
} as Analysis);
setIncidentName('');
}
// 결과 처리
const merged: OilParticle[] = [];
let latestSummary: SimulationSummary | null = null;
let latestCenterPoints: CenterPoint[] = [];
const newWindDataByModel: Record<string, WindPoint[][]> = {};
const newHydrDataByModel: Record<string, (HydrDataStep | null)[]> = {};
const newSummaryByModel: Record<string, SimulationSummary> = {};
const errors: string[] = [];
data.results.forEach(({ model, status, trajectory, summary, centerPoints, windData, hydrData, error }) => {
if (status === 'ERROR') {
errors.push(error || `${model} 분석 중 오류가 발생했습니다.`);
return;
}
if (trajectory) {
merged.push(...trajectory.map(p => ({ ...p, model })));
}
if (summary) {
newSummaryByModel[model] = summary;
if (model === 'OpenDrift' || !latestSummary) latestSummary = summary;
}
if (windData) newWindDataByModel[model] = windData;
if (hydrData) newHydrDataByModel[model] = hydrData;
if (centerPoints) {
latestCenterPoints = [...latestCenterPoints, ...centerPoints.map(p => ({ ...p, model }))];
}
});
if (merged.length > 0) {
setOilTrajectory(merged);
const doneModels = new Set<PredictionModel>(
data.results
.filter(r => r.status === 'DONE' && r.trajectory && r.trajectory.length > 0)
.map(r => r.model as PredictionModel)
);
setVisibleModels(doneModels);
setSimulationSummary(latestSummary);
setCenterPoints(latestCenterPoints);
const refWindData = newWindDataByModel['OpenDrift'] ?? Object.values(newWindDataByModel)[0];
const refHydrData = newHydrDataByModel['OpenDrift'] ?? Object.values(newHydrDataByModel)[0];
doneModels.forEach(model => {
if (!newWindDataByModel[model] && refWindData) newWindDataByModel[model] = refWindData;
if (!newHydrDataByModel[model] && refHydrData) newHydrDataByModel[model] = refHydrData;
});
setWindDataByModel(newWindDataByModel);
setHydrDataByModel(newHydrDataByModel);
setSummaryByModel(newSummaryByModel);
const booms = generateAIBoomLines(merged, effectiveCoord, algorithmSettings);
setBoomLines(booms);
setSensitiveResources(DEMO_SENSITIVE_RESOURCES);
setCurrentStep(0);
setIsPlaying(true);
setFlyToCoord({ lon: effectiveCoord.lon, lat: effectiveCoord.lat });
}
if (errors.length > 0 && merged.length === 0) {
setSimulationError(errors.join('; '));
} else {
simulationSucceeded = true;
}
} catch (err) {
const msg =
(err as { message?: string })?.message
?? '시뮬레이션 실행 중 오류가 발생했습니다.';
setSimulationError(msg);
} finally {
stopProgressTimer(simulationSucceeded);
setIsRunningSimulation(false);
}
}
const handleOpenReport = () => {
const OIL_TYPE_CODE: Record<string, string> = {
'벙커C유': 'BUNKER_C', '경유': 'DIESEL', '원유': 'CRUDE_OIL', '윤활유': 'LUBE_OIL',
};
const accidentName =
selectedAnalysis?.acdntNm ||
analysisDetail?.acdnt?.acdntNm ||
incidentName ||
'(미입력)';
const occurTime =
selectedAnalysis?.occurredAt ||
analysisDetail?.acdnt?.occurredAt ||
accidentTime ||
'';
const wx = analysisDetail?.weather?.[0] ?? null;
const payload: OilReportPayload = {
incident: {
name: accidentName,
occurTime,
location: selectedAnalysis?.location || analysisDetail?.acdnt?.location || (() => {
const _lat = incidentCoord?.lat ?? selectedAnalysis?.lat ?? null;
const _lon = incidentCoord?.lon ?? selectedAnalysis?.lon ?? null;
return (_lat != null && _lon != null)
? `위도 ${Number(_lat).toFixed(4)}, 경도 ${Number(_lon).toFixed(4)}`
: '';
})(),
lat: incidentCoord?.lat ?? selectedAnalysis?.lat ?? null,
lon: incidentCoord?.lon ?? selectedAnalysis?.lon ?? null,
pollutant: OIL_TYPE_CODE[oilType] || oilType,
spillAmount: `${spillAmount} ${spillUnit}`,
shipName: analysisDetail?.vessels?.[0]?.vesselNm || '',
},
pollution: {
spillAmount: `${spillAmount.toFixed(2)} ${spillUnit}`,
weathered: simulationSummary ? `${simulationSummary.weatheredVolume.toFixed(2)}` : '—',
seaRemain: simulationSummary ? `${simulationSummary.remainingVolume.toFixed(2)}` : '—',
pollutionArea: simulationSummary ? `${simulationSummary.pollutionArea.toFixed(2)} km²` : '—',
coastAttach: simulationSummary ? `${simulationSummary.beachedVolume.toFixed(2)}` : '—',
coastLength: simulationSummary ? `${simulationSummary.pollutionCoastLength.toFixed(2)} km` : '—',
oilType: OIL_TYPE_CODE[oilType] || oilType,
},
weather: wx
? { windDir: wx.wind, windSpeed: wx.wind, waveHeight: wx.wave, temp: wx.temp }
: null,
spread: (() => {
const fmt = (model: string) => {
const s = summaryByModel[model];
return s ? `${s.pollutionArea.toFixed(2)} km²` : '—';
};
return { kosps: fmt('KOSPS'), openDrift: fmt('OpenDrift'), poseidon: fmt('POSEIDON') };
})(),
coastal: {
firstTime: (() => {
const beachedTimes = oilTrajectory.filter(p => p.stranded === 1).map(p => p.time);
if (beachedTimes.length === 0) return null;
const d = new Date(Math.min(...beachedTimes) * 1000);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
})(),
},
spreadSteps: (() => {
const steps = stepSummariesByModel[windHydrModel] ?? [];
const toRow = (elapsed: string, s: typeof steps[0] | undefined) => ({
elapsed,
weathered: s ? s.weatheredVolume.toFixed(2) : '',
seaRemain: s ? s.remainingVolume.toFixed(2) : '',
coastAttach: s ? s.beachedVolume.toFixed(2) : '',
area: s ? s.pollutionArea.toFixed(2) : '',
});
return [toRow('3시간', steps[3]), toRow('6시간', steps[6])];
})(),
hasSimulation: simulationSummary !== null,
mapData: incidentCoord ? {
center: [incidentCoord.lat, incidentCoord.lon],
zoom: 10,
trajectory: oilTrajectory,
currentStep,
centerPoints,
simulationStartTime: accidentTime,
} : null,
};
setOilReportPayload(payload);
setReportGenCategory(0);
navigateToTab('reports', 'generate');
};
return (
<div className="relative flex flex-1 overflow-hidden">
{/* Left Sidebar */}
{activeSubTab === 'analysis' && (
<LeftPanel
selectedAnalysis={selectedAnalysis}
enabledLayers={enabledLayers}
onToggleLayer={handleToggleLayer}
accidentTime={accidentTime}
onAccidentTimeChange={setAccidentTime}
incidentCoord={incidentCoord}
onCoordChange={setIncidentCoord}
isSelectingLocation={isSelectingLocation}
onMapSelectClick={() => setIsSelectingLocation(prev => !prev)}
onRunSimulation={handleRunSimulation}
isRunningSimulation={isRunningSimulation}
selectedModels={selectedModels}
onModelsChange={setSelectedModels}
visibleModels={visibleModels}
onVisibleModelsChange={setVisibleModels}
hasResults={oilTrajectory.length > 0}
predictionTime={predictionTime}
onPredictionTimeChange={setPredictionTime}
spillType={spillType}
onSpillTypeChange={setSpillType}
oilType={oilType}
onOilTypeChange={setOilType}
spillAmount={spillAmount}
onSpillAmountChange={setSpillAmount}
incidentName={incidentName}
onIncidentNameChange={setIncidentName}
spillUnit={spillUnit}
onSpillUnitChange={setSpillUnit}
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}
onImageAnalysisResult={handleImageAnalysisResult}
/>
)}
{/* 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 ?? undefined}
flyToIncident={flyToCoord}
isSelectingLocation={isSelectingLocation || isDrawingBoom || drawAnalysisMode === 'polygon'}
onMapClick={handleMapClick}
oilTrajectory={oilTrajectory.filter(p => visibleModels.has((p.model || 'OpenDrift') as PredictionModel))}
selectedModels={selectedModels}
boomLines={boomLines}
isDrawingBoom={isDrawingBoom}
drawingPoints={drawingPoints}
layerOpacity={layerOpacity}
layerBrightness={layerBrightness}
sensitiveResources={sensitiveResources}
lightMode
centerPoints={centerPoints.filter(p => visibleModels.has((p.model || 'OpenDrift') as PredictionModel))}
windData={windData}
hydrData={hydrData}
flyToTarget={flyToTarget}
fitBoundsTarget={fitBoundsTarget}
onIncidentFlyEnd={handleFlyEnd}
drawAnalysisMode={drawAnalysisMode}
analysisPolygonPoints={analysisPolygonPoints}
analysisCircleCenter={analysisCircleCenter}
analysisCircleRadiusM={analysisCircleRadiusM}
externalCurrentTime={oilTrajectory.length > 0 ? currentStep : undefined}
backtrackReplay={isReplayActive && replayShips.length > 0 && incidentCoord ? {
isActive: true,
ships: replayShips,
collisionEvent: collisionEvent ?? null,
replayFrame,
totalFrames: TOTAL_REPLAY_FRAMES,
incidentCoord,
} : undefined}
showCurrent={displayControls.showCurrent}
showWind={displayControls.showWind}
showBeached={displayControls.showBeached}
showTimeLabel={displayControls.showTimeLabel}
simulationStartTime={accidentTime || undefined}
/>
{/* 타임라인 플레이어 (리플레이 비활성 시) */}
{!isReplayActive && (() => {
const progressPct = maxTime > 0 ? (currentStep / maxTime) * 100 : 0;
// 동적 라벨: 스텝 수에 따라 균등 분배
const visibleLabels: number[] = (() => {
if (timeSteps.length === 0) return [0];
if (timeSteps.length <= 8) return timeSteps;
const interval = Math.ceil(timeSteps.length / 7);
return timeSteps.filter((_, i) => i % interval === 0 || i === timeSteps.length - 1);
})();
return (
<div className="absolute bottom-0 left-0 right-0 h-[72px] flex items-center px-5 gap-4" style={{
background: 'rgba(15,21,36,0.95)', backdropFilter: 'blur(16px)',
borderTop: '1px solid var(--bd)',
zIndex: 1100
}}>
{/* 컨트롤 버튼 */}
<div className="flex gap-1 shrink-0">
{[
{ icon: '⏮', action: () => { setCurrentStep(timeSteps[0] ?? 0); setIsPlaying(false); } },
{ icon: '◀', action: () => { const idx = timeSteps.indexOf(currentStep); if (idx > 0) setCurrentStep(timeSteps[idx - 1]); } },
].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={() => {
if (!isPlaying && currentStep >= maxTime) setCurrentStep(timeSteps[0] ?? 0);
setIsPlaying(p => !p);
}} 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: () => { const idx = timeSteps.indexOf(currentStep); if (idx < timeSteps.length - 1) setCurrentStep(timeSteps[idx + 1]); } },
{ icon: '⏭', action: () => { setCurrentStep(maxTime); setIsPlaying(false); } },
].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 className="w-2" />
<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 className="flex-1 flex flex-col gap-1.5">
{/* 동적 시간 라벨 */}
<div className="relative h-4">
{visibleLabels.map(t => {
const pos = maxTime > 0 ? (t / maxTime) * 100 : 0;
const isActive = t === currentStep;
return (
<span key={t} style={{
position: 'absolute', left: `${pos}%`, transform: 'translateX(-50%)',
fontSize: '10px', fontFamily: 'var(--fM)',
color: isActive ? 'var(--cyan)' : 'var(--t3)',
fontWeight: isActive ? 600 : 400, cursor: 'pointer', whiteSpace: 'nowrap'
}} onClick={() => setCurrentStep(t)}>{t}h</span>
)
})}
</div>
{/* 슬라이더 트랙 */}
<div className="relative h-6 flex items-center">
<div
style={{ width: '100%', height: '4px', background: 'var(--bd)', borderRadius: '2px', position: 'relative', cursor: 'pointer' }}
onClick={(e) => {
if (timeSteps.length === 0) return;
const rect = e.currentTarget.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
const targetTime = pct * maxTime;
const closest = timeSteps.reduce((a, b) =>
Math.abs(b - targetTime) < Math.abs(a - targetTime) ? b : a
);
setCurrentStep(closest);
}}
>
{/* 진행 바 */}
<div style={{
position: 'absolute', top: 0, left: 0,
width: `${progressPct}%`, height: '100%',
background: 'linear-gradient(90deg, var(--cyan), var(--blue))',
borderRadius: '2px', transition: 'width 0.15s'
}} />
{/* 스텝 마커 (각 타임스텝 위치에 틱 표시) */}
{timeSteps.map(t => {
const pos = maxTime > 0 ? (t / maxTime) * 100 : 0;
return (
<div key={`tick-${t}`} style={{
position: 'absolute', width: '2px', height: '10px',
background: t <= currentStep ? 'var(--cyan)' : 'var(--t3)',
top: '-3px', left: `${pos}%`, opacity: 0.6
}} />
);
})}
{/* 방어선 설치 이벤트 마커 */}
{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: `${progressPct}%`, 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)' }}>
+{currentStep}h {(() => {
const base = accidentTime ? new Date(accidentTime) : new Date();
const d = new Date(base.getTime() + currentStep * 3600 * 1000);
return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')} KST`;
})()}
</div>
<div style={{ display: 'flex', gap: '14px' }}>
{(() => {
const stepSummary = stepSummariesByModel[windHydrModel]?.[currentStep] ?? null;
const weatheredVal = stepSummary ? `${stepSummary.weatheredVolume.toFixed(2)}` : '—';
const areaVal = stepSummary ? `${stepSummary.pollutionArea.toFixed(1)} km²` : '—';
return [
{ label: '풍화량', value: weatheredVal },
{ label: '면적', value: areaVal },
{ label: '차단율', value: boomLines.length > 0 ? `${Math.min(95, 70 + Math.round(progressPct * 0.2))}%` : '—', color: 'var(--boom)' },
].map((s, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px' }}>
<span className="text-text-3">{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}
/>
)}
</>
)}
</div>
{/* Right Panel */}
{activeSubTab === 'analysis' && (
<RightPanel
onOpenBacktrack={handleOpenBacktrack}
onOpenRecalc={() => setRecalcModalOpen(true)}
onOpenReport={handleOpenReport}
detail={analysisDetail}
summary={stepSummariesByModel[windHydrModel]?.[currentStep] ?? summaryByModel[windHydrModel] ?? simulationSummary}
displayControls={displayControls}
onDisplayControlsChange={setDisplayControls}
windHydrModel={windHydrModel}
windHydrModelOptions={windHydrModelOptions}
onWindHydrModelChange={setWindHydrModel}
analysisTab={analysisTab}
onSwitchAnalysisTab={setAnalysisTab}
drawAnalysisMode={drawAnalysisMode}
analysisPolygonPoints={analysisPolygonPoints}
circleRadiusNm={circleRadiusNm}
onCircleRadiusChange={setCircleRadiusNm}
analysisResult={analysisResult}
incidentCoord={incidentCoord}
onStartPolygonDraw={handleStartPolygonDraw}
onRunPolygonAnalysis={handleRunPolygonAnalysis}
onRunCircleAnalysis={handleRunCircleAnalysis}
onCancelAnalysis={handleCancelAnalysis}
onClearAnalysis={handleClearAnalysis}
/>
)}
{/* 확산 예측 실행 중 로딩 오버레이 */}
{isRunningSimulation && (
<SimulationLoadingOverlay
status="RUNNING"
progress={simulationProgress}
/>
)}
{/* 확산 예측 에러 팝업 */}
{simulationError && (
<SimulationErrorModal
message={simulationError}
onClose={() => setSimulationError(null)}
/>
)}
{/* 재계산 모달 */}
<RecalcModal
isOpen={recalcModalOpen}
onClose={() => setRecalcModalOpen(false)}
oilType={oilType}
spillAmount={spillAmount}
spillType={spillType}
predictionTime={predictionTime}
incidentCoord={incidentCoord ?? { lat: 0, lon: 0 }}
selectedModels={selectedModels}
onSubmit={(params) => {
setOilType(params.oilType)
setSpillAmount(params.spillAmount)
setSpillType(params.spillType)
setPredictionTime(params.predictionTime)
setIncidentCoord(params.incidentCoord)
setSelectedModels(params.selectedModels)
handleRunSimulation({
models: params.selectedModels,
oilType: params.oilType,
spillAmount: params.spillAmount,
spillType: params.spillType,
predictionTime: params.predictionTime,
incidentCoord: params.incidentCoord,
})
}}
/>
{/* 역추적 모달 */}
<BacktrackModal
isOpen={backtrackModalOpen}
onClose={() => setBacktrackModalOpen(false)}
phase={backtrackPhase}
conditions={backtrackConditions}
vessels={backtrackVessels}
onRunAnalysis={handleRunBacktrackAnalysis}
onStartReplay={handleStartReplay}
/>
</div>
)
}