Port f5fc288 Consolidate shared frontend render/fetch helpers from monorepo (drift reconciliation)
Ports monorepo PR #43: cache.js gains a shared loadView() (spinner-delay + supersession + SWR wiring) used by app.js/score.js instead of each duplicating that dance around getJSON, and shared.js gains tierKeySegs()/ GUIDE_LINK used by app.js/calendar.js instead of each hand-building the tier-key markup. Resolved against this branch's uv()/API_VERSION work: every fetch URL touched here stays routed through uv(...) (grade/score already were) rather than reverting to a raw api/v2/... literal.
This commit is contained in:
parent
290187db6b
commit
0af523bc34
5 changed files with 68 additions and 78 deletions
|
|
@ -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 = `<p class="spinner">Fetching decades of climate history…</p>`;
|
||||
}, 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 = `<p class="error">${err.message}</p>`;
|
||||
return;
|
||||
}
|
||||
clearTimeout(spin);
|
||||
if (seq !== gradeSeq) return;
|
||||
render(data);
|
||||
await loadView({
|
||||
url, ttl: TTL.grade, seq: ++gradeSeq, current: () => gradeSeq,
|
||||
spinner: () => { results.innerHTML = `<p class="spinner">Fetching decades of climate history…</p>`; },
|
||||
onData: render,
|
||||
onError: (err) => { results.innerHTML = `<p class="error">${err.message}</p>`; },
|
||||
});
|
||||
}
|
||||
|
||||
// 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) =>
|
||||
`<div class="tk-seg">
|
||||
<span class="tk-swatch" style="background:${TIER_COLORS[t.c]}"></span>
|
||||
<span class="tk-label">${t.label}</span>
|
||||
</div>`).join("");
|
||||
const segs = tierKeySegs([...SCALE_TEMP].reverse().map((t) =>
|
||||
({ color: TIER_COLORS[t.c], label: t.label })));
|
||||
return `<div class="section-title">Grade scale · low → high vs local normal</div>
|
||||
<div class="tier-key">${segs}</div>
|
||||
<p class="muted tk-note"><a href="legend">Full guide to metrics & grades →</a></p>`;
|
||||
<div class="tier-key">${segs}</div>${GUIDE_LINK}`;
|
||||
}
|
||||
|
||||
let chartDays = [];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <input type=month>
|
||||
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 = `<p class="muted tk-note"><a href="legend">Full guide to metrics & grades →</a></p>`;
|
||||
|
||||
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]) =>
|
||||
`<div class="tk-seg"><span class="tk-swatch" style="background:${drynessColor(d)}"></span>
|
||||
<span class="tk-label">${lab}</span></div>`).join("");
|
||||
const segs = tierKeySegs(steps.map(([label, d]) => ({ color: drynessColor(d), label })));
|
||||
keyEl.innerHTML = `<div class="section-title">Days since last rain: dry streak (caps at ${DRY_CAP} days)</div>
|
||||
<div class="tier-key">${segs}</div>${GUIDE_LINK}`;
|
||||
return;
|
||||
}
|
||||
if (metric === "precip") {
|
||||
const rain = [...SCALE_RAIN].reverse().map((t) =>
|
||||
`<div class="tk-seg"><span class="tk-swatch" style="background:${PRECIP_COLORS[t.c]}"></span>
|
||||
<span class="tk-label">${t.label}</span></div>`).join("");
|
||||
const dry = [["≤1 day", 1], ["7", 7], ["14+", 14]].map(([l, d]) =>
|
||||
`<div class="tk-seg"><span class="tk-swatch" style="background:${drynessColor(d)}"></span>
|
||||
<span class="tk-label">${l}</span></div>`).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 = `<div class="section-title">Rain intensity · light → heavy</div>
|
||||
<div class="tier-key">${rain}</div>
|
||||
<div class="section-title" style="margin-top:10px">Dry days · days since rain</div>
|
||||
|
|
@ -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) =>
|
||||
`<div class="tk-seg"><span class="tk-swatch" style="background:${TIER_COLORS[t.c]}"></span>
|
||||
<span class="tk-label">${t.label}</span></div>`).join("");
|
||||
const segs = tierKeySegs([...SCALE_TEMP].reverse().map((t) =>
|
||||
({ color: TIER_COLORS[t.c], label: t.label })));
|
||||
keyEl.innerHTML = `<div class="section-title">Coloring by ${label} · low → high vs local normal</div>
|
||||
<div class="tier-key">${segs}</div>${GUIDE_LINK}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = `<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(
|
||||
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 = `<p class="error">${esc(err.message)}</p>`;
|
||||
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 = `<p class="spinner">Scoring 45 years of climate…</p>`;
|
||||
scoreBody.innerHTML = "";
|
||||
},
|
||||
onData: finishScore,
|
||||
onError: (err) => {
|
||||
scoreHead.innerHTML = `<p class="error">${esc(err.message)}</p>`;
|
||||
scoreBody.innerHTML = "";
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function finishScore(data) {
|
||||
|
|
|
|||
|
|
@ -264,6 +264,17 @@ export const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<")
|
|||
export const placeLabel = (data) =>
|
||||
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) =>
|
||||
`<div class="tk-seg"><span class="tk-swatch" style="background:${it.color}"></span>` +
|
||||
`<span class="tk-label">${it.label}</span></div>`).join("");
|
||||
|
||||
// The "full guide" footer link shared under every grade-scale key.
|
||||
export const GUIDE_LINK =
|
||||
`<p class="muted tk-note"><a href="legend">Full guide to metrics & grades →</a></p>`;
|
||||
|
||||
// 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue