/** * 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(startDate); // Start from now const [isPlaying, setIsPlaying] = useState(false); const [speed, setSpeed] = useState(1); // 1 day per second const animationFrameRef = useRef(); const lastUpdateRef = useRef(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(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) => { 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 (
{/* Header: Date & Icon */}
历史回溯
{formatFullDate(currentDate)}
{/* Progress Bar */}
{formatShortDate(startDate)} (现在) {formatShortDate(endDate)} (过去)
{/* Controls */}
{/* Speed Selector */}
{/* Main Controls */}
); }