Add a climate-score page from recent-vs-baseline percentile divergence (#196)
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.
2026-07-19 23:02:33 +00:00
|
|
|
|
// 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 = `<p class="spinner">Scoring 45 years of climate…</p>`;
|
|
|
|
|
|
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 = `<p class="error">${esc(err.message)}</p>`;
|
|
|
|
|
|
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 = `<div class="score-hero"><h2>${esc(place)}</h2>
|
|
|
|
|
|
<p class="score-sub">${esc(s.unavailable)}</p></div>`;
|
|
|
|
|
|
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 = `
|
|
|
|
|
|
<div class="score-hero" style="--cat:${tintColor(ov.class)}">
|
|
|
|
|
|
<p class="score-eyebrow">Climate-shift score · ${esc(place)}</p>
|
|
|
|
|
|
<div class="score-hero-row">
|
|
|
|
|
|
<span class="score-num">${ov.score}</span>
|
|
|
|
|
|
<div class="score-hero-label">
|
|
|
|
|
|
<span class="score-grade">${esc(ov.grade)}</span>
|
2026-07-19 23:37:42 +00:00
|
|
|
|
<span class="score-sub">Net change · ${recentYrs} vs ${baseYrs} · ${s.n_recent.toLocaleString()} recent days scored</span>
|
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
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.
2026-07-19 23:02:33 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-07-19 23:37:42 +00:00
|
|
|
|
<p class="score-note">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.</p>
|
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
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.
2026-07-19 23:02:33 +00:00
|
|
|
|
</div>`;
|
|
|
|
|
|
|
|
|
|
|
|
// ---- per-metric cards (annual) ----
|
|
|
|
|
|
const cards = METRIC_ORDER.map((k) => metricCard(annual.metrics[k])).join("");
|
2026-07-19 23:37:42 +00:00
|
|
|
|
const wbNote = wetbulbNote(annual.metrics.wetbulb, baseYrs);
|
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
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.
2026-07-19 23:02:33 +00:00
|
|
|
|
|
|
|
|
|
|
// ---- 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
|
|
|
|
|
|
? `<span class="score-chip ${tintClass(oc.class)}" title="Overall">${oc.score}</span>`
|
|
|
|
|
|
: `<span class="score-chip t-none" title="Overall">—</span>`;
|
|
|
|
|
|
return `<div class="season-scores">
|
|
|
|
|
|
<span class="season-scores-name">${SEASON_NAMES[sk]}</span>
|
|
|
|
|
|
<span class="season-scores-overall">${overallChip}</span>
|
|
|
|
|
|
<span class="season-scores-chips">${chips}</span>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
}).join("");
|
|
|
|
|
|
|
|
|
|
|
|
scoreBody.innerHTML = `
|
|
|
|
|
|
<section class="normals">${cards}</section>
|
2026-07-19 23:37:42 +00:00
|
|
|
|
${wbNote}
|
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
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.
2026-07-19 23:02:33 +00:00
|
|
|
|
<section class="score-section">
|
|
|
|
|
|
<p class="section-title">By season</p>
|
|
|
|
|
|
<p class="score-legend">${METRIC_ORDER.map((k) => shortLabel(k)).join(" · ")}</p>
|
|
|
|
|
|
${seasonRows}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
<button type="button" class="summary-toggle" aria-expanded="false" aria-controls="score-summary">
|
|
|
|
|
|
Summary <span class="summary-caret" aria-hidden="true">▾</span></button>
|
|
|
|
|
|
<section id="score-summary" hidden>${summaryHTML(s)}</section>`;
|
|
|
|
|
|
|
|
|
|
|
|
wireSummaryToggle();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-19 23:37:42 +00:00
|
|
|
|
// 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 = `<strong>Wet bulb</strong> 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 `<p class="score-callout">${def}</p>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
const thr = fmtTemp(m.freq.threshold_f);
|
|
|
|
|
|
const { f6, f45, d } = m.freq;
|
|
|
|
|
|
const delta = d ? ` <span class="pq-d ${d >= 0 ? "pq-up" : "pq-down"}">(${d >= 0 ? "+" : ""}${d} pts)</span>` : "";
|
|
|
|
|
|
return `<p class="score-callout">${def}
|
|
|
|
|
|
Heat-stress days (peak wet bulb ≥ ${esc(String(thr))}): <strong>${f6}%</strong> of recent days
|
|
|
|
|
|
vs <strong>${f45}%</strong> across ${baseYrs}${delta} — the rest are normal.</p>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
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.
2026-07-19 23:02:33 +00:00
|
|
|
|
// One metric score card (reuses .normal-card). Null metrics show a muted reason.
|
|
|
|
|
|
function metricCard(m) {
|
|
|
|
|
|
if (!m) return "";
|
|
|
|
|
|
if (m.score == null) {
|
|
|
|
|
|
return `<section class="normal-card">
|
|
|
|
|
|
<div class="nc-head"><h3>${esc(m.label)}</h3></div>
|
|
|
|
|
|
<div class="big" style="--cat:var(--muted)">—</div>
|
|
|
|
|
|
<div class="range">${esc(m.reason || "no data")}</div>
|
|
|
|
|
|
</section>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return `<section class="normal-card" style="--cat:${tintColor(m.class)}">
|
|
|
|
|
|
<div class="nc-head"><h3>${esc(m.label)}</h3><span class="nc-grade">${esc(m.grade)}</span></div>
|
|
|
|
|
|
<div class="big">${m.score}</div>
|
|
|
|
|
|
<div class="range">${scoreDirection(m)}</div>
|
|
|
|
|
|
</section>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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 `<span class="score-chip t-none">—</span>`;
|
|
|
|
|
|
return `<span class="score-chip ${tintClass(m.class)}" title="${esc(m.label)}: ${esc(m.grade)}">${m.score}</span>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 `<li><strong>${esc(m.label)}:</strong> ${esc(m.grade)} — about ${Math.abs(m.bias).toFixed(0)} percentile points ${esc(m.direction)}.${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>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Metrics × slices table of scores, each cell tinted by its tier.
|
|
|
|
|
|
function scoreTableHTML(s) {
|
|
|
|
|
|
const slices = ["annual", ...SEASON_ORDER];
|
|
|
|
|
|
const head = `<tr><th>Metric</th>${slices.map((sk) =>
|
|
|
|
|
|
`<th>${sk === "annual" ? "Annual" : SEASON_NAMES[sk]}</th>`).join("")}</tr>`;
|
|
|
|
|
|
const rows = METRIC_ORDER.map((k) => {
|
|
|
|
|
|
const cells = slices.map((sk) => {
|
|
|
|
|
|
const m = s.slices[sk].metrics[k];
|
|
|
|
|
|
if (!m || m.score == null) return `<td class="t-none">—</td>`;
|
|
|
|
|
|
return `<td class="${tintClass(m.class)}" title="${esc(m.grade)}">${m.score}</td>`;
|
|
|
|
|
|
}).join("");
|
|
|
|
|
|
const label = s.slices.annual.metrics[k]?.label || k;
|
|
|
|
|
|
return `<tr><th>${esc(label)}</th>${cells}</tr>`;
|
|
|
|
|
|
}).join("");
|
|
|
|
|
|
const overallCells = slices.map((sk) => {
|
|
|
|
|
|
const o = s.slices[sk].overall;
|
|
|
|
|
|
if (!o) return `<td class="t-none">—</td>`;
|
|
|
|
|
|
return `<td class="${tintClass(o.class)}"><strong>${o.score}</strong></td>`;
|
|
|
|
|
|
}).join("");
|
|
|
|
|
|
const overallRow = `<tr class="score-table-overall"><th>Overall</th>${overallCells}</tr>`;
|
|
|
|
|
|
return `<p class="section-title">All scores</p>
|
|
|
|
|
|
<div class="score-table-wrap"><table class="score-table">
|
|
|
|
|
|
<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.
|
|
|
|
|
|
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("");
|
|
|
|
|
|
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>
|
|
|
|
|
|
<div class="score-table-wrap"><table class="score-table pq-table"><tbody>${rows}</tbody></table></div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|