wing-ops/frontend/src/tabs/prediction/components/OilSpillView.tsx
jeonghyo.k e285f2330f feat(prediction): 역추적 분석 엔진 및 동적 파라미터 입력 기능 구현
- 백엔드: backtrackAnalysisService 신규 개발
  * AIS 기반 선박 항적 API 연동 및 공간 조회
  * 공간(40%)/시간(25%)/행동(20%)/선박유형(15%) 가중치 위험도 점수 산정
  * 상위 5척 리플레이 데이터 및 충돌 이벤트 생성
  * Python 서버 미연동 시 폴백 메커니즘 제공
- 백엔드: 역추적 생성 시 동기 분석 → BacktrackResult 즉시 반환
- 프론트엔드: 모달에서 유출 시각/분석 범위/탐색 반경 직접 입력 가능
- 프론트엔드: 리플레이 바에 실제 분석 시간 범위 동적 표시
- DB: AIS_TRACK 테이블 신규 생성 (선박 항적 이력 + GIS 인덱스)
2026-03-27 14:57:00 +09:00

1346 lines
58 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, useMemo, useRef } from 'react'
import { LeftPanel } from './LeftPanel'
import { RightPanel } from './RightPanel'
import { MapView } from '@common/components/map/MapView'
import { AnalysisListTable, type Analysis } from './AnalysisListTable'
import { OilSpillTheoryView } from './OilSpillTheoryView'
import { BoomDeploymentTheoryView } from './BoomDeploymentTheoryView'
import { BacktrackModal } from './BacktrackModal'
import { RecalcModal } from './RecalcModal'
import { BacktrackReplayBar } from '@common/components/map/BacktrackReplayBar'
import { useSubMenu, navigateToTab, setReportGenCategory, setOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu'
import { fetchWeatherSnapshotForCoord } from '@tabs/weather/services/weatherUtils'
import { useWeatherSnapshotStore } from '@common/store/weatherSnapshotStore'
import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine'
import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, BacktrackInputConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack'
import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'
import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory, fetchSensitiveResources, fetchSensitiveResourcesGeojson } from '../services/predictionApi'
import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, RunModelSyncResponse, SimulationSummary, SensitiveResourceCategory, SensitiveResourceFeatureCollection, WindPoint } from '../services/predictionApi'
import SimulationLoadingOverlay from './SimulationLoadingOverlay'
import SimulationErrorModal from './SimulationErrorModal'
import { api } from '@common/services/api'
import { generateAIBoomLines, haversineDistance, pointInPolygon, polygonAreaKm2, circleAreaKm2 } from '@common/utils/geo'
import { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal'
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
const toLocalDateTimeStr = (raw: string): string => {
const d = new Date(raw)
if (isNaN(d.getTime())) return ''
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
// ---------------------------------------------------------------------------
// 민감자원 타입 + 데모 데이터
// ---------------------------------------------------------------------------
export interface SensitiveResource {
id: string
name: string
type: 'aquaculture' | 'beach' | 'ecology' | 'intake'
lat: number
lon: number
radiusM: number
arrivalTimeH: number
}
export interface DisplayControls {
showCurrent: boolean; // 유향/유속
showWind: boolean; // 풍향/풍속
showBeached: boolean; // 해안부착
showTimeLabel: boolean; // 시간 표시
showSensitiveResources: boolean; // 민감자원
}
// ---------------------------------------------------------------------------
// 데모 궤적 생성 (seeded PRNG — deterministic)
// ---------------------------------------------------------------------------
function mulberry32(seed: number) {
return () => {
seed |= 0; seed = seed + 0x6D2B79F5 | 0
let t = Math.imul(seed ^ seed >>> 15, 1 | seed)
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t
return ((t ^ t >>> 14) >>> 0) / 4294967296
}
}
const DEG2RAD = Math.PI / 180
function generateDemoTrajectory(
incident: { lat: number; lon: number },
models: PredictionModel[],
durationHours: number
): Array<{ lat: number; lon: number; time: number; particle: number; model: PredictionModel }> {
const result: Array<{ lat: number; lon: number; time: number; particle: number; model: PredictionModel }> = []
const PARTICLES_PER_MODEL = 60
const TIME_STEP = 3 // hours
const modelParams: Record<PredictionModel, { bearing: number; speed: number; spread: number; seed: number }> = {
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<Set<string>>(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<OilParticle[]>([])
const [centerPoints, setCenterPoints] = useState<CenterPoint[]>([])
const [windDataByModel, setWindDataByModel] = useState<Record<string, WindPoint[][]>>({})
const [hydrDataByModel, setHydrDataByModel] = useState<Record<string, (HydrDataStep | null)[]>>({})
const [windHydrModel, setWindHydrModel] = useState<string>('OpenDrift')
const [isRunningSimulation, setIsRunningSimulation] = useState(false)
const [simulationProgress, setSimulationProgress] = useState(0)
const progressTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const [simulationError, setSimulationError] = useState<string | null>(null)
const [selectedModels, setSelectedModels] = useState<Set<PredictionModel>>(new Set(['OpenDrift']))
const [visibleModels, setVisibleModels] = useState<Set<PredictionModel>>(new Set(['OpenDrift']))
const [predictionTime, setPredictionTime] = useState(48)
const [accidentTime, setAccidentTime] = useState<string>('')
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<SensitiveResource[]>([])
const [sensitiveResourceCategories, setSensitiveResourceCategories] = useState<SensitiveResourceCategory[]>([])
const [sensitiveResourceGeojson, setSensitiveResourceGeojson] = useState<SensitiveResourceFeatureCollection | null>(null)
// 오일펜스 배치 상태
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 [displayControls, setDisplayControls] = useState<DisplayControls>({
showCurrent: true,
showWind: false,
showBeached: false,
showTimeLabel: false,
showSensitiveResources: false,
})
// 타임라인 플레이어 상태
const [isPlaying, setIsPlaying] = useState(false)
const [currentStep, setCurrentStep] = useState(0) // 현재 시간값 (시간 단위)
const [playSpeed, setPlaySpeed] = useState(1)
// 역추적 상태
const [backtrackModalOpen, setBacktrackModalOpen] = useState(false)
const [backtrackPhase, setBacktrackPhase] = useState<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: 37.3883, lon: 126.6435 }, totalVessels: 0,
})
const [replayShips, setReplayShips] = useState<ReplayShip[]>([])
const [collisionEvent, setCollisionEvent] = useState<CollisionEvent | null>(null)
const [replayTimeRange, setReplayTimeRange] = useState<{ start: string; end: string } | null>(null)
// 재계산 상태
const [recalcModalOpen, setRecalcModalOpen] = useState(false)
const [simulationSummary, setSimulationSummary] = useState<SimulationSummary | null>(null)
const [summaryByModel, setSummaryByModel] = useState<Record<string, SimulationSummary>>({})
const [stepSummariesByModel, setStepSummariesByModel] = useState<Record<string, SimulationSummary[]>>({})
// 펜스차단량 계산 (오일붐 차단 효율 × 총 유류량)
const boomBlockedVolume = useMemo(() => {
if (!containmentResult || !simulationSummary) return 0;
const totalVolumeM3 = simulationSummary.remainingVolume + simulationSummary.weatheredVolume + simulationSummary.beachedVolume;
return totalVolumeM3 * (containmentResult.overallEfficiency / 100);
}, [containmentResult, simulationSummary])
// 오염분석 상태
const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon')
const [drawAnalysisMode, setDrawAnalysisMode] = useState<'polygon' | null>(null)
const [analysisPolygonPoints, setAnalysisPolygonPoints] = useState<{ lat: number; lon: number }[]>([])
const [circleRadiusNm, setCircleRadiusNm] = useState<number>(5)
const [analysisResult, setAnalysisResult] = useState<{ area: number; particleCount: number; particlePercent: number; sensitiveCount: number } | null>(null)
// 원 분석용 derived 값 (state 아님)
const analysisCircleCenter = analysisTab === 'circle' && incidentCoord ? incidentCoord : null
const analysisCircleRadiusM = circleRadiusNm * 1852
const handleToggleLayer = (layerId: string, enabled: boolean) => {
setEnabledLayers(prev => {
const newSet = new Set(prev)
if (enabled) {
newSet.add(layerId)
} else {
newSet.delete(layerId)
}
return newSet
})
}
// 역추적: API에서 기존 결과 로딩
const loadBacktrackData = useCallback(async (acdntSn: number) => {
try {
const bt = await fetchBacktrackByAcdnt(acdntSn)
if (bt && bt.execSttsCd === 'COMPLETED' && bt.rsltData) {
const rslt = bt.rsltData as Record<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)
}
if (rslt['timeRange']) {
setReplayTimeRange(rslt['timeRange'] as { start: string; end: string })
}
setBacktrackConditions({
estimatedSpillTime: bt.estSpilDtm ? new Date(bt.estSpilDtm).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '',
analysisRange: bt.anlysRange || '±12시간',
searchRadius: bt.srchRadiusNm ? `${bt.srchRadiusNm} NM` : '10 NM',
spillLocation: { lat: bt.lat || incidentCoord?.lat || 0, lon: bt.lon || incidentCoord?.lon || 0 },
totalVessels: bt.totalVessels || 0,
})
setBacktrackPhase('results')
return
}
} catch (err) {
console.error('[prediction] 역추적 데이터 로딩 실패:', err)
}
// 기존 결과 없으면 conditions 상태 유지
setBacktrackPhase('conditions')
setBacktrackVessels([])
setReplayShips([])
setCollisionEvent(null)
}, [incidentCoord])
// 역추적 핸들러
const handleOpenBacktrack = () => {
setBacktrackModalOpen(true)
setBacktrackConditions(prev => ({
...prev,
spillLocation: incidentCoord ?? prev.spillLocation,
estimatedSpillTime: selectedAnalysis?.occurredAt
? toLocalDateTimeStr(selectedAnalysis.occurredAt)
: prev.estimatedSpillTime,
}))
if (selectedAnalysis) {
loadBacktrackData(selectedAnalysis.acdntSn)
} else {
setBacktrackPhase('conditions')
setBacktrackVessels([])
}
}
const handleRunBacktrackAnalysis = async (input: BacktrackInputConditions) => {
if (!incidentCoord || !selectedAnalysis) return
setBacktrackPhase('analyzing')
try {
const anlysRangeStr = `±${input.analysisRange}시간`
const bt = await createBacktrack({
acdntSn: selectedAnalysis.acdntSn,
lon: incidentCoord.lon,
lat: incidentCoord.lat,
estSpilDtm: input.estimatedSpillTime ? new Date(input.estimatedSpillTime).toISOString() : undefined,
anlysRange: anlysRangeStr,
srchRadiusNm: input.searchRadius,
})
if (bt.execSttsCd === 'COMPLETED' && bt.rsltData) {
const rslt = bt.rsltData as Record<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)
if (rslt['timeRange']) setReplayTimeRange(rslt['timeRange'] as { start: string; end: string })
setBacktrackConditions(prev => ({ ...prev, totalVessels: bt.totalVessels || 0 }))
setBacktrackPhase('results')
} else {
console.error('[prediction] 역추적 분석 실패:', bt.execSttsCd)
setBacktrackPhase('conditions')
}
} catch (err) {
console.error('[prediction] 역추적 분석 실패:', err)
setBacktrackPhase('conditions')
}
}
const handleStartReplay = () => {
setBacktrackModalOpen(false)
setIsReplayActive(true)
setReplayFrame(0)
setIsReplayPlaying(false)
}
const handleCloseReplay = () => {
setIsReplayActive(false)
setIsReplayPlaying(false)
setReplayFrame(0)
}
// 역추적 리플레이 애니메이션
useEffect(() => {
if (!isReplayPlaying) return
const interval = setInterval(() => {
setReplayFrame(prev => {
const next = prev + 1
if (next >= TOTAL_REPLAY_FRAMES) {
setIsReplayPlaying(false)
return TOTAL_REPLAY_FRAMES
}
return next
})
}, 50 / replaySpeed)
return () => clearInterval(interval)
}, [isReplayPlaying, replaySpeed])
// flyTo 완료 후 재생 대기 플래그
const pendingPlayRef = useRef(false)
// 항공 이미지 분석 완료 후 자동실행 플래그
const pendingAutoRunRef = useRef(false)
// 마운트 시 이미지 분석 시그널 확인 (유출유면적분석 탭에서 이동한 경우)
useEffect(() => {
const pending = consumePendingImageAnalysis()
if (!pending) return
handleImageAnalysisResult({
acdntSn: pending.acdntSn,
lat: pending.lat,
lon: pending.lon,
oilType: pending.oilType,
area: pending.area,
volume: pending.volume,
fileId: pending.fileId,
occurredAt: pending.occurredAt,
})
if (pending.autoRun) pendingAutoRunRef.current = true
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// incidentCoord 업데이트 후 시뮬레이션 자동실행
useEffect(() => {
if (pendingAutoRunRef.current && incidentCoord) {
pendingAutoRunRef.current = false
handleRunSimulation()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [incidentCoord])
const handleFlyEnd = useCallback(() => {
setFlyToCoord(undefined)
if (pendingPlayRef.current) {
pendingPlayRef.current = false
setIsPlaying(true)
}
}, [])
// trajectory 변경 시 플레이어 스텝 초기화 (재생은 각 경로에서 별도 처리)
useEffect(() => {
if (oilTrajectory.length > 0) {
setCurrentStep(0);
}
}, [oilTrajectory.length]);
useEffect(() => {
return () => {
if (progressTimerRef.current) clearInterval(progressTimerRef.current);
};
}, []);
// visibleModels 변경 시 windHydrModel 동기화
useEffect(() => {
if (visibleModels.size === 0) return;
if (visibleModels.size === 1) {
// 단일 모델 → 항상 해당 모델로 동기화
setWindHydrModel(Array.from(visibleModels)[0]);
} else if (!visibleModels.has(windHydrModel as PredictionModel)) {
// 다중 모델이지만 현재 선택이 사라진 경우 → fallback
const preferred: PredictionModel[] = ['OpenDrift', 'POSEIDON', 'KOSPS'];
const next = preferred.find(m => visibleModels.has(m)) ?? Array.from(visibleModels)[0];
setWindHydrModel(next);
}
}, [visibleModels]);
// 플레이어 재생 애니메이션 (1x = 1초/스텝, 2x = 0.5초/스텝, 4x = 0.25초/스텝)
const timeSteps = useMemo(() => {
if (oilTrajectory.length === 0) return [];
const unique = [...new Set(oilTrajectory.map(p => p.time))].sort((a, b) => a - b);
return unique;
}, [oilTrajectory]);
const maxTime = timeSteps[timeSteps.length - 1] ?? predictionTime;
// 유향유속/풍향풍속 데이터 — 선택한 모델 기준으로 파생
const windHydrModelOptions = useMemo(() => Array.from(visibleModels), [visibleModels])
const windData = useMemo(
() => windDataByModel[windHydrModel] ?? [],
[windDataByModel, windHydrModel]
)
const hydrData = useMemo(
() => hydrDataByModel[windHydrModel] ?? [],
[hydrDataByModel, windHydrModel]
)
useEffect(() => {
if (!isPlaying || timeSteps.length === 0) return;
if (currentStep >= maxTime) {
setIsPlaying(false);
return;
}
const ms = 1000 / playSpeed;
const id = setInterval(() => {
setCurrentStep(prev => {
const idx = timeSteps.indexOf(prev);
if (idx < 0 || idx >= timeSteps.length - 1) {
setIsPlaying(false);
return timeSteps[timeSteps.length - 1];
}
return timeSteps[idx + 1];
});
}, ms);
return () => clearInterval(id);
}, [isPlaying, currentStep, playSpeed, timeSteps, maxTime]);
// 분석 목록에서 사고명 클릭 시
const handleSelectAnalysis = async (analysis: Analysis) => {
setIsPlaying(false)
setCurrentStep(0)
setSelectedAnalysis(analysis)
setCenterPoints([])
if (analysis.occurredAt) {
setAccidentTime(toLocalDateTimeStr(analysis.occurredAt))
}
if (analysis.lon != null && analysis.lat != null) {
setIncidentCoord({ lon: analysis.lon, lat: analysis.lat })
setFlyToCoord({ lon: analysis.lon, lat: analysis.lat })
}
// 유종 매핑
const oilTypeMap: Record<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)
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<PredictionModel>(['KOSPS']))
// 완료된 모델이 있는 경우 실제 궤적 로드, 없으면 데모로 fallback
const hasCompletedModel =
analysis.opendriftStatus === 'completed' || analysis.poseidonStatus === 'completed';
if (hasCompletedModel) {
try {
const { trajectory, summary, centerPoints: cp, windDataByModel: wdByModel, hydrDataByModel: hdByModel, summaryByModel: sbModel, stepSummariesByModel: stepSbModel } = await fetchAnalysisTrajectory(analysis.acdntSn, analysis.predRunSn ?? undefined)
if (trajectory && trajectory.length > 0) {
setOilTrajectory(trajectory)
if (summary) setSimulationSummary(summary)
setCenterPoints(cp ?? [])
setWindDataByModel(wdByModel ?? {});
setHydrDataByModel(hdByModel ?? {});
if (sbModel) setSummaryByModel(sbModel);
if (stepSbModel) setStepSummariesByModel(stepSbModel);
if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings))
setSensitiveResources([])
fetchSensitiveResources(analysis.acdntSn)
.then(setSensitiveResourceCategories)
.catch(err => console.warn('[prediction] 민감자원 조회 실패:', err))
fetchSensitiveResourcesGeojson(analysis.acdntSn)
.then(setSensitiveResourceGeojson)
.catch(err => console.warn('[prediction] 민감자원 GeoJSON 조회 실패:', err))
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
pendingPlayRef.current = true
} else {
setIsPlaying(true)
}
return
}
} catch (err) {
console.error('[prediction] trajectory 로딩 실패, 데모로 fallback:', err)
}
}
// 데모 궤적 생성 (fallback) — stale wind/current 데이터 초기화
setWindDataByModel({})
setHydrDataByModel({})
setSummaryByModel({})
setStepSummariesByModel({})
const demoTrajectory = generateDemoTrajectory(coord ?? { lat: 37.39, lon: 126.64 }, demoModels, parseInt(analysis.duration) || 48)
setOilTrajectory(demoTrajectory)
if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings))
setSensitiveResources([])
setSensitiveResourceCategories([])
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
pendingPlayRef.current = true
} else {
setIsPlaying(true)
}
}
const handleMapClick = (lon: number, lat: number) => {
if (isDrawingBoom) {
setDrawingPoints(prev => [...prev, { lat, lon }])
} else if (drawAnalysisMode === 'polygon') {
setAnalysisPolygonPoints(prev => [...prev, { lat, lon }])
} else if (isSelectingLocation) {
setIncidentCoord({ lon, lat })
setIsSelectingLocation(false)
}
}
const handleStartPolygonDraw = () => {
setDrawAnalysisMode('polygon')
setAnalysisPolygonPoints([])
setAnalysisResult(null)
}
const handleRunPolygonAnalysis = async () => {
if (analysisPolygonPoints.length < 3) return
const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1
const inside = currentParticles.filter(p => pointInPolygon({ lat: p.lat, lon: p.lon }, analysisPolygonPoints)).length
const sensitiveCount = sensitiveResources.filter(r => pointInPolygon({ lat: r.lat, lon: r.lon }, analysisPolygonPoints)).length
setAnalysisResult({
area: polygonAreaKm2(analysisPolygonPoints),
particleCount: inside,
particlePercent: Math.round((inside / totalIds) * 100),
sensitiveCount,
})
setDrawAnalysisMode(null)
}
const handleRunCircleAnalysis = async () => {
if (!incidentCoord) return
const radiusM = circleRadiusNm * 1852
const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1
const inside = currentParticles.filter(p =>
haversineDistance({ lat: incidentCoord.lat, lon: incidentCoord.lon }, { lat: p.lat, lon: p.lon }) <= radiusM
).length
const sensitiveCount = sensitiveResources.filter(r =>
haversineDistance({ lat: incidentCoord.lat, lon: incidentCoord.lon }, { lat: r.lat, lon: r.lon }) <= radiusM
).length
setAnalysisResult({
area: circleAreaKm2(radiusM),
particleCount: inside,
particlePercent: Math.round((inside / totalIds) * 100),
sensitiveCount,
})
}
const handleCancelAnalysis = () => {
setDrawAnalysisMode(null)
setAnalysisPolygonPoints([])
}
const handleClearAnalysis = () => {
setDrawAnalysisMode(null)
setAnalysisPolygonPoints([])
setAnalysisResult(null)
}
const handleImageAnalysisResult = useCallback((result: ImageAnalyzeResult) => {
setIncidentCoord({ lat: result.lat, lon: result.lon })
setFlyToCoord({ lat: result.lat, lon: result.lon })
setAccidentTime(toLocalDateTimeStr(result.occurredAt))
setOilType(result.oilType)
setSpillAmount(parseFloat(result.volume.toFixed(4)))
setSpillUnit('kL')
setSelectedAnalysis({
acdntSn: result.acdntSn,
acdntNm: '',
occurredAt: result.occurredAt,
analysisDate: '',
requestor: '',
duration: '48',
oilType: result.oilType,
volume: result.volume,
location: '',
lat: result.lat,
lon: result.lon,
kospsStatus: 'pending',
poseidonStatus: 'pending',
opendriftStatus: 'pending',
backtrackStatus: 'pending',
analyst: '',
officeName: '',
acdntSttsCd: 'ACTIVE',
})
}, [])
const startProgressTimer = useCallback((runTimeHours: number) => {
const expectedMs = runTimeHours * 6000;
const startTime = Date.now();
progressTimerRef.current = setInterval(() => {
const elapsed = Date.now() - startTime;
setSimulationProgress(Math.min(90, Math.round((elapsed / expectedMs) * 90)));
}, 500);
}, []);
const stopProgressTimer = useCallback((completed: boolean) => {
if (progressTimerRef.current) {
clearInterval(progressTimerRef.current);
progressTimerRef.current = null;
}
if (completed) {
setSimulationProgress(100);
setTimeout(() => setSimulationProgress(0), 800);
} else {
setSimulationProgress(0);
}
}, []);
const handleRunSimulation = async (overrides?: {
models?: Set<PredictionModel>;
oilType?: string;
spillAmount?: number;
spillType?: string;
predictionTime?: number;
incidentCoord?: { lat: number; lon: number } | null;
}) => {
// incidentName이 있으면 직접 입력 모드 — 기존 selectedAnalysis.acdntSn 무시하고 새 사고 생성
const isDirectInput = incidentName.trim().length > 0;
const existingAcdntSn = isDirectInput
? undefined
: (selectedAnalysis?.acdntSn ?? analysisDetail?.acdnt?.acdntSn);
const effectiveCoord = overrides?.incidentCoord ?? incidentCoord;
if (!isDirectInput && !existingAcdntSn) return;
if (!effectiveCoord) return;
const effectiveOilType = overrides?.oilType ?? oilType;
const effectiveSpillAmount = overrides?.spillAmount ?? spillAmount;
const effectiveSpillType = overrides?.spillType ?? spillType;
const effectivePredictionTime = overrides?.predictionTime ?? predictionTime;
const effectiveModels = overrides?.models ?? selectedModels;
setIsRunningSimulation(true);
setSimulationSummary(null);
startProgressTimer(effectivePredictionTime);
let simulationSucceeded = false;
try {
const payload: Record<string, unknown> = {
acdntSn: existingAcdntSn,
lat: effectiveCoord.lat,
lon: effectiveCoord.lon,
runTime: effectivePredictionTime,
matTy: effectiveOilType,
matVol: effectiveSpillAmount,
spillTime: effectiveSpillType === '연속' ? effectivePredictionTime : 0,
startTime: accidentTime
? `${accidentTime}:00`
: analysisDetail?.acdnt?.occurredAt,
models: Array.from(effectiveModels),
};
if (isDirectInput) {
payload.acdntNm = incidentName.trim();
payload.spillUnit = spillUnit;
payload.spillTypeCd = spillType;
}
// 동기 방식: 예측 완료 시 결과를 직접 반환 (최대 35분 대기)
const { data } = await api.post<RunModelSyncResponse>('/simulation/run-model', payload, {
timeout: 35 * 60 * 1000,
});
// 직접 입력으로 신규 생성된 경우: selectedAnalysis 갱신 + incidentName 초기화
if (data.acdntSn && isDirectInput) {
setSelectedAnalysis({
acdntSn: data.acdntSn,
acdntNm: incidentName.trim(),
occurredAt: accidentTime ? `${accidentTime}:00` : '',
analysisDate: new Date().toISOString(),
requestor: '',
duration: String(predictionTime),
oilType,
volume: spillAmount,
location: '',
lat: effectiveCoord.lat,
lon: effectiveCoord.lon,
kospsStatus: 'pending',
poseidonStatus: 'pending',
opendriftStatus: 'pending',
backtrackStatus: 'pending',
analyst: '',
officeName: '',
} as Analysis);
setIncidentName('');
}
// 결과 처리
const merged: OilParticle[] = [];
let latestSummary: SimulationSummary | null = null;
let latestCenterPoints: CenterPoint[] = [];
const newWindDataByModel: Record<string, WindPoint[][]> = {};
const newHydrDataByModel: Record<string, (HydrDataStep | null)[]> = {};
const newSummaryByModel: Record<string, SimulationSummary> = {};
const newStepSummariesByModel: Record<string, SimulationSummary[]> = {};
const errors: string[] = [];
data.results.forEach(({ model, status, trajectory, summary, stepSummaries, centerPoints, windData, hydrData, error }) => {
if (status === 'ERROR') {
errors.push(error || `${model} 분석 중 오류가 발생했습니다.`);
return;
}
if (trajectory) {
merged.push(...trajectory.map(p => ({ ...p, model })));
}
if (summary) {
newSummaryByModel[model] = summary;
if (model === 'OpenDrift' || !latestSummary) latestSummary = summary;
}
if (stepSummaries) newStepSummariesByModel[model] = stepSummaries;
if (windData) newWindDataByModel[model] = windData;
if (hydrData) newHydrDataByModel[model] = hydrData;
if (centerPoints) {
latestCenterPoints = [...latestCenterPoints, ...centerPoints.map(p => ({ ...p, model }))];
}
});
if (merged.length > 0) {
setOilTrajectory(merged);
const doneModels = new Set<PredictionModel>(
data.results
.filter(r => r.status === 'DONE' && r.trajectory && r.trajectory.length > 0)
.map(r => r.model as PredictionModel)
);
setVisibleModels(doneModels);
setSimulationSummary(latestSummary);
setCenterPoints(latestCenterPoints);
const refWindData = newWindDataByModel['OpenDrift'] ?? Object.values(newWindDataByModel)[0];
const refHydrData = newHydrDataByModel['OpenDrift'] ?? Object.values(newHydrDataByModel)[0];
doneModels.forEach(model => {
if (!newWindDataByModel[model] && refWindData) newWindDataByModel[model] = refWindData;
if (!newHydrDataByModel[model] && refHydrData) newHydrDataByModel[model] = refHydrData;
});
setWindDataByModel(newWindDataByModel);
setHydrDataByModel(newHydrDataByModel);
setSummaryByModel(newSummaryByModel);
setStepSummariesByModel(newStepSummariesByModel);
const booms = generateAIBoomLines(merged, effectiveCoord, algorithmSettings);
setBoomLines(booms);
setSensitiveResources([]);
setCurrentStep(0);
setIsPlaying(true);
setFlyToCoord({ lon: effectiveCoord.lon, lat: effectiveCoord.lat });
}
if (errors.length > 0 && merged.length === 0) {
setSimulationError(errors.join('; '));
} else {
simulationSucceeded = true;
const effectiveAcdntSn = data.acdntSn ?? selectedAnalysis?.acdntSn;
if (effectiveCoord) {
fetchWeatherSnapshotForCoord(effectiveCoord.lat, effectiveCoord.lon)
.then(snapshot => {
useWeatherSnapshotStore.getState().setSnapshot(snapshot);
if (effectiveAcdntSn) {
api.post(`/incidents/${effectiveAcdntSn}/weather`, snapshot)
.catch(err => console.warn('[weather] 기상 저장 실패:', err));
}
})
.catch(err => console.warn('[weather] 기상 데이터 수집 실패:', err));
}
if (effectiveAcdntSn) {
fetchSensitiveResources(effectiveAcdntSn)
.then(setSensitiveResourceCategories)
.catch(err => console.warn('[prediction] 민감자원 조회 실패:', err));
fetchSensitiveResourcesGeojson(effectiveAcdntSn)
.then(setSensitiveResourceGeojson)
.catch(err => console.warn('[prediction] 민감자원 GeoJSON 조회 실패:', err));
}
}
} catch (err) {
const msg =
(err as { message?: string })?.message
?? '시뮬레이션 실행 중 오류가 발생했습니다.';
setSimulationError(msg);
} finally {
stopProgressTimer(simulationSucceeded);
setIsRunningSimulation(false);
}
}
const handleOpenReport = () => {
const OIL_TYPE_CODE: Record<string, string> = {
'벙커C유': 'BUNKER_C', '경유': 'DIESEL', '원유': 'CRUDE_OIL', '윤활유': 'LUBE_OIL',
};
const accidentName =
selectedAnalysis?.acdntNm ||
analysisDetail?.acdnt?.acdntNm ||
incidentName ||
'(미입력)';
const occurTime =
selectedAnalysis?.occurredAt ||
analysisDetail?.acdnt?.occurredAt ||
accidentTime ||
'';
const wx = analysisDetail?.weather?.[0] ?? null;
const weatherSnapshot = useWeatherSnapshotStore.getState().snapshot;
const payload: OilReportPayload = {
incident: {
name: accidentName,
occurTime,
location: selectedAnalysis?.location || analysisDetail?.acdnt?.location || (() => {
const _lat = incidentCoord?.lat ?? selectedAnalysis?.lat ?? null;
const _lon = incidentCoord?.lon ?? selectedAnalysis?.lon ?? null;
return (_lat != null && _lon != null)
? `위도 ${Number(_lat).toFixed(4)}, 경도 ${Number(_lon).toFixed(4)}`
: '';
})(),
lat: incidentCoord?.lat ?? selectedAnalysis?.lat ?? null,
lon: incidentCoord?.lon ?? selectedAnalysis?.lon ?? null,
pollutant: OIL_TYPE_CODE[oilType] || oilType,
spillAmount: `${spillAmount} ${spillUnit}`,
shipName: analysisDetail?.vessels?.[0]?.vesselNm || '',
},
pollution: {
spillAmount: `${spillAmount.toFixed(2)} ${spillUnit}`,
weathered: simulationSummary ? `${simulationSummary.weatheredVolume.toFixed(2)}` : '—',
seaRemain: simulationSummary ? `${simulationSummary.remainingVolume.toFixed(2)}` : '—',
pollutionArea: simulationSummary ? `${simulationSummary.pollutionArea.toFixed(2)} km²` : '—',
coastAttach: simulationSummary ? `${simulationSummary.beachedVolume.toFixed(2)}` : '—',
coastLength: simulationSummary ? `${simulationSummary.pollutionCoastLength.toFixed(2)} km` : '—',
oilType: OIL_TYPE_CODE[oilType] || oilType,
},
weather: (() => {
if (weatherSnapshot) {
return {
windDir: `${weatherSnapshot.wind.directionLabel} ${weatherSnapshot.wind.direction}°`,
windSpeed: `${weatherSnapshot.wind.speed.toFixed(1)} m/s`,
waveHeight: `${weatherSnapshot.wave.height.toFixed(1)} m`,
temp: `${weatherSnapshot.temperature.current.toFixed(1)} °C`,
pressure: `${weatherSnapshot.pressure} hPa`,
visibility: `${weatherSnapshot.visibility} km`,
salinity: `${weatherSnapshot.salinity} PSU`,
waveMaxHeight: `${weatherSnapshot.wave.maxHeight.toFixed(1)} m`,
wavePeriod: `${weatherSnapshot.wave.period} s`,
currentDir: '',
currentSpeed: '',
};
}
if (wx) {
return { windDir: wx.wind, windSpeed: wx.wind, waveHeight: wx.wave, temp: wx.temp };
}
return null;
})(),
spread: (() => {
const fmt = (model: string) => {
const s = summaryByModel[model];
return s ? `${s.pollutionArea.toFixed(2)} km²` : '—';
};
return { kosps: fmt('KOSPS'), openDrift: fmt('OpenDrift'), poseidon: fmt('POSEIDON') };
})(),
coastal: {
firstTime: (() => {
const beachedTimes = oilTrajectory.filter(p => p.stranded === 1).map(p => p.time);
if (beachedTimes.length === 0) return null;
const d = new Date(Math.min(...beachedTimes) * 1000);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
})(),
},
spreadSteps: (() => {
const steps = stepSummariesByModel[windHydrModel] ?? [];
const toRow = (elapsed: string, s: typeof steps[0] | undefined) => ({
elapsed,
weathered: s ? s.weatheredVolume.toFixed(2) : '',
seaRemain: s ? s.remainingVolume.toFixed(2) : '',
coastAttach: s ? s.beachedVolume.toFixed(2) : '',
area: s ? s.pollutionArea.toFixed(2) : '',
});
return [toRow('3시간', steps[3]), toRow('6시간', steps[6])];
})(),
hasSimulation: simulationSummary !== null,
sensitiveResources: sensitiveResourceCategories.length > 0
? sensitiveResourceCategories.map(r => ({
category: r.category,
count: r.count,
totalArea: r.totalArea,
}))
: undefined,
acdntSn: selectedAnalysis?.acdntSn ?? undefined,
mapData: incidentCoord ? {
center: [incidentCoord.lat, incidentCoord.lon],
zoom: 10,
trajectory: oilTrajectory,
currentStep,
centerPoints,
simulationStartTime: accidentTime,
} : null,
};
setOilReportPayload(payload);
setReportGenCategory(0);
navigateToTab('reports', 'generate');
};
return (
<div className="relative flex flex-1 overflow-hidden">
{/* Left Sidebar */}
{activeSubTab === 'analysis' && (
<LeftPanel
selectedAnalysis={selectedAnalysis}
enabledLayers={enabledLayers}
onToggleLayer={handleToggleLayer}
accidentTime={accidentTime}
onAccidentTimeChange={setAccidentTime}
incidentCoord={incidentCoord}
onCoordChange={setIncidentCoord}
isSelectingLocation={isSelectingLocation}
onMapSelectClick={() => setIsSelectingLocation(prev => !prev)}
onRunSimulation={handleRunSimulation}
isRunningSimulation={isRunningSimulation}
selectedModels={selectedModels}
onModelsChange={setSelectedModels}
visibleModels={visibleModels}
onVisibleModelsChange={setVisibleModels}
hasResults={oilTrajectory.length > 0}
predictionTime={predictionTime}
onPredictionTimeChange={setPredictionTime}
spillType={spillType}
onSpillTypeChange={setSpillType}
oilType={oilType}
onOilTypeChange={setOilType}
spillAmount={spillAmount}
onSpillAmountChange={setSpillAmount}
incidentName={incidentName}
onIncidentNameChange={setIncidentName}
spillUnit={spillUnit}
onSpillUnitChange={setSpillUnit}
boomLines={boomLines}
onBoomLinesChange={setBoomLines}
oilTrajectory={oilTrajectory}
algorithmSettings={algorithmSettings}
onAlgorithmSettingsChange={setAlgorithmSettings}
isDrawingBoom={isDrawingBoom}
onDrawingBoomChange={setIsDrawingBoom}
drawingPoints={drawingPoints}
onDrawingPointsChange={setDrawingPoints}
containmentResult={containmentResult}
onContainmentResultChange={setContainmentResult}
layerOpacity={layerOpacity}
onLayerOpacityChange={setLayerOpacity}
layerBrightness={layerBrightness}
onLayerBrightnessChange={setLayerBrightness}
sensitiveResources={sensitiveResourceCategories}
onImageAnalysisResult={handleImageAnalysisResult}
/>
)}
{/* Center - Map/Content Area */}
<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 ?? undefined}
flyToIncident={flyToCoord}
isSelectingLocation={isSelectingLocation || isDrawingBoom || drawAnalysisMode === 'polygon'}
onMapClick={handleMapClick}
oilTrajectory={oilTrajectory.filter(p => visibleModels.has((p.model || 'OpenDrift') as PredictionModel))}
selectedModels={selectedModels}
boomLines={boomLines}
isDrawingBoom={isDrawingBoom}
drawingPoints={drawingPoints}
layerOpacity={layerOpacity}
layerBrightness={layerBrightness}
sensitiveResources={sensitiveResources}
sensitiveResourceGeojson={displayControls.showSensitiveResources ? sensitiveResourceGeojson : null}
lightMode
centerPoints={centerPoints.filter(p => visibleModels.has((p.model || 'OpenDrift') as PredictionModel))}
windData={windData}
hydrData={hydrData}
flyToTarget={flyToTarget}
fitBoundsTarget={fitBoundsTarget}
onIncidentFlyEnd={handleFlyEnd}
drawAnalysisMode={drawAnalysisMode}
analysisPolygonPoints={analysisPolygonPoints}
analysisCircleCenter={analysisCircleCenter}
analysisCircleRadiusM={analysisCircleRadiusM}
externalCurrentTime={oilTrajectory.length > 0 ? currentStep : undefined}
backtrackReplay={isReplayActive && replayShips.length > 0 && incidentCoord ? {
isActive: true,
ships: replayShips,
collisionEvent: collisionEvent ?? null,
replayFrame,
totalFrames: TOTAL_REPLAY_FRAMES,
incidentCoord,
} : 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 (
<div className="absolute bottom-0 left-0 right-0 h-[72px] flex items-center px-5 gap-4" style={{
background: 'rgba(15,21,36,0.95)', backdropFilter: 'blur(16px)',
borderTop: '1px solid var(--bd)',
zIndex: 1100
}}>
{/* 컨트롤 버튼 */}
<div className="flex gap-1 shrink-0">
{[
{ 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) => (
<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={() => {
if (!isPlaying && currentStep >= maxTime) setCurrentStep(timeSteps[0] ?? 0);
setIsPlaying(p => !p);
}} 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: () => { const idx = timeSteps.indexOf(currentStep); if (idx < timeSteps.length - 1) setCurrentStep(timeSteps[idx + 1]); } },
{ icon: '⏭', action: () => { setCurrentStep(maxTime); setIsPlaying(false); } },
].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 className="w-2" />
<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 className="flex-1 flex flex-col gap-1.5">
{/* 동적 시간 라벨 */}
<div className="relative h-4">
{visibleLabels.map(t => {
const pos = maxTime > 0 ? (t / maxTime) * 100 : 0;
const isActive = t === currentStep;
return (
<span key={t} style={{
position: 'absolute', left: `${pos}%`, transform: 'translateX(-50%)',
fontSize: '10px', fontFamily: 'var(--fM)',
color: isActive ? 'var(--cyan)' : 'var(--t3)',
fontWeight: isActive ? 600 : 400, cursor: 'pointer', whiteSpace: 'nowrap'
}} onClick={() => setCurrentStep(t)}>{t}h</span>
)
})}
</div>
{/* 슬라이더 트랙 */}
<div className="relative h-6 flex items-center">
<div
style={{ width: '100%', height: '4px', background: 'var(--bd)', borderRadius: '2px', position: 'relative', cursor: 'pointer' }}
onClick={(e) => {
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);
}}
>
{/* 진행 바 */}
<div style={{
position: 'absolute', top: 0, left: 0,
width: `${progressPct}%`, height: '100%',
background: 'linear-gradient(90deg, var(--cyan), var(--blue))',
borderRadius: '2px', transition: 'width 0.15s'
}} />
{/* 스텝 마커 (각 타임스텝 위치에 틱 표시) */}
{timeSteps.map(t => {
const pos = maxTime > 0 ? (t / maxTime) * 100 : 0;
return (
<div key={`tick-${t}`} style={{
position: 'absolute', width: '2px', height: '10px',
background: t <= currentStep ? 'var(--cyan)' : 'var(--t3)',
top: '-3px', left: `${pos}%`, opacity: 0.6
}} />
);
})}
{/* 방어선 설치 이벤트 마커 */}
{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: `${progressPct}%`, 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)' }}>
+{currentStep}h {(() => {
const base = accidentTime ? new Date(accidentTime) : new Date();
const d = new Date(base.getTime() + currentStep * 3600 * 1000);
return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')} KST`;
})()}
</div>
<div style={{ display: 'flex', gap: '14px' }}>
{(() => {
const stepSummary = stepSummariesByModel[windHydrModel]?.[currentStep] ?? null;
const weatheredVal = stepSummary ? `${stepSummary.weatheredVolume.toFixed(2)}` : '—';
const areaVal = stepSummary ? `${stepSummary.pollutionArea.toFixed(1)} km²` : '—';
return [
{ label: '풍화량', value: weatheredVal },
{ label: '면적', value: areaVal },
{ label: '차단율', value: boomLines.length > 0 ? `${Math.min(95, 70 + Math.round(progressPct * 0.2))}%` : '—', color: 'var(--boom)' },
].map((s, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px' }}>
<span className="text-text-3">{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}
replayTimeRange={replayTimeRange ?? undefined}
/>
)}
</>
)}
</div>
{/* Right Panel */}
{activeSubTab === 'analysis' && (
<RightPanel
onOpenBacktrack={handleOpenBacktrack}
onOpenRecalc={() => {
if (!selectedAnalysis) {
alert('선택된 사고가 없습니다.\n분석 목록에서 사고를 선택해주세요.');
return;
}
setRecalcModalOpen(true);
}}
onOpenReport={handleOpenReport}
detail={analysisDetail}
summary={stepSummariesByModel[windHydrModel]?.[currentStep] ?? summaryByModel[windHydrModel] ?? simulationSummary}
boomBlockedVolume={boomBlockedVolume}
displayControls={displayControls}
onDisplayControlsChange={setDisplayControls}
windHydrModel={windHydrModel}
windHydrModelOptions={windHydrModelOptions}
onWindHydrModelChange={setWindHydrModel}
analysisTab={analysisTab}
onSwitchAnalysisTab={setAnalysisTab}
drawAnalysisMode={drawAnalysisMode}
analysisPolygonPoints={analysisPolygonPoints}
circleRadiusNm={circleRadiusNm}
onCircleRadiusChange={setCircleRadiusNm}
analysisResult={analysisResult}
incidentCoord={incidentCoord}
centerPoints={centerPoints}
predictionTime={predictionTime}
onStartPolygonDraw={handleStartPolygonDraw}
onRunPolygonAnalysis={handleRunPolygonAnalysis}
onRunCircleAnalysis={handleRunCircleAnalysis}
onCancelAnalysis={handleCancelAnalysis}
onClearAnalysis={handleClearAnalysis}
/>
)}
{/* 확산 예측 실행 중 로딩 오버레이 */}
{isRunningSimulation && (
<SimulationLoadingOverlay
status="RUNNING"
progress={simulationProgress}
/>
)}
{/* 확산 예측 에러 팝업 */}
{simulationError && (
<SimulationErrorModal
message={simulationError}
onClose={() => setSimulationError(null)}
/>
)}
{/* 재계산 모달 */}
<RecalcModal
isOpen={recalcModalOpen}
onClose={() => setRecalcModalOpen(false)}
incidentName={selectedAnalysis?.acdntNm || incidentName}
oilType={oilType}
spillAmount={spillAmount}
spillType={spillType}
predictionTime={predictionTime}
incidentCoord={incidentCoord ?? { lat: 0, lon: 0 }}
selectedModels={selectedModels}
onSubmit={(params) => {
setOilType(params.oilType)
setSpillAmount(params.spillAmount)
setSpillType(params.spillType)
setPredictionTime(params.predictionTime)
setIncidentCoord(params.incidentCoord)
setSelectedModels(params.selectedModels)
handleRunSimulation({
models: params.selectedModels,
oilType: params.oilType,
spillAmount: params.spillAmount,
spillType: params.spillType,
predictionTime: params.predictionTime,
incidentCoord: params.incidentCoord,
})
}}
/>
{/* 역추적 모달 */}
<BacktrackModal
isOpen={backtrackModalOpen}
onClose={() => setBacktrackModalOpen(false)}
phase={backtrackPhase}
conditions={backtrackConditions}
vessels={backtrackVessels}
onRunAnalysis={handleRunBacktrackAnalysis}
onStartReplay={handleStartReplay}
/>
</div>
)
}