fitness: disable chart grow-in animation, add trendlines to exercise charts
All checks were successful
CI / update (push) Successful in 2m16s
All checks were successful
CI / update (push) Successful in 2m16s
Disable initial animation on all Chart.js charts (FitnessChart and cospend BarChart) while keeping transition animations for interactions. Add linear regression trendline with ±1σ uncertainty bands to exercise charts (Est. 1RM, Max Weight, Total Volume).
This commit is contained in:
@@ -107,6 +107,7 @@
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 0 },
|
||||
layout: {
|
||||
padding: {
|
||||
top: 40
|
||||
@@ -322,6 +323,15 @@
|
||||
|
||||
onMount(() => {
|
||||
createChart();
|
||||
// Enable animations for subsequent updates (legend toggles, etc.)
|
||||
requestAnimationFrame(() => {
|
||||
if (chart) {
|
||||
chart.options.animation = { duration: 300 };
|
||||
chart.options.transitions = {
|
||||
active: { animation: { duration: 200 } }
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for theme changes (both media query and data-theme attribute)
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
animation: { duration: 0 },
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
@@ -120,6 +120,14 @@
|
||||
|
||||
onMount(() => {
|
||||
createChart();
|
||||
requestAnimationFrame(() => {
|
||||
if (chart) {
|
||||
chart.options.animation = { duration: 300 };
|
||||
chart.options.transitions = {
|
||||
active: { animation: { duration: 200 } }
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const onTheme = () => setTimeout(createChart, 100);
|
||||
|
||||
@@ -19,28 +19,116 @@
|
||||
|
||||
const est1rmChartData = $derived.by(() => {
|
||||
const points = charts.est1rmOverTime ?? [];
|
||||
return {
|
||||
return withTrend({
|
||||
labels: points.map((/** @type {any} */ p) => new Date(p.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })),
|
||||
datasets: [{ label: 'Est. 1RM (kg)', data: points.map((/** @type {any} */ p) => p.value) }]
|
||||
};
|
||||
datasets: [{ label: 'Est. 1RM (kg)', data: points.map((/** @type {any} */ p) => p.value), borderColor: '#88C0D0' }]
|
||||
});
|
||||
});
|
||||
|
||||
const maxWeightChartData = $derived.by(() => {
|
||||
const points = charts.maxWeightOverTime ?? [];
|
||||
return {
|
||||
return withTrend({
|
||||
labels: points.map((/** @type {any} */ p) => new Date(p.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })),
|
||||
datasets: [{ label: 'Max Weight (kg)', data: points.map((/** @type {any} */ p) => p.value), borderColor: '#A3BE8C' }]
|
||||
};
|
||||
}, '#A3BE8C');
|
||||
});
|
||||
|
||||
const volumeChartData = $derived.by(() => {
|
||||
const points = charts.totalVolumeOverTime ?? [];
|
||||
return {
|
||||
return withTrend({
|
||||
labels: points.map((/** @type {any} */ p) => new Date(p.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })),
|
||||
datasets: [{ label: 'Total Volume (kg)', data: points.map((/** @type {any} */ p) => p.value), borderColor: '#EBCB8B' }]
|
||||
};
|
||||
}, '#EBCB8B');
|
||||
});
|
||||
|
||||
/**
|
||||
* Compute linear regression trendline + ±1σ bands for a data array.
|
||||
* Returns { trend, upper, lower } arrays of same length.
|
||||
* @param {number[]} data
|
||||
*/
|
||||
function trendWithBands(data) {
|
||||
const n = data.length;
|
||||
if (n < 3) return null;
|
||||
|
||||
// Linear regression
|
||||
let sx = 0, sy = 0, sxx = 0, sxy = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
sx += i; sy += data[i]; sxx += i * i; sxy += i * data[i];
|
||||
}
|
||||
const slope = (n * sxy - sx * sy) / (n * sxx - sx * sx);
|
||||
const intercept = (sy - slope * sx) / n;
|
||||
|
||||
const trend = data.map((_, i) => Math.round((intercept + slope * i) * 10) / 10);
|
||||
|
||||
// Residual standard deviation
|
||||
let ssRes = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const r = data[i] - trend[i];
|
||||
ssRes += r * r;
|
||||
}
|
||||
const sigma = Math.sqrt(ssRes / (n - 2));
|
||||
|
||||
const upper = trend.map(v => Math.round((v + sigma) * 10) / 10);
|
||||
const lower = trend.map(v => Math.round((v - sigma) * 10) / 10);
|
||||
|
||||
return { trend, upper, lower };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add trendline + uncertainty datasets to a chart data object.
|
||||
* @param {{ labels: string[], datasets: Array<any> }} chartData
|
||||
* @param {string} trendColor
|
||||
*/
|
||||
function withTrend(chartData, trendColor = '#5E81AC') {
|
||||
const values = chartData.datasets[0]?.data;
|
||||
if (!values || values.length < 3) return chartData;
|
||||
|
||||
const bands = trendWithBands(values);
|
||||
if (!bands) return chartData;
|
||||
|
||||
return {
|
||||
labels: chartData.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '± 1σ',
|
||||
data: bands.upper,
|
||||
borderColor: 'transparent',
|
||||
backgroundColor: `${trendColor}26`,
|
||||
fill: '+1',
|
||||
pointRadius: 0,
|
||||
borderWidth: 0,
|
||||
tension: 0.3,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
label: '± 1σ (lower)',
|
||||
data: bands.lower,
|
||||
borderColor: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
fill: false,
|
||||
pointRadius: 0,
|
||||
borderWidth: 0,
|
||||
tension: 0.3,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
label: 'Trend',
|
||||
data: bands.trend,
|
||||
borderColor: trendColor,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2,
|
||||
tension: 0.3,
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
...chartData.datasets[0],
|
||||
borderWidth: 1,
|
||||
order: 0
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/** @param {number} weight @param {number} reps */
|
||||
function epley1rm(weight, reps) {
|
||||
if (reps <= 0) return weight;
|
||||
|
||||
Reference in New Issue
Block a user