near-toll-audit / docs/charts.js · raw
// Small SVG chart kit. No dependencies. Marks follow one spec: 2px lines, thin bars with a 4px rounded data end,// hairline grid, one y axis, a tooltip on every mark and a table view for every chart.(function (root) {  "use strict";  var NS = "http://www.w3.org/2000/svg";  var tip = null;  function el(tag, attrs, parent) {    var node = document.createElementNS(NS, tag);    Object.keys(attrs || {}).forEach(function (k) { node.setAttribute(k, attrs[k]); });    if (parent) parent.appendChild(node);    return node;  }  function h(tag, cls, text, parent) {    var node = document.createElement(tag);    if (cls) node.className = cls;    if (text !== undefined && text !== null) node.textContent = text;    if (parent) parent.appendChild(node);    return node;  }  function css(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }  function tooltip() {    if (!tip) {      tip = h("div", "viz-tip", null, document.body);      tip.setAttribute("role", "status");      tip.hidden = true;    }    return tip;  }  function showTip(x, y, title, rows) {    var t = tooltip();    t.textContent = "";    if (title) h("div", "viz-tip-title", title, t);    rows.forEach(function (r) {      var row = h("div", "viz-tip-row", null, t);      var key = h("span", "viz-tip-key", null, row);      if (r.color) key.style.background = r.color;      h("strong", null, r.value, row);      h("span", "viz-tip-name", r.name, row);    });    t.hidden = false;    var w = t.offsetWidth, hh = t.offsetHeight;    var left = Math.min(Math.max(8, x + 14), window.innerWidth - w - 8);    var top = y - hh - 12;    if (top < 8) top = y + 16;    t.style.left = left + "px";    t.style.top = top + "px";  }  function hideTip() { if (tip) tip.hidden = true; }  function niceTicks(lo, hi, n) {    if (hi === lo) hi = lo + 1;    var span = hi - lo, step = Math.pow(10, Math.floor(Math.log10(span / n))), err = span / n / step;    if (err >= 7.5) step *= 10; else if (err >= 3) step *= 5; else if (err >= 1.5) step *= 2;    var start = Math.floor(lo / step) * step, out = [];    for (var v = start; v < hi + step * 0.999; v += step) out.push(Math.abs(v) < step * 1e-6 ? 0 : v);    return out;  }  function logTicks(lo, hi) {    var out = [], a = Math.floor(Math.log10(lo)), b = Math.ceil(Math.log10(hi));    for (var i = a; i <= b; i++) out.push(Math.pow(10, i));    return out;  }  var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];  function shortDate(t) {    var d = new Date(t);    return MONTHS[d.getUTCMonth()] + " '" + String(d.getUTCFullYear()).slice(2);  }  function fullDate(t) {    var d = new Date(t);    return d.getUTCDate() + " " + MONTHS[d.getUTCMonth()] + " " + d.getUTCFullYear();  }  function time(s) { return Date.parse(s.length <= 7 ? s + "-01T00:00:00Z" : s + "T00:00:00Z"); }  function legend(target, series, kind) {    if (series.length < 2) return;    var box = h("div", "viz-legend", null, target);    series.forEach(function (s) {      var item = h("span", "viz-legend-item", null, box);      var mark = h("span", "viz-legend-mark " + (kind === "bar" ? "is-bar" : "is-line"), null, item);      mark.style.background = s.color;      h("span", null, s.name, item);    });  }  function width(target) { return Math.max(280, target.clientWidth || 320); }  // Time series with a crosshair. opts: series [{name,color,points:[[t,y]]}], yFormat, height, log, marks [{t,label}], yMin  function line(target, opts) {    target.textContent = "";    var W = width(target), H = opts.height || (W < 520 ? 230 : 280);    var m = { t: 16, r: 14, b: 26, l: opts.left || 52 };    var svg = el("svg", { width: W, height: H, viewBox: "0 0 " + W + " " + H, role: "img", "aria-label": opts.label || "Line chart" }, target);    var all = [], xs = {};    opts.series.forEach(function (s) { s.points.forEach(function (p) { if (p[1] !== null && p[1] !== undefined) { all.push(p[1]); xs[p[0]] = 1; } }); });    var xv = Object.keys(xs).map(Number).sort(function (a, b) { return a - b; });    var x0 = xv[0], x1 = xv[xv.length - 1];    var lo = opts.log ? Math.min.apply(null, all.filter(function (v) { return v > 0; })) : (opts.yMin !== undefined ? opts.yMin : Math.min(0, Math.min.apply(null, all)));    var hi = Math.max.apply(null, all);    var ticks = opts.log ? logTicks(lo, hi) : niceTicks(lo, hi, W < 520 ? 4 : 5);    var y0 = opts.log ? ticks[0] : Math.min(lo, ticks[0]), y1 = ticks[ticks.length - 1];    function X(t) { return m.l + (t - x0) / ((x1 - x0) || 1) * (W - m.l - m.r); }    function Y(v) {      var f = opts.log ? (Math.log10(Math.max(v, y0)) - Math.log10(y0)) / (Math.log10(y1) - Math.log10(y0)) : (v - y0) / ((y1 - y0) || 1);      return H - m.b - f * (H - m.t - m.b);    }    var grid = css("--grid"), muted = css("--muted");    ticks.forEach(function (v) {      el("line", { x1: m.l, x2: W - m.r, y1: Y(v), y2: Y(v), stroke: grid, "stroke-width": 1 }, svg);      var t = el("text", { x: m.l - 8, y: Y(v) + 4, "text-anchor": "end", fill: muted, class: "viz-tick" }, svg);      t.textContent = opts.yFormat ? opts.yFormat(v) : v;    });    var nx = W < 520 ? 3 : 6, seen = {};    for (var i = 0; i < nx; i++) {      var tt = x0 + (x1 - x0) * i / (nx - 1), lab = shortDate(tt);      if (seen[lab]) continue;      seen[lab] = 1;      var tx = el("text", { x: X(tt), y: H - 8, "text-anchor": i === 0 ? "start" : i === nx - 1 ? "end" : "middle", fill: muted, class: "viz-tick" }, svg);      tx.textContent = lab;    }    (opts.marks || []).forEach(function (mk) {      var px = X(mk.t);      el("line", { x1: px, x2: px, y1: m.t, y2: H - m.b, stroke: css("--axis"), "stroke-width": 1 }, svg);      var anchor = px > W * 0.62 ? "end" : "start";      var label = el("text", { x: px + (anchor === "end" ? -6 : 6), y: m.t + 10 + (mk.row || 0) * 14, "text-anchor": anchor, fill: css("--ink-2"), class: "viz-note" }, svg);      label.textContent = mk.label;    });    opts.series.forEach(function (s) {      var pts = s.points.filter(function (p) { return p[1] !== null && p[1] !== undefined; });      if (opts.series.length === 1 && !opts.log) {        var area = "M" + X(pts[0][0]) + "," + Y(Math.max(y0, 0));        pts.forEach(function (p) { area += "L" + X(p[0]) + "," + Y(p[1]); });        area += "L" + X(pts[pts.length - 1][0]) + "," + Y(Math.max(y0, 0)) + "Z";        el("path", { d: area, fill: s.color, "fill-opacity": 0.1 }, svg);      }      var d = "";      pts.forEach(function (p, i) { d += (i ? "L" : "M") + X(p[0]).toFixed(1) + "," + Y(p[1]).toFixed(1); });      el("path", { d: d, fill: "none", stroke: s.color, "stroke-width": s.mute ? 1.5 : 2, "stroke-linejoin": "round", "stroke-linecap": "round", "stroke-opacity": s.mute ? 0.65 : 1 }, svg);      var end = pts[pts.length - 1];      el("circle", { cx: X(end[0]), cy: Y(end[1]), r: 4, fill: s.color, stroke: css("--surface"), "stroke-width": 2 }, svg);    });    var cross = el("line", { y1: m.t, y2: H - m.b, stroke: css("--axis"), "stroke-width": 1, visibility: "hidden" }, svg);    var hit = el("rect", { x: m.l, y: m.t, width: W - m.l - m.r, height: H - m.t - m.b, fill: "transparent", tabindex: 0 }, svg);    function nearest(px) {      var t = x0 + (px - m.l) / (W - m.l - m.r) * (x1 - x0), best = xv[0];      xv.forEach(function (v) { if (Math.abs(v - t) < Math.abs(best - t)) best = v; });      return best;    }    function at(t, cx, cy) {      cross.setAttribute("x1", X(t)); cross.setAttribute("x2", X(t)); cross.setAttribute("visibility", "visible");      var rows = [];      opts.series.forEach(function (s) {        var p = s.points.filter(function (q) { return q[0] === t; })[0];        if (p && p[1] !== null && p[1] !== undefined) rows.push({ color: s.color, name: s.name, value: opts.yFormat ? opts.yFormat(p[1], true) : String(p[1]) });      });      showTip(cx, cy, opts.monthly ? shortDate(t) : fullDate(t), rows);    }    hit.addEventListener("pointermove", function (e) {      var r = svg.getBoundingClientRect();      at(nearest(e.clientX - r.left), e.clientX, e.clientY);    });    hit.addEventListener("pointerleave", function () { cross.setAttribute("visibility", "hidden"); hideTip(); });    var idx = xv.length - 1;    hit.addEventListener("keydown", function (e) {      if (e.key === "ArrowLeft") idx = Math.max(0, idx - 1);      else if (e.key === "ArrowRight") idx = Math.min(xv.length - 1, idx + 1);      else return;      e.preventDefault();      var r = svg.getBoundingClientRect();      at(xv[idx], r.left + X(xv[idx]), r.top + m.t + 20);    });    hit.addEventListener("blur", function () { cross.setAttribute("visibility", "hidden"); hideTip(); });    legend(target, opts.series, "line");  }  // Vertical columns. opts: labels, series [{name,color,values}], stacked, yFormat  function columns(target, opts) {    target.textContent = "";    var W = width(target), H = opts.height || (W < 520 ? 220 : 260);    var m = { t: 14, r: 10, b: 26, l: opts.left || 48 };    var svg = el("svg", { width: W, height: H, viewBox: "0 0 " + W + " " + H, role: "img", "aria-label": opts.label || "Column chart" }, target);    var n = opts.labels.length, totals = opts.labels.map(function (_, i) {      return opts.stacked ? opts.series.reduce(function (a, s) { return a + (s.values[i] || 0); }, 0) : Math.max.apply(null, opts.series.map(function (s) { return s.values[i] || 0; }));    });    var lows = opts.labels.map(function (_, i) { return Math.min.apply(null, opts.series.map(function (s) { return s.values[i] || 0; })); });    var lo = Math.min(0, Math.min.apply(null, lows));    var ticks = niceTicks(lo, Math.max.apply(null, totals), 4);    var y0 = ticks[0], y1 = ticks[ticks.length - 1];    function Y(v) { return H - m.b - (v - y0) / ((y1 - y0) || 1) * (H - m.t - m.b); }    var grid = css("--grid"), muted = css("--muted"), surface = css("--surface");    ticks.forEach(function (v) {      el("line", { x1: m.l, x2: W - m.r, y1: Y(v), y2: Y(v), stroke: grid, "stroke-width": 1 }, svg);      var t = el("text", { x: m.l - 8, y: Y(v) + 4, "text-anchor": "end", fill: muted, class: "viz-tick" }, svg);      t.textContent = opts.yFormat ? opts.yFormat(v) : v;    });    var band = (W - m.l - m.r) / n, bw = Math.min(24, band - 3), every = Math.ceil(n / (W < 520 ? 5 : 9));    opts.labels.forEach(function (lab, i) {      var cx = m.l + band * i + band / 2;      if (i % every === 0 || n <= 9) {        var tx = el("text", { x: cx, y: H - 8, "text-anchor": "middle", fill: muted, class: "viz-tick" }, svg);        tx.textContent = opts.xFormat ? opts.xFormat(lab) : lab;      }      var base = 0, group = opts.stacked ? 1 : opts.series.length, each = Math.min(bw, (band - 3) / group);      opts.series.forEach(function (s, j) {        var v = s.values[i] || 0;        var top = opts.stacked ? Y(base + v) : Y(Math.max(v, 0));        var bottom = opts.stacked ? Y(base) : Y(Math.min(v, 0));        var x = opts.stacked ? cx - each / 2 : cx - each * group / 2 + j * each;        var barH = Math.max(0, bottom - top - (opts.stacked ? 2 : 0));        var r = Math.min(4, each / 2, barH);        var d = "M" + x + "," + (top + barH) + "L" + x + "," + (top + r) + "Q" + x + "," + top + " " + (x + r) + "," + top +          "L" + (x + each - r - (opts.stacked ? 0 : 1)) + "," + top + "Q" + (x + each - (opts.stacked ? 0 : 1)) + "," + top + " " + (x + each - (opts.stacked ? 0 : 1)) + "," + (top + r) +          "L" + (x + each - (opts.stacked ? 0 : 1)) + "," + (top + barH) + "Z";        el("path", { d: d, fill: s.color }, svg);        base += v;      });      var hit = el("rect", { x: m.l + band * i, y: m.t, width: band, height: H - m.t - m.b, fill: "transparent", tabindex: n <= 24 ? 0 : -1 }, svg);      function show(cx2, cy2) {        showTip(cx2, cy2, opts.xFormat ? opts.xFormat(lab, true) : lab, opts.series.map(function (s) {          return { color: s.color, name: s.name, value: opts.yFormat ? opts.yFormat(s.values[i] || 0, true) : String(s.values[i]) };        }));      }      hit.addEventListener("pointermove", function (e) { show(e.clientX, e.clientY); });      hit.addEventListener("pointerleave", hideTip);      hit.addEventListener("focus", function () { var r = hit.getBoundingClientRect(); show(r.left + r.width / 2, r.top + 20); });      hit.addEventListener("blur", hideTip);    });    if (lo < 0) el("line", { x1: m.l, x2: W - m.r, y1: Y(0), y2: Y(0), stroke: css("--axis"), "stroke-width": 1 }, svg);    legend(target, opts.series, "bar");  }  // Horizontal bars in HTML, so long account names wrap cleanly. rows: [{label, value, text, note}]  function bars(target, opts) {    target.textContent = "";    var maxv = Math.max.apply(null, opts.rows.map(function (r) { return Math.max(Math.abs(r.value) || 0, Math.abs(r.value2) || 0); }).concat([1e-9]));    var list = h("div", "viz-bars", null, target);    opts.rows.forEach(function (r) {      var row = h("div", "viz-bar-row" + (r.strong ? " is-strong" : ""), null, list);      h("div", "viz-bar-label", r.label, row);      var lines = h("div", "viz-bar-lines", null, row);      [[r.value, r.text, r.color || opts.color || css("--s1")]].concat(r.value2 !== undefined ? [[r.value2, r.text2, opts.color2 || css("--s2")]] : []).forEach(function (b) {        var track = h("div", "viz-bar-track", null, lines);        var fill = h("div", "viz-bar-fill", null, track);        fill.style.width = Math.max(0.6, Math.abs(b[0]) / maxv * 100) + "%";        fill.style.background = b[2];        h("div", "viz-bar-value", b[1], lines);      });    });    if (opts.legend) legend(target, opts.legend, "bar");  }  // Cell grid. opts: rows, cols, values[r][c], color(v) -> css, text(v), rowLabel  function heat(target, opts) {    target.textContent = "";    var wrap = h("div", "viz-heat", null, target);    var table = h("table", null, null, wrap);    var head = h("tr", null, null, h("thead", null, null, table));    h("th", null, opts.corner || "", head);    opts.cols.forEach(function (c) { h("th", null, c, head); });    var body = h("tbody", null, null, table);    opts.rows.forEach(function (rl, i) {      var tr = h("tr", null, null, body);      h("th", null, rl, tr);      opts.cols.forEach(function (c, j) {        var v = opts.values[i][j];        var td = h("td", null, v === null || v === undefined ? "" : opts.text(v), tr);        if (v !== null && v !== undefined) {          var fill = opts.color(v);          td.style.background = fill.bg;          td.style.color = fill.fg;        }        td.tabIndex = -1;        td.addEventListener("pointermove", function (e) { if (v !== null && v !== undefined) showTip(e.clientX, e.clientY, rl + " · " + c, [{ name: opts.name || "", value: opts.text(v, true) }]); });        td.addEventListener("pointerleave", hideTip);      });    });  }  // Low to high range around a midpoint. rows: [{label, low, high, lowText, highText}]  function tornado(target, opts) {    target.textContent = "";    var box = h("div", "viz-tornado", null, target);    var lo = Math.min.apply(null, opts.rows.map(function (r) { return Math.min(r.low, r.high, opts.mid); }));    var hi = Math.max.apply(null, opts.rows.map(function (r) { return Math.max(r.low, r.high, opts.mid); })) * 1.02;    function pct(v) { return (v - lo) / ((hi - lo) || 1) * 100; }    opts.rows.forEach(function (r) {      var row = h("div", "viz-t-row", null, box);      h("div", "viz-t-label", r.label, row);      var track = h("div", "viz-t-track", null, row);      var a = Math.min(r.low, r.high), b = Math.max(r.low, r.high);      var neg = h("div", "viz-t-fill is-neg", null, track);      neg.style.left = pct(a) + "%";      neg.style.width = Math.max(0.5, pct(Math.min(opts.mid, b)) - pct(a)) + "%";      if (b > opts.mid) {        var pos = h("div", "viz-t-fill is-pos", null, track);        pos.style.left = pct(opts.mid) + "%";        pos.style.width = Math.max(0.5, pct(b) - pct(opts.mid)) + "%";      }      var mid = h("div", "viz-t-mid", null, track);      mid.style.left = pct(opts.mid) + "%";      if (opts.market !== undefined && opts.market <= hi) {        var mk = h("div", "viz-t-market", null, track);        mk.style.left = pct(opts.market) + "%";      }      h("div", "viz-t-range", r.lowText + " to " + r.highText, row);      row.addEventListener("pointermove", function (e) { showTip(e.clientX, e.clientY, r.label, [{ name: "at the low input", value: r.lowText }, { name: "at the high input", value: r.highText }]); });      row.addEventListener("pointerleave", hideTip);    });  }  // One estimate with its 95% interval, against zero.  function interval(target, opts) {    target.textContent = "";    var W = width(target), H = 86, m = { l: 14, r: 14 };    var svg = el("svg", { width: W, height: H, viewBox: "0 0 " + W + " " + H, role: "img", "aria-label": opts.label || "Estimate with interval" }, target);    var span = Math.max(Math.abs(opts.lo), Math.abs(opts.hi)) * 1.15 || 1;    function X(v) { return m.l + (v + span) / (2 * span) * (W - m.l - m.r); }    var y = 40, muted = css("--muted");    el("line", { x1: m.l, x2: W - m.r, y1: y, y2: y, stroke: css("--grid"), "stroke-width": 1 }, svg);    el("line", { x1: X(0), x2: X(0), y1: y - 22, y2: y + 22, stroke: css("--axis"), "stroke-width": 1 }, svg);    el("line", { x1: X(opts.lo), x2: X(opts.hi), y1: y, y2: y, stroke: css("--s1"), "stroke-width": 2, "stroke-linecap": "round" }, svg);    el("circle", { cx: X(opts.est), cy: y, r: 6, fill: css("--s1"), stroke: css("--surface"), "stroke-width": 2 }, svg);    [[opts.lo, opts.fmt(opts.lo), "start"], [opts.hi, opts.fmt(opts.hi), "end"]].forEach(function (a) {      var t = el("text", { x: X(a[0]), y: y + 22, "text-anchor": a[2], fill: muted, class: "viz-tick" }, svg);      t.textContent = a[1];    });    var z = el("text", { x: X(0), y: y - 26, "text-anchor": "middle", fill: muted, class: "viz-tick" }, svg);    z.textContent = "no effect";  }  root.Viz = { line: line, interval: interval, columns: columns, bars: bars, heat: heat, tornado: tornado, time: time, css: css, h: h, hideTip: hideTip, shortDate: shortDate };})(window);