93 lines
3.3 KiB
TypeScript
93 lines
3.3 KiB
TypeScript
import type { Race } from "../api";
|
||
import { formatRaceDate, isCloseDistance, parseFinishTimeToSeconds, parseRaceDate } from "../lib";
|
||
|
||
type PaceTrendChartProps = {
|
||
races: Race[];
|
||
distanceKm: number;
|
||
};
|
||
|
||
/** Линейный график: время финиша (минуты) по завершённым стартам выбранной дистанции. */
|
||
export function PaceTrendChart(props: PaceTrendChartProps): JSX.Element {
|
||
const { races, distanceKm } = props;
|
||
|
||
const series = 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) => {
|
||
const seconds = parseFinishTimeToSeconds(race.finishTime)!;
|
||
return { race, minutes: seconds / 60 };
|
||
});
|
||
|
||
if (series.length < 2) {
|
||
return (
|
||
<p className="pace-chart__empty">
|
||
Нужно минимум два завершённых старта с временем на выбранной дистанции.
|
||
</p>
|
||
);
|
||
}
|
||
|
||
const minutes = series.map((s) => s.minutes);
|
||
const minM = Math.min(...minutes);
|
||
const maxM = Math.max(...minutes);
|
||
const range = maxM - minM || 1;
|
||
const n = series.length;
|
||
|
||
const pad = 4;
|
||
const w = 100;
|
||
const h = 36;
|
||
const innerW = w - pad * 2;
|
||
const innerH = h - pad * 2;
|
||
|
||
const points = series
|
||
.map((s, i) => {
|
||
const x = pad + (n === 1 ? innerW / 2 : (i / (n - 1)) * innerW);
|
||
const norm = (maxM - s.minutes) / range;
|
||
const y = pad + (1 - norm) * innerH;
|
||
return `${x},${y}`;
|
||
})
|
||
.join(" ");
|
||
|
||
const last = series[series.length - 1]!;
|
||
const best = series.reduce((currentBest, item) => (item.minutes < currentBest.minutes ? item : currentBest), series[0]!);
|
||
const dotPoints = series.map((s, i) => {
|
||
const x = pad + (n === 1 ? innerW / 2 : (i / (n - 1)) * innerW);
|
||
const norm = (maxM - s.minutes) / range;
|
||
const y = pad + (1 - norm) * innerH;
|
||
return { x, y, id: s.race.id };
|
||
});
|
||
|
||
return (
|
||
<div className="pace-chart">
|
||
<svg className="pace-chart__svg" viewBox={`0 0 ${w} ${h}`} role="img" aria-label="Динамика времени на дистанции">
|
||
<line className="pace-chart__grid-line" x1={pad} y1={pad} x2={w - pad} y2={pad} />
|
||
<line className="pace-chart__grid-line" x1={pad} y1={h - pad} x2={w - pad} y2={h - pad} />
|
||
<polyline className="pace-chart__line" fill="none" points={points} />
|
||
{dotPoints.map((point, index) => (
|
||
<circle
|
||
key={point.id}
|
||
className={index === dotPoints.length - 1 ? "pace-chart__dot pace-chart__dot--last" : "pace-chart__dot"}
|
||
cx={point.x}
|
||
cy={point.y}
|
||
r="1.6"
|
||
/>
|
||
))}
|
||
</svg>
|
||
<div className="pace-chart__stats">
|
||
<p className="pace-chart__caption">
|
||
Последний: {formatRaceDate(last.race.date)} · {last.race.finishTime} · {last.minutes.toFixed(1)} мин
|
||
</p>
|
||
<p className="pace-chart__caption pace-chart__caption--best">
|
||
Лучший: {formatRaceDate(best.race.date)} · {best.race.finishTime} · {best.minutes.toFixed(1)} мин
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|