thermograph/static/score.js
Emi Griffith 91a780dd55 Align the by-season scores into a columns-aligned table (#198)
The by-season chips wrapped freely, so a metric's scores didn't line up
across seasons. Render it as a table instead — seasons as rows, one aligned
column per metric (plus an Overall column), tinted cells, horizontal scroll
on narrow screens.
2026-07-19 23:41:03 +00:00

282 lines
12 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">Net change · ${recentYrs} vs ${baseYrs} · ${s.n_recent.toLocaleString()} recent days scored</span>
</div>
</div>
<p class="score-note">A net-change total across all metrics: 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("");
const wbNote = wetbulbNote(annual.metrics.wetbulb, baseYrs);
// ---- by-season table (seasons × metrics, columns aligned) ----
const seasonHead = `<tr><th></th><th>Overall</th>${METRIC_ORDER.map((k) =>
`<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>`;
}).join("");
scoreBody.innerHTML = `
<section class="normals">${cards}</section>
${wbNote}
<section class="score-section">
<p class="section-title">By season</p>
<div class="score-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">
Summary <span class="summary-caret" aria-hidden="true">▾</span></button>
<section id="score-summary" hidden>${summaryHTML(s)}</section>`;
wireSummaryToggle();
}
// Explains what wet bulb is and reports the share of heat-stress ("wet-bulb")
// days vs normal days, recent vs baseline. Re-rendered on unit change.
function wetbulbNote(m, baseYrs) {
const def = `<strong>Wet bulb</strong> is how cool evaporation — like sweat — can make you in the
current air. When it climbs, sweat stops shedding heat and high temperatures turn dangerous.`;
if (!m || m.score == null || !m.freq) {
return `<p class="score-callout">${def}</p>`;
}
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>` : "";
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>`;
}
// 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 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;
// ---- 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();
}
})();