@mmutils/logger 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +1038 -2
- package/dist/cjs/index.cjs +72 -0
- package/dist/cjs/index.cjs.map +1 -0
- package/dist/cjs/levels.cjs +82 -0
- package/dist/cjs/levels.cjs.map +1 -0
- package/dist/cjs/logger.cjs +576 -0
- package/dist/cjs/logger.cjs.map +1 -0
- package/dist/cjs/outputs.cjs +24 -0
- package/dist/cjs/outputs.cjs.map +1 -0
- package/dist/cjs/plugins/columns.cjs +782 -0
- package/dist/cjs/plugins/columns.cjs.map +1 -0
- package/dist/cjs/plugins/graph.cjs +578 -0
- package/dist/cjs/plugins/graph.cjs.map +1 -0
- package/dist/cjs/plugins/json.cjs +644 -0
- package/dist/cjs/plugins/json.cjs.map +1 -0
- package/dist/cjs/plugins/message.cjs +108 -0
- package/dist/cjs/plugins/message.cjs.map +1 -0
- package/dist/cjs/plugins/shared.cjs +194 -0
- package/dist/cjs/plugins/shared.cjs.map +1 -0
- package/dist/cjs/time.cjs +234 -0
- package/dist/cjs/time.cjs.map +1 -0
- package/dist/cjs/utils/text.cjs +646 -0
- package/dist/cjs/utils/text.cjs.map +1 -0
- package/dist/esm/index.js +11 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/levels.js +72 -0
- package/dist/esm/levels.js.map +1 -0
- package/dist/esm/logger.js +572 -0
- package/dist/esm/logger.js.map +1 -0
- package/dist/esm/outputs.js +21 -0
- package/dist/esm/outputs.js.map +1 -0
- package/dist/esm/plugins/columns.js +778 -0
- package/dist/esm/plugins/columns.js.map +1 -0
- package/dist/esm/plugins/graph.js +572 -0
- package/dist/esm/plugins/graph.js.map +1 -0
- package/dist/esm/plugins/json.js +641 -0
- package/dist/esm/plugins/json.js.map +1 -0
- package/dist/esm/plugins/message.js +105 -0
- package/dist/esm/plugins/message.js.map +1 -0
- package/dist/esm/plugins/shared.js +187 -0
- package/dist/esm/plugins/shared.js.map +1 -0
- package/dist/esm/time.js +231 -0
- package/dist/esm/time.js.map +1 -0
- package/dist/esm/utils/text.js +621 -0
- package/dist/esm/utils/text.js.map +1 -0
- package/dist/types/index.d.ts +11 -0
- package/dist/types/levels.d.ts +50 -0
- package/dist/types/logger.d.ts +245 -0
- package/dist/types/outputs.d.ts +12 -0
- package/dist/types/plugins/columns.d.ts +151 -0
- package/dist/types/plugins/graph.d.ts +243 -0
- package/dist/types/plugins/json.d.ts +169 -0
- package/dist/types/plugins/message.d.ts +181 -0
- package/dist/types/plugins/shared.d.ts +74 -0
- package/dist/types/time.d.ts +48 -0
- package/dist/types/types.d.ts +176 -0
- package/dist/types/utils/text.d.ts +231 -0
- package/package.json +85 -4
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var time = require('@mmutils/time');
|
|
4
|
+
var outputs = require('../outputs.cjs');
|
|
5
|
+
var text = require('../utils/text.cjs');
|
|
6
|
+
var shared = require('./shared.cjs');
|
|
7
|
+
|
|
8
|
+
const SAFE_SANITIZER = text.createSanitizer('safe');
|
|
9
|
+
/** The unit a formatted time ends with: `ms` in `2s 25ms`. */
|
|
10
|
+
const LAST_UNIT = /(?:[hms]|[mµn]s)$/;
|
|
11
|
+
/**
|
|
12
|
+
* Formats an axis label. A zero time label takes the smallest unit of the
|
|
13
|
+
* other label, so both show the same precision: `0` next to `2s 25ms`
|
|
14
|
+
* reads `0ms`.
|
|
15
|
+
*
|
|
16
|
+
* @param label Label to format.
|
|
17
|
+
* @param axis Axis settings.
|
|
18
|
+
* @param other The opposite label.
|
|
19
|
+
*/
|
|
20
|
+
const formatAxisLabel = (label, { unit, capTo = 2 }, other) => {
|
|
21
|
+
if (label === undefined)
|
|
22
|
+
return '';
|
|
23
|
+
if (typeof label === 'string')
|
|
24
|
+
return label;
|
|
25
|
+
if (unit === undefined)
|
|
26
|
+
return String(label);
|
|
27
|
+
if (label === 0 && typeof other === 'number' && other !== 0) {
|
|
28
|
+
const otherLabel = time.formatHighResTime(other, capTo, unit);
|
|
29
|
+
const otherUnit = LAST_UNIT.exec(otherLabel)?.[0];
|
|
30
|
+
return time.formatHighResTime(0, otherUnit ?? unit, unit);
|
|
31
|
+
}
|
|
32
|
+
return time.formatHighResTime(label, capTo, unit);
|
|
33
|
+
};
|
|
34
|
+
const DEFAULT_GRAPH_LIMITS = {
|
|
35
|
+
cols: 500,
|
|
36
|
+
rows: 50,
|
|
37
|
+
precision: 20,
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Characters the frame adds to the bars: `│` on each side.
|
|
41
|
+
*/
|
|
42
|
+
const FRAME_WIDTH = 2;
|
|
43
|
+
/**
|
|
44
|
+
* Size settings with their minimum values.
|
|
45
|
+
*/
|
|
46
|
+
const SIZE_SETTINGS = [
|
|
47
|
+
['cols', 1],
|
|
48
|
+
['rows', 1],
|
|
49
|
+
['precision', 0],
|
|
50
|
+
];
|
|
51
|
+
/**
|
|
52
|
+
* `toFixed` accepts at most 100 digits.
|
|
53
|
+
*/
|
|
54
|
+
const MAX_PRECISION = 100;
|
|
55
|
+
const isIntegerIn = (value, min, max) => Number.isInteger(value) &&
|
|
56
|
+
value >= min &&
|
|
57
|
+
value <= max;
|
|
58
|
+
/**
|
|
59
|
+
* Validates the limits and the plugin's size settings, which are
|
|
60
|
+
* configuration: invalid values throw when the plugin is created.
|
|
61
|
+
*/
|
|
62
|
+
const validateLimits = (limits) => {
|
|
63
|
+
for (const [name, min] of SIZE_SETTINGS) {
|
|
64
|
+
const limit = limits[name];
|
|
65
|
+
const maxLimit = name === 'precision' ? MAX_PRECISION : Infinity;
|
|
66
|
+
if (!isIntegerIn(limit, min, maxLimit))
|
|
67
|
+
throw new RangeError(`graphPlugin: limits.${name} must be an integer ${maxLimit === Infinity ? `of at least ${String(min)}` : `from ${String(min)} to ${String(maxLimit)}`}, got ${String(limit)}.`);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const validateSizes = (defaults, limits) => {
|
|
71
|
+
for (const [name, min] of SIZE_SETTINGS)
|
|
72
|
+
if (!isIntegerIn(defaults[name], min, limits[name]))
|
|
73
|
+
throw new RangeError(`graphPlugin: ${name} must be an integer from ${String(min)} to ${String(limits[name])}, got ${String(defaults[name])}.`);
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Bars of a plugin `width`, validated against `limits.cols` plus the frame.
|
|
77
|
+
*
|
|
78
|
+
* @returns `undefined` without `width`.
|
|
79
|
+
* @throws RangeError if `cols` is set too, or `width` is out of range.
|
|
80
|
+
*/
|
|
81
|
+
const colsOfWidth = ({ cols, width }, limits) => {
|
|
82
|
+
if (width === undefined)
|
|
83
|
+
return undefined;
|
|
84
|
+
if (cols !== undefined)
|
|
85
|
+
throw new RangeError('graphPlugin: set cols (bars) or width (terminal columns, frame included), not both.');
|
|
86
|
+
const max = limits.cols + FRAME_WIDTH;
|
|
87
|
+
if (!isIntegerIn(width, 1 + FRAME_WIDTH, max))
|
|
88
|
+
throw new RangeError(`graphPlugin: width must be an integer from ${String(1 + FRAME_WIDTH)} to ${String(max)}, got ${String(width)}.`);
|
|
89
|
+
return width - FRAME_WIDTH;
|
|
90
|
+
};
|
|
91
|
+
const describeSetting = (value) => typeof value === 'number' ? String(value) : text.safeStringify(value);
|
|
92
|
+
/**
|
|
93
|
+
* Applies the `width` of a single graph: clamps it to the limits (values
|
|
94
|
+
* that are not finite numbers fall back to the plugin's width) and sets the
|
|
95
|
+
* bars. A per-graph `width` wins over a per-graph `cols`.
|
|
96
|
+
*
|
|
97
|
+
* @returns A note if the width was changed, e.g. `width 100000 → 502`.
|
|
98
|
+
*/
|
|
99
|
+
const limitWidth = (settings, width, defaults, limits) => {
|
|
100
|
+
const limited = typeof width === 'number' && Number.isFinite(width) ?
|
|
101
|
+
Math.min(limits.cols + FRAME_WIDTH, Math.max(1 + FRAME_WIDTH, Math.trunc(width)))
|
|
102
|
+
: defaults.cols + FRAME_WIDTH;
|
|
103
|
+
settings.cols = limited - FRAME_WIDTH;
|
|
104
|
+
return limited === width ? undefined : (`width ${describeSetting(width)} → ${String(limited)}`);
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* Clamps the size settings of a single graph to the limits. Values that are
|
|
108
|
+
* not finite numbers fall back to the plugin's settings.
|
|
109
|
+
*
|
|
110
|
+
* @returns Notes on the changed values, e.g. `cols 100000 → 500`.
|
|
111
|
+
*/
|
|
112
|
+
const limitSizes = (settings, defaults, limits) => {
|
|
113
|
+
const notes = [];
|
|
114
|
+
for (const [name, min] of SIZE_SETTINGS) {
|
|
115
|
+
// logged data is not type checked
|
|
116
|
+
const value = settings[name];
|
|
117
|
+
if (value === defaults[name])
|
|
118
|
+
continue;
|
|
119
|
+
if (value === undefined) {
|
|
120
|
+
settings[name] = defaults[name];
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const limited = typeof value === 'number' && Number.isFinite(value) ?
|
|
124
|
+
Math.min(limits[name], Math.max(min, Math.trunc(value)))
|
|
125
|
+
: defaults[name];
|
|
126
|
+
if (limited === value)
|
|
127
|
+
continue;
|
|
128
|
+
settings[name] = limited;
|
|
129
|
+
notes.push(`${name} ${describeSetting(value)} → ${String(limited)}`);
|
|
130
|
+
}
|
|
131
|
+
return notes;
|
|
132
|
+
};
|
|
133
|
+
const isFiniteNumber = (value) => Number.isFinite(value);
|
|
134
|
+
const isBaseline = (value) => value === 'min' || value === 'max' || Number.isFinite(value);
|
|
135
|
+
/**
|
|
136
|
+
* The first range setting out of order, and the bound it passed: `min`
|
|
137
|
+
* above a numeric baseline or `max` below it; with `'min'`, `'max'` or the
|
|
138
|
+
* default baseline, `min` above `max`.
|
|
139
|
+
*/
|
|
140
|
+
const outOfOrder = ({ baseline, min, max, }) => {
|
|
141
|
+
if (typeof baseline === 'number') {
|
|
142
|
+
if (min !== undefined && min > baseline)
|
|
143
|
+
return ['min', baseline, 'baseline'];
|
|
144
|
+
if (max !== undefined && max < baseline)
|
|
145
|
+
return ['max', baseline, 'baseline'];
|
|
146
|
+
}
|
|
147
|
+
else if (min !== undefined && max !== undefined && min > max)
|
|
148
|
+
return ['min', max, 'max'];
|
|
149
|
+
return undefined;
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* Validates the plugin's range settings, which are configuration: `min` and
|
|
153
|
+
* `max` finite numbers, `baseline` a finite number, `'min'` or `'max'`, and
|
|
154
|
+
* `min ≤ baseline ≤ max` (`min ≤ max` without a numeric baseline).
|
|
155
|
+
*
|
|
156
|
+
* @throws RangeError for an invalid setting.
|
|
157
|
+
*/
|
|
158
|
+
const validateRange = (defaults) => {
|
|
159
|
+
const { baseline, min, max } = defaults;
|
|
160
|
+
if (baseline !== undefined && !isBaseline(baseline))
|
|
161
|
+
throw new RangeError(`graphPlugin: baseline must be a finite number, 'min' or 'max', got ${describeSetting(baseline)}.`);
|
|
162
|
+
for (const [name, value] of [
|
|
163
|
+
['min', min],
|
|
164
|
+
['max', max],
|
|
165
|
+
])
|
|
166
|
+
if (value !== undefined && !isFiniteNumber(value))
|
|
167
|
+
throw new RangeError(`graphPlugin: ${name} must be a finite number, got ${describeSetting(value)}.`);
|
|
168
|
+
const wrong = outOfOrder(defaults);
|
|
169
|
+
if (wrong) {
|
|
170
|
+
const [name, bound, boundName] = wrong;
|
|
171
|
+
throw new RangeError(`graphPlugin: ${name} must not be ${name === 'min' ? 'higher' : 'lower'} than ${boundName} (${String(bound)}), got ${String(defaults[name])}.`);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
/**
|
|
175
|
+
* A range setting of a single graph, or the plugin's if it is invalid.
|
|
176
|
+
*
|
|
177
|
+
* @param notes Receives a note if an invalid value was replaced, e.g.
|
|
178
|
+
* `baseline NaN → 0` or `max "x" ignored` (the plugin has none).
|
|
179
|
+
*/
|
|
180
|
+
const rangeSetting = (name, value, isValid, fallback, notes) => {
|
|
181
|
+
if (isValid(value))
|
|
182
|
+
return value;
|
|
183
|
+
if (value !== undefined)
|
|
184
|
+
notes.push(`${name} ${describeSetting(value)} ${fallback === undefined ? 'ignored' : `→ ${String(fallback)}`}`);
|
|
185
|
+
return fallback;
|
|
186
|
+
};
|
|
187
|
+
/**
|
|
188
|
+
* Checks the range settings of a single graph: invalid values fall back to
|
|
189
|
+
* the plugin's, then settings out of order are moved to the bound they
|
|
190
|
+
* passed (`min 30 → 20` for a baseline of 20), also when the plugin set
|
|
191
|
+
* them.
|
|
192
|
+
*
|
|
193
|
+
* @param notes Receives notes on the changed values.
|
|
194
|
+
*/
|
|
195
|
+
const limitRange = (settings, defaults, notes) => {
|
|
196
|
+
// logged data is not type checked
|
|
197
|
+
const { baseline, min, max } = settings;
|
|
198
|
+
settings.baseline = rangeSetting('baseline', baseline, isBaseline, defaults.baseline, notes);
|
|
199
|
+
settings.min = rangeSetting('min', min, isFiniteNumber, defaults.min, notes);
|
|
200
|
+
settings.max = rangeSetting('max', max, isFiniteNumber, defaults.max, notes);
|
|
201
|
+
// at most twice: `min` and `max` on the wrong side of the baseline
|
|
202
|
+
for (let wrong = outOfOrder(settings); wrong; wrong = outOfOrder(settings)) {
|
|
203
|
+
const [name, bound] = wrong;
|
|
204
|
+
notes.push(`${name} ${String(settings[name])} → ${String(bound)}`);
|
|
205
|
+
settings[name] = bound;
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
/**
|
|
209
|
+
* Text of a per-log label setting, which comes from logged data: strings are
|
|
210
|
+
* cut to `stringLength` like message text, `null` and `undefined` are empty,
|
|
211
|
+
* other values are described (`5`, `{"a":1}`) instead of breaking the row
|
|
212
|
+
* layout.
|
|
213
|
+
*/
|
|
214
|
+
const textSetting = (value, limits) => typeof value === 'string' ? text.truncate(value, limits.stringLength)
|
|
215
|
+
: value === null || value === undefined ? ''
|
|
216
|
+
: text.safeStringify(value, limits);
|
|
217
|
+
const axisLabel = (value, limits) => value === undefined || typeof value === 'number' ?
|
|
218
|
+
value
|
|
219
|
+
: textSetting(value, limits);
|
|
220
|
+
/**
|
|
221
|
+
* Cuts the title, unit and axis labels of a single graph to the output
|
|
222
|
+
* limits. A length check for ordinary labels.
|
|
223
|
+
*/
|
|
224
|
+
const limitLabels = (settings, limits) => {
|
|
225
|
+
settings.title = textSetting(settings.title, limits);
|
|
226
|
+
settings.unit = textSetting(settings.unit, limits);
|
|
227
|
+
const { axis } = settings;
|
|
228
|
+
if (typeof axis !== 'object' || axis === null)
|
|
229
|
+
return;
|
|
230
|
+
const start = axisLabel(axis.start, limits);
|
|
231
|
+
const end = axisLabel(axis.end, limits);
|
|
232
|
+
settings.axis = {
|
|
233
|
+
...axis,
|
|
234
|
+
...(start !== undefined && { start }),
|
|
235
|
+
...(end !== undefined && { end }),
|
|
236
|
+
};
|
|
237
|
+
};
|
|
238
|
+
/**
|
|
239
|
+
* Key of graph data whose settings could not be read; the value is what was
|
|
240
|
+
* thrown. A symbol: data can't set it by accident.
|
|
241
|
+
*/
|
|
242
|
+
const SETTINGS_ERROR = Symbol('settingsError');
|
|
243
|
+
const paintNote = (note, styles, { label }) => shared.paint(styles['textDim'] ?? '', label(note), styles['reset'] ?? '');
|
|
244
|
+
/**
|
|
245
|
+
* Eight vertical steps of a single row, from empty to full.
|
|
246
|
+
*/
|
|
247
|
+
const BAR_SYMBOLS = ' ▁▂▃▄▅▆▇█';
|
|
248
|
+
const STEPS = BAR_SYMBOLS.length - 1;
|
|
249
|
+
/**
|
|
250
|
+
* Eighths of a row filled by `fill` rows: `0` for empty, negative or `NaN`.
|
|
251
|
+
*/
|
|
252
|
+
const eighthsOf = (fill) => fill >= 1 ? STEPS
|
|
253
|
+
: fill > 0 ? Math.round(fill * STEPS)
|
|
254
|
+
: 0;
|
|
255
|
+
/**
|
|
256
|
+
* A row filled from the top by `eighths`, for bars growing down: the
|
|
257
|
+
* nearest of the upper blocks every terminal draws, `▔` (an eighth) and `▀`
|
|
258
|
+
* (a half). The other upper eighths (🮂 🮃 🮄 🮅 🮆, Symbols for Legacy
|
|
259
|
+
* Computing) are missing from many fonts and renderers, and a lower block
|
|
260
|
+
* in inverse video paints the cell background; so tips below the baseline
|
|
261
|
+
* are coarser than above it.
|
|
262
|
+
*/
|
|
263
|
+
const upperBlock = (eighths) => eighths === 0 ? ' '
|
|
264
|
+
: eighths <= 2 ? '▔'
|
|
265
|
+
: eighths <= 6 ? '▀'
|
|
266
|
+
: '█';
|
|
267
|
+
const isNumberArray = (value) => Array.isArray(value) && value.every((item) => typeof item === 'number');
|
|
268
|
+
const isGraphDataWithSettings = (value) => typeof value === 'object' &&
|
|
269
|
+
value !== null &&
|
|
270
|
+
'values' in value &&
|
|
271
|
+
isNumberArray(value.values);
|
|
272
|
+
/**
|
|
273
|
+
* Converts values to rows of bar symbols growing up from `min`, top row
|
|
274
|
+
* first.
|
|
275
|
+
*
|
|
276
|
+
* @param max Top of the graph: higher values draw full bars.
|
|
277
|
+
* @param min Bottom of the graph. Default: `0`.
|
|
278
|
+
*/
|
|
279
|
+
const valuesToBars = (values, rows, max, min = 0) => {
|
|
280
|
+
const lines = [];
|
|
281
|
+
const rowValue = (max - min) / rows;
|
|
282
|
+
for (let row = rows - 1; row >= 0; row--) {
|
|
283
|
+
let line = '';
|
|
284
|
+
for (const value of values)
|
|
285
|
+
line += BAR_SYMBOLS.charAt(eighthsOf(rowValue > 0 ? (value - min) / rowValue - row : 0));
|
|
286
|
+
lines.push(line);
|
|
287
|
+
}
|
|
288
|
+
return lines;
|
|
289
|
+
};
|
|
290
|
+
/**
|
|
291
|
+
* Value per row of a graph with `below` of its `rows` under the baseline:
|
|
292
|
+
* the side that needs more per row sets the scale of both.
|
|
293
|
+
*/
|
|
294
|
+
const rowValueOf = (rows, top, bottom, baseline, below) => Math.max(below < rows ? (top - baseline) / (rows - below) : 0, below > 0 ? (baseline - bottom) / below : 0);
|
|
295
|
+
/**
|
|
296
|
+
* Converts values to rows of bars growing from `baseline`: up to values
|
|
297
|
+
* above it and down to values below it, top row first.
|
|
298
|
+
*
|
|
299
|
+
* The baseline is placed in proportion to `bottom`…`top`, at the row
|
|
300
|
+
* boundary next to that position that gives the bars more room. Both sides
|
|
301
|
+
* share the scale of the side that needs more per row, so nothing is cut
|
|
302
|
+
* and equal distances draw equal bars. A side gets at least one row (if
|
|
303
|
+
* `rows` allows) when a bar to its end draws at least an eighth of that
|
|
304
|
+
* row, and none otherwise: a value of -0.001 next to 100 sits on the
|
|
305
|
+
* baseline instead of taking a row. Bars growing down end in upper blocks
|
|
306
|
+
* (`upperBlock`).
|
|
307
|
+
*/
|
|
308
|
+
const barsFromBaseline = (values, rows, top, bottom, baseline) => {
|
|
309
|
+
const span = top - bottom;
|
|
310
|
+
// nothing to draw: no range, or one that can't be scaled
|
|
311
|
+
if (!(span > 0 && Number.isFinite(span)))
|
|
312
|
+
return Array.from({ length: rows }, () => ' '.repeat(values.length));
|
|
313
|
+
const proportional = ((baseline - bottom) / span) * rows;
|
|
314
|
+
const least = (bottom < baseline &&
|
|
315
|
+
eighthsOf((baseline - bottom) / rowValueOf(rows, top, bottom, baseline, 1)) > 0) ?
|
|
316
|
+
1
|
|
317
|
+
: 0;
|
|
318
|
+
const most = (top > baseline &&
|
|
319
|
+
eighthsOf((top - baseline) / rowValueOf(rows, top, bottom, baseline, rows - 1)) > 0) ?
|
|
320
|
+
rows - 1
|
|
321
|
+
: rows;
|
|
322
|
+
let rowsBelow;
|
|
323
|
+
if (least > most)
|
|
324
|
+
// one row for two sides: the larger side gets it
|
|
325
|
+
rowsBelow = Math.min(rows, Math.max(0, Math.round(proportional)));
|
|
326
|
+
else {
|
|
327
|
+
// the row boundary below or above the exact position, whichever gives
|
|
328
|
+
// the bars more room (a smaller value per row)
|
|
329
|
+
const lower = Math.min(most, Math.max(least, Math.floor(proportional)));
|
|
330
|
+
const upper = Math.min(most, Math.max(least, Math.ceil(proportional)));
|
|
331
|
+
rowsBelow =
|
|
332
|
+
(rowValueOf(rows, top, bottom, baseline, upper) <
|
|
333
|
+
rowValueOf(rows, top, bottom, baseline, lower)) ?
|
|
334
|
+
upper
|
|
335
|
+
: lower;
|
|
336
|
+
}
|
|
337
|
+
const rowsAbove = rows - rowsBelow;
|
|
338
|
+
const rowValue = rowValueOf(rows, top, bottom, baseline, rowsBelow);
|
|
339
|
+
const lines = [];
|
|
340
|
+
for (let row = rowsAbove - 1; row >= 0; row--) {
|
|
341
|
+
let line = '';
|
|
342
|
+
for (const value of values)
|
|
343
|
+
line += BAR_SYMBOLS.charAt(eighthsOf((value - baseline) / rowValue - row));
|
|
344
|
+
lines.push(line);
|
|
345
|
+
}
|
|
346
|
+
for (let row = 0; row < rowsBelow; row++) {
|
|
347
|
+
let line = '';
|
|
348
|
+
for (const value of values)
|
|
349
|
+
line += upperBlock(eighthsOf((baseline - value) / rowValue - row));
|
|
350
|
+
lines.push(line);
|
|
351
|
+
}
|
|
352
|
+
return lines;
|
|
353
|
+
};
|
|
354
|
+
/**
|
|
355
|
+
* `toFixed` without a sign on zero: `-0.004` with precision 2 is `0.00`,
|
|
356
|
+
* not `-0.00`.
|
|
357
|
+
*/
|
|
358
|
+
const fixed = (number, precision) => {
|
|
359
|
+
const text = number.toFixed(precision);
|
|
360
|
+
return text.startsWith('-') && Number(text) === 0 ? text.slice(1) : text;
|
|
361
|
+
};
|
|
362
|
+
const segmentsLength = (segments) => {
|
|
363
|
+
let length = 0;
|
|
364
|
+
for (const [text$1] of segments)
|
|
365
|
+
length += text.textWidth(text$1);
|
|
366
|
+
return length;
|
|
367
|
+
};
|
|
368
|
+
const renderSegments = (segments, reset) => {
|
|
369
|
+
let text = '';
|
|
370
|
+
for (const [segment, style] of segments)
|
|
371
|
+
text += shared.paint(style, segment, reset);
|
|
372
|
+
return text;
|
|
373
|
+
};
|
|
374
|
+
/**
|
|
375
|
+
* Renders a framed graph with an optional axis row and value statistics:
|
|
376
|
+
* title, bars, frame and values in `textHighlight`, legend labels in
|
|
377
|
+
* `textNormal`, axis labels and units in `textDim`. When the baseline splits
|
|
378
|
+
* the graph, a `baseline` row follows the average.
|
|
379
|
+
*
|
|
380
|
+
* Average, min, max and the default top and bottom are computed from the
|
|
381
|
+
* original values, so peaks blunted by scaling are still reported. Bars are
|
|
382
|
+
* scaled to `cols` with `settings.aggregate`. The top value is shown on the
|
|
383
|
+
* title row, the bottom value on the average row.
|
|
384
|
+
*
|
|
385
|
+
* Title, unit and axis labels are sanitized as single line `label`s and
|
|
386
|
+
* measured in terminal columns with `textWidth` (wide characters take two,
|
|
387
|
+
* as in columns). Default: `'safe'` (see `Sanitize`).
|
|
388
|
+
*/
|
|
389
|
+
const renderGraph = (values, settings, { textNormal = '', textDim = '', textHighlight = '', reset = '', } = {}, { label } = SAFE_SANITIZER) => {
|
|
390
|
+
const { cols, rows, precision, min: fixedMin, max: fixedMax } = settings;
|
|
391
|
+
const title = label(settings.title);
|
|
392
|
+
const unit = label(settings.unit);
|
|
393
|
+
// statistics, top and bottom describe the data, not the scaled bars
|
|
394
|
+
let min = Infinity;
|
|
395
|
+
let max = -Infinity;
|
|
396
|
+
let sum = 0;
|
|
397
|
+
for (const value of values) {
|
|
398
|
+
if (value < min)
|
|
399
|
+
min = value;
|
|
400
|
+
if (value > max)
|
|
401
|
+
max = value;
|
|
402
|
+
sum += value;
|
|
403
|
+
}
|
|
404
|
+
if (!values.length)
|
|
405
|
+
min = max = 0;
|
|
406
|
+
const average = values.length ? sum / values.length : 0;
|
|
407
|
+
// the default baseline, 0, moves inside a fixed range: `min: 480` alone
|
|
408
|
+
// starts the bars at 480
|
|
409
|
+
const setting = settings.baseline ??
|
|
410
|
+
Math.min(Math.max(0, fixedMin ?? -Infinity), fixedMax ?? Infinity);
|
|
411
|
+
let top;
|
|
412
|
+
let bottom;
|
|
413
|
+
let baseline;
|
|
414
|
+
if (typeof setting === 'number') {
|
|
415
|
+
// the range holds a numeric baseline, also when `min` or `max` leave it
|
|
416
|
+
// out (the plugin rejects or moves those); without values it is only
|
|
417
|
+
// the baseline, so the zeros of the scaled bars draw nothing
|
|
418
|
+
baseline = setting;
|
|
419
|
+
top = Math.max(fixedMax ?? (values.length ? max : setting), setting);
|
|
420
|
+
bottom = Math.min(fixedMin ?? (values.length ? min : setting), setting);
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
bottom = fixedMin ?? (values.length ? min : (fixedMax ?? 0));
|
|
424
|
+
top = Math.max(fixedMax ?? (values.length ? max : bottom), bottom);
|
|
425
|
+
baseline = setting === 'min' ? bottom : top;
|
|
426
|
+
}
|
|
427
|
+
const scaled = text.scaleArray(values, cols, settings.aggregate);
|
|
428
|
+
const width = cols + FRAME_WIDTH;
|
|
429
|
+
const value = (number) => [
|
|
430
|
+
[fixed(number, precision), textHighlight],
|
|
431
|
+
[unit ? ` ${unit}` : '', textDim],
|
|
432
|
+
];
|
|
433
|
+
const legend = (label, number) => [
|
|
434
|
+
[`${label}: `, textNormal],
|
|
435
|
+
...value(number),
|
|
436
|
+
];
|
|
437
|
+
// left and right justified, in terminal columns (wide characters 2); too long rows
|
|
438
|
+
// break before the right side, unless there is no right side
|
|
439
|
+
const row = (left, right = []) => {
|
|
440
|
+
const rightLength = segmentsLength(right);
|
|
441
|
+
const gap = width - segmentsLength(left) - rightLength;
|
|
442
|
+
const separator = gap >= 0 ? ' '.repeat(gap)
|
|
443
|
+
: rightLength ? '\n'
|
|
444
|
+
: '';
|
|
445
|
+
return `${renderSegments(left, reset)}${separator}${renderSegments(right, reset)}`;
|
|
446
|
+
};
|
|
447
|
+
const lines = [row([[title, textHighlight]], value(top))];
|
|
448
|
+
// bars growing up from the bottom: the plain path, as before baselines
|
|
449
|
+
const bars = baseline > bottom ?
|
|
450
|
+
barsFromBaseline(scaled, rows, top, bottom, baseline)
|
|
451
|
+
: valuesToBars(scaled, rows, top, bottom);
|
|
452
|
+
for (const line of bars)
|
|
453
|
+
lines.push(shared.paint(textHighlight, `│${line}│`, reset));
|
|
454
|
+
const { axis } = settings;
|
|
455
|
+
if (axis && (axis.start !== undefined || axis.end !== undefined))
|
|
456
|
+
lines.push(row([[label(formatAxisLabel(axis.start, axis, axis.end)), textDim]], [[label(formatAxisLabel(axis.end, axis, axis.start)), textDim]]));
|
|
457
|
+
lines.push(row(settings.showAverage ? legend('average', average) : [], value(bottom)));
|
|
458
|
+
// a baseline at the top or the bottom is already the value shown there
|
|
459
|
+
if (bottom < baseline && baseline < top)
|
|
460
|
+
lines.push(row(legend('baseline', baseline)));
|
|
461
|
+
if (settings.showMin)
|
|
462
|
+
lines.push(row(legend('min', min)));
|
|
463
|
+
if (settings.showMax)
|
|
464
|
+
lines.push(row(legend('max', max)));
|
|
465
|
+
return lines.join('\n');
|
|
466
|
+
};
|
|
467
|
+
/**
|
|
468
|
+
* Renders logs of a given kind as bar graphs. Adds a graph method per log
|
|
469
|
+
* type to the logger: `infoGraph`, `errorGraph`, etc.
|
|
470
|
+
*
|
|
471
|
+
* ```
|
|
472
|
+
* 2026/03/16 15:35:00 [i] info
|
|
473
|
+
* CPU 82.00 %
|
|
474
|
+
* │▁▁▁▂▃▃▃▄▄▄▆▆▆▇██▇▆▆▆▄▄▄▃▃▃▂▁▁▁│
|
|
475
|
+
* average: 41.33 % 0.00 %
|
|
476
|
+
* ```
|
|
477
|
+
*/
|
|
478
|
+
const graphPlugin = (options = {}) => {
|
|
479
|
+
const kind = options.kind ?? 'graph';
|
|
480
|
+
const sanitizer = text.createSanitizer(options.sanitize);
|
|
481
|
+
const limits = {
|
|
482
|
+
cols: options.limits?.cols ?? DEFAULT_GRAPH_LIMITS.cols,
|
|
483
|
+
rows: options.limits?.rows ?? DEFAULT_GRAPH_LIMITS.rows,
|
|
484
|
+
precision: options.limits?.precision ?? DEFAULT_GRAPH_LIMITS.precision,
|
|
485
|
+
};
|
|
486
|
+
validateLimits(limits);
|
|
487
|
+
const defaults = {
|
|
488
|
+
cols: colsOfWidth(options, limits) ?? options.cols ?? 30,
|
|
489
|
+
rows: options.rows ?? 3,
|
|
490
|
+
title: options.title ?? '',
|
|
491
|
+
unit: options.unit ?? '',
|
|
492
|
+
precision: options.precision ?? 2,
|
|
493
|
+
showAverage: options.showAverage ?? true,
|
|
494
|
+
showMin: options.showMin ?? true,
|
|
495
|
+
showMax: options.showMax ?? true,
|
|
496
|
+
aggregate: options.aggregate ?? 'average',
|
|
497
|
+
// no baseline: 0, moved inside `min` and `max` (see `renderGraph`)
|
|
498
|
+
...(options.baseline !== undefined && { baseline: options.baseline }),
|
|
499
|
+
...(options.max !== undefined && { max: options.max }),
|
|
500
|
+
...(options.min !== undefined && { min: options.min }),
|
|
501
|
+
...(options.axis !== undefined && { axis: options.axis }),
|
|
502
|
+
};
|
|
503
|
+
const outputLimits = text.resolveOutputLimits(options.limits, 'graphPlugin');
|
|
504
|
+
validateSizes(defaults, limits);
|
|
505
|
+
validateRange(defaults);
|
|
506
|
+
return {
|
|
507
|
+
name: `graph:${kind}`,
|
|
508
|
+
styles: shared.mergeStyleSheets(shared.DEFAULT_TEXT_STYLES, options.styles),
|
|
509
|
+
templateMaps: {
|
|
510
|
+
[kind]: {
|
|
511
|
+
...shared.headerTemplates(options),
|
|
512
|
+
// never throws: unreadable data or invalid settings give a placeholder
|
|
513
|
+
data: (value, { styles }) => {
|
|
514
|
+
try {
|
|
515
|
+
if (isNumberArray(value))
|
|
516
|
+
return `\n${renderGraph(value, defaults, styles, sanitizer)}`;
|
|
517
|
+
if (isGraphDataWithSettings(value)) {
|
|
518
|
+
const { values, width, ...overrides } = value;
|
|
519
|
+
const settings = { ...defaults, ...overrides };
|
|
520
|
+
const widthNote = width === undefined ? undefined : (limitWidth(settings, width, defaults, limits));
|
|
521
|
+
const notes = limitSizes(settings, defaults, limits);
|
|
522
|
+
if (widthNote !== undefined)
|
|
523
|
+
notes.unshift(widthNote);
|
|
524
|
+
limitRange(settings, defaults, notes);
|
|
525
|
+
limitLabels(settings, outputLimits);
|
|
526
|
+
const graph = renderGraph(values, settings, styles, sanitizer);
|
|
527
|
+
const unreadable = value[SETTINGS_ERROR];
|
|
528
|
+
if (!notes.length && unreadable === undefined)
|
|
529
|
+
return `\n${graph}`;
|
|
530
|
+
let text$1 = `\n${graph}`;
|
|
531
|
+
if (notes.length)
|
|
532
|
+
text$1 += `\n${paintNote(`(limited: ${notes.join(', ')})`, styles, sanitizer)}`;
|
|
533
|
+
if (unreadable !== undefined)
|
|
534
|
+
text$1 += `\n${paintNote(`(settings unreadable: ${text.describeThrown(unreadable)})`, styles, sanitizer)}`;
|
|
535
|
+
return text$1;
|
|
536
|
+
}
|
|
537
|
+
return shared.paint(styles['textNormal'] ?? '', text.stringifyData(value, sanitizer, outputLimits), styles['reset'] ?? '');
|
|
538
|
+
}
|
|
539
|
+
catch (thrown) {
|
|
540
|
+
return shared.paintUnrenderable(thrown, styles, sanitizer);
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
},
|
|
545
|
+
outputs: { [kind]: outputs.joinedOutput(options.write) },
|
|
546
|
+
methods: (logger) => {
|
|
547
|
+
const graph = (type) => (values, settings) => {
|
|
548
|
+
let data = values;
|
|
549
|
+
// settings are data too: unreadable ones (throwing getters,
|
|
550
|
+
// revoked proxies) give a note, never an exception
|
|
551
|
+
if (settings)
|
|
552
|
+
try {
|
|
553
|
+
data = { ...settings, values };
|
|
554
|
+
}
|
|
555
|
+
catch (thrown) {
|
|
556
|
+
data = { values, [SETTINGS_ERROR]: thrown };
|
|
557
|
+
}
|
|
558
|
+
logger.log(type, data, kind);
|
|
559
|
+
};
|
|
560
|
+
return {
|
|
561
|
+
fatalGraph: graph('fatal'),
|
|
562
|
+
errorGraph: graph('error'),
|
|
563
|
+
warningGraph: graph('warning'),
|
|
564
|
+
infoGraph: graph('info'),
|
|
565
|
+
debugGraph: graph('debug'),
|
|
566
|
+
traceGraph: graph('trace'),
|
|
567
|
+
verboseGraph: graph('verbose'),
|
|
568
|
+
};
|
|
569
|
+
},
|
|
570
|
+
};
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
exports.DEFAULT_GRAPH_LIMITS = DEFAULT_GRAPH_LIMITS;
|
|
574
|
+
exports.formatAxisLabel = formatAxisLabel;
|
|
575
|
+
exports.graphPlugin = graphPlugin;
|
|
576
|
+
exports.renderGraph = renderGraph;
|
|
577
|
+
exports.valuesToBars = valuesToBars;
|
|
578
|
+
//# sourceMappingURL=graph.cjs.map
|