/** * ASCIIBarChart - A dynamic ASCII-based vertical bar chart renderer * * Creates real-time animated bar charts using monospaced characters (X) * with automatic scaling, labels, and responsive font sizing. */ class ASCIIBarChart { /** * Create a new ASCII bar chart * @param {string} containerId - The ID of the HTML element to render the chart in * @param {Object} options - Configuration options * @param {number} [options.maxHeight=20] - Maximum height of the chart in rows * @param {number} [options.maxDataPoints=30] - Maximum number of data columns before scrolling * @param {string} [options.title=''] - Chart title (displayed centered at top) * @param {string} [options.xAxisLabel=''] - X-axis label (displayed centered at bottom) * @param {string} [options.yAxisLabel=''] - Y-axis label (displayed vertically on left) * @param {boolean} [options.autoFitWidth=true] - Automatically adjust font size to fit container width */ constructor(containerId, options = {}) { this.container = document.getElementById(containerId); this.data = []; this.maxHeight = options.maxHeight || 20; this.maxDataPoints = options.maxDataPoints || 30; this.totalDataPoints = 0; // Track total number of data points added this.title = options.title || ''; this.xAxisLabel = options.xAxisLabel || ''; this.yAxisLabel = options.yAxisLabel || ''; this.autoFitWidth = options.autoFitWidth !== false; // Default to true // Set up resize observer if auto-fit is enabled if (this.autoFitWidth) { this.resizeObserver = new ResizeObserver(() => { this.adjustFontSize(); }); this.resizeObserver.observe(this.container); } } /** * Add a new data point to the chart * @param {number} value - The numeric value to add */ addValue(value) { this.data.push(value); this.totalDataPoints++; // Keep only the most recent data points if (this.data.length > this.maxDataPoints) { this.data.shift(); } this.render(); this.updateInfo(); } /** * Clear all data from the chart */ clear() { this.data = []; this.totalDataPoints = 0; this.render(); this.updateInfo(); } /** * Calculate the width of the chart in characters * @returns {number} The chart width in characters * @private */ getChartWidth() { if (this.data.length === 0) return 50; // Default width for empty chart const yAxisPadding = this.yAxisLabel ? 2 : 0; const yAxisNumbers = 3; // Width of Y-axis numbers const separator = 1; // The '|' character const dataWidth = this.data.length * 2; // Each column is 2 characters wide const padding = 1; // Extra padding return yAxisPadding + yAxisNumbers + separator + dataWidth + padding; } /** * Adjust font size to fit container width * @private */ adjustFontSize() { if (!this.autoFitWidth) return; const containerWidth = this.container.clientWidth; const chartWidth = this.getChartWidth(); if (chartWidth === 0) return; // Calculate optimal font size // For monospace fonts, character width is approximately 0.6 * font size const charWidthRatio = 0.6; const padding = 40; // Account for container padding const availableWidth = containerWidth - padding; const optimalFontSize = Math.floor((availableWidth / chartWidth) / charWidthRatio); // Set reasonable bounds (min 4px, max 20px) const fontSize = Math.max(4, Math.min(20, optimalFontSize)); this.container.style.fontSize = fontSize + 'px'; this.container.style.lineHeight = '1.0'; } /** * Render the chart to the container * @private */ render() { if (this.data.length === 0) { this.container.textContent = 'No data yet. Click Start to begin.'; return; } let output = ''; const maxValue = Math.max(...this.data); const minValue = Math.min(...this.data); const valueRange = maxValue - minValue; const scale = this.maxHeight; // Calculate Y-axis label width (for vertical text) const yLabelWidth = this.yAxisLabel ? 2 : 0; const yAxisPadding = this.yAxisLabel ? ' ' : ''; // Add title if provided (centered) if (this.title) { const chartWidth = 4 + this.data.length * 2; // Y-axis numbers + data columns const titlePadding = Math.floor((chartWidth - this.title.length) / 2); output += yAxisPadding + ' '.repeat(Math.max(0, titlePadding)) + this.title + '\n\n'; } // Draw from top to bottom for (let row = scale; row > 0; row--) { let line = ''; // Add vertical Y-axis label character if (this.yAxisLabel) { const L = this.yAxisLabel.length; const startRow = Math.floor((scale - L) / 2) + 1; const relativeRow = scale - row + 1; // 1 at top, scale at bottom if (relativeRow >= startRow && relativeRow < startRow + L) { const labelIndex = relativeRow - startRow; line += this.yAxisLabel[labelIndex] + ' '; } else { line += ' '; } } // Calculate the actual value this row represents const rowValue = minValue + (valueRange * (row - 1) / (scale - 1)); // Add Y-axis label (show actual values) line += String(Math.round(rowValue)).padStart(3, ' ') + ' |'; // Draw each column for (let i = 0; i < this.data.length; i++) { const value = this.data[i]; // Scale the value to fit between 1 and scale let scaledValue; if (valueRange === 0) { // All values are the same scaledValue = 1; } else { scaledValue = 1 + Math.round(((value - minValue) / valueRange) * (scale - 1)); } if (scaledValue >= row) { line += ' X'; } else { line += ' '; } } output += line + '\n'; } // Draw X-axis output += yAxisPadding + ' +' + '-'.repeat(this.data.length * 2) + '\n'; // Draw X-axis labels (column numbers that move with the data) let xAxisLabels = yAxisPadding + ' '; const startIndex = this.totalDataPoints - this.data.length + 1; for (let i = 0; i < this.data.length; i++) { const dataPointNumber = startIndex + i; if (i % 5 === 0) { xAxisLabels += String(dataPointNumber).padStart(2, ' '); } else { xAxisLabels += ' '; } } output += xAxisLabels + '\n'; // Add X-axis label if provided if (this.xAxisLabel) { const labelPadding = Math.floor((this.data.length * 2 - this.xAxisLabel.length) / 2); output += '\n' + yAxisPadding + ' ' + ' '.repeat(Math.max(0, labelPadding)) + this.xAxisLabel + '\n'; } this.container.textContent = output; // Adjust font size to fit width if (this.autoFitWidth) { this.adjustFontSize(); } // Update the external info display document.getElementById('values').textContent = `[${this.data.join(', ')}]`; document.getElementById('max-value').textContent = maxValue; document.getElementById('scale').textContent = `Min: ${minValue}, Max: ${maxValue}, Height: ${scale}`; } /** * Update the info display * @private */ updateInfo() { document.getElementById('count').textContent = this.data.length; } }