412 lines
13 KiB
TypeScript
Executable File
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>
|
|
);
|
|
}
|