// SPDX-License-Identifier: GPL-3.0-or-later /* === SVG charts: score sparkline + severity bars — premium, compact, diagnostic === */ function fmtWhen(at) { const d = new Date(at); if (isNaN(d.getTime())) return ""; const p = (n) => String(n).padStart(2, "0"); return (d.getMonth() + 1) + "/" + d.getDate() + " " + p(d.getHours()) + ":" + p(d.getMinutes()); } /* Tiny inline sparkline for the hero meta row */ function miniSpark(data) { const W = 80, H = 18, pad = 2; const scores = data.map((r) => r.score); if (scores.length < 2) return ""; const min = Math.min(...scores), max = Math.max(...scores); const span = Math.max(1, max - min); const X = (i) => pad + (i * (W - 2 * pad)) / (scores.length - 1); const Y = (s) => H - pad - ((s - min) / span) * (H - 2 * pad); const line = scores.map((s, i) => X(i).toFixed(1) + "," + Y(s).toFixed(1)).join(" "); const last = scores[scores.length - 1], first = scores[0]; const color = last > first ? "var(--green)" : last < first ? "var(--red)" : "var(--muted2)"; return '' + ''; } function scoreChart(data) { const W = 240, H = 34, pad = 4; const scores = data.map((r) => r.score); const min = Math.min(...scores), max = Math.max(...scores); const span = Math.max(1, max - min); /* A flat series has no shape to show; pin it to the middle of the trace instead of drawing a hairline along the floor, which reads as a border. "Middle" is the score's own height though: a flat 100 belongs at the top of the plot, a flat 60 just above the floor. */ const flat = max === min; const X = (i) => pad + (i * (W - 2 * pad)) / (scores.length - 1); const Y = (s) => (flat ? H - pad - (Math.max(0, Math.min(100, s)) / 100) * (H - 2 * pad) : H - pad - ((s - min) / span) * (H - 2 * pad)); const pts = scores.map((s, i) => [X(i).toFixed(1), Y(s).toFixed(1)]); const last = scores[scores.length - 1]; const first = scores[0]; const dir = last > first ? { txt: "Improving", icon: "↗", cls: "up", col: "var(--green)" } : last < first ? { txt: "Declining", icon: "↘", cls: "down", col: "var(--red)" } : { txt: "Stable", icon: "→", cls: "flat", col: "var(--muted2)" }; const range = scores.length + " runs · " + first + " → " + last; const line = pts.map((p) => p.join(",")).join(" "); const area = "M" + line.replace(/ /g, " L") + " L" + (W - pad) + "," + (H - pad) + " L" + pad + "," + (H - pad) + " Z"; const dots = pts.map((p, i) => // A flat series has no shape, so each run gets a visible mark: twelve // readings in a row read as twelve readings, not as a stray line. The // mark is decorative; the transparent circle on top is the control. (flat ? '' : "") + '' + '' + scores[i] + "/100 · " + fmtWhen(data[i].at) + ' — click to show diff').join(""); const end = pts[pts.length - 1]; const thresh = (min < 50 && max > 50) ? '' : ""; // A floor under the trace: without it a flat series (same score every run) // draws a line in mid-air and reads as a divider rather than a reading. const floor = ''; // Hero for history: large score + direction, and the trace directly under // it — one instrument, the same shape-from-data language as the rail's // vitals pulse. The run window is printed under the severity chart beside // it, so the trace does not repeat it. const hero = '
' + last + '/100
' + '
' + dir.icon + ' ' + dir.txt + '' + range + '
'; const from = data[0] && fmtWhen(data[0].at); const to = data[data.length - 1] && fmtWhen(data[data.length - 1].at); return '
' + hero + '
' + (flat ? "" : floor + thresh + '') + '' + dots + '
' + '
' + last + '/100 ' + dir.txt + ' ' + range + '
' + '
'; } function severityChart(data) { const W = 280, H = 52, spad = 6, sgap = 3; const counts = data.map((r) => ({ high: (r.counts && r.counts.high) || 0, medium: (r.counts && r.counts.medium) || 0, info: (r.counts && r.counts.info) || 0, at: r.at, })); const maxTotal = Math.max(1, ...counts.map((c) => c.high + c.medium + c.info)); const n = counts.length; const bw = (W - 2 * spad - (n - 1) * sgap) / n; const totalH = H - 2 * spad; const bars = counts.map((c, i) => { const x = (spad + i * (bw + sgap)).toFixed(1); const iH = totalH * c.info / maxTotal; const mH = totalH * c.medium / maxTotal; const hH = totalH * c.high / maxTotal; const yInfo = (H - spad - iH).toFixed(1); const yMed = (H - spad - iH - mH).toFixed(1); const yHigh = (H - spad - iH - mH - hH).toFixed(1); const tip = "high: " + c.high + " · medium: " + c.medium + " · info: " + c.info; const when = fmtWhen(c.at); // Every run is one bar and every bar is dated: the segments carry the // counts, and a full-height transparent target on top of them makes the // whole column hoverable — a run with one observation is a 6-pixel bar, // which is not a target anyone can point at. The date leads the tooltip // because "which run is this?" is the question the chart cannot answer // by itself. return '' + '' + '' + '' + '' + "" + (when ? when + " \u00b7 " : "") + tip + " \u2014 click to show the diff"; }).join(""); const oldest = counts.length ? fmtWhen(counts[0].at) : ""; const newest = counts.length ? fmtWhen(counts[counts.length - 1].at) : ""; const axis = (oldest || newest) ? '
' + (oldest || "\u2014") + "" + (newest || "\u2014") + "
" : ""; return '
' + '
Findings by severity
' + '' + bars + '' + axis + '
needs attention warnings observations
' + '
'; }