- 백엔드: backtrackAnalysisService 신규 개발 * AIS 기반 선박 항적 API 연동 및 공간 조회 * 공간(40%)/시간(25%)/행동(20%)/선박유형(15%) 가중치 위험도 점수 산정 * 상위 5척 리플레이 데이터 및 충돌 이벤트 생성 * Python 서버 미연동 시 폴백 메커니즘 제공 - 백엔드: 역추적 생성 시 동기 분석 → BacktrackResult 즉시 반환 - 프론트엔드: 모달에서 유출 시각/분석 범위/탐색 반경 직접 입력 가능 - 프론트엔드: 리플레이 바에 실제 분석 시간 범위 동적 표시 - DB: AIS_TRACK 테이블 신규 생성 (선박 항적 이력 + GIS 인덱스)
366 lines
13 KiB
TypeScript
Executable File
366 lines
13 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(--bg3)',
|
|
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(--bg1)',
|
|
borderRadius: '14px',
|
|
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
|
}} className="border border-border overflow-hidden flex flex-col">
|
|
{/* Header */}
|
|
<div style={{
|
|
padding: '20px 24px',
|
|
}} className="border-b border-border 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-[11px] text-text-3 mt-[2px]">
|
|
AIS 항적 기반 유출 선박 추정
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
style={{
|
|
width: '32px', height: '32px', borderRadius: '8px',
|
|
background: 'var(--bg3)',
|
|
fontSize: '14px',
|
|
}}
|
|
className="border border-border text-text-3 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-[12px] font-bold text-text-2 mb-[10px]">
|
|
분석 조건
|
|
</h3>
|
|
<div style={{
|
|
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px',
|
|
}}>
|
|
{/* 유출 추정 시각 */}
|
|
<div style={{
|
|
padding: '10px 12px', background: 'var(--bg3)',
|
|
borderRadius: '8px',
|
|
}} className="border border-border">
|
|
<div className="text-[9px] text-text-3 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(--bg3)',
|
|
borderRadius: '8px',
|
|
}} className="border border-border">
|
|
<div className="text-[9px] text-text-3 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(--bg3)',
|
|
borderRadius: '8px',
|
|
}} className="border border-border">
|
|
<div className="text-[9px] text-text-3 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-[10px] text-text-3 shrink-0">NM</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 유출 위치 */}
|
|
<div style={{
|
|
padding: '10px 12px', background: 'var(--bg3)',
|
|
borderRadius: '8px',
|
|
}} className="border border-border">
|
|
<div className="text-[9px] text-text-3 mb-1">
|
|
유출 위치
|
|
</div>
|
|
<div className="text-[12px] 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(--bg3)',
|
|
border: '1px solid rgba(168,85,247,0.3)', borderRadius: '8px',
|
|
gridColumn: '1 / -1',
|
|
}}>
|
|
<div className="text-[9px] text-text-3 mb-1">
|
|
분석 대상 선박
|
|
</div>
|
|
<div className="text-sm font-bold text-primary-purple font-mono">
|
|
{conditions.totalVessels}척 <span className="text-[10px] font-medium text-text-3">(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-[12px] font-bold text-text-2 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-[10px] font-bold text-status-red">
|
|
{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-border flex gap-2">
|
|
{phase === 'conditions' && (
|
|
<button
|
|
onClick={() => onRunAnalysis({ estimatedSpillTime: inputTime, analysisRange: inputRange, searchRadius: inputRadius })}
|
|
style={{
|
|
padding: '12px',
|
|
borderRadius: '8px',
|
|
background: 'linear-gradient(135deg, var(--purple), var(--cyan))',
|
|
border: 'none', color: '#fff',
|
|
}}
|
|
className="flex-1 text-[13px] font-bold cursor-pointer"
|
|
>
|
|
🔍 역추적 분석 실행
|
|
</button>
|
|
)}
|
|
{phase === 'analyzing' && (
|
|
<button
|
|
disabled
|
|
style={{
|
|
padding: '12px',
|
|
borderRadius: '8px',
|
|
background: 'var(--bg3)',
|
|
color: 'var(--purple)', cursor: 'wait',
|
|
}}
|
|
className="flex-1 text-[13px] font-bold border border-border"
|
|
>
|
|
⏳ AIS 항적 분석중...
|
|
</button>
|
|
)}
|
|
{phase === 'results' && (
|
|
<button
|
|
onClick={onStartReplay}
|
|
style={{
|
|
padding: '12px',
|
|
borderRadius: '8px',
|
|
background: 'linear-gradient(135deg, var(--purple), var(--cyan))',
|
|
border: 'none', color: '#fff',
|
|
}}
|
|
className="flex-1 text-[13px] font-bold cursor-pointer"
|
|
>
|
|
🗺 지도에서 리플레이 보기
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
|
|
const probColor = vessel.probability >= 80 ? 'var(--red)' :
|
|
vessel.probability >= 20 ? 'var(--orange)' : 'var(--t3)'
|
|
|
|
return (
|
|
<div style={{
|
|
padding: '14px', background: 'var(--bg0)',
|
|
borderLeft: `4px solid ${vessel.color}`,
|
|
borderRadius: '10px',
|
|
}} className="border border-border">
|
|
{/* 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-[13px] font-bold font-mono">
|
|
{vessel.name}
|
|
</div>
|
|
<div className="text-[9px] text-text-3 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-[8px] text-text-3">유출 확률</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(--bg3)', borderRadius: '6px',
|
|
border: s.highlight ? '1px solid rgba(239,68,68,0.3)' : '1px solid var(--bd)',
|
|
}}>
|
|
<div className="text-[8px] text-text-3 mb-[2px]">
|
|
{s.label}
|
|
</div>
|
|
<div style={{
|
|
color: s.highlight ? 'var(--red)' : 'var(--t1)',
|
|
}} className="text-[10px] 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-[9px] text-text-2">
|
|
{vessel.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|