259 lines
8.9 KiB
JavaScript
259 lines
8.9 KiB
JavaScript
class RetroBarChart {
|
|
constructor(canvasId, data, options = {}) {
|
|
this.canvas = document.getElementById(canvasId);
|
|
this.ctx = this.canvas.getContext('2d');
|
|
this.data = data;
|
|
this.options = {
|
|
barColor: '#d4be98',
|
|
barHoverColor: '#d4be98',
|
|
textColor: 'white',
|
|
tooltipBgColor: 'rgba(0, 0, 0, 0.8)',
|
|
tooltipTextColor: '#d4be98',
|
|
barWidth: 0,
|
|
spacing: 0,
|
|
title: '',
|
|
titleColor: 'white',
|
|
titleFont: '16px Mix',
|
|
yAxisColor: 'white',
|
|
yAxisFont: '12px Mix',
|
|
formatTooltip: (label, value) => `${label}: ${value}`,
|
|
...options
|
|
};
|
|
|
|
this.setupCanvas();
|
|
this.setupEventListeners();
|
|
this.setupMutationObserver();
|
|
|
|
// Initial draw attempt
|
|
this.tryDraw();
|
|
}
|
|
|
|
setupCanvas() {
|
|
if (this.isCanvasVisible()) {
|
|
const parentWidth = this.canvas.parentElement.clientWidth;
|
|
const parentHeight = Math.min(300, parentWidth * 0.5);
|
|
this.canvas.width = parentWidth;
|
|
this.canvas.height = parentHeight;
|
|
this.ctx.imageSmoothingEnabled = false;
|
|
|
|
const titleHeight = this.options.title ? 30 : 0;
|
|
const titleSpacing = this.options.title ? 20 : 0;
|
|
this.chartArea = {
|
|
top: 10 + titleHeight + titleSpacing,
|
|
right: this.canvas.width - 10,
|
|
bottom: this.canvas.height - 30,
|
|
left: 40
|
|
};
|
|
|
|
if (!this.options.barWidth) {
|
|
this.options.barWidth = Math.floor((this.chartArea.right - this.chartArea.left) / (this.data.length + 1));
|
|
}
|
|
if (!this.options.spacing) {
|
|
this.options.spacing = Math.floor((this.chartArea.right - this.chartArea.left - this.options.barWidth * this.data.length) / (this.data.length + 1));
|
|
}
|
|
}
|
|
}
|
|
|
|
setupEventListeners() {
|
|
this.canvas.addEventListener('mousemove', this.handleHover.bind(this));
|
|
this.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this));
|
|
window.addEventListener('resize', this.handleResize.bind(this));
|
|
}
|
|
|
|
setupMutationObserver() {
|
|
const observer = new MutationObserver((mutations) => {
|
|
mutations.forEach((mutation) => {
|
|
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
|
|
this.handleVisibilityChange();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Observe the canvas and all its ancestor elements
|
|
let element = this.canvas;
|
|
while (element && element !== document.body) {
|
|
observer.observe(element, {
|
|
attributes: true,
|
|
attributeFilter: ['style']
|
|
});
|
|
element = element.parentElement;
|
|
}
|
|
}
|
|
|
|
handleVisibilityChange() {
|
|
if (this.isCanvasVisible()) {
|
|
this.setupCanvas();
|
|
this.draw();
|
|
}
|
|
}
|
|
|
|
isCanvasVisible() {
|
|
let element = this.canvas;
|
|
while (element) {
|
|
const style = window.getComputedStyle(element);
|
|
if (style.display === 'none' || style.visibility === 'hidden') {
|
|
return false;
|
|
}
|
|
element = element.parentElement;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
handleResize() {
|
|
this.setupCanvas();
|
|
this.tryDraw();
|
|
}
|
|
|
|
tryDraw() {
|
|
if (this.isCanvasVisible()) {
|
|
this.draw();
|
|
} else {
|
|
// If not visible, set up a periodic check
|
|
if (!this.drawAttemptInterval) {
|
|
this.drawAttemptInterval = setInterval(() => {
|
|
if (this.isCanvasVisible()) {
|
|
this.draw();
|
|
clearInterval(this.drawAttemptInterval);
|
|
this.drawAttemptInterval = null;
|
|
}
|
|
}, 100); // Check every 100ms
|
|
}
|
|
}
|
|
}
|
|
|
|
draw() {
|
|
if (!this.isCanvasVisible()) return;
|
|
|
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
this.drawTitle();
|
|
this.drawYAxis();
|
|
this.drawBars();
|
|
}
|
|
|
|
drawTitle() {
|
|
if (this.options.title) {
|
|
this.ctx.fillStyle = this.options.titleColor;
|
|
this.ctx.font = this.options.titleFont;
|
|
this.ctx.textAlign = 'center';
|
|
this.ctx.fillText(this.options.title, this.canvas.width / 2, 30);
|
|
}
|
|
}
|
|
|
|
drawYAxis() {
|
|
const maxValue = Math.max(...this.data.map(item => item.value));
|
|
const yAxisSteps = 5;
|
|
const stepValue = maxValue / yAxisSteps;
|
|
|
|
this.ctx.strokeStyle = this.options.yAxisColor;
|
|
this.ctx.fillStyle = this.options.yAxisColor;
|
|
this.ctx.font = this.options.yAxisFont;
|
|
this.ctx.textAlign = 'right';
|
|
|
|
for (let i = 0; i <= yAxisSteps; i++) {
|
|
const y = this.chartArea.bottom - (i / yAxisSteps) * (this.chartArea.bottom - this.chartArea.top);
|
|
const value = Math.round(i * stepValue);
|
|
|
|
// Draw tick
|
|
this.ctx.beginPath();
|
|
this.ctx.moveTo(this.chartArea.left - 5, y);
|
|
this.ctx.lineTo(this.chartArea.left, y);
|
|
this.ctx.stroke();
|
|
|
|
// Draw value
|
|
this.ctx.fillText(value.toString(), this.chartArea.left - 8, y + 4);
|
|
}
|
|
}
|
|
|
|
drawBars() {
|
|
const maxValue = Math.max(...this.data.map(item => item.value));
|
|
|
|
this.data.forEach((item, index) => {
|
|
const x = this.chartArea.left + this.options.spacing + (this.options.barWidth + this.options.spacing) * index;
|
|
const barHeight = Math.floor((item.value / maxValue) * (this.chartArea.bottom - this.chartArea.top));
|
|
const y = this.chartArea.bottom - barHeight;
|
|
|
|
// Draw bar
|
|
this.ctx.fillStyle = this.options.barColor;
|
|
for (let i = 0; i < barHeight; i += 2) {
|
|
this.ctx.fillRect(x, y + i, this.options.barWidth, 1);
|
|
}
|
|
|
|
// Draw value
|
|
this.ctx.fillStyle = this.options.textColor;
|
|
this.ctx.font = `bold ${Math.max(12, Math.floor(this.options.barWidth / 4))}px Mix`;
|
|
this.ctx.textAlign = 'center';
|
|
this.ctx.fillText(item.value.toString(), x + this.options.barWidth / 2, this.chartArea.bottom + 15);
|
|
|
|
// Draw label
|
|
this.ctx.font = `${Math.max(10, Math.floor(this.options.barWidth / 5))}px Mix`;
|
|
this.ctx.fillText(item.label, x + this.options.barWidth / 2, this.chartArea.bottom + 28);
|
|
});
|
|
}
|
|
|
|
handleHover(event) {
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
const x = event.clientX - rect.left;
|
|
const y = event.clientY - rect.top;
|
|
|
|
this.draw();
|
|
|
|
let hovered = false;
|
|
|
|
this.data.forEach((item, index) => {
|
|
const barX = this.chartArea.left + this.options.spacing + (this.options.barWidth + this.options.spacing) * index;
|
|
const barHeight = Math.floor((item.value / Math.max(...this.data.map(d => d.value))) * (this.chartArea.bottom - this.chartArea.top));
|
|
const barY = this.chartArea.bottom - barHeight;
|
|
|
|
if (x >= barX && x <= barX + this.options.barWidth && y >= barY && y <= this.chartArea.bottom) {
|
|
hovered = true;
|
|
|
|
// Highlight hovered bar
|
|
this.ctx.fillStyle = this.options.barHoverColor;
|
|
this.ctx.fillRect(barX, barY, this.options.barWidth, barHeight);
|
|
|
|
// Show tooltip
|
|
const tooltipContent = this.options.formatTooltip(item.label, item.value);
|
|
this.drawTooltip(x, y, tooltipContent);
|
|
}
|
|
});
|
|
|
|
this.canvas.style.cursor = hovered ? 'pointer' : 'default';
|
|
}
|
|
|
|
drawTooltip(x, y, content) {
|
|
this.ctx.font = 'bold 14px Mix';
|
|
const metrics = this.ctx.measureText(content);
|
|
const tooltipWidth = metrics.width + 20; // Add padding
|
|
const tooltipHeight = 30;
|
|
const tooltipX = Math.max(0, Math.min(x - tooltipWidth / 2, this.canvas.width - tooltipWidth));
|
|
const tooltipY = Math.max(0, y - tooltipHeight - 5);
|
|
|
|
// Draw tooltip background
|
|
this.ctx.fillStyle = this.options.tooltipBgColor;
|
|
this.ctx.beginPath();
|
|
this.ctx.roundRect(tooltipX, tooltipY, tooltipWidth, tooltipHeight, 5);
|
|
this.ctx.fill();
|
|
|
|
// Draw tooltip text
|
|
this.ctx.fillStyle = this.options.tooltipTextColor;
|
|
this.ctx.textAlign = 'center';
|
|
this.ctx.fillText(content, tooltipX + tooltipWidth / 2, tooltipY + 20);
|
|
}
|
|
|
|
handleMouseLeave() {
|
|
this.draw();
|
|
this.canvas.style.cursor = 'default';
|
|
}
|
|
|
|
updateData(newData) {
|
|
this.data = newData;
|
|
this.setupCanvas();
|
|
this.tryDraw();
|
|
}
|
|
|
|
updateOptions(newOptions) {
|
|
this.options = { ...this.options, ...newOptions };
|
|
this.setupCanvas();
|
|
this.tryDraw();
|
|
}
|
|
} |