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);