- 실시간 선박 13K: MapLibre symbol → deck.gl IconLayer (useShipDeckLayers + shipDeckStore) - 선단/어구 폴리곤: MapLibre Source/Layer → deck.gl GeoJsonLayer (useFleetClusterDeckLayers) - 선박 팝업: MapLibre Popup → React 오버레이 (ShipPopupOverlay + ShipHoverTooltip) - 리플레이 집중 모드 (focusMode), 라벨 클러스터링, fontScale 연동 - Python: group_key 고정 + sub_cluster_id 분리, 한국 국적 어구 오탐 제외 - DB: sub_cluster_id 컬럼 추가 + 기존 '#N' 데이터 마이그레이션 - Backend: DISTINCT ON CTE로 서브클러스터 중복 제거, subClusterId DTO 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
138 lines
6.4 KiB
TypeScript
138 lines
6.4 KiB
TypeScript
import { useRef, useEffect } from 'react';
|
|
import { FONT_MONO } from '../../styles/fonts';
|
|
import { useGearReplayStore } from '../../stores/gearReplayStore';
|
|
|
|
interface HistoryReplayControllerProps {
|
|
onClose: () => void;
|
|
onFilterByScore: (minPct: number | null) => void;
|
|
}
|
|
|
|
const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayControllerProps) => {
|
|
const isPlaying = useGearReplayStore(s => s.isPlaying);
|
|
const snapshotRanges = useGearReplayStore(s => s.snapshotRanges);
|
|
const frameCount = useGearReplayStore(s => s.historyFrames.length);
|
|
const showTrails = useGearReplayStore(s => s.showTrails);
|
|
const showLabels = useGearReplayStore(s => s.showLabels);
|
|
const focusMode = useGearReplayStore(s => s.focusMode);
|
|
|
|
const progressBarRef = useRef<HTMLInputElement>(null);
|
|
const progressIndicatorRef = useRef<HTMLDivElement>(null);
|
|
const timeDisplayRef = useRef<HTMLSpanElement>(null);
|
|
|
|
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;
|
|
const btnStyle: React.CSSProperties = {
|
|
background: 'none', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 4,
|
|
color: '#e2e8f0', cursor: 'pointer', padding: '2px 6px', fontSize: 10, fontFamily: FONT_MONO,
|
|
};
|
|
const btnActiveStyle: React.CSSProperties = {
|
|
...btnStyle, background: 'rgba(99,179,237,0.15)', color: '#93c5fd',
|
|
};
|
|
|
|
return (
|
|
<div style={{
|
|
position: 'absolute', bottom: 20,
|
|
left: 'calc(50% + 100px)', transform: 'translateX(-50%)',
|
|
width: 'calc(100vw - 880px)',
|
|
minWidth: 380, maxWidth: 1320,
|
|
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',
|
|
}}>
|
|
{/* 프로그레스 바 */}
|
|
<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)',
|
|
}} />
|
|
))}
|
|
<div ref={progressIndicatorRef} style={{
|
|
position: 'absolute', left: '0%', top: -1, width: 3, height: 10,
|
|
background: '#fbbf24', borderRadius: 1, transform: 'translateX(-50%)',
|
|
}} />
|
|
</div>
|
|
|
|
{/* 컨트롤 행 1: 재생 + 타임라인 */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<button type="button" onClick={() => { if (isPlaying) store.getState().pause(); else store.getState().play(); }}
|
|
style={{ ...btnStyle, fontSize: 12 }}>
|
|
{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;
|
|
store.getState().pause();
|
|
store.getState().seek(startTime + progress * (endTime - startTime));
|
|
}}
|
|
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>
|
|
|
|
{/* 컨트롤 행 2: 표시 옵션 */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 4 }}>
|
|
<button type="button" onClick={() => store.getState().setShowTrails(!showTrails)}
|
|
style={showTrails ? btnActiveStyle : btnStyle} title="전체 항적 표시">
|
|
항적
|
|
</button>
|
|
<button type="button" onClick={() => store.getState().setShowLabels(!showLabels)}
|
|
style={showLabels ? btnActiveStyle : btnStyle} title="이름 표시">
|
|
이름
|
|
</button>
|
|
<button type="button" onClick={() => store.getState().setFocusMode(!focusMode)}
|
|
style={focusMode ? { ...btnStyle, background: 'rgba(239,68,68,0.15)', color: '#f87171', borderColor: 'rgba(239,68,68,0.4)' } : btnStyle}
|
|
title="집중 모드 — 주변 라이브 정보 숨김">
|
|
집중
|
|
</button>
|
|
<span style={{ color: '#475569', margin: '0 2px' }}>|</span>
|
|
<span style={{ color: '#64748b', fontSize: 9 }}>일치율</span>
|
|
<select
|
|
onChange={e => {
|
|
const val = e.target.value;
|
|
onFilterByScore(val === '' ? null : Number(val));
|
|
}}
|
|
style={{
|
|
background: 'rgba(15,23,42,0.9)', border: '1px solid rgba(99,179,237,0.3)',
|
|
borderRadius: 4, color: '#e2e8f0', fontSize: 9, fontFamily: FONT_MONO,
|
|
padding: '1px 4px', cursor: 'pointer',
|
|
}}
|
|
title="일치율 이상만 표시" aria-label="일치율 필터"
|
|
>
|
|
<option value="">전체</option>
|
|
<option value="50">50%+</option>
|
|
<option value="60">60%+</option>
|
|
<option value="70">70%+</option>
|
|
<option value="80">80%+</option>
|
|
<option value="90">90%+</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default HistoryReplayController;
|