feat(fitness/stats): body-fat trend chart as Δ from baseline

Mirrors the weight chart pipeline (SMA + ±1σ confidence band) for
body-fat %, but emits deltas from the first displayed measurement so
the y-axis shows change instead of raw numbers. Title surfaces the
baseline (e.g. "Body Fat · Δ from 18.2%"), y-unit is "pp" (percentage
points), colours are purple trend on top of an orange raw-data line
so it reads differently from weight's green+blue at a glance.

FitnessChart gained two shared upgrades: `interaction.mode = 'index'`
on line charts so hovering the x-axis shows tooltips for every dataset
(including the trend line whose pointRadius is 0), and a `σ` dataset
filter so the confidence band doesn't clutter the tooltip. A new
optional `tooltipFormatter` prop lets callers format the hover label;
the BF chart uses it to show the signed delta + reconstructed
absolute % for raw points and additionally the ±1σ window for trend
points (e.g. "+0.30 ±0.45 pp · 18.5% (18.0–18.9%)").
This commit is contained in:
2026-04-23 13:57:47 +02:00
parent 8611275bca
commit f807a43d58
5 changed files with 170 additions and 7 deletions
+17 -3
View File
@@ -10,10 +10,11 @@
* title?: string,
* height?: string,
* yUnit?: string,
* goalLine?: number
* goalLine?: number,
* tooltipFormatter?: (value: number, datasetIndex: number, dataIndex: number, label: string) => string
* }}
*/
let { type = 'line', data, title = '', height = '250px', yUnit = '', goalLine = undefined } = $props();
let { type = 'line', data, title = '', height = '250px', yUnit = '', goalLine = undefined, tooltipFormatter = undefined } = $props();
/** @type {HTMLCanvasElement | undefined} */
let canvas = $state(undefined);
@@ -109,6 +110,7 @@
responsive: true,
maintainAspectRatio: false,
animation: { duration: 0 },
interaction: type === 'line' ? { mode: 'index', intersect: false } : undefined,
scales: {
x: useTimeAxis ? {
type: 'time',
@@ -156,7 +158,19 @@
bodyColor: dark ? '#D8DEE9' : '#3B4252',
borderWidth: 0,
cornerRadius: 8,
padding: 10
padding: 10,
filter: (/** @type {any} */ ctx) => !(ctx.dataset?.label ?? '').includes('σ'),
...(tooltipFormatter ? {
callbacks: {
label: (/** @type {any} */ ctx) => {
const v = ctx.parsed.y;
const label = ctx.dataset.label ?? '';
if (v == null) return label;
const formatted = tooltipFormatter(v, ctx.datasetIndex, ctx.dataIndex, label);
return `${label}: ${formatted}`;
}
}
} : {})
}
})
}