This website is meant to be read and understood quickly by humans, but is only fully parsable, on a technical level, with the aid of an AI system. Read why →
Loop MMT
Numbers In, a Committable SVG Chart Outrender← all gifts

SVG-sink

svg-sink turns a numeric series into a self-contained SVG chart with zero dependencies — line, bar, or scatter, one series or many. Turning a list of numbers into a chart normally drags in a charting library (a dependency, a build step, a runtime); this is the coordinate arithmetic and the SVG string and nothing else. The load-bearing property: the same spec in produces byte-identical SVG out, so the chart can be committed, diffed, and cached — and it runs identically in Node or a browser because the core is a pure function on plain arrays.

The honest edge
svg-sink is a chart PRIMITIVE, not a charting library: geometry only — no axes, gridlines, tick labels, legend, title, or interactivity, and no embedded fonts, CSS, or script. It takes NUMBERS ONLY (no caller text reaches the output, so there is no escaping surface to get wrong); colors come from a fixed named palette, never caller input. A non-finite value is a hard error, never a guessed point. If you need axes or labels, wrap it — it hands you the clean geometry to build on.
Run it
echo '[3,1,4,1,5,9,2,6]' | node svg-sink.js # line chart; --kind bar|scatter test_svg-sink.js (12/12, out-of-band hand-computed SVG oracle) + Plumb conformance GREEN (I1-I5, signed, clock-independent) Node / browser, no dependencies
The code — every file that ships
svg-sink.js277 lineson GitHub →
#!/usr/bin/env node
/*
 * svg-sink — turn numeric series into a standalone, deterministic SVG chart, 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 CHART PRIMITIVE, not a charting library. It draws GEOMETRY ONLY — a
 *   <polyline> per series (line), a <rect> per value (bar), or a <circle> per value
 *   (scatter) inside a plain <svg> frame. It renders NO axes, gridlines, tick marks,
 *   tick labels, legend, title, or interactivity, and it embeds NO fonts, NO CSS, and
 *   NO <script>. It takes NUMBERS ONLY — no caller text ever reaches the output, so
 *   there is no text-escaping surface to get wrong. Colors are NOT free-form input:
 *   series are painted from a fixed, named palette (index = series order), so the
 *   output can never carry an attacker-chosen attribute string. 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 spec in → byte-identical SVG out.
 *
 * USAGE:
 *   echo '[3,1,4,1,5,9,2,6]' | node svg-sink.js                 # line chart, defaults
 *   echo '[3,1,4,1,5,9]'     | node svg-sink.js --kind bar
 *   echo '[[1,2,3],[3,2,1]]' | node svg-sink.js --kind scatter  # two series
 *   echo '{"series":[1,2,3],"kind":"line","width":400,"height":120}' | node svg-sink.js
 *   node svg-sink.js --help
 *
 * INPUT (stdin, JSON): either
 *   - a bare array of numbers            -> one series, e.g. [1,2,3]
 *   - a bare array of arrays of numbers  -> many series, e.g. [[1,2],[3,4]]
 *   - a spec object { series, kind, width, height, pad, min, max, palette }
 *     where `series` is either of the two array forms above.
 *   CLI flags (--kind --width --height --pad --palette) OVERRIDE object fields.
 *
 * OUTPUT (stdout): one SVG document string (UTF-8), trailing newline.
 *
 * DETERMINISM: no wall-clock, no randomness, fixed 3-decimal coordinate precision,
 *   stable attribute order, palette indexed by series position. Same spec → same bytes.
 * PORTABILITY: pure JS on plain arrays/strings — identical in Node and the browser.
 */
'use strict';

// ---- palettes (the ONLY source of color; no free-form color input) --------------
// Named, closed sets. A series at index i is painted PALETTES[name][i % len].
var PALETTES = {
  loop:  ['#2f6f8f', '#c25b3a', '#4a8a52', '#8a6d3b', '#6d4a8a', '#3b6d8a'],
  mono:  ['#111111', '#555555', '#999999', '#bbbbbb'],
  warm:  ['#c25b3a', '#d98a3a', '#b23b3b', '#8a5a2b'],
  cool:  ['#2f6f8f', '#4a8a8a', '#3b5a8a', '#5a6d8a']
};
var DEFAULTS = { kind: 'line', width: 300, height: 100, pad: 6, palette: 'loop' };
var KINDS = { line: 1, bar: 1, scatter: 1 };

