The scene previously returned early when realtime rows were absent and selected map-tier assets for network mode, hiding SCADA markers and station interiors and floors. Use SCADA timestamps as valid frames, render node-aligned monitor markers, and pin the full network context in the manifest regression test. Keep the timeline close action inside its drag header.
665 lines
31 KiB
TypeScript
665 lines
31 KiB
TypeScript
"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<HTMLElement>(null);
|
||
const dragShieldRef = useRef<HTMLDivElement>(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<number | null>(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 (
|
||
<div
|
||
className={clsx(
|
||
"pointer-events-none absolute inset-x-2 bottom-2 z-20 flex justify-center transition-[right] duration-200 md:left-4 md:bottom-4",
|
||
sidePanelOpen ? "md:right-[416px]" : "md:right-4",
|
||
)}
|
||
>
|
||
<div
|
||
ref={dragShieldRef}
|
||
aria-hidden="true"
|
||
className="pointer-events-auto fixed inset-0 z-0 hidden cursor-move"
|
||
/>
|
||
<DraggableCore
|
||
nodeRef={timelineRef}
|
||
handle=".timeline-drag-handle"
|
||
cancel="button, input, select, [role='dialog']"
|
||
onStart={handleDragStart}
|
||
onDrag={handleDrag}
|
||
onStop={handleDragStop}
|
||
>
|
||
<section
|
||
ref={timelineRef}
|
||
aria-label="三维场景时间轴"
|
||
className={clsx(
|
||
glassClass,
|
||
"pointer-events-auto relative z-10 w-full max-w-[950px] rounded-2xl opacity-95 transition-opacity duration-200 hover:opacity-100",
|
||
)}
|
||
>
|
||
<div className="timeline-drag-handle relative flex h-9 cursor-move touch-none items-center justify-center rounded-t-2xl border-b border-white/35 bg-white/[0.08]">
|
||
<span aria-hidden="true" className="h-1 w-10 rounded-full bg-slate-400/55 transition-colors hover:bg-slate-500/70" />
|
||
<button
|
||
type="button"
|
||
aria-label="收起时间轴"
|
||
title="收起时间轴"
|
||
onClick={onClose}
|
||
className="absolute right-2 top-1/2 grid h-8 w-8 -translate-y-1/2 place-items-center rounded-full bg-white/20 text-base text-slate-600 ring-1 ring-white/55 [box-shadow:inset_0_1px_0_rgba(255,255,255,0.72),0_4px_12px_rgba(15,43,69,0.08)] after:absolute after:-inset-1 after:content-[''] transition-[transform,color,background-color,box-shadow] [@media(hover:hover)]:hover:bg-white/50 [@media(hover:hover)]:hover:text-slate-900 [@media(hover:hover)]:hover:[box-shadow:inset_0_1px_0_rgba(255,255,255,0.88),0_6px_16px_rgba(15,43,69,0.14)] active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/45"
|
||
>
|
||
<FiX />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="px-3 pb-3 pt-3 md:px-4 md:pb-4">
|
||
<div className="mb-3 flex items-center gap-2">
|
||
<span className="text-xs font-semibold text-slate-800">结果时间轴</span>
|
||
<span className="rounded-md bg-blue-50/60 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 ring-1 ring-blue-200/55">{stepMinutes} 分钟步长</span>
|
||
<time className="ml-auto hidden text-xs tabular-nums text-slate-500 sm:block">
|
||
{formatDate(selectedDate)} {formatTimelineTime(safeCurrentTime, 0, durationMinutes)}
|
||
</time>
|
||
</div>
|
||
|
||
<div className="space-y-2.5">
|
||
<div className="grid gap-2.5 sm:grid-cols-[minmax(260px,0.85fr)_minmax(350px,1.15fr)]">
|
||
<div className="rounded-xl bg-white/25 px-3 py-2 ring-1 ring-white/40">
|
||
<span className="mb-1.5 block text-[11px] font-medium text-slate-500">数据日期</span>
|
||
<div className="flex w-full items-center justify-between gap-3">
|
||
<SquareButton label="后退一天" disabled={disabled} onClick={() => onSelectedDateChange(addDays(selectedDate, -1))}>
|
||
<FiChevronLeft />
|
||
</SquareButton>
|
||
<CalendarPicker
|
||
selectedDate={selectedDate}
|
||
disabled={disabled}
|
||
onChange={onSelectedDateChange}
|
||
/>
|
||
<SquareButton
|
||
label="前进一天"
|
||
disabled={disabled || startOfDay(selectedDate).getTime() >= startOfDay(new Date()).getTime()}
|
||
onClick={() => onSelectedDateChange(addDays(selectedDate, 1))}
|
||
>
|
||
<FiChevronRight />
|
||
</SquareButton>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-xl bg-white/25 px-3 py-2 ring-1 ring-white/40">
|
||
<span className="mb-1.5 block text-[11px] font-medium text-slate-500">播放控制</span>
|
||
<div className="flex w-full items-center justify-between gap-3">
|
||
<PlaybackSpeedPicker
|
||
value={playIntervalMs}
|
||
disabled={disabled}
|
||
onChange={setPlayIntervalMs}
|
||
/>
|
||
<div className="flex items-center gap-2">
|
||
<TimelineButton label={`后退 ${stepMinutes} 分钟`} disabled={disabled} onClick={() => advance(-1)}><FiSkipBack /></TimelineButton>
|
||
<TimelineButton label={playing ? "暂停播放" : "播放时间轴"} disabled={disabled} active={playing} onClick={() => setPlaying((value) => !value)}>{playing ? <FiPause /> : <FiPlay className="translate-x-px" />}</TimelineButton>
|
||
<TimelineButton label={`前进 ${stepMinutes} 分钟`} disabled={disabled} onClick={() => advance(1)}><FiSkipForward /></TimelineButton>
|
||
</div>
|
||
<TimelineButton label="回到当前时刻" disabled={disabled} onClick={resetToCurrentTime}><FiRotateCcw /></TimelineButton>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="min-w-0 rounded-xl bg-white/20 px-3 pb-2 pt-2.5 ring-1 ring-white/35">
|
||
<div className="mb-1.5 flex items-baseline gap-2">
|
||
<span className="text-[11px] text-slate-500">查询时刻</span>
|
||
<strong className="text-sm font-semibold tabular-nums text-slate-800">{formatTimelineTime(safeCurrentTime, 0, durationMinutes)}</strong>
|
||
</div>
|
||
<div className="relative">
|
||
<input
|
||
type="range"
|
||
aria-label="三维场景查询时刻"
|
||
min={0}
|
||
max={durationMinutes}
|
||
step={stepMinutes}
|
||
value={safeCurrentTime}
|
||
disabled={disabled}
|
||
onChange={(event) => setPreviewTime(Number(event.target.value))}
|
||
onPointerUp={(event) => commitPreview(event.currentTarget.value)}
|
||
onKeyUp={(event) => commitPreview(event.currentTarget.value)}
|
||
onBlur={(event) => previewTime !== null && commitPreview(event.currentTarget.value)}
|
||
className="block h-1.5 w-full cursor-pointer appearance-none rounded-full disabled:cursor-not-allowed disabled:opacity-40 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-blue-600 [&::-moz-range-thumb]:shadow-md [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-600 [&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:ring-[3px] [&::-webkit-slider-thumb]:ring-white/80"
|
||
style={{ background: `linear-gradient(90deg, #2563eb 0%, #2563eb ${progress}%, rgba(148,163,184,.42) ${progress}%, rgba(148,163,184,.42) 100%)` }}
|
||
/>
|
||
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-1/2 flex -translate-y-1/2 justify-between px-0.5">
|
||
{[0, 1, 2, 3, 4].map((mark) => <span key={mark} className="h-1 w-1 rounded-full bg-white/90 shadow-sm" />)}
|
||
</div>
|
||
</div>
|
||
<div aria-hidden="true" className="mt-1.5 flex justify-between text-[10px] tabular-nums text-slate-500">
|
||
{marks.map((mark, index) => <span key={`${mark}-${index}`} className={clsx(index > 0 && index < marks.length - 1 && "hidden sm:inline")}>{mark}</span>)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</DraggableCore>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type CalendarView = "days" | "months" | "years";
|
||
|
||
function CalendarPicker({ selectedDate, disabled, onChange }: { selectedDate: Date; disabled: boolean; onChange: (date: Date) => void }) {
|
||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||
const calendarRef = useRef<HTMLDivElement>(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<CalendarView>("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<HTMLButtonElement>("[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<HTMLDivElement>) => {
|
||
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 (
|
||
<div ref={wrapperRef} className="relative">
|
||
<button
|
||
type="button"
|
||
aria-label={`选择日期,当前 ${formatDate(selectedDate)}`}
|
||
aria-haspopup="dialog"
|
||
aria-expanded={open}
|
||
disabled={disabled}
|
||
onClick={() => {
|
||
if (open) setOpen(false);
|
||
else openCalendar();
|
||
}}
|
||
className={clsx(
|
||
"flex h-10 min-w-[154px] items-center gap-2 rounded-xl bg-white/48 px-3 text-sm tabular-nums text-slate-700 ring-1 ring-white/65 transition-[transform,background-color,box-shadow] hover:bg-white/72 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-40",
|
||
open && "bg-white/78 ring-2 ring-blue-500/35",
|
||
)}
|
||
>
|
||
<FiCalendar className="text-blue-600" />
|
||
<span>{formatDate(selectedDate)}</span>
|
||
<FiChevronDown className={clsx("ml-auto text-xs text-slate-500 transition-transform", open && "rotate-180")} />
|
||
</button>
|
||
|
||
{open && (
|
||
<div
|
||
ref={calendarRef}
|
||
role="dialog"
|
||
aria-label="选择数据日期"
|
||
onKeyDown={handleCalendarKeyDown}
|
||
className={clsx(
|
||
calendarGlassClass,
|
||
"absolute bottom-[calc(100%+10px)] left-[-46px] z-50 w-[min(312px,calc(100vw-32px))] rounded-2xl p-3 sm:left-0",
|
||
)}
|
||
>
|
||
<div className="mb-2 flex items-center">
|
||
<button type="button" aria-label={view === "years" ? "前十二年" : view === "months" ? "上一年" : "上个月"} onClick={() => movePeriod(-1)} className="grid h-10 w-10 place-items-center rounded-xl text-slate-600 transition hover:bg-white/55 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"><FiChevronLeft /></button>
|
||
<button
|
||
type="button"
|
||
aria-label={view === "days" ? "选择月份" : view === "months" ? "选择年份" : "返回日期"}
|
||
aria-live="polite"
|
||
onClick={() => setView((current) => current === "days" ? "months" : current === "months" ? "years" : "days")}
|
||
className="min-h-10 flex-1 rounded-xl px-2 text-center text-sm font-semibold tabular-nums text-slate-800 transition hover:bg-white/55 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"
|
||
>
|
||
{title}
|
||
</button>
|
||
<button type="button" onClick={selectToday} className="min-h-10 rounded-xl px-2.5 text-[11px] font-semibold text-blue-700 transition hover:bg-white/55 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40">今天</button>
|
||
<button type="button" aria-label={view === "years" ? "后十二年" : view === "months" ? "下一年" : "下个月"} disabled={atLatestPeriod} onClick={() => movePeriod(1)} className="grid h-10 w-10 place-items-center rounded-xl text-slate-600 transition hover:bg-white/55 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-25"><FiChevronRight /></button>
|
||
</div>
|
||
{view === "days" && (
|
||
<>
|
||
<div className="grid grid-cols-7 text-center">
|
||
{WEEK_LABELS.map((label, index) => <span key={label} className={clsx("py-1 text-[10px] font-medium", index > 4 ? "text-blue-600" : "text-slate-500")}>{label}</span>)}
|
||
</div>
|
||
<div className="grid grid-cols-7 gap-0.5">
|
||
{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 (
|
||
<button
|
||
key={date.toISOString()}
|
||
type="button"
|
||
aria-label={`${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日${current ? ",今天" : ""}`}
|
||
aria-pressed={selected}
|
||
data-calendar-focused={focused}
|
||
tabIndex={focused ? 0 : -1}
|
||
disabled={future}
|
||
onFocus={() => setFocusedDate(startOfDay(date))}
|
||
onClick={() => selectDate(date)}
|
||
className={clsx(
|
||
"relative grid h-9 w-9 place-items-center rounded-xl text-xs tabular-nums transition-[transform,color,background-color,box-shadow] active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/45 disabled:cursor-not-allowed disabled:opacity-20",
|
||
selected
|
||
? "bg-blue-600 font-semibold text-white shadow-md shadow-blue-700/20"
|
||
: "text-slate-700 hover:bg-white/65 hover:text-blue-700",
|
||
outsideMonth && !selected && "text-slate-400",
|
||
current && !selected && "font-semibold text-blue-700 after:absolute after:bottom-1 after:h-1 after:w-1 after:rounded-full after:bg-blue-600",
|
||
)}
|
||
>
|
||
{date.getDate()}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</>
|
||
)}
|
||
{view === "months" && (
|
||
<div role="grid" aria-label={`${visibleMonth.getFullYear()} 年月份`} className="grid grid-cols-3 gap-1 py-1">
|
||
{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 (
|
||
<button key={month} type="button" role="gridcell" aria-selected={selected} disabled={future} onClick={() => { setVisibleMonth(new Date(visibleMonth.getFullYear(), month, 1)); setView("days"); }} className={clsx("min-h-11 rounded-xl text-sm transition hover:bg-white/60 hover:text-blue-700 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-20", selected ? "bg-blue-600 font-semibold text-white shadow-sm hover:bg-blue-600 hover:text-white" : "text-slate-700")}>{month + 1} 月</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
{view === "years" && (
|
||
<div role="grid" aria-label="选择年份" className="grid grid-cols-3 gap-1 py-1">
|
||
{Array.from({ length: 12 }, (_, offset) => yearPageStart + offset).map((year) => {
|
||
const future = year > today.getFullYear();
|
||
const selected = year === selectedDate.getFullYear();
|
||
return (
|
||
<button key={year} type="button" role="gridcell" aria-selected={selected} disabled={future} onClick={() => { const month = year === today.getFullYear() ? Math.min(visibleMonth.getMonth(), today.getMonth()) : visibleMonth.getMonth(); setVisibleMonth(new Date(year, month, 1)); setView("months"); }} className={clsx("min-h-11 rounded-xl text-sm tabular-nums transition hover:bg-white/60 hover:text-blue-700 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-20", selected ? "bg-blue-600 font-semibold text-white shadow-sm hover:bg-blue-600 hover:text-white" : "text-slate-700")}>{year}</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<HTMLDivElement>(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 (
|
||
<div ref={wrapperRef} className="relative">
|
||
<button
|
||
type="button"
|
||
aria-label={`播放速度,当前 ${selected.label}`}
|
||
aria-haspopup="listbox"
|
||
aria-expanded={open}
|
||
disabled={disabled}
|
||
onClick={() => setOpen((current) => !current)}
|
||
className={clsx(
|
||
"flex h-10 min-w-[86px] items-center gap-2 rounded-xl bg-white/46 px-2.5 text-xs font-medium tabular-nums text-slate-700 ring-1 ring-white/65 transition-[transform,background-color,box-shadow] hover:bg-white/72 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-40",
|
||
open && "bg-white/78 ring-2 ring-blue-500/35",
|
||
)}
|
||
>
|
||
<FiZap aria-hidden="true" className="text-sm text-blue-600" />
|
||
<span>{selected.label}</span>
|
||
<FiChevronDown aria-hidden="true" className={clsx("ml-auto text-xs text-slate-500 transition-transform", open && "rotate-180")} />
|
||
</button>
|
||
{open && (
|
||
<div
|
||
role="listbox"
|
||
aria-label="选择播放速度"
|
||
className={clsx(
|
||
calendarGlassClass,
|
||
"absolute bottom-[calc(100%+8px)] left-0 z-50 w-36 overflow-hidden rounded-xl p-1.5",
|
||
)}
|
||
>
|
||
{playbackSpeedOptions.map((option) => (
|
||
<button
|
||
key={option.value}
|
||
type="button"
|
||
role="option"
|
||
aria-selected={option.value === value}
|
||
onClick={() => {
|
||
onChange(option.value);
|
||
setOpen(false);
|
||
}}
|
||
className={clsx(
|
||
"flex min-h-11 w-full items-center rounded-lg px-3 text-left transition-[transform,color,background-color] active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500/40",
|
||
option.value === value
|
||
? "bg-blue-600 text-white shadow-sm shadow-blue-700/20"
|
||
: "text-slate-700 hover:bg-white/60 hover:text-blue-700",
|
||
)}
|
||
>
|
||
<span className="text-sm font-semibold tabular-nums">{option.label}</span>
|
||
<span className={clsx("ml-auto text-[10px]", option.value === value ? "text-blue-100" : "text-slate-500")}>{option.description}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SquareButton({ label, disabled, onClick, children }: { label: string; disabled: boolean; onClick: () => void; children: ReactNode }) {
|
||
return (
|
||
<button type="button" aria-label={label} title={label} disabled={disabled} onClick={onClick} className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-white/42 text-base text-slate-600 ring-1 ring-white/60 transition-[transform,color,background-color] hover:bg-blue-50/75 hover:text-blue-700 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-35">
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function TimelineButton({ label, disabled, active = false, onClick, children }: { label: string; disabled: boolean; active?: boolean; onClick: () => void; children: ReactNode }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
aria-label={label}
|
||
title={label}
|
||
disabled={disabled}
|
||
onClick={onClick}
|
||
className={clsx(
|
||
"grid h-10 w-10 shrink-0 place-items-center rounded-full text-base transition-[transform,color,background-color,box-shadow] duration-150 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-35",
|
||
active
|
||
? "bg-blue-600 text-white shadow-md shadow-blue-700/25"
|
||
: "bg-slate-100/52 text-slate-600 ring-1 ring-white/55 hover:bg-blue-50/80 hover:text-blue-700",
|
||
)}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|