From c2d9b602dd668e1c646dce597624122a03476246 Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Sun, 19 Jul 2026 16:02:33 -0700 Subject: [PATCH] Add a climate-score page from recent-vs-baseline percentile divergence (#196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Score how far a location's last 6 years have drifted from its full 45-year record. For each metric and percentile category (p10/p25/p50/p75/p90), the recent-years value is placed on the baseline distribution and the gap from the expected percentile is the divergence — unit-free, so metrics compare directly. Scored per meteorological season plus annual, weighted into per-metric and overall scores (temps, humidity and feels-like weighted heaviest). - backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation split (wet-day frequency + amount), tier mapping onto the existing temp scale. - climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before the humidity column is converted to absolute — via a shared _derive_metrics wrapper at all four read sites. - api/v2/score endpoint + build_score payload, cached on the history token with a scoring-version key. - frontend score page: overall hero, per-metric cards, by-season chips, and a button-revealed summary (sentences + metrics×season table + per-percentile detail). Score nav link across all headers. - Tests for the scoring math, wet-bulb formula, payload shape and route. --- static/cache.js | 2 +- static/calendar.html | 1 + static/compare.html | 1 + static/day.html | 1 + static/legend.html | 1 + static/score.html | 75 +++++++++++ static/score.js | 266 ++++++++++++++++++++++++++++++++++++++ static/style.css | 80 ++++++++++++ static/subscriptions.html | 1 + 9 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 static/score.html create mode 100644 static/score.js diff --git a/static/cache.js b/static/cache.js index a8b0df2..d55aa42 100644 --- a/static/cache.js +++ b/static/cache.js @@ -13,7 +13,7 @@ // etag is stored, so an unchanged payload costs an empty 304). // * network failure with any entry cached → the stale copy, rather than an error. -export const TTL = { grade: 600000, forecast: 1800000, calendar: 21600000, day: 600000 }; // calendar: 6h +export const TTL = { grade: 600000, forecast: 1800000, calendar: 21600000, day: 600000, score: 21600000 }; // calendar/score: 6h const IDB_NAME = "thermograph-cache", IDB_STORE = "resp"; const IDB_MAX_AGE = 14 * 86400000; // prune entries untouched for 2 weeks diff --git a/static/calendar.html b/static/calendar.html index a1e46c8..f319988 100644 --- a/static/calendar.html +++ b/static/calendar.html @@ -43,6 +43,7 @@ Calendar Day Detail Compare + Score Climate Alerts diff --git a/static/compare.html b/static/compare.html index 03a74d7..6d54c96 100644 --- a/static/compare.html +++ b/static/compare.html @@ -44,6 +44,7 @@ Calendar Day Detail Compare + Score Climate Alerts diff --git a/static/day.html b/static/day.html index 72296ce..cf0294e 100644 --- a/static/day.html +++ b/static/day.html @@ -44,6 +44,7 @@ Calendar Day Detail Compare + Score Climate Alerts diff --git a/static/legend.html b/static/legend.html index 786ac88..cf4598e 100644 --- a/static/legend.html +++ b/static/legend.html @@ -43,6 +43,7 @@ Calendar Day Detail Compare + Score Climate Alerts diff --git a/static/score.html b/static/score.html new file mode 100644 index 0000000..c75880d --- /dev/null +++ b/static/score.html @@ -0,0 +1,75 @@ + + + + + + Thermograph — climate score + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+

Thermograph

+

How far the last 6 years have drifted from 45 years of local climate

+
+ +
+
+ +
+
+
+ + +
+
+ + +
+

Pick a place — or open this from the map or calendar — to see how much its + recent climate has drifted from its long-term normal, metric by metric.

+
+
+
+ + + + + diff --git a/static/score.js b/static/score.js new file mode 100644 index 0000000..e1a9f5a --- /dev/null +++ b/static/score.js @@ -0,0 +1,266 @@ +// 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 = `

Scoring 45 years of climate…

`; + 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 = `

${esc(err.message)}

`; + 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 = `

${esc(place)}

+

${esc(s.unavailable)}

`; + 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 = ` +
+

Climate-shift score · ${esc(place)}

+
+ ${ov.score} +
+ ${esc(ov.grade)} + ${recentYrs} vs ${baseYrs} · ${s.n_recent.toLocaleString()} recent days scored +
+
+

0 = no change from the long-term normal · 100 = an extreme shift. + Temperature, feels-like and humidity are weighted most.

+
`; + + // ---- per-metric cards (annual) ---- + const cards = METRIC_ORDER.map((k) => metricCard(annual.metrics[k])).join(""); + + // ---- by-season rows ---- + const seasonRows = SEASON_ORDER.map((sk) => { + const sl = s.slices[sk]; + const chips = METRIC_ORDER.map((k) => seasonChip(sl.metrics[k])).join(""); + const oc = sl.overall; + const overallChip = oc + ? `${oc.score}` + : ``; + return `
+ ${SEASON_NAMES[sk]} + ${overallChip} + ${chips} +
`; + }).join(""); + + scoreBody.innerHTML = ` +
${cards}
+
+

By season

+

${METRIC_ORDER.map((k) => shortLabel(k)).join(" · ")}

+ ${seasonRows} +
+ + `; + + wireSummaryToggle(); +} + +// 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 seasonChip(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:

    `; +} + +// Metrics × slices table of scores, each cell tinted by its tier. +function scoreTableHTML(s) { + const slices = ["annual", ...SEASON_ORDER]; + const head = `Metric${slices.map((sk) => + `${sk === "annual" ? "Annual" : SEASON_NAMES[sk]}`).join("")}`; + const rows = METRIC_ORDER.map((k) => { + const cells = slices.map((sk) => { + const m = s.slices[sk].metrics[k]; + if (!m || m.score == null) return `—`; + return `${m.score}`; + }).join(""); + const label = s.slices.annual.metrics[k]?.label || k; + return `${esc(label)}${cells}`; + }).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) => + `p${q.q}: ${esc(String(fmt(q.v6)))} → ${pctOrd(q.pct)} (${q.d >= 0 ? "+" : ""}${q.d})`).join(""); + return `${esc(m.label)}${cells}`; + }).join(""); + if (!rows) return ""; + return `

    Annual percentile detail

    +

    Each recent-years percentile value, and where it lands in the full record (▲/▼ = shift in percentile points).

    +
    ${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(); + } +})(); diff --git a/static/style.css b/static/style.css index 6e51e54..938933a 100644 --- a/static/style.css +++ b/static/style.css @@ -1704,6 +1704,86 @@ td.rec-date { color: var(--muted); font-size: 13px; } so the colour reads as a chip around the number. */ .t-inline { padding: 1px 7px; border-radius: 7px; } +/* ---- Climate score page --------------------------------------------------- + Hero (overall score), per-metric cards reuse .normal-card, a by-season chip + grid, and a button-revealed summary with tinted score tables. All color comes + from the shared temperature scale via --cat / the .t-* washes. */ +.score-hero { + background: var(--surface); border: 1px solid var(--border); border-radius: 14px; + padding: 18px 20px; margin: 4px 0 18px; +} +.score-eyebrow { + font-size: 12px; text-transform: uppercase; letter-spacing: .05em; + color: var(--muted); margin: 0 0 8px; +} +.score-hero-row { display: flex; align-items: center; gap: 16px; } +.score-num { + font-size: 52px; font-weight: 800; line-height: 1; + color: color-mix(in oklab, var(--cat, var(--text)), var(--text) 15%); +} +.score-hero-label { display: flex; flex-direction: column; gap: 4px; } +.score-grade { + font-size: 18px; font-weight: 700; + color: color-mix(in oklab, var(--cat, var(--text)), var(--text) 22%); +} +.score-sub { font-size: 13px; color: var(--muted); } +.score-note { font-size: 12px; color: var(--muted); margin: 12px 0 0; line-height: 1.4; } + +.score-section { margin: 20px 0; } +.score-legend { font-size: 12px; color: var(--muted); margin: -6px 0 10px; } + +/* One row per season: name · overall · a chip per metric. */ +.season-scores { + display: grid; grid-template-columns: 64px 40px 1fr; align-items: center; + gap: 10px; padding: 7px 0; border-top: 1px solid var(--border); +} +.season-scores-name { font-size: 13px; font-weight: 600; color: var(--text); } +.season-scores-chips { display: flex; flex-wrap: wrap; gap: 5px; } +.score-chip { + display: inline-flex; align-items: center; justify-content: center; + min-width: 30px; padding: 3px 6px; border-radius: 7px; + font-size: 12px; font-weight: 700; color: var(--text); +} +.season-scores-overall .score-chip { min-width: 34px; font-weight: 800; } + +/* Summary reveal — pill button + caret, mirrors the season-expand pattern. */ +.summary-toggle { + display: inline-flex; align-items: center; gap: 6px; min-height: 44px; + margin: 8px 0 4px; padding: 8px 16px; border: 1px solid var(--border); + border-radius: 10px; background: var(--surface); color: var(--text); + font-size: 15px; font-weight: 600; cursor: pointer; +} +.summary-toggle:hover { border-color: var(--accent); } +.summary-caret { transition: transform .15s ease; } +.summary-toggle[aria-expanded="true"] { border-color: var(--accent); } +.summary-toggle[aria-expanded="true"] .summary-caret { transform: rotate(180deg); } + +#score-summary { margin-top: 14px; } +.score-summary-lead { font-size: 14px; color: var(--text); margin: 6px 0; } +.score-sentences { margin: 8px 0 18px; padding-left: 20px; } +.score-sentences li { font-size: 14px; line-height: 1.5; margin-bottom: 8px; color: var(--text); } + +.score-table-wrap { overflow-x: auto; margin: 8px 0 20px; } +.score-table { border-collapse: collapse; font-size: 13px; } +.score-table th, .score-table td { + padding: 6px 10px; border-bottom: 1px solid var(--border); text-align: center; white-space: nowrap; +} +.score-table thead th { color: var(--muted); font-weight: 600; } +.score-table tbody th { text-align: left; font-weight: 600; color: var(--text); } +.score-table-overall th, .score-table-overall td { + border-top: 2px solid var(--border); font-weight: 800; +} +.pq-table td { text-align: left; font-size: 12px; color: var(--muted); } +.pq-d { font-weight: 700; } +.pq-up { color: color-mix(in oklab, var(--hot), var(--text) 30%); } +.pq-down { color: color-mix(in oklab, var(--cool), var(--text) 30%); } + +@media (max-width: 640px) { + .score-num { font-size: 40px; } + .score-hero { padding: 14px 15px; } + .season-scores { grid-template-columns: 56px 36px 1fr; gap: 8px; } +} + /* Monthly temperature-range strip: one gradient bar per month, low→high on a shared axis. The signature climate visual — colourful and unmistakably Thermograph. */ .range-strip { display: grid; gap: 6px; margin: 16px 0 8px; max-width: 620px; } diff --git a/static/subscriptions.html b/static/subscriptions.html index 754215d..2925cd8 100644 --- a/static/subscriptions.html +++ b/static/subscriptions.html @@ -41,6 +41,7 @@ Calendar Day Detail Compare + Score Climate Alerts