// ---- deterministic number formatting -------------------------------------------
// Round to 3 decimals, strip trailing zeros (and a bare trailing dot), normalize -0.
// The whole determinism guarantee of a coordinate emitter rests on this one function:
// float arithmetic that fed toString() directly would leak platform-dependent digits.
var PRECISION = 3, SCALE = 1000; // 10 ** 3
function num(x) {
  var r = Math.round(x * SCALE) / SCALE;
  var s = r.toFixed(PRECISION);            // always has a '.' and PRECISION digits
  s = s.replace(/\.?0+$/, '');             // "12.300"->"12.3", "10.000"->"10"
  return (s === '' || s === '-0') ? '0' : s;
}

// ---- input normalization -------------------------------------------------------
// Accept: number[] | number[][] | {series, ...}. Return a validated spec.
function normalize(input, flags) {
  var spec = {};
  var seriesRaw;
  if (input && !Array.isArray(input) && typeof input === 'object') {
    seriesRaw = input.series;
    if (input.kind    != null) spec.kind    = input.kind;
    if (input.width   != null) spec.width   = input.width;
    if (input.height  != null) spec.height  = input.height;
    if (input.pad     != null) spec.pad     = input.pad;
    if (input.min     != null) spec.min     = input.min;
    if (input.max     != null) spec.max     = input.max;
    if (input.palette != null) spec.palette = input.palette;
  } else {
    seriesRaw = input;
  }
  // CLI flags override object fields.
  flags = flags || {};
  for (var k in flags) if (flags[k] != null) spec[k] = flags[k];

  // series -> number[][]
  if (!Array.isArray(seriesRaw)) throw new Error('input has no numeric series (expected an array or a {series:...} object)');
  var series;
  if (seriesRaw.length > 0 && Array.isArray(seriesRaw[0])) {
    series = seriesRaw.map(function (s, i) {
      if (!Array.isArray(s)) throw new Error('series ' + i + ' is not an array');
      return s.map(function (v, j) { return finite(v, i, j); });
    });
  } else {
    series = [seriesRaw.map(function (v, j) { return finite(v, 0, j); })];
  }

  spec.series  = series;
  spec.kind    = (spec.kind    != null) ? String(spec.kind)     : DEFAULTS.kind;
  spec.width   = int(spec.width,   DEFAULTS.width,  'width');
  spec.height  = int(spec.height,  DEFAULTS.height, 'height');
  spec.pad     = int(spec.pad,     DEFAULTS.pad,    'pad');
  spec.palette = (spec.palette != null) ? String(spec.palette) : DEFAULTS.palette;

  if (!KINDS[spec.kind]) throw new Error('unknown kind "' + spec.kind + '" (expected line | bar | scatter)');
  if (!PALETTES[spec.palette]) throw new Error('unknown palette "' + spec.palette + '" (expected ' + Object.keys(PALETTES).join(' | ') + ')');
  if (spec.width <= 2 * spec.pad || spec.height <= 2 * spec.pad) throw new Error('width/height too small for pad (need width,height > 2*pad)');
  return spec;
}
function finite(v, i, j) {
  if (typeof v !== 'number' || !isFinite(v)) throw new Error('series ' + i + '[' + j + '] is not a finite number: ' + JSON.stringify(v));
  return v;
}
function int(v, dflt, name) {
  if (v == null) return dflt;
  var n = Number(v);
  if (!isFinite(n) || Math.floor(n) !== n || n < 0) throw new Error(name + ' must be a non-negative integer, got ' + JSON.stringify(v));
  return n;
}

