Files
CA/frontend/src/components/TimelinePlayer.tsx

201 lines
6.1 KiB
TypeScript
Raw Normal View History

import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Play, Pause, SkipBack, SkipForward } from 'lucide-react';
interface TimelinePlayerProps {
startDate: string;
endDate: string;
currentDate: string;
onDateChange: (date: string) => void;
isPlaying?: boolean;
speed?: number;
onSpeedChange?: (speed: number) => void;
onPlayPause?: (playing: boolean) => void;
}
const SPEEDS = [0.5, 1, 2, 5, 10];
export function TimelinePlayer({
startDate,
endDate,
currentDate,
onDateChange,
isPlaying = false,
speed = 1,
onSpeedChange,
onPlayPause,
}: TimelinePlayerProps) {
const [playing, setPlaying] = useState(isPlaying);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const generateDateRange = useCallback((start: string, end: string) => {
const dates: string[] = [];
const current = new Date(start);
const final = new Date(end);
while (current <= final) {
dates.push(current.toISOString().split('T')[0]);
current.setDate(current.getDate() + 1);
}
return dates;
}, []);
const dateRange = useMemo(() => generateDateRange(startDate, endDate), [startDate, endDate, generateDateRange]);
const currentIndex = useMemo(() => dateRange.indexOf(currentDate), [dateRange, currentDate]);
const progress = useMemo(() => ((currentIndex + 1) / dateRange.length) * 100, [currentIndex, dateRange.length]);
const play = useCallback(() => {
setPlaying(true);
onPlayPause?.(true);
}, [onPlayPause]);
const pause = useCallback(() => {
setPlaying(false);
onPlayPause?.(false);
}, [onPlayPause]);
const togglePlay = () => {
if (playing) {
pause();
} else {
play();
}
};
const goToNext = useCallback(() => {
const nextIndex = Math.min(currentIndex + 1, dateRange.length - 1);
onDateChange(dateRange[nextIndex]);
}, [currentIndex, dateRange, onDateChange]);
const goToStart = () => {
onDateChange(dateRange[0]);
};
useEffect(() => {
if (playing) {
const interval = 1000 / speed;
timerRef.current = setInterval(() => {
goToNext();
}, interval);
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}
}, [playing, speed, goToNext]);
useEffect(() => {
if (currentIndex >= dateRange.length - 1) {
pause();
}
}, [currentIndex, dateRange.length, pause]);
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const index = Math.round((Number(e.target.value) / 100) * (dateRange.length - 1));
onDateChange(dateRange[index]);
};
const handleSpeedChange = () => {
const currentIndex = SPEEDS.indexOf(speed);
const nextIndex = (currentIndex + 1) % SPEEDS.length;
onSpeedChange?.(SPEEDS[nextIndex]);
};
const formatSpeed = (s: number) => {
return s >= 1 ? `${s}x` : `${s.toFixed(1)}x`;
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const today = new Date();
const isToday = date.toDateString() === today.toDateString();
if (isToday) {
return `今天 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`;
}
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
};
return (
<div className="fixed right-4 top-1/2 -translate-y-1/2 z-[9999] w-64">
<div className="bg-white/95 backdrop-blur-xl border border-gray-200/80 rounded-2xl shadow-[0_8px_32px_rgba(0,0,0,0.12)] px-4 py-3">
{/* Date display */}
<div className="text-center mb-3">
<div className="font-medium text-gray-900 text-sm">{formatDate(currentDate)}</div>
<div className="text-xs text-gray-400 mt-0.5">
{currentIndex + 1} / {dateRange.length}
</div>
</div>
{/* Vertical slider */}
<div className="flex justify-center mb-3">
<input
type="range"
min="0"
max="100"
value={progress}
onChange={handleSliderChange}
className="h-1.5 w-full bg-gray-200 rounded-full appearance-none cursor-pointer accent-blue-600"
style={{
background: `linear-gradient(to right, #2563eb 0%, #2563eb ${progress}%, #e5e7eb ${progress}%, #e5e7eb 100%)`,
}}
/>
</div>
<div className="flex justify-between text-[10px] text-gray-400 mb-3">
<span>{new Date(startDate).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })}</span>
<span>{new Date(endDate).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })}</span>
</div>
{/* Transport controls */}
<div className="flex items-center justify-center gap-2">
<button
onClick={goToStart}
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
title="跳到开始"
>
<SkipBack className="w-4 h-4" />
</button>
<button
onClick={togglePlay}
className="p-2.5 bg-blue-600 text-white rounded-full hover:bg-blue-700 transition-colors shadow-md"
>
{playing ? (
<Pause className="w-5 h-5" />
) : (
<Play className="w-5 h-5 ml-0.5" />
)}
</button>
<button
onClick={goToNext}
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
title="跳到下一天"
>
<SkipForward className="w-4 h-4" />
</button>
</div>
{/* Speed */}
<div className="flex items-center justify-center gap-2 mt-2">
<button
onClick={handleSpeedChange}
className="px-2 py-0.5 text-xs font-medium text-gray-600 bg-gray-100/80 rounded-full hover:bg-gray-200 transition-colors"
title="调整播放速度"
>
{formatSpeed(speed)}
</button>
</div>
</div>
</div>
);
}