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 { 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 = { 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>(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([]) 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 [selectedModels, setSelectedModels] = useState>(new Set(['OpenDrift'])) const [visibleModels, setVisibleModels] = useState>(new Set(['OpenDrift'])) const [predictionTime, setPredictionTime] = useState(48) 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([]) const [sensitiveResourceGeojson, setSensitiveResourceGeojson] = useState(null) // 오일펜스 배치 상태 const [boomLines, setBoomLines] = useState([]) 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 [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>({}) // 펜스차단량 계산 (오일붐 차단 효율 × 총 유류량) 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) || 48) // 모델 상태에 따라 선택 모델 설정 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) || 48) 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(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; 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 = { 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('/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 = {}; 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, effectiveCoord, algorithmSettings); setBoomLines(booms); setSensitiveResources([]); setCurrentStep(0); setIsPlaying(true); setFlyToCoord({ lon: effectiveCoord.lon, lat: effectiveCoord.lat }); } if (errors.length > 0 && merged.length === 0) { setSimulationError(errors.join('; ')); } else { simulationSucceeded = true; const effectiveAcdntSn = data.acdntSn ?? selectedAnalysis?.acdntSn; if (effectiveCoord) { fetchWeatherSnapshotForCoord(effectiveCoord.lat, effectiveCoord.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' && ( 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} sensitiveResources={sensitiveResourceCategories} onImageAnalysisResult={handleImageAnalysisResult} /> )} {/* Center - Map/Content Area */}
{activeSubTab === 'list' ? ( ) : activeSubTab === 'theory' ? ( ) : activeSubTab === 'boom-theory' ? ( ) : ( <> visibleModels.has((p.model || 'OpenDrift') as PredictionModel))} selectedModels={selectedModels} boomLines={boomLines} isDrawingBoom={isDrawingBoom} drawingPoints={drawingPoints} layerOpacity={layerOpacity} layerBrightness={layerBrightness} sensitiveResources={sensitiveResources} sensitiveResourceGeojson={displayControls.showSensitiveResources ? sensitiveResourceGeojson : null} 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, backwardParticles, } : 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 (
{/* 컨트롤 버튼 */}
{[ { 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(--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} />
) }