import { useState, useEffect, useCallback } 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 } 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 } from '../services/predictionApi' import type { PredictionDetail } from '../services/predictionApi' import { api } from '@common/services/api' import { generateAIBoomLines } from '@common/utils/geo' 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 } const DEMO_SENSITIVE_RESOURCES: SensitiveResource[] = [ { id: 'aq-1', name: '여수 돌산 양식장', type: 'aquaculture', lat: 34.755, lon: 127.735, radiusM: 800, arrivalTimeH: 3 }, { id: 'bc-1', name: '만성리 해수욕장', type: 'beach', lat: 34.765, lon: 127.765, radiusM: 400, arrivalTimeH: 6 }, { id: 'ec-1', name: '오동도 생태보호구역', type: 'ecology', lat: 34.745, lon: 127.78, radiusM: 600, arrivalTimeH: 12 }, { id: 'aq-2', name: '금오도 전복 양식장', type: 'aquaculture', lat: 34.70, lon: 127.75, radiusM: 700, arrivalTimeH: 8 }, { id: 'bc-2', name: '방죽포 해수욕장', type: 'beach', lat: 34.72, lon: 127.81, radiusM: 350, arrivalTimeH: 10 }, ] // --------------------------------------------------------------------------- // 데모 궤적 생성 (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: 42, speed: 0.003, spread: 0.008, seed: 42 }, POSEIDON: { bearing: 55, speed: 0.0025, spread: 0.01, seed: 137 }, OpenDrift: { bearing: 35, 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: 127.6845, lat: 34.7312 }) const [isSelectingLocation, setIsSelectingLocation] = useState(false) const [oilTrajectory, setOilTrajectory] = useState>([]) const [isRunningSimulation, setIsRunningSimulation] = useState(false) const [selectedModels, setSelectedModels] = useState>(new Set(['KOSPS'])) const [predictionTime, setPredictionTime] = useState(48) const [spillType, setSpillType] = useState('연속') const [oilType, setOilType] = useState('벙커C유') const [spillAmount, setSpillAmount] = useState(100) // 민감자원 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 [isPlaying, setIsPlaying] = useState(false) const [timelinePosition, setTimelinePosition] = useState(25) // 0~100% 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: 34.7312, lon: 127.6845 }, totalVessels: 0, }) const [replayShips, setReplayShips] = useState([]) const [collisionEvent, setCollisionEvent] = useState(null) // 재계산 상태 const [recalcModalOpen, setRecalcModalOpen] = useState(false) 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, lon: bt.lon || incidentCoord.lon }, 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, })) if (selectedAnalysis) { loadBacktrackData(selectedAnalysis.acdntSn) } else { setBacktrackPhase('conditions') setBacktrackVessels([]) } } const handleRunBacktrackAnalysis = async () => { 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 if (replayFrame >= TOTAL_REPLAY_FRAMES) { setIsReplayPlaying(false) 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, replayFrame, replaySpeed]) // 분석 목록에서 사고명 클릭 시 const handleSelectAnalysis = async (analysis: Analysis) => { setSelectedAnalysis(analysis) if (analysis.lon != null && analysis.lat != null) { setIncidentCoord({ 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) // 분석 상세 로딩 (선박/기상 정보) try { const detail = await fetchPredictionDetail(analysis.acdntSn) setAnalysisDetail(detail) } catch (err) { console.error('[prediction] 분석 상세 로딩 실패:', err) } // 분석 화면으로 전환 setActiveSubTab('analysis') } const handleMapClick = (lon: number, lat: number) => { if (isDrawingBoom) { setDrawingPoints(prev => [...prev, { lat, lon }]) } else { setIncidentCoord({ lon, lat }) setIsSelectingLocation(false) } } const handleRunSimulation = async () => { if (selectedModels.size === 0) return setIsRunningSimulation(true) try { const models = Array.from(selectedModels) const results = await Promise.all( models.map(async (model) => { const { data } = await api.post<{ trajectory: Array<{ lat: number; lon: number; time: number; particle?: number }> }>('/simulation/run', { model, lat: incidentCoord.lat, lon: incidentCoord.lon, duration_hours: predictionTime, oil_type: oilType, spill_amount: spillAmount, spill_type: spillType, }) return data.trajectory.map(p => ({ ...p, model })) }) ) setOilTrajectory(results.flat()) } catch { // 백엔드 미구현 — 클라이언트 데모 궤적 fallback console.info('[prediction] 서버 시뮬레이션 미구현, 데모 궤적 생성') const models = Array.from(selectedModels) const demoTrajectory = generateDemoTrajectory(incidentCoord, models, predictionTime) setOilTrajectory(demoTrajectory) // AI 방어선 자동 생성 const demoBooms = generateAIBoomLines(demoTrajectory, incidentCoord, algorithmSettings) setBoomLines(demoBooms) // 민감자원 로드 setSensitiveResources(DEMO_SENSITIVE_RESOURCES) } finally { setIsRunningSimulation(false) } } return (
{/* Left Sidebar */} {activeSubTab === 'analysis' && ( setIsSelectingLocation(true)} onRunSimulation={handleRunSimulation} isRunningSimulation={isRunningSimulation} selectedModels={selectedModels} onModelsChange={setSelectedModels} predictionTime={predictionTime} onPredictionTimeChange={setPredictionTime} spillType={spillType} onSpillTypeChange={setSpillType} oilType={oilType} onOilTypeChange={setOilType} spillAmount={spillAmount} onSpillAmountChange={setSpillAmount} 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} /> )} {/* Center - Map/Content Area */}
{activeSubTab === 'list' ? ( ) : activeSubTab === 'theory' ? ( ) : activeSubTab === 'boom-theory' ? ( ) : ( <> 0 ? { isActive: true, ships: replayShips, collisionEvent: collisionEvent || undefined, replayFrame, totalFrames: TOTAL_REPLAY_FRAMES, incidentCoord, } : undefined} /> {/* 타임라인 플레이어 (리플레이 비활성 시) */} {!isReplayActive &&
{/* 컨트롤 버튼 */}
{[ { icon: '⏮', action: () => setTimelinePosition(0) }, { icon: '◀', action: () => setTimelinePosition(Math.max(0, timelinePosition - 100 / 12)) }, ].map((btn, i) => ( ))} {[ { icon: '▶▶', action: () => setTimelinePosition(Math.min(100, timelinePosition + 100 / 12)) }, { icon: '⏭', action: () => setTimelinePosition(100) }, ].map((btn, i) => ( ))}
{/* 타임라인 슬라이더 */}
{/* 시간 라벨 */}
{['0h', '6h', '12h', '18h', '24h', '36h', '48h', '60h', '72h'].map((label, i) => { const pos = [0, 8.33, 16.67, 25, 33.33, 50, 66.67, 83.33, 100][i] const isActive = Math.abs(timelinePosition - pos) < 5 return ( setTimelinePosition(pos)}>{label} ) })}
{/* 슬라이더 트랙 */}
{/* 트랙 레일 */}
{ const rect = e.currentTarget.getBoundingClientRect() setTimelinePosition(Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100))) }} > {/* 진행 바 */}
{/* 주요 마커 */} {[0, 16.67, 33.33, 50, 66.67, 83.33, 100].map((pos) => (
))} {/* 보조 마커 */} {[8.33, 25].map((pos) => (
))} {/* 방어선 설치 이벤트 마커 */} {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) => (
🛡
))}
{/* 드래그 핸들 */}
{/* 시간 정보 */}
+{Math.round(timelinePosition * 72 / 100)}h — {(() => { const d = new Date(); d.setHours(d.getHours() + Math.round(timelinePosition * 72 / 100)) 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(timelinePosition * 0.4))}%`, color: 'var(--t1)' }, { label: '면적', value: `${(timelinePosition * 0.08).toFixed(1)} km²`, color: 'var(--t1)' }, { label: '차단율', value: boomLines.length > 0 ? `${Math.min(95, 70 + Math.round(timelinePosition * 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 || undefined} /> )} )}
{/* Right Panel */} {activeSubTab === 'analysis' && setRecalcModalOpen(true)} onOpenReport={() => { setReportGenCategory(0); navigateToTab('reports', 'generate') }} detail={analysisDetail} />} {/* 재계산 모달 */} setRecalcModalOpen(false)} oilType={oilType} spillAmount={spillAmount} spillType={spillType} predictionTime={predictionTime} incidentCoord={incidentCoord} 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} />
) }