Files
Home_assistant/frontend/src/components/FitnessCharts.tsx
T
2026-06-14 06:26:16 +00:00

218 lines
7.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 { 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>
);
}