Grading daily weather across the range…
`; calEl.innerHTML = ""; keyEl.innerHTML = ""; }, 150); const q = `lat=${selected.lat}&lon=${selected.lon}`; // A custom span is split into ≤2-year requests; the default (no range) is one // last-24-months request that also discovers the latest available date. const chunks = (rangeStart && rangeEnd) ? buildChunks(rangeStart, rangeEnd) : [null]; const dayMap = new Map(); const results = new Array(chunks.length); // per-chunk response (or {error}), by index let donePrefix = 0; // chunks [0, donePrefix) are all loaded // Set the location, pickers, and filters once — off whichever chunk lands first // (cell/place are identical across chunks; the requested span drives the rest). const initOnce = (d) => { if (data) return; data = d; reqStart = rangeStart || d.range.start; reqEnd = rangeEnd || d.range.end; 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.hidden = false; setFindLabel(findBtn, "Change location"); renderFilters(); }; // Advance the visible span over the contiguous run of loaded chunks from the // oldest, so the grid fills top-down with no gaps even when chunks finish out of // order. A failed chunk halts the visible prefix at its position. const advance = () => { while (donePrefix < chunks.length && results[donePrefix] && !results[donePrefix].error) donePrefix++; if (donePrefix === 0 || !data) return; data.days = [...dayMap.values()]; data.range = { start: results[0].range.start, end: results[donePrefix - 1].range.end }; renderHead(chunks.length - donePrefix, true); renderCalendar(); renderKey(); }; const urls = chunks.map((ch) => ch ? `api/v2/calendar?${q}&start=${ch.start}&end=${ch.end}` : `api/v2/calendar?${q}&months=24`); // Bounded-parallel streaming load (see cache.js). Stale-while-revalidate // updates re-merge that chunk's days and repaint — the cell/place metadata is // identical across versions, and initOnce self-guards after the first chunk. await chunkedFetch(urls, { ttl: TTL.calendar, concurrency: CHUNK_CONCURRENCY, isCurrent: () => token === fetchToken, onResult: (i, d) => { results[i] = d; if (!d.error) { for (const x of d.days) dayMap.set(x.date, x); initOnce(d); } advance(); }, }); clearTimeout(spin); if (token !== fetchToken) return; // superseded while loading if (!data) { // every chunk failed const firstErr = results.find((r) => r && r.error); calHead.innerHTML = `${firstErr ? firstErr.error.message : "Failed to load."}
`; return; } renderHead(chunks.length - donePrefix, false); // final head (flags any gap) prefetchViews(selected.lat, selected.lon, ["calendar"]); // warm weekly/day/forecast } // 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 years = ((new Date(data.range.end) - new Date(data.range.start)) / 3.15576e10); let note = ""; if (remaining > 0 && loading) { note = ` · loading ${remaining} more block${remaining === 1 ? "" : "s"}…`; } else if (remaining > 0) { note = ` · ${remaining} block${remaining === 1 ? "" : "s"} failed to load`; } calHead.innerHTML = `${IC_POINTER} Tap or click any day for its weather and grades
`; } function renderCalendar() { if (!data) return; const byDate = new Map(data.days.map((d) => [d.date, d])); const start = new Date(data.range.start + "T00:00:00"); const end = new Date(data.range.end + "T00:00:00"); let y = start.getFullYear(), m = start.getMonth(); const endY = end.getFullYear(), endM = end.getMonth(); let html = ""; const wActive = weatherFilterActive(); const visible = []; // every graded day in a shown month: {rec, pass} while (y < endY || (y === endY && m <= endM)) { // Only render months that are fully inside the graded range — never a partial // boundary month showing a stray day or two. The range is month-based, so a // clipped edge month (e.g. data starting mid-June) is dropped entirely. const mFirst = new Date(y, m, 1), mLast = new Date(y, m + 1, 0); if (mFirst < start || mLast > end) { m++; if (m > 11) { m = 0; y++; } continue; } // Season/year filters: skip months whose season or year is unchecked. if (!checkedSeasons.has(seasonOf(m)) || (checkedYears && !checkedYears.has(y))) { m++; if (m > 11) { m = 0; y++; } continue; } const lead = new Date(y, m, 1).getDay(); // 0 = Sunday const dim = new Date(y, m + 1, 0).getDate(); let cells = ""; for (let i = 0; i < lead; i++) cells += ``; for (let day = 1; day <= dim; day++) { const iso = `${y}-${String(m + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; const rec = byDate.get(iso); let bg = "", pass = true; if (rec) { if (metric === "dsr") { bg = drynessColor(rec.dsr); } else if (metric === "precip") { const g = rec.precip; // Dry days take their dry-streak color; rainy days take the blue ramp. bg = !g ? "" : g.c === "dry" ? drynessColor(rec.dsr) : PRECIP_COLORS[g.c]; } else { const g = rec[metric]; bg = g ? TIER_COLORS[g.c] : ""; } pass = !wActive || weatherPass(rec); visible.push({ rec, pass }); } // Days with no graded record (outside the range) render transparent, not as a // grey tile — so every displayed month reads as clean color with no dead squares. const num = `${day}`; cells += rec ? `No months match the selected seasons/years.
`; renderTotals(visible); attachHover(byDate); } // ---- category totals ---- // "What share of the shown days lands in each category?" for the current metric, // rendered above the grid as a stacked share bar + per-category percentages. // When the weather filter is narrowing, only matching days are totaled (and the // title says how many matched) — so it doubles as a "days you'd love" counter. function renderTotals(visible) { const totEl = document.getElementById("cal-totals"); lastVisible = visible; // remembered so the "Show count" toggle can re-render in place if (!visible.length) { totEl.hidden = true; totEl.innerHTML = ""; return; } const wActive = weatherFilterActive(); const days = wActive ? visible.filter((v) => v.pass).map((v) => v.rec) : visible.map((v) => v.rec); // Buckets low→high for the metric: [label, color, count-fn]. let buckets, title; if (metric === "dsr") { const B = [ ["Rain day", drynessColor(0), (d) => d === 0], ["1–3 dry", drynessColor(2), (d) => d >= 1 && d <= 3], ["4–6 dry", drynessColor(5), (d) => d >= 4 && d <= 6], ["7–13 dry", drynessColor(10), (d) => d >= 7 && d <= 13], ["14+ dry", drynessColor(14), (d) => d >= 14], ]; const vals = days.map((r) => r.dsr).filter((d) => d != null); // Group (index 3) sets which categories share a height scale: the lone "Rain day" // (wet) is scaled apart from the dry-streak buckets so it can't flatten them. buckets = B.map(([label, color, fn], i) => [label, color, vals.filter(fn).length, i === 0 ? "wet" : "dry"]); title = "dry streak"; } else if (metric === "precip") { const classes = days.map((r) => r.precip && r.precip.c).filter(Boolean); // Dry days scale independently of the rain-intensity buckets (wet) so a big dry // count doesn't crush the rain bars — and vice versa. // Index 4 carries the rain-tier key (e.g. "wet-5") so a single crowded label — // "Moderate", wedged between the two-line "Light–Mod"/"Mod–Heavy" — can be nudged // in CSS without brittle text matching. buckets = [["Dry", drynessColor(7), classes.filter((c) => c === "dry").length, "dry"]] .concat(SCALE_RAIN.map((t) => [t.label, PRECIP_COLORS[t.c], classes.filter((c) => c === t.c).length, "wet", t.c])); title = "rain intensity"; } else { const classes = days .map((r) => { const g = r[metric]; return g && g.c; }) .filter(Boolean); // Temperature tiers all share one scale group, so they scale together as before. buckets = SCALE_TEMP.map((t) => [t.label, TIER_COLORS[t.c], classes.filter((c) => c === t.c).length, "temp"]); title = (TEMP_METRIC_INFO[metric] || {}).label || "value"; } const total = buckets.reduce((n, b) => n + b[2], 0); // Per-group subtotals + membership, so a bar's share can be scoped to its own group. const groupTotal = {}, groupCount = {}; for (const b of buckets) { groupTotal[b[3]] = (groupTotal[b[3]] || 0) + b[2]; groupCount[b[3]] = (groupCount[b[3]] || 0) + 1; } // A multi-bar group (rain intensities on precip, streak lengths on dry streak) shows // each bar's share *within that group* — matching the per-group bar heights. A lone // opposing column (Dry on precip, Rain day on dry streak) stays a share of ALL shown // days, since a group-relative percentage there would trivially read 100%. const pctText = (n, group) => { const d = groupCount[group] > 1 ? (groupTotal[group] || 1) : total; const p = 100 * n / d, r = Math.round(p); return n > 0 && r === 0 ? `${p.toFixed(1)}%` : `${r}%`; }; // The strip prints either each category's share (default) or its raw day count, // toggled by the "Show count" checkbox rendered in the header (state: showCount). const valText = (n, group) => !n ? "0" : (showCount ? n.toLocaleString() : pctText(n, group)); const head = wActive ? `${days.length.toLocaleString()} of ${visible.length.toLocaleString()} shown days match the weather filter — by ${title}:` : `${visible.length.toLocaleString()} days shown — by ${title}:`; const countToggle = ``; const header = `No matching days.
`; totEl.hidden = false; return; } // A compact distribution strip: one thin status-colored line per category, ordered // low→high like the color scale, with each value printed above it in that category's // own color. Every category — including the neutral "Normal" center of the diverging // temperature scale — draws a line, so the strip reads as a full distribution. // Category names live in the color key below the grid too, so this stays terse. // Line heights scale to the tallest category *within each scale group* (index 3): // for precip/dry-streak, wet and dry days scale independently, so a lopsided count // on one side doesn't shrink the opposing side's bars into invisibility. Temperature // tiers all sit in one group, so they still scale against each other as before. const maxByGroup = {}; for (const b of buckets) maxByGroup[b[3]] = Math.max(maxByGroup[b[3]] || 1, b[2]); const LINE_MAX = 30; // px — height of the tallest category in its group const cols = buckets.map(([label, color, n, group, cls]) => { const h = !n ? 0 : Math.max(2, Math.round(LINE_MAX * n / maxByGroup[group])); const line = h ? `` : ""; // Caption stacks the category name over its value; the name reserves two lines // so the value figures stay aligned across columns whether a name wraps or not. return `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]) => `