// Small SVG charts for the statistics page: a line/area chart with a // crosshair tooltip, a weekday x hour heatmap, and bar lists. No library; // colors come from CSS custom properties (--series-N, --heat-N) so light // and dark themes are handled in style.css. All text is inserted as text, // never as markup. import { h } from "./ui.js"; const NS = "http://www.w3.org/2000/svg"; function svg(tag, attrs, ...children) { const el = document.createElementNS(NS, tag); for (const [k, v] of Object.entries(attrs || {})) { if (v !== undefined && v !== null && v !== false) el.setAttribute(k, v); } for (const c of children.flat(Infinity)) { if (c === null || c === undefined || c === false) continue; el.append(c instanceof Node ? c : document.createTextNode(String(c))); } return el; } // compact formats a count: 1,234 / 12.9K / 4.2M. export function compact(n) { const a = Math.abs(n); if (a >= 1e9) return (n / 1e9).toFixed(a >= 1e10 ? 0 : 1).replace(/\.0$/, "") + "B"; if (a >= 1e6) return (n / 1e6).toFixed(a >= 1e7 ? 0 : 1).replace(/\.0$/, "") + "M"; if (a >= 1e4) return (n / 1e3).toFixed(a >= 1e5 ? 0 : 1).replace(/\.0$/, "") + "K"; return Math.round(n).toLocaleString(); } // niceTicks returns 0..max in clean steps (1, 2 or 5 x 10^k), at most n+1 ticks. export function niceTicks(max, n = 4) { if (max <= 0) return [0, 1]; const raw = max / n; const mag = Math.pow(10, Math.floor(Math.log10(raw))); const step = [1, 2, 5, 10].map((m) => m * mag).find((s) => s >= raw); const ticks = []; for (let v = 0; v < max + step * 0.999; v += step) ticks.push(Math.round(v * 1e6) / 1e6); return ticks; } const MIN = 60, HOUR = 3600, DAY = 86400; const tickSteps = [5 * MIN, 10 * MIN, 15 * MIN, 30 * MIN, HOUR, 2 * HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR, DAY, 2 * DAY, 7 * DAY, 14 * DAY, 30 * DAY]; // timeTicks picks local-time-aligned ticks between t0 and t1 (Unix seconds). function timeTicks(t0, t1, maxTicks) { const span = t1 - t0; const step = tickSteps.find((s) => span / s <= maxTicks) || 30 * DAY; const out = []; const d = new Date(t0 * 1000); if (step >= DAY) { d.setHours(0, 0, 0, 0); } else { d.setSeconds(0, 0); const mins = d.getHours() * 60 + d.getMinutes(); const stepMin = step / 60; d.setHours(0, Math.floor(mins / stepMin) * stepMin, 0, 0); } for (let t = d.getTime() / 1000; t <= t1; ) { if (t >= t0) out.push(t); if (step >= DAY) { const nd = new Date(t * 1000); nd.setDate(nd.getDate() + step / DAY); t = nd.getTime() / 1000; } else t += step; } return { ticks: out, step }; } const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const pad = (n) => String(n).padStart(2, "0"); function tickLabel(t, step) { const d = new Date(t * 1000); if (step >= DAY) return step >= 7 * DAY ? months[d.getMonth()] + " " + d.getDate() : weekdays[d.getDay()] + " " + d.getDate(); const hm = pad(d.getHours()) + ":" + pad(d.getMinutes()); return d.getHours() === 0 && d.getMinutes() === 0 ? weekdays[d.getDay()] + " " + d.getDate() : hm; } // longTime describes a bucket for tooltips and tables. export function bucketLabel(t, stepSecs) { const d = new Date(t * 1000); const day = weekdays[d.getDay()] + " " + d.getDate() + " " + months[d.getMonth()]; if (stepSecs >= DAY) return day; const end = new Date((t + stepSecs) * 1000); const hm = (x) => pad(x.getHours()) + ":" + pad(x.getMinutes()); return day + ", " + hm(d) + (stepSecs > MIN ? "–" + hm(end) : ""); } export const stepSeconds = { "1m": MIN, "5m": 5 * MIN, "15m": 15 * MIN, "1h": HOUR, "6h": 6 * HOUR, "1d": DAY }; // tooltip is one floating readout per chart. function tooltip() { const el = h("div", { class: "chart-tip", role: "status" }); el.hidden = true; return el; } // chartCard wraps a chart with its title and a table-view toggle (the // accessible twin of every chart). export function chartCard(title, subtitle, chart, tableView, opts = {}) { const body = h("div", { class: "chart-body" }, chart); const tableBox = h("div", { class: "chart-table" }, tableView); tableBox.hidden = true; const toggle = h("button", { class: "small link", type: "button", "aria-pressed": "false" }, "Table"); toggle.addEventListener("click", () => { const showTable = tableBox.hidden; tableBox.hidden = !showTable; body.hidden = showTable; toggle.textContent = showTable ? "Chart" : "Table"; toggle.setAttribute("aria-pressed", String(showTable)); }); return h("figure", { class: "panel chart-card" + (opts.wide ? " wide" : "") }, h("figcaption", { class: "chart-head" }, h("div", { class: "grow" }, h("h2", null, title), subtitle ? h("div", { class: "muted small" }, subtitle) : null), opts.legend || null, tableView ? toggle : null), body, tableBox); } // legend lists series with a short line key (the mark the series uses). export function legend(series) { return h("ul", { class: "chart-legend" }, series.map((s) => h("li", null, h("span", { class: "key line s" + s.slot }), s.label))); } // lineChart draws series over time. series: [{label, values, slot}] (slot // = categorical color 1..3); times: bucket starts (Unix seconds); step in // seconds. With area, the first series gets a 10% wash under its line. export function lineChart({ series, times, step, area = false, height = 220, endLabels = true }) { const root = h("div", { class: "chart", tabindex: "0", "aria-label": series.map((s) => s.label).join(", ") + " over time" }); const tip = tooltip(); root.append(tip); let width = 0, focusIdx = -1, geom = null; function draw() { const w = root.clientWidth; if (!w || w === width) return; width = w; root.querySelector("svg")?.remove(); const max = Math.max(1, ...series.flatMap((s) => s.values)); const yTicks = niceTicks(max, 4); const yMax = yTicks[yTicks.length - 1]; const labelW = Math.max(...yTicks.map((t) => compact(t).length)) * 7 + 10; const endW = endLabels && series.length > 1 && series.length <= 4 ? Math.max(...series.map((s) => s.label.length)) * 7 + 12 : 8; const m = { top: 10, right: endW, bottom: 24, left: labelW }; const pw = Math.max(40, w - m.left - m.right), ph = height - m.top - m.bottom; const t0 = times[0], t1 = times[times.length - 1] + step; const x = (t) => m.left + ((t - t0) / Math.max(1, t1 - t0)) * pw; const cx = (i) => x(times[i] + step / 2); // the middle of a bucket const y = (v) => m.top + ph - (v / yMax) * ph; const s = svg("svg", { width: w, height, viewBox: `0 0 ${w} ${height}`, role: "img", "aria-hidden": "true" }); // Grid and axes. for (const t of yTicks) { s.append(svg("line", { class: t === 0 ? "axis" : "grid", x1: m.left, x2: m.left + pw, y1: y(t), y2: y(t) })); s.append(svg("text", { class: "tick", x: m.left - 6, y: y(t) + 4, "text-anchor": "end" }, compact(t))); } const { ticks, step: tstep } = timeTicks(t0, t1, Math.max(2, Math.floor(pw / 90))); for (const t of ticks) { s.append(svg("text", { class: "tick", x: x(t), y: height - 6, "text-anchor": "middle" }, tickLabel(t, tstep))); } // Series. series.forEach((sr, k) => { const pts = sr.values.map((v, i) => [cx(i), y(v)]); const d = pts.map((p, i) => (i ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1)).join(""); if (area && k === 0 && pts.length) { s.append(svg("path", { class: "area s" + sr.slot, d: d + `L${pts[pts.length - 1][0].toFixed(1)} ${y(0)}L${pts[0][0].toFixed(1)} ${y(0)}Z` })); } s.append(svg("path", { class: "line s" + sr.slot, d })); if (pts.length === 1) s.append(svg("circle", { class: "dot s" + sr.slot, cx: pts[0][0], cy: pts[0][1], r: 4 })); }); // Direct end labels when there is room and they don't collide. if (m.right > 8) { const ends = series.map((sr) => ({ label: sr.label, y: y(sr.values[sr.values.length - 1] || 0) })).sort((a, b) => a.y - b.y); if (ends.every((e, i) => i === 0 || e.y - ends[i - 1].y >= 13)) { for (const e of ends) s.append(svg("text", { class: "end-label", x: m.left + pw + 6, y: e.y + 4 }, e.label)); } } // Crosshair (drawn on hover/focus). const cross = svg("line", { class: "crosshair", y1: m.top, y2: m.top + ph, visibility: "hidden" }); const dots = series.map((sr) => svg("circle", { class: "dot s" + sr.slot, r: 4, visibility: "hidden" })); s.append(cross, ...dots); // The hit area is the whole plot: readers aim at a time, not a line. const hit = svg("rect", { class: "hit", x: m.left, y: m.top, width: pw, height: ph }); s.append(hit); root.prepend(s); geom = { cx, y, m, pw, ph, cross, dots, hit }; hit.addEventListener("pointermove", (e) => { const r = s.getBoundingClientRect(); const px = e.clientX - r.left; const i = Math.round(((px - m.left) / pw) * (times.length) - 0.5); show(Math.max(0, Math.min(times.length - 1, i))); }); hit.addEventListener("pointerleave", hide); if (focusIdx >= 0) show(focusIdx); } function show(i) { if (!geom) return; focusIdx = i; const xpos = geom.cx(i); geom.cross.setAttribute("x1", xpos); geom.cross.setAttribute("x2", xpos); geom.cross.setAttribute("visibility", "visible"); series.forEach((sr, k) => { geom.dots[k].setAttribute("cx", xpos); geom.dots[k].setAttribute("cy", geom.y(sr.values[i])); geom.dots[k].setAttribute("visibility", "visible"); }); tip.replaceChildren( h("div", { class: "tip-head" }, bucketLabel(times[i], step)), ...series.map((sr) => h("div", { class: "tip-row" }, h("span", { class: "key line s" + sr.slot }), h("strong", null, sr.values[i].toLocaleString()), " ", h("span", { class: "muted" }, sr.label)))); tip.hidden = false; // Keep the readout inside the chart, on the far side of the pointer. const left = xpos > root.clientWidth / 2 ? xpos - tip.offsetWidth - 12 : xpos + 12; tip.style.left = Math.max(0, left) + "px"; tip.style.top = geom.m.top + "px"; } function hide() { if (!geom) return; geom.cross.setAttribute("visibility", "hidden"); geom.dots.forEach((d) => d.setAttribute("visibility", "hidden")); tip.hidden = true; focusIdx = -1; } // Keyboard: arrows move the crosshair, as hover does. root.addEventListener("focus", () => show(focusIdx >= 0 ? focusIdx : times.length - 1)); root.addEventListener("blur", hide); root.addEventListener("keydown", (e) => { if (e.key === "ArrowLeft" || e.key === "ArrowRight") { e.preventDefault(); show(Math.max(0, Math.min(times.length - 1, (focusIdx < 0 ? times.length - 1 : focusIdx) + (e.key === "ArrowLeft" ? -1 : 1)))); } }); new ResizeObserver(draw).observe(root); return root; } // timeTable is the table view of a line chart (busiest buckets first is // not useful here: keep time order, newest first). export function timeTable(series, times, step) { const rows = times.map((t, i) => ({ t, i })).reverse(); return h("div", { class: "table-wrap scroll" }, h("table", null, h("thead", null, h("tr", null, h("th", null, "Time"), series.map((s) => h("th", { class: "num" }, s.label)))), h("tbody", null, rows.map((r) => h("tr", null, h("td", null, bucketLabel(r.t, step)), series.map((s) => h("td", { class: "num" }, s.values[r.i].toLocaleString()))))))); } // heatmap draws average activity by weekday (rows, Monday first) and hour // (columns) with a sequential one-hue ramp; cells without data stay empty. export function heatmap(hm, unit) { const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; let peak = 0; hm.avg.forEach((row) => row.forEach((v) => { peak = Math.max(peak, v); })); const steps = 7; const level = (v) => (peak <= 0 ? 0 : Math.min(steps - 1, Math.floor((v / peak) * steps))); const tip = tooltip(); const grid = h("div", { class: "heatmap", role: "grid", "aria-label": "Average " + unit + " per hour by weekday and hour of day" }); grid.append(h("div", { class: "hm-corner" })); for (let hr = 0; hr < 24; hr++) grid.append(h("div", { class: "hm-col" }, hr % 3 === 0 ? pad(hr) : "")); days.forEach((day, d) => { grid.append(h("div", { class: "hm-row" }, day)); for (let hr = 0; hr < 24; hr++) { const v = hm.avg[d][hr]; const n = hm.samples[d][hr]; const text = v < 0 ? day + " " + pad(hr) + ":00 – no data" : day + " " + pad(hr) + ":00 – " + Math.round(v).toLocaleString() + " " + unit + " per hour (average of " + n + (n === 1 ? " hour)" : " hours)"); const cell = h("div", { class: "hm-cell " + (v < 0 ? "empty" : "heat-" + level(v)), tabindex: "-1", "aria-label": text }); const showTip = () => { tip.textContent = text; tip.hidden = false; const r = cell.getBoundingClientRect(), pr = grid.getBoundingClientRect(); tip.style.left = Math.max(0, Math.min(pr.width - tip.offsetWidth, r.left - pr.left - tip.offsetWidth / 2 + r.width / 2)) + "px"; tip.style.top = (r.bottom - pr.top + 6) + "px"; }; cell.addEventListener("pointerenter", showTip); cell.addEventListener("focus", showTip); cell.addEventListener("pointerleave", () => { tip.hidden = true; }); cell.addEventListener("blur", () => { tip.hidden = true; }); grid.append(cell); } }); // Arrow keys move between cells. grid.tabIndex = 0; let cur = -1; const cells = () => [...grid.querySelectorAll(".hm-cell")]; grid.addEventListener("keydown", (e) => { const delta = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -24, ArrowDown: 24 }[e.key]; if (delta === undefined) return; e.preventDefault(); cur = Math.max(0, Math.min(7 * 24 - 1, (cur < 0 ? 0 : cur) + delta)); cells()[cur].focus(); }); const scale = h("div", { class: "hm-scale" }, h("span", { class: "muted small" }, "0"), Array.from({ length: steps }, (_, i) => h("span", { class: "hm-swatch heat-" + i })), h("span", { class: "muted small" }, compact(peak) + " " + unit + "/hour"), h("span", { class: "hm-swatch empty" }), h("span", { class: "muted small" }, "no data")); return h("div", { class: "heatmap-wrap" }, grid, tip, scale); } export function heatmapTable(hm, unit) { const days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; return h("div", { class: "table-wrap scroll" }, h("table", null, h("thead", null, h("tr", null, h("th", null, "Hour"), days.map((d) => h("th", { class: "num" }, d)))), h("tbody", null, Array.from({ length: 24 }, (_, hr) => h("tr", null, h("td", null, pad(hr) + ":00"), days.map((_, d) => h("td", { class: "num" }, hm.avg[d][hr] < 0 ? "–" : Math.round(hm.avg[d][hr]).toLocaleString())))))), h("div", { class: "muted small" }, "Average " + unit + " per hour.")); } // barList shows ranked entries as labelled horizontal bars in one hue // (one series: no legend, the title names it). Values are always printed, // so the list is its own table view. export function barList(entries, { total, label = (k) => k, empty = "No data yet.", href } = {}) { if (!entries.length) return h("div", { class: "empty" }, empty); // Entries may be in a natural order (e.g. latency buckets), not by size. const peak = Math.max(1, ...entries.map((e) => e.count)); const sum = total || entries.reduce((a, e) => a + e.count, 0); return h("ol", { class: "bar-list" }, entries.map((e) => { const share = sum ? (e.count / sum) * 100 : 0; const name = label(e.key); const link = href ? href(e.key) : null; return h("li", { title: name + ": " + e.count.toLocaleString() + " (" + share.toFixed(1) + "%)" }, h("div", { class: "bar-text" }, link ? h("a", { href: link, class: "bar-label" }, name) : h("span", { class: "bar-label" }, name), h("span", { class: "bar-value" }, compact(e.count), h("span", { class: "muted" }, " · " + share.toFixed(share < 10 ? 1 : 0) + "%"))), h("div", { class: "bar-track" }, h("div", { class: "bar-fill", style: { width: Math.max(0.5, (e.count / peak) * 100) + "%" } }))); })); }