Files
GopherGate/static/js/charts.js
hobokenchicken fbd3751102
Some checks failed
CI / Check (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Formatting (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Release Build (push) Has been cancelled
fix(dashboard): guard customTooltip plugin against undefined tooltip and missing Y scale
chart.tooltip is undefined during initial draw for radial chart types
(doughnut/pie), and chart.scales.y doesn't exist on non-cartesian charts.
This crashed chart creation, causing 'Failed to load' messages on all pages.
2026-03-02 13:49:37 -05:00

538 lines
18 KiB
JavaScript

// Chart.js Configuration and Helpers
class ChartManager {
constructor() {
this.charts = new Map();
this.defaultColors = [
'#fe8019', // Orange
'#b8bb26', // Green
'#fabd2f', // Yellow
'#fb4934', // Red
'#83a598', // Blue
'#d3869b', // Purple
'#8ec07c', // Aqua
'#d65d0e', // Dark Orange
'#98971a', // Dark Green
'#d79921', // Dark Yellow
];
this.init();
}
init() {
// Set Chart.js defaults for Gruvbox
Chart.defaults.color = '#bdae93'; // fg3
Chart.defaults.borderColor = '#504945'; // bg2
this.registerPlugins();
}
registerPlugins() {
// Register a plugin that draws a vertical dashed line at the active tooltip point.
// Only applies to charts with a cartesian Y scale (line, bar) — skip radial types.
Chart.register({
id: 'customTooltip',
beforeDraw: (chart) => {
if (!chart.tooltip || !chart.tooltip._active || !chart.tooltip._active.length) return;
if (!chart.scales.y) return; // no cartesian Y axis (pie/doughnut)
const ctx = chart.ctx;
const activePoint = chart.tooltip._active[0];
const x = activePoint.element.x;
const topY = chart.scales.y.top;
const bottomY = chart.scales.y.bottom;
ctx.save();
ctx.beginPath();
ctx.setLineDash([5, 5]);
ctx.moveTo(x, topY);
ctx.lineTo(x, bottomY);
ctx.lineWidth = 1;
ctx.strokeStyle = '#665c54'; // bg3
ctx.stroke();
ctx.restore();
}
});
}
createChart(canvasId, config) {
const canvas = document.getElementById(canvasId);
if (!canvas) {
console.warn(`Canvas element #${canvasId} not found`);
return null;
}
// Destroy existing chart if it exists
if (this.charts.has(canvasId)) {
this.charts.get(canvasId).destroy();
}
// Create new chart
const ctx = canvas.getContext('2d');
const chart = new Chart(ctx, {
...config,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
labels: {
padding: 20,
usePointStyle: true,
pointStyle: 'circle',
color: '#ebdbb2' // fg1
}
},
tooltip: {
mode: 'index',
intersect: false,
backgroundColor: '#3c3836', // bg1
titleColor: '#fbf1c7', // fg0
bodyColor: '#ebdbb2', // fg1
borderColor: '#504945', // bg2
borderWidth: 1,
cornerRadius: 4,
padding: 12,
boxPadding: 6,
callbacks: {
label: function(context) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += context.parsed.y.toLocaleString();
}
return label;
}
}
}
},
interaction: {
intersect: false,
mode: 'nearest'
},
scales: {
x: {
grid: {
display: true,
color: '#3c3836' // bg1
},
ticks: {
color: '#a89984' // fg4
}
},
y: {
beginAtZero: true,
grid: {
display: true,
color: '#3c3836' // bg1
},
ticks: {
color: '#a89984', // fg4
callback: function(value) {
return value.toLocaleString();
}
}
}
},
...config.options
}
});
// Store chart reference
this.charts.set(canvasId, chart);
return chart;
}
destroyChart(canvasId) {
if (this.charts.has(canvasId)) {
this.charts.get(canvasId).destroy();
this.charts.delete(canvasId);
}
}
destroyAllCharts() {
this.charts.forEach((chart, canvasId) => {
chart.destroy();
});
this.charts.clear();
}
// Chart templates
createLineChart(canvasId, data, options = {}) {
const config = {
type: 'line',
data: {
labels: data.labels || [],
datasets: data.datasets.map((dataset, index) => ({
label: dataset.label,
data: dataset.data,
borderColor: dataset.color || this.defaultColors[index % this.defaultColors.length],
backgroundColor: dataset.fill ? this.hexToRgba(dataset.color || this.defaultColors[index % this.defaultColors.length], 0.15) : 'transparent',
borderWidth: 2,
pointRadius: 3,
pointHoverRadius: 6,
pointBackgroundColor: dataset.color || this.defaultColors[index % this.defaultColors.length],
pointBorderColor: '#282828', // bg0
pointBorderWidth: 2,
fill: dataset.fill || false,
tension: 0.4,
...dataset
}))
},
options: {
plugins: {
tooltip: {
callbacks: {
label: function(context) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += context.parsed.y.toLocaleString();
}
return label;
}
}
}
},
...options
}
};
return this.createChart(canvasId, config);
}
createBarChart(canvasId, data, options = {}) {
const config = {
type: 'bar',
data: {
labels: data.labels || [],
datasets: data.datasets.map((dataset, index) => ({
label: dataset.label,
data: dataset.data,
backgroundColor: dataset.color || this.defaultColors[index % this.defaultColors.length],
borderColor: dataset.borderColor || '#ffffff',
borderWidth: 1,
borderRadius: 4,
borderSkipped: false,
...dataset
}))
},
options: {
plugins: {
tooltip: {
callbacks: {
label: function(context) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += context.parsed.y.toLocaleString();
}
return label;
}
}
}
},
...options
}
};
return this.createChart(canvasId, config);
}
createPieChart(canvasId, data, options = {}) {
const config = {
type: 'pie',
data: {
labels: data.labels || [],
datasets: [{
data: data.data || [],
backgroundColor: data.colors || this.defaultColors.slice(0, data.data.length),
borderColor: '#282828', // bg0
borderWidth: 2,
hoverOffset: 15
}]
},
options: {
plugins: {
legend: {
position: 'right',
labels: {
padding: 20,
usePointStyle: true,
pointStyle: 'circle'
}
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.raw || 0;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = Math.round((value / total) * 100);
return `${label}: ${value.toLocaleString()} (${percentage}%)`;
}
}
}
},
...options
}
};
return this.createChart(canvasId, config);
}
createDoughnutChart(canvasId, data, options = {}) {
const config = {
type: 'doughnut',
data: {
labels: data.labels || [],
datasets: [{
data: data.data || [],
backgroundColor: data.colors || this.defaultColors.slice(0, data.data.length),
borderColor: '#282828', // bg0
borderWidth: 2,
hoverOffset: 15
}]
},
options: {
cutout: '60%',
plugins: {
legend: {
position: 'right',
labels: {
padding: 20,
usePointStyle: true,
pointStyle: 'circle'
}
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.raw || 0;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = Math.round((value / total) * 100);
return `${label}: ${value.toLocaleString()} (${percentage}%)`;
}
}
}
},
...options
}
};
return this.createChart(canvasId, config);
}
createHorizontalBarChart(canvasId, data, options = {}) {
const config = {
type: 'bar',
data: {
labels: data.labels || [],
datasets: data.datasets.map((dataset, index) => ({
label: dataset.label,
data: dataset.data,
backgroundColor: dataset.color || this.defaultColors[index % this.defaultColors.length],
borderColor: '#282828', // bg0
borderWidth: 1,
borderRadius: 4,
...dataset
}))
},
options: {
indexAxis: 'y',
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: function(context) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.x !== null) {
label += context.parsed.x.toLocaleString();
}
return label;
}
}
}
},
scales: {
x: {
beginAtZero: true,
grid: {
display: true,
color: '#3c3836' // bg1
},
ticks: {
color: '#a89984', // fg4
callback: function(value) {
return value.toLocaleString();
}
}
},
y: {
grid: {
display: false
},
ticks: {
color: '#a89984' // fg4
}
}
},
...options
}
};
return this.createChart(canvasId, config);
}
// Helper methods
hexToRgba(hex, alpha = 1) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
generateTimeLabels(hours = 24) {
const now = luxon.DateTime.now();
const labels = [];
for (let i = hours - 1; i >= 0; i--) {
const time = now.minus({ hours: i });
labels.push(time.toFormat('HH:00'));
}
return labels;
}
generateDateLabels(days = 7) {
const now = luxon.DateTime.now();
const labels = [];
for (let i = days - 1; i >= 0; i--) {
const date = now.minus({ days: i });
labels.push(date.toFormat('MMM dd'));
}
return labels;
}
// Demo data generators
generateDemoTimeSeries(hours = 24, seriesCount = 1) {
const labels = this.generateTimeLabels(hours);
const datasets = [];
for (let i = 0; i < seriesCount; i++) {
const data = [];
let value = Math.floor(Math.random() * 100) + 50;
for (let j = 0; j < hours; j++) {
// Add some randomness but keep trend
value += Math.floor(Math.random() * 20) - 10;
value = Math.max(10, value);
data.push(value);
}
datasets.push({
label: `Series ${i + 1}`,
data: data,
color: this.defaultColors[i % this.defaultColors.length]
});
}
return { labels, datasets };
}
generateDemoBarData(labels, seriesCount = 1) {
const datasets = [];
for (let i = 0; i < seriesCount; i++) {
const data = labels.map(() => Math.floor(Math.random() * 100) + 20);
datasets.push({
label: `Dataset ${i + 1}`,
data: data,
color: this.defaultColors[i % this.defaultColors.length]
});
}
return { labels, datasets };
}
generateDemoPieData(labels) {
const data = labels.map(() => Math.floor(Math.random() * 100) + 10);
const total = data.reduce((a, b) => a + b, 0);
return {
labels: labels,
data: data,
colors: labels.map((_, i) => this.defaultColors[i % this.defaultColors.length])
};
}
// Update chart data
updateChartData(canvasId, newData) {
const chart = this.charts.get(canvasId);
if (!chart) return;
chart.data.labels = newData.labels || chart.data.labels;
if (newData.datasets) {
newData.datasets.forEach((dataset, index) => {
if (chart.data.datasets[index]) {
chart.data.datasets[index].data = dataset.data;
if (dataset.label) {
chart.data.datasets[index].label = dataset.label;
}
}
});
}
chart.update();
}
// Add data point to time series
addDataPoint(canvasId, newPoint, datasetIndex = 0) {
const chart = this.charts.get(canvasId);
if (!chart || chart.config.type !== 'line') return;
const dataset = chart.data.datasets[datasetIndex];
if (!dataset) return;
// Add new point
dataset.data.push(newPoint);
// Remove oldest point if we have too many
if (dataset.data.length > 100) {
dataset.data.shift();
chart.data.labels.shift();
} else {
// Add new label
const now = luxon.DateTime.now();
chart.data.labels.push(now.toFormat('HH:mm:ss'));
}
chart.update();
}
}
// Initialize chart manager when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
window.chartManager = new ChartManager();
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = ChartManager;
}