Phase 3: DeckGLOverlay에 overlayRef 추가, KoreaMap에서
리플레이 레이어 합성 (imperative setProps → React 렌더 우회)
Phase 4: 기존 MapLibre 리플레이 레이어 → deck.gl 전환
- FleetClusterLayer: 애니메이션 state/ref/timer 제거 → Zustand 스토어
- useFleetClusterGeoJson: 리플레이 useMemo 15개 제거 (618→389줄)
- FleetClusterMapLayers: MapLibre 재생 레이어 6개 제거 (492→397줄)
- HistoryReplayController: React refs → Zustand subscribe 바인딩
성능: React re-render 20회/초 → 0회/초 (재생 중)
GeoJSON 직렬화 15개/프레임 → 0 (raw 배열 → deck.gl)
트레일: 매 프레임 재생성 → TripsLayer GPU 셰이더
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
162 lines
4.9 KiB
TypeScript
162 lines
4.9 KiB
TypeScript
import { useRef, useEffect } from 'react';
|
|
import { FONT_MONO } from '../../styles/fonts';
|
|
import { useGearReplayStore } from '../../stores/gearReplayStore';
|
|
|
|
interface HistoryReplayControllerProps {
|
|
onClose: () => void;
|
|
}
|
|
|
|
const HistoryReplayController = ({ onClose }: HistoryReplayControllerProps) => {
|
|
// React selectors (infrequent changes)
|
|
const isPlaying = useGearReplayStore(s => s.isPlaying);
|
|
const snapshotRanges = useGearReplayStore(s => s.snapshotRanges);
|
|
const frameCount = useGearReplayStore(s => s.historyFrames.length);
|
|
|
|
// DOM refs for imperative updates
|
|
const progressBarRef = useRef<HTMLInputElement>(null);
|
|
const progressIndicatorRef = useRef<HTMLDivElement>(null);
|
|
const timeDisplayRef = useRef<HTMLSpanElement>(null);
|
|
|
|
// Subscribe to currentTime for DOM updates (no React re-render)
|
|
useEffect(() => {
|
|
const unsub = useGearReplayStore.subscribe(
|
|
s => s.currentTime,
|
|
(currentTime) => {
|
|
const { startTime, endTime } = useGearReplayStore.getState();
|
|
if (endTime <= startTime) return;
|
|
const progress = (currentTime - startTime) / (endTime - startTime);
|
|
if (progressBarRef.current) progressBarRef.current.value = String(Math.round(progress * 1000));
|
|
if (progressIndicatorRef.current) progressIndicatorRef.current.style.left = `${progress * 100}%`;
|
|
if (timeDisplayRef.current) {
|
|
timeDisplayRef.current.textContent = new Date(currentTime).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
},
|
|
);
|
|
return unsub;
|
|
}, []);
|
|
|
|
const store = useGearReplayStore;
|
|
|
|
return (
|
|
<div style={{
|
|
position: 'absolute',
|
|
bottom: 20,
|
|
left: '50%',
|
|
transform: 'translateX(-50%)',
|
|
background: 'rgba(12,24,37,0.95)',
|
|
border: '1px solid rgba(99,179,237,0.25)',
|
|
borderRadius: 8,
|
|
padding: '8px 14px',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 4,
|
|
zIndex: 20,
|
|
fontFamily: FONT_MONO,
|
|
fontSize: 10,
|
|
color: '#e2e8f0',
|
|
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
|
pointerEvents: 'auto',
|
|
minWidth: 360,
|
|
}}>
|
|
{/* 프로그레스 바 — 갭 표시 */}
|
|
<div style={{
|
|
position: 'relative',
|
|
height: 8,
|
|
background: 'rgba(255,255,255,0.05)',
|
|
borderRadius: 4,
|
|
overflow: 'hidden',
|
|
}}>
|
|
{snapshotRanges.map((pos, i) => (
|
|
<div key={i} style={{
|
|
position: 'absolute',
|
|
left: `${pos * 100}%`,
|
|
top: 0,
|
|
width: 2,
|
|
height: '100%',
|
|
background: 'rgba(251,191,36,0.4)',
|
|
}} />
|
|
))}
|
|
{/* 현재 위치 인디케이터 (DOM ref로 업데이트) */}
|
|
<div ref={progressIndicatorRef} style={{
|
|
position: 'absolute',
|
|
left: '0%',
|
|
top: -1,
|
|
width: 3,
|
|
height: 10,
|
|
background: '#fbbf24',
|
|
borderRadius: 1,
|
|
transform: 'translateX(-50%)',
|
|
}} />
|
|
</div>
|
|
|
|
{/* 컨트롤 행 */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<button
|
|
type="button"
|
|
onClick={() => { if (isPlaying) store.getState().pause(); else store.getState().play(); }}
|
|
style={{
|
|
background: 'none',
|
|
border: '1px solid rgba(99,179,237,0.3)',
|
|
borderRadius: 4,
|
|
color: '#e2e8f0',
|
|
cursor: 'pointer',
|
|
padding: '2px 6px',
|
|
fontSize: 12,
|
|
fontFamily: FONT_MONO,
|
|
}}
|
|
>
|
|
{isPlaying ? '⏸' : '▶'}
|
|
</button>
|
|
|
|
<span
|
|
ref={timeDisplayRef}
|
|
style={{ color: '#fbbf24', minWidth: 40, textAlign: 'center' }}
|
|
>
|
|
--:--
|
|
</span>
|
|
|
|
<input
|
|
ref={progressBarRef}
|
|
type="range"
|
|
min={0}
|
|
max={1000}
|
|
defaultValue={0}
|
|
onChange={e => {
|
|
const { startTime, endTime } = store.getState();
|
|
const progress = Number(e.target.value) / 1000;
|
|
const seekTime = startTime + progress * (endTime - startTime);
|
|
store.getState().pause();
|
|
store.getState().seek(seekTime);
|
|
}}
|
|
style={{ flex: 1, cursor: 'pointer', accentColor: '#fbbf24' }}
|
|
title="히스토리 타임라인"
|
|
aria-label="히스토리 타임라인"
|
|
/>
|
|
|
|
<span style={{ color: '#64748b', fontSize: 9 }}>
|
|
{frameCount}건
|
|
</span>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
style={{
|
|
background: 'none',
|
|
border: '1px solid rgba(239,68,68,0.3)',
|
|
borderRadius: 4,
|
|
color: '#ef4444',
|
|
cursor: 'pointer',
|
|
padding: '2px 6px',
|
|
fontSize: 11,
|
|
fontFamily: FONT_MONO,
|
|
}}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default HistoryReplayController;
|