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