// 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)} 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.

`; // ---- per-metric cards (annual) ---- const cards = METRIC_ORDER.map((k) => metricCard(annual.metrics[k])).join(""); const wbNote = wetbulbNote(annual.metrics.wetbulb, baseYrs); // ---- 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}
${wbNote}

By season

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

${seasonRows}
`; 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}

`; } const thr = fmtTemp(m.freq.threshold_f); const { f6, f45, d } = m.freq; const delta = d ? ` (${d >= 0 ? "+" : ""}${d} pts)` : ""; 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 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(); } })();