463 lines
15 KiB
TypeScript
Executable File
463 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: 'var(--font-size-caption)',
|
|
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)',
|
|
}}
|
|
className="flex items-center justify-center text-title-1"
|
|
>
|
|
🔍
|
|
</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: 'var(--font-size-body-2)',
|
|
}}
|
|
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-body-2 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: 'var(--font-size-caption)',
|
|
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>
|
|
);
|
|
}
|