- 사고별 이미지 분석 API 및 항공 미디어 조회 연동 - 사고 마커 팝업 디자인 개선, 필터링된 사고만 지도 표시 - 이미지 분석 시 사고명 파라미터 지원, 기본 예측시간 6시간으로 변경 - 유출량 정밀도 NUMERIC(14,10) 확대 (migration 031) - OpenDrift 유종 매핑 수정 (원유, 등유)
1750 lines
68 KiB
TypeScript
Executable File
1750 lines
68 KiB
TypeScript
Executable File
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 [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)} m³` : '—',
|
||
seaRemain: simulationSummary ? `${simulationSummary.remainingVolume.toFixed(2)} m³` : '—',
|
||
pollutionArea: simulationSummary
|
||
? `${simulationSummary.pollutionArea.toFixed(2)} km²`
|
||
: '—',
|
||
coastAttach: simulationSummary ? `${simulationSummary.beachedVolume.toFixed(2)} m³` : '—',
|
||
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={(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}
|
||
/>
|
||
)}
|
||
|
||
{/* 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}
|
||
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: '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(--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: '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(--stroke-default)',
|
||
background: 'var(--bg-card)',
|
||
color: 'var(--fg-sub)',
|
||
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(--stroke-default)',
|
||
background: 'var(--bg-card)',
|
||
color: 'var(--fg-sub)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
cursor: 'pointer',
|
||
fontSize: '11px',
|
||
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: '10px',
|
||
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: '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(--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: '14px',
|
||
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)} m³`
|
||
: '—';
|
||
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: '11px',
|
||
}}
|
||
>
|
||
<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' && (
|
||
<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>
|
||
);
|
||
}
|