fitness: improve workouts chart with goal line, max bar width, and full 10-week range
Some checks failed
CI / update (push) Has been cancelled
Some checks failed
CI / update (push) Has been cancelled
Add weekly goal as a solid horizontal line on the bar chart via a custom Chart.js plugin. Cap bar width at 40px. Always show all 10 weeks including empty ones instead of trimming leading zeros.
This commit is contained in:
@@ -5,13 +5,14 @@
|
|||||||
/**
|
/**
|
||||||
* @type {{
|
* @type {{
|
||||||
* type?: 'line' | 'bar',
|
* type?: 'line' | 'bar',
|
||||||
* data: { labels: string[], datasets: Array<{ label: string, data: (number|null)[], borderColor?: string, backgroundColor?: string, borderWidth?: number, pointRadius?: number, pointBackgroundColor?: string, tension?: number, fill?: boolean|string, order?: number }> },
|
* data: { labels: string[], datasets: Array<{ label: string, data: (number|null)[], borderColor?: string, backgroundColor?: string, borderWidth?: number, pointRadius?: number, pointBackgroundColor?: string, tension?: number, fill?: boolean|string, order?: number, type?: string, borderDash?: number[], [key: string]: any }> },
|
||||||
* title?: string,
|
* title?: string,
|
||||||
* height?: string,
|
* height?: string,
|
||||||
* yUnit?: string
|
* yUnit?: string,
|
||||||
|
* goalLine?: number
|
||||||
* }}
|
* }}
|
||||||
*/
|
*/
|
||||||
let { type = 'line', data, title = '', height = '250px', yUnit = '' } = $props();
|
let { type = 'line', data, title = '', height = '250px', yUnit = '', goalLine = undefined } = $props();
|
||||||
|
|
||||||
/** @type {HTMLCanvasElement | undefined} */
|
/** @type {HTMLCanvasElement | undefined} */
|
||||||
let canvas = $state(undefined);
|
let canvas = $state(undefined);
|
||||||
@@ -47,25 +48,55 @@
|
|||||||
const gridColor = dark ? 'rgba(216,222,233,0.1)' : 'rgba(46,52,64,0.08)';
|
const gridColor = dark ? 'rgba(216,222,233,0.1)' : 'rgba(46,52,64,0.08)';
|
||||||
|
|
||||||
const plainLabels = [...(data.labels || [])];
|
const plainLabels = [...(data.labels || [])];
|
||||||
const plainDatasets = (data.datasets || []).map((ds, i) => ({
|
const plainDatasets = (data.datasets || []).map((ds, i) => {
|
||||||
label: ds.label,
|
const isLine = ds.type === 'line' || (type === 'line' && !ds.type);
|
||||||
data: [...(ds.data || [])],
|
const isBar = ds.type === 'bar' || (type === 'bar' && !ds.type);
|
||||||
borderColor: ds.borderColor || nordColors[i % nordColors.length],
|
return {
|
||||||
backgroundColor: ds.backgroundColor ?? (type === 'bar'
|
...ds,
|
||||||
? (nordColors[i % nordColors.length])
|
data: [...(ds.data || [])],
|
||||||
: 'transparent'),
|
borderColor: ds.borderColor || nordColors[i % nordColors.length],
|
||||||
borderWidth: ds.borderWidth ?? (type === 'line' ? 2 : 0),
|
backgroundColor: ds.backgroundColor ?? (isBar
|
||||||
pointRadius: ds.pointRadius ?? (type === 'line' ? 3 : 0),
|
? (nordColors[i % nordColors.length])
|
||||||
pointBackgroundColor: ds.pointBackgroundColor || ds.borderColor || nordColors[i % nordColors.length],
|
: 'transparent'),
|
||||||
tension: ds.tension ?? 0.3,
|
borderWidth: ds.borderWidth ?? (isLine ? 2 : 0),
|
||||||
fill: ds.fill ?? false,
|
pointRadius: ds.pointRadius ?? (isLine ? 3 : 0),
|
||||||
spanGaps: true,
|
pointBackgroundColor: ds.pointBackgroundColor || ds.borderColor || nordColors[i % nordColors.length],
|
||||||
order: ds.order ?? i
|
tension: ds.tension ?? 0.3,
|
||||||
}));
|
fill: ds.fill ?? false,
|
||||||
|
spanGaps: true,
|
||||||
|
order: ds.order ?? i,
|
||||||
|
...(isBar ? { maxBarThickness: 40 } : {})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/** @type {import('chart.js').Plugin[]} */
|
||||||
|
const plugins = [];
|
||||||
|
if (goalLine != null) {
|
||||||
|
plugins.push({
|
||||||
|
id: 'goalLine',
|
||||||
|
afterDraw(chart) {
|
||||||
|
const yScale = chart.scales.y;
|
||||||
|
const xScale = chart.scales.x;
|
||||||
|
if (!yScale || !xScale) return;
|
||||||
|
const y = yScale.getPixelForValue(goalLine);
|
||||||
|
const ctx = chart.ctx;
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.strokeStyle = '#EBCB8B';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.moveTo(xScale.left, y);
|
||||||
|
ctx.lineTo(xScale.right, y);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
chart = new Chart(ctx, {
|
chart = new Chart(ctx, {
|
||||||
type,
|
type,
|
||||||
data: { labels: plainLabels, datasets: plainDatasets },
|
data: { labels: plainLabels, datasets: plainDatasets },
|
||||||
|
plugins,
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
|
|||||||
@@ -147,12 +147,9 @@ export const GET: RequestHandler = async ({ locals }) => {
|
|||||||
allData.push(weekMap.get(key) ?? 0);
|
allData.push(weekMap.get(key) ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trim leading empty weeks, but always keep from first week with data
|
|
||||||
let firstNonZero = allData.findIndex((v) => v > 0);
|
|
||||||
if (firstNonZero === -1) firstNonZero = allData.length - 1; // show at least current week
|
|
||||||
const workoutsChart = {
|
const workoutsChart = {
|
||||||
labels: allLabels.slice(firstNonZero),
|
labels: allLabels,
|
||||||
data: allData.slice(firstNonZero)
|
data: allData
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build chart-ready weight data with SMA ± 1 std dev confidence band
|
// Build chart-ready weight data with SMA ± 1 std dev confidence band
|
||||||
|
|||||||
@@ -187,6 +187,7 @@
|
|||||||
data={workoutsChartData}
|
data={workoutsChartData}
|
||||||
title={t('workouts_per_week', lang)}
|
title={t('workouts_per_week', lang)}
|
||||||
height="220px"
|
height="220px"
|
||||||
|
goalLine={goalWeekly ?? undefined}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="empty-chart">{t('no_workout_data', lang)}</p>
|
<p class="empty-chart">{t('no_workout_data', lang)}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user