148 lines
4.6 KiB
TypeScript
148 lines
4.6 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) {
|
||
|
|
const startDate = minDate || new Date(Date.now() - 365 * 24 * 60 * 60 * 1000); // 1 year ago
|
||
|
|
const endDate = maxDate || new Date();
|
||
|
|
|
||
|
|
const [currentDate, setCurrentDate] = useState<Date>(startDate); // Start from minDate instead of maxDate
|
||
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
||
|
|
const [speed, setSpeed] = useState(30); // 30 days 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);
|
||
|
|
|
||
|
|
// Loop back to start if we reach the end
|
||
|
|
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
|
||
|
|
useEffect(() => {
|
||
|
|
onTimeChange(currentDate);
|
||
|
|
}, [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 = endDate.getTime() - startDate.getTime();
|
||
|
|
const newDate = new Date(startDate.getTime() + (value / 100) * totalRange);
|
||
|
|
setCurrentDate(newDate);
|
||
|
|
setIsPlaying(false);
|
||
|
|
}, [startDate, endDate]);
|
||
|
|
|
||
|
|
const currentProgress = ((currentDate.getTime() - startDate.getTime()) / (endDate.getTime() - startDate.getTime())) * 100;
|
||
|
|
|
||
|
|
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">
|
||
|
|
<div className="text-white text-center mb-2">
|
||
|
|
<div className="text-sm font-bold mb-1">时间轴</div>
|
||
|
|
<div className="text-xs text-gray-300">{currentDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })}</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Progress bar */}
|
||
|
|
<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
|
||
|
|
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
|
||
|
|
onClick={() => {
|
||
|
|
setCurrentDate(new Date(startDate));
|
||
|
|
setIsPlaying(false);
|
||
|
|
}}
|
||
|
|
className="bg-gray-600 hover:bg-gray-700 text-white px-3 py-2 rounded text-xs"
|
||
|
|
>
|
||
|
|
⏮ 重置
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|