gc-wing/apps/web/src/widgets/trackReplay/GlobalTrackReplayPanel.tsx
htlee 6acf2045b2 chore: vessel-track 브랜치 병합 (squash)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 22:12:48 +09:00

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>
);
}