Simplify the score module and page: dedup builders, consolidate metric metadata (#209)

Backend (scoring.py):
- Fold the parallel WEIGHTS/METRIC_LABELS/DIRECTION_WORDS/TEMP_DIR_METRICS maps
  into one METRICS descriptor so a scored metric is defined in one place.
- Share one _build_entry between the seasonal and annual paths.
- Add _freq_for so the annual entry reads the wet-day / heat-stress-day share
  directly instead of re-running the full precip_divergence just for it.
- Drop unconsumed payload: per-q v6/pct and the overall bias.
- Drop _slice's dead 'annual' branch; derive SLICES from SEASONS.

Frontend (score.js):
- One scoreCell() and signedPts() helper for the four score-cell sites and the
  two signed-points chips; reuse .section-title / .table-wrap instead of cloning.

Behavior-preserving (identical scores); bumps the score cache version.
This commit is contained in:
Emi Griffith 2026-07-19 21:02:40 -07:00 committed by GitHub
parent 1f99485ca3
commit 169678e1bc
2 changed files with 26 additions and 35 deletions

View file

@ -14,7 +14,20 @@ const SEASON_NAMES = { djf: "Winter", mam: "Spring", jja: "Summer", son: "Fall"
const SEASON_ORDER = ["djf", "mam", "jja", "son"];
const tintColor = (cls) => TIER_COLORS[cls] || "";
const tintClass = (cls) => (cls ? `t-${cls}` : "t-none");
const tintClass = (cls) => `t-${cls}`;
// A signed-points chip, colored up (▲) or down (▼): "+3" / "-2".
const signedPts = (d, suffix = "") =>
`<span class="pq-d ${d >= 0 ? "pq-up" : "pq-down"}">${d >= 0 ? "+" : ""}${d}${suffix}</span>`;
// One tinted score cell (an em-dash when the entry has no score). `strong` bolds
// the number for an Overall row/column; the tint + hover title come from the entry.
function scoreCell(entry, strong = false) {
if (!entry || entry.score == null) return `<td class="t-none">—</td>`;
const title = entry.label ? `${entry.label}: ${entry.grade}` : entry.grade;
const num = strong ? `<strong>${entry.score}</strong>` : `${entry.score}`;
return `<td class="${tintClass(entry.class)}" title="${esc(title)}">${num}</td>`;
}
let selected = null; // {lat, lon}
let lastData = null; // most recent /score response, for repainting on a unit switch
@ -101,7 +114,7 @@ function render(data) {
// ---- hero: the overall annual score ----
scoreHead.innerHTML = `
<div class="score-hero" style="--cat:${tintColor(ov.class)}">
<p class="score-eyebrow">Climate-shift score · ${esc(place)}</p>
<p class="section-title score-eyebrow">Climate-shift score · ${esc(place)}</p>
<div class="score-hero-row">
<span class="score-num">${ov.score}</span>
<div class="score-hero-label">
@ -122,12 +135,8 @@ function render(data) {
`<th>${shortLabel(k)}</th>`).join("")}</tr>`;
const seasonRows = SEASON_ORDER.map((sk) => {
const sl = s.slices[sk];
const oc = sl.overall;
const overallCell = oc
? `<td class="${tintClass(oc.class)}"><strong>${oc.score}</strong></td>`
: `<td class="t-none">—</td>`;
const cells = METRIC_ORDER.map((k) => seasonCell(sl.metrics[k])).join("");
return `<tr><th>${SEASON_NAMES[sk]}</th>${overallCell}${cells}</tr>`;
const cells = METRIC_ORDER.map((k) => scoreCell(sl.metrics[k])).join("");
return `<tr><th>${SEASON_NAMES[sk]}</th>${scoreCell(sl.overall, true)}${cells}</tr>`;
}).join("");
scoreBody.innerHTML = `
@ -135,7 +144,7 @@ function render(data) {
${wbNote}
<section class="score-section">
<p class="section-title">By season</p>
<div class="score-table-wrap"><table class="score-table season-table">
<div class="table-wrap"><table class="score-table season-table">
<thead>${seasonHead}</thead><tbody>${seasonRows}</tbody></table></div>
</section>
<button type="button" class="summary-toggle" aria-expanded="false" aria-controls="score-summary">
@ -155,7 +164,7 @@ function wetbulbNote(m, baseYrs) {
}
const thr = fmtTemp(m.freq.threshold_f);
const { f6, f45, d } = m.freq;
const delta = d ? ` <span class="pq-d ${d >= 0 ? "pq-up" : "pq-down"}">(${d >= 0 ? "+" : ""}${d} pts)</span>` : "";
const delta = d ? ` (${signedPts(d, " pts")})` : "";
return `<p class="score-callout">${def}
Heat-stress days (peak wet bulb ${esc(String(thr))}): <strong>${f6}%</strong> of recent days
vs <strong>${f45}%</strong> across ${baseYrs}${delta}. The rest are normal.</p>`;
@ -165,7 +174,6 @@ function wetbulbNote(m, baseYrs) {
// their own lines (rather than crammed top-right), so a long grade like
// "Extreme shift — windier" never clusters in the corner on a narrow card.
function metricCard(m) {
if (!m) return "";
if (m.score == null) {
return `<section class="normal-card score-card">
<h3>${esc(m.label)}</h3>
@ -187,11 +195,6 @@ function scoreDetail(m) {
return `~${Math.abs(m.bias).toFixed(0)} pts ${esc(m.direction)}`;
}
function seasonCell(m) {
if (!m || m.score == null) return `<td class="t-none">—</td>`;
return `<td class="${tintClass(m.class)}" title="${esc(m.label)}: ${esc(m.grade)}">${m.score}</td>`;
}
const shortLabel = (k) => ({ precip: "Precip", tmax: "High", tmin: "Low", feels: "Feels",
humid: "Humid", wetbulb: "Wet bulb", wind: "Wind", gust: "Gust" })[k] || k;
@ -220,22 +223,14 @@ function scoreTableHTML(s) {
const head = `<tr><th>Metric</th>${slices.map((sk) =>
`<th>${sk === "annual" ? "Annual" : SEASON_NAMES[sk]}</th>`).join("")}</tr>`;
const rows = METRIC_ORDER.map((k) => {
const cells = slices.map((sk) => {
const m = s.slices[sk].metrics[k];
if (!m || m.score == null) return `<td class="t-none">—</td>`;
return `<td class="${tintClass(m.class)}" title="${esc(m.grade)}">${m.score}</td>`;
}).join("");
const cells = slices.map((sk) => scoreCell(s.slices[sk].metrics[k])).join("");
const label = s.slices.annual.metrics[k]?.label || k;
return `<tr><th>${esc(label)}</th>${cells}</tr>`;
}).join("");
const overallCells = slices.map((sk) => {
const o = s.slices[sk].overall;
if (!o) return `<td class="t-none">—</td>`;
return `<td class="${tintClass(o.class)}"><strong>${o.score}</strong></td>`;
}).join("");
const overallCells = slices.map((sk) => scoreCell(s.slices[sk].overall, true)).join("");
const overallRow = `<tr class="score-table-overall"><th>Overall</th>${overallCells}</tr>`;
return `<p class="section-title">All scores</p>
<div class="score-table-wrap"><table class="score-table">
<div class="table-wrap"><table class="score-table">
<thead>${head}</thead><tbody>${rows}${overallRow}</tbody></table></div>`;
}
@ -246,14 +241,13 @@ function perQTableHTML(annual) {
.map((k) => annual.metrics[k])
.filter((m) => m && m.score != null && m.per_q && m.per_q.length)
.map((m) => {
const cells = m.per_q.map((q) =>
`<td>p${q.q}: <span class="pq-d ${q.d >= 0 ? "pq-up" : "pq-down"}">${q.d >= 0 ? "+" : ""}${q.d}</span></td>`).join("");
const cells = m.per_q.map((q) => `<td>p${q.q}: ${signedPts(q.d)}</td>`).join("");
return `<tr><th>${esc(m.label)}</th>${cells}</tr>`;
}).join("");
if (!rows) return "";
return `<p class="section-title">Shift by percentile band</p>
<p class="score-note">The mean shift at each percentile band, averaged across all four seasons (percentile points; higher / lower than the seasonal norm).</p>
<div class="score-table-wrap"><table class="score-table pq-table"><tbody>${rows}</tbody></table></div>`;
<div class="table-wrap"><table class="score-table pq-table"><tbody>${rows}</tbody></table></div>`;
}
// Mirror of shared.js initSeasonExpand: flip [hidden] + aria-expanded.

View file

@ -1712,10 +1712,7 @@ td.rec-date { color: var(--muted); font-size: 13px; }
background: var(--surface); border: 1px solid var(--border); border-radius: 14px;
padding: 18px 20px; margin: 4px 0 18px;
}
.score-eyebrow {
font-size: 12px; text-transform: uppercase; letter-spacing: .05em;
color: var(--muted); margin: 0 0 8px;
}
.score-eyebrow { margin: 0 0 8px; } /* else styled by .section-title */
.score-hero-row { display: flex; align-items: center; gap: 16px; }
.score-num {
font-size: 52px; font-weight: 800; line-height: 1;
@ -1769,7 +1766,7 @@ td.rec-date { color: var(--muted); font-size: 13px; }
.score-sentences { margin: 8px 0 18px; padding-left: 20px; }
.score-sentences li { font-size: 14px; line-height: 1.5; margin-bottom: 8px; color: var(--text); }
.score-table-wrap { overflow-x: auto; margin: 8px 0 20px; }
#score-body .table-wrap { margin: 8px 0 20px; } /* .table-wrap supplies overflow-x */
.score-table { border-collapse: collapse; font-size: 13px; }
.score-table th, .score-table td {
padding: 6px 10px; border-bottom: 1px solid var(--border); text-align: center; white-space: nowrap;