// 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 = "") =>
`${d >= 0 ? "+" : ""}${d}${suffix}`;
// 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 `
—
`;
const title = entry.label ? `${entry.label}: ${entry.grade}` : entry.grade;
const num = strong ? `${entry.score}` : `${entry.score}`;
return `
${num}
`;
}
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 = `
${summaryHTML(s)}`;
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 = `Wet bulb 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 `
${def}
Heat-stress days (peak wet bulb ≥ ${esc(String(thr))}): ${f6}% of recent days
vs ${f45}% across ${baseYrs}${delta}. The rest are normal.
`;
}
// 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 `
${esc(m.label)}
—
${esc(m.reason || "no data")}
`;
}
return `
${esc(m.label)}
${m.score}
${esc(m.tier)}
${scoreDetail(m)}
`;
}
// 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 `
${esc(m.label)}: ${esc(m.grade)}, about ${Math.abs(m.bias).toFixed(0)} percentile points ${esc(m.direction)} on average.${where}
`;
});
if (!lines.length) return `
Every metric is close to its long-term normal here, with no notable recent drift.
`;
return `
What has shifted the most:
${lines.join("")}
`;
}
// Metrics × slices table of scores, each cell tinted by its tier.
function scoreTableHTML(s) {
const slices = ["annual", ...SEASON_ORDER];
const head = `
`;
}
// 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) => `
p${q.q}: ${signedPts(q.d)}
`).join("");
return `
${esc(m.label)}
${cells}
`;
}).join("");
if (!rows) return "";
return `
Shift by percentile band
The mean shift at each percentile band, averaged across all four seasons (percentile points; ▲ higher / ▼ lower than the seasonal norm).
${rows}
`;
}
// 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();
}
})();