wing-ops/frontend/src/tabs/prediction/components/OilSpillView.tsx

1798 lines
70 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,
BackwardParticleStep,
} 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 [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
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 [validationErrors, setValidationErrors] = useState<Set<string>>(new Set());
const [selectedModels, setSelectedModels] = useState<Set<PredictionModel>>(
new Set(['OpenDrift']),
);
const [visibleModels, setVisibleModels] = useState<Set<PredictionModel>>(new Set(['OpenDrift']));
const [predictionTime, setPredictionTime] = useState(6);
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 [layerColors, setLayerColors] = useState<Record<string, string>>({});
// 표시 정보 제어
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 [backwardParticles, setBackwardParticles] = useState<BackwardParticleStep[]>([]);
// 재계산 상태
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 });
}
setBackwardParticles(
Array.isArray(rslt['backwardParticles'])
? (rslt['backwardParticles'] as BackwardParticleStep[])
: [],
);
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 });
setBackwardParticles(
Array.isArray(rslt['backwardParticles'])
? (rslt['backwardParticles'] as BackwardParticleStep[])
: [],
);
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) || 6);
// 모델 상태에 따라 선택 모델 설정
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) || 6,
);
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(20)));
setSpillUnit('kL');
setSelectedAnalysis({
acdntSn: result.acdntSn,
acdntNm: '',
occurredAt: result.occurredAt,
analysisDate: '',
requestor: '',
duration: '6',
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;
const effectiveModels = overrides?.models ?? selectedModels;
// ── 입력 유효성 검증 (border 하이라이트) ──
const errors = new Set<string>();
if (isDirectInput) {
if (!incidentName.trim()) errors.add('incidentName');
if (!accidentTime) errors.add('accidentTime');
if (!effectiveCoord || (effectiveCoord.lat === 0 && effectiveCoord.lon === 0))
errors.add('coord');
} else if (!existingAcdntSn) {
errors.add('incidentName');
}
if (!effectiveCoord) errors.add('coord');
if (effectiveModels.size === 0) errors.add('models');
if (errors.size > 0) {
setValidationErrors(errors);
return;
}
setValidationErrors(new Set());
const coord = effectiveCoord!; // 검증 통과 후 non-null 보장
const effectiveOilType = overrides?.oilType ?? oilType;
const effectiveSpillAmount = overrides?.spillAmount ?? spillAmount;
const effectiveSpillType = overrides?.spillType ?? spillType;
const effectivePredictionTime = overrides?.predictionTime ?? predictionTime;
setIsRunningSimulation(true);
setSimulationSummary(null);
startProgressTimer(effectivePredictionTime);
let simulationSucceeded = false;
try {
const payload: Record<string, unknown> = {
acdntSn: existingAcdntSn,
lat: coord.lat,
lon: coord.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: coord.lat,
lon: coord.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, coord, algorithmSettings);
setBoomLines(booms);
setSensitiveResources([]);
setCurrentStep(0);
setIsPlaying(true);
setFlyToCoord({ lon: coord.lon, lat: coord.lat });
}
if (errors.length > 0 && merged.length === 0) {
setSimulationError(errors.join('; '));
} else {
simulationSucceeded = true;
const effectiveAcdntSn = data.acdntSn ?? selectedAnalysis?.acdntSn;
if (coord) {
fetchWeatherSnapshotForCoord(coord.lat, coord.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' && (
<div className="shrink-0 overflow-hidden" style={{ width: leftCollapsed ? 0 : 320 }}>
<LeftPanel
selectedAnalysis={selectedAnalysis}
enabledLayers={enabledLayers}
onToggleLayer={handleToggleLayer}
accidentTime={accidentTime}
onAccidentTimeChange={(v) => {
setAccidentTime(v);
setValidationErrors((prev) => {
const n = new Set(prev);
n.delete('accidentTime');
return n;
});
}}
incidentCoord={incidentCoord}
onCoordChange={(v) => {
setIncidentCoord(v);
setValidationErrors((prev) => {
const n = new Set(prev);
n.delete('coord');
return n;
});
}}
isSelectingLocation={isSelectingLocation}
onMapSelectClick={() => setIsSelectingLocation((prev) => !prev)}
onRunSimulation={handleRunSimulation}
isRunningSimulation={isRunningSimulation}
selectedModels={selectedModels}
onModelsChange={(v) => {
setSelectedModels(v);
setValidationErrors((prev) => {
const n = new Set(prev);
n.delete('models');
return n;
});
}}
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={(v) => {
setIncidentName(v);
setValidationErrors((prev) => {
const n = new Set(prev);
n.delete('incidentName');
return n;
});
}}
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}
layerColors={layerColors}
onLayerColorChange={(id, color) => setLayerColors((prev) => ({ ...prev, [id]: color }))}
sensitiveResources={sensitiveResourceCategories}
onImageAnalysisResult={handleImageAnalysisResult}
validationErrors={validationErrors}
/>
</div>
)}
{/* Center - Map/Content Area */}
<div className="flex-1 relative overflow-hidden">
{/* Left panel toggle button */}
{activeSubTab === 'analysis' && (
<button
onClick={() => setLeftCollapsed((v) => !v)}
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
style={{
left: 0,
width: 18,
height: 40,
background: 'var(--bg-elevated)',
border: '1px solid var(--stroke-default)',
borderLeft: 'none',
borderRadius: '0 6px 6px 0',
color: 'var(--fg-sub)',
cursor: 'pointer',
}}
>
{leftCollapsed ? '▶' : '◀'}
</button>
)}
{/* Right panel toggle button */}
{activeSubTab === 'analysis' && (
<button
onClick={() => setRightCollapsed((v) => !v)}
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
style={{
right: 0,
width: 18,
height: 40,
background: 'var(--bg-elevated)',
border: '1px solid var(--stroke-default)',
borderRight: 'none',
borderRadius: '6px 0 0 6px',
color: 'var(--fg-sub)',
cursor: 'pointer',
}}
>
{rightCollapsed ? '◀' : '▶'}
</button>
)}
{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}
layerColors={layerColors}
sensitiveResources={sensitiveResources}
sensitiveResourceGeojson={
displayControls.showSensitiveResources ? sensitiveResourceGeojson : null
}
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,
backwardParticles,
}
: 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: 'var(--bg-surface)',
backdropFilter: 'blur(16px)',
borderTop: '1px solid var(--stroke-default)',
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(--stroke-default)',
background: 'var(--bg-card)',
color: 'var(--fg-sub)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: 'var(--font-size-body-2)',
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(--color-accent)'
: '1px solid var(--stroke-default)',
background: isPlaying ? 'var(--color-accent)' : 'var(--bg-card)',
color: isPlaying ? 'var(--bg-base)' : 'var(--fg-sub)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: 'var(--font-size-body-2)',
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(--stroke-default)',
background: 'var(--bg-card)',
color: 'var(--fg-sub)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: 'var(--font-size-caption)',
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(--stroke-default)',
background: 'var(--bg-card)',
color: 'var(--fg-sub)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: 'var(--font-size-caption)',
fontWeight: 600,
fontFamily: 'var(--font-mono)',
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: 'var(--font-size-caption)',
fontFamily: 'var(--font-mono)',
color: isActive ? 'var(--color-accent)' : 'var(--fg-disabled)',
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(--stroke-default)',
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(--color-accent), var(--color-info))',
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(--color-accent)' : 'var(--fg-disabled)',
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: 'var(--font-size-caption)',
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(--color-accent)',
border: '3px solid var(--bg-base)',
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: 'var(--font-size-body-2)',
fontWeight: 600,
color: 'var(--color-accent)',
fontFamily: 'var(--font-mono)',
}}
>
+{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(--color-boom)',
},
].map((s, i) => (
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
gap: '5px',
fontSize: 'var(--font-size-caption)',
}}
>
<span className="text-fg-disabled">{s.label}</span>
<span
style={{
color: s.color,
fontWeight: 600,
fontFamily: 'var(--font-mono)',
}}
>
{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}
hasBackwardParticles={backwardParticles.length > 0}
/>
)}
</>
)}
</div>
{/* Right Panel */}
{activeSubTab === 'analysis' && (
<div className="shrink-0 overflow-hidden" style={{ width: rightCollapsed ? 0 : 300 }}>
<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}
/>
</div>
)}
{/* 확산 예측 실행 중 로딩 오버레이 */}
{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>
);
}