201 lines
7.2 KiB
TypeScript
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>
|
|
);
|
|
}
|