308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
|
import { useTrackPlaybackStore, TRACK_PLAYBACK_SPEED_OPTIONS } from '../../features/trackReplay/stores/trackPlaybackStore';
|
|
import { useTrackQueryStore } from '../../features/trackReplay/stores/trackQueryStore';
|
|
|
|
function formatDateTime(ms: number): string {
|
|
if (!Number.isFinite(ms) || ms <= 0) return '--';
|
|
const date = new Date(ms);
|
|
const pad = (value: number) => String(value).padStart(2, '0');
|
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(
|
|
date.getMinutes(),
|
|
)}:${pad(date.getSeconds())}`;
|
|
}
|
|
|
|
export function GlobalTrackReplayPanel() {
|
|
const PANEL_WIDTH = 420;
|
|
const PANEL_MARGIN = 12;
|
|
const PANEL_DEFAULT_TOP = 16;
|
|
const PANEL_RIGHT_RESERVED = 520;
|
|
|
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
|
const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>(
|
|
null,
|
|
);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
|
|
const clampPosition = useCallback(
|
|
(x: number, y: number) => {
|
|
if (typeof window === 'undefined') return { x, y };
|
|
const viewportWidth = window.innerWidth;
|
|
const viewportHeight = window.innerHeight;
|
|
const panelHeight = panelRef.current?.offsetHeight ?? 360;
|
|
return {
|
|
x: Math.min(Math.max(PANEL_MARGIN, x), Math.max(PANEL_MARGIN, viewportWidth - PANEL_WIDTH - PANEL_MARGIN)),
|
|
y: Math.min(Math.max(PANEL_MARGIN, y), Math.max(PANEL_MARGIN, viewportHeight - panelHeight - PANEL_MARGIN)),
|
|
};
|
|
},
|
|
[PANEL_MARGIN, PANEL_WIDTH],
|
|
);
|
|
|
|
const [position, setPosition] = useState(() => {
|
|
if (typeof window === 'undefined') {
|
|
return { x: PANEL_MARGIN, y: PANEL_DEFAULT_TOP };
|
|
}
|
|
return {
|
|
x: Math.max(PANEL_MARGIN, window.innerWidth - PANEL_WIDTH - PANEL_RIGHT_RESERVED),
|
|
y: PANEL_DEFAULT_TOP,
|
|
};
|
|
});
|
|
|
|
const tracks = useTrackQueryStore((state) => state.tracks);
|
|
const isLoading = useTrackQueryStore((state) => state.isLoading);
|
|
const error = useTrackQueryStore((state) => state.error);
|
|
const showPoints = useTrackQueryStore((state) => state.showPoints);
|
|
const showVirtualShip = useTrackQueryStore((state) => state.showVirtualShip);
|
|
const showLabels = useTrackQueryStore((state) => state.showLabels);
|
|
const showTrail = useTrackQueryStore((state) => state.showTrail);
|
|
const hideLiveShips = useTrackQueryStore((state) => state.hideLiveShips);
|
|
const setShowPoints = useTrackQueryStore((state) => state.setShowPoints);
|
|
const setShowVirtualShip = useTrackQueryStore((state) => state.setShowVirtualShip);
|
|
const setShowLabels = useTrackQueryStore((state) => state.setShowLabels);
|
|
const setShowTrail = useTrackQueryStore((state) => state.setShowTrail);
|
|
const setHideLiveShips = useTrackQueryStore((state) => state.setHideLiveShips);
|
|
const closeTrackQuery = useTrackQueryStore((state) => state.closeQuery);
|
|
|
|
const isPlaying = useTrackPlaybackStore((state) => state.isPlaying);
|
|
const currentTime = useTrackPlaybackStore((state) => state.currentTime);
|
|
const startTime = useTrackPlaybackStore((state) => state.startTime);
|
|
const endTime = useTrackPlaybackStore((state) => state.endTime);
|
|
const playbackSpeed = useTrackPlaybackStore((state) => state.playbackSpeed);
|
|
const loop = useTrackPlaybackStore((state) => state.loop);
|
|
const play = useTrackPlaybackStore((state) => state.play);
|
|
const pause = useTrackPlaybackStore((state) => state.pause);
|
|
const stop = useTrackPlaybackStore((state) => state.stop);
|
|
const setCurrentTime = useTrackPlaybackStore((state) => state.setCurrentTime);
|
|
const setPlaybackSpeed = useTrackPlaybackStore((state) => state.setPlaybackSpeed);
|
|
const toggleLoop = useTrackPlaybackStore((state) => state.toggleLoop);
|
|
|
|
const progress = useMemo(() => {
|
|
if (endTime <= startTime) return 0;
|
|
return ((currentTime - startTime) / (endTime - startTime)) * 100;
|
|
}, [startTime, endTime, currentTime]);
|
|
const isVisible = isLoading || tracks.length > 0 || !!error;
|
|
|
|
useEffect(() => {
|
|
if (!isVisible) return;
|
|
if (typeof window === 'undefined') return;
|
|
const onResize = () => {
|
|
setPosition((prev) => clampPosition(prev.x, prev.y));
|
|
};
|
|
window.addEventListener('resize', onResize);
|
|
return () => window.removeEventListener('resize', onResize);
|
|
}, [clampPosition, isVisible]);
|
|
|
|
useEffect(() => {
|
|
if (!isVisible) return;
|
|
const onPointerMove = (event: PointerEvent) => {
|
|
const drag = dragRef.current;
|
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
setPosition(() => {
|
|
const nextX = drag.originX + (event.clientX - drag.startX);
|
|
const nextY = drag.originY + (event.clientY - drag.startY);
|
|
return clampPosition(nextX, nextY);
|
|
});
|
|
};
|
|
|
|
const stopDrag = (event: PointerEvent) => {
|
|
const drag = dragRef.current;
|
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
dragRef.current = null;
|
|
setIsDragging(false);
|
|
};
|
|
|
|
window.addEventListener('pointermove', onPointerMove);
|
|
window.addEventListener('pointerup', stopDrag);
|
|
window.addEventListener('pointercancel', stopDrag);
|
|
|
|
return () => {
|
|
window.removeEventListener('pointermove', onPointerMove);
|
|
window.removeEventListener('pointerup', stopDrag);
|
|
window.removeEventListener('pointercancel', stopDrag);
|
|
};
|
|
}, [clampPosition, isVisible]);
|
|
|
|
const handleHeaderPointerDown = useCallback(
|
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
if (event.button !== 0) return;
|
|
dragRef.current = {
|
|
pointerId: event.pointerId,
|
|
startX: event.clientX,
|
|
startY: event.clientY,
|
|
originX: position.x,
|
|
originY: position.y,
|
|
};
|
|
setIsDragging(true);
|
|
try {
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
},
|
|
[position.x, position.y],
|
|
);
|
|
|
|
if (!isVisible) return null;
|
|
|
|
return (
|
|
<div
|
|
ref={panelRef}
|
|
style={{
|
|
position: 'absolute',
|
|
left: position.x,
|
|
top: position.y,
|
|
width: PANEL_WIDTH,
|
|
background: 'rgba(15,23,42,0.94)',
|
|
border: '1px solid rgba(148,163,184,0.35)',
|
|
borderRadius: 12,
|
|
padding: 12,
|
|
color: '#e2e8f0',
|
|
zIndex: 40,
|
|
backdropFilter: 'blur(8px)',
|
|
boxShadow: '0 8px 24px rgba(2,6,23,0.45)',
|
|
}}
|
|
>
|
|
<div
|
|
onPointerDown={handleHeaderPointerDown}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginBottom: 8,
|
|
cursor: isDragging ? 'grabbing' : 'grab',
|
|
userSelect: 'none',
|
|
touchAction: 'none',
|
|
}}
|
|
>
|
|
<strong style={{ fontSize: 13 }}>Track Replay</strong>
|
|
<button
|
|
type="button"
|
|
onClick={() => closeTrackQuery()}
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
style={{
|
|
fontSize: 11,
|
|
padding: '4px 8px',
|
|
borderRadius: 6,
|
|
border: '1px solid rgba(148,163,184,0.5)',
|
|
background: 'rgba(30,41,59,0.7)',
|
|
color: '#e2e8f0',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
닫기
|
|
</button>
|
|
</div>
|
|
|
|
{error ? (
|
|
<div style={{ marginBottom: 8, color: '#fca5a5', fontSize: 12 }}>{error}</div>
|
|
) : null}
|
|
|
|
{isLoading ? <div style={{ marginBottom: 8, fontSize: 12 }}>항적 조회 중...</div> : null}
|
|
|
|
<div style={{ fontSize: 11, color: '#93c5fd', marginBottom: 8 }}>
|
|
선박 {tracks.length}척 · {formatDateTime(startTime)} ~ {formatDateTime(endTime)}
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
|
|
<button
|
|
type="button"
|
|
onClick={() => (isPlaying ? pause() : play())}
|
|
disabled={tracks.length === 0}
|
|
style={{
|
|
padding: '6px 10px',
|
|
borderRadius: 6,
|
|
border: '1px solid rgba(148,163,184,0.45)',
|
|
background: 'rgba(30,41,59,0.8)',
|
|
color: '#e2e8f0',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
{isPlaying ? '일시정지' : '재생'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => stop()}
|
|
disabled={tracks.length === 0}
|
|
style={{
|
|
padding: '6px 10px',
|
|
borderRadius: 6,
|
|
border: '1px solid rgba(148,163,184,0.45)',
|
|
background: 'rgba(30,41,59,0.8)',
|
|
color: '#e2e8f0',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
정지
|
|
</button>
|
|
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
배속
|
|
<select
|
|
value={playbackSpeed}
|
|
onChange={(event) => setPlaybackSpeed(Number(event.target.value))}
|
|
style={{
|
|
background: 'rgba(30,41,59,0.85)',
|
|
border: '1px solid rgba(148,163,184,0.45)',
|
|
borderRadius: 6,
|
|
color: '#e2e8f0',
|
|
fontSize: 12,
|
|
padding: '4px 6px',
|
|
}}
|
|
>
|
|
{TRACK_PLAYBACK_SPEED_OPTIONS.map((speed) => (
|
|
<option key={speed} value={speed}>
|
|
{speed}x
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<div style={{ marginBottom: 10 }}>
|
|
<input
|
|
type="range"
|
|
min={startTime}
|
|
max={endTime || startTime + 1}
|
|
value={currentTime}
|
|
onChange={(event) => setCurrentTime(Number(event.target.value))}
|
|
style={{ width: '100%' }}
|
|
disabled={tracks.length === 0 || endTime <= startTime}
|
|
/>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: '#94a3b8' }}>
|
|
<span>{formatDateTime(currentTime)}</span>
|
|
<span>{Math.max(0, Math.min(100, progress)).toFixed(1)}%</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 6, fontSize: 12 }}>
|
|
<label>
|
|
<input type="checkbox" checked={showPoints} onChange={(event) => setShowPoints(event.target.checked)} /> 포인트
|
|
</label>
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={showVirtualShip}
|
|
onChange={(event) => setShowVirtualShip(event.target.checked)}
|
|
/>{' '}
|
|
가상선박
|
|
</label>
|
|
<label>
|
|
<input type="checkbox" checked={showLabels} onChange={(event) => setShowLabels(event.target.checked)} /> 선명
|
|
</label>
|
|
<label>
|
|
<input type="checkbox" checked={showTrail} onChange={(event) => setShowTrail(event.target.checked)} /> 잔상
|
|
</label>
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={hideLiveShips}
|
|
onChange={(event) => setHideLiveShips(event.target.checked)}
|
|
/>{' '}
|
|
라이브 숨김
|
|
</label>
|
|
<label>
|
|
<input type="checkbox" checked={loop} onChange={() => toggleLoop()} /> 반복
|
|
</label>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|