Files
runners-calendar/frontend/src/components/PaceTrendChart.tsx
admin 880d57dc59
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
fix(frontend): sort completed races and show pace scale
2026-08-13 18:39:24 +03:00

115 lines
4.0 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;
paceSeconds: number;
race: Race;
};
function formatAxisPace(seconds: number): string {
const roundedSeconds = Math.round(seconds);
return `${Math.floor(roundedSeconds / 60)}:${String(roundedSeconds % 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),
paceSeconds: parseFinishTimeToSeconds(race.finishTime)! / race.distanceKm,
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",
reverse: true,
min: 0,
title: { text: "Темп, мин/км" },
label: { formatter: ({ value }) => formatAxisPace(Number(value)) },
},
},
series: [{
type: "line",
xKey: "dateLabel",
xName: "Дата старта",
yKey: "paceSeconds",
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>
);
}