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 { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
|
||||||
import { fmtTemp, onUnitChange, toWind, windUnit, precipUnit } from "./units.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 { 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 { initFindButton, setFindLabel } from "./mappicker.js";
|
||||||
import { W, PW, H, PL, PR, plotTop, plotBot, xLabY, setChartWidth, chartPalette,
|
import { W, PW, H, PL, PR, plotTop, plotBot, xLabY, setChartWidth, chartPalette,
|
||||||
tempChart, precipChart, dryChart, attachChartHover } from "./chart.js";
|
tempChart, precipChart, dryChart, attachChartHover } from "./chart.js";
|
||||||
import { track } from "./digest.js";
|
import { track } from "./digest.js";
|
||||||
import { TIER_COLORS, SCALE_TEMP, drynessColor, pctOrd, esc, todayISO, placeLabel,
|
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}
|
let selected = null; // {lat, lon}
|
||||||
|
|
||||||
|
|
@ -152,34 +152,16 @@ async function runGrade() {
|
||||||
updateHash();
|
updateHash();
|
||||||
placeholder.hidden = true;
|
placeholder.hidden = true;
|
||||||
results.hidden = false;
|
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
|
// 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.
|
// fired from the other views — a same-tab navigation then reuses the response.
|
||||||
const q = `lat=${selected.lat}&lon=${selected.lon}`;
|
const q = `lat=${selected.lat}&lon=${selected.lon}`;
|
||||||
const url = dateInput.value === todayISO() ? uv(`grade?${q}`) : uv(`grade?${q}&date=${dateInput.value}`);
|
const url = dateInput.value === todayISO() ? uv(`grade?${q}`) : uv(`grade?${q}&date=${dateInput.value}`);
|
||||||
let data;
|
await loadView({
|
||||||
try {
|
url, ttl: TTL.grade, seq: ++gradeSeq, current: () => gradeSeq,
|
||||||
// Stale-while-revalidate: a stale cached copy renders immediately and the
|
spinner: () => { results.innerHTML = `<p class="spinner">Fetching decades of climate history…</p>`; },
|
||||||
// update callback repaints if the background revalidation finds new data.
|
onData: render,
|
||||||
data = await getJSON(url, TTL.grade, false, (upd) => {
|
onError: (err) => { results.innerHTML = `<p class="error">${err.message}</p>`; },
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compact cell formatters for the dense recent/forecast table (units live in the
|
// Compact cell formatters for the dense recent/forecast table (units live in the
|
||||||
|
|
@ -390,14 +372,10 @@ function scrollTableToTarget(host, target) {
|
||||||
|
|
||||||
function tierKeyHtml() {
|
function tierKeyHtml() {
|
||||||
// Highest tier first (Near Record hot → Near Record cold).
|
// Highest tier first (Near Record hot → Near Record cold).
|
||||||
const segs = [...SCALE_TEMP].reverse().map((t) =>
|
const segs = tierKeySegs([...SCALE_TEMP].reverse().map((t) =>
|
||||||
`<div class="tk-seg">
|
({ color: TIER_COLORS[t.c], label: t.label })));
|
||||||
<span class="tk-swatch" style="background:${TIER_COLORS[t.c]}"></span>
|
|
||||||
<span class="tk-label">${t.label}</span>
|
|
||||||
</div>`).join("");
|
|
||||||
return `<div class="section-title">Grade scale · low → high vs local normal</div>
|
return `<div class="section-title">Grade scale · low → high vs local normal</div>
|
||||||
<div class="tier-key">${segs}</div>
|
<div class="tier-key">${segs}</div>${GUIDE_LINK}`;
|
||||||
<p class="muted tk-note"><a href="legend">Full guide to metrics & grades →</a></p>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let chartDays = [];
|
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
|
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.
|
// 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) —
|
// Results (or {error}) are reported in place via onResult(i, result, isUpdate) —
|
||||||
// including stale-while-revalidate updates — so callers can render the
|
// 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 { TTL, chunkedFetch, prefetchViews } from "./cache.js";
|
||||||
import { initFindButton, setFindLabel } from "./mappicker.js";
|
import { initFindButton, setFindLabel } from "./mappicker.js";
|
||||||
import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, pctOrd,
|
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,
|
CHUNK_MONTHS, buildChunks, clickOpensPicker, metricBuckets, distStrip,
|
||||||
seasonFilterDropdown, seasonSummaryText, syncSeasonChecks,
|
seasonFilterDropdown, seasonSummaryText, syncSeasonChecks,
|
||||||
applySeasonMonthChange, initSeasonExpand, allMonths, monthsToMask, maskToMonths,
|
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";
|
import { initFilterSheet } from "./filtersheet.js";
|
||||||
|
|
||||||
// The diverging-temperature-scale metrics (colored exactly like High/Low), with a
|
// 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>
|
startInput.value = reqStart.slice(0, 7); // "YYYY-MM" for <input type=month>
|
||||||
endInput.value = reqEnd.slice(0, 7);
|
endInput.value = reqEnd.slice(0, 7);
|
||||||
document.getElementById("metric-block").hidden = false;
|
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;
|
locLabel.hidden = false;
|
||||||
setFindLabel(findBtn, "Change location");
|
setFindLabel(findBtn, "Change location");
|
||||||
renderFilters();
|
renderFilters();
|
||||||
|
|
@ -460,8 +460,7 @@ async function fetchCalendar() {
|
||||||
// Header summary. While `loading`, `remaining` > 0 shows a "loading N more blocks"
|
// Header summary. While `loading`, `remaining` > 0 shows a "loading N more blocks"
|
||||||
// note; once loading has finished, a leftover `remaining` flags chunks that failed.
|
// note; once loading has finished, a leftover `remaining` flags chunks that failed.
|
||||||
function renderHead(remaining, loading) {
|
function renderHead(remaining, loading) {
|
||||||
const cell = data.cell;
|
const place = placeLabel(data);
|
||||||
const place = data.place || `${cell.center_lat.toFixed(3)}, ${cell.center_lon.toFixed(3)}`;
|
|
||||||
const years = ((new Date(data.range.end) - new Date(data.range.start)) / 3.15576e10);
|
const years = ((new Date(data.range.end) - new Date(data.range.start)) / 3.15576e10);
|
||||||
let note = "";
|
let note = "";
|
||||||
if (remaining > 0 && loading) {
|
if (remaining > 0 && loading) {
|
||||||
|
|
@ -575,25 +574,19 @@ function renderTotals(visible) {
|
||||||
totEl.hidden = false;
|
totEl.hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GUIDE_LINK = `<p class="muted tk-note"><a href="legend">Full guide to metrics & grades →</a></p>`;
|
|
||||||
|
|
||||||
function renderKey() {
|
function renderKey() {
|
||||||
if (metric === "dsr") {
|
if (metric === "dsr") {
|
||||||
const steps = [["Rain", 0], ["1 day", 1], ["4", 4], ["7", 7], ["10", 10], ["14+", 14]];
|
const steps = [["Rain", 0], ["1 day", 1], ["4", 4], ["7", 7], ["10", 10], ["14+", 14]];
|
||||||
const segs = steps.map(([lab, d]) =>
|
const segs = tierKeySegs(steps.map(([label, d]) => ({ color: drynessColor(d), label })));
|
||||||
`<div class="tk-seg"><span class="tk-swatch" style="background:${drynessColor(d)}"></span>
|
|
||||||
<span class="tk-label">${lab}</span></div>`).join("");
|
|
||||||
keyEl.innerHTML = `<div class="section-title">Days since last rain: dry streak (caps at ${DRY_CAP} days)</div>
|
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}`;
|
<div class="tier-key">${segs}</div>${GUIDE_LINK}`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (metric === "precip") {
|
if (metric === "precip") {
|
||||||
const rain = [...SCALE_RAIN].reverse().map((t) =>
|
const rain = tierKeySegs([...SCALE_RAIN].reverse().map((t) =>
|
||||||
`<div class="tk-seg"><span class="tk-swatch" style="background:${PRECIP_COLORS[t.c]}"></span>
|
({ color: PRECIP_COLORS[t.c], label: t.label })));
|
||||||
<span class="tk-label">${t.label}</span></div>`).join("");
|
const dry = tierKeySegs([["≤1 day", 1], ["7", 7], ["14+", 14]].map(([label, d]) =>
|
||||||
const dry = [["≤1 day", 1], ["7", 7], ["14+", 14]].map(([l, d]) =>
|
({ color: drynessColor(d), label })));
|
||||||
`<div class="tk-seg"><span class="tk-swatch" style="background:${drynessColor(d)}"></span>
|
|
||||||
<span class="tk-label">${l}</span></div>`).join("");
|
|
||||||
keyEl.innerHTML = `<div class="section-title">Rain intensity · light → heavy</div>
|
keyEl.innerHTML = `<div class="section-title">Rain intensity · light → heavy</div>
|
||||||
<div class="tier-key">${rain}</div>
|
<div class="tier-key">${rain}</div>
|
||||||
<div class="section-title" style="margin-top:10px">Dry days · days since 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";
|
const label = (TEMP_METRIC_INFO[metric] || {}).label || "value";
|
||||||
// Highest tier first (Near Record hot → Near Record cold).
|
// Highest tier first (Near Record hot → Near Record cold).
|
||||||
const segs = [...SCALE_TEMP].reverse().map((t) =>
|
const segs = tierKeySegs([...SCALE_TEMP].reverse().map((t) =>
|
||||||
`<div class="tk-seg"><span class="tk-swatch" style="background:${TIER_COLORS[t.c]}"></span>
|
({ color: TIER_COLORS[t.c], label: t.label })));
|
||||||
<span class="tk-label">${t.label}</span></div>`).join("");
|
|
||||||
keyEl.innerHTML = `<div class="section-title">Coloring by ${label} · low → high vs local normal</div>
|
keyEl.innerHTML = `<div class="section-title">Coloring by ${label} · low → high vs local normal</div>
|
||||||
<div class="tier-key">${segs}</div>${GUIDE_LINK}`;
|
<div class="tier-key">${segs}</div>${GUIDE_LINK}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
|
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
|
||||||
import { fmtTemp, onUnitChange } from "./units.js";
|
import { fmtTemp, onUnitChange } from "./units.js";
|
||||||
import { uv } from "./account.js"; // header sign-in entry + notification bell, and the API-version resolver
|
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 { initFindButton, setFindLabel } from "./mappicker.js";
|
||||||
import { TIER_COLORS, esc, placeLabel } from "./shared.js";
|
import { TIER_COLORS, esc, placeLabel } from "./shared.js";
|
||||||
|
|
||||||
|
|
@ -55,31 +55,19 @@ async function fetchScore() {
|
||||||
history.replaceState(null, "", locHash(selected.lat, selected.lon));
|
history.replaceState(null, "", locHash(selected.lat, selected.lon));
|
||||||
placeholder.hidden = true;
|
placeholder.hidden = true;
|
||||||
scoreHead.hidden = false;
|
scoreHead.hidden = false;
|
||||||
const seq = ++seqCounter;
|
await loadView({
|
||||||
// Delay the spinner: a cache-served render lands in a few ms, so blanking the
|
url: uv(`score?lat=${selected.lat}&lon=${selected.lon}`),
|
||||||
// page immediately would just flash. Only a slow (network) load shows it.
|
ttl: TTL.score, seq: ++seqCounter, current: () => seqCounter,
|
||||||
const spin = setTimeout(() => {
|
spinner: () => {
|
||||||
if (seq !== seqCounter) return;
|
|
||||||
scoreHead.innerHTML = `<p class="spinner">Scoring 45 years of climate…</p>`;
|
scoreHead.innerHTML = `<p class="spinner">Scoring 45 years of climate…</p>`;
|
||||||
scoreBody.innerHTML = "";
|
scoreBody.innerHTML = "";
|
||||||
}, 150);
|
},
|
||||||
let data;
|
onData: finishScore,
|
||||||
try {
|
onError: (err) => {
|
||||||
// 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>`;
|
scoreHead.innerHTML = `<p class="error">${esc(err.message)}</p>`;
|
||||||
scoreBody.innerHTML = "";
|
scoreBody.innerHTML = "";
|
||||||
return;
|
},
|
||||||
}
|
});
|
||||||
clearTimeout(spin);
|
|
||||||
if (seq !== seqCounter) return;
|
|
||||||
finishScore(data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function finishScore(data) {
|
function finishScore(data) {
|
||||||
|
|
|
||||||
|
|
@ -264,6 +264,17 @@ export const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<")
|
||||||
export const placeLabel = (data) =>
|
export const placeLabel = (data) =>
|
||||||
data.place || `${data.cell.center_lat.toFixed(3)}, ${data.cell.center_lon.toFixed(3)}`;
|
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
|
// 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:
|
// backend). For display we lead with the first segment and mute the rest:
|
||||||
// namePrimary → the lead segment; nameParts → { primary, rest } with rest joined by
|
// namePrimary → the lead segment; nameParts → { primary, rest } with rest joined by
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue