309 lines
11 KiB
JavaScript
309 lines
11 KiB
JavaScript
window.chartInterop = {
|
|
chartInstances: {},
|
|
transactionLabels: {},
|
|
|
|
createOrUpdateChart: function (canvasId, labels, datasets, dayTransactionLabels, highlightIndex, salaryDayIndices, 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 salaryIndices = salaryDayIndices || [];
|
|
|
|
// Salary payday highlight plugin — subtle green band + 💰 marker
|
|
const salaryPlugin = {
|
|
id: 'salaryDays',
|
|
afterDraw: (chart) => {
|
|
const indices = chart.config._salaryDayIndices;
|
|
if (!indices || indices.length === 0) return;
|
|
const meta = chart.getDatasetMeta(0);
|
|
if (!meta) return;
|
|
const ctx = chart.ctx;
|
|
const top = chart.chartArea.top;
|
|
const bottom = chart.chartArea.bottom;
|
|
indices.forEach(idx => {
|
|
if (!meta.data[idx]) return;
|
|
const x = meta.data[idx].x;
|
|
ctx.save();
|
|
ctx.fillStyle = 'rgba(25, 135, 84, 0.10)';
|
|
ctx.fillRect(x - 6, top, 12, bottom - top);
|
|
ctx.font = '11px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('💰', x, top + 14);
|
|
ctx.restore();
|
|
});
|
|
}
|
|
};
|
|
|
|
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, salaryPlugin]
|
|
});
|
|
|
|
chartInstance.config._highlightIndex = (highlightIndex != null && highlightIndex >= 0) ? highlightIndex : null;
|
|
chartInstance.config._salaryDayIndices = salaryIndices;
|
|
if ((highlightIndex != null && highlightIndex >= 0) || salaryIndices.length > 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();
|
|
}
|
|
});
|