218 lines
7.0 KiB
TypeScript
218 lines
7.0 KiB
TypeScript
import { useMemo } from "react";
|
||
import { FitnessChartPoint, FitnessChartSeries, FitnessChartsResponse } from "../api/client";
|
||
import "./FitnessCharts.css";
|
||
|
||
const CHART_KEYS = ["weight_kg", "calories", "protein_g", "water_l", "steps", "body_fat_pct"] as const;
|
||
|
||
interface MetricChartProps {
|
||
series: FitnessChartSeries;
|
||
showTrend: boolean;
|
||
granularity: "week" | "day";
|
||
}
|
||
|
||
function formatTick(point: FitnessChartPoint, granularity: "week" | "day") {
|
||
if (granularity === "day") {
|
||
const d = new Date(`${point.date}T12:00:00`);
|
||
return d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
|
||
}
|
||
const d = new Date(`${point.week_start}T12:00:00`);
|
||
return d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
|
||
}
|
||
|
||
function MetricChart({ series, showTrend, granularity }: MetricChartProps) {
|
||
const layout = useMemo(() => {
|
||
const width = 640;
|
||
const height = 168;
|
||
const pad = { top: 12, right: 12, bottom: 28, left: 44 };
|
||
const plotW = width - pad.left - pad.right;
|
||
const plotH = height - pad.top - pad.bottom;
|
||
|
||
const active = series.points.filter((p) => p.has_data && p.value != null);
|
||
if (active.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
const xMax = Math.max(1, series.points.length - 1);
|
||
const values = active.map((p) => p.value as number);
|
||
let yMin = Math.min(...values);
|
||
let yMax = Math.max(...values);
|
||
if (yMin === yMax) {
|
||
yMin -= 1;
|
||
yMax += 1;
|
||
} else {
|
||
const padY = (yMax - yMin) * 0.12;
|
||
yMin -= padY;
|
||
yMax += padY;
|
||
}
|
||
|
||
const xScale = (index: number) => pad.left + (index / xMax) * plotW;
|
||
const yScale = (value: number) => pad.top + plotH - ((value - yMin) / (yMax - yMin)) * plotH;
|
||
|
||
const dots = active.map((p) => ({
|
||
x: xScale(p.index),
|
||
y: yScale(p.value as number),
|
||
label: formatTick(p, granularity),
|
||
value: p.value as number,
|
||
}));
|
||
|
||
let trendPath = "";
|
||
if (showTrend && series.trend?.line) {
|
||
const trendPoints = series.trend.line
|
||
.map((p) => `${xScale(p.index)},${yScale(p.value)}`)
|
||
.join(" ");
|
||
trendPath = trendPoints;
|
||
}
|
||
|
||
const yTicks = [yMin, (yMin + yMax) / 2, yMax];
|
||
const labelIndexes = active.length <= 4
|
||
? active.map((p) => p.index)
|
||
: [active[0].index, active[active.length - 1].index];
|
||
|
||
return {
|
||
width,
|
||
height,
|
||
pad,
|
||
plotW,
|
||
plotH,
|
||
yMin,
|
||
yMax,
|
||
yScale,
|
||
xScale,
|
||
dots,
|
||
trendPath,
|
||
yTicks,
|
||
labelIndexes,
|
||
};
|
||
}, [granularity, series, showTrend]);
|
||
|
||
if (!layout) {
|
||
return null;
|
||
}
|
||
|
||
const slopeLabel =
|
||
granularity === "day" && series.trend && "slope_per_day" in series.trend
|
||
? `${(series.trend as { slope_per_day: number }).slope_per_day > 0 ? "+" : ""}${(series.trend as { slope_per_day: number }).slope_per_day}/день`
|
||
: series.trend && "slope_per_week" in series.trend
|
||
? `${(series.trend as { slope_per_week: number }).slope_per_week > 0 ? "+" : ""}${(series.trend as { slope_per_week: number }).slope_per_week}/нед`
|
||
: null;
|
||
|
||
return (
|
||
<article className="fitness-chart-card">
|
||
<div className="fitness-chart-card-header">
|
||
<h4>
|
||
{series.label} <span className="fitness-chart-unit">({series.unit})</span>
|
||
</h4>
|
||
<span className="fitness-chart-meta">
|
||
{series.data_points} {granularity === "day" ? "дн." : "нед."}
|
||
{showTrend && slopeLabel ? ` · тренд ${slopeLabel}` : ""}
|
||
</span>
|
||
</div>
|
||
<svg
|
||
className="fitness-chart-svg"
|
||
viewBox={`0 0 ${layout.width} ${layout.height}`}
|
||
role="img"
|
||
aria-label={`График ${series.label}`}
|
||
>
|
||
{layout.yTicks.map((tick) => (
|
||
<g key={tick}>
|
||
<line
|
||
x1={layout.pad.left}
|
||
x2={layout.pad.left + layout.plotW}
|
||
y1={layout.yScale(tick)}
|
||
y2={layout.yScale(tick)}
|
||
className="fitness-chart-grid"
|
||
/>
|
||
<text x={layout.pad.left - 6} y={layout.yScale(tick) + 4} className="fitness-chart-axis">
|
||
{tick.toFixed(tick >= 100 ? 0 : 1)}
|
||
</text>
|
||
</g>
|
||
))}
|
||
|
||
{showTrend && layout.trendPath ? (
|
||
<polyline points={layout.trendPath} className="fitness-chart-trend" />
|
||
) : null}
|
||
|
||
{layout.dots.map((dot) => (
|
||
<g key={`${dot.label}-${dot.value}`}>
|
||
<circle cx={dot.x} cy={dot.y} r={4.5} className="fitness-chart-dot" />
|
||
<title>
|
||
{dot.label}: {dot.value.toFixed(1)} {series.unit}
|
||
</title>
|
||
</g>
|
||
))}
|
||
|
||
{layout.labelIndexes.map((idx) => {
|
||
const point = series.points[idx];
|
||
if (!point?.has_data) return null;
|
||
return (
|
||
<text
|
||
key={idx}
|
||
x={layout.xScale(idx)}
|
||
y={layout.height - 8}
|
||
textAnchor="middle"
|
||
className="fitness-chart-axis"
|
||
>
|
||
{formatTick(point, granularity)}
|
||
</text>
|
||
);
|
||
})}
|
||
</svg>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
interface FitnessChartsProps {
|
||
data: FitnessChartsResponse | null;
|
||
showTrend: boolean;
|
||
loading?: boolean;
|
||
}
|
||
|
||
export default function FitnessCharts({ data, showTrend, loading }: FitnessChartsProps) {
|
||
if (loading) {
|
||
return <p className="fitness-chart-status">Загрузка графиков…</p>;
|
||
}
|
||
if (!data) {
|
||
return null;
|
||
}
|
||
|
||
const granularity = data.granularity;
|
||
const source =
|
||
granularity === "day" && data.daily_series ? data.daily_series : data.series;
|
||
|
||
const charts = CHART_KEYS.map((key) => source[key]).filter(
|
||
(series) => series && series.data_points > 0,
|
||
);
|
||
|
||
return (
|
||
<section className="fitness-section fitness-charts-section">
|
||
<div className="fitness-charts-head">
|
||
<div>
|
||
<h3>Динамика за год</h3>
|
||
<p className="fitness-charts-subtitle">
|
||
{granularity === "day"
|
||
? `Мало данных (${data.days_with_data} дн.) — показаны дневные точки`
|
||
: `Недельные точки · заполнено ${data.weeks_with_data} из ${data.weeks} нед.`}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{charts.length === 0 ? (
|
||
<p className="fitness-empty">
|
||
Пока нет данных для графиков. Логируй еду, воду, шаги или вес — точки появятся автоматически.
|
||
</p>
|
||
) : (
|
||
<div className="fitness-charts-grid">
|
||
{charts.map((series) => (
|
||
<MetricChart
|
||
key={series.key}
|
||
series={series}
|
||
showTrend={showTrend}
|
||
granularity={granularity}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|