// ---- scale (shared y-domain across all series) ---------------------------------
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 i = 0; i < series.length; i++)
      for (var j = 0; j < series[i].length; j++) {
        var v = series[i][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 -> a unit window, drawn mid-frame
  return { lo: lo, hi: hi };
}

// ---- core: renderSVG(spec) -> string  (pure, the whole gift) --------------------
function renderSVG(input, flags) {
  var spec = normalize(input, flags);
  var W = spec.width, H = spec.height, pad = spec.pad;
  var series = spec.series;
  var dom = domain(series, spec.min, spec.max);
  // Bar charts baseline at zero: extend an AUTO-computed domain to include 0 so the
  // zero line is on-canvas and bar heights read value-proportional (the bar convention).
  // An explicit min/max is honored as-is — the caller's window wins.
  if (spec.kind === 'bar') {
    if (spec.min == null) dom.lo = Math.min(dom.lo, 0);
    if (spec.max == null) dom.hi = Math.max(dom.hi, 0);
  }
  var pal = PALETTES[spec.palette];
  var innerW = W - 2 * pad, innerH = H - 2 * pad;

  // index i (0..n-1) -> x within [pad, W-pad]; value v -> y (inverted) within [pad, H-pad]
  function X(i, n) { return n <= 1 ? pad + innerW / 2 : pad + (i / (n - 1)) * innerW; }
  function Y(v)    { return (H - pad) - ((v - dom.lo) / (dom.hi - dom.lo)) * innerH; }

  var body = [];
  if (spec.kind === 'line')    body = lineBody(series, pal, X, Y);
  else if (spec.kind === 'bar')     body = barBody(series, pal, X, Y, W, pad, dom);
  else if (spec.kind === 'scatter') body = scatterBody(series, pal, X, Y);

  // Fixed attribute order; xmlns first so the fragment is a valid standalone document.
  var open = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H + '">';
  return open + '\n' + (body.length ? body.join('\n') + '\n' : '') + '</svg>\n';
}

function lineBody(series, pal, X, Y) {
  var out = [];
  for (var s = 0; s < series.length; s++) {
    var pts = series[s];
    var n = pts.length;
    if (n === 0) continue;
    var coords = [];
    for (var i = 0; i < n; i++) coords.push(num(X(i, n)) + ',' + num(Y(pts[i])));
    var color = pal[s % pal.length];
    if (n === 1) {
      // a single point is drawn as a dot so it is not invisible
      var p = coords[0].split(',');
      out.push('<circle cx="' + p[0] + '" cy="' + p[1] + '" r="1.5" fill="' + color + '" />');
    } else {
      out.push('<polyline fill="none" stroke="' + color + '" stroke-width="1.5" points="' + coords.join(' ') + '" />');
    }
  }
  return out;
}

function scatterBody(series, pal, X, Y) {
  var out = [];
  for (var s = 0; s < series.length; s++) {
    var pts = series[s], n = pts.length, color = pal[s % pal.length];
    for (var i = 0; i < n; i++)
      out.push('<circle cx="' + num(X(i, n)) + '" cy="' + num(Y(pts[i])) + '" r="2" fill="' + color + '" />');
  }
  return out;
}

function barBody(series, pal, X, Y, W, pad, dom) {
  var out = [];
  var nSeries = series.length;
  // baseline: 0 if the domain straddles it, else the domain floor.
  var baseV = (dom.lo <= 0 && dom.hi >= 0) ? 0 : dom.lo;
  var baseY = Y(baseV);
  // slot = horizontal room per index; bars for multiple series subdivide the slot.
  var maxN = 0;
  for (var s = 0; s < nSeries; s++) if (series[s].length > maxN) maxN = series[s].length;
  if (maxN === 0) return out;
  var innerW = W - 2 * pad;
  var slot = innerW / maxN;
  var groupW = slot * 0.8;             // 20% gap between index groups
  var barW = groupW / nSeries;
  for (var si = 0; si < nSeries; si++) {
    var pts = series[si], color = pal[si % pal.length];
    for (var i = 0; i < pts.length; i++) {
      var v = pts[i];
      var y = Y(v);
      var top = Math.min(y, baseY), h = Math.abs(y - baseY);
      var x = pad + i * slot + (slot - groupW) / 2 + si * barW;
      out.push('<rect x="' + num(x) + '" y="' + num(top) + '" width="' + num(barW) + '" height="' + num(h) + '" fill="' + color + '" />');
    }
  }
  return out;
}

// ---- cli -----------------------------------------------------------------------
var HELP =
'svg-sink — numeric series -> a standalone, deterministic SVG chart, zero deps.\n\n' +
'  echo \'[3,1,4,1,5]\'     | node svg-sink.js                line chart (default)\n' +
'  echo \'[3,1,4,1,5]\'     | node svg-sink.js --kind bar\n' +
'  echo \'[[1,2,3],[3,2,1]]\' | node svg-sink.js --kind scatter   two series\n' +
'  echo \'{"series":[1,2,3],"width":400,"height":120}\' | node svg-sink.js\n\n' +
'Flags (override object fields): --kind line|bar|scatter  --width N  --height N\n' +
'                                --pad N  --palette loop|mono|warm|cool\n\n' +
'Geometry only: no axes, labels, legend, fonts, CSS, or script. Numbers in, shapes\n' +
'out. Colors come from a fixed named palette (not caller input). Non-finite -> error.\n';

function parseFlags(argv) {
  var f = {};
  for (var i = 0; i < argv.length; i++) {
    var a = argv[i];
    if (a === '--kind')    f.kind    = argv[++i];
    else if (a === '--width')   f.width   = argv[++i];
    else if (a === '--height')  f.height  = argv[++i];
    else if (a === '--pad')     f.pad     = argv[++i];
    else if (a === '--palette') f.palette = argv[++i];
  }
  return f;
}

function main() {
  var argv = process.argv.slice(2);
  if (argv.indexOf('--help') !== -1 || argv.indexOf('-h') !== -1) { process.stdout.write(HELP); return; }
  var flags = parseFlags(argv);
  var chunks = '';
  process.stdin.setEncoding('utf8');
  process.stdin.on('data', function (d) { chunks += d; });
  process.stdin.on('end', function () {
    var input;
    try { input = JSON.parse(chunks); }
    catch (e) { process.stderr.write('svg-sink: input is not valid JSON: ' + e.message + '\n'); process.exitCode = 1; return; }
    var svg;
    try { svg = renderSVG(input, flags); }
    catch (e) { process.stderr.write('svg-sink: ' + e.message + '\n'); process.exitCode = 1; return; }
    process.stdout.write(svg);
  });
}

// ---- triple export (browser attach · require · direct run) ---------------------
if (typeof window !== 'undefined') {
  window.LoopGifts = window.LoopGifts || {};
  window.LoopGifts['svg-sink'] = { renderSVG: renderSVG };
}
if (typeof module !== 'undefined' && module.exports) {
  module.exports = { renderSVG: renderSVG, num: num, normalize: normalize, PALETTES: PALETTES };
}
if (typeof require !== 'undefined' && require.main === module) {
  main();
}
test_svg-sink.js133 lineson GitHub →
#!/usr/bin/env node
/*
 * test_svg-sink.js — drift-check battery for the svg-sink gift.
 *
 * THE ORACLE IS OUT-OF-BAND BY CONSTRUCTION. The exact-SVG expectations below are
 * hand-computed from the documented scale math (X = pad + i/(n-1)*innerW; Y inverted),
 * written independently of the emitter — not captured from the emitter's own output.
 * A build cannot certify itself, so the expected strings are the test author's fact.
 * The remaining checks are contract properties (determinism, in-viewBox, honest errors).
 *
 * Run: node test_svg-sink.js   (exit 0 = all pass; nonzero = a named failure)
 */
'use strict';
const assert = require('assert');
const { renderSVG, num } = require('./svg-sink.js');

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; }
}
const OPEN = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">';

