183 lines
6.0 KiB
TypeScript
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>
|
|
);
|
|
}
|