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: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
|
animation: { duration: 0 },
|
||||||
layout: {
|
layout: {
|
||||||
padding: {
|
padding: {
|
||||||
top: 40
|
top: 40
|
||||||
@@ -322,6 +323,15 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
createChart();
|
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)
|
// Watch for theme changes (both media query and data-theme attribute)
|
||||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
animation: false,
|
animation: { duration: 0 },
|
||||||
scales: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
grid: { display: false },
|
grid: { display: false },
|
||||||
@@ -120,6 +120,14 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
createChart();
|
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 mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
const onTheme = () => setTimeout(createChart, 100);
|
const onTheme = () => setTimeout(createChart, 100);
|
||||||
|
|||||||
@@ -19,28 +19,116 @@
|
|||||||
|
|
||||||
const est1rmChartData = $derived.by(() => {
|
const est1rmChartData = $derived.by(() => {
|
||||||
const points = charts.est1rmOverTime ?? [];
|
const points = charts.est1rmOverTime ?? [];
|
||||||
return {
|
return withTrend({
|
||||||
labels: points.map((/** @type {any} */ p) => new Date(p.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })),
|
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 maxWeightChartData = $derived.by(() => {
|
||||||
const points = charts.maxWeightOverTime ?? [];
|
const points = charts.maxWeightOverTime ?? [];
|
||||||
return {
|
return withTrend({
|
||||||
labels: points.map((/** @type {any} */ p) => new Date(p.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })),
|
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' }]
|
datasets: [{ label: 'Max Weight (kg)', data: points.map((/** @type {any} */ p) => p.value), borderColor: '#A3BE8C' }]
|
||||||
};
|
}, '#A3BE8C');
|
||||||
});
|
});
|
||||||
|
|
||||||
const volumeChartData = $derived.by(() => {
|
const volumeChartData = $derived.by(() => {
|
||||||
const points = charts.totalVolumeOverTime ?? [];
|
const points = charts.totalVolumeOverTime ?? [];
|
||||||
return {
|
return withTrend({
|
||||||
labels: points.map((/** @type {any} */ p) => new Date(p.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })),
|
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' }]
|
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 */
|
/** @param {number} weight @param {number} reps */
|
||||||
function epley1rm(weight, reps) {
|
function epley1rm(weight, reps) {
|
||||||
if (reps <= 0) return weight;
|
if (reps <= 0) return weight;
|
||||||
|
|||||||
Reference in New Issue
Block a user