Files
runners-calendar/frontend/src/components/PaceTrendChart.tsx
2026-07-13 08:31:12 +03:00

115 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}