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

412 lines
13 KiB
TypeScript
Executable File

import { useState, useRef, useEffect } from 'react';
import type { PredictionModel } from './OilSpillView';
interface RecalcModalProps {
isOpen: boolean;
onClose: () => void;
incidentName: string;
oilType: string;
spillAmount: number;
spillType: string;
predictionTime: number;
incidentCoord: { lat: number; lon: number };
selectedModels: Set<PredictionModel>;
onSubmit: (params: {
oilType: string;
spillAmount: number;
spillType: string;
predictionTime: number;
incidentCoord: { lat: number; lon: number };
selectedModels: Set<PredictionModel>;
}) => void;
}
type RecalcPhase = 'editing' | 'running' | 'done';
const OIL_TYPES = [
'벙커C유',
'원유(중질)',
'원유(경질)',
'디젤유(경유)',
'휘발유',
'등유',
'윤활유',
'HFO 380',
'HFO 180',
];
const SPILL_TYPES = ['연속', '순간', '점진적'];
const PREDICTION_TIMES = [6, 12, 24, 48, 72, 96, 120];
const snapToValidTime = (t: number): number =>
PREDICTION_TIMES.includes(t) ? t : (PREDICTION_TIMES.find((h) => h >= t) ?? PREDICTION_TIMES[0]);
export function RecalcModal({
isOpen,
onClose,
incidentName,
oilType: initOilType,
spillAmount: initSpillAmount,
spillType: initSpillType,
predictionTime: initPredictionTime,
incidentCoord: initCoord,
selectedModels: initModels,
onSubmit,
}: RecalcModalProps) {
const backdropRef = useRef<HTMLDivElement>(null);
const [oilType, setOilType] = useState(initOilType);
const [spillAmount, setSpillAmount] = useState(initSpillAmount);
const [spillUnit, setSpillUnit] = useState<'kl' | 'ton' | 'bbl'>('kl');
const [spillType, setSpillType] = useState(initSpillType);
const [predictionTime, setPredictionTime] = useState(() => snapToValidTime(initPredictionTime));
const [lat, setLat] = useState(initCoord.lat);
const [lon, setLon] = useState(initCoord.lon);
const [models, setModels] = useState<Set<PredictionModel>>(new Set(initModels));
const [phase, setPhase] = useState<RecalcPhase>('editing');
// Sync when modal opens
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
if (isOpen) {
setOilType(initOilType);
setSpillAmount(initSpillAmount);
setSpillType(initSpillType);
setPredictionTime(snapToValidTime(initPredictionTime));
setLat(initCoord.lat);
setLon(initCoord.lon);
setModels(new Set(initModels));
setPhase('editing');
}
}, [
isOpen,
initOilType,
initSpillAmount,
initSpillType,
initPredictionTime,
initCoord.lat,
initCoord.lon,
initModels,
]);
/* eslint-enable react-hooks/set-state-in-effect */
useEffect(() => {
const handler = (e: MouseEvent) => {
if (e.target === backdropRef.current) onClose();
};
if (isOpen) document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [isOpen, onClose]);
const toggleModel = (model: PredictionModel) => {
setModels((prev) => {
const next = new Set(prev);
if (next.has(model)) next.delete(model);
else next.add(model);
return next;
});
};
const handleRun = () => {
setPhase('running');
setTimeout(() => {
setPhase('done');
setTimeout(() => {
onSubmit({
oilType,
spillAmount,
spillType,
predictionTime,
incidentCoord: { lat, lon },
selectedModels: models,
});
onClose();
}, 1000);
}, 2500);
};
if (!isOpen) return null;
return (
<div
ref={backdropRef}
style={{
inset: 0,
background: 'rgba(0,0,0,0.55)',
backdropFilter: 'blur(4px)',
}}
className="fixed z-[9999] flex items-center justify-center"
>
<div
style={{
width: '380px',
maxHeight: 'calc(100vh - 120px)',
background: 'var(--bg-surface)',
borderRadius: '14px',
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
}}
className="border border-stroke overflow-hidden flex flex-col"
>
{/* Header */}
<div
style={{
padding: '16px 20px',
}}
className="border-b border-stroke flex items-center gap-3"
>
<div
style={{
width: '36px',
height: '36px',
borderRadius: '10px',
background: 'linear-gradient(135deg, rgba(249,115,22,0.2), rgba(6,182,212,0.2))',
border: '1px solid rgba(249,115,22,0.3)',
fontSize: '16px',
}}
className="flex items-center justify-center"
>
🔄
</div>
<div className="flex-1">
<h2 className="text-subtitle font-bold m-0"> </h2>
<div className="text-caption text-fg-disabled mt-[2px]">
·
</div>
</div>
<button
onClick={onClose}
style={{
width: '28px',
height: '28px',
borderRadius: '6px',
background: 'var(--bg-card)',
fontSize: '12px',
}}
className="border border-stroke text-fg-disabled cursor-pointer flex items-center justify-center"
>
</button>
</div>
{/* Scrollable Content */}
<div
style={{
padding: '16px 20px',
}}
className="flex-1 overflow-y-auto flex flex-col gap-[14px]"
>
{/* 현재 분석 정보 */}
<div
style={{
padding: '10px 12px',
background: 'rgba(6,182,212,0.04)',
border: '1px solid rgba(6,182,212,0.15)',
borderRadius: '8px',
}}
>
<div className="text-caption font-bold text-color-accent mb-1.5"> </div>
<div
style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px' }}
className="text-caption"
>
<InfoItem label="사고명" value={incidentName} />
<InfoItem label="유종" value={initOilType} />
<InfoItem label="유출량" value={`${initSpillAmount} kl`} />
<InfoItem label="예측시간" value={`${initPredictionTime}시간`} />
</div>
</div>
{/* 유종 */}
<FieldGroup label="유종">
<select className="prd-i" value={oilType} onChange={(e) => setOilType(e.target.value)}>
{OIL_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</FieldGroup>
{/* 유출량 */}
<FieldGroup label="유출량">
<div className="flex gap-1.5">
<input
type="number"
className="prd-i flex-1"
value={spillAmount}
onChange={(e) => setSpillAmount(Number(e.target.value))}
/>
<select
className="prd-i"
value={spillUnit}
onChange={(e) => setSpillUnit(e.target.value as 'kl' | 'ton' | 'bbl')}
style={{ width: '70px' }}
>
<option value="kl">kl</option>
<option value="ton">ton</option>
<option value="bbl">bbl</option>
</select>
</div>
</FieldGroup>
{/* 유출 형태 */}
<FieldGroup label="유출 형태">
<select
className="prd-i"
value={spillType}
onChange={(e) => setSpillType(e.target.value)}
>
{SPILL_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</FieldGroup>
{/* 예측 시간 */}
<FieldGroup label="예측 시간">
<select
className="prd-i"
value={predictionTime}
onChange={(e) => setPredictionTime(Number(e.target.value))}
>
{PREDICTION_TIMES.map((h) => (
<option key={h} value={h}>
{h}
</option>
))}
</select>
</FieldGroup>
{/* 유출 위치 */}
<FieldGroup label="유출 위치 (좌표)">
<div className="flex gap-1.5">
<div className="flex-1">
<div className="text-caption text-fg-disabled mb-[3px]"> (N)</div>
<input
type="number"
className="prd-i font-mono"
value={lat}
step={0.0001}
onChange={(e) => setLat(Number(e.target.value))}
/>
</div>
<div className="flex-1">
<div className="text-caption text-fg-disabled mb-[3px]"> (E)</div>
<input
type="number"
className="prd-i font-mono"
value={lon}
step={0.0001}
onChange={(e) => setLon(Number(e.target.value))}
/>
</div>
</div>
</FieldGroup>
{/* 모델 선택 */}
<FieldGroup label="예측 모델 선택">
<div className="flex flex-wrap gap-1.5">
{[
{ model: 'KOSPS' as PredictionModel, color: 'var(--color-accent)', ready: false },
{ model: 'POSEIDON' as PredictionModel, color: 'var(--color-danger)', ready: true },
{ model: 'OpenDrift' as PredictionModel, color: 'var(--color-info)', ready: true },
].map(({ model, color, ready }) => (
<button
key={model}
className={`prd-mc ${models.has(model) ? 'on' : ''}`}
onClick={() => {
if (!ready) {
alert(`${model} 모델은 현재 준비중입니다.`);
return;
}
toggleModel(model);
}}
style={{ fontSize: '11px', padding: '5px 10px' }}
>
<span className="prd-md" style={{ background: color }} />
{model}
</button>
))}
</div>
</FieldGroup>
</div>
{/* Footer */}
<div
style={{
padding: '14px 20px',
}}
className="border-t border-stroke flex gap-2"
>
<button
onClick={onClose}
disabled={phase !== 'editing'}
style={{
padding: '10px',
borderRadius: '8px',
background: 'var(--bg-card)',
opacity: phase !== 'editing' ? 0.5 : 1,
}}
className="flex-1 text-label-1 font-semibold border border-stroke text-fg-sub cursor-pointer"
>
</button>
<button
onClick={handleRun}
disabled={phase !== 'editing' || models.size === 0}
style={{
padding: '10px',
borderRadius: '8px',
cursor: phase === 'editing' ? 'pointer' : 'wait',
background:
phase === 'done'
? 'rgba(34,197,94,0.15)'
: phase === 'running'
? 'var(--bg-card)'
: 'linear-gradient(135deg, var(--color-warning), var(--color-accent))',
border:
phase === 'done'
? '1px solid rgba(34,197,94,0.4)'
: phase === 'running'
? '1px solid var(--stroke-default)'
: 'none',
color:
phase === 'done'
? 'var(--color-success)'
: phase === 'running'
? 'var(--color-warning)'
: '#fff',
opacity: models.size === 0 && phase === 'editing' ? 0.5 : 1,
}}
className="flex-[2] text-label-1 font-bold"
>
{phase === 'done'
? '✅ 재계산 완료!'
: phase === 'running'
? '⏳ 재계산 실행중...'
: '🔄 재계산 실행'}
</button>
</div>
</div>
</div>
);
}
function FieldGroup({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<div className="text-caption font-bold text-fg-sub mb-1.5">{label}</div>
{children}
</div>
);
}
function InfoItem({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between py-[2px]">
<span className="text-fg-disabled">{label}</span>
<span className="font-semibold font-mono">{value}</span>
</div>
);
}