${esc(ov.grade)}Net change · ${recentYrs} vs ${baseYrs} · ${s.n_recent.toLocaleString()} recent days scored
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.
${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 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 `
${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 (reuses .normal-card). Null metrics show a muted reason.
function metricCard(m) {
if (!m) return "";
if (m.score == null) {
return `
${esc(m.label)}
—
${esc(m.reason || "no data")}
`;
}
return `
${esc(m.label)}
${esc(m.grade)}
${m.score}
${scoreDirection(m)}
`;
}
// 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 `
—
`;
return `
${m.score}
`;
}
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 `
${esc(m.label)}: ${esc(m.grade)} — about ${Math.abs(m.bias).toFixed(0)} percentile points ${esc(m.direction)}.${where}
`;
});
if (!lines.length) return `
Every metric is close to its long-term normal here — 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 = `
`;
}).join("");
const overallCells = slices.map((sk) => {
const o = s.slices[sk].overall;
if (!o) return `
—
`;
return `
${o.score}
`;
}).join("");
const overallRow = `
Overall
${overallCells}
`;
return `
All scores
${head}${rows}${overallRow}
`;
}
// 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) =>
`