sparkline
[github]three live block sparklines scrolling left, values at right
data 72×9 12 fps 6.1 kB
min max cpu ▆▆▆▃▄▂▄▅▅▆▆▇▇▇▇▆▄▄▅▅▆▇▆▃▃▂▂▂▂▂▃▄▅▅▆▇▏ 89% 10 92 memory ▃▃▃▃▃▃▃▄▄▄▄▄▅▅▅▅▆▆▆▆▆▇▇▇▂▂▃▃▃▃▃▄▄▄▄▄▏ 2.3 gb 1.6 3.1 requests ▇▇▆▆▆▆▆▅▄▅▅▄▅▄▄▄▄▄▂▃▄▃▂▂▃▂▁▂▁▂▃▃▃▄▂▂▏ 21/s 15 218 9s ago now min max cpu ▆▆▆▃▄▂▄▅▅▆▆▇▇▇▇▆▄▄▅▅▆▇▆▃▃▂▂▂▂▂▃▄▅▅▆▇▏ 89% 10 92 memory ▃▃▃▃▃▃▃▄▄▄▄▄▅▅▅▅▆▆▆▆▆▇▇▇▂▂▃▃▃▃▃▄▄▄▄▄▏ 2.3 gb 1.6 3.1 requests ▇▇▆▆▆▆▆▅▄▅▅▄▅▄▄▄▄▄▂▃▄▃▂▂▃▂▁▂▁▂▃▃▃▄▂▂▏ 21/s 15 218 9s ago now
use it
html
<script type="module" src="https://ascii.rest/ascii.js"></script>
<ascii-art piece="sparkline" options='{"series":[{"label":"cpu","unit":"%","lo":0,"hi":100},{"label":"memory","unit":" gb","lo":1.2,"hi":3.4,"digits":1},{"label":"requests","unit":"/s","lo":0,"hi":260}],"range":true,"rate":4}'></ascii-art> No install and no build step. ascii.js defines the <ascii-art> tag, loads the piece from ascii.rest, plays it while it is on screen, and holds the first frame for anyone who prefers reduced motion.
react
// npm install github:bas3line/ascii
import { Ascii } from "ascii.rest/react";
import { sparkline } from "ascii.rest/pieces";
<Ascii piece={sparkline} options={{"series":[{"label":"cpu","unit":"%","lo":0,"hi":100},{"label":"memory","unit":" gb","lo":1.2,"hi":3.4,"digits":1},{"label":"requests","unit":"/s","lo":0,"hi":260}],"range":true,"rate":4}} /> A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="sparkline", to fetch the piece only when it mounts.
astro
---
// npm install github:bas3line/ascii
import Ascii from "ascii.rest/astro";
---
<Ascii piece="sparkline" options={{"series":[{"label":"cpu","unit":"%","lo":0,"hi":100},{"label":"memory","unit":" gb","lo":1.2,"hi":3.4,"digits":1},{"label":"requests","unit":"/s","lo":0,"hi":260}],"range":true,"rate":4}} /> Renders the first frame on the server, so the page is whole before any script runs, then plays the piece once it loads.
typescript
// npm install github:bas3line/ascii
import { mount } from "ascii.rest";
import { sparkline } from "ascii.rest/pieces";
const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, sparkline, {"series":[{"label":"cpu","unit":"%","lo":0,"hi":100},{"label":"memory","unit":" gb","lo":1.2,"hi":3.4,"digits":1},{"label":"requests","unit":"/s","lo":0,"hi":260}],"range":true,"rate":4}); mount plays a piece in a <pre>, or on a <canvas> for the coloured ones, and returns a function that stops it. Every piece, option and frame is typed.
/*
* sparkline: live metrics as one-row block sparklines, scrolling left as new
* samples arrive, each with its label, its latest value and the span's range.
*/
import type { Frame, Meta } from "../types.ts";
export interface SparklineSeries {
label?: string;
unit?: string;
lo?: number;
hi?: number;
digits?: number;
format?: (v: number) => unknown;
values?: ArrayLike<number>;
}
export interface SparklineOptions {
[key: string]: unknown;
series: SparklineSeries[];
range: boolean;
rate: number;
}
export const meta = {
name: "sparkline",
category: "data",
note: "three live block sparklines scrolling left, values at right",
cols: 72,
rows: 9,
fps: 12,
// A series with `values` shows the newest of them, read every frame, so
// pushing to the array moves the line; without, it plays a sample signal.
// `lo` and `hi` fix the scale (else it fits what is shown), `unit` and
// `digits` format the value, or pass `format` as a function. Up to five
// series fit, spaced a row apart when there are three or fewer. Without
// `range` the min and max columns go and the lines grow longer. `rate` is
// samples a second, which labels the time axis.
options: {
series: [
{ label: "cpu", unit: "%", lo: 0, hi: 100 },
{ label: "memory", unit: " gb", lo: 1.2, hi: 3.4, digits: 1 },
{ label: "requests", unit: "/s", lo: 0, hi: 260 },
],
range: true,
rate: 4,
},
} satisfies Meta<SparklineOptions>;
const BARS = "▁▂▃▄▅▆▇█";
const LW = 10, VW = 7, RW = 5; // label, value and min/max widths
const SLOTS = 5; // rows the series share
function mulberry32(a: number) {
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
interface SignalState {
v: number;
busy: number;
cap: number;
n: number;
}
// Sample signals, each 0..1 from its own seeded state, scaled into lo..hi.
const SIGNALS: ((s: SignalState, rand: () => number) => number)[] = [
// Mostly idle, with bursts of work that come and go.
(s, rand) => {
if (s.busy > 0) s.busy--;
else if (rand() < 0.06) s.busy = 4 + Math.floor(rand() * 9);
s.v += ((s.busy ? 0.78 : 0.22) - s.v) * 0.55 + (rand() - 0.5) * 0.3;
return (s.v = Math.max(0.02, Math.min(0.99, s.v)));
},
// A heap that fills unevenly and falls back when it is collected.
(s, rand) => {
s.v += 0.01 + rand() * 0.035;
if (s.v > s.cap) {
s.v = 0.1 + rand() * 0.14;
s.cap = 0.73 + rand() * 0.22;
}
return s.v;
},
// A slow swell in traffic with jitter on every sample.
(s, rand) => {
const base = 0.48 + 0.27 * Math.sin(s.n++ / 14);
s.v += (base - s.v) * 0.7 + (rand() + rand() - 1) * 0.23;
return (s.v = Math.max(0.015, Math.min(1, s.v)));
},
];
export default function sparkline({
series = meta.options.series,
range = meta.options.range,
rate = meta.options.rate,
}: Partial<SparklineOptions> = {}): Frame {
const { cols, rows } = meta;
rate = Math.max(0.05, +rate || meta.options.rate);
const x0 = 2 + LW;
const W = cols - x0 - 2 - VW - (range ? 2 + RW + 1 + RW : 0) - 2; // samples on screen
series = (series || []).slice(0, SLOTS);
// The header, the series and the time axis sit centred as one block.
const gap = series.length <= 3 ? 2 : 1;
const span = (Math.max(1, series.length) - 1) * gap + 1;
const top = Math.floor((rows - span - 2) / 2) + 1; // the first series row
const blank = " ".repeat(cols);
const fit = (s: string) => s.slice(0, cols).padEnd(cols);
const header = range ? fit(" ".repeat(x0 + W + 2 + VW + 2) + "min".padStart(RW) + " " + "max".padStart(RW)) : blank;
const axis = fit(" ".repeat(x0) + (Math.round(W / rate) + "s ago").padEnd(W - 2) + "now");
const fmt = (s: SparklineSeries, v: number, unit: boolean) =>
typeof s.format === "function" ? String(s.format(v)) : v.toFixed(s.digits ?? 0) + (unit ? s.unit ?? "" : "");
// A ring of the last W samples per signal; play time decides how many exist.
let made: number, rings: Float32Array[], states: SignalState[], rands: (() => number)[];
const reset = () => {
made = 0;
rings = series.map(() => new Float32Array(W));
rands = series.map((s, i) => mulberry32(911 + i * 7919));
states = series.map(() => ({ v: 0.4, busy: 0, cap: 0.82, n: 0 }));
};
const fill = (need: number) => {
if (need < made) reset();
for (; made < need; made++)
series.forEach((s, i) => {
if (!s.values) rings[i][made % W] = SIGNALS[i % SIGNALS.length](states[i], rands[i]);
});
};
reset();
const win = new Array<number>(W);
return (t) => {
fill(W * 2 + Math.floor(t * rate));
const lines = new Array<string>(rows).fill(blank);
lines[top - 1] = header;
lines[top + span] = axis;
series.forEach((s, i) => {
// What is on screen, oldest first, in the series' own units.
let n = 0;
if (s.values) {
const v = s.values;
for (let j = Math.max(0, v.length - W); j < v.length; j++) win[n++] = +v[j];
} else {
const lo = s.lo ?? 0, hi = s.hi ?? 100;
for (let j = 0; j < W; j++) win[n++] = lo + rings[i][(made + j) % W] * (hi - lo);
}
let min = Infinity, max = -Infinity;
for (let j = 0; j < n; j++) if (Number.isFinite(win[j])) (min = Math.min(min, win[j])), (max = Math.max(max, win[j]));
const lo = s.lo ?? min, hi = s.hi ?? (max > lo ? max : lo + 1);
const d = hi - lo || 1;
let bars = " ".repeat(W - n);
for (let j = 0; j < n; j++) {
if (!Number.isFinite(win[j])) bars += " ";
else bars += BARS[Math.max(0, Math.min(7, Math.round(((win[j] - lo) / d) * 7)))];
}
const last = win[n - 1];
const label = String(s.label ?? "").slice(0, LW - 1).padEnd(LW);
const value = (n && Number.isFinite(last) ? fmt(s, last, true) : "").padStart(VW);
let line = " " + label + bars + "▏ " + value;
if (range && min <= max) line += " " + fmt(s, min, false).padStart(RW) + " " + fmt(s, max, false).padStart(RW);
lines[top + i * gap] = fit(line);
});
return lines.join("\n");
};
}