thermograph/frontend/static/score.js
Emi Griffith d6df04eab2 Subtree-merge thermograph-frontend (origin/main) into frontend/
git-subtree-dir: frontend
git-subtree-mainline: a4be7066e5
git-subtree-split: 3a98146da4
2026-07-22 22:01:11 -07:00

260 lines
11 KiB
JavaScript
Raw Permalink 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 an
// equal-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 { uv } from "./account.js"; // header sign-in entry + notification bell, and the API-version resolver
import { loadView, TTL, prefetchViews } from "./cache.js";
import { initFindButton, setFindLabel } from "./mappicker.js";
import { TIER_COLORS, esc, placeLabel } from "./shared.js";
// Display order (site convention leads with Precip).
const METRIC_ORDER = ["precip", "tmax", "tmin", "feels", "humid", "wetbulb", "wind", "gust"];
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) => `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
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;
await loadView({
url: uv(`score?lat=${selected.lat}&lon=${selected.lon}`),
ttl: TTL.score, seq: ++seqCounter, current: () => seqCounter,
spinner: () => {
scoreHead.innerHTML = `<p class="spinner">Scoring 45 years of climate…</p>`;
scoreBody.innerHTML = "";
},
onData: finishScore,
onError: (err) => {
scoreHead.innerHTML = `<p class="error">${esc(err.message)}</p>`;
scoreBody.innerHTML = "";
},
});
}
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="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">
<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. Every metric counts equally.</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 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 = `
<section class="normals">${cards}</section>
${wbNote}
<section class="score-section">
<p class="section-title">By season</p>
<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">
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 the lowest temperature that evaporating sweat can cool you to.
When it climbs, sweat can't shed heat fast enough and hot days 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 ? ` (${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>`;
}
// One metric score card. The tier and its direction stack under the score on
// 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.score == null) {
return `<section class="normal-card score-card">
<h3>${esc(m.label)}</h3>
<div class="big" style="--cat:var(--muted)">—</div>
<div class="range">${esc(m.reason || "no data")}</div>
</section>`;
}
return `<section class="normal-card score-card" style="--cat:${tintColor(m.class)}">
<h3>${esc(m.label)}</h3>
<div class="big">${m.score}</div>
<div class="nc-tier">${esc(m.tier)}</div>
<div class="range">${scoreDetail(m)}</div>
</section>`;
}
// The muted detail line under a card's tier — the signed drift, or a steady note.
function scoreDetail(m) {
if (m.score < 15) return "close to the long-term normal";
return `~${Math.abs(m.bias).toFixed(0)} pts ${esc(m.direction)}`;
}
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 ? ` At the median, recent readings sit about ${Math.abs(mid.d)} points ${mid.d >= 0 ? "higher" : "lower"} than the seasonal norm.` : "";
return `<li><strong>${esc(m.label)}:</strong> ${esc(m.grade)}, about ${Math.abs(m.bias).toFixed(0)} percentile points ${esc(m.direction)} on average.${where}</li>`;
});
if (!lines.length) return `<p class="score-summary-lead">Every metric is close to its long-term normal here, with 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) => 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) => 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="table-wrap"><table class="score-table">
<thead>${head}</thead><tbody>${rows}${overallRow}</tbody></table></div>`;
}
// The per-percentile detail: for each band, the mean shift (in percentile points)
// averaged across the four seasons — the differentials the score is built from.
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 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="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();
}
})();