/** * 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 // TEMP: commented for no-space test const dataWidth = dataLength; // Each column is 1 character wide // TEMP: adjusted for no-space columns 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 // Use a slightly smaller ratio to fit more content const charWidthRatio = 0.6; const padding = 30; // Reduce padding to fit more content 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; } // Always create a fixed-length array filled with 0s, then overlay actual bin data dataToRender = new Array(this.maxDataPoints).fill(0); // Overlay actual bin data (most recent bins, reversed for left-to-right display) const startIndex = Math.max(0, this.bins.length - this.maxDataPoints); const recentBins = this.bins.slice(startIndex); // Reverse the bins so most recent is on the left, and overlay onto the fixed array recentBins.reverse().forEach((bin, index) => { if (index < this.maxDataPoints) { dataToRender[index] = bin.count; } }); console.log('render() dataToRender:', dataToRender, 'bins length:', this.bins.length); 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 // TEMP: commented for no-space test const chartWidth = 4 + this.maxDataPoints; // Y-axis numbers + data columns // TEMP: adjusted for no-space 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'; // TEMP: commented out space between columns line += 'X'; // TEMP: no space between columns } else { // line += ' '; // TEMP: commented out space between columns line += ' '; // TEMP: single space for empty columns } } output += line + '\n'; } // Draw X-axis // output += yAxisPadding + ' +' + '-'.repeat(this.maxDataPoints * 2) + '\n'; // TEMP: commented out for no-space test output += yAxisPadding + ' +' + '-'.repeat(this.maxDataPoints) + '\n'; // TEMP: back to original length // Draw X-axis labels based on mode and format let xAxisLabels = yAxisPadding + ' '; // Initial padding to align with X-axis // Determine label interval (every 5 columns) const labelInterval = 5; // Generate all labels first and store in array let labels = []; for (let i = 0; i < this.maxDataPoints; i++) { if (i % labelInterval === 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 * this.binDuration) / 1000; // Format with appropriate precision for sub-second bins if (this.binDuration < 1000) { // Show decimal seconds for sub-second bins label = elapsedSec.toFixed(1) + 's'; } else { // Show whole seconds for 1+ second bins label = String(Math.round(elapsedSec)) + 's'; } } else { // For legacy mode, show data point numbers const startIndex = Math.max(1, this.totalDataPoints - this.maxDataPoints + 1); label = String(startIndex + i); } labels.push(label); } } // Build the label string with calculated spacing for (let i = 0; i < labels.length; i++) { const label = labels[i]; xAxisLabels += label; // Add spacing: labelInterval - label.length (except for last label) if (i < labels.length - 1) { const spacing = labelInterval - label.length; xAxisLabels += ' '.repeat(spacing); } } // Ensure the label line extends to match the X-axis dash line length // The dash line is this.maxDataPoints characters long, starting after " +" const dashLineLength = this.maxDataPoints; const minLabelLineLength = yAxisPadding.length + 4 + dashLineLength; // 4 for " " if (xAxisLabels.length < minLabelLineLength) { xAxisLabels += ' '.repeat(minLabelLineLength - xAxisLabels.length); } output += xAxisLabels + '\n'; // Add X-axis label if provided if (this.xAxisLabel) { // const labelPadding = Math.floor((this.maxDataPoints * 2 - this.xAxisLabel.length) / 2); // TEMP: commented for no-space test const labelPadding = Math.floor((this.maxDataPoints - this.xAxisLabel.length) / 2); // TEMP: adjusted for no-space columns 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'; } } }