Sparkline-sink
sparkline-sink turns a numeric series into one inline unicode sparkline with zero dependencies — a single line of eight block levels (▁▂▃▄▅▆▇█), one glyph per value. It renders no axis, labels, numbers, or color — only the shape — so it drops straight into a log line, a commit message, a terminal, or a table cell where a chart library would be absurd. The load-bearing property: because each value maps to an INTEGER bucket, no float ever reaches the output, so the same series in produces byte-identical glyphs out — in Node or a browser, committable and diffable.
The honest edge
sparkline-sink is a GLYPH sparkline, not a chart: one line of 8 discrete block levels, one glyph per value, no axis, labels, numbers, color, or scale markers. Two values in the same eighth of the range draw the SAME glyph — it shows TREND, not magnitude, and it is not a substitute for the number. It takes NUMBERS ONLY (no caller text reaches the output, so there is no escaping surface). A non-finite value is a hard error, never a guessed point. Same series in → byte-identical line out.
Run it
echo '[3,1,4,1,5,9,2,6]' | node sparkline-sink.js # ▃▁▄▁▅█▂▆ (--min N / --max N to pin the domain)
test_sparkline-sink.js (12/12, out-of-band hand-computed glyph oracle) + Plumb conformance GREEN (I1-I5, signed, clock-independent)
Node / browser, no dependencies
The code — every file that ships
sparkline-sink.js156 lineson GitHub →
#!/usr/bin/env node
/*
* sparkline-sink — turn a numeric series into one inline unicode sparkline, no deps.
* MIT · zero-dependency · standalone gift · lane: sink (consumes data, emits an artifact).
*
* THE PRINTED EDGE (read before trusting the output):
* This is a GLYPH sparkline, not a chart. It emits ONE line of unicode block
* characters (▁▂▃▄▅▆▇█), one glyph per value, mapping each value to one of
* EXACTLY 8 discrete levels across the data's [min,max] range. It renders NO
* axis, labels, numbers, color, or scale markers — only the shape. Because there
* are only 8 levels, two values in the same eighth of the range draw the SAME
* glyph: it shows TREND, not magnitude, and it is not a substitute for the number.
* It takes NUMBERS ONLY — no caller text ever reaches the output, so there is no
* escaping surface. A non-finite value (NaN / Infinity) is a hard error, never a
* silently-dropped or guessed point. Output is a PURE FUNCTION of the input: same
* series in → byte-identical line out.
*
* USAGE:
* echo '[3,1,4,1,5,9,2,6]' | node sparkline-sink.js # ▃▁▄▁▅█▂▆ (+ newline)
* echo '[0,1,2,3,4,5,6,7]' | node sparkline-sink.js # ▁▂▃▄▅▆▇█
* echo '{"series":[5],"min":0,"max":10}' | node sparkline-sink.js
* node sparkline-sink.js --help
*
* INPUT (stdin, JSON): either
* - a bare array of numbers -> [1,2,3]
* - a spec object { series, min, max } -> pin the domain instead of auto-fit
* CLI flags (--min --max) OVERRIDE object fields.
*
* OUTPUT (stdout): one line of block glyphs (UTF-8), trailing newline. Empty series -> a bare newline.
*
* DETERMINISM: each value maps to an INTEGER level (a bucket), so no float noise or
* platform drift can reach the output. Same series → same bytes, in Node or a browser.
* PORTABILITY: pure JS on a plain array — identical in Node and the browser.
*/
'use strict';
// The ONLY output alphabet: 8 block levels, low to high. U+2581 .. U+2588.
var LEVELS = ['\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588'];
function finite(v, j) {
if (typeof v !== 'number' || !isFinite(v)) {
throw new Error('series[' + j + '] is not a finite number: ' + JSON.stringify(v));
}
return v;
}
// Shared shape with svg-sink's domain(): auto-fit [min(data),max(data)] unless pinned;
// a flat series opens to a unit window so every value lands mid-scale (never a divide-by-0).
function domain(series, min, max) {
var lo = (min != null) ? Number(min) : Infinity;
var hi = (max != null) ? Number(max) : -Infinity;
if (min == null || max == null) {
for (var j = 0; j < series.length; j++) {
var v = series[j];
if (min == null && v < lo) lo = v;
if (max == null && v > hi) hi = v;
}
}
if (!isFinite(lo)) lo = 0;
if (!isFinite(hi)) hi = 0;
if (lo === hi) { lo -= 1; hi += 1; } // flat series -> unit window, drawn mid-scale
return { lo: lo, hi: hi };
}
function level(v, lo, hi) {
var k = Math.floor(((v - lo) / (hi - lo)) * LEVELS.length);
if (k < 0) k = 0;
if (k >= LEVELS.length) k = LEVELS.length - 1; // v == hi -> 8 clamps to 7
return k;
}
function normalize(input, flags) {
var spec = {};
var seriesRaw;
if (input && !Array.isArray(input) && typeof input === 'object') {
seriesRaw = input.series;
if (input.min != null) spec.min = input.min;
if (input.max != null) spec.max = input.max;
} else {
seriesRaw = input;
}
flags = flags || {};
if (flags.min != null) spec.min = flags.min; // CLI flags override object fields
if (flags.max != null) spec.max = flags.max;
if (!Array.isArray(seriesRaw)) {
throw new Error('input has no numeric series (expected an array or a {series:...} object)');
}
if (seriesRaw.length > 0 && Array.isArray(seriesRaw[0])) {
throw new Error('sparkline-sink takes ONE series (a flat array of numbers), not nested arrays');
}
spec.series = seriesRaw.map(function (v, j) { return finite(v, j); });
if (spec.min != null) { spec.min = numOrThrow(spec.min, 'min'); }
if (spec.max != null) { spec.max = numOrThrow(spec.max, 'max'); }
if (spec.min != null && spec.max != null && spec.min >= spec.max) {
throw new Error('min must be < max (got min=' + spec.min + ', max=' + spec.max + ')');
}
return spec;
}
function numOrThrow(v, name) {
var n = Number(v);
if (typeof n !== 'number' || !isFinite(n)) throw new Error(name + ' must be a finite number, got ' + JSON.stringify(v));
return n;
}
// ---- the gift: series -> one line of block glyphs -------------------------------
function sparkline(input, flags) {
var spec = normalize(input, flags);
var d = domain(spec.series, spec.min, spec.max);
var out = '';
for (var j = 0; j < spec.series.length; j++) {
out += LEVELS[level(spec.series[j], d.lo, d.hi)];
}
return out + '\n';
}
// ---- exports (Node require + browser attach) ------------------------------------
if (typeof module !== 'undefined' && module.exports) {
module.exports = { sparkline: sparkline, normalize: normalize, domain: domain, LEVELS: LEVELS };
}
if (typeof window !== 'undefined') {
window.LoopGifts = window.LoopGifts || {};
window.LoopGifts['sparkline-sink'] = { sparkline: sparkline, LEVELS: LEVELS };
}
// ---- CLI ------------------------------------------------------------------------
function main() {
var argv = process.argv.slice(2);
if (argv.indexOf('--help') !== -1 || argv.indexOf('-h') !== -1) {
process.stdout.write(
'sparkline-sink — numbers -> one inline unicode sparkline (' + LEVELS.join('') + '), zero deps.\n\n' +
'USAGE:\n' +
" echo '[3,1,4,1,5,9,2,6]' | node sparkline-sink.js [--min N] [--max N]\n\n" +
'INPUT (stdin, JSON): a bare array of numbers, or { "series": [...], "min": N, "max": N }.\n' +
'OUTPUT: one line of 8-level block glyphs, trailing newline. Numbers only; non-finite is a hard error.\n' +
'EDGE: 8 discrete levels — shows shape, not magnitude; no axis/labels/color.\n');
return;
}
var flags = {};
for (var i = 0; i < argv.length; i++) {
if (argv[i] === '--min') flags.min = Number(argv[++i]);
else if (argv[i] === '--max') flags.max = Number(argv[++i]);
}
var chunks = [];
process.stdin.on('data', function (c) { chunks.push(c); });
process.stdin.on('end', function () {
var raw = chunks.join('').trim();
if (!raw) { process.stdout.write('\n'); return; } // empty stdin -> empty sparkline
var input;
try { input = JSON.parse(raw); }
catch (e) { process.stderr.write('sparkline-sink: input is not valid JSON: ' + e.message + '\n'); process.exit(1); }
try { process.stdout.write(sparkline(input, flags)); }
catch (e) { process.stderr.write('sparkline-sink: ' + e.message + '\n'); process.exit(1); }
});
}
if (typeof require !== 'undefined' && require.main === module) { main(); }
test_sparkline-sink.js95 lineson GitHub →
#!/usr/bin/env node
/*
* test_sparkline-sink.js — drift-check battery for the sparkline-sink gift.
*
* THE ORACLE IS OUT-OF-BAND BY CONSTRUCTION. The expected glyph lines below are
* hand-computed from the documented level math — level(v) = clamp(floor((v-lo)/(hi-lo)*8), 0, 7),
* lo/hi the auto-fit or pinned domain — written independently of the emitter, not captured
* from its output. A build cannot certify itself, so the expected strings are the author's fact.
* The remaining checks are contract properties (determinism, honest errors, alphabet-closed).
*
* Run: node test_sparkline-sink.js (exit 0 = all pass; nonzero = a named failure)
*/
'use strict';
const assert = require('assert');
const { sparkline, LEVELS } = require('./sparkline-sink.js');
const [L0, L1, L2, L3, L4, L5, L6, L7] = LEVELS; // ▁▂▃▄▅▆▇█
let n = 0, passed = 0;
function check(name, fn) {
n++;
try { fn(); passed++; console.log(' ok ' + name); }
catch (e) { console.log(' FAIL ' + name + ' — ' + e.message); process.exitCode = 1; }
}
// 1 — the clean 8-step ramp: [0..7] auto-domain [0,7] -> one glyph per level, in order.
check('ramp: [0..7] -> ▁▂▃▄▅▆▇█ (every level, in order)', () => {
assert.strictEqual(sparkline([0, 1, 2, 3, 4, 5, 6, 7]), '\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588\n');
});
// 2 — flat series: all equal -> unit window -> all mid-scale (level 4 = ▅), never a divide-by-0.
check('flat: [1,1,1] -> ▅▅▅ (mid-scale, no NaN)', () => {
assert.strictEqual(sparkline([1, 1, 1]), L4 + L4 + L4 + '\n');
});
// 3 — single value: flat window -> one mid-scale glyph.
check('single: [5] -> ▅', () => {
assert.strictEqual(sparkline([5]), L4 + '\n');
});
// 4 — two-point extremes: [0,10] auto [0,10] -> low, high (10 clamps 8->7).
check('extremes: [0,10] -> ▁█', () => {
assert.strictEqual(sparkline([0, 10]), L0 + L7 + '\n');
});
// 5 — negatives across zero: [-4,0,4] auto [-4,4] -> ▁▅█.
check('negatives: [-4,0,4] -> ▁▅█', () => {
assert.strictEqual(sparkline([-4, 0, 4]), L0 + L4 + L7 + '\n');
});
// 6 — the classic: [3,1,4,1,5,9,2,6] auto [1,9] (hi-lo=8 -> level=floor(v-1)).
// 3->2 4->3 1->0 5->4 9->8clamp7 2->1 6->5 => ▃▁▄▁▅█▂▆
check('classic: [3,1,4,1,5,9,2,6] -> ▃▁▄▁▅█▂▆', () => {
assert.strictEqual(sparkline([3, 1, 4, 1, 5, 9, 2, 6]), L2 + L0 + L3 + L0 + L4 + L7 + L1 + L5 + '\n');
});
// 7 — pinned domain: [5] with min0 max10 -> ▅ (level 4), overriding the flat-window auto-fit.
check('pinned: {series:[5],min:0,max:10} -> ▅', () => {
assert.strictEqual(sparkline({ series: [5], min: 0, max: 10 }), L4 + '\n');
// CLI flags override object fields:
assert.strictEqual(sparkline({ series: [5], min: 0, max: 10 }, { min: 0, max: 2 }), L7 + '\n'); // 5 > max2 -> clamp high
});
// 8 — empty series -> a bare newline, never a crash.
check('empty: [] -> "\\n"', () => {
assert.strictEqual(sparkline([]), '\n');
});
// 9 — determinism: same series -> byte-identical output, twice.
check('determinism: repeated render is byte-identical', () => {
const s = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5];
assert.strictEqual(sparkline(s), sparkline(s));
});
// 10 — alphabet is CLOSED: every output char is one of the 8 block levels or the trailing \n.
check('closed alphabet: only ▁▂▃▄▅▆▇█ (+ newline) ever appear', () => {
const out = sparkline([3, -2, 7, 0, 5, -4, 9, 1, 8]);
const body = out.replace(/\n$/, '');
for (const ch of body) assert.ok(LEVELS.indexOf(ch) !== -1, 'unexpected char ' + JSON.stringify(ch));
assert.strictEqual(body.length, 9, 'one glyph per value');
});
// 11 — non-finite is a hard, named error (never a guessed point).
check('non-finite value throws, naming the position', () => {
assert.throws(() => sparkline([1, NaN, 3]), /series\[1\] is not a finite number/);
assert.throws(() => sparkline([1, 2, Infinity]), /series\[2\]/);
});
// 12 — honest input errors: nested arrays and bad min/max throw rather than mis-render.
check('nested arrays and inverted domain throw', () => {
assert.throws(() => sparkline([[1, 2], [3, 4]]), /ONE series/);
assert.throws(() => sparkline({ series: [1, 2], min: 5, max: 5 }), /min must be < max/);
});
console.log('\n' + passed + '/' + n + ' passed');
if (passed !== n) process.exit(1);