Files
text_graph/text_graph.js
2025-10-16 07:38:23 -04:00

224 lines
8.2 KiB
JavaScript

/**
* 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;
}
}