thermograph/static/score.js
Emi Griffith c2d9b602dd Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).

- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
  split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
  the humidity column is converted to absolute — via a shared _derive_metrics
  wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
  a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
  button-revealed summary (sentences + metrics×season table + per-percentile
  detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00

266 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Thermograph climate-score subpage — for one location, shows how far the last 6
// years have drifted from the full 45-year record, per metric and season, with a
// weighted overall score and a button-revealed summary. Talks to /api/v2/score.
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
import { fmtTemp, onUnitChange } from "./units.js";
import "./account.js"; // header sign-in entry + notification bell
import { getJSON, TTL, prefetchViews } from "./cache.js";
import { initFindButton, setFindLabel } from "./mappicker.js";
import { TIER_COLORS, pctOrd, fmtPrecip, fmtWind, fmtHumid, esc, placeLabel } from "./shared.js";
// Display order (site convention leads with Precip), and how each metric's raw
// per-percentile value is formatted in the detail table.
const METRIC_ORDER = ["precip", "tmax", "tmin", "feels", "humid", "wetbulb", "wind", "gust"];
const FMT = {
tmax: fmtTemp, tmin: fmtTemp, feels: fmtTemp, wetbulb: fmtTemp,
humid: fmtHumid, wind: fmtWind, gust: fmtWind, precip: fmtPrecip,
};
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");
let selected = null; // {lat, lon}
let lastData = null; // most recent /score response, for repainting on a unit switch
const placeholder = document.getElementById("score-placeholder");
const scoreHead = document.getElementById("score-head");
const scoreBody = document.getElementById("score-body");
// ---- location picker ----
const findBtn = document.getElementById("find-btn");
const locLabel = document.getElementById("loc-label");
initFindButton(findBtn, "Find a location", () => selected,
(lat, lon) => {
selected = { lat, lon };
locLabel.textContent = `${lat.toFixed(3)}, ${lon.toFixed(3)}`;
locLabel.hidden = false;
fetchScore();
});
// ---- fetch + render ----
let seqCounter = 0; // supersedes an in-flight load when the user picks a new spot
async function fetchScore() {
if (!selected) return;
history.replaceState(null, "", locHash(selected.lat, selected.lon));
placeholder.hidden = true;
scoreHead.hidden = false;
const seq = ++seqCounter;
// Delay the spinner: a cache-served render lands in a few ms, so blanking the
// page immediately would just flash. Only a slow (network) load shows it.
const spin = setTimeout(() => {
if (seq !== seqCounter) return;
scoreHead.innerHTML = `<p class="spinner">Scoring 45 years of climate…</p>`;
scoreBody.innerHTML = "";
}, 150);
let data;
try {
// Stale-while-revalidate: a stale cached copy renders immediately; the update
// callback repaints if the background revalidation finds new data.
data = await getJSON(
`api/v2/score?lat=${selected.lat}&lon=${selected.lon}`, TTL.score,
false, (upd) => { if (seq === seqCounter) finishScore(upd); });
} catch (err) {
clearTimeout(spin);
if (seq !== seqCounter) return;
scoreHead.innerHTML = `<p class="error">${esc(err.message)}</p>`;
scoreBody.innerHTML = "";
return;
}
clearTimeout(spin);
if (seq !== seqCounter) return;
finishScore(data);
}
function finishScore(data) {
saveLastLocation(selected.lat, selected.lon);
render(data);
prefetchViews(selected.lat, selected.lon, []); // warm the other views
}
onUnitChange(() => { if (lastData) render(lastData); });
function render(data) {
lastData = data;
const place = placeLabel(data);
locLabel.textContent = place;
locLabel.hidden = false;
setFindLabel(findBtn, "Change location");
const s = data.scores;
if (s.unavailable) {
scoreHead.innerHTML = `<div class="score-hero"><h2>${esc(place)}</h2>
<p class="score-sub">${esc(s.unavailable)}</p></div>`;
scoreBody.innerHTML = "";
return;
}
const annual = s.slices.annual;
const ov = annual.overall;
const rr = s.recent_range, br = s.baseline_range;
const recentYrs = `${rr[0].slice(0, 4)}${rr[1].slice(0, 4)}`;
const baseYrs = `${br[0].slice(0, 4)}${br[1].slice(0, 4)}`;
// ---- 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>
<div class="score-hero-row">
<span class="score-num">${ov.score}</span>
<div class="score-hero-label">
<span class="score-grade">${esc(ov.grade)}</span>
<span class="score-sub">${recentYrs} vs ${baseYrs} · ${s.n_recent.toLocaleString()} recent days scored</span>
</div>
</div>
<p class="score-note">0 = no change from the long-term normal · 100 = an extreme shift.
Temperature, feels-like and humidity are weighted most.</p>
</div>`;
// ---- per-metric cards (annual) ----
const cards = METRIC_ORDER.map((k) => metricCard(annual.metrics[k])).join("");
// ---- by-season rows ----
const seasonRows = SEASON_ORDER.map((sk) => {
const sl = s.slices[sk];
const chips = METRIC_ORDER.map((k) => seasonChip(sl.metrics[k])).join("");
const oc = sl.overall;
const overallChip = oc
? `<span class="score-chip ${tintClass(oc.class)}" title="Overall">${oc.score}</span>`
: `<span class="score-chip t-none" title="Overall">—</span>`;
return `<div class="season-scores">
<span class="season-scores-name">${SEASON_NAMES[sk]}</span>
<span class="season-scores-overall">${overallChip}</span>
<span class="season-scores-chips">${chips}</span>
</div>`;
}).join("");
scoreBody.innerHTML = `
<section class="normals">${cards}</section>
<section class="score-section">
<p class="section-title">By season</p>
<p class="score-legend">${METRIC_ORDER.map((k) => shortLabel(k)).join(" · ")}</p>
${seasonRows}
</section>
<button type="button" class="summary-toggle" aria-expanded="false" aria-controls="score-summary">
Summary <span class="summary-caret" aria-hidden="true">▾</span></button>
<section id="score-summary" hidden>${summaryHTML(s)}</section>`;
wireSummaryToggle();
}
// One metric score card (reuses .normal-card). Null metrics show a muted reason.
function metricCard(m) {
if (!m) return "";
if (m.score == null) {
return `<section class="normal-card">
<div class="nc-head"><h3>${esc(m.label)}</h3></div>
<div class="big" style="--cat:var(--muted)">—</div>
<div class="range">${esc(m.reason || "no data")}</div>
</section>`;
}
return `<section class="normal-card" style="--cat:${tintColor(m.class)}">
<div class="nc-head"><h3>${esc(m.label)}</h3><span class="nc-grade">${esc(m.grade)}</span></div>
<div class="big">${m.score}</div>
<div class="range">${scoreDirection(m)}</div>
</section>`;
}
// A short "shifted N pts warmer" line for a metric card.
function scoreDirection(m) {
if (m.score < 15) return "steady vs the long-term normal";
const pts = Math.abs(m.bias).toFixed(0);
return `~${pts} pts ${esc(m.direction)}`;
}
function seasonChip(m) {
if (!m || m.score == null) return `<span class="score-chip t-none">—</span>`;
return `<span class="score-chip ${tintClass(m.class)}" title="${esc(m.label)}: ${esc(m.grade)}">${m.score}</span>`;
}
const shortLabel = (k) => ({ precip: "Precip", tmax: "High", tmin: "Low", feels: "Feels",
humid: "Humid", wetbulb: "Wet bulb", wind: "Wind", gust: "Gust" })[k] || k;
// ---- summary (revealed by the button) ----
function summaryHTML(s) {
return sentencesHTML(s.slices.annual) + scoreTableHTML(s) + perQTableHTML(s.slices.annual);
}
// A plain-language sentence per metric that actually shifted.
function sentencesHTML(annual) {
const lines = METRIC_ORDER
.map((k) => annual.metrics[k])
.filter((m) => m && m.score != null && m.score >= 15)
.map((m) => {
const mid = m.per_q && m.per_q.find((q) => q.q === 50);
const where = mid ? ` Its recent median now lands near the ${pctOrd(mid.pct)} percentile of the full record.` : "";
return `<li><strong>${esc(m.label)}:</strong> ${esc(m.grade)} — about ${Math.abs(m.bias).toFixed(0)} percentile points ${esc(m.direction)}.${where}</li>`;
});
if (!lines.length) return `<p class="score-summary-lead">Every metric is close to its long-term normal here — no notable recent drift.</p>`;
return `<p class="score-summary-lead">What has shifted the most:</p><ul class="score-sentences">${lines.join("")}</ul>`;
}
// Metrics × slices table of scores, each cell tinted by its tier.
function scoreTableHTML(s) {
const slices = ["annual", ...SEASON_ORDER];
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 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 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">
<thead>${head}</thead><tbody>${rows}${overallRow}</tbody></table></div>`;
}
// The annual per-percentile detail: for each category, the recent value and where
// it lands in the 45-year record.
function perQTableHTML(annual) {
const rows = METRIC_ORDER
.map((k) => annual.metrics[k])
.filter((m) => m && m.score != null && m.per_q && m.per_q.length)
.map((m) => {
const fmt = FMT[m.key] || ((v) => v);
const cells = m.per_q.map((q) =>
`<td>p${q.q}: ${esc(String(fmt(q.v6)))}${pctOrd(q.pct)} <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>`;
}).join("");
if (!rows) return "";
return `<p class="section-title">Annual percentile detail</p>
<p class="score-note">Each recent-years percentile value, and where it lands in the full record (▲/▼ = shift in percentile points).</p>
<div class="score-table-wrap"><table class="score-table pq-table"><tbody>${rows}</tbody></table></div>`;
}
// Mirror of shared.js initSeasonExpand: flip [hidden] + aria-expanded.
function wireSummaryToggle() {
const btn = scoreBody.querySelector(".summary-toggle");
const panel = scoreBody.querySelector("#score-summary");
if (!btn || !panel) return;
btn.addEventListener("click", () => {
const show = panel.hidden;
panel.hidden = !show;
btn.setAttribute("aria-expanded", String(show));
});
}
// ---- restore (hash from a shared link, else the last place you looked at) ----
(function restore() {
const loc = loadLastLocation();
if (loc) {
selected = { lat: loc.lat, lon: loc.lon };
fetchScore();
}
})();