pipechart 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +228 -0
- package/bin/pipechart.js +319 -0
- package/lib/ansi.js +131 -0
- package/lib/detect.js +177 -0
- package/lib/render/bar.js +131 -0
- package/lib/render/hist.js +151 -0
- package/lib/render/line.js +239 -0
- package/lib/render/spark.js +101 -0
- package/lib/scale.js +185 -0
- package/package.json +39 -0
package/lib/detect.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// JSON shape auto-detection
|
|
4
|
+
// Determines the best chart type for a given dataset
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Check if a value is a finite number.
|
|
8
|
+
* @param {*} v
|
|
9
|
+
* @returns {boolean}
|
|
10
|
+
*/
|
|
11
|
+
function isNum(v) {
|
|
12
|
+
return typeof v === 'number' && isFinite(v);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Check if an object looks like a time-value pair.
|
|
17
|
+
* Accepts { t, v }, { time, value }, { timestamp, value }, { x, y }
|
|
18
|
+
* @param {object} obj
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
21
|
+
function isTimeSeries(obj) {
|
|
22
|
+
if (!obj || typeof obj !== 'object') return false;
|
|
23
|
+
var hasTime = ('t' in obj) || ('time' in obj) || ('timestamp' in obj) || ('x' in obj);
|
|
24
|
+
var hasValue = ('v' in obj) || ('value' in obj) || ('y' in obj);
|
|
25
|
+
return hasTime && hasValue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract the time value from a time-series object.
|
|
30
|
+
* @param {object} obj
|
|
31
|
+
* @returns {number|string}
|
|
32
|
+
*/
|
|
33
|
+
function extractTime(obj) {
|
|
34
|
+
if ('t' in obj) return obj.t;
|
|
35
|
+
if ('time' in obj) return obj.time;
|
|
36
|
+
if ('timestamp' in obj) return obj.timestamp;
|
|
37
|
+
if ('x' in obj) return obj.x;
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Extract the numeric value from a time-series object.
|
|
43
|
+
* @param {object} obj
|
|
44
|
+
* @returns {number}
|
|
45
|
+
*/
|
|
46
|
+
function extractValue(obj) {
|
|
47
|
+
if ('v' in obj) return obj.v;
|
|
48
|
+
if ('value' in obj) return obj.value;
|
|
49
|
+
if ('y' in obj) return obj.y;
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Find the first numeric field in an object.
|
|
55
|
+
* @param {object} obj
|
|
56
|
+
* @returns {string|null}
|
|
57
|
+
*/
|
|
58
|
+
function findNumericField(obj) {
|
|
59
|
+
if (!obj || typeof obj !== 'object') return null;
|
|
60
|
+
var keys = Object.keys(obj);
|
|
61
|
+
for (var i = 0; i < keys.length; i++) {
|
|
62
|
+
if (isNum(obj[keys[i]])) return keys[i];
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Detect the shape of the input data and return a descriptor.
|
|
69
|
+
*
|
|
70
|
+
* Returns an object:
|
|
71
|
+
* {
|
|
72
|
+
* type: 'bar' | 'line' | 'spark' | 'hist',
|
|
73
|
+
* values: number[], // extracted numeric values
|
|
74
|
+
* labels: string[] | null, // labels for bar chart
|
|
75
|
+
* times: any[] | null, // time axis for line chart
|
|
76
|
+
* field: string | null // which field was used
|
|
77
|
+
* }
|
|
78
|
+
*
|
|
79
|
+
* @param {*} data — parsed JSON input
|
|
80
|
+
* @param {object} opts — CLI options (type, field)
|
|
81
|
+
* @returns {object}
|
|
82
|
+
*/
|
|
83
|
+
function detect(data, opts) {
|
|
84
|
+
opts = opts || {};
|
|
85
|
+
|
|
86
|
+
// ── Flat array of numbers ────────────────────────────────────────────────
|
|
87
|
+
if (Array.isArray(data) && data.length > 0 && isNum(data[0])) {
|
|
88
|
+
var allNums = data.every(function(v) { return isNum(v); });
|
|
89
|
+
if (allNums) {
|
|
90
|
+
var defaultType = opts.type || (data.length > 60 ? 'hist' : 'spark');
|
|
91
|
+
return {
|
|
92
|
+
type: defaultType,
|
|
93
|
+
values: data,
|
|
94
|
+
labels: null,
|
|
95
|
+
times: null,
|
|
96
|
+
field: null
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Array of objects ─────────────────────────────────────────────────────
|
|
102
|
+
if (Array.isArray(data) && data.length > 0 && typeof data[0] === 'object' && data[0] !== null) {
|
|
103
|
+
|
|
104
|
+
// Time-series: { t, v } or { time, value } etc.
|
|
105
|
+
if (!opts.field && isTimeSeries(data[0])) {
|
|
106
|
+
var tsValues = [];
|
|
107
|
+
var tsTimes = [];
|
|
108
|
+
var valid = true;
|
|
109
|
+
for (var i = 0; i < data.length; i++) {
|
|
110
|
+
var tv = extractValue(data[i]);
|
|
111
|
+
if (!isNum(tv)) { valid = false; break; }
|
|
112
|
+
tsValues.push(tv);
|
|
113
|
+
tsTimes.push(extractTime(data[i]));
|
|
114
|
+
}
|
|
115
|
+
if (valid) {
|
|
116
|
+
return {
|
|
117
|
+
type: opts.type || 'line',
|
|
118
|
+
values: tsValues,
|
|
119
|
+
labels: null,
|
|
120
|
+
times: tsTimes,
|
|
121
|
+
field: null
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Array of objects with a specified or auto-detected numeric field
|
|
127
|
+
var field = opts.field || findNumericField(data[0]);
|
|
128
|
+
if (field) {
|
|
129
|
+
var objValues = [];
|
|
130
|
+
var objLabels = [];
|
|
131
|
+
for (var j = 0; j < data.length; j++) {
|
|
132
|
+
var v = data[j][field];
|
|
133
|
+
if (!isNum(v)) continue;
|
|
134
|
+
objValues.push(v);
|
|
135
|
+
// Use 'name', 'label', 'key', 'id', or index as label
|
|
136
|
+
var label = data[j].name || data[j].label || data[j].key || data[j].id || String(j);
|
|
137
|
+
objLabels.push(String(label));
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
type: opts.type || 'bar',
|
|
141
|
+
values: objValues,
|
|
142
|
+
labels: objLabels,
|
|
143
|
+
times: null,
|
|
144
|
+
field: field
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Single number ────────────────────────────────────────────────────────
|
|
150
|
+
if (isNum(data)) {
|
|
151
|
+
return {
|
|
152
|
+
type: opts.type || 'spark',
|
|
153
|
+
values: [data],
|
|
154
|
+
labels: null,
|
|
155
|
+
times: null,
|
|
156
|
+
field: null
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Fallback: try to extract any numbers we can find ────────────────────
|
|
161
|
+
return {
|
|
162
|
+
type: opts.type || 'spark',
|
|
163
|
+
values: [],
|
|
164
|
+
labels: null,
|
|
165
|
+
times: null,
|
|
166
|
+
field: null
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = {
|
|
171
|
+
detect: detect,
|
|
172
|
+
isNum: isNum,
|
|
173
|
+
isTimeSeries: isTimeSeries,
|
|
174
|
+
extractTime: extractTime,
|
|
175
|
+
extractValue: extractValue,
|
|
176
|
+
findNumericField: findNumericField
|
|
177
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Horizontal bar chart renderer
|
|
4
|
+
|
|
5
|
+
var ansi = require('../ansi');
|
|
6
|
+
var scale = require('../scale');
|
|
7
|
+
|
|
8
|
+
var BAR_CHAR = '\u2588'; // █
|
|
9
|
+
var HALF_BAR = '\u258C'; // ▌
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Render a horizontal bar chart.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} opts
|
|
15
|
+
* @param {number[]} opts.values — data values
|
|
16
|
+
* @param {string[]} opts.labels — bar labels (parallel to values)
|
|
17
|
+
* @param {number} opts.width — total terminal width
|
|
18
|
+
* @param {string} [opts.color] — ANSI color name
|
|
19
|
+
* @param {string} [opts.title] — chart title
|
|
20
|
+
* @returns {string} — multi-line string ready to print
|
|
21
|
+
*/
|
|
22
|
+
function render(opts) {
|
|
23
|
+
var values = opts.values || [];
|
|
24
|
+
var labels = opts.labels || [];
|
|
25
|
+
var width = opts.width || 80;
|
|
26
|
+
var color = opts.color || 'cyan';
|
|
27
|
+
var title = opts.title || null;
|
|
28
|
+
|
|
29
|
+
if (values.length === 0) {
|
|
30
|
+
return ansi.colorize('(no data)', 'dim');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
var lines = [];
|
|
34
|
+
|
|
35
|
+
// Title
|
|
36
|
+
if (title) {
|
|
37
|
+
lines.push(ansi.bold(ansi.colorize(title, color)));
|
|
38
|
+
lines.push('');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
var minVal = scale.min(values);
|
|
42
|
+
var maxVal = scale.max(values);
|
|
43
|
+
|
|
44
|
+
// Determine label column width
|
|
45
|
+
var maxLabelLen = 0;
|
|
46
|
+
for (var i = 0; i < values.length; i++) {
|
|
47
|
+
var lbl = labels[i] !== undefined ? String(labels[i]) : String(i);
|
|
48
|
+
if (lbl.length > maxLabelLen) maxLabelLen = lbl.length;
|
|
49
|
+
}
|
|
50
|
+
// Cap label width at 20 chars
|
|
51
|
+
var labelWidth = Math.min(maxLabelLen, 20);
|
|
52
|
+
|
|
53
|
+
// Value suffix width: " 12345.67"
|
|
54
|
+
var maxValStr = scale.formatNum(maxVal);
|
|
55
|
+
var valWidth = maxValStr.length + 1; // +1 for space
|
|
56
|
+
|
|
57
|
+
// Available bar width
|
|
58
|
+
var barAreaWidth = width - labelWidth - valWidth - 3; // 3 = " │ "
|
|
59
|
+
if (barAreaWidth < 4) barAreaWidth = 4;
|
|
60
|
+
|
|
61
|
+
// Axis info
|
|
62
|
+
var axisMin = Math.min(0, minVal);
|
|
63
|
+
var axisMax = maxVal;
|
|
64
|
+
if (axisMax === axisMin) axisMax = axisMin + 1;
|
|
65
|
+
|
|
66
|
+
// Zero line position (for negative values)
|
|
67
|
+
var zeroPos = scale.mapToRange(0, axisMin, axisMax, barAreaWidth);
|
|
68
|
+
|
|
69
|
+
for (var j = 0; j < values.length; j++) {
|
|
70
|
+
var val = values[j];
|
|
71
|
+
var label = labels[j] !== undefined ? String(labels[j]) : String(j);
|
|
72
|
+
label = scale.truncate(label, labelWidth);
|
|
73
|
+
label = scale.padRight(label, labelWidth);
|
|
74
|
+
|
|
75
|
+
var barLen = scale.mapToRange(Math.max(val, 0), axisMin, axisMax, barAreaWidth);
|
|
76
|
+
var negLen = val < 0 ? scale.mapToRange(Math.abs(val), 0, axisMax - axisMin, barAreaWidth) : 0;
|
|
77
|
+
|
|
78
|
+
var bar;
|
|
79
|
+
if (val >= 0) {
|
|
80
|
+
// Positive bar: spaces up to zero, then bar
|
|
81
|
+
var prefix = zeroPos > 0 ? scale.padRight('', zeroPos) : '';
|
|
82
|
+
bar = prefix + ansi.colorize(repeat(BAR_CHAR, barLen), color);
|
|
83
|
+
} else {
|
|
84
|
+
// Negative bar: spaces, then bar going left, then zero marker
|
|
85
|
+
var negBar = ansi.colorize(repeat(BAR_CHAR, negLen), 'red');
|
|
86
|
+
var afterNeg = scale.padRight('', zeroPos - negLen);
|
|
87
|
+
bar = afterNeg + negBar;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Pad bar area to full width
|
|
91
|
+
var rawBarLen = (val >= 0 ? zeroPos + barLen : zeroPos) ;
|
|
92
|
+
var padding = barAreaWidth - rawBarLen;
|
|
93
|
+
if (padding < 0) padding = 0;
|
|
94
|
+
bar = bar + scale.padRight('', padding);
|
|
95
|
+
|
|
96
|
+
var valStr = scale.padLeft(scale.formatNum(val), valWidth);
|
|
97
|
+
|
|
98
|
+
lines.push(
|
|
99
|
+
ansi.dim(label) + ' \u2502 ' + bar + ansi.dim(valStr)
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Bottom axis
|
|
104
|
+
var axisLine = scale.padRight('', labelWidth) + ' \u2514' + repeat('\u2500', barAreaWidth + 1);
|
|
105
|
+
lines.push(ansi.dim(axisLine));
|
|
106
|
+
|
|
107
|
+
// Axis labels: min and max
|
|
108
|
+
var minLabel = scale.formatNum(axisMin);
|
|
109
|
+
var maxLabel = scale.formatNum(axisMax);
|
|
110
|
+
var axisLabels = scale.padRight('', labelWidth + 2) +
|
|
111
|
+
minLabel +
|
|
112
|
+
scale.padRight('', barAreaWidth - minLabel.length - maxLabel.length) +
|
|
113
|
+
maxLabel;
|
|
114
|
+
lines.push(ansi.dim(axisLabels));
|
|
115
|
+
|
|
116
|
+
return lines.join('\n');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Repeat a character n times.
|
|
121
|
+
* @param {string} ch
|
|
122
|
+
* @param {number} n
|
|
123
|
+
* @returns {string}
|
|
124
|
+
*/
|
|
125
|
+
function repeat(ch, n) {
|
|
126
|
+
var s = '';
|
|
127
|
+
for (var i = 0; i < n; i++) s += ch;
|
|
128
|
+
return s;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { render: render };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Histogram renderer — vertical bars using █ blocks, auto-bins
|
|
4
|
+
|
|
5
|
+
var ansi = require('../ansi');
|
|
6
|
+
var scale = require('../scale');
|
|
7
|
+
|
|
8
|
+
var FULL_BLOCK = '\u2588'; // █
|
|
9
|
+
var UPPER_HALF = '\u2580'; // ▀ (top half block, for finer resolution)
|
|
10
|
+
var LOWER_HALF = '\u2584'; // ▄
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Render a histogram.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} opts
|
|
16
|
+
* @param {number[]} opts.values — raw data values (will be binned)
|
|
17
|
+
* @param {number} opts.width — terminal width
|
|
18
|
+
* @param {string} [opts.color] — ANSI color name
|
|
19
|
+
* @param {string} [opts.title] — chart title
|
|
20
|
+
* @param {number} [opts.height] — chart height in rows (default: 12)
|
|
21
|
+
* @param {number} [opts.bins] — number of bins (default: auto)
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
function render(opts) {
|
|
25
|
+
var values = opts.values || [];
|
|
26
|
+
var width = opts.width || 80;
|
|
27
|
+
var color = opts.color || 'yellow';
|
|
28
|
+
var title = opts.title || null;
|
|
29
|
+
var height = opts.height || 12;
|
|
30
|
+
|
|
31
|
+
if (values.length === 0) {
|
|
32
|
+
return ansi.colorize('(no data)', 'dim');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Y-axis label width
|
|
36
|
+
var maxCount = 0; // will be set after binning
|
|
37
|
+
|
|
38
|
+
// Auto-determine bin count: Sturges' rule, capped to available width
|
|
39
|
+
var yLabelWidth = 5; // placeholder, recalculated below
|
|
40
|
+
var plotWidth = width - yLabelWidth - 2;
|
|
41
|
+
if (plotWidth < 4) plotWidth = 4;
|
|
42
|
+
|
|
43
|
+
var binCount = opts.bins || Math.min(
|
|
44
|
+
Math.max(Math.ceil(Math.log2(values.length) + 1), 5),
|
|
45
|
+
plotWidth
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
var bins = scale.autoBin(values, binCount);
|
|
49
|
+
|
|
50
|
+
// Find max count for scaling
|
|
51
|
+
for (var i = 0; i < bins.length; i++) {
|
|
52
|
+
if (bins[i].count > maxCount) maxCount = bins[i].count;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Recalculate y-axis label width based on actual max count
|
|
56
|
+
yLabelWidth = String(maxCount).length + 1;
|
|
57
|
+
plotWidth = width - yLabelWidth - 2;
|
|
58
|
+
if (plotWidth < 4) plotWidth = 4;
|
|
59
|
+
|
|
60
|
+
// Each bin gets at least 1 column; distribute evenly
|
|
61
|
+
var colsPerBin = Math.max(1, Math.floor(plotWidth / bins.length));
|
|
62
|
+
// Actual used width
|
|
63
|
+
var usedWidth = colsPerBin * bins.length;
|
|
64
|
+
|
|
65
|
+
var lines = [];
|
|
66
|
+
|
|
67
|
+
if (title) {
|
|
68
|
+
lines.push(ansi.bold(ansi.colorize(title, color)));
|
|
69
|
+
lines.push('');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Build grid row by row (top to bottom)
|
|
73
|
+
for (var row = 0; row < height; row++) {
|
|
74
|
+
// What count value does this row represent?
|
|
75
|
+
// row 0 = top = maxCount, row height-1 = bottom = 0
|
|
76
|
+
var rowThreshold = maxCount * (1 - row / (height - 1));
|
|
77
|
+
|
|
78
|
+
// Y-axis label
|
|
79
|
+
var yLabel = '';
|
|
80
|
+
if (row === 0) {
|
|
81
|
+
yLabel = String(maxCount);
|
|
82
|
+
} else if (row === Math.floor(height / 2)) {
|
|
83
|
+
yLabel = String(Math.round(maxCount / 2));
|
|
84
|
+
} else if (row === height - 1) {
|
|
85
|
+
yLabel = '0';
|
|
86
|
+
}
|
|
87
|
+
yLabel = scale.padLeft(yLabel, yLabelWidth);
|
|
88
|
+
|
|
89
|
+
var rowStr = '';
|
|
90
|
+
for (var b = 0; b < bins.length; b++) {
|
|
91
|
+
var binHeight = (bins[b].count / maxCount) * height;
|
|
92
|
+
var filledRows = height - Math.ceil(binHeight); // rows from top that are empty
|
|
93
|
+
|
|
94
|
+
var cellChar;
|
|
95
|
+
if (row < filledRows) {
|
|
96
|
+
// Empty above the bar
|
|
97
|
+
cellChar = repeat(' ', colsPerBin);
|
|
98
|
+
} else {
|
|
99
|
+
// Filled bar cell
|
|
100
|
+
cellChar = ansi.colorize(repeat(FULL_BLOCK, colsPerBin), color);
|
|
101
|
+
}
|
|
102
|
+
rowStr += cellChar;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Pad to usedWidth if needed
|
|
106
|
+
var rawLen = bins.length * colsPerBin;
|
|
107
|
+
if (rawLen < plotWidth) {
|
|
108
|
+
rowStr += repeat(' ', plotWidth - rawLen);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
lines.push(ansi.dim(yLabel) + ' \u2502' + rowStr);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Bottom axis
|
|
115
|
+
var axisLine = scale.padRight('', yLabelWidth) + ' \u2514' + repeat('\u2500', usedWidth);
|
|
116
|
+
lines.push(ansi.dim(axisLine));
|
|
117
|
+
|
|
118
|
+
// Bin range labels: first bin min and last bin max
|
|
119
|
+
var minVal = scale.min(values);
|
|
120
|
+
var maxVal = scale.max(values);
|
|
121
|
+
var minLabel = scale.formatNum(minVal);
|
|
122
|
+
var maxLabel = scale.formatNum(maxVal);
|
|
123
|
+
var midLabel = scale.formatNum((minVal + maxVal) / 2);
|
|
124
|
+
|
|
125
|
+
var labelLine = scale.padRight('', yLabelWidth + 2) +
|
|
126
|
+
minLabel +
|
|
127
|
+
scale.padRight('', Math.floor(usedWidth / 2) - minLabel.length - Math.floor(midLabel.length / 2)) +
|
|
128
|
+
midLabel +
|
|
129
|
+
scale.padRight('', usedWidth - Math.floor(usedWidth / 2) - Math.ceil(midLabel.length / 2) - maxLabel.length) +
|
|
130
|
+
maxLabel;
|
|
131
|
+
lines.push(ansi.dim(labelLine));
|
|
132
|
+
|
|
133
|
+
// Count label
|
|
134
|
+
lines.push(ansi.dim(scale.padRight('', yLabelWidth + 2) + 'n=' + values.length + ' bins=' + binCount));
|
|
135
|
+
|
|
136
|
+
return lines.join('\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Repeat a character n times.
|
|
141
|
+
* @param {string} ch
|
|
142
|
+
* @param {number} n
|
|
143
|
+
* @returns {string}
|
|
144
|
+
*/
|
|
145
|
+
function repeat(ch, n) {
|
|
146
|
+
var s = '';
|
|
147
|
+
for (var i = 0; i < n; i++) s += ch;
|
|
148
|
+
return s;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = { render: render };
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Line chart renderer — plots points across terminal width
|
|
4
|
+
// Connects adjacent points with ASCII slope characters
|
|
5
|
+
|
|
6
|
+
var ansi = require('../ansi');
|
|
7
|
+
var scale = require('../scale');
|
|
8
|
+
|
|
9
|
+
// Characters used to draw the line
|
|
10
|
+
var CHARS = {
|
|
11
|
+
dot: '\u2022', // • point
|
|
12
|
+
horiz: '\u2500', // ─ flat
|
|
13
|
+
up: '/', // rising
|
|
14
|
+
down: '\\', // falling
|
|
15
|
+
vert: '\u2502', // │ vertical
|
|
16
|
+
cross: '\u253C', // ┼ axis cross
|
|
17
|
+
axisH: '\u2500', // ─
|
|
18
|
+
axisV: '\u2502', // │
|
|
19
|
+
corner: '\u2514', // └
|
|
20
|
+
tickH: '\u252C', // ┬
|
|
21
|
+
tickV: '\u251C' // ├
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Render a line chart.
|
|
26
|
+
*
|
|
27
|
+
* @param {object} opts
|
|
28
|
+
* @param {number[]} opts.values — data values
|
|
29
|
+
* @param {any[]} [opts.times] — time labels (parallel to values)
|
|
30
|
+
* @param {number} opts.width — terminal width
|
|
31
|
+
* @param {string} [opts.color] — ANSI color name
|
|
32
|
+
* @param {string} [opts.title] — chart title
|
|
33
|
+
* @param {number} [opts.height] — chart height in rows (default: 12)
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
function render(opts) {
|
|
37
|
+
var values = opts.values || [];
|
|
38
|
+
var times = opts.times || null;
|
|
39
|
+
var width = opts.width || 80;
|
|
40
|
+
var color = opts.color || 'cyan';
|
|
41
|
+
var title = opts.title || null;
|
|
42
|
+
var height = opts.height || 12;
|
|
43
|
+
|
|
44
|
+
if (values.length === 0) {
|
|
45
|
+
return ansi.colorize('(no data)', 'dim');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
var minVal = scale.min(values);
|
|
49
|
+
var maxVal = scale.max(values);
|
|
50
|
+
// Give a little padding so points don't sit on the very edge
|
|
51
|
+
var range = maxVal - minVal;
|
|
52
|
+
if (range === 0) range = 1;
|
|
53
|
+
|
|
54
|
+
// Y-axis label width
|
|
55
|
+
var yLabelWidth = Math.max(
|
|
56
|
+
scale.formatNum(minVal).length,
|
|
57
|
+
scale.formatNum(maxVal).length
|
|
58
|
+
) + 1;
|
|
59
|
+
|
|
60
|
+
// Available plot width (after y-axis)
|
|
61
|
+
var plotWidth = width - yLabelWidth - 2; // 2 = "│ "
|
|
62
|
+
if (plotWidth < 4) plotWidth = 4;
|
|
63
|
+
|
|
64
|
+
// Downsample or map values to plotWidth columns
|
|
65
|
+
var plotValues = values;
|
|
66
|
+
if (values.length > plotWidth) {
|
|
67
|
+
plotValues = downsample(values, plotWidth);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
var cols = plotValues.length;
|
|
71
|
+
|
|
72
|
+
// Build a 2D grid: grid[row][col] = char or ' '
|
|
73
|
+
var grid = [];
|
|
74
|
+
for (var r = 0; r < height; r++) {
|
|
75
|
+
var row = [];
|
|
76
|
+
for (var c = 0; c < plotWidth; c++) {
|
|
77
|
+
row.push(' ');
|
|
78
|
+
}
|
|
79
|
+
grid.push(row);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Map a data value to a grid row (0 = top, height-1 = bottom).
|
|
84
|
+
*/
|
|
85
|
+
function valueToRow(v) {
|
|
86
|
+
var norm = scale.normalize(v, minVal - range * 0.05, maxVal + range * 0.05);
|
|
87
|
+
var row = Math.round((1 - norm) * (height - 1));
|
|
88
|
+
if (row < 0) row = 0;
|
|
89
|
+
if (row >= height) row = height - 1;
|
|
90
|
+
return row;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Plot points and connecting lines
|
|
94
|
+
for (var i = 0; i < cols; i++) {
|
|
95
|
+
var col = Math.floor(i * plotWidth / cols);
|
|
96
|
+
if (col >= plotWidth) col = plotWidth - 1;
|
|
97
|
+
|
|
98
|
+
var row = valueToRow(plotValues[i]);
|
|
99
|
+
|
|
100
|
+
if (i === 0) {
|
|
101
|
+
grid[row][col] = CHARS.dot;
|
|
102
|
+
} else {
|
|
103
|
+
var prevCol = Math.floor((i - 1) * plotWidth / cols);
|
|
104
|
+
if (prevCol >= plotWidth) prevCol = plotWidth - 1;
|
|
105
|
+
var prevRow = valueToRow(plotValues[i - 1]);
|
|
106
|
+
|
|
107
|
+
// Draw the point
|
|
108
|
+
grid[row][col] = CHARS.dot;
|
|
109
|
+
|
|
110
|
+
// Connect with slope character
|
|
111
|
+
if (prevRow === row) {
|
|
112
|
+
// Flat — fill horizontal between prevCol+1 and col-1
|
|
113
|
+
for (var fc = prevCol + 1; fc < col; fc++) {
|
|
114
|
+
if (grid[row][fc] === ' ') grid[row][fc] = CHARS.horiz;
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
// Sloped — draw diagonal connector
|
|
118
|
+
var rowStep = prevRow < row ? 1 : -1;
|
|
119
|
+
var colStep = col > prevCol ? 1 : -1;
|
|
120
|
+
var dr = Math.abs(row - prevRow);
|
|
121
|
+
var dc = Math.abs(col - prevCol);
|
|
122
|
+
var ch = rowStep > 0 ? CHARS.down : CHARS.up;
|
|
123
|
+
|
|
124
|
+
if (dc === 0) {
|
|
125
|
+
// Vertical segment
|
|
126
|
+
for (var vr = prevRow + rowStep; vr !== row; vr += rowStep) {
|
|
127
|
+
if (grid[vr][col] === ' ') grid[vr][col] = CHARS.vert;
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
// Bresenham-like diagonal
|
|
131
|
+
var err = 0;
|
|
132
|
+
var cr = prevRow;
|
|
133
|
+
var cc = prevCol;
|
|
134
|
+
for (var step = 0; step < dc + dr; step++) {
|
|
135
|
+
err += dr;
|
|
136
|
+
if (err * 2 >= dc) {
|
|
137
|
+
cr += rowStep;
|
|
138
|
+
err -= dc;
|
|
139
|
+
} else {
|
|
140
|
+
cc += colStep;
|
|
141
|
+
}
|
|
142
|
+
if (cr !== row || cc !== col) {
|
|
143
|
+
if (cc >= 0 && cc < plotWidth && cr >= 0 && cr < height) {
|
|
144
|
+
if (grid[cr][cc] === ' ') grid[cr][cc] = ch;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Render lines
|
|
154
|
+
var lines = [];
|
|
155
|
+
|
|
156
|
+
if (title) {
|
|
157
|
+
lines.push(ansi.bold(ansi.colorize(title, color)));
|
|
158
|
+
lines.push('');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for (var row2 = 0; row2 < height; row2++) {
|
|
162
|
+
// Y-axis label (only at top, middle, bottom)
|
|
163
|
+
var yLabel = '';
|
|
164
|
+
if (row2 === 0) {
|
|
165
|
+
yLabel = scale.formatNum(maxVal);
|
|
166
|
+
} else if (row2 === Math.floor(height / 2)) {
|
|
167
|
+
yLabel = scale.formatNum((minVal + maxVal) / 2);
|
|
168
|
+
} else if (row2 === height - 1) {
|
|
169
|
+
yLabel = scale.formatNum(minVal);
|
|
170
|
+
}
|
|
171
|
+
yLabel = scale.padLeft(yLabel, yLabelWidth);
|
|
172
|
+
|
|
173
|
+
var rowStr = '';
|
|
174
|
+
for (var col2 = 0; col2 < plotWidth; col2++) {
|
|
175
|
+
var ch2 = grid[row2][col2];
|
|
176
|
+
if (ch2 !== ' ') {
|
|
177
|
+
rowStr += ansi.colorize(ch2, color);
|
|
178
|
+
} else {
|
|
179
|
+
rowStr += ' ';
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
lines.push(ansi.dim(yLabel) + ' ' + CHARS.axisV + rowStr);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// X axis
|
|
187
|
+
var xAxisLine = scale.padRight('', yLabelWidth) + ' ' + CHARS.corner + repeat(CHARS.axisH, plotWidth);
|
|
188
|
+
lines.push(ansi.dim(xAxisLine));
|
|
189
|
+
|
|
190
|
+
// X axis labels (first, middle, last time label)
|
|
191
|
+
if (times && times.length > 0) {
|
|
192
|
+
var t0 = String(times[0]);
|
|
193
|
+
var tMid = String(times[Math.floor(times.length / 2)]);
|
|
194
|
+
var tEnd = String(times[times.length - 1]);
|
|
195
|
+
var xLabels = scale.padRight('', yLabelWidth + 2) +
|
|
196
|
+
t0 +
|
|
197
|
+
scale.padRight('', Math.floor(plotWidth / 2) - t0.length - Math.floor(tMid.length / 2)) +
|
|
198
|
+
tMid +
|
|
199
|
+
scale.padRight('', plotWidth - Math.floor(plotWidth / 2) - Math.ceil(tMid.length / 2) - tEnd.length) +
|
|
200
|
+
tEnd;
|
|
201
|
+
lines.push(ansi.dim(xLabels));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return lines.join('\n');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Downsample an array to targetLen by averaging buckets.
|
|
209
|
+
* @param {number[]} values
|
|
210
|
+
* @param {number} targetLen
|
|
211
|
+
* @returns {number[]}
|
|
212
|
+
*/
|
|
213
|
+
function downsample(values, targetLen) {
|
|
214
|
+
var result = [];
|
|
215
|
+
var bucketSize = values.length / targetLen;
|
|
216
|
+
for (var i = 0; i < targetLen; i++) {
|
|
217
|
+
var start = Math.floor(i * bucketSize);
|
|
218
|
+
var end = Math.floor((i + 1) * bucketSize);
|
|
219
|
+
if (end > values.length) end = values.length;
|
|
220
|
+
var sum = 0;
|
|
221
|
+
for (var j = start; j < end; j++) sum += values[j];
|
|
222
|
+
result.push(sum / (end - start));
|
|
223
|
+
}
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Repeat a character n times.
|
|
229
|
+
* @param {string} ch
|
|
230
|
+
* @param {number} n
|
|
231
|
+
* @returns {string}
|
|
232
|
+
*/
|
|
233
|
+
function repeat(ch, n) {
|
|
234
|
+
var s = '';
|
|
235
|
+
for (var i = 0; i < n; i++) s += ch;
|
|
236
|
+
return s;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
module.exports = { render: render };
|