- Board: 매뉴얼 CRUD + 첨부파일 API (012_board_ext.sql) - HNS: 분석 CRUD 5개 API (013_hns_analysis.sql) - Prediction: 분석/역추적/오일펜스 7개 API (014_prediction.sql) - Aerial: 미디어/CCTV/위성 6개 API + PostGIS (015_aerial.sql) - Rescue: 구난 작전/시나리오 3개 API + JSONB (016_rescue.sql) - backtrackMockData.ts 삭제 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
569 lines
25 KiB
TypeScript
Executable File
569 lines
25 KiB
TypeScript
Executable File
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'
|
||
|
||
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
|
||
// eslint-disable-next-line react-refresh/only-export-components
|
||
export const ALL_MODELS: PredictionModel[] = ['KOSPS', 'POSEIDON', 'OpenDrift']
|
||
|
||
export function OilSpillView() {
|
||
const { activeSubTab, setActiveSubTab } = useSubMenu('prediction')
|
||
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set())
|
||
const [incidentCoord, setIncidentCoord] = useState({ lon: 127.6845, lat: 34.7312 })
|
||
const [isSelectingLocation, setIsSelectingLocation] = useState(false)
|
||
const [oilTrajectory, setOilTrajectory] = useState<Array<{ lat: number; lon: number; time: number; particle?: number; model?: PredictionModel }>>([])
|
||
const [isRunningSimulation, setIsRunningSimulation] = useState(false)
|
||
const [selectedModels, setSelectedModels] = useState<Set<PredictionModel>>(new Set(['KOSPS']))
|
||
const [predictionTime, setPredictionTime] = useState(48)
|
||
const [spillType, setSpillType] = useState('연속')
|
||
const [oilType, setOilType] = useState('벙커C유')
|
||
const [spillAmount, setSpillAmount] = useState(100)
|
||
|
||
// 오일펜스 배치 상태
|
||
const [boomLines, setBoomLines] = useState<BoomLine[]>([])
|
||
const [algorithmSettings, setAlgorithmSettings] = useState<AlgorithmSettings>({
|
||
currentOrthogonalCorrection: 15,
|
||
safetyMarginMinutes: 60,
|
||
minContainmentEfficiency: 80,
|
||
waveHeightCorrectionFactor: 1.0,
|
||
})
|
||
const [isDrawingBoom, setIsDrawingBoom] = useState(false)
|
||
const [drawingPoints, setDrawingPoints] = useState<BoomLineCoord[]>([])
|
||
const [containmentResult, setContainmentResult] = useState<ContainmentResult | null>(null)
|
||
|
||
// 레이어 스타일 (투명도 / 밝기)
|
||
const [layerOpacity, setLayerOpacity] = useState(50)
|
||
const [layerBrightness, setLayerBrightness] = useState(50)
|
||
|
||
// 타임라인 플레이어 상태
|
||
const [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<BacktrackPhase>('conditions')
|
||
const [backtrackVessels, setBacktrackVessels] = useState<BacktrackVessel[]>([])
|
||
const [isReplayActive, setIsReplayActive] = useState(false)
|
||
const [isReplayPlaying, setIsReplayPlaying] = useState(false)
|
||
const [replayFrame, setReplayFrame] = useState(0)
|
||
const [replaySpeed, setReplaySpeed] = useState(1)
|
||
|
||
// 선택된 분석 (목록에서 클릭 시)
|
||
const [selectedAnalysis, setSelectedAnalysis] = useState<Analysis | null>(null)
|
||
// 분석 상세 (API에서 가져온 선박/기상 정보)
|
||
const [analysisDetail, setAnalysisDetail] = useState<PredictionDetail | null>(null)
|
||
|
||
// 역추적 API 데이터
|
||
const [backtrackConditions, setBacktrackConditions] = useState<BacktrackConditions>({
|
||
estimatedSpillTime: '', analysisRange: '±12시간', searchRadius: '10 NM',
|
||
spillLocation: { lat: 34.7312, lon: 127.6845 }, totalVessels: 0,
|
||
})
|
||
const [replayShips, setReplayShips] = useState<ReplayShip[]>([])
|
||
const [collisionEvent, setCollisionEvent] = useState<CollisionEvent | null>(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<string, unknown>
|
||
if (Array.isArray(rslt.vessels)) {
|
||
setBacktrackVessels(rslt.vessels as BacktrackVessel[])
|
||
}
|
||
if (Array.isArray(rslt.replayShips)) {
|
||
setReplayShips(rslt.replayShips as ReplayShip[])
|
||
}
|
||
if (rslt.collisionEvent) {
|
||
setCollisionEvent(rslt.collisionEvent as CollisionEvent)
|
||
}
|
||
setBacktrackConditions({
|
||
estimatedSpillTime: bt.estSpilDtm ? new Date(bt.estSpilDtm).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '',
|
||
analysisRange: bt.anlysRange || '±12시간',
|
||
searchRadius: bt.srchRadiusNm ? `${bt.srchRadiusNm} NM` : '10 NM',
|
||
spillLocation: { lat: bt.lat || incidentCoord.lat, 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<string, unknown>
|
||
if (Array.isArray(rslt.vessels)) {
|
||
setBacktrackVessels(rslt.vessels as BacktrackVessel[])
|
||
}
|
||
if (Array.isArray(rslt.replayShips)) {
|
||
setReplayShips(rslt.replayShips as ReplayShip[])
|
||
}
|
||
if (rslt.collisionEvent) {
|
||
setCollisionEvent(rslt.collisionEvent as CollisionEvent)
|
||
}
|
||
setBacktrackConditions(prev => ({
|
||
...prev,
|
||
totalVessels: bt.totalVessels || 0,
|
||
}))
|
||
setBacktrackPhase('results')
|
||
} else {
|
||
// 엔진 미구현 — PENDING 상태, 일단 빈 결과
|
||
console.info('[prediction] 역추적 생성 완료 (SN:', backtrackSn, '), 엔진 미구현')
|
||
setBacktrackPhase('conditions')
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('[prediction] 역추적 분석 실패:', err)
|
||
setBacktrackPhase('conditions')
|
||
}
|
||
}
|
||
|
||
const handleStartReplay = () => {
|
||
setBacktrackModalOpen(false)
|
||
setIsReplayActive(true)
|
||
setReplayFrame(0)
|
||
setIsReplayPlaying(false)
|
||
}
|
||
|
||
const handleCloseReplay = () => {
|
||
setIsReplayActive(false)
|
||
setIsReplayPlaying(false)
|
||
setReplayFrame(0)
|
||
}
|
||
|
||
// 역추적 리플레이 애니메이션
|
||
useEffect(() => {
|
||
if (!isReplayPlaying) return
|
||
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<string, string> = {
|
||
'BUNKER_C': '벙커C유', 'DIESEL': '경유', 'CRUDE_OIL': '원유', 'LUBE_OIL': '윤활유',
|
||
}
|
||
setOilType(oilTypeMap[analysis.oilType] || '벙커C유')
|
||
setSpillAmount(analysis.volume ?? 100)
|
||
setPredictionTime(parseInt(analysis.duration) || 48)
|
||
// 모델 상태에 따라 선택 모델 설정
|
||
const models = new Set<PredictionModel>()
|
||
if (analysis.kospsStatus !== 'pending') models.add('KOSPS')
|
||
if (analysis.poseidonStatus !== 'pending') models.add('POSEIDON')
|
||
if (analysis.opendriftStatus !== 'pending') models.add('OpenDrift')
|
||
setSelectedModels(models)
|
||
// 분석 상세 로딩 (선박/기상 정보)
|
||
try {
|
||
const detail = await fetchPredictionDetail(analysis.acdntSn)
|
||
setAnalysisDetail(detail)
|
||
} catch (err) {
|
||
console.error('[prediction] 분석 상세 로딩 실패:', err)
|
||
}
|
||
// 분석 화면으로 전환
|
||
setActiveSubTab('analysis')
|
||
}
|
||
|
||
const 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 response = await fetch('http://localhost:3001/api/simulation/run', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
model,
|
||
lat: incidentCoord.lat,
|
||
lon: incidentCoord.lon,
|
||
duration_hours: predictionTime,
|
||
oil_type: oilType,
|
||
spill_amount: spillAmount,
|
||
spill_type: spillType
|
||
})
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`API 오류 (${model}): ${response.status}`)
|
||
}
|
||
|
||
const data = await response.json()
|
||
return (data.trajectory as Array<{ lat: number; lon: number; time: number; particle?: number }>)
|
||
.map(p => ({ ...p, model }))
|
||
})
|
||
)
|
||
|
||
setOilTrajectory(results.flat())
|
||
} catch (error) {
|
||
console.error('시뮬레이션 실행 오류:', error)
|
||
alert('시뮬레이션 실행 중 오류가 발생했습니다.')
|
||
} finally {
|
||
setIsRunningSimulation(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-1 overflow-hidden">
|
||
{/* Left Sidebar */}
|
||
{activeSubTab === 'analysis' && (
|
||
<LeftPanel
|
||
selectedAnalysis={selectedAnalysis}
|
||
enabledLayers={enabledLayers}
|
||
onToggleLayer={handleToggleLayer}
|
||
incidentCoord={incidentCoord}
|
||
onCoordChange={setIncidentCoord}
|
||
onMapSelectClick={() => 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 */}
|
||
<div className="flex-1 relative overflow-hidden">
|
||
{activeSubTab === 'list' ? (
|
||
<AnalysisListTable onTabChange={setActiveSubTab} onSelectAnalysis={handleSelectAnalysis} />
|
||
) : activeSubTab === 'theory' ? (
|
||
<OilSpillTheoryView />
|
||
) : activeSubTab === 'boom-theory' ? (
|
||
<BoomDeploymentTheoryView />
|
||
) : (
|
||
<>
|
||
<MapView
|
||
enabledLayers={enabledLayers}
|
||
incidentCoord={incidentCoord}
|
||
isSelectingLocation={isSelectingLocation || isDrawingBoom}
|
||
onMapClick={handleMapClick}
|
||
oilTrajectory={oilTrajectory}
|
||
selectedModels={selectedModels}
|
||
boomLines={boomLines}
|
||
isDrawingBoom={isDrawingBoom}
|
||
drawingPoints={drawingPoints}
|
||
layerOpacity={layerOpacity}
|
||
layerBrightness={layerBrightness}
|
||
backtrackReplay={isReplayActive && replayShips.length > 0 ? {
|
||
isActive: true,
|
||
ships: replayShips,
|
||
collisionEvent: collisionEvent || undefined,
|
||
replayFrame,
|
||
totalFrames: TOTAL_REPLAY_FRAMES,
|
||
incidentCoord,
|
||
} : undefined}
|
||
/>
|
||
|
||
{/* 타임라인 플레이어 (리플레이 비활성 시) */}
|
||
{!isReplayActive && <div style={{
|
||
position: 'absolute', bottom: 0, left: 0, right: 0, height: '72px',
|
||
background: 'rgba(15,21,36,0.95)', backdropFilter: 'blur(16px)',
|
||
borderTop: '1px solid var(--bd)',
|
||
display: 'flex', alignItems: 'center', padding: '0 20px', gap: '16px', zIndex: 1100
|
||
}}>
|
||
{/* 컨트롤 버튼 */}
|
||
<div style={{ display: 'flex', gap: '4px', flexShrink: 0 }}>
|
||
{[
|
||
{ icon: '⏮', action: () => setTimelinePosition(0) },
|
||
{ icon: '◀', action: () => setTimelinePosition(Math.max(0, timelinePosition - 100 / 12)) },
|
||
].map((btn, i) => (
|
||
<button key={i} onClick={btn.action} style={{
|
||
width: '34px', height: '34px', borderRadius: 'var(--rS, 4px)',
|
||
border: '1px solid var(--bd)', background: 'var(--bg3)', color: 'var(--t2)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
cursor: 'pointer', fontSize: '14px', transition: '0.2s'
|
||
}}>{btn.icon}</button>
|
||
))}
|
||
<button onClick={() => setIsPlaying(!isPlaying)} style={{
|
||
width: '34px', height: '34px', borderRadius: 'var(--rS, 4px)',
|
||
border: isPlaying ? '1px solid var(--cyan)' : '1px solid var(--bd)',
|
||
background: isPlaying ? 'var(--cyan)' : 'var(--bg3)',
|
||
color: isPlaying ? 'var(--bg0)' : 'var(--t2)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
cursor: 'pointer', fontSize: '14px', transition: '0.2s'
|
||
}}>{isPlaying ? '⏸' : '▶'}</button>
|
||
{[
|
||
{ icon: '▶▶', action: () => setTimelinePosition(Math.min(100, timelinePosition + 100 / 12)) },
|
||
{ icon: '⏭', action: () => setTimelinePosition(100) },
|
||
].map((btn, i) => (
|
||
<button key={i} onClick={btn.action} style={{
|
||
width: '34px', height: '34px', borderRadius: 'var(--rS, 4px)',
|
||
border: '1px solid var(--bd)', background: 'var(--bg3)', color: 'var(--t2)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
cursor: 'pointer', fontSize: '12px', transition: '0.2s'
|
||
}}>{btn.icon}</button>
|
||
))}
|
||
<div style={{ width: '8px' }} />
|
||
<button onClick={() => setPlaySpeed(playSpeed >= 4 ? 1 : playSpeed * 2)} style={{
|
||
width: '34px', height: '34px', borderRadius: 'var(--rS, 4px)',
|
||
border: '1px solid var(--bd)', background: 'var(--bg3)', color: 'var(--t2)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
cursor: 'pointer', fontSize: '11px', fontWeight: 600, fontFamily: 'var(--fM)', transition: '0.2s'
|
||
}}>{playSpeed}×</button>
|
||
</div>
|
||
|
||
{/* 타임라인 슬라이더 */}
|
||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||
{/* 시간 라벨 */}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '0 4px' }}>
|
||
{['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 (
|
||
<span key={label} style={{
|
||
fontSize: '10px', fontFamily: 'var(--fM)',
|
||
color: isActive ? 'var(--cyan)' : 'var(--t3)',
|
||
fontWeight: isActive ? 600 : 400, cursor: 'pointer'
|
||
}} onClick={() => setTimelinePosition(pos)}>{label}</span>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* 슬라이더 트랙 */}
|
||
<div style={{ position: 'relative', height: '24px', display: 'flex', alignItems: 'center' }}>
|
||
{/* 트랙 레일 */}
|
||
<div
|
||
style={{ width: '100%', height: '4px', background: 'var(--bd)', borderRadius: '2px', position: 'relative', cursor: 'pointer' }}
|
||
onClick={(e) => {
|
||
const rect = e.currentTarget.getBoundingClientRect()
|
||
setTimelinePosition(Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100)))
|
||
}}
|
||
>
|
||
{/* 진행 바 */}
|
||
<div style={{
|
||
position: 'absolute', top: 0, left: 0,
|
||
width: `${timelinePosition}%`, height: '100%',
|
||
background: 'linear-gradient(90deg, var(--cyan), var(--blue))',
|
||
borderRadius: '2px', transition: 'width 0.15s'
|
||
}} />
|
||
{/* 주요 마커 */}
|
||
{[0, 16.67, 33.33, 50, 66.67, 83.33, 100].map((pos) => (
|
||
<div key={`mj-${pos}`} style={{
|
||
position: 'absolute', width: '2px', height: '14px',
|
||
background: 'var(--t3)', top: '-5px', left: `${pos}%`
|
||
}} />
|
||
))}
|
||
{/* 보조 마커 */}
|
||
{[8.33, 25].map((pos) => (
|
||
<div key={`mn-${pos}`} style={{
|
||
position: 'absolute', width: '2px', height: '10px',
|
||
background: 'var(--bdL)', top: '-3px', left: `${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) => (
|
||
<div key={`bm-${i}`} title={bm.label} style={{
|
||
position: 'absolute', top: '-18px', left: `${bm.pos}%`,
|
||
transform: 'translateX(-50%)', fontSize: '12px', cursor: 'pointer',
|
||
filter: 'drop-shadow(0 0 4px rgba(245,158,11,0.5))'
|
||
}}>🛡</div>
|
||
))}
|
||
</div>
|
||
{/* 드래그 핸들 */}
|
||
<div style={{
|
||
position: 'absolute', left: `${timelinePosition}%`, top: '50%',
|
||
transform: 'translate(-50%, -50%)',
|
||
width: '16px', height: '16px',
|
||
background: 'var(--cyan)', border: '3px solid var(--bg0)',
|
||
borderRadius: '50%', cursor: 'grab',
|
||
boxShadow: '0 0 10px rgba(6,182,212,0.4)', zIndex: 2,
|
||
transition: 'left 0.15s'
|
||
}} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* 시간 정보 */}
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '4px', flexShrink: 0, minWidth: '200px' }}>
|
||
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--cyan)', fontFamily: 'var(--fM)' }}>
|
||
+{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`
|
||
})()}
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '14px' }}>
|
||
{[
|
||
{ 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) => (
|
||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px' }}>
|
||
<span style={{ color: 'var(--t3)', fontFamily: 'var(--fK)' }}>{s.label}</span>
|
||
<span style={{ color: s.color, fontWeight: 600, fontFamily: 'var(--fM)' }}>{s.value}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>}
|
||
|
||
{/* 역추적 리플레이 바 */}
|
||
{isReplayActive && (
|
||
<BacktrackReplayBar
|
||
isPlaying={isReplayPlaying}
|
||
replayFrame={replayFrame}
|
||
totalFrames={TOTAL_REPLAY_FRAMES}
|
||
replaySpeed={replaySpeed}
|
||
onTogglePlay={() => setIsReplayPlaying(!isReplayPlaying)}
|
||
onSeek={setReplayFrame}
|
||
onSpeedChange={setReplaySpeed}
|
||
onClose={handleCloseReplay}
|
||
replayShips={replayShips}
|
||
collisionEvent={collisionEvent || undefined}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Right Panel */}
|
||
{activeSubTab === 'analysis' && <RightPanel onOpenBacktrack={handleOpenBacktrack} onOpenRecalc={() => setRecalcModalOpen(true)} onOpenReport={() => { setReportGenCategory(0); navigateToTab('reports', 'generate') }} detail={analysisDetail} />}
|
||
|
||
{/* 재계산 모달 */}
|
||
<RecalcModal
|
||
isOpen={recalcModalOpen}
|
||
onClose={() => 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()
|
||
}}
|
||
/>
|
||
|
||
{/* 역추적 모달 */}
|
||
<BacktrackModal
|
||
isOpen={backtrackModalOpen}
|
||
onClose={() => setBacktrackModalOpen(false)}
|
||
phase={backtrackPhase}
|
||
conditions={backtrackConditions}
|
||
vessels={backtrackVessels}
|
||
onRunAnalysis={handleRunBacktrackAnalysis}
|
||
onStartReplay={handleStartReplay}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|