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