224 lines
8.2 KiB
JavaScript
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;
|
|
}
|
|
} |