diff --git a/README.md b/README.md index 7ef18c2..d01a553 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,9 @@ A dynamic, real-time ASCII-based vertical bar chart library that renders beautif - 🏷️ **Customizable Labels**: Add title, X-axis, and vertical Y-axis labels - 🎯 **Scrolling Data**: Old data automatically scrolls off as new data arrives - 🎨 **Terminal Theme**: Retro green-on-black aesthetic +- ⏱️ **Time Bin Mode**: Aggregate data into configurable time bins (e.g., count events per 10-second window) +- 📈 **Multiple X-Axis Formats**: Display elapsed time, bin numbers, timestamps, or time ranges +- 👁️ **Active Bin Indicator**: Visual marker shows which bin is currently accumulating data ## Demo @@ -117,6 +120,9 @@ Creates a new ASCII bar chart instance. | `xAxisLabel` | string | `''` | X-axis label (displayed centered at bottom) | | `yAxisLabel` | string | `''` | Y-axis label (displayed vertically on left side) | | `autoFitWidth` | boolean | `true` | Automatically adjust font size to fit container width | +| `useBinMode` | boolean | `false` | Enable time bin mode for data aggregation | +| `binDuration` | number | `4000` | Duration of each time bin in milliseconds (4 seconds default) | +| `xAxisLabelFormat` | string | `'elapsed'` | X-axis label format: `'elapsed'`, `'bins'`, `'timestamps'`, `'ranges'` | **Example:** @@ -249,6 +255,47 @@ cpuChart.addValue(45); memChart.addValue(2048); ``` +## Time Bin Mode + +Time bin mode aggregates data points into time-based bins, showing the count of events within each time window. This is useful for visualizing event frequency over time. + +### Basic Time Bin Usage + +```javascript +const chart = new ASCIIBarChart('chart-container', { + useBinMode: true, + binDuration: 10000, // 10-second bins + xAxisLabelFormat: 'elapsed', // Show elapsed time + title: 'Event Counter', + yAxisLabel: 'Count' +}); + +// Each addValue() increments the count in the current active bin +chart.addValue(1); // Bin 1: count = 1 +chart.addValue(1); // Bin 1: count = 2 +// ... after 10 seconds, automatically creates Bin 2 +``` + +### X-Axis Label Formats + +- **`'elapsed'`** (default): Shows elapsed seconds since chart start ("0s", "10s", "20s") +- **`'bins'`**: Shows bin numbers ("Bin 1", "Bin 2", "Bin 3") +- **`'timestamps'`**: Shows actual timestamps ("103000", "103010", "103020") +- **`'ranges'`**: Shows time ranges ("0-10s", "10-20s", "20-30s") + +### Visual Indicators + +- **X**: Regular bin data +- **O**: Active bin currently accumulating data +- Chart automatically scales when bin counts exceed chart height + +### Manual Bin Rotation + +```javascript +// Force rotation to new bin (useful for testing) +chart.rotateBin(); +``` + ## Styling The chart uses monospaced fonts and renders as plain text. Style the container element: diff --git a/text_graph.html b/text_graph.html index f10005b..bdf39f5 100644 --- a/text_graph.html +++ b/text_graph.html @@ -18,7 +18,7 @@ padding: 20px; border: 2px solid #00ff00; border-radius: 5px; - overflow-x: auto; + overflow: hidden; white-space: pre; font-size: 8px; line-height: 1.0; @@ -103,7 +103,25 @@ Chart Height: + + + +
@@ -115,9 +133,10 @@
-
Legend: Each X represents a count unit
+
Legend: Each X represents a count of events
Values: --
Max value: --, Scale: --
+
@@ -132,7 +151,10 @@ title: 'Real-Time Data Visualization', xAxisLabel: 'Time (seconds)', yAxisLabel: 'Count', - autoFitWidth: true // Automatically adjust font size to fit container width + autoFitWidth: true, // Automatically adjust font size to fit container width + useBinMode: true, // Start in time bin mode + binDuration: 4000, // 4 seconds + xAxisLabelFormat: 'elapsed' }); // Initial render @@ -163,12 +185,21 @@ // Add some randomness around the base value (±3) const variation = Math.floor(Math.random() * 7) - 3; const value = this.baseValue + variation; - + // Increase the base value slightly for next time (0.5 to 1.5 increase) this.baseValue += 0.5 + Math.random(); - + return Math.max(1, Math.round(value)); } + + generateRandomInterval() { + // Generate random interval based on update interval setting + // Random between 10% and 200% of the base update interval + const baseInterval = parseInt(document.getElementById('update-interval').value); + const minInterval = Math.max(50, baseInterval * 0.1); // Minimum 50ms + const maxInterval = baseInterval * 2; // Maximum 2x the base interval + return Math.floor(Math.random() * (maxInterval - minInterval)) + minInterval; + } updateCountdown() { if (!this.isRunning || !this.nextUpdateTime) { @@ -183,46 +214,60 @@ start() { if (this.isRunning) return; - + this.isRunning = true; document.getElementById('status').textContent = 'Running'; - + // Add first data point immediately chart.addValue(this.generateValue()); - - // Set up interval for subsequent updates - this.nextUpdateTime = Date.now() + this.updateInterval; - this.intervalId = setInterval(() => { - chart.addValue(this.generateValue()); - this.nextUpdateTime = Date.now() + this.updateInterval; - }, this.updateInterval); - - // Update countdown every second + + // Set up interval for subsequent updates with random timing + this.scheduleNextUpdate(); this.countdownId = setInterval(() => { this.updateCountdown(); }, 1000); - + this.updateCountdown(); } + + scheduleNextUpdate() { + if (!this.isRunning) return; + + const randomInterval = this.generateRandomInterval(); + this.nextUpdateTime = Date.now() + randomInterval; + + this.intervalId = setTimeout(() => { + if (this.isRunning) { + chart.addValue(this.generateValue()); + this.scheduleNextUpdate(); + } + }, randomInterval); + } stop() { if (!this.isRunning) return; - + this.isRunning = false; document.getElementById('status').textContent = 'Stopped'; - + if (this.intervalId) { - clearInterval(this.intervalId); + clearTimeout(this.intervalId); this.intervalId = null; } - + if (this.countdownId) { clearInterval(this.countdownId); this.countdownId = null; } - + this.nextUpdateTime = null; this.updateCountdown(); + + // Stop bin rotation timer when stopping data generation + if (chart.binCheckInterval) { + clearInterval(chart.binCheckInterval); + chart.binCheckInterval = null; + } } reset() { @@ -235,18 +280,48 @@ // Create data generator for testing let dataGenerator = new DataGenerator(1000); + // Function to toggle bin mode + function toggleBinMode() { + const useBinMode = document.getElementById('use-bin-mode').checked; + const binDurationInput = document.getElementById('bin-duration'); + const xAxisFormatSelect = document.getElementById('x-axis-format'); + const rotateBinBtn = document.getElementById('rotate-bin-btn'); + const binInfo = document.getElementById('bin-info'); + const legendText = document.getElementById('legend-text'); + + if (useBinMode) { + rotateBinBtn.style.display = 'inline-block'; + binInfo.style.display = 'block'; + legendText.textContent = 'Each X represents a count of events. O marks the active bin.'; + } else { + rotateBinBtn.style.display = 'none'; + binInfo.style.display = 'none'; + legendText.textContent = 'Each X represents a count of events'; + } + } + + // Function to manually rotate bin + function rotateBin() { + if (chart && chart.useBinMode) { + chart.rotateBin(); + } + } + // Function to apply settings function applySettings() { const wasRunning = dataGenerator.isRunning; - + // Stop current generator dataGenerator.stop(); - + // Get new settings const updateInterval = parseInt(document.getElementById('update-interval').value); const maxColumns = parseInt(document.getElementById('max-columns').value); const chartHeight = parseInt(document.getElementById('chart-height').value); - + const useBinMode = document.getElementById('use-bin-mode').checked; + const binDuration = parseFloat(document.getElementById('bin-duration').value) * 1000; // Convert to milliseconds + const xAxisLabelFormat = document.getElementById('x-axis-format').value; + // Recreate chart with new settings chart = new ASCIIBarChart('chart-container', { maxHeight: chartHeight, @@ -254,20 +329,43 @@ title: 'Real-Time Data Visualization', xAxisLabel: 'Time (seconds)', yAxisLabel: 'Count', - autoFitWidth: true + autoFitWidth: true, + useBinMode: useBinMode, + binDuration: binDuration, + xAxisLabelFormat: xAxisLabelFormat }); - + + // Force font size adjustment for new settings + chart.fontSizeAdjusted = false; chart.render(); chart.updateInfo(); - + // Recreate data generator with new interval dataGenerator = new DataGenerator(updateInterval); - + // Restart if it was running if (wasRunning) { dataGenerator.start(); + } else { + // If not restarting, make sure bin timer is also stopped + if (chart.binCheckInterval) { + clearInterval(chart.binCheckInterval); + chart.binCheckInterval = null; + } } } + + // Update bin info display + setInterval(() => { + if (chart && chart.useBinMode && chart.bins.length > 0) { + const currentBin = chart.currentBinIndex + 1; + const timeRemaining = chart.binStartTime ? + Math.max(0, Math.ceil((chart.binStartTime + chart.binDuration - Date.now()) / 1000)) : 0; + + document.getElementById('current-bin').textContent = currentBin; + document.getElementById('time-remaining').textContent = timeRemaining; + } + }, 1000); \ No newline at end of file diff --git a/text_graph.js b/text_graph.js index 44d734f..a0d4315 100644 --- a/text_graph.js +++ b/text_graph.js @@ -15,6 +15,9 @@ class ASCIIBarChart { * @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); @@ -26,7 +29,19 @@ class ASCIIBarChart { 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(() => { @@ -34,6 +49,11 @@ class ASCIIBarChart { }); this.resizeObserver.observe(this.container); } + + // Initialize first bin if bin mode is enabled + if (this.useBinMode) { + this.initializeBins(); + } } /** @@ -41,14 +61,22 @@ class ASCIIBarChart { * @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(); + 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(); } @@ -59,6 +87,14 @@ class ASCIIBarChart { clear() { this.data = []; this.totalDataPoints = 0; + + if (this.useBinMode) { + this.bins = []; + this.currentBinIndex = -1; + this.binStartTime = null; + this.initializeBins(); + } + this.render(); this.updateInfo(); } @@ -69,15 +105,25 @@ class ASCIIBarChart { * @private */ getChartWidth() { - if (this.data.length === 0) return 50; // Default width for empty chart - + 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 = this.data.length * 2; // Each column is 2 characters wide + const dataWidth = dataLength * 2; // Each column is 2 characters wide const padding = 1; // Extra padding - - return yAxisPadding + yAxisNumbers + separator + dataWidth + 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; } /** @@ -86,22 +132,28 @@ class ASCIIBarChart { */ 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'; } @@ -111,32 +163,60 @@ class ASCIIBarChart { * @private */ render() { - if (this.data.length === 0) { - this.container.textContent = 'No data yet. Click Start to begin.'; - return; + 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 maxValue = Math.max(...this.data); - const minValue = Math.min(...this.data); - const valueRange = maxValue - minValue; 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.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'; - } - + 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; @@ -149,69 +229,78 @@ class ASCIIBarChart { 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, ' ') + ' |'; - + + // 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 < 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) { + 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.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 += 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.data.length * 2 - this.xAxisLabel.length) / 2); - output += '\n' + yAxisPadding + ' ' + ' '.repeat(Math.max(0, labelPadding)) + this.xAxisLabel + '\n'; - } - + 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 - if (this.autoFitWidth) { - this.adjustFontSize(); - } - + + // Adjust font size to fit width (only once at initialization) + 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}`; + 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}`; + } } /** @@ -219,6 +308,115 @@ class ASCIIBarChart { * @private */ updateInfo() { - document.getElementById('count').textContent = this.data.length; + 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'; + } } } \ No newline at end of file