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.
198 lines
5.4 KiB
Svelte
198 lines
5.4 KiB
Svelte
<script>
|
|
import { onMount } from 'svelte';
|
|
import { Chart, registerables } from 'chart.js';
|
|
|
|
/**
|
|
* @type {{
|
|
* 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, type?: string, borderDash?: number[], [key: string]: any }> },
|
|
* title?: string,
|
|
* height?: string,
|
|
* yUnit?: string,
|
|
* goalLine?: number
|
|
* }}
|
|
*/
|
|
let { type = 'line', data, title = '', height = '250px', yUnit = '', goalLine = undefined } = $props();
|
|
|
|
/** @type {HTMLCanvasElement | undefined} */
|
|
let canvas = $state(undefined);
|
|
/** @type {Chart | null} */
|
|
let chart = $state(null);
|
|
let registered = false;
|
|
|
|
const nordColors = [
|
|
'#88C0D0', '#A3BE8C', '#EBCB8B', '#D08770', '#BF616A',
|
|
'#B48EAD', '#5E81AC', '#81A1C1', '#8FBCBB'
|
|
];
|
|
|
|
function isDark() {
|
|
const theme = document.documentElement.getAttribute('data-theme');
|
|
if (theme === 'dark') return true;
|
|
if (theme === 'light') return false;
|
|
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
}
|
|
|
|
function createChart() {
|
|
if (!canvas || !data?.datasets) return;
|
|
if (!registered) {
|
|
Chart.register(...registerables);
|
|
registered = true;
|
|
}
|
|
if (chart) chart.destroy();
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
const dark = isDark();
|
|
const textColor = dark ? '#D8DEE9' : '#2E3440';
|
|
const gridColor = dark ? 'rgba(216,222,233,0.1)' : 'rgba(46,52,64,0.08)';
|
|
|
|
const plainLabels = [...(data.labels || [])];
|
|
const plainDatasets = (data.datasets || []).map((ds, i) => {
|
|
const isLine = ds.type === 'line' || (type === 'line' && !ds.type);
|
|
const isBar = ds.type === 'bar' || (type === 'bar' && !ds.type);
|
|
return {
|
|
...ds,
|
|
data: [...(ds.data || [])],
|
|
borderColor: ds.borderColor || nordColors[i % nordColors.length],
|
|
backgroundColor: ds.backgroundColor ?? (isBar
|
|
? (nordColors[i % nordColors.length])
|
|
: 'transparent'),
|
|
borderWidth: ds.borderWidth ?? (isLine ? 2 : 0),
|
|
pointRadius: ds.pointRadius ?? (isLine ? 3 : 0),
|
|
pointBackgroundColor: ds.pointBackgroundColor || ds.borderColor || nordColors[i % nordColors.length],
|
|
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, {
|
|
type,
|
|
data: { labels: plainLabels, datasets: plainDatasets },
|
|
plugins,
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
animation: { duration: 0 },
|
|
scales: {
|
|
x: {
|
|
grid: { display: false },
|
|
border: { display: false },
|
|
ticks: { color: textColor, font: { size: 11 }, maxTicksLimit: 8 }
|
|
},
|
|
y: {
|
|
beginAtZero: type === 'bar',
|
|
grid: { color: gridColor },
|
|
border: { display: false },
|
|
ticks: {
|
|
color: textColor,
|
|
font: { size: 11 },
|
|
stepSize: type === 'bar' ? 1 : undefined,
|
|
callback: yUnit ? (/** @type {any} */ v) => `${v}${yUnit}` : undefined
|
|
}
|
|
}
|
|
},
|
|
plugins: /** @type {any} */ ({
|
|
legend: {
|
|
display: plainDatasets.length > 1,
|
|
labels: {
|
|
color: textColor,
|
|
usePointStyle: true,
|
|
padding: 12,
|
|
filter: (/** @type {any} */ item) => !item.text?.includes('(lower)')
|
|
}
|
|
},
|
|
title: {
|
|
display: !!title,
|
|
text: title,
|
|
color: textColor,
|
|
font: { size: 14, weight: 'bold' },
|
|
padding: { bottom: 12 }
|
|
},
|
|
tooltip: {
|
|
backgroundColor: dark ? '#2E3440' : '#ECEFF4',
|
|
titleColor: dark ? '#ECEFF4' : '#2E3440',
|
|
bodyColor: dark ? '#D8DEE9' : '#3B4252',
|
|
borderWidth: 0,
|
|
cornerRadius: 8,
|
|
padding: 10
|
|
}
|
|
})
|
|
}
|
|
});
|
|
}
|
|
|
|
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);
|
|
mq.addEventListener('change', onTheme);
|
|
|
|
const obs = new MutationObserver((muts) => {
|
|
for (const m of muts) {
|
|
if (m.attributeName === 'data-theme') onTheme();
|
|
}
|
|
});
|
|
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
|
|
|
return () => {
|
|
mq.removeEventListener('change', onTheme);
|
|
obs.disconnect();
|
|
if (chart) chart.destroy();
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<div class="chart-container" style="height: {height}">
|
|
<canvas bind:this={canvas}></canvas>
|
|
</div>
|
|
|
|
<style>
|
|
.chart-container {
|
|
background: var(--color-surface);
|
|
border-radius: 12px;
|
|
padding: 1rem;
|
|
border: 1px solid var(--color-border);
|
|
}
|
|
canvas {
|
|
max-width: 100%;
|
|
height: 100% !important;
|
|
}
|
|
</style>
|