From a8e04f5743201f643c9808d3e3c1c6838b23cdf4 Mon Sep 17 00:00:00 2001 From: Protocol Zero <257158451+Protocol-zero-0@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:36:55 +0000 Subject: [PATCH] Evidence heatmap: add a colour-scale legend so it's self-explanatory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a low→high gradient bar tagged with the selected metric's actual min/max, a 'Cell colour = value (per metric)' caption, and the SOTA marker, so the heatmap explains itself without hovering. The legend's min/max stay in sync when the metric is switched. buildMatrixHeat now exposes its min/max; en/zh strings added in equal sets. (Originally f243b7d, briefly committed to master by mistake during concurrent release work, reverted off master in 29c5d08, re-submitted here for review.) Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/js/dashboard_e2e.mjs | 22 ++++++++++++++++++++++ web/static/css/style.css | 32 ++++++++++++++++++++++++++++++++ web/static/js/app.js | 30 +++++++++++++++++++++++++++++- web/static/js/i18n.js | 4 ++++ 4 files changed, 87 insertions(+), 1 deletion(-) 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 += '
'; @@ -1062,6 +1080,16 @@ function updateMatrixMetric(selectEl) { const metric = selectEl.value; const heat = buildMatrixHeat(matrix, metric); + + // Keep the legend's gradient endpoints in sync with the selected metric. + const minEl = container.querySelector('.matrix-legend .legend-min'); + const maxEl = container.querySelector('.matrix-legend .legend-max'); + if (minEl && maxEl) { + const hasRange = isFinite(heat.min) && isFinite(heat.max); + minEl.textContent = hasRange ? heat.min.toFixed(1) : ''; + maxEl.textContent = hasRange ? heat.max.toFixed(1) : ''; + } + const rows = container.querySelectorAll('tbody tr'); rows.forEach((row, mi) => { diff --git a/web/static/js/i18n.js b/web/static/js/i18n.js index 4445b18..48e0619 100644 --- a/web/static/js/i18n.js +++ b/web/static/js/i18n.js @@ -83,6 +83,8 @@ "evidence.select": "Select research area:", "evidence.option": "-- Select a leaf research area --", "evidence.hint": "Select a leaf research area to view the benchmark matrix.", + "evidence.heatScale": "Cell colour = value (per metric)", + "evidence.heatSota": "best (SOTA)", "evidence.gaps": "Matrix Gaps", "papers.title": "Source Paper", "papers.filter": "Filter source papers...", @@ -524,6 +526,8 @@ "evidence.select": "选择研究领域:", "evidence.option": "-- 选择叶子研究领域 --", "evidence.hint": "选择叶子研究领域以查看基准矩阵。", + "evidence.heatScale": "格子颜色 = 数值高低(按指标)", + "evidence.heatSota": "最佳(SOTA)", "evidence.gaps": "矩阵空白", "papers.title": "文献", "papers.filter": "筛选文献...",