diff --git a/tests/js/dashboard_e2e.mjs b/tests/js/dashboard_e2e.mjs index 81294d0..bf84bb0 100644 --- a/tests/js/dashboard_e2e.mjs +++ b/tests/js/dashboard_e2e.mjs @@ -170,6 +170,28 @@ try { check(heat.distinct > 1, `matrix is not a heatmap: only ${heat.distinct} distinct fill colour(s)`); ok(`heatmap rendered in ${matrixMs}ms: ${heat.filled} filled cells, ${heat.distinct} distinct colours (e.g. ${JSON.stringify(heat.sample)})`); + // the heatmap is self-explanatory: a colour-scale legend with a gradient + // bar, the metric's min/max, a scale label and the SOTA marker. + const legend = await page.evaluate(() => { + const l = document.querySelector(".matrix-legend"); + if (!l) return null; + return { + label: (l.querySelector(".legend-label")?.textContent || "").trim(), + min: (l.querySelector(".legend-min")?.textContent || "").trim(), + max: (l.querySelector(".legend-max")?.textContent || "").trim(), + hasBar: !!l.querySelector(".legend-bar"), + sota: (l.querySelector(".legend-sota")?.textContent || "").trim(), + }; + }); + check(!!legend, "heatmap legend missing"); + if (legend) { + check(legend.label.length > 0, "legend has no scale label"); + check(legend.hasBar, "legend has no gradient bar"); + check(/^\d/.test(legend.min) && /^\d/.test(legend.max), `legend min/max not numeric: ${legend.min}/${legend.max}`); + check(legend.sota.length > 0, "legend has no SOTA marker label"); + ok(`heatmap legend: "${legend.label}" ${legend.min}→${legend.max}, bar + SOTA marker present`); + } + // switch the metric and confirm it stays a heatmap (updateMatrixMetric path) const hasSelect = await page.$(".matrix-metric-select"); if (hasSelect) { diff --git a/web/static/css/style.css b/web/static/css/style.css index f7de9bf..7fa28a2 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -945,6 +945,7 @@ header#topBar { .matrix-controls { display: flex; align-items: center; + flex-wrap: wrap; gap: var(--space-12); margin-bottom: var(--space-12); font-size: var(--text-base); @@ -1006,6 +1007,37 @@ header#topBar { .cell-sota { background: var(--green-dim); color: var(--green); font-weight: 700; } .cell-empty { background: var(--bg-base); color: var(--text-muted); } +/* ── Heatmap colour-scale legend ──────────────────────────────────── */ +.matrix-legend { + display: inline-flex; + align-items: center; + gap: var(--space-6); + font-size: var(--text-2xs); + color: var(--text-dim); +} +.matrix-legend .legend-label { font-weight: 600; } +.matrix-legend .legend-min, .matrix-legend .legend-max { font-variant-numeric: tabular-nums; } +.matrix-legend .legend-bar { + width: 72px; + height: 9px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: linear-gradient(to right, var(--heat-lo), var(--heat-hi)); +} +.matrix-legend .legend-sota { + display: inline-flex; + align-items: center; + gap: var(--space-4); + margin-left: var(--space-6); +} +.matrix-legend .legend-sota-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--green-dim); + border: 1.5px solid var(--green); +} + /* ── Gaps in Evidence tab ─────────────────────────────────────────── */ .gaps-list { display: flex; flex-direction: column; gap: 8px; } diff --git a/web/static/js/app.js b/web/static/js/app.js index 05811f7..2458781 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -991,11 +991,28 @@ function buildMatrixHeat(matrix, metric) { if (v > max) max = v; } const span = max - min; - return (value) => { + const map = (value) => { if (value == null) return ''; const t = span > 0 ? (Number(value) - min) / span : 0.5; return _lerpRgb(lo, hi, Math.max(0, Math.min(1, t))); }; + // Expose the range so the legend can label the gradient endpoints. + map.min = min; + map.max = max; + return map; +} + +// The colour-scale legend that makes the heatmap self-explanatory: a low→high +// gradient bar tagged with the metric's actual min/max, plus the SOTA marker. +function matrixHeatLegendHtml(heat) { + const hasRange = isFinite(heat.min) && isFinite(heat.max); + return ` + ${esc(tr('evidence.heatScale'))} + ${hasRange ? heat.min.toFixed(1) : ''} + + ${hasRange ? heat.max.toFixed(1) : ''} + ${esc(tr('evidence.heatSota'))} + `; } function renderMatrix(container, matrix) { @@ -1022,6 +1039,7 @@ function renderMatrix(container, matrix) { } html += ''; html += `${esc(tr('common.methodsCount', { count: matrix.methods.length }))} x ${esc(tr('common.datasetsCount', { count: matrix.datasets.length }))}`; + html += matrixHeatLegendHtml(heat); html += ''; html += '