import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useVesselSignals } from '@common/hooks/useVesselSignals'; import type { MapBounds } from '@common/types/vessel'; 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 { fetchWeatherSnapshotForCoord } from '@tabs/weather/services/weatherUtils'; import { useWeatherSnapshotStore } from '@common/store/weatherSnapshotStore'; import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord, } from '@common/types/boomLine'; import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, BacktrackInputConditions, ReplayShip, CollisionEvent, BackwardParticleStep, } from '@common/types/backtrack'; import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'; import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory, fetchSensitiveResources, fetchSensitiveResourcesGeojson, } from '../services/predictionApi'; import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, RunModelSyncResponse, SimulationSummary, SensitiveResourceCategory, SensitiveResourceFeatureCollection, 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'; const toLocalDateTimeStr = (raw: string): string => { const d = new Date(raw); if (isNaN(d.getTime())) return ''; const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; }; // --------------------------------------------------------------------------- // 민감자원 타입 + 데모 데이터 // --------------------------------------------------------------------------- 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; // 시간 표시 showSensitiveResources: boolean; // 민감자원 } // --------------------------------------------------------------------------- // 데모 궤적 생성 (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 [leftCollapsed, setLeftCollapsed] = useState(false); const [rightCollapsed, setRightCollapsed] = useState(false); const [enabledLayers, setEnabledLayers] = useState>(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 [mapBounds, setMapBounds] = useState(null); const vessels = useVesselSignals(mapBounds); const [isSelectingLocation, setIsSelectingLocation] = useState(false); const [oilTrajectory, setOilTrajectory] = useState([]); const [centerPoints, setCenterPoints] = useState([]); const [windDataByModel, setWindDataByModel] = useState>({}); const [hydrDataByModel, setHydrDataByModel] = useState>( {}, ); const [windHydrModel, setWindHydrModel] = useState('OpenDrift'); const [isRunningSimulation, setIsRunningSimulation] = useState(false); const [simulationProgress, setSimulationProgress] = useState(0); const progressTimerRef = useRef | null>(null); const [simulationError, setSimulationError] = useState(null); const [validationErrors, setValidationErrors] = useState>(new Set()); const [selectedModels, setSelectedModels] = useState>( new Set(['OpenDrift']), ); const [visibleModels, setVisibleModels] = useState>(new Set(['OpenDrift'])); const [predictionTime, setPredictionTime] = useState(6); const [accidentTime, setAccidentTime] = useState(''); 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([]); const [sensitiveResourceCategories, setSensitiveResourceCategories] = useState< SensitiveResourceCategory[] >([]); const [sensitiveResourceGeojson, setSensitiveResourceGeojson] = useState(null); // 오일펜스 배치 상태 const [boomLines, setBoomLines] = useState([]); const [showBoomLines, setShowBoomLines] = useState(true); const [algorithmSettings, setAlgorithmSettings] = useState({ currentOrthogonalCorrection: 15, safetyMarginMinutes: 60, minContainmentEfficiency: 80, waveHeightCorrectionFactor: 1.0, }); const [isDrawingBoom, setIsDrawingBoom] = useState(false); const [drawingPoints, setDrawingPoints] = useState([]); const [containmentResult, setContainmentResult] = useState(null); // 레이어 스타일 (투명도 / 밝기 / 색상) const [layerOpacity, setLayerOpacity] = useState(50); const [layerBrightness, setLayerBrightness] = useState(50); const [layerColors, setLayerColors] = useState>({}); // 표시 정보 제어 const [displayControls, setDisplayControls] = useState({ showCurrent: true, showWind: false, showBeached: false, showTimeLabel: false, showSensitiveResources: 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('conditions'); const [backtrackVessels, setBacktrackVessels] = useState([]); const [isReplayActive, setIsReplayActive] = useState(false); const [isReplayPlaying, setIsReplayPlaying] = useState(false); const [replayFrame, setReplayFrame] = useState(0); const [replaySpeed, setReplaySpeed] = useState(1); // 선택된 분석 (목록에서 클릭 시) const [selectedAnalysis, setSelectedAnalysis] = useState(null); // 분석 상세 (API에서 가져온 선박/기상 정보) const [analysisDetail, setAnalysisDetail] = useState(null); // 역추적 API 데이터 const [backtrackConditions, setBacktrackConditions] = useState({ estimatedSpillTime: '', analysisRange: '±12시간', searchRadius: '10 NM', spillLocation: { lat: 37.3883, lon: 126.6435 }, totalVessels: 0, }); const [replayShips, setReplayShips] = useState([]); const [collisionEvent, setCollisionEvent] = useState(null); const [replayTimeRange, setReplayTimeRange] = useState<{ start: string; end: string } | null>( null, ); const [backwardParticles, setBackwardParticles] = useState([]); // 재계산 상태 const [recalcModalOpen, setRecalcModalOpen] = useState(false); const [simulationSummary, setSimulationSummary] = useState(null); const [summaryByModel, setSummaryByModel] = useState>({}); const [stepSummariesByModel, setStepSummariesByModel] = useState< Record >({}); // 펜스차단량 계산 (오일붐 차단 효율 × 총 유류량) const boomBlockedVolume = useMemo(() => { if (!containmentResult || !simulationSummary) return 0; const totalVolumeM3 = simulationSummary.remainingVolume + simulationSummary.weatheredVolume + simulationSummary.beachedVolume; return totalVolumeM3 * (containmentResult.overallEfficiency / 100); }, [containmentResult, 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(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; 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); } if (rslt['timeRange']) { setReplayTimeRange(rslt['timeRange'] as { start: string; end: string }); } setBackwardParticles( Array.isArray(rslt['backwardParticles']) ? (rslt['backwardParticles'] as BackwardParticleStep[]) : [], ); 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, estimatedSpillTime: selectedAnalysis?.occurredAt ? toLocalDateTimeStr(selectedAnalysis.occurredAt) : prev.estimatedSpillTime, })); if (selectedAnalysis) { loadBacktrackData(selectedAnalysis.acdntSn); } else { setBacktrackPhase('conditions'); setBacktrackVessels([]); } }; const handleRunBacktrackAnalysis = async (input: BacktrackInputConditions) => { if (!incidentCoord || !selectedAnalysis) return; setBacktrackPhase('analyzing'); try { const anlysRangeStr = `±${input.analysisRange}시간`; const bt = await createBacktrack({ acdntSn: selectedAnalysis.acdntSn, lon: incidentCoord.lon, lat: incidentCoord.lat, estSpilDtm: input.estimatedSpillTime ? new Date(input.estimatedSpillTime).toISOString() : undefined, anlysRange: anlysRangeStr, srchRadiusNm: input.searchRadius, }); if (bt.execSttsCd === 'COMPLETED' && bt.rsltData) { const rslt = bt.rsltData as Record; 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); if (rslt['timeRange']) setReplayTimeRange(rslt['timeRange'] as { start: string; end: string }); setBackwardParticles( Array.isArray(rslt['backwardParticles']) ? (rslt['backwardParticles'] as BackwardParticleStep[]) : [], ); setBacktrackConditions((prev) => ({ ...prev, totalVessels: bt.totalVessels || 0 })); setBacktrackPhase('results'); } else { console.error('[prediction] 역추적 분석 실패:', bt.execSttsCd); 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(toLocalDateTimeStr(analysis.occurredAt)); } if (analysis.lon != null && analysis.lat != null) { setIncidentCoord({ lon: analysis.lon, lat: analysis.lat }); setFlyToCoord({ lon: analysis.lon, lat: analysis.lat }); } // 유종 매핑 const oilTypeMap: Record = { BUNKER_C: '벙커C유', DIESEL: '경유', CRUDE_OIL: '원유', LUBE_OIL: '윤활유', }; setOilType(oilTypeMap[analysis.oilType] || '벙커C유'); setSpillAmount(analysis.volume ?? 100); setPredictionTime(parseInt(analysis.duration) || 6); // 모델 상태에 따라 선택 모델 설정 const models = new Set(); 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(['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, analysis.predRunSn ?? undefined); 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([]); fetchSensitiveResources(analysis.acdntSn) .then(setSensitiveResourceCategories) .catch((err) => console.warn('[prediction] 민감자원 조회 실패:', err)); fetchSensitiveResourcesGeojson(analysis.acdntSn) .then(setSensitiveResourceGeojson) .catch((err) => console.warn('[prediction] 민감자원 GeoJSON 조회 실패:', err)); // 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) || 6, ); setOilTrajectory(demoTrajectory); if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings)); setSensitiveResources([]); setSensitiveResourceCategories([]); // 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 if (isSelectingLocation) { setIncidentCoord({ lon, lat }); setIsSelectingLocation(false); } }; const handleStartPolygonDraw = () => { setDrawAnalysisMode('polygon'); setAnalysisPolygonPoints([]); setAnalysisResult(null); }; const handleRunPolygonAnalysis = async () => { 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 = async () => { 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(toLocalDateTimeStr(result.occurredAt)); setOilType(result.oilType); setSpillAmount(parseFloat(result.volume.toFixed(20))); setSpillUnit('kL'); setSelectedAnalysis({ acdntSn: result.acdntSn, acdntNm: '', occurredAt: result.occurredAt, analysisDate: '', requestor: '', duration: '6', 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; 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; const effectiveModels = overrides?.models ?? selectedModels; // ── 입력 유효성 검증 (border 하이라이트) ── const errors = new Set(); if (isDirectInput) { if (!incidentName.trim()) errors.add('incidentName'); if (!accidentTime) errors.add('accidentTime'); if (!effectiveCoord || (effectiveCoord.lat === 0 && effectiveCoord.lon === 0)) errors.add('coord'); } else if (!existingAcdntSn) { errors.add('incidentName'); } if (!effectiveCoord) errors.add('coord'); if (effectiveModels.size === 0) errors.add('models'); if (errors.size > 0) { setValidationErrors(errors); return; } setValidationErrors(new Set()); const coord = effectiveCoord!; // 검증 통과 후 non-null 보장 const effectiveOilType = overrides?.oilType ?? oilType; const effectiveSpillAmount = overrides?.spillAmount ?? spillAmount; const effectiveSpillType = overrides?.spillType ?? spillType; const effectivePredictionTime = overrides?.predictionTime ?? predictionTime; setIsRunningSimulation(true); setSimulationSummary(null); startProgressTimer(effectivePredictionTime); let simulationSucceeded = false; try { const payload: Record = { acdntSn: existingAcdntSn, lat: coord.lat, lon: coord.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('/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: coord.lat, lon: coord.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 = {}; const newHydrDataByModel: Record = {}; const newSummaryByModel: Record = {}; const newStepSummariesByModel: Record = {}; const errors: string[] = []; data.results.forEach( ({ model, status, trajectory, summary, stepSummaries, 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 (stepSummaries) newStepSummariesByModel[model] = stepSummaries; 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( 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); setStepSummariesByModel(newStepSummariesByModel); const booms = generateAIBoomLines(merged, coord, algorithmSettings); setBoomLines(booms); setSensitiveResources([]); setCurrentStep(0); setIsPlaying(true); setFlyToCoord({ lon: coord.lon, lat: coord.lat }); } if (errors.length > 0 && merged.length === 0) { setSimulationError(errors.join('; ')); } else { simulationSucceeded = true; const effectiveAcdntSn = data.acdntSn ?? selectedAnalysis?.acdntSn; if (coord) { fetchWeatherSnapshotForCoord(coord.lat, coord.lon) .then((snapshot) => { useWeatherSnapshotStore.getState().setSnapshot(snapshot); if (effectiveAcdntSn) { api .post(`/incidents/${effectiveAcdntSn}/weather`, snapshot) .catch((err) => console.warn('[weather] 기상 저장 실패:', err)); } }) .catch((err) => console.warn('[weather] 기상 데이터 수집 실패:', err)); } if (effectiveAcdntSn) { fetchSensitiveResources(effectiveAcdntSn) .then(setSensitiveResourceCategories) .catch((err) => console.warn('[prediction] 민감자원 조회 실패:', err)); fetchSensitiveResourcesGeojson(effectiveAcdntSn) .then(setSensitiveResourceGeojson) .catch((err) => console.warn('[prediction] 민감자원 GeoJSON 조회 실패:', err)); } } } catch (err) { const msg = (err as { message?: string })?.message ?? '시뮬레이션 실행 중 오류가 발생했습니다.'; setSimulationError(msg); } finally { stopProgressTimer(simulationSucceeded); setIsRunningSimulation(false); } }; const handleOpenReport = () => { const OIL_TYPE_CODE: Record = { 벙커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 weatherSnapshot = useWeatherSnapshotStore.getState().snapshot; 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)} 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: (() => { if (weatherSnapshot) { return { windDir: `${weatherSnapshot.wind.directionLabel} ${weatherSnapshot.wind.direction}°`, windSpeed: `${weatherSnapshot.wind.speed.toFixed(1)} m/s`, waveHeight: `${weatherSnapshot.wave.height.toFixed(1)} m`, temp: `${weatherSnapshot.temperature.current.toFixed(1)} °C`, pressure: `${weatherSnapshot.pressure} hPa`, visibility: `${weatherSnapshot.visibility} km`, salinity: `${weatherSnapshot.salinity} PSU`, waveMaxHeight: `${weatherSnapshot.wave.maxHeight.toFixed(1)} m`, wavePeriod: `${weatherSnapshot.wave.period} s`, currentDir: '', currentSpeed: '', }; } if (wx) { return { windDir: wx.wind, windSpeed: wx.wind, waveHeight: wx.wave, temp: wx.temp }; } return 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, sensitiveResources: sensitiveResourceCategories.length > 0 ? sensitiveResourceCategories.map((r) => ({ category: r.category, count: r.count, totalArea: r.totalArea, })) : undefined, acdntSn: selectedAnalysis?.acdntSn ?? undefined, mapData: incidentCoord ? { center: [incidentCoord.lat, incidentCoord.lon], zoom: 10, trajectory: oilTrajectory, currentStep, centerPoints, simulationStartTime: accidentTime, } : null, }; setOilReportPayload(payload); setReportGenCategory(0); navigateToTab('reports', 'generate'); }; return (
{/* Left Sidebar */} {activeSubTab === 'analysis' && (
{ setAccidentTime(v); setValidationErrors((prev) => { const n = new Set(prev); n.delete('accidentTime'); return n; }); }} incidentCoord={incidentCoord} onCoordChange={(v) => { setIncidentCoord(v); setValidationErrors((prev) => { const n = new Set(prev); n.delete('coord'); return n; }); }} isSelectingLocation={isSelectingLocation} onMapSelectClick={() => setIsSelectingLocation((prev) => !prev)} onRunSimulation={handleRunSimulation} isRunningSimulation={isRunningSimulation} selectedModels={selectedModels} onModelsChange={(v) => { setSelectedModels(v); setValidationErrors((prev) => { const n = new Set(prev); n.delete('models'); return n; }); }} 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={(v) => { setIncidentName(v); setValidationErrors((prev) => { const n = new Set(prev); n.delete('incidentName'); return n; }); }} spillUnit={spillUnit} onSpillUnitChange={setSpillUnit} boomLines={boomLines} onBoomLinesChange={setBoomLines} showBoomLines={showBoomLines} onShowBoomLinesChange={setShowBoomLines} 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} layerColors={layerColors} onLayerColorChange={(id, color) => setLayerColors((prev) => ({ ...prev, [id]: color }))} sensitiveResources={sensitiveResourceCategories} onImageAnalysisResult={handleImageAnalysisResult} onFlyToCoord={(c: { lon: number; lat: number }) => setFlyToCoord({ lat: c.lat, lon: c.lon }) } validationErrors={validationErrors} />
)} {/* Center - Map/Content Area */}
{/* Left panel toggle button */} {activeSubTab === 'analysis' && ( )} {/* Right panel toggle button */} {activeSubTab === 'analysis' && ( )} {activeSubTab === 'list' ? ( ) : activeSubTab === 'theory' ? ( ) : activeSubTab === 'boom-theory' ? ( ) : ( <> visibleModels.has((p.model || 'OpenDrift') as PredictionModel), )} selectedModels={selectedModels} boomLines={boomLines} showBoomLines={showBoomLines} isDrawingBoom={isDrawingBoom} drawingPoints={drawingPoints} layerOpacity={layerOpacity} layerBrightness={layerBrightness} layerColors={layerColors} sensitiveResources={sensitiveResources} sensitiveResourceGeojson={ displayControls.showSensitiveResources ? sensitiveResourceGeojson : null } 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, backwardParticles, } : undefined } showCurrent={displayControls.showCurrent} showWind={displayControls.showWind} showBeached={displayControls.showBeached} showTimeLabel={displayControls.showTimeLabel} simulationStartTime={accidentTime || undefined} vessels={vessels} onBoundsChange={setMapBounds} /> {/* 타임라인 플레이어 (리플레이 비활성 시) */} {!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 (
{/* 컨트롤 버튼 */}
{[ { 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) => ( ))} {[ { 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) => ( ))}
{/* 타임라인 슬라이더 */}
{/* 동적 시간 라벨 */}
{visibleLabels.map((t) => { const pos = maxTime > 0 ? (t / maxTime) * 100 : 0; const isActive = t === currentStep; return ( setCurrentStep(t)} > {t}h ); })}
{/* 슬라이더 트랙 */}
{ 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); }} > {/* 진행 바 */}
{/* 스텝 마커 (각 타임스텝 위치에 틱 표시) */} {timeSteps.map((t) => { const pos = maxTime > 0 ? (t / maxTime) * 100 : 0; return (
); })} {/* 방어선 설치 이벤트 마커 */} {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) => (
🛡
))}
{/* 드래그 핸들 */}
{/* 시간 정보 */}
+{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`; })()}
{(() => { const stepSummary = stepSummariesByModel[windHydrModel]?.[currentStep] ?? null; const weatheredVal = stepSummary ? `${stepSummary.weatheredVolume.toFixed(2)} m³` : '—'; 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(--color-boom)', }, ].map((s, i) => (
{s.label} {s.value}
)); })()}
); })()} {/* 역추적 리플레이 바 */} {isReplayActive && ( setIsReplayPlaying(!isReplayPlaying)} onSeek={setReplayFrame} onSpeedChange={setReplaySpeed} onClose={handleCloseReplay} replayShips={replayShips} collisionEvent={collisionEvent} replayTimeRange={replayTimeRange ?? undefined} hasBackwardParticles={backwardParticles.length > 0} /> )} )}
{/* Right Panel */} {activeSubTab === 'analysis' && (
{ if (!selectedAnalysis) { alert('선택된 사고가 없습니다.\n분석 목록에서 사고를 선택해주세요.'); return; } setRecalcModalOpen(true); }} onOpenReport={handleOpenReport} detail={analysisDetail} summary={ stepSummariesByModel[windHydrModel]?.[currentStep] ?? summaryByModel[windHydrModel] ?? simulationSummary } boomBlockedVolume={boomBlockedVolume} 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} centerPoints={centerPoints} predictionTime={predictionTime} onStartPolygonDraw={handleStartPolygonDraw} onRunPolygonAnalysis={handleRunPolygonAnalysis} onRunCircleAnalysis={handleRunCircleAnalysis} onCancelAnalysis={handleCancelAnalysis} onClearAnalysis={handleClearAnalysis} />
)} {/* 확산 예측 실행 중 로딩 오버레이 */} {isRunningSimulation && ( )} {/* 확산 예측 에러 팝업 */} {simulationError && ( setSimulationError(null)} /> )} {/* 재계산 모달 */} setRecalcModalOpen(false)} incidentName={selectedAnalysis?.acdntNm || incidentName} 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, }); }} /> {/* 역추적 모달 */} setBacktrackModalOpen(false)} phase={backtrackPhase} conditions={backtrackConditions} vessels={backtrackVessels} onRunAnalysis={handleRunBacktrackAnalysis} onStartReplay={handleStartReplay} />
); }