cosmo/frontend/src/components/TimelineController.tsx

201 lines
7.2 KiB
TypeScript

/**
* TimelineController - controls time for viewing historical positions
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { Play, Pause, RotateCcw, FastForward, CalendarClock } from 'lucide-react';
export interface TimelineState {
currentDate: Date;
isPlaying: boolean;
speed: number; // days per second
startDate: Date;
endDate: Date;
}
interface TimelineControllerProps {
onTimeChange: (date: Date) => void;
minDate?: Date;
maxDate?: Date;
}
export function TimelineController({ onTimeChange, minDate, maxDate }: TimelineControllerProps) {
// Swap: startDate is now (maxDate), endDate is past (minDate)
const startDate = maxDate || new Date(); // Start from now
const endDate = minDate || new Date(Date.now() - 365 * 24 * 60 * 60 * 1000); // End at past
const [currentDate, setCurrentDate] = useState<Date>(startDate); // Start from now
const [isPlaying, setIsPlaying] = useState(false);
const [speed, setSpeed] = useState(1); // 1 day per second
const animationFrameRef = useRef<number>();
const lastUpdateRef = useRef<number>(Date.now());
// Animation loop
useEffect(() => {
if (!isPlaying) {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
return;
}
const animate = () => {
const now = Date.now();
const deltaSeconds = (now - lastUpdateRef.current) / 1000;
lastUpdateRef.current = now;
setCurrentDate((prev) => {
const newDate = new Date(prev.getTime() - speed * deltaSeconds * 24 * 60 * 60 * 1000); // Subtract to go backward in time
// Loop back to start (now) if we reach the end (past)
if (newDate < endDate) {
return new Date(startDate);
}
return newDate;
});
animationFrameRef.current = requestAnimationFrame(animate);
};
lastUpdateRef.current = Date.now();
animationFrameRef.current = requestAnimationFrame(animate);
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [isPlaying, speed, startDate, endDate]);
// Notify parent of time changes (debounced to avoid excessive updates)
const lastNotifiedDateRef = useRef<string | null>(null);
useEffect(() => {
// 圆整到天,避免同一天被通知多次
const roundedDate = new Date(currentDate);
roundedDate.setUTCHours(0, 0, 0, 0);
const dateKey = roundedDate.toISOString();
// 只在日期真正变化时通知父组件
if (lastNotifiedDateRef.current !== dateKey) {
lastNotifiedDateRef.current = dateKey;
onTimeChange(roundedDate);
}
}, [currentDate, onTimeChange]);
const handlePlayPause = useCallback(() => {
setIsPlaying((prev) => !prev);
}, []);
const handleSpeedChange = useCallback((newSpeed: number) => {
setSpeed(newSpeed);
}, []);
const handleSliderChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseFloat(e.target.value);
const totalRange = startDate.getTime() - endDate.getTime(); // Now - Past (positive)
const newDate = new Date(startDate.getTime() - (value / 100) * totalRange); // Start - progress
setCurrentDate(newDate);
setIsPlaying(false);
}, [startDate, endDate]);
const currentProgress = ((startDate.getTime() - currentDate.getTime()) / (startDate.getTime() - endDate.getTime())) * 100;
// Format date as YYYY-MM-DD for top display
const formatFullDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
// Format date as MM/DD for range labels
const formatShortDate = (date: Date) => {
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${month}/${day}`;
};
return (
<div className="fixed bottom-20 left-1/2 -translate-x-1/2 z-40 flex flex-col items-center w-full max-w-md px-4 pointer-events-none">
<div className="bg-black/80 backdrop-blur-md border border-white/10 rounded-2xl p-4 w-full shadow-2xl pointer-events-auto">
{/* Header: Date & Icon */}
<div className="flex items-center justify-between mb-3 text-white">
<div className="flex items-center gap-2 text-blue-400">
<CalendarClock size={18} />
<span className="text-xs font-bold uppercase tracking-wider"></span>
</div>
<div className="font-mono text-xl font-bold tracking-tight">
{formatFullDate(currentDate)}
</div>
</div>
{/* Progress Bar */}
<div className="relative mb-4 group">
<div className="flex justify-between text-[10px] text-gray-500 font-mono mb-1">
<span>{formatShortDate(startDate)} ()</span>
<span>{formatShortDate(endDate)} ()</span>
</div>
<input
type="range"
min="0"
max="100"
step="0.1"
value={currentProgress}
onChange={handleSliderChange}
className="w-full h-1.5 bg-gray-700 rounded-full appearance-none cursor-pointer hover:h-2 transition-all accent-blue-500"
/>
</div>
{/* Controls */}
<div className="flex items-center justify-between">
{/* Speed Selector */}
<div className="flex items-center gap-2 bg-white/5 rounded-lg p-1 border border-white/5">
<FastForward size={14} className="text-gray-400 ml-1" />
<select
value={speed}
onChange={(e) => handleSpeedChange(Number(e.target.value))}
className="bg-transparent text-white text-xs focus:outline-none cursor-pointer py-1 pr-1"
>
<option value="1">1/</option>
<option value="7">1/</option>
<option value="30">1/</option>
<option value="365">1/</option>
</select>
</div>
{/* Main Controls */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => {
setCurrentDate(new Date(startDate)); // Reset to start (now)
setIsPlaying(false);
}}
className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-gray-300 transition-colors"
title="重置"
>
<RotateCcw size={16} />
</button>
<button
type="button"
onClick={handlePlayPause}
className={`
p-3 rounded-full text-white shadow-lg transition-all transform hover:scale-105 active:scale-95
${isPlaying
? 'bg-amber-500 hover:bg-amber-600 shadow-amber-500/30'
: 'bg-[#238636] hover:bg-[#2ea043] shadow-[#238636]/30'
}
`}
title={isPlaying ? "暂停" : "播放"}
>
{isPlaying ? <Pause size={20} fill="currentColor" /> : <Play size={20} fill="currentColor" className="ml-0.5" />}
</button>
</div>
</div>
</div>
</div>
);
}