- 백엔드: backtrackAnalysisService 신규 개발 * AIS 기반 선박 항적 API 연동 및 공간 조회 * 공간(40%)/시간(25%)/행동(20%)/선박유형(15%) 가중치 위험도 점수 산정 * 상위 5척 리플레이 데이터 및 충돌 이벤트 생성 * Python 서버 미연동 시 폴백 메커니즘 제공 - 백엔드: 역추적 생성 시 동기 분석 → BacktrackResult 즉시 반환 - 프론트엔드: 모달에서 유출 시각/분석 범위/탐색 반경 직접 입력 가능 - 프론트엔드: 리플레이 바에 실제 분석 시간 범위 동적 표시 - DB: AIS_TRACK 테이블 신규 생성 (선박 항적 이력 + GIS 인덱스)
213 lines
7.0 KiB
TypeScript
Executable File
213 lines
7.0 KiB
TypeScript
Executable File
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
||
|
||
interface BacktrackReplayBarProps {
|
||
isPlaying: boolean
|
||
replayFrame: number
|
||
totalFrames: number
|
||
replaySpeed: number
|
||
onTogglePlay: () => void
|
||
onSeek: (frame: number) => void
|
||
onSpeedChange: (speed: number) => void
|
||
onClose: () => void
|
||
replayShips: ReplayShip[]
|
||
collisionEvent: CollisionEvent | null
|
||
replayTimeRange?: { start: string; end: string }
|
||
}
|
||
|
||
export function BacktrackReplayBar({
|
||
isPlaying,
|
||
replayFrame,
|
||
totalFrames,
|
||
replaySpeed,
|
||
onTogglePlay,
|
||
onSeek,
|
||
onSpeedChange,
|
||
onClose,
|
||
replayShips,
|
||
collisionEvent,
|
||
replayTimeRange,
|
||
}: BacktrackReplayBarProps) {
|
||
const progress = (replayFrame / totalFrames) * 100
|
||
|
||
// 타임 계산
|
||
let startLabel: string
|
||
let endLabel: string
|
||
let currentTimeLabel: string
|
||
|
||
if (replayTimeRange) {
|
||
const startMs = new Date(replayTimeRange.start).getTime()
|
||
const endMs = new Date(replayTimeRange.end).getTime()
|
||
const currentMs = startMs + (replayFrame / totalFrames) * (endMs - startMs)
|
||
const fmt = (ms: number) => {
|
||
const d = new Date(ms + 9 * 3600000) // KST
|
||
const mo = String(d.getUTCMonth() + 1).padStart(2, '0')
|
||
const day = String(d.getUTCDate()).padStart(2, '0')
|
||
const hh = String(d.getUTCHours()).padStart(2, '0')
|
||
const mm = String(d.getUTCMinutes()).padStart(2, '0')
|
||
return { date: `${mo}-${day}`, time: `${hh}:${mm}` }
|
||
}
|
||
const startFmt = fmt(startMs)
|
||
const endFmt = fmt(endMs)
|
||
const curFmt = fmt(currentMs)
|
||
startLabel = `${startFmt.date} ${startFmt.time}`
|
||
endLabel = `${endFmt.date} ${endFmt.time}`
|
||
currentTimeLabel = `${curFmt.date} ${curFmt.time} KST`
|
||
} else {
|
||
// 기존 하드코딩 폴백
|
||
const hours = 18.5 + (replayFrame / totalFrames) * 12
|
||
const displayHours = hours >= 24 ? hours - 24 : hours
|
||
const h = Math.floor(displayHours)
|
||
const m = Math.round((displayHours - h) * 60)
|
||
const dayLabel = hours >= 24 ? '02-10' : '02-09'
|
||
startLabel = '18:30'
|
||
endLabel = '06:30'
|
||
currentTimeLabel = `${dayLabel} ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')} KST`
|
||
}
|
||
|
||
const handleSeekClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||
const rect = e.currentTarget.getBoundingClientRect()
|
||
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||
onSeek(Math.round(pct * totalFrames))
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className="absolute flex flex-col"
|
||
style={{
|
||
bottom: '80px', left: '50%', transform: 'translateX(-50%)',
|
||
minWidth: '480px', maxWidth: '680px', width: '60%',
|
||
background: 'rgba(10,15,25,0.92)', backdropFilter: 'blur(12px)',
|
||
border: '1px solid rgba(168,85,247,0.3)', borderRadius: '12px',
|
||
padding: '12px 18px', zIndex: 1200,
|
||
gap: '10px',
|
||
}}
|
||
>
|
||
{/* Header row */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<div
|
||
className="w-2 h-2 rounded-full bg-primary-purple"
|
||
style={{ boxShadow: '0 0 8px rgba(168,85,247,0.5)' }}
|
||
/>
|
||
<span className="text-xs font-bold">
|
||
역추적 리플레이
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1.5">
|
||
{/* Speed buttons */}
|
||
{[1, 2, 4].map((spd) => (
|
||
<button
|
||
key={spd}
|
||
onClick={() => onSpeedChange(spd)}
|
||
className={`bt-spd-btn ${replaySpeed === spd ? 'active' : ''}`}
|
||
>
|
||
{spd}×
|
||
</button>
|
||
))}
|
||
|
||
<div className="w-2" />
|
||
|
||
{/* Close button */}
|
||
<button
|
||
onClick={onClose}
|
||
className="text-status-red cursor-pointer font-bold"
|
||
style={{
|
||
padding: '4px 10px', borderRadius: '6px', fontSize: '10px',
|
||
background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)',
|
||
}}
|
||
>
|
||
✕ 닫기
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Controls row */}
|
||
<div className="flex items-center gap-3">
|
||
{/* Play/Pause */}
|
||
<button
|
||
onClick={onTogglePlay}
|
||
className="shrink-0 w-9 h-9 rounded-full flex items-center justify-center text-sm cursor-pointer"
|
||
style={{
|
||
background: isPlaying ? 'var(--purple)' : 'rgba(168,85,247,0.15)',
|
||
border: `2px solid ${isPlaying ? 'var(--purple)' : 'rgba(168,85,247,0.4)'}`,
|
||
color: isPlaying ? '#fff' : 'var(--purple)',
|
||
}}
|
||
>
|
||
{isPlaying ? '⏸' : '▶'}
|
||
</button>
|
||
|
||
{/* Timeline */}
|
||
<div className="flex-1 flex flex-col gap-1">
|
||
{/* Progress bar */}
|
||
<div
|
||
className="relative h-5 flex items-center cursor-pointer"
|
||
onClick={handleSeekClick}
|
||
>
|
||
<div
|
||
className="w-full h-1 bg-border relative overflow-visible"
|
||
style={{ borderRadius: '2px' }}
|
||
>
|
||
{/* Fill */}
|
||
<div
|
||
className="absolute top-0 left-0 h-full"
|
||
style={{
|
||
width: `${progress}%`,
|
||
background: 'linear-gradient(90deg, var(--purple), var(--cyan))',
|
||
borderRadius: '2px', transition: 'width 0.05s',
|
||
}}
|
||
/>
|
||
|
||
{/* Collision marker */}
|
||
{collisionEvent && (
|
||
<div
|
||
className="absolute text-[10px] cursor-pointer"
|
||
style={{
|
||
top: '-14px',
|
||
left: `${collisionEvent.progressPercent}%`,
|
||
transform: 'translateX(-50%)',
|
||
}}
|
||
title={collisionEvent.timeLabel}
|
||
>
|
||
💥
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Thumb */}
|
||
<div
|
||
className="absolute top-1/2 w-3.5 h-3.5 bg-white rounded-full"
|
||
style={{
|
||
left: `${progress}%`,
|
||
transform: 'translate(-50%, -50%)',
|
||
border: '3px solid var(--purple)',
|
||
boxShadow: '0 0 8px rgba(168,85,247,0.4)',
|
||
zIndex: 2, transition: 'left 0.05s',
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* Time labels */}
|
||
<div className="flex justify-between text-[9px] font-mono">
|
||
<span className="text-text-3">{startLabel}</span>
|
||
<span className="font-semibold text-primary-purple">{currentTimeLabel}</span>
|
||
<span className="text-text-3">{endLabel}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Legend row */}
|
||
<div className="flex items-center gap-[14px] pt-1 border-t border-border">
|
||
{replayShips.map((ship) => (
|
||
<div key={ship.vesselName} className="flex items-center gap-1.5">
|
||
<div className="w-4 h-[3px]" style={{ background: ship.color, borderRadius: '1px' }} />
|
||
<span className="text-[9px] text-text-2 font-mono">
|
||
{ship.vesselName}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|