Files
Budgeteer/BudgetApp/wwwroot/js/chart-interop.js
T
2026-03-02 17:00:53 +00:00

282 lines
10 KiB
JavaScript

window.chartInterop = {
chartInstances: {},
transactionLabels: {},
createOrUpdateChart: function (canvasId, labels, datasets, dayTransactionLabels, highlightIndex, currencySymbol) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
if (this.chartInstances[canvasId]) {
this.chartInstances[canvasId].destroy();
}
this.transactionLabels[canvasId] = dayTransactionLabels || [];
const sym = currencySymbol || '£';
const chartDatasets = datasets.map(ds => ({
label: ds.label,
data: ds.data,
borderColor: ds.color,
backgroundColor: ds.color + '20',
fill: true,
tension: 0.3,
pointRadius: 1,
pointHoverRadius: 5,
pointHoverBorderWidth: 2,
pointHoverBackgroundColor: '#fff',
borderWidth: 2,
currencySymbol: ds.currencySymbol || sym
}));
// Vertical highlight line plugin (transaction selection)
const highlightPlugin = {
id: 'highlightLine',
afterDraw: (chart) => {
const idx = chart.config._highlightIndex;
if (idx == null || idx < 0) return;
const meta = chart.getDatasetMeta(0);
if (!meta || !meta.data[idx]) return;
const x = meta.data[idx].x;
const ctx = chart.ctx;
const top = chart.chartArea.top;
const bottom = chart.chartArea.bottom;
ctx.save();
ctx.beginPath();
ctx.moveTo(x, top);
ctx.lineTo(x, bottom);
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(255, 99, 132, 0.8)';
ctx.setLineDash([6, 4]);
ctx.stroke();
ctx.restore();
// Draw label
const label = labels[idx];
if (label) {
ctx.save();
ctx.font = 'bold 11px sans-serif';
ctx.fillStyle = 'rgba(255, 99, 132, 1)';
ctx.textAlign = 'center';
ctx.fillText('\u25BC ' + label, x, top - 4);
ctx.restore();
}
}
};
// Crosshair plugin — vertical line at hovered index
const crosshairPlugin = {
id: 'crosshair',
afterDraw: (chart) => {
const tooltip = chart.tooltip;
if (!tooltip || !tooltip.getActiveElements().length) return;
const activePoint = tooltip.getActiveElements()[0];
const meta = chart.getDatasetMeta(activePoint.datasetIndex);
if (!meta || !meta.data[activePoint.index]) return;
const x = meta.data[activePoint.index].x;
const ctx = chart.ctx;
const top = chart.chartArea.top;
const bottom = chart.chartArea.bottom;
ctx.save();
ctx.beginPath();
ctx.moveTo(x, top);
ctx.lineTo(x, bottom);
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(0, 0, 0, 0.15)';
ctx.stroke();
ctx.restore();
}
};
const storedLabels = this.transactionLabels[canvasId];
const chartInstance = new Chart(canvas, {
type: 'line',
data: {
labels: labels,
datasets: chartDatasets
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index',
axis: 'x'
},
plugins: {
legend: {
position: 'top'
},
tooltip: {
position: 'nearest',
caretPadding: 8,
callbacks: {
label: function (context) {
const dsSymbol = context.dataset.currencySymbol || sym;
return context.dataset.label + ': ' + dsSymbol + context.parsed.y.toFixed(2);
},
afterBody: function (contexts) {
const idx = contexts[0]?.dataIndex;
if (idx == null || !storedLabels[idx] || storedLabels[idx].length === 0) return [];
const lines = ['', 'Transactions:'];
storedLabels[idx].forEach(l => lines.push(' ' + l));
return lines;
}
}
}
},
scales: {
x: {
ticks: {
maxTicksLimit: 10
}
},
y: {
ticks: {
callback: function (value) {
return sym + value.toFixed(0);
}
}
}
}
},
plugins: [highlightPlugin, crosshairPlugin]
});
chartInstance.config._highlightIndex = (highlightIndex != null && highlightIndex >= 0) ? highlightIndex : null;
if (highlightIndex != null && highlightIndex >= 0) {
chartInstance.update();
}
this.chartInstances[canvasId] = chartInstance;
},
highlightDate: function (canvasId, index) {
const chart = this.chartInstances[canvasId];
if (!chart) return;
chart.config._highlightIndex = (index != null && index >= 0) ? index : null;
chart.update();
},
destroyChart: function (canvasId) {
if (this.chartInstances[canvasId]) {
this.chartInstances[canvasId].destroy();
delete this.chartInstances[canvasId];
delete this.transactionLabels[canvasId];
}
},
createOrUpdatePieChart: function (canvasId, labels, data, colors, currencySymbol, chartType) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
if (this.chartInstances[canvasId]) {
this.chartInstances[canvasId].destroy();
}
const sym = currencySymbol || '£';
this.chartInstances[canvasId] = new Chart(canvas, {
type: chartType || 'doughnut',
data: {
labels: labels,
datasets: [{
data: data,
backgroundColor: colors,
borderColor: '#fff',
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
padding: 16,
usePointStyle: true,
pointStyle: 'circle'
}
},
tooltip: {
callbacks: {
label: function (context) {
const value = context.parsed;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const pct = total > 0 ? ((value / total) * 100).toFixed(1) : 0;
return context.label + ': ' + sym + value.toFixed(2) + ' (' + pct + '%)';
}
}
}
}
}
});
},
createOrUpdateBarChart: function (canvasId, labels, incomeData, expenseData, currencySymbol) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
if (this.chartInstances[canvasId]) {
this.chartInstances[canvasId].destroy();
}
const sym = currencySymbol || '£';
this.chartInstances[canvasId] = new Chart(canvas, {
type: 'bar',
data: {
labels: labels,
datasets: [
{
label: 'Income',
data: incomeData,
backgroundColor: 'rgba(40, 167, 69, 0.7)',
borderColor: 'rgba(40, 167, 69, 1)',
borderWidth: 1
},
{
label: 'Expenses',
data: expenseData,
backgroundColor: 'rgba(220, 53, 69, 0.7)',
borderColor: 'rgba(220, 53, 69, 1)',
borderWidth: 1
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top' },
tooltip: {
callbacks: {
label: function (context) {
return context.dataset.label + ': ' + sym + context.parsed.y.toFixed(2);
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function (value) {
return sym + value.toFixed(0);
}
}
}
}
}
});
}
};
// Auto-select contents of numeric inputs on focus
document.addEventListener('focusin', function (e) {
if (e.target && e.target.matches('input[type="number"], input.numeric-input')) {
e.target.select();
}
});