Phase 2: 정적 인라인 스타일을 Tailwind className으로 변환 - common/: MapView, BacktrackReplayBar, LoginPage, LayerTree, ComboBox, SubMenuBar - hns/: HNSSubstanceView, HNSScenarioView, HNSView, HNSLeftPanel 등 8파일 - prediction/: BoomDeploymentTheoryView, OilBoomSection, RecalcModal, RightPanel 등 8파일 - incidents/: IncidentsView, IncidentsLeftPanel, IncidentsRightPanel - rescue/: RescueScenarioView - aerial/: SatelliteRequest, AerialTheoryView - assets/: ShipInsurance, AssetTheory, AssetManagement 등 5파일 - board/: BoardView - reports/: ReportsView, OilSpillReportTemplate, ReportGenerator - weather/: WeatherMapOverlay, WeatherView, WeatherRightPanel 변환 패턴: color/background/border/borderRadius/display/flex/gap/fontSize/fontWeight → Tailwind 동적 스타일(rgba, gradient, 삼항 조건부, 런타임 변수)은 style prop에 유지 JS 번들: 2,921KB → 2,897KB (-24KB) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
184 lines
6.0 KiB
TypeScript
Executable File
184 lines
6.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
|
||
}
|
||
|
||
export function BacktrackReplayBar({
|
||
isPlaying,
|
||
replayFrame,
|
||
totalFrames,
|
||
replaySpeed,
|
||
onTogglePlay,
|
||
onSeek,
|
||
onSpeedChange,
|
||
onClose,
|
||
replayShips,
|
||
collisionEvent,
|
||
}: BacktrackReplayBarProps) {
|
||
const progress = (replayFrame / totalFrames) * 100
|
||
|
||
// Time calculation: 12-hour span from 18:30 to 06:30
|
||
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'
|
||
const 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">18:30</span>
|
||
<span className="font-semibold text-primary-purple">{currentTimeLabel}</span>
|
||
<span className="text-text-3">06:30</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>
|
||
)
|
||
}
|