/** * GearReplayController — 어구 그룹 24시간 궤적 재생 컨트롤러 * * 맵 위에 absolute 포지셔닝으로 표시. * Zustand subscribe 패턴으로 DOM 직접 업데이트 → 재생 중 React re-render 없음. */ import { useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '@shared/components/ui/button'; import { useGearReplayStore } from '@stores/gearReplayStore'; import { Play, Pause, X } from 'lucide-react'; interface GearReplayControllerProps { onClose: () => void; } const SPEED_OPTIONS = [1, 2, 5, 10] as const; type SpeedOption = (typeof SPEED_OPTIONS)[number]; function formatEpochTime(epochMs: number): string { if (epochMs === 0) return '--:--'; const d = new Date(epochMs); const MM = String(d.getMonth() + 1).padStart(2, '0'); const dd = String(d.getDate()).padStart(2, '0'); const hh = String(d.getHours()).padStart(2, '0'); const mm = String(d.getMinutes()).padStart(2, '0'); return `${MM}/${dd} ${hh}:${mm}`; } export function GearReplayController({ onClose }: GearReplayControllerProps) { const { t: tc } = useTranslation('common'); const play = useGearReplayStore((s) => s.play); const pause = useGearReplayStore((s) => s.pause); const seek = useGearReplayStore((s) => s.seek); const setSpeed = useGearReplayStore((s) => s.setSpeed); const isPlaying = useGearReplayStore((s) => s.isPlaying); const playbackSpeed = useGearReplayStore((s) => s.playbackSpeed); const startTime = useGearReplayStore((s) => s.startTime); const endTime = useGearReplayStore((s) => s.endTime); const snapshotRanges = useGearReplayStore((s) => s.snapshotRanges); const dataStartTime = useGearReplayStore((s) => s.dataStartTime); const dataEndTime = useGearReplayStore((s) => s.dataEndTime); // DOM refs for direct updates — no React state during playback const progressBarRef = useRef(null); const timeLabelRef = useRef(null); const trackRef = useRef(null); // Subscribe to currentTime changes and update DOM directly useEffect(() => { const unsubscribe = useGearReplayStore.subscribe( (s) => s.currentTime, (currentTime) => { const duration = endTime - startTime; const pct = duration > 0 ? ((currentTime - startTime) / duration) * 100 : 0; if (progressBarRef.current) { progressBarRef.current.style.width = `${Math.min(100, pct)}%`; } if (timeLabelRef.current) { timeLabelRef.current.textContent = formatEpochTime(currentTime); } }, ); return unsubscribe; }, [startTime, endTime]); // Handle click on track to seek const handleTrackClick = (e: React.MouseEvent) => { if (!trackRef.current) return; const rect = trackRef.current.getBoundingClientRect(); const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); seek(startTime + ratio * (endTime - startTime)); }; const handlePlayPause = () => { if (isPlaying) { pause(); } else { play(); } }; const duration = endTime - startTime; const initialPct = duration > 0 ? ((useGearReplayStore.getState().currentTime - startTime) / duration) * 100 : 0; return (
{/* Play / Pause */} ))}
{/* Start time */} {formatEpochTime(startTime)} {/* Progress track */}
{/* 스냅샷 틱마크 */} {snapshotRanges.map((r, i) => (
))}
{/* End time */} {formatEpochTime(endTime)} {/* Current time label */} {formatEpochTime(useGearReplayStore.getState().currentTime)} {/* Close */}
); }