# Conflicts: # docs/RELEASE-NOTES.md # frontend/src/common/components/map/MapView.tsx # frontend/src/tabs/prediction/components/OilSpillView.tsx # frontend/src/tabs/prediction/components/RightPanel.tsx
1101 lines
47 KiB
TypeScript
Executable File
1101 lines
47 KiB
TypeScript
Executable File
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, 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, 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 [windData, setWindData] = useState<WindPoint[][]>([])
|
||
const [hydrData, setHydrData] = useState<(HydrDataStep | null)[]>([])
|
||
const [isRunningSimulation, setIsRunningSimulation] = useState(false)
|
||
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('연속')
|
||
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: true,
|
||
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 [currentExecSn, setCurrentExecSn] = useState<number | null>(null)
|
||
const [simulationSummary, setSimulationSummary] = useState<SimulationSummary | null>(null)
|
||
const { data: simStatus } = useSimulationStatus(currentExecSn)
|
||
|
||
// 오염분석 상태
|
||
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
|
||
|
||
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)
|
||
}
|
||
}, [])
|
||
|
||
// 시뮬레이션 폴링 결과 처리
|
||
useEffect(() => {
|
||
if (!simStatus) return;
|
||
if (simStatus.status === 'DONE' && simStatus.trajectory) {
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
setOilTrajectory(simStatus.trajectory);
|
||
setSimulationSummary(simStatus.summary ?? null);
|
||
setCenterPoints(simStatus.centerPoints ?? []);
|
||
setWindData(simStatus.windData ?? []);
|
||
setHydrData(simStatus.hydrData ?? []);
|
||
setIsRunningSimulation(false);
|
||
setCurrentExecSn(null);
|
||
// AI 방어선 자동 생성
|
||
if (incidentCoord) {
|
||
const booms = generateAIBoomLines(simStatus.trajectory, incidentCoord, algorithmSettings);
|
||
setBoomLines(booms);
|
||
}
|
||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES);
|
||
// 새 시뮬레이션 완료 시 flyTo 없으므로 즉시 재생
|
||
setCurrentStep(0);
|
||
setIsPlaying(true);
|
||
}
|
||
if (simStatus.status === 'ERROR') {
|
||
setIsRunningSimulation(false);
|
||
setCurrentExecSn(null);
|
||
setSimulationError(simStatus.error ?? '시뮬레이션 처리 중 오류가 발생했습니다.');
|
||
}
|
||
}, [simStatus, incidentCoord, algorithmSettings]);
|
||
|
||
// trajectory 변경 시 플레이어 스텝 초기화 (재생은 각 경로에서 별도 처리)
|
||
useEffect(() => {
|
||
if (oilTrajectory.length > 0) {
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
setCurrentStep(0);
|
||
}
|
||
}, [oilTrajectory.length]);
|
||
|
||
// 플레이어 재생 애니메이션 (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;
|
||
|
||
useEffect(() => {
|
||
if (!isPlaying || timeSteps.length === 0) return;
|
||
if (currentStep >= maxTime) {
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
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)
|
||
// 분석 상세 로딩 (선박/기상 정보)
|
||
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']))
|
||
|
||
// OpenDrift 완료된 경우 실제 궤적 로드, 없으면 데모로 fallback
|
||
if (analysis.opendriftStatus === 'completed') {
|
||
try {
|
||
const { trajectory, summary, centerPoints: cp, windData: wd, hydrData: hd } = await fetchAnalysisTrajectory(analysis.acdntSn)
|
||
if (trajectory && trajectory.length > 0) {
|
||
setOilTrajectory(trajectory)
|
||
if (summary) setSimulationSummary(summary)
|
||
setCenterPoints(cp ?? [])
|
||
setWindData(wd ?? [])
|
||
setHydrData(hd ?? [])
|
||
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)
|
||
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: '',
|
||
})
|
||
}, [])
|
||
|
||
const handleRunSimulation = async () => {
|
||
// incidentName이 있으면 직접 입력 모드 — 기존 selectedAnalysis.acdntSn 무시하고 새 사고 생성
|
||
const isDirectInput = incidentName.trim().length > 0;
|
||
const existingAcdntSn = isDirectInput
|
||
? undefined
|
||
: (selectedAnalysis?.acdntSn ?? analysisDetail?.acdnt?.acdntSn);
|
||
|
||
// 선택 모드인데 사고도 없으면 실행 불가, 직접 입력 모드인데 사고명 없으면 실행 불가
|
||
if (!isDirectInput && !existingAcdntSn) {
|
||
return;
|
||
}
|
||
if (!incidentCoord) {
|
||
return;
|
||
}
|
||
|
||
setIsRunningSimulation(true);
|
||
setSimulationSummary(null);
|
||
try {
|
||
const payload: Record<string, unknown> = {
|
||
acdntSn: existingAcdntSn,
|
||
lat: incidentCoord.lat,
|
||
lon: incidentCoord.lon,
|
||
runTime: predictionTime,
|
||
matTy: oilType,
|
||
matVol: spillAmount,
|
||
spillTime: spillType === '연속' ? predictionTime : 0,
|
||
startTime: accidentTime
|
||
? `${accidentTime}:00`
|
||
: analysisDetail?.acdnt?.occurredAt,
|
||
};
|
||
|
||
// 직접 입력 모드: 백엔드에서 ACDNT + SPIL_DATA 생성에 필요한 필드 추가
|
||
if (isDirectInput) {
|
||
payload.acdntNm = incidentName.trim();
|
||
payload.spillUnit = spillUnit;
|
||
payload.spillTypeCd = spillType;
|
||
}
|
||
|
||
const { data } = await api.post<SimulationRunResponse>('/simulation/run', payload);
|
||
setCurrentExecSn(data.execSn);
|
||
|
||
// 직접 입력으로 신규 생성된 경우: 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: incidentCoord.lat,
|
||
lon: incidentCoord.lon,
|
||
kospsStatus: 'pending',
|
||
poseidonStatus: 'pending',
|
||
opendriftStatus: 'pending',
|
||
backtrackStatus: 'pending',
|
||
analyst: '',
|
||
officeName: '',
|
||
} as Analysis);
|
||
// 다음 실행 시 동일 사고 재생성 방지 — 이후에는 selectedAnalysis.acdntSn 사용
|
||
setIncidentName('');
|
||
}
|
||
// setIsRunningSimulation(false)는 폴링 결과 useEffect에서 처리
|
||
} catch (err) {
|
||
setIsRunningSimulation(false);
|
||
const msg =
|
||
(err as { message?: string })?.message
|
||
?? '시뮬레이션 실행 중 오류가 발생했습니다.';
|
||
setSimulationError(msg);
|
||
}
|
||
}
|
||
|
||
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 || '',
|
||
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)} m³` : '—',
|
||
seaRemain: simulationSummary ? `${simulationSummary.remainingVolume.toFixed(2)} m³` : '—',
|
||
pollutionArea: simulationSummary ? `${simulationSummary.pollutionArea.toFixed(2)} km²` : '—',
|
||
coastAttach: simulationSummary ? `${simulationSummary.beachedVolume.toFixed(2)} m³` : '—',
|
||
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: { kosps: '—', openDrift: '—', 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')}`;
|
||
})(),
|
||
},
|
||
hasSimulation: simulationSummary !== 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}
|
||
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}
|
||
selectedModels={selectedModels}
|
||
boomLines={boomLines}
|
||
isDrawingBoom={isDrawingBoom}
|
||
drawingPoints={drawingPoints}
|
||
layerOpacity={layerOpacity}
|
||
layerBrightness={layerBrightness}
|
||
sensitiveResources={sensitiveResources}
|
||
centerPoints={centerPoints}
|
||
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 d = new Date(); d.setHours(d.getHours() + currentStep);
|
||
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(progressPct * 0.4))}%` },
|
||
{ label: '면적', value: `${(progressPct * 0.08).toFixed(1)} km²` },
|
||
{ 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={simulationSummary}
|
||
displayControls={displayControls}
|
||
onDisplayControlsChange={setDisplayControls}
|
||
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={simStatus?.status === 'RUNNING' ? 'RUNNING' : 'PENDING'}
|
||
progress={simStatus?.progress}
|
||
/>
|
||
)}
|
||
|
||
{/* 확산 예측 에러 팝업 */}
|
||
{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()
|
||
}}
|
||
/>
|
||
|
||
{/* 역추적 모달 */}
|
||
<BacktrackModal
|
||
isOpen={backtrackModalOpen}
|
||
onClose={() => setBacktrackModalOpen(false)}
|
||
phase={backtrackPhase}
|
||
conditions={backtrackConditions}
|
||
vessels={backtrackVessels}
|
||
onRunAnalysis={handleRunBacktrackAnalysis}
|
||
onStartReplay={handleStartReplay}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|