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 { useMultiSimulationStatus } from '../hooks/useSimulationStatus' import type { ModelExecRef } 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 = { 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 [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 [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, }) // 타임라인 플레이어 상태 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 [recalcModalOpen, setRecalcModalOpen] = useState(false) const [pendingExecSns, setPendingExecSns] = useState([]) const [simulationSummary, setSimulationSummary] = useState(null) const { allDone: simAllDone, anyError: simAnyError, results: simResults, errors: simErrors } = useMultiSimulationStatus(pendingExecSns) // 오염분석 상태 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 // 분석 탭 초기 진입 시 기본 데모 자동 표시 useEffect(() => { if (activeSubTab === 'analysis' && oilTrajectory.length === 0 && !selectedAnalysis) { const models = Array.from(selectedModels.size > 0 ? selectedModels : new Set(['OpenDrift'])) const coord = incidentCoord ?? { lat: 37.39, lon: 126.64 } const demoTrajectory = generateDemoTrajectory(coord, models, predictionTime) setOilTrajectory(demoTrajectory) const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings) setBoomLines(demoBooms) setSensitiveResources(DEMO_SENSITIVE_RESOURCES) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeSubTab]) const handleToggleLayer = (layerId: string, enabled: boolean) => { setEnabledLayers(prev => { const newSet = new Set(prev) if (enabled) { newSet.add(layerId) } else { newSet.delete(layerId) } return newSet }) } // 역추적: API에서 기존 결과 로딩 const loadBacktrackData = useCallback(async (acdntSn: number) => { try { const bt = await fetchBacktrackByAcdnt(acdntSn) if (bt && bt.execSttsCd === 'completed' && bt.rsltData) { const rslt = bt.rsltData as Record 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 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 (pendingExecSns.length === 0) return; if (simAllDone) { // 모든 모델의 trajectory 병합 (model 필드 포함) const merged: OilParticle[] = []; let latestSummary: SimulationSummary | null = null; let latestCenterPoints: CenterPoint[] = []; const newWindDataByModel: Record = {}; const newHydrDataByModel: Record = {}; simResults.forEach((statusData, model) => { if (statusData.trajectory) { const withModel = statusData.trajectory.map(p => ({ ...p, model })); merged.push(...withModel); } // summary는 OpenDrift 우선, 없으면 다른 모델 if (model === 'OpenDrift' || !latestSummary) { if (statusData.summary) latestSummary = statusData.summary; } // windData/hydrData는 모델별로 저장 if (statusData.windData) newWindDataByModel[model] = statusData.windData; if (statusData.hydrData) newHydrDataByModel[model] = statusData.hydrData; // centerPoints는 모든 모델 누적 (model 필드 포함 보장) if (statusData.centerPoints) { const withModel = statusData.centerPoints.map(p => ({ ...p, model })); latestCenterPoints = [...latestCenterPoints, ...withModel]; } }); if (merged.length > 0) { setOilTrajectory(merged); const doneModels = new Set( Array.from(simResults.entries()) .filter(([, s]) => s.trajectory && s.trajectory.length > 0) .map(([m]) => m as PredictionModel) ) setVisibleModels(doneModels) setSimulationSummary(latestSummary); setCenterPoints(latestCenterPoints); // 데이터가 없는 모델에 OpenDrift(또는 첫 번째 보유 모델) 데이터 복사 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); setWindHydrModel('OpenDrift'); if (incidentCoord) { const booms = generateAIBoomLines(merged, incidentCoord, algorithmSettings); setBoomLines(booms); } setSensitiveResources(DEMO_SENSITIVE_RESOURCES); setCurrentStep(0); setIsPlaying(true); if (incidentCoord) { setFlyToCoord({ lon: incidentCoord.lon, lat: incidentCoord.lat }); } } setIsRunningSimulation(false); setPendingExecSns([]); } if (simAnyError) { setIsRunningSimulation(false); setPendingExecSns([]); const errorMessages = Array.from(simErrors.values()).join('; '); setSimulationError(errorMessages || '시뮬레이션 처리 중 오류가 발생했습니다.'); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [simAllDone, simAnyError, simResults, simErrors, pendingExecSns.length, 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; // 유향유속/풍향풍속 데이터 — 선택한 모델 기준으로 파생 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) { // 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 = { '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'])) // 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 ?? []) setWindDataByModel(wd && wd.length > 0 ? { 'OpenDrift': wd } : {}) setHydrDataByModel(hd && hd.length > 0 ? { 'OpenDrift': hd } : {}) setWindHydrModel('OpenDrift') 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: '', acdntSttsCd: 'ACTIVE', }) }, []) 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 = { 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; } payload.models = Array.from(selectedModels); const { data } = await api.post('/simulation/run', payload); setPendingExecSns( data.execSns ?? (data.execSn ? [{ model: 'OpenDrift', execSn: 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 = { '벙커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, 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} 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} lightMode centerPoints={centerPoints.filter(p => visibleModels.has((p.model || 'OpenDrift') as PredictionModel))} windData={windData} hydrData={hydrData} flyToTarget={flyToTarget} fitBoundsTarget={fitBoundsTarget} onIncidentFlyEnd={handleFlyEnd} drawAnalysisMode={drawAnalysisMode} analysisPolygonPoints={analysisPolygonPoints} analysisCircleCenter={analysisCircleCenter} analysisCircleRadiusM={analysisCircleRadiusM} externalCurrentTime={oilTrajectory.length > 0 ? currentStep : undefined} backtrackReplay={isReplayActive && replayShips.length > 0 && incidentCoord ? { isActive: true, ships: replayShips, collisionEvent: collisionEvent ?? null, replayFrame, totalFrames: TOTAL_REPLAY_FRAMES, incidentCoord, } : undefined} showCurrent={displayControls.showCurrent} showWind={displayControls.showWind} showBeached={displayControls.showBeached} showTimeLabel={displayControls.showTimeLabel} simulationStartTime={accidentTime || undefined} /> {/* 타임라인 플레이어 (리플레이 비활성 시) */} {!isReplayActive && (() => { const progressPct = maxTime > 0 ? (currentStep / maxTime) * 100 : 0; // 동적 라벨: 스텝 수에 따라 균등 분배 const visibleLabels: number[] = (() => { if (timeSteps.length === 0) return [0]; if (timeSteps.length <= 8) return timeSteps; const interval = Math.ceil(timeSteps.length / 7); return timeSteps.filter((_, i) => i % interval === 0 || i === timeSteps.length - 1); })(); return (
{/* 컨트롤 버튼 */}
{[ { 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 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`; })()}
{[ { 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) => (
{s.label} {s.value}
))}
); })()} {/* 역추적 리플레이 바 */} {isReplayActive && ( setIsReplayPlaying(!isReplayPlaying)} onSeek={setReplayFrame} onSpeedChange={setReplaySpeed} onClose={handleCloseReplay} replayShips={replayShips} collisionEvent={collisionEvent} /> )} )}
{/* Right Panel */} {activeSubTab === 'analysis' && ( setRecalcModalOpen(true)} onOpenReport={handleOpenReport} detail={analysisDetail} summary={simulationSummary} displayControls={displayControls} onDisplayControlsChange={setDisplayControls} windHydrModel={windHydrModel} windHydrModelOptions={windHydrModelOptions} onWindHydrModelChange={setWindHydrModel} analysisTab={analysisTab} onSwitchAnalysisTab={setAnalysisTab} drawAnalysisMode={drawAnalysisMode} analysisPolygonPoints={analysisPolygonPoints} circleRadiusNm={circleRadiusNm} onCircleRadiusChange={setCircleRadiusNm} analysisResult={analysisResult} incidentCoord={incidentCoord} onStartPolygonDraw={handleStartPolygonDraw} onRunPolygonAnalysis={handleRunPolygonAnalysis} onRunCircleAnalysis={handleRunCircleAnalysis} onCancelAnalysis={handleCancelAnalysis} onClearAnalysis={handleClearAnalysis} /> )} {/* 확산 예측 실행 중 로딩 오버레이 */} {isRunningSimulation && ( )} {/* 확산 예측 에러 팝업 */} {simulationError && ( setSimulationError(null)} /> )} {/* 재계산 모달 */} 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() }} /> {/* 역추적 모달 */} setBacktrackModalOpen(false)} phase={backtrackPhase} conditions={backtrackConditions} vessels={backtrackVessels} onRunAnalysis={handleRunBacktrackAnalysis} onStartReplay={handleStartReplay} />
) }