wing-ops/frontend/src/common/components/map/BacktrackReplayBar.tsx
jeonghyo.k e285f2330f feat(prediction): 역추적 분석 엔진 및 동적 파라미터 입력 기능 구현
- 백엔드: backtrackAnalysisService 신규 개발
  * AIS 기반 선박 항적 API 연동 및 공간 조회
  * 공간(40%)/시간(25%)/행동(20%)/선박유형(15%) 가중치 위험도 점수 산정
  * 상위 5척 리플레이 데이터 및 충돌 이벤트 생성
  * Python 서버 미연동 시 폴백 메커니즘 제공
- 백엔드: 역추적 생성 시 동기 분석 → BacktrackResult 즉시 반환
- 프론트엔드: 모달에서 유출 시각/분석 범위/탐색 반경 직접 입력 가능
- 프론트엔드: 리플레이 바에 실제 분석 시간 범위 동적 표시
- DB: AIS_TRACK 테이블 신규 생성 (선박 항적 이력 + GIS 인덱스)
2026-03-27 14:57:00 +09:00

213 lines
7.0 KiB
TypeScript
Executable File
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}