// 1 — line, exact bytes. [0,10] on 100x100 pad10 -> x:10,90  y:90,10
check('line: exact SVG for a hand-computed 2-point series', () => {
  const got = renderSVG([0, 10], { kind: 'line', width: 100, height: 100, pad: 10, palette: 'mono' });
  const want = OPEN + '\n' +
    '<polyline fill="none" stroke="#111111" stroke-width="1.5" points="10,90 90,10" />' + '\n' +
    '</svg>\n';
  assert.strictEqual(got, want);
});

// 2 — scatter, exact bytes. single point [5] -> flat domain [4,6], centered at (50,50)
check('scatter: single point centers via flat-domain rule', () => {
  const got = renderSVG([5], { kind: 'scatter', width: 100, height: 100, pad: 10 });
  const want = OPEN + '\n<circle cx="50" cy="50" r="2" fill="#2f6f8f" />\n</svg>\n';
  assert.strictEqual(got, want);
});

// 3 — bar, exact bytes. [2,10] on 100x100 pad10 -> ZERO-baselined; heights 16:80 == 2:10.
// The 1:5 height ratio is only true if bars baseline at 0 (not the domain floor of 2).
check('bar: zero-baselined rects with value-proportional heights', () => {
  const got = renderSVG([2, 10], { kind: 'bar', width: 100, height: 100, pad: 10, palette: 'mono' });
  const want = OPEN + '\n' +
    '<rect x="14" y="74" width="32" height="16" fill="#111111" />' + '\n' +
    '<rect x="54" y="10" width="32" height="80" fill="#111111" />' + '\n' +
    '</svg>\n';
  assert.strictEqual(got, want);
});

