forked from admin/runners-calendar
115 lines
4.1 KiB
TypeScript
115 lines
4.1 KiB
TypeScript
import { AgCharts } from "ag-charts-react";
|
||
import type { AgChartOptions } from "ag-charts-community";
|
||
import { useMemo } from "react";
|
||
import { useNavigate } from "react-router-dom";
|
||
import type { Race } from "../api";
|
||
import { formatRaceDate, getPaceLabel, isCloseDistance, parseFinishTimeToSeconds, parseRaceDate } from "../lib";
|
||
|
||
type PaceTrendChartProps = {
|
||
races: Race[];
|
||
distanceKm: number;
|
||
};
|
||
|
||
type PaceDatum = {
|
||
id: string;
|
||
date: string;
|
||
dateLabel: string;
|
||
minutes: number;
|
||
race: Race;
|
||
};
|
||
|
||
function formatAxisTime(minutes: number): string {
|
||
const totalSeconds = Math.round(minutes * 60);
|
||
const hours = Math.floor(totalSeconds / 3600);
|
||
const rest = totalSeconds % 3600;
|
||
return hours ? `${hours}:${String(Math.floor(rest / 60)).padStart(2, "0")}` : `${Math.floor(rest / 60)}:${String(rest % 60).padStart(2, "0")}`;
|
||
}
|
||
|
||
function formatShortDate(date: string): string {
|
||
return parseRaceDate(date).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
|
||
}
|
||
|
||
function cssToken(name: string, fallback: string): string {
|
||
if (typeof window === "undefined") {
|
||
return fallback;
|
||
}
|
||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
||
}
|
||
|
||
/** Линейный график: время финиша по завершённым стартам выбранной дистанции. */
|
||
export function PaceTrendChart({ races, distanceKm }: PaceTrendChartProps): JSX.Element {
|
||
const navigate = useNavigate();
|
||
const series = useMemo<PaceDatum[]>(
|
||
() => races
|
||
.filter((race) => race.status === "completed" && isCloseDistance(race.distanceKm, distanceKm) && parseFinishTimeToSeconds(race.finishTime) != null)
|
||
.sort((a, b) => parseRaceDate(a.date).getTime() - parseRaceDate(b.date).getTime())
|
||
.map((race) => ({
|
||
id: race.id,
|
||
date: race.date,
|
||
dateLabel: formatShortDate(race.date),
|
||
minutes: parseFinishTimeToSeconds(race.finishTime)! / 60,
|
||
race,
|
||
})),
|
||
[distanceKm, races],
|
||
);
|
||
|
||
const options = useMemo<AgChartOptions<PaceDatum>>(() => ({
|
||
data: series,
|
||
autoSize: true,
|
||
minHeight: 280,
|
||
padding: { top: 16, right: 20, bottom: 16, left: 16 },
|
||
background: { fill: "transparent" },
|
||
animation: { enabled: false },
|
||
keyboard: { enabled: true, tabIndex: 0, initialFocus: "data-start" },
|
||
axes: {
|
||
x: { type: "category", position: "bottom", title: { text: "Дата старта" } },
|
||
y: {
|
||
type: "number",
|
||
position: "left",
|
||
title: { text: "Время" },
|
||
label: { formatter: ({ value }) => formatAxisTime(Number(value)) },
|
||
},
|
||
},
|
||
series: [{
|
||
type: "line",
|
||
xKey: "dateLabel",
|
||
xName: "Дата старта",
|
||
yKey: "minutes",
|
||
yName: "Время",
|
||
stroke: cssToken("--color-accent", "#1168d8"),
|
||
strokeWidth: 3,
|
||
marker: {
|
||
enabled: true,
|
||
size: 8,
|
||
fill: cssToken("--color-surface", "#ffffff"),
|
||
stroke: cssToken("--color-accent", "#1168d8"),
|
||
strokeWidth: 2,
|
||
},
|
||
tooltip: {
|
||
renderer: ({ datum }) => ({
|
||
title: datum.race.title,
|
||
data: [
|
||
{ label: "Дата", value: formatRaceDate(datum.date) },
|
||
{ label: "Время", value: datum.race.finishTime ?? "—" },
|
||
{ label: "Темп", value: getPaceLabel(datum.race.finishTime, datum.race.distanceKm) ?? "—" },
|
||
],
|
||
}),
|
||
},
|
||
}],
|
||
listeners: {
|
||
seriesNodeClick: ({ datum }) => navigate(`/races/${datum.race.id}`),
|
||
},
|
||
}), [navigate, series]);
|
||
|
||
if (series.length < 2) {
|
||
return <p className="pace-chart__empty">Нужно минимум два завершённых старта с временем на выбранной дистанции.</p>;
|
||
}
|
||
|
||
return (
|
||
<div className="pace-chart" role="group" aria-label="Динамика времени на дистанции. Выберите точку, чтобы открыть старт.">
|
||
<AgCharts options={options} />
|
||
<p className="pace-chart__hint">Наведите на точку или выберите её клавиатурой.</p>
|
||
</div>
|
||
);
|
||
}
|