Score each metric on the average of its seasonal differentials (#200)

Annual scores were computed from an all-year pooled distribution, which widens
the reference spread and hides a shift confined to one season — Seattle's daily
high read 16 despite a summer high of 65. Build each metric's annual score
(and the overall) as the mean of its four seasonal divergences instead, so a
real seasonal shift shows through (that high now reads 28). Frequency read-outs
(precip wet days, wet-bulb heat-stress days) stay pooled over the year.

Also lock the by-season table to fixed, uniform columns (min-width to scroll on
a phone) so each metric lines up vertically across the seasons, and show the
per-percentile detail as the season-averaged shift.

Bumps the score cache version.
This commit is contained in:
Emi Griffith 2026-07-19 16:57:55 -07:00 committed by GitHub
parent c6b2e55ff2
commit c008ece671
2 changed files with 14 additions and 17 deletions

View file

@ -6,15 +6,10 @@ 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";
import { TIER_COLORS, 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.
// Display order (site convention leads with Precip).
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"];
@ -210,8 +205,8 @@ function sentencesHTML(annual) {
.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>`;
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 — no notable recent drift.</p>`;
return `<p class="score-summary-lead">What has shifted the most:</p><ul class="score-sentences">${lines.join("")}</ul>`;
@ -242,21 +237,20 @@ function scoreTableHTML(s) {
<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.
// 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 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("");
`<td>p${q.q}: <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>
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="score-table-wrap"><table class="score-table pq-table"><tbody>${rows}</tbody></table></div>`;
}

View file

@ -1737,8 +1737,11 @@ td.rec-date { color: var(--muted); font-size: 13px; }
padding: 12px 14px; margin: 14px 0 4px; font-size: 13px; line-height: 1.5; color: var(--text);
}
/* By-season scores: seasons as rows, one aligned column per metric. */
.season-table th:first-child, .season-table td:first-child { text-align: left; }
/* By-season scores: seasons as rows, one uniform column per metric so every
metric lines up vertically across the seasons. Fixed layout keeps the columns
equal width; the min-width lets it scroll (not squash) on a phone. */
.season-table { table-layout: fixed; width: 100%; min-width: 620px; max-width: 740px; }
.season-table th:first-child, .season-table td:first-child { width: 62px; text-align: left; }
/* Summary reveal — pill button + caret, mirrors the season-expand pattern. */
.summary-toggle {