cosmo/frontend/src/components/TimelineController.tsx

183 lines
6.0 KiB
TypeScript

/**
* TimelineController - controls time for viewing historical positions
*/
import { useState, useEffect, useCallback, useRef } from '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 transform -translate-x-1/2 z-50 bg-black bg-opacity-80 p-4 rounded-lg shadow-lg min-w-96">
{/* Current date display - full format */}
<div className="text-center text-white text-sm mb-2 font-mono">
{formatFullDate(currentDate)}
</div>
{/* Date range labels */}
<div className="flex justify-between text-white text-xs mb-1 px-1">
<span className="text-gray-400">{formatShortDate(startDate)}</span>
<span className="text-gray-400">{formatShortDate(endDate)}</span>
</div>
{/* Progress bar (standard slider) */}
<input
type="range"
min="0"
max="100"
step="0.1"
value={currentProgress}
onChange={handleSliderChange}
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer mb-3"
/>
{/* Controls */}
<div className="flex items-center justify-center gap-4">
{/* Play/Pause button */}
<button
type="button"
onClick={handlePlayPause}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
>
{isPlaying ? '⏸ 暂停' : '▶ 播放'}
</button>
{/* Speed control */}
<div className="flex items-center gap-2">
<span className="text-white text-xs">:</span>
<select
value={speed}
onChange={(e) => handleSpeedChange(Number(e.target.value))}
className="bg-gray-700 text-white px-2 py-1 rounded text-xs"
>
<option value="1">1x (1/)</option>
<option value="7">7x (1/)</option>
<option value="30">30x (1/)</option>
<option value="365">365x (1/)</option>
</select>
</div>
{/* Reset button */}
<button
type="button"
onClick={() => {
setCurrentDate(new Date(startDate)); // Reset to start (now)
setIsPlaying(false);
}}
className="bg-gray-600 hover:bg-gray-700 text-white px-3 py-2 rounded text-xs"
>
</button>
</div>
</div>
);
}