"use client"; import clsx from "clsx"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { DraggableCore, type DraggableData, type DraggableEvent, } from "react-draggable"; import { FiCalendar, FiChevronDown, FiChevronLeft, FiChevronRight, FiPause, FiPlay, FiRotateCcw, FiSkipBack, FiSkipForward, FiX, FiZap, } from "react-icons/fi"; import { formatTimelineTime, getRoundedCurrentTimelineMinutes, normalizeTimelineMinutes, } from "@components/olmap/core/Controls/timelineTime"; const DEFAULT_PLAY_INTERVAL_MS = 2_500; const WEEK_LABELS = ["一", "二", "三", "四", "五", "六", "日"]; const glassClass = "bg-[rgba(234,244,250,0.62)] [backdrop-filter:blur(26px)_saturate(145%)_contrast(96%)] [-webkit-backdrop-filter:blur(26px)_saturate(145%)_contrast(96%)] [box-shadow:inset_0_1px_0_rgba(255,255,255,0.90),inset_0_-1px_0_rgba(112,145,168,0.16),0_18px_50px_rgba(15,43,69,0.20)] ring-1 ring-white/60"; const calendarGlassClass = "bg-[rgba(238,246,251,0.88)] [backdrop-filter:blur(30px)_saturate(140%)_contrast(95%)] [-webkit-backdrop-filter:blur(30px)_saturate(140%)_contrast(95%)] [box-shadow:inset_0_1px_0_rgba(255,255,255,0.96),inset_0_-1px_0_rgba(112,145,168,0.18),0_22px_60px_rgba(15,43,69,0.24)] ring-1 ring-white/75"; export type ThreeDimensionalTimelineProps = { selectedDate: Date; currentTime: number; durationMinutes: number; stepMinutes: number; disabled?: boolean; sidePanelOpen?: boolean; onClose: () => void; onSelectedDateChange: (date: Date) => void; onCurrentTimeChange: (minutes: number) => void; }; const startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()); const isSameDay = (left: Date, right: Date) => left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth() && left.getDate() === right.getDate(); const addDays = (date: Date, amount: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + amount, 12); const formatDate = (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}`; }; export function ThreeDimensionalTimeline({ selectedDate, currentTime, durationMinutes, stepMinutes, disabled = false, sidePanelOpen = false, onClose, onSelectedDateChange, onCurrentTimeChange, }: ThreeDimensionalTimelineProps) { const timelineRef = useRef(null); const dragShieldRef = useRef(null); const dragPositionRef = useRef({ x: 0, y: 0 }); const dragStartRef = useRef<{ pointerX: number; pointerY: number; originX: number; originY: number; } | null>(null); const [playing, setPlaying] = useState(false); const [playIntervalMs, setPlayIntervalMs] = useState(DEFAULT_PLAY_INTERVAL_MS); const [previewTime, setPreviewTime] = useState(null); const safeCurrentTime = normalizeTimelineMinutes( previewTime ?? currentTime, 0, durationMinutes, ); const advance = useCallback( (direction: 1 | -1) => { const next = safeCurrentTime + stepMinutes * direction; onCurrentTimeChange( next > durationMinutes ? 0 : next < 0 ? durationMinutes : next, ); }, [durationMinutes, onCurrentTimeChange, safeCurrentTime, stepMinutes], ); useEffect(() => { if (!playing || disabled) return; const intervalId = window.setInterval(() => advance(1), playIntervalMs); return () => window.clearInterval(intervalId); }, [advance, disabled, playIntervalMs, playing]); const marks = useMemo( () => [0, 0.25, 0.5, 0.75, 1].map((ratio) => { const value = Math.round((durationMinutes * ratio) / stepMinutes) * stepMinutes; return formatTimelineTime(Math.min(durationMinutes, value), 0, durationMinutes); }), [durationMinutes, stepMinutes], ); const resetToCurrentTime = () => { const now = new Date(); setPlaying(false); onSelectedDateChange(now); onCurrentTimeChange( getRoundedCurrentTimelineMinutes(now, stepMinutes, durationMinutes), ); }; const commitPreview = (value?: string) => { const next = Number(value ?? previewTime ?? safeCurrentTime); if (!Number.isFinite(next)) return; setPreviewTime(null); onCurrentTimeChange(next); }; const progress = durationMinutes > 0 ? Math.min(100, Math.max(0, (safeCurrentTime / durationMinutes) * 100)) : 0; const applyDragPosition = useCallback((x: number, y: number) => { dragPositionRef.current = { x, y }; if (timelineRef.current) { timelineRef.current.style.transform = `translate3d(${x}px, ${y}px, 0)`; } }, []); const handleDragStart = useCallback( (_event: DraggableEvent, data: DraggableData) => { const { x: originX, y: originY } = dragPositionRef.current; dragStartRef.current = { pointerX: data.x, pointerY: data.y, originX, originY, }; if (dragShieldRef.current) { dragShieldRef.current.style.display = "block"; } if (timelineRef.current) { timelineRef.current.style.willChange = "transform"; } }, [], ); const handleDrag = useCallback( (_event: DraggableEvent, data: DraggableData) => { const start = dragStartRef.current; if (!start) return; applyDragPosition( start.originX + data.x - start.pointerX, start.originY + data.y - start.pointerY, ); }, [applyDragPosition], ); const handleDragStop = useCallback( (event: DraggableEvent, data: DraggableData) => { handleDrag(event, data); dragStartRef.current = null; if (dragShieldRef.current) { dragShieldRef.current.style.display = ""; } if (timelineRef.current) { timelineRef.current.style.willChange = ""; } }, [handleDrag], ); return (
); } type CalendarView = "days" | "months" | "years"; function CalendarPicker({ selectedDate, disabled, onChange }: { selectedDate: Date; disabled: boolean; onChange: (date: Date) => void }) { const wrapperRef = useRef(null); const calendarRef = useRef(null); const [open, setOpen] = useState(false); const [visibleMonth, setVisibleMonth] = useState(() => new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); const [focusedDate, setFocusedDate] = useState(() => startOfDay(selectedDate)); const [view, setView] = useState("days"); const [yearPageStart, setYearPageStart] = useState(() => Math.floor(selectedDate.getFullYear() / 12) * 12); const today = startOfDay(new Date()); useEffect(() => { if (!open) return; const handlePointerDown = (event: PointerEvent) => { if (!wrapperRef.current?.contains(event.target as Node)) setOpen(false); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") setOpen(false); }; document.addEventListener("pointerdown", handlePointerDown); document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("pointerdown", handlePointerDown); document.removeEventListener("keydown", handleKeyDown); }; }, [open]); useEffect(() => { if (!open || view !== "days") return; window.requestAnimationFrame(() => { calendarRef.current ?.querySelector("[data-calendar-focused='true']") ?.focus(); }); }, [focusedDate, open, view, visibleMonth]); const days = useMemo(() => { const firstWeekday = (visibleMonth.getDay() + 6) % 7; return Array.from({ length: 42 }, (_, index) => new Date(visibleMonth.getFullYear(), visibleMonth.getMonth(), index - firstWeekday + 1, 12), ); }, [visibleMonth]); const selectDate = (date: Date) => { onChange(date); setOpen(false); }; const selectToday = () => { const now = new Date(); onChange(now); setFocusedDate(startOfDay(now)); setVisibleMonth(new Date(now.getFullYear(), now.getMonth(), 1)); setYearPageStart(Math.floor(now.getFullYear() / 12) * 12); setView("days"); }; const showDate = (date: Date) => { const bounded = startOfDay(date).getTime() > today.getTime() ? today : startOfDay(date); setFocusedDate(bounded); setVisibleMonth(new Date(bounded.getFullYear(), bounded.getMonth(), 1)); }; const handleCalendarKeyDown = (event: React.KeyboardEvent) => { if (view !== "days") return; let next: Date | null = null; if (event.key === "ArrowLeft") next = addDays(focusedDate, -1); if (event.key === "ArrowRight") next = addDays(focusedDate, 1); if (event.key === "ArrowUp") next = addDays(focusedDate, -7); if (event.key === "ArrowDown") next = addDays(focusedDate, 7); if (event.key === "PageUp") next = new Date(focusedDate.getFullYear(), focusedDate.getMonth() - 1, focusedDate.getDate(), 12); if (event.key === "PageDown") next = new Date(focusedDate.getFullYear(), focusedDate.getMonth() + 1, focusedDate.getDate(), 12); const weekday = (focusedDate.getDay() + 6) % 7; if (event.key === "Home") next = addDays(focusedDate, -weekday); if (event.key === "End") next = addDays(focusedDate, 6 - weekday); if (!next) return; event.preventDefault(); showDate(next); }; const atLatestPeriod = view === "days" ? visibleMonth.getFullYear() === today.getFullYear() && visibleMonth.getMonth() >= today.getMonth() : view === "months" ? visibleMonth.getFullYear() >= today.getFullYear() : yearPageStart + 11 >= today.getFullYear(); const movePeriod = (direction: -1 | 1) => { if (view === "days") { setVisibleMonth((date) => new Date(date.getFullYear(), date.getMonth() + direction, 1)); return; } if (view === "months") { setVisibleMonth((date) => new Date(date.getFullYear() + direction, date.getMonth(), 1)); return; } setYearPageStart((year) => year + direction * 12); }; const title = view === "days" ? `${visibleMonth.getFullYear()} 年 ${visibleMonth.getMonth() + 1} 月` : view === "months" ? `${visibleMonth.getFullYear()} 年` : `${yearPageStart} 至 ${yearPageStart + 11} 年`; const openCalendar = () => { const selected = startOfDay(selectedDate); setVisibleMonth(new Date(selected.getFullYear(), selected.getMonth(), 1)); setFocusedDate(selected); setYearPageStart(Math.floor(selected.getFullYear() / 12) * 12); setView("days"); setOpen(true); }; return (
{open && (
{view === "days" && ( <>
{WEEK_LABELS.map((label, index) => 4 ? "text-blue-600" : "text-slate-500")}>{label})}
{days.map((date) => { const selected = isSameDay(date, selectedDate); const current = isSameDay(date, today); const focused = isSameDay(date, focusedDate); const outsideMonth = date.getMonth() !== visibleMonth.getMonth(); const future = startOfDay(date).getTime() > today.getTime(); return ( ); })}
)} {view === "months" && (
{Array.from({ length: 12 }, (_, month) => { const future = visibleMonth.getFullYear() > today.getFullYear() || (visibleMonth.getFullYear() === today.getFullYear() && month > today.getMonth()); const selected = selectedDate.getFullYear() === visibleMonth.getFullYear() && selectedDate.getMonth() === month; return ( ); })}
)} {view === "years" && (
{Array.from({ length: 12 }, (_, offset) => yearPageStart + offset).map((year) => { const future = year > today.getFullYear(); const selected = year === selectedDate.getFullYear(); return ( ); })}
)}
)}
); } const playbackSpeedOptions = [ { value: 1000, label: "1.0×", description: "每秒一步" }, { value: 2500, label: "0.4×", description: "2.5 秒一步" }, { value: 5000, label: "0.2×", description: "5 秒一步" }, ]; function PlaybackSpeedPicker({ value, disabled, onChange }: { value: number; disabled: boolean; onChange: (value: number) => void }) { const wrapperRef = useRef(null); const [open, setOpen] = useState(false); const selected = playbackSpeedOptions.find((option) => option.value === value) ?? playbackSpeedOptions[1]; useEffect(() => { if (!open) return; const handlePointerDown = (event: PointerEvent) => { if (!wrapperRef.current?.contains(event.target as Node)) setOpen(false); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") setOpen(false); }; document.addEventListener("pointerdown", handlePointerDown); document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("pointerdown", handlePointerDown); document.removeEventListener("keydown", handleKeyDown); }; }, [open]); return (
{open && (
{playbackSpeedOptions.map((option) => ( ))}
)}
); } function SquareButton({ label, disabled, onClick, children }: { label: string; disabled: boolean; onClick: () => void; children: ReactNode }) { return ( ); } function TimelineButton({ label, disabled, active = false, onClick, children }: { label: string; disabled: boolean; active?: boolean; onClick: () => void; children: ReactNode }) { return ( ); }