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.
This commit is contained in:
parent
ef61308a9e
commit
c2d9b602dd
9 changed files with 427 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
<a href="calendar" data-view="calendar" class="active">Calendar</a>
|
||||
<a href="day" data-view="day">Day Detail</a>
|
||||
<a href="compare" data-view="compare">Compare</a>
|
||||
<a href="score" data-view="score">Score</a>
|
||||
<a href="climate">Climate</a>
|
||||
<a href="alerts">Alerts</a>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@
|
|||
<a href="calendar" data-view="calendar">Calendar</a>
|
||||
<a href="day" data-view="day">Day Detail</a>
|
||||
<a href="compare" data-view="compare" class="active">Compare</a>
|
||||
<a href="score" data-view="score">Score</a>
|
||||
<a href="climate">Climate</a>
|
||||
<a href="alerts">Alerts</a>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@
|
|||
<a href="calendar" data-view="calendar">Calendar</a>
|
||||
<a href="day" data-view="day" class="active">Day Detail</a>
|
||||
<a href="compare" data-view="compare">Compare</a>
|
||||
<a href="score" data-view="score">Score</a>
|
||||
<a href="climate">Climate</a>
|
||||
<a href="alerts">Alerts</a>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
<a href="calendar" data-view="calendar">Calendar</a>
|
||||
<a href="day" data-view="day">Day Detail</a>
|
||||
<a href="compare" data-view="compare">Compare</a>
|
||||
<a href="score" data-view="score">Score</a>
|
||||
<a href="climate">Climate</a>
|
||||
<a href="alerts">Alerts</a>
|
||||
</nav>
|
||||
|
|
|
|||
75
static/score.html
Normal file
75
static/score.html
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Thermograph — climate score</title>
|
||||
<meta name="description" content="How far a place's last six years have drifted from its 45-year climate: a per-metric and overall climate-shift score across temperature, feels-like, humidity, wet bulb, wind and rain." />
|
||||
<meta name="theme-color" content="#f0803c" />
|
||||
<!-- Link previews (Discord, Slack, iMessage…). The server fills the og:url /
|
||||
og:image origins in from this request's scheme://host + base path —
|
||||
crawlers need absolute URLs and don't run JS. -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Thermograph" />
|
||||
<meta property="og:title" content="Thermograph — climate score" />
|
||||
<meta property="og:description" content="How far a place's last six years have drifted from its 45-year climate: a per-metric and overall climate-shift score across temperature, feels-like, humidity, wet bulb, wind and rain." />
|
||||
<meta property="og:url" content="__ORIGIN__/score" />
|
||||
<meta property="og:image" content="__ORIGIN__/logo.png?v=2" />
|
||||
<meta property="og:image:width" content="512" />
|
||||
<meta property="og:image:height" content="512" />
|
||||
<meta property="og:image:alt" content="Thermograph logo" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<link rel="canonical" href="__ORIGIN__/score" />
|
||||
<link rel="icon" href="favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="favicon-48.png" type="image/png" sizes="48x48" />
|
||||
<link rel="icon" href="favicon-32.png" type="image/png" sizes="32x32" />
|
||||
<link rel="icon" href="favicon-16.png" type="image/png" sizes="16x16" />
|
||||
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||
<link rel="manifest" href="manifest.webmanifest" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="brand">
|
||||
<div>
|
||||
<h1><a href="./"><span class="logo"><svg viewBox="0 0 512 512" width="28" height="28" aria-hidden="true"><rect x="32" y="32" width="448" height="448" rx="96" fill="#10141B" stroke="#fff" stroke-opacity=".08" stroke-width="4"/><rect x="64" y="170" width="384" height="48" rx="8" fill="#27131C"/><rect x="64" y="222" width="384" height="48" rx="8" fill="#38322E"/><rect x="64" y="274" width="384" height="48" rx="8" fill="#1A2D27"/><rect x="64" y="326" width="384" height="48" rx="8" fill="#26333F"/><rect x="64" y="378" width="384" height="48" rx="8" fill="#18293A"/><line x1="64" y1="132" x2="448" y2="132" stroke="#46586E" stroke-width="8" stroke-dasharray="26 20"/><path d="M64 410H126V386H160" fill="none" stroke="var(--cold)" stroke-width="34"/><path d="M160 403V352H244V368H282" fill="none" stroke="var(--cool)" stroke-width="34"/><path d="M282 385V320H312" fill="none" stroke="var(--normal)" stroke-width="34"/><path d="M312 337V248H338" fill="none" stroke="var(--warm)" stroke-width="34"/><path d="M338 265V196H366" fill="none" stroke="var(--hot)" stroke-width="34"/><path d="M366 213V132H396" fill="none" stroke="var(--very-hot)" stroke-width="34"/><rect x="382" y="100" width="64" height="64" rx="12" fill="var(--rec-hot)" stroke="#10141B" stroke-width="12"/></svg></span>Thermograph</a></h1>
|
||||
<p class="tag">How far the last 6 years have drifted from 45 years of local climate</p>
|
||||
</div>
|
||||
<details class="nav-menu">
|
||||
<summary class="nav-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg></summary>
|
||||
<div class="nav-panel">
|
||||
<nav class="view-nav" aria-label="Views">
|
||||
<a href="./" data-view="map">Weekly</a>
|
||||
<a href="calendar" data-view="calendar">Calendar</a>
|
||||
<a href="day" data-view="day">Day Detail</a>
|
||||
<a href="compare" data-view="compare">Compare</a>
|
||||
<a href="score" data-view="score" class="active">Score</a>
|
||||
<a href="climate">Climate</a>
|
||||
<a href="alerts">Alerts</a>
|
||||
</nav>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="controls">
|
||||
<div class="find-bar">
|
||||
<button type="button" id="find-btn" class="find-btn"></button>
|
||||
<span id="loc-label" class="loc-label" hidden></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="score-head" hidden></div>
|
||||
<div class="placeholder" id="score-placeholder">
|
||||
<p>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.</p>
|
||||
</div>
|
||||
<div id="score-body"></div>
|
||||
</main>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script type="module" src="score.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
266
static/score.js
Normal file
266
static/score.js
Normal file
|
|
@ -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 = `<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>
|
||||
<span class="score-sub">${recentYrs} vs ${baseYrs} · ${s.n_recent.toLocaleString()} recent days scored</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="score-note">0 = no change from the long-term normal · 100 = an extreme shift.
|
||||
Temperature, feels-like and humidity are weighted most.</p>
|
||||
</div>`;
|
||||
|
||||
// ---- 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
|
||||
? `<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>
|
||||
<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();
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
})();
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
<a href="calendar" data-view="calendar">Calendar</a>
|
||||
<a href="day" data-view="day">Day Detail</a>
|
||||
<a href="compare" data-view="compare">Compare</a>
|
||||
<a href="score" data-view="score">Score</a>
|
||||
<a href="climate">Climate</a>
|
||||
<a href="alerts" class="active">Alerts</a>
|
||||
</nav>
|
||||
|
|
|
|||
Loading…
Reference in a new issue