wing-ops/frontend/src/tabs/prediction/components/OilSpillView.tsx
htlee 6356b0a3bd feat(frontend): 항공탐색 탭 개선 + 확산분석 데모 데이터 시각화
항공탐색 탭:
- CctvView 크래시 수정 (cctvCameras → cameras 필드 매핑)
- AerialView 이중 서브메뉴 분기 → 플랫 switch 단순화
- SensorAnalysis SVG 300pt → Canvas 2D 5000/8000pt 고밀도 전환
- RealtimeDrone CSS 시뮬레이션 → MapLibre + deck.gl 실제 지도 전환

확산분석 탭:
- 시뮬레이션 백엔드 미구현 시 클라이언트 데모 궤적 fallback 생성
- AI 방어선 3개(직교차단/U형포위/연안보호) 자동 배치
- 민감자원 5개소(양식장/해수욕장/보호구역) deck.gl 레이어 표시
- 해류 화살표 11x11 그리드 TextLayer 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 08:59:13 +09:00

651 lines
28 KiB
TypeScript
Executable File
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<PredictionModel, { bearing: number; speed: number; spread: number; seed: number }> = {
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<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 [sensitiveResources, setSensitiveResources] = useState<SensitiveResource[]>([])
// 오일펜스 배치 상태
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 { 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 (
<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}
sensitiveResources={sensitiveResources}
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>
)
}