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:
parent
1f99485ca3
commit
169678e1bc
2 changed files with 26 additions and 35 deletions
|
|
@ -14,7 +14,20 @@ const SEASON_NAMES = { djf: "Winter", mam: "Spring", jja: "Summer", son: "Fall"
|
||||||
const SEASON_ORDER = ["djf", "mam", "jja", "son"];
|
const SEASON_ORDER = ["djf", "mam", "jja", "son"];
|
||||||
|
|
||||||
const tintColor = (cls) => TIER_COLORS[cls] || "";
|
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 selected = null; // {lat, lon}
|
||||||
let lastData = null; // most recent /score response, for repainting on a unit switch
|
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 ----
|
// ---- hero: the overall annual score ----
|
||||||
scoreHead.innerHTML = `
|
scoreHead.innerHTML = `
|
||||||
<div class="score-hero" style="--cat:${tintColor(ov.class)}">
|
<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">
|
<div class="score-hero-row">
|
||||||
<span class="score-num">${ov.score}</span>
|
<span class="score-num">${ov.score}</span>
|
||||||
<div class="score-hero-label">
|
<div class="score-hero-label">
|
||||||
|
|
@ -122,12 +135,8 @@ function render(data) {
|
||||||
`<th>${shortLabel(k)}</th>`).join("")}</tr>`;
|
`<th>${shortLabel(k)}</th>`).join("")}</tr>`;
|
||||||
const seasonRows = SEASON_ORDER.map((sk) => {
|
const seasonRows = SEASON_ORDER.map((sk) => {
|
||||||
const sl = s.slices[sk];
|
const sl = s.slices[sk];
|
||||||
const oc = sl.overall;
|
const cells = METRIC_ORDER.map((k) => scoreCell(sl.metrics[k])).join("");
|
||||||
const overallCell = oc
|
return `<tr><th>${SEASON_NAMES[sk]}</th>${scoreCell(sl.overall, true)}${cells}</tr>`;
|
||||||
? `<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>`;
|
|
||||||
}).join("");
|
}).join("");
|
||||||
|
|
||||||
scoreBody.innerHTML = `
|
scoreBody.innerHTML = `
|
||||||
|
|
@ -135,7 +144,7 @@ function render(data) {
|
||||||
${wbNote}
|
${wbNote}
|
||||||
<section class="score-section">
|
<section class="score-section">
|
||||||
<p class="section-title">By season</p>
|
<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>
|
<thead>${seasonHead}</thead><tbody>${seasonRows}</tbody></table></div>
|
||||||
</section>
|
</section>
|
||||||
<button type="button" class="summary-toggle" aria-expanded="false" aria-controls="score-summary">
|
<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 thr = fmtTemp(m.freq.threshold_f);
|
||||||
const { f6, f45, d } = m.freq;
|
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}
|
return `<p class="score-callout">${def}
|
||||||
Heat-stress days (peak wet bulb ≥ ${esc(String(thr))}): <strong>${f6}%</strong> of recent days
|
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>`;
|
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
|
// 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.
|
// "Extreme shift — windier" never clusters in the corner on a narrow card.
|
||||||
function metricCard(m) {
|
function metricCard(m) {
|
||||||
if (!m) return "";
|
|
||||||
if (m.score == null) {
|
if (m.score == null) {
|
||||||
return `<section class="normal-card score-card">
|
return `<section class="normal-card score-card">
|
||||||
<h3>${esc(m.label)}</h3>
|
<h3>${esc(m.label)}</h3>
|
||||||
|
|
@ -187,11 +195,6 @@ function scoreDetail(m) {
|
||||||
return `~${Math.abs(m.bias).toFixed(0)} pts ${esc(m.direction)}`;
|
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",
|
const shortLabel = (k) => ({ precip: "Precip", tmax: "High", tmin: "Low", feels: "Feels",
|
||||||
humid: "Humid", wetbulb: "Wet bulb", wind: "Wind", gust: "Gust" })[k] || k;
|
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) =>
|
const head = `<tr><th>Metric</th>${slices.map((sk) =>
|
||||||
`<th>${sk === "annual" ? "Annual" : SEASON_NAMES[sk]}</th>`).join("")}</tr>`;
|
`<th>${sk === "annual" ? "Annual" : SEASON_NAMES[sk]}</th>`).join("")}</tr>`;
|
||||||
const rows = METRIC_ORDER.map((k) => {
|
const rows = METRIC_ORDER.map((k) => {
|
||||||
const cells = slices.map((sk) => {
|
const cells = slices.map((sk) => scoreCell(s.slices[sk].metrics[k])).join("");
|
||||||
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 label = s.slices.annual.metrics[k]?.label || k;
|
const label = s.slices.annual.metrics[k]?.label || k;
|
||||||
return `<tr><th>${esc(label)}</th>${cells}</tr>`;
|
return `<tr><th>${esc(label)}</th>${cells}</tr>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
const overallCells = slices.map((sk) => {
|
const overallCells = slices.map((sk) => scoreCell(s.slices[sk].overall, true)).join("");
|
||||||
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 overallRow = `<tr class="score-table-overall"><th>Overall</th>${overallCells}</tr>`;
|
const overallRow = `<tr class="score-table-overall"><th>Overall</th>${overallCells}</tr>`;
|
||||||
return `<p class="section-title">All scores</p>
|
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>`;
|
<thead>${head}</thead><tbody>${rows}${overallRow}</tbody></table></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -246,14 +241,13 @@ function perQTableHTML(annual) {
|
||||||
.map((k) => annual.metrics[k])
|
.map((k) => annual.metrics[k])
|
||||||
.filter((m) => m && m.score != null && m.per_q && m.per_q.length)
|
.filter((m) => m && m.score != null && m.per_q && m.per_q.length)
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
const cells = m.per_q.map((q) =>
|
const cells = m.per_q.map((q) => `<td>p${q.q}: ${signedPts(q.d)}</td>`).join("");
|
||||||
`<td>p${q.q}: <span class="pq-d ${q.d >= 0 ? "pq-up" : "pq-down"}">${q.d >= 0 ? "+" : ""}${q.d}</span></td>`).join("");
|
|
||||||
return `<tr><th>${esc(m.label)}</th>${cells}</tr>`;
|
return `<tr><th>${esc(m.label)}</th>${cells}</tr>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
if (!rows) return "";
|
if (!rows) return "";
|
||||||
return `<p class="section-title">Shift by percentile band</p>
|
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>
|
<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.
|
// Mirror of shared.js initSeasonExpand: flip [hidden] + aria-expanded.
|
||||||
|
|
|
||||||
|
|
@ -1712,10 +1712,7 @@ td.rec-date { color: var(--muted); font-size: 13px; }
|
||||||
background: var(--surface); border: 1px solid var(--border); border-radius: 14px;
|
background: var(--surface); border: 1px solid var(--border); border-radius: 14px;
|
||||||
padding: 18px 20px; margin: 4px 0 18px;
|
padding: 18px 20px; margin: 4px 0 18px;
|
||||||
}
|
}
|
||||||
.score-eyebrow {
|
.score-eyebrow { margin: 0 0 8px; } /* else styled by .section-title */
|
||||||
font-size: 12px; text-transform: uppercase; letter-spacing: .05em;
|
|
||||||
color: var(--muted); margin: 0 0 8px;
|
|
||||||
}
|
|
||||||
.score-hero-row { display: flex; align-items: center; gap: 16px; }
|
.score-hero-row { display: flex; align-items: center; gap: 16px; }
|
||||||
.score-num {
|
.score-num {
|
||||||
font-size: 52px; font-weight: 800; line-height: 1;
|
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 { margin: 8px 0 18px; padding-left: 20px; }
|
||||||
.score-sentences li { font-size: 14px; line-height: 1.5; margin-bottom: 8px; color: var(--text); }
|
.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 { border-collapse: collapse; font-size: 13px; }
|
||||||
.score-table th, .score-table td {
|
.score-table th, .score-table td {
|
||||||
padding: 6px 10px; border-bottom: 1px solid var(--border); text-align: center; white-space: nowrap;
|
padding: 6px 10px; border-bottom: 1px solid var(--border); text-align: center; white-space: nowrap;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue