/** * 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 * @param {boolean} [options.useBinMode=false] - Enable time bin mode for data aggregation * @param {number} [options.binDuration=10000] - Duration of each time bin in milliseconds (10 seconds default) * @param {string} [options.xAxisLabelFormat='elapsed'] - X-axis label format: 'elapsed', 'bins', 'timestamps', 'ranges' */ 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 // Time bin configuration this.useBinMode = options.useBinMode !== false; // Default to true this.binDuration = options.binDuration || 4000; // 4 seconds default this.xAxisLabelFormat = options.xAxisLabelFormat || 'elapsed'; // Time bin data structures this.bins = []; this.currentBinIndex = -1; this.binStartTime = null; this.binCheckInterval = null; this.chartStartTime = Date.now(); // Set up resize observer if auto-fit is enabled if (this.autoFitWidth) { this.resizeObserver = new ResizeObserver(() => { this.adjustFontSize(); }); this.resizeObserver.observe(this.container); } // Initialize first bin if bin mode is enabled if (this.useBinMode) { this.initializeBins(); } } /** * Add a new data point to the chart * @param {number} value - The numeric value to add */ addValue(value) { if (this.useBinMode) { // Time bin mode: increment count in current active bin this.checkBinRotation(); // Ensure we have an active bin this.bins[this.currentBinIndex].count++; this.totalDataPoints++; } else { // Legacy mode: add individual values 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; if (this.useBinMode) { this.bins = []; this.currentBinIndex = -1; this.binStartTime = null; this.initializeBins(); } this.render(); this.updateInfo(); } /** * Calculate the width of the chart in characters * @returns {number} The chart width in characters * @private */ getChartWidth() { let dataLength = this.maxDataPoints; // Always use maxDataPoints for consistent width if (dataLength === 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 = dataLength * 2; // Each column is 2 characters wide const padding = 1; // Extra padding const totalWidth = yAxisPadding + yAxisNumbers + separator + dataWidth + padding; // Only log when width changes if (this.lastChartWidth !== totalWidth) { console.log('getChartWidth changed:', { dataLength, totalWidth, previous: this.lastChartWidth }); this.lastChartWidth = totalWidth; } return totalWidth; } /** * 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)); // Only log when font size changes if (this.lastFontSize !== fontSize) { console.log('fontSize changed:', { containerWidth, chartWidth, fontSize, previous: this.lastFontSize }); this.lastFontSize = fontSize; } this.container.style.fontSize = fontSize + 'px'; this.container.style.lineHeight = '1.0'; } /** * Render the chart to the container * @private */ render() { let dataToRender = []; let maxValue = 0; let minValue = 0; let valueRange = 0; if (this.useBinMode) { // Bin mode: render bin counts if (this.bins.length === 0) { this.container.textContent = 'No data yet. Click Start to begin.'; return; } // Only render the most recent bins up to maxDataPoints // Reverse the order so the most recent (active) bin is on the left const startIndex = Math.max(0, this.bins.length - this.maxDataPoints); const recentBins = this.bins.slice(startIndex); dataToRender = recentBins.reverse().map(bin => bin.count); // Reverse so active bin is first (leftmost) maxValue = Math.max(...dataToRender); minValue = Math.min(...dataToRender); valueRange = maxValue - minValue; } else { // Legacy mode: render individual values if (this.data.length === 0) { this.container.textContent = 'No data yet. Click Start to begin.'; return; } dataToRender = this.data; maxValue = Math.max(...this.data); minValue = Math.min(...this.data); valueRange = maxValue - minValue; } let output = ''; const scale = this.maxHeight; // Calculate scaling factor: each X represents at least 1 count const maxCount = Math.max(...dataToRender); const scaleFactor = Math.max(1, Math.ceil(maxCount / scale)); // 1 X = scaleFactor counts const scaledMax = Math.ceil(maxCount / scaleFactor) * scaleFactor; // 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.maxDataPoints * 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 count value this row represents (0 at bottom, increasing upward) const rowCount = (row - 1) * scaleFactor; // Add Y-axis label (show actual count values) line += String(rowCount).padStart(3, ' ') + ' |'; // Draw each column for (let i = 0; i < dataToRender.length; i++) { const count = dataToRender[i]; const scaledHeight = Math.ceil(count / scaleFactor); if (scaledHeight >= row) { line += ' X'; } else { line += ' '; } } output += line + '\n'; } // Draw X-axis output += yAxisPadding + ' +' + '-'.repeat(this.maxDataPoints * 2) + '\n'; // Draw X-axis labels based on mode and format let xAxisLabels = yAxisPadding + ' '; for (let i = 0; i < this.maxDataPoints; i++) { if (i % 5 === 0) { let label = ''; if (this.useBinMode) { // For bin mode, show labels for all possible positions // i=0 is leftmost (most recent), i=maxDataPoints-1 is rightmost (oldest) const elapsedSec = i * Math.floor(this.binDuration / 1000); label = String(elapsedSec).padStart(2, ' ') + 's'; } else { // For legacy mode, show data point numbers const startIndex = Math.max(1, this.totalDataPoints - this.maxDataPoints + 1); label = String(startIndex + i).padStart(2, ' '); } xAxisLabels += label; } else { xAxisLabels += ' '; } } output += xAxisLabels + '\n'; // Add X-axis label if provided if (this.xAxisLabel) { const labelPadding = Math.floor((this.maxDataPoints * 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 (only once at initialization) if (this.autoFitWidth) { this.adjustFontSize(); } // Update the external info display if (this.useBinMode) { const binCounts = this.bins.map(bin => bin.count); const scaleFactor = Math.max(1, Math.ceil(maxValue / scale)); document.getElementById('values').textContent = `[${dataToRender.join(', ')}]`; document.getElementById('max-value').textContent = maxValue; document.getElementById('scale').textContent = `Min: ${minValue}, Max: ${maxValue}, 1X=${scaleFactor} counts`; } else { 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() { if (this.useBinMode) { const totalCount = this.bins.reduce((sum, bin) => sum + bin.count, 0); document.getElementById('count').textContent = totalCount; } else { document.getElementById('count').textContent = this.data.length; } } /** * Initialize the bin system * @private */ initializeBins() { this.bins = []; this.currentBinIndex = -1; this.binStartTime = null; this.chartStartTime = Date.now(); // Create first bin this.rotateBin(); // Set up automatic bin rotation check this.binCheckInterval = setInterval(() => { this.checkBinRotation(); }, 100); // Check every 100ms for responsiveness } /** * Check if current bin should rotate and create new bin if needed * @private */ checkBinRotation() { if (!this.useBinMode || !this.binStartTime) return; const now = Date.now(); if ((now - this.binStartTime) >= this.binDuration) { this.rotateBin(); } } /** * Rotate to a new bin, finalizing the current one */ rotateBin() { // Finalize current bin if it exists if (this.currentBinIndex >= 0) { this.bins[this.currentBinIndex].isActive = false; } // Create new bin const newBin = { startTime: Date.now(), count: 0, isActive: true }; this.bins.push(newBin); this.currentBinIndex = this.bins.length - 1; this.binStartTime = newBin.startTime; // Keep only the most recent bins if (this.bins.length > this.maxDataPoints) { this.bins.shift(); this.currentBinIndex--; } // Ensure currentBinIndex points to the last bin (the active one) this.currentBinIndex = this.bins.length - 1; // Force a render to update the display immediately this.render(); this.updateInfo(); } /** * Format X-axis label for a bin based on the configured format * @param {number} binIndex - Index of the bin * @returns {string} Formatted label * @private */ formatBinLabel(binIndex) { const bin = this.bins[binIndex]; if (!bin) return ' '; switch (this.xAxisLabelFormat) { case 'bins': return String(binIndex + 1).padStart(2, ' '); case 'timestamps': const time = new Date(bin.startTime); return time.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }).replace(/:/g, ''); case 'ranges': const startSec = Math.floor((bin.startTime - this.chartStartTime) / 1000); const endSec = startSec + Math.floor(this.binDuration / 1000); return `${startSec}-${endSec}`; case 'elapsed': default: // For elapsed time, always show time relative to the first bin (index 0) // This keeps the leftmost label as 0s and increases to the right const firstBinTime = this.bins[0] ? this.bins[0].startTime : this.chartStartTime; const elapsedSec = Math.floor((bin.startTime - firstBinTime) / 1000); return String(elapsedSec).padStart(2, ' ') + 's'; } } }