// 4 — multi-series line: two polylines, palette by series index
check('line: two series -> two polylines, indexed palette', () => {
  const got = renderSVG([[0, 10], [10, 0]], { kind: 'line', width: 100, height: 100, pad: 10, palette: 'loop' });
  const lines = got.split('\n').filter(l => l.indexOf('<polyline') === 0);
  assert.strictEqual(lines.length, 2, 'expected 2 polylines');
  assert.ok(lines[0].indexOf('stroke="#2f6f8f"') !== -1, 'series 0 uses palette[0]');
  assert.ok(lines[1].indexOf('stroke="#c25b3a"') !== -1, 'series 1 uses palette[1]');
  assert.ok(lines[0].indexOf('points="10,90 90,10"') !== -1);
  assert.ok(lines[1].indexOf('points="10,10 90,90"') !== -1);
});

// 5 — determinism: same spec -> byte-identical output, twice
check('determinism: repeated render is byte-identical', () => {
  const spec = { series: [3, 1, 4, 1, 5, 9, 2, 6], kind: 'line', width: 320, height: 90 };
  const a = renderSVG(spec.series, { kind: spec.kind, width: spec.width, height: spec.height });
  const b = renderSVG(spec.series, { kind: spec.kind, width: spec.width, height: spec.height });
  assert.strictEqual(a, b);
});

// 6 — all emitted coordinates fall inside the viewBox (no overflow)
check('containment: every coordinate lies within [0,W]x[0,H]', () => {
  const W = 300, H = 100;
  ['line', 'bar', 'scatter'].forEach(kind => {
    const svg = renderSVG([3, -2, 7, 0, 5, -4, 9], { kind, width: W, height: H, pad: 6 });
    // pull the geometry attrs specifically and assert each is in-frame:
    const attrs = svg.match(/(?:cx|cy|x|y|width|height)="(-?\d+(?:\.\d+)?)"/g) || [];
    attrs.forEach(a => {
      const v = Number(a.split('"')[1]);
      assert.ok(v >= -0.001 && v <= Math.max(W, H) + 0.001, 'coord ' + v + ' out of frame in ' + kind);
    });
    // points= list too
    const pm = svg.match(/points="([^"]*)"/g) || [];
    pm.forEach(p => p.slice(8, -1).split(/[ ,]/).map(Number).forEach(v => {
      assert.ok(v >= -0.001 && v <= Math.max(W, H) + 0.001, 'point ' + v + ' out of frame');
    }));
  });
});

// 7 — empty series -> a valid empty frame, never a crash
check('empty series -> valid empty <svg> frame', () => {
  const got = renderSVG([], { kind: 'line', width: 100, height: 100, pad: 10 });
  assert.strictEqual(got, OPEN + '\n</svg>\n');
});

// 8 — non-finite is a hard, named error (never a guessed point)
check('non-finite value throws, naming the position', () => {
  assert.throws(() => renderSVG([1, NaN, 3]), /series 0\[1\] is not a finite number/);
  assert.throws(() => renderSVG([[1, 2], [3, Infinity]]), /series 1\[1\]/);
});

// 9 — unknown kind / palette are honest errors, not silent fallbacks
check('unknown kind and palette throw', () => {
  assert.throws(() => renderSVG([1, 2], { kind: 'pie' }), /unknown kind/);
  assert.throws(() => renderSVG([1, 2], { palette: 'neon' }), /unknown palette/);
});

// 10 — the number formatter: rounding, trailing-zero strip, -0 normalization
check('num(): deterministic 3-dp formatting', () => {
  assert.strictEqual(num(12.3),   '12.3');
  assert.strictEqual(num(10),     '10');
  assert.strictEqual(num(12.3006),'12.301');
  assert.strictEqual(num(5.12),   '5.12');
  assert.strictEqual(num(-0.0001),'0');     // normalizes -0
  assert.strictEqual(num(100.5),  '100.5');
});

// 11 — spec object with CLI-flag override precedence
check('spec object: CLI flag overrides object field', () => {
  const svg = renderSVG({ series: [1, 2], kind: 'line', width: 100, height: 100, pad: 10 }, { kind: 'scatter' });
  assert.ok(svg.indexOf('<circle') !== -1, 'flag kind=scatter should win over object kind=line');
  assert.ok(svg.indexOf('<polyline') === -1);
});

// 12 — single-point line degenerates to a visible dot (not an empty polyline)
check('line: single point renders as a dot', () => {
  const svg = renderSVG([7], { kind: 'line', width: 100, height: 100, pad: 10 });
  assert.ok(svg.indexOf('<circle') !== -1, 'single-point line should draw a circle');
  assert.ok(svg.indexOf('<polyline') === -1);
});

console.log('\n' + passed + '/' + n + ' passed');
if (passed !== n) process.exit(1);
Take the whole folder → MIT Node / browser, no dependencies