diff --git a/static/app.js b/static/app.js
index 4765519..d4ec56b 100644
--- a/static/app.js
+++ b/static/app.js
@@ -2,13 +2,13 @@
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
import { fmtTemp, onUnitChange, toWind, windUnit, precipUnit } from "./units.js";
import { uv } from "./account.js"; // header sign-in entry + notification bell, and the API-version resolver
-import { getJSON, TTL, prefetchViews } from "./cache.js";
+import { loadView, TTL, prefetchViews } from "./cache.js";
import { initFindButton, setFindLabel } from "./mappicker.js";
import { W, PW, H, PL, PR, plotTop, plotBot, xLabY, setChartWidth, chartPalette,
tempChart, precipChart, dryChart, attachChartHover } from "./chart.js";
import { track } from "./digest.js";
import { TIER_COLORS, SCALE_TEMP, drynessColor, pctOrd, esc, todayISO, placeLabel,
- fmtPrecip, fmtWind, fmtHumid } from "./shared.js";
+ tierKeySegs, GUIDE_LINK, fmtPrecip, fmtWind, fmtHumid } from "./shared.js";
let selected = null; // {lat, lon}
@@ -152,34 +152,16 @@ async function runGrade() {
updateHash();
placeholder.hidden = true;
results.hidden = false;
- const seq = ++gradeSeq;
- // Delay the spinner: a cache-served render lands in a few ms, so blanking the
- // panel immediately would just flash. Only a genuinely slow (network) load
- // ever shows it.
- const spin = setTimeout(() => {
- if (seq === gradeSeq) results.innerHTML = `
Fetching decades of climate history…
`;
- }, 150);
-
// Omit the date when it's today, so the URL (and cache key) matches the prefetch
// fired from the other views — a same-tab navigation then reuses the response.
const q = `lat=${selected.lat}&lon=${selected.lon}`;
const url = dateInput.value === todayISO() ? uv(`grade?${q}`) : uv(`grade?${q}&date=${dateInput.value}`);
- let data;
- try {
- // Stale-while-revalidate: a stale cached copy renders immediately and the
- // update callback repaints if the background revalidation finds new data.
- data = await getJSON(url, TTL.grade, false, (upd) => {
- if (seq === gradeSeq) render(upd);
- });
- } catch (err) {
- clearTimeout(spin);
- if (seq !== gradeSeq) return;
- results.innerHTML = `${err.message}
`;
- return;
- }
- clearTimeout(spin);
- if (seq !== gradeSeq) return;
- render(data);
+ await loadView({
+ url, ttl: TTL.grade, seq: ++gradeSeq, current: () => gradeSeq,
+ spinner: () => { results.innerHTML = `Fetching decades of climate history…
`; },
+ onData: render,
+ onError: (err) => { results.innerHTML = `${err.message}
`; },
+ });
}
// Compact cell formatters for the dense recent/forecast table (units live in the
@@ -390,14 +372,10 @@ function scrollTableToTarget(host, target) {
function tierKeyHtml() {
// Highest tier first (Near Record hot → Near Record cold).
- const segs = [...SCALE_TEMP].reverse().map((t) =>
- `
-
- ${t.label}
-
`).join("");
+ const segs = tierKeySegs([...SCALE_TEMP].reverse().map((t) =>
+ ({ color: TIER_COLORS[t.c], label: t.label })));
return `Grade scale · low → high vs local normal
- ${segs}
- Full guide to metrics & grades →
`;
+ ${segs}
${GUIDE_LINK}`;
}
let chartDays = [];
diff --git a/static/cache.js b/static/cache.js
index 190e06d..33fe40c 100644
--- a/static/cache.js
+++ b/static/cache.js
@@ -140,6 +140,27 @@ export async function getJSON(url, ttlMs = 600000, persist = false, onUpdate = n
catch (e) { if (rec) return rec.d; throw e; } // offline/unreachable → stale beats an error
}
+// A guarded stale-while-revalidate load for a single primary view (grade, score…).
+// Centralizes the fiddly, easy-to-get-wrong bits shared by those loaders: a
+// spinner delayed 150ms so a warm render never flashes it, supersession so a
+// newer selection wins (`seq` is this call's captured token, `current()` the live
+// one), an SWR repaint via getJSON's update callback, and clearing the spinner on
+// both the success and error paths. `spinner`/`onData`/`onError` are caller UI
+// callbacks; `onData` handles both the SWR update and the final result.
+export async function loadView({ url, ttl, seq, current, spinner, onData, onError }) {
+ const spin = setTimeout(() => { if (seq === current()) spinner(); }, 150);
+ let data;
+ try {
+ data = await getJSON(url, ttl, false, (upd) => { if (seq === current()) onData(upd); });
+ } catch (err) {
+ clearTimeout(spin);
+ if (seq === current()) onError(err);
+ return;
+ }
+ clearTimeout(spin);
+ if (seq === current()) onData(data);
+}
+
// Load an ordered list of URLs through the cache with a bounded-parallel pool.
// Results (or {error}) are reported in place via onResult(i, result, isUpdate) —
// including stale-while-revalidate updates — so callers can render the
diff --git a/static/calendar.js b/static/calendar.js
index 9b97a2f..9838f76 100644
--- a/static/calendar.js
+++ b/static/calendar.js
@@ -7,11 +7,11 @@ import { uv } from "./account.js"; // header sign-in entry + notification bell
import { TTL, chunkedFetch, prefetchViews } from "./cache.js";
import { initFindButton, setFindLabel } from "./mappicker.js";
import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, pctOrd,
- fmtPrecip, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd,
+ placeLabel, fmtPrecip, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd,
CHUNK_MONTHS, buildChunks, clickOpensPicker, metricBuckets, distStrip,
seasonFilterDropdown, seasonSummaryText, syncSeasonChecks,
applySeasonMonthChange, initSeasonExpand, allMonths, monthsToMask, maskToMonths,
- weatherType as wxType, DRY_CAP } from "./shared.js";
+ tierKeySegs, GUIDE_LINK, weatherType as wxType, DRY_CAP } from "./shared.js";
import { initFilterSheet } from "./filtersheet.js";
// The diverging-temperature-scale metrics (colored exactly like High/Low), with a
@@ -407,7 +407,7 @@ async function fetchCalendar() {
startInput.value = reqStart.slice(0, 7); // "YYYY-MM" for
endInput.value = reqEnd.slice(0, 7);
document.getElementById("metric-block").hidden = false;
- locLabel.textContent = d.place || `${d.cell.center_lat.toFixed(3)}, ${d.cell.center_lon.toFixed(3)}`;
+ locLabel.textContent = placeLabel(d);
locLabel.hidden = false;
setFindLabel(findBtn, "Change location");
renderFilters();
@@ -460,8 +460,7 @@ async function fetchCalendar() {
// Header summary. While `loading`, `remaining` > 0 shows a "loading N more blocks"
// note; once loading has finished, a leftover `remaining` flags chunks that failed.
function renderHead(remaining, loading) {
- const cell = data.cell;
- const place = data.place || `${cell.center_lat.toFixed(3)}, ${cell.center_lon.toFixed(3)}`;
+ const place = placeLabel(data);
const years = ((new Date(data.range.end) - new Date(data.range.start)) / 3.15576e10);
let note = "";
if (remaining > 0 && loading) {
@@ -575,25 +574,19 @@ function renderTotals(visible) {
totEl.hidden = false;
}
-const GUIDE_LINK = `Full guide to metrics & grades →
`;
-
function renderKey() {
if (metric === "dsr") {
const steps = [["Rain", 0], ["1 day", 1], ["4", 4], ["7", 7], ["10", 10], ["14+", 14]];
- const segs = steps.map(([lab, d]) =>
- `
- ${lab}
`).join("");
+ const segs = tierKeySegs(steps.map(([label, d]) => ({ color: drynessColor(d), label })));
keyEl.innerHTML = `Days since last rain: dry streak (caps at ${DRY_CAP} days)
${segs}
${GUIDE_LINK}`;
return;
}
if (metric === "precip") {
- const rain = [...SCALE_RAIN].reverse().map((t) =>
- `
- ${t.label}
`).join("");
- const dry = [["≤1 day", 1], ["7", 7], ["14+", 14]].map(([l, d]) =>
- `
- ${l}
`).join("");
+ const rain = tierKeySegs([...SCALE_RAIN].reverse().map((t) =>
+ ({ color: PRECIP_COLORS[t.c], label: t.label })));
+ const dry = tierKeySegs([["≤1 day", 1], ["7", 7], ["14+", 14]].map(([label, d]) =>
+ ({ color: drynessColor(d), label })));
keyEl.innerHTML = `Rain intensity · light → heavy
${rain}
Dry days · days since rain
@@ -602,9 +595,8 @@ function renderKey() {
}
const label = (TEMP_METRIC_INFO[metric] || {}).label || "value";
// Highest tier first (Near Record hot → Near Record cold).
- const segs = [...SCALE_TEMP].reverse().map((t) =>
- `
- ${t.label}
`).join("");
+ const segs = tierKeySegs([...SCALE_TEMP].reverse().map((t) =>
+ ({ color: TIER_COLORS[t.c], label: t.label })));
keyEl.innerHTML = `Coloring by ${label} · low → high vs local normal
${segs}
${GUIDE_LINK}`;
}
diff --git a/static/score.js b/static/score.js
index 7ce5eeb..87e6c1b 100644
--- a/static/score.js
+++ b/static/score.js
@@ -4,7 +4,7 @@
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
import { fmtTemp, onUnitChange } from "./units.js";
import { uv } from "./account.js"; // header sign-in entry + notification bell, and the API-version resolver
-import { getJSON, TTL, prefetchViews } from "./cache.js";
+import { loadView, TTL, prefetchViews } from "./cache.js";
import { initFindButton, setFindLabel } from "./mappicker.js";
import { TIER_COLORS, esc, placeLabel } from "./shared.js";
@@ -55,31 +55,19 @@ async function fetchScore() {
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(
- uv(`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);
+ await loadView({
+ url: uv(`score?lat=${selected.lat}&lon=${selected.lon}`),
+ ttl: TTL.score, seq: ++seqCounter, current: () => seqCounter,
+ spinner: () => {
+ scoreHead.innerHTML = `Scoring 45 years of climate…
`;
+ scoreBody.innerHTML = "";
+ },
+ onData: finishScore,
+ onError: (err) => {
+ scoreHead.innerHTML = `${esc(err.message)}
`;
+ scoreBody.innerHTML = "";
+ },
+ });
}
function finishScore(data) {
diff --git a/static/shared.js b/static/shared.js
index 1aea0fc..423c5ea 100644
--- a/static/shared.js
+++ b/static/shared.js
@@ -264,6 +264,17 @@ export const esc = (s) => String(s).replace(/&/g, "&").replace(/
data.place || `${data.cell.center_lat.toFixed(3)}, ${data.cell.center_lon.toFixed(3)}`;
+// The grade/scale key strip: one ".tk-seg" (colored swatch + label) per item.
+// Callers resolve each item's color (TIER_COLORS / PRECIP_COLORS / drynessColor)
+// and pass { color, label } pairs, so this stays free of any color-map coupling.
+export const tierKeySegs = (items) => items.map((it) =>
+ ` ` +
+ `${it.label}
`).join("");
+
+// The "full guide" footer link shared under every grade-scale key.
+export const GUIDE_LINK =
+ `Full guide to metrics & grades →
`;
+
// A place label is "neighbourhood, city, region, country" (comma-joined by the
// backend). For display we lead with the first segment and mute the rest:
// namePrimary → the lead segment; nameParts → { primary, rest } with rest joined by