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

464 lines
15 KiB
TypeScript
Executable File

import { useRef, useEffect, useState } from 'react';
import type {
BacktrackPhase,
BacktrackVessel,
BacktrackConditions,
BacktrackInputConditions,
} from '@common/types/backtrack';
interface BacktrackModalProps {
isOpen: boolean;
onClose: () => void;
phase: BacktrackPhase;
conditions: BacktrackConditions;
vessels: BacktrackVessel[];
onRunAnalysis: (input: BacktrackInputConditions) => void;
onStartReplay: () => void;
}
const toDateTimeLocalValue = (raw: string): string => {
if (!raw) return '';
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())}`;
};
const nowDateTimeLocalValue = (): string => {
const d = new Date();
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 function BacktrackModal({
isOpen,
onClose,
phase,
conditions,
vessels,
onRunAnalysis,
onStartReplay,
}: BacktrackModalProps) {
const backdropRef = useRef<HTMLDivElement>(null);
const [inputTimeOverride, setInputTime] = useState<string | undefined>(undefined);
const inputTime =
inputTimeOverride ??
toDateTimeLocalValue(conditions.estimatedSpillTime) ??
nowDateTimeLocalValue();
const [inputRange, setInputRange] = useState('12');
const [inputRadius, setInputRadius] = useState(10);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (e.target === backdropRef.current) onClose();
};
if (isOpen) document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [isOpen, onClose]);
if (!isOpen) return null;
const inputDisabled = phase !== 'conditions';
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '6px 8px',
background: 'var(--bg-card)',
border: '1px solid var(--bd)',
borderRadius: '6px',
color: 'var(--t1)',
fontSize: '11px',
fontFamily: 'var(--fM)',
outline: 'none',
opacity: inputDisabled ? 0.6 : 1,
};
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: '580px',
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: '20px 24px',
}}
className="border-b border-stroke flex items-center gap-[14px]"
>
<div
style={{
width: '40px',
height: '40px',
borderRadius: '10px',
background: 'linear-gradient(135deg, rgba(168,85,247,0.2), rgba(6,182,212,0.2))',
border: '1px solid rgba(168,85,247,0.3)',
fontSize: '18px',
}}
className="flex items-center justify-center"
>
🔍
</div>
<div className="flex-1">
<h2 className="text-base font-bold m-0"> </h2>
<div className="text-label-2 text-fg-disabled mt-[2px]">
AIS
</div>
</div>
<button
onClick={onClose}
style={{
width: '32px',
height: '32px',
borderRadius: '8px',
background: 'var(--bg-card)',
fontSize: '14px',
}}
className="border border-stroke text-fg-disabled cursor-pointer flex items-center justify-center"
>
</button>
</div>
{/* Scrollable Content */}
<div
style={{
padding: '20px 24px',
}}
className="flex-1 overflow-y-auto flex flex-col gap-4"
>
{/* Analysis Conditions */}
<div>
<h3 className="text-label-1 font-bold text-fg-sub mb-[10px]"> </h3>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px',
}}
>
{/* 유출 추정 시각 */}
<div
style={{
padding: '10px 12px',
background: 'var(--bg-card)',
borderRadius: '8px',
}}
className="border border-stroke"
>
<div className="text-caption text-fg-disabled mb-1"> </div>
<input
type="datetime-local"
value={inputTime}
onChange={(e) => setInputTime(e.target.value)}
disabled={inputDisabled}
style={inputStyle}
/>
</div>
{/* 분석 범위 */}
<div
style={{
padding: '10px 12px',
background: 'var(--bg-card)',
borderRadius: '8px',
}}
className="border border-stroke"
>
<div className="text-caption text-fg-disabled mb-1"> </div>
<select
value={inputRange}
onChange={(e) => setInputRange(e.target.value)}
disabled={inputDisabled}
style={inputStyle}
>
<option value="6">±6</option>
<option value="12">±12</option>
<option value="24">±24</option>
</select>
</div>
{/* 탐색 반경 */}
<div
style={{
padding: '10px 12px',
background: 'var(--bg-card)',
borderRadius: '8px',
}}
className="border border-stroke"
>
<div className="text-caption text-fg-disabled mb-1"> </div>
<div className="flex items-center gap-1">
<input
type="number"
value={inputRadius}
onChange={(e) => setInputRadius(Number(e.target.value))}
disabled={inputDisabled}
min={1}
max={100}
step={0.5}
style={{ ...inputStyle, flex: 1 }}
/>
<span className="text-caption text-fg-disabled shrink-0">NM</span>
</div>
</div>
{/* 유출 위치 */}
<div
style={{
padding: '10px 12px',
background: 'var(--bg-card)',
borderRadius: '8px',
}}
className="border border-stroke"
>
<div className="text-caption text-fg-disabled mb-1"> </div>
<div className="text-label-1 font-semibold font-mono">
{conditions.spillLocation.lat.toFixed(4)}°N,{' '}
{conditions.spillLocation.lon.toFixed(4)}°E
</div>
</div>
{/* 분석 대상 선박 */}
<div
style={{
padding: '10px 12px',
background: 'var(--bg-card)',
border: '1px solid rgba(168,85,247,0.3)',
borderRadius: '8px',
gridColumn: '1 / -1',
}}
>
<div className="text-caption text-fg-disabled mb-1"> </div>
<div className="text-sm font-bold text-color-tertiary font-mono">
{conditions.totalVessels}{' '}
<span className="text-caption font-medium text-fg-disabled">(AIS )</span>
</div>
</div>
</div>
</div>
{/* Results */}
{phase === 'results' && vessels.length > 0 && (
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-label-1 font-bold text-fg-sub m-0"> </h3>
<div
style={{
padding: '4px 10px',
borderRadius: '12px',
background: 'rgba(239,68,68,0.1)',
border: '1px solid rgba(239,68,68,0.3)',
}}
className="text-caption font-bold text-color-danger"
>
{conditions.totalVessels} {vessels.length}
</div>
</div>
<div className="flex flex-col gap-2.5">
{vessels.map((v) => (
<VesselCard key={v.imo} vessel={v} />
))}
</div>
</div>
)}
</div>
{/* Footer */}
<div
style={{
padding: '16px 24px',
}}
className="border-t border-stroke flex gap-2"
>
{phase === 'conditions' && (
<button
onClick={() =>
onRunAnalysis({
estimatedSpillTime: inputTime,
analysisRange: inputRange,
searchRadius: inputRadius,
})
}
style={{
padding: '12px',
borderRadius: '8px',
background: 'linear-gradient(135deg, var(--color-tertiary), var(--color-accent))',
border: 'none',
color: '#fff',
}}
className="flex-1 text-title-4 font-bold cursor-pointer"
>
🔍
</button>
)}
{phase === 'analyzing' && (
<button
disabled
style={{
padding: '12px',
borderRadius: '8px',
background: 'var(--bg-card)',
color: 'var(--color-tertiary)',
cursor: 'wait',
}}
className="flex-1 text-title-4 font-bold border border-stroke"
>
AIS ...
</button>
)}
{phase === 'results' && (
<button
onClick={onStartReplay}
style={{
padding: '12px',
borderRadius: '8px',
background: 'linear-gradient(135deg, var(--color-tertiary), var(--color-accent))',
border: 'none',
color: '#fff',
}}
className="flex-1 text-title-4 font-bold cursor-pointer"
>
🗺
</button>
)}
</div>
</div>
</div>
);
}
function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
const probColor =
vessel.probability >= 80
? 'var(--color-danger)'
: vessel.probability >= 20
? 'var(--color-warning)'
: 'var(--fg-disabled)';
return (
<div
style={{
padding: '14px',
background: 'var(--bg-base)',
borderLeft: `4px solid ${vessel.color}`,
borderRadius: '10px',
}}
className="border border-stroke"
>
{/* Header row */}
<div className="flex items-center gap-[10px] mb-[10px]">
<div
style={{
width: '28px',
height: '28px',
borderRadius: '50%',
background: `${vessel.color}20`,
border: `2px solid ${vessel.color}`,
fontSize: '12px',
fontWeight: 800,
color: vessel.color,
}}
className="flex items-center justify-center font-mono"
>
{vessel.rank}
</div>
<div className="flex-1">
<div className="text-title-4 font-bold font-mono">{vessel.name}</div>
<div className="text-caption text-fg-disabled font-mono mt-[2px]">
IMO: {vessel.imo} · {vessel.type} · {vessel.flag}
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div
style={{ fontSize: '22px', color: probColor, lineHeight: 1 }}
className="font-bold font-mono"
>
{vessel.probability}%
</div>
<div className="text-caption text-fg-disabled"> </div>
</div>
</div>
{/* Stats grid */}
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr 1fr',
gap: '6px',
marginBottom: vessel.description ? '8px' : 0,
}}
>
{[
{ label: '최근접 시각', value: vessel.closestTime },
{ label: '최근접 거리', value: `${vessel.closestDistance} NM` },
{
label: '속도 변화',
value: vessel.speedChange,
highlight: vessel.speedChange === '급감속',
},
{
label: 'AIS 상태',
value: vessel.aisStatus,
highlight: vessel.aisStatus === '충돌신호',
},
].map((s, i) => (
<div
key={i}
style={{
padding: '6px',
background: 'var(--bg-card)',
borderRadius: '6px',
border: s.highlight
? '1px solid rgba(239,68,68,0.3)'
: '1px solid var(--stroke-default)',
}}
>
<div className="text-caption text-fg-disabled mb-[2px]">{s.label}</div>
<div
style={{
color: s.highlight ? 'var(--color-danger)' : 'var(--fg-default)',
}}
className="text-caption font-semibold font-mono"
>
{s.value}
</div>
</div>
))}
</div>
{/* Description */}
{vessel.description && (
<div
style={{
padding: '8px 10px',
background: 'rgba(239,68,68,0.05)',
border: '1px solid rgba(239,68,68,0.15)',
borderRadius: '6px',
lineHeight: '1.5',
}}
className="text-caption text-fg-sub"
>
{vessel.description}
</div>
)}
</div>
);
}