Add a season/month time filter + mobile filter sheet (compare & calendar) (#79)

Compare gains a time-of-year filter it never had; the calendar's flat 4-season
filter is upgraded to the same model so both pages filter time identically.

- Shared season/month model in shared.js: seasons are the primary, color-coded
  selector, the 12 months a nested suboption, backed by one Set of month indices.
  seasonFilterDropdown/syncSeasonChecks/applySeasonMonthChange/seasonSummaryText
  plus month<->bitmask helpers, reused by both pages.
- Compare: filter gates r.date's month into computeStats and the distribution;
  instant re-rank, no refetch. Persisted (cmpMonths bitmask) and carried in the
  URL hash (m=, omitted when all 12). Empty-selection hint when a filter excludes
  every day.
- Calendar: checkedSeasons -> checkedMonths, applied in the render loop; persisted
  as calMonths. Season checkbox is checked/indeterminate by how many of its months
  are on.
- Mobile filter sheet (filtersheet.js): a subtle floating pill slides the page's
  filter panel up as a bottom sheet, reachable from anywhere on the page, on both
  compare and calendar. Desktop keeps the inline panel unchanged. Respects
  reduced-motion and the home-indicator inset.

Frontend-only; no backend or payload change.
This commit is contained in:
Emi Griffith 2026-07-11 22:33:09 -07:00 committed by GitHub
parent e11fc73f46
commit d38bf0192c
6 changed files with 390 additions and 29 deletions

View file

@ -8,7 +8,10 @@ import { initFindButton, setFindLabel } from "./mappicker.js";
import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, ord, import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, ord,
fmtPrecip, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd, fmtPrecip, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd,
CHUNK_MONTHS, buildChunks, clickOpensPicker, metricBuckets, distStrip, CHUNK_MONTHS, buildChunks, clickOpensPicker, metricBuckets, distStrip,
seasonFilterDropdown, seasonSummaryText, syncSeasonChecks,
applySeasonMonthChange, initSeasonExpand, allMonths, monthsToMask, maskToMonths,
weatherType as wxType } from "./shared.js"; weatherType as wxType } from "./shared.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
// display name for the key + tooltip and the formatter for their values. // display name for the key + tooltip and the formatter for their values.
@ -43,13 +46,18 @@ let data = null; // last /api/v2/calendar response
// Coloring metric. Persisted so a refresh keeps your selection. // Coloring metric. Persisted so a refresh keeps your selection.
let metric = localStorage.getItem("thermograph:calMetric") || "tmax"; // tmax|tmin|feels|wind|gust|precip|dsr let metric = localStorage.getItem("thermograph:calMetric") || "tmax"; // tmax|tmin|feels|wind|gust|precip|dsr
// Season/year filters: only months whose season AND year are checked are shown. // Time-of-year filter: only months that are checked (AND whose year is checked) are
// Keys group months (DecFeb = "winter", etc.); in the southern hemisphere those // shown. Seasons are the primary, color-coded selector and months a nested suboption,
// same months are the opposite season, so only the display label flips (s[2]). // but the single source of truth is this Set of month indices (0=Jan … 11=Dec); a
const SEASONS = [["winter", "Winter", "Summer"], ["spring", "Spring", "Fall"], ["summer", "Summer", "Winter"], ["fall", "Fall", "Spring"]]; // season rolls up its three months. Southern-hemisphere labels flip (handled by the
const seasonOf = (m) => (m === 11 || m <= 1) ? "winter" : m <= 4 ? "spring" : m <= 7 ? "summer" : "fall"; // shared helpers via `southern`). Persisted so a refresh keeps your selection.
const seasonLabel = (s) => (selected && selected.lat < 0 ? s[2] : s[1]); function loadCalMonths() {
let checkedSeasons = new Set(SEASONS.map((s) => s[0])); // persists across reloads try { const m = maskToMonths(localStorage.getItem("thermograph:calMonths")); if (m && m.size) return m; } catch (e) {}
return allMonths();
}
let checkedMonths = loadCalMonths();
const southern = () => !!(selected && selected.lat < 0);
const persistCalMonths = () => { try { localStorage.setItem("thermograph:calMonths", monthsToMask(checkedMonths)); } catch (e) {} };
let checkedYears = null; // reset to all available on each fetch let checkedYears = null; // reset to all available on each fetch
// Weather filter (day feel × sky). Unlike seasons/years it filters DAYS, not // Weather filter (day feel × sky). Unlike seasons/years it filters DAYS, not
@ -181,12 +189,8 @@ function calYears() {
let filterYears = []; // the year set behind the Years dropdown (for its summary) let filterYears = []; // the year set behind the Years dropdown (for its summary)
// A collapsed summary of a multi-select: "All" when everything's on, "None" when // A collapsed summary of a multi-select: "All" when everything's on, "None" when
// nothing is, otherwise the picked labels joined. // nothing is, otherwise the picked labels joined. (Seasons/months use the shared
function seasonSummaryText() { // seasonSummaryText.)
if (checkedSeasons.size === SEASONS.length) return "All seasons";
if (checkedSeasons.size === 0) return "None";
return SEASONS.filter((s) => checkedSeasons.has(s[0])).map(seasonLabel).join(", ");
}
function yearSummaryText() { function yearSummaryText() {
const n = checkedYears ? checkedYears.size : 0; const n = checkedYears ? checkedYears.size : 0;
if (!filterYears.length || n === filterYears.length) return "All years"; if (!filterYears.length || n === filterYears.length) return "All years";
@ -206,7 +210,7 @@ function updateFilterSummaries() {
const s = seasonYearEl.querySelector('[data-dd="season"] .cal-dd-sum'); const s = seasonYearEl.querySelector('[data-dd="season"] .cal-dd-sum');
const y = seasonYearEl.querySelector('[data-dd="year"] .cal-dd-sum'); const y = seasonYearEl.querySelector('[data-dd="year"] .cal-dd-sum');
const w = seasonYearEl.querySelector('[data-dd="weather"] .cal-dd-sum'); const w = seasonYearEl.querySelector('[data-dd="weather"] .cal-dd-sum');
if (s) s.textContent = seasonSummaryText(); if (s) s.textContent = seasonSummaryText(checkedMonths, { southern: southern() });
if (y) y.textContent = yearSummaryText(); if (y) y.textContent = yearSummaryText();
if (w) w.textContent = weatherSummaryText(); if (w) w.textContent = weatherSummaryText();
} }
@ -255,18 +259,20 @@ function renderFilters() {
// season/year/weather dropdowns are rebuilt here, since the year set depends on // season/year/weather dropdowns are rebuilt here, since the year set depends on
// the span (the weather sets survive the rebuild — they're module state). // the span (the weather sets survive the rebuild — they're module state).
seasonYearEl.innerHTML = seasonYearEl.innerHTML =
filterDropdown("season", "Seasons", "season", seasonFilterDropdown(checkedMonths, { southern: southern() }) +
SEASONS.map((s) => [s[0], seasonLabel(s), checkedSeasons.has(s[0])]), seasonSummaryText()) +
filterDropdown("year", "Years", "year", filterDropdown("year", "Years", "year",
years.map((y) => [y, y, checkedYears.has(y)]), yearSummaryText()) + years.map((y) => [y, y, checkedYears.has(y)]), yearSummaryText()) +
weatherDropdown(); weatherDropdown();
syncSeasonChecks(seasonYearEl, checkedMonths); // set the season indeterminate state
} }
filtersEl.addEventListener("change", (e) => { filtersEl.addEventListener("change", (e) => {
const cb = e.target.closest("input[type=checkbox]"); const cb = e.target.closest("input[type=checkbox]");
if (!cb) return; if (!cb) return;
if (cb.dataset.season) { if (applySeasonMonthChange(cb, checkedMonths)) { // a season or month toggle
cb.checked ? checkedSeasons.add(cb.dataset.season) : checkedSeasons.delete(cb.dataset.season); syncSeasonChecks(seasonYearEl, checkedMonths);
persistCalMonths();
if (calSheet) calSheet.setActive(checkedMonths.size < 12);
} else if (cb.dataset.year) { } else if (cb.dataset.year) {
const y = +cb.dataset.year; const y = +cb.dataset.year;
cb.checked ? checkedYears.add(y) : checkedYears.delete(y); cb.checked ? checkedYears.add(y) : checkedYears.delete(y);
@ -278,6 +284,11 @@ filtersEl.addEventListener("change", (e) => {
updateFilterSummaries(); updateFilterSummaries();
renderCalendar(); renderCalendar();
}); });
// Wire the per-season "Months" expand toggles (delegated on the stable container),
// and the mobile floating pill that slides the whole filter panel up as a sheet.
initSeasonExpand(seasonYearEl);
const calSheet = initFilterSheet({ panel: filtersEl, label: "Filters" });
calSheet.setActive(checkedMonths.size < 12);
// Close any open filter dropdown when tapping outside it (native <details> stays // Close any open filter dropdown when tapping outside it (native <details> stays
// open otherwise). Doesn't touch the day tooltip's own outside-tap handler. // open otherwise). Doesn't touch the day tooltip's own outside-tap handler.
@ -470,8 +481,8 @@ function renderCalendar() {
m++; if (m > 11) { m = 0; y++; } m++; if (m > 11) { m = 0; y++; }
continue; continue;
} }
// Season/year filters: skip months whose season or year is unchecked. // Time-of-year / year filters: skip months whose month or year is unchecked.
if (!checkedSeasons.has(seasonOf(m)) || (checkedYears && !checkedYears.has(y))) { if (!checkedMonths.has(m) || (checkedYears && !checkedYears.has(y))) {
m++; if (m > 11) { m = 0; y++; } m++; if (m > 11) { m = 0; y++; }
continue; continue;
} }
@ -509,7 +520,7 @@ function renderCalendar() {
<div class="cal-grid">${cells}</div></div>`; <div class="cal-grid">${cells}</div></div>`;
m++; if (m > 11) { m = 0; y++; } m++; if (m > 11) { m = 0; y++; }
} }
calEl.innerHTML = html || `<p class="muted">No months match the selected seasons/years.</p>`; calEl.innerHTML = html || `<p class="muted">No months match the selected months/years.</p>`;
renderTotals(visible); renderTotals(visible);
attachHover(byDate); attachHover(byDate);
} }

View file

@ -92,6 +92,12 @@
</div> </div>
</div> </div>
<div class="cmp-param cmp-season" id="cmp-season">
<span class="cal-filter-label">Time of year</span>
<div class="cmp-season-dd" id="cmp-season-dd"></div>
<span class="hint">Count only days in these seasons/months — re-ranks instantly.</span>
</div>
<div class="cmp-param cmp-range cal-range" id="cmp-range"> <div class="cmp-param cmp-range cal-range" id="cmp-range">
<div class="cal-range-head"> <div class="cal-range-head">
<span class="cal-filter-label">Date range</span> <span class="cal-filter-label">Date range</span>

View file

@ -19,8 +19,12 @@ import { loadLastLocation, saveLastLocation } from "./nav.js";
import { getJSON, TTL } from "./cache.js"; import { getJSON, TTL } from "./cache.js";
import { initFindButton } from "./mappicker.js"; import { initFindButton } from "./mappicker.js";
import { MONTHS, pad, monthStart, monthEnd, buildChunks, import { MONTHS, pad, monthStart, monthEnd, buildChunks,
clickOpensPicker, metricBuckets, distStrip } from "./shared.js"; clickOpensPicker, metricBuckets, distStrip,
seasonFilterDropdown, seasonSummaryText, syncSeasonChecks,
applySeasonMonthChange, initSeasonExpand,
allMonths, monthsToMask, maskToMonths } from "./shared.js";
import { fmtTemp, fmtDelta, onUnitChange } from "./units.js"; import { fmtTemp, fmtDelta, onUnitChange } from "./units.js";
import { initFilterSheet } from "./filtersheet.js";
const CMP = { const CMP = {
lo: "thermograph:cmpLo", lo: "thermograph:cmpLo",
@ -29,6 +33,7 @@ const CMP = {
thi: "thermograph:cmpThi", thi: "thermograph:cmpThi",
basis: "thermograph:cmpBasis", basis: "thermograph:cmpBasis",
range: "thermograph:cmpRange", range: "thermograph:cmpRange",
months: "thermograph:cmpMonths",
locs: "thermograph:cmpLocs", locs: "thermograph:cmpLocs",
distMetric: "thermograph:cmpDistMetric", distMetric: "thermograph:cmpDistMetric",
distCount: "thermograph:cmpDistCount", distCount: "thermograph:cmpDistCount",
@ -64,6 +69,24 @@ function loadTol() {
let { tlo, thi } = loadTol(); let { tlo, thi } = loadTol();
let basis = BASES.includes(lsGet(CMP.basis)) ? lsGet(CMP.basis) : "tmax"; let basis = BASES.includes(lsGet(CMP.basis)) ? lsGet(CMP.basis) : "tmax";
// Time-of-year filter: only days whose month is checked count toward the ranking and
// the distribution. Seasons are the primary selector, months the nested suboption;
// the single source of truth is this Set of month indices (0=Jan … 11=Dec). Restored
// from a 12-char bitmask, defaulting to all 12 months (no filtering).
function loadMonths() {
const m = maskToMonths(lsGet(CMP.months));
return m && m.size ? m : allMonths();
}
let checkedMonths = loadMonths();
const monthsActive = () => checkedMonths.size < 12;
const monthOf = (r) => +r.date.slice(5, 7) - 1;
// A location's days narrowed to the checked months (pass-through when all 12 are on).
function visibleSeries(loc) {
if (!loc.series) return null;
if (!monthsActive()) return loc.series;
return loc.series.filter((r) => r.date && checkedMonths.has(monthOf(r)));
}
// Distribution section (independent of comfort): which metric to bucket by, and // Distribution section (independent of comfort): which metric to bucket by, and
// whether the strip prints shares or raw day counts. // whether the strip prints shares or raw day counts.
const DIST_METRICS = ["tmax", "tmin", "feels", "humid", "wind", "gust", "precip", "dsr"]; const DIST_METRICS = ["tmax", "tmin", "feels", "humid", "wind", "gust", "precip", "dsr"];
@ -106,12 +129,13 @@ function writeHashState() {
const p = new URLSearchParams(); const p = new URLSearchParams();
p.set("lo", lo); p.set("hi", hi); p.set("tlo", tlo); p.set("thi", thi); p.set("b", basis); p.set("lo", lo); p.set("hi", hi); p.set("tlo", tlo); p.set("thi", thi); p.set("b", basis);
p.set("s", range.start); p.set("e", range.end); p.set("s", range.start); p.set("e", range.end);
if (monthsActive()) p.set("m", monthsToMask(checkedMonths)); // omit when all 12 (clean links)
if (locations.length) p.set("loc", locations.map((l) => `${l.lat.toFixed(4)},${l.lon.toFixed(4)}`).join(";")); if (locations.length) p.set("loc", locations.map((l) => `${l.lat.toFixed(4)},${l.lon.toFixed(4)}`).join(";"));
history.replaceState(null, "", "#" + p.toString()); history.replaceState(null, "", "#" + p.toString());
} }
function readHashState() { function readHashState() {
const p = new URLSearchParams(location.hash.slice(1)); const p = new URLSearchParams(location.hash.slice(1));
if (!["lo", "hi", "tlo", "thi", "c", "t", "b", "s", "e", "loc"].some((k) => p.has(k))) return null; // not a compare link if (!["lo", "hi", "tlo", "thi", "c", "t", "b", "s", "e", "m", "loc"].some((k) => p.has(k))) return null; // not a compare link
const st = {}; const st = {};
if (p.has("lo") || p.has("hi")) { if (p.has("lo") || p.has("hi")) {
const l = clamp(+p.get("lo"), R_MIN, R_MAX), h = clamp(+p.get("hi"), R_MIN, R_MAX); const l = clamp(+p.get("lo"), R_MIN, R_MAX), h = clamp(+p.get("hi"), R_MIN, R_MAX);
@ -129,6 +153,7 @@ function readHashState() {
if (BASES.includes(p.get("b"))) st.basis = p.get("b"); if (BASES.includes(p.get("b"))) st.basis = p.get("b");
if (isYM(p.get("s"))) st.start = p.get("s"); if (isYM(p.get("s"))) st.start = p.get("s");
if (isYM(p.get("e"))) st.end = p.get("e"); if (isYM(p.get("e"))) st.end = p.get("e");
if (p.has("m")) { const ms = maskToMonths(p.get("m")); if (ms && ms.size) st.months = ms; }
if (p.has("loc")) { if (p.has("loc")) {
st.locs = p.get("loc").split(";").map((pair) => { st.locs = p.get("loc").split(";").map((pair) => {
const [a, b] = pair.split(",").map(Number); const [a, b] = pair.split(",").map(Number);
@ -160,6 +185,7 @@ const tickEls = {
const fillEl = document.getElementById("cmp-fill"); const fillEl = document.getElementById("cmp-fill");
const fillTolEl = document.getElementById("cmp-fill-tol"); const fillTolEl = document.getElementById("cmp-fill-tol");
const basisToggle = document.getElementById("cmp-basis"); const basisToggle = document.getElementById("cmp-basis");
const seasonDd = document.getElementById("cmp-season-dd");
const startInput = document.getElementById("cmp-start"); const startInput = document.getElementById("cmp-start");
const endInput = document.getElementById("cmp-end"); const endInput = document.getElementById("cmp-end");
const distSection = document.getElementById("cmp-dist"); const distSection = document.getElementById("cmp-dist");
@ -442,7 +468,7 @@ function renderBaseline(scored) {
function renderResults() { function renderResults() {
const loading = locations.filter((l) => l.loading).length; const loading = locations.filter((l) => l.loading).length;
const scored = locations const scored = locations
.map((l) => ({ loc: l, s: l.series ? computeStats(l.series) : null })) .map((l) => ({ loc: l, s: l.series ? computeStats(visibleSeries(l)) : null }))
.filter((x) => x.s); .filter((x) => x.s);
// Best match score first; break ties by the smaller typical miss. // Best match score first; break ties by the smaller typical miss.
scored.sort((a, b) => (b.s.score - a.s.score) || (a.s.mad - b.s.mad)); scored.sort((a, b) => (b.s.score - a.s.score) || (a.s.mad - b.s.mad));
@ -451,9 +477,13 @@ function renderResults() {
if (!scored.length) { if (!scored.length) {
head.hidden = true; head.hidden = true;
// Loaded places can score empty when the month filter excludes all their days.
const filteredOut = !loading && monthsActive() && locations.some((l) => l.series && l.series.length);
results.innerHTML = loading results.innerHTML = loading
? `<p class="cmp-loading"><span class="cmp-spinner"></span>Loading daily weather…</p>` + ? `<p class="cmp-loading"><span class="cmp-spinner"></span>Loading daily weather…</p>` +
Array.from({ length: Math.min(loading, 3) }, skeletonRow).join("") Array.from({ length: Math.min(loading, 3) }, skeletonRow).join("")
: filteredOut
? `<p class="muted">No days fall in the selected months. Widen the time-of-year filter.</p>`
: ""; : "";
return; return;
} }
@ -479,10 +509,12 @@ function renderResults() {
// are percentile tiers, but each bar now shows the tier's average value + range, so it // are percentile tiers, but each bar now shows the tier's average value + range, so it
// re-renders on the °C/°F toggle (wired below). // re-renders on the °C/°F toggle (wired below).
function renderDist() { function renderDist() {
const withData = locations.filter((l) => l.series && l.series.length); const withData = locations
.map((l) => ({ l, vis: visibleSeries(l) }))
.filter((x) => x.vis && x.vis.length);
if (!withData.length) { distSection.hidden = true; distBody.innerHTML = ""; return; } if (!withData.length) { distSection.hidden = true; distBody.innerHTML = ""; return; }
distBody.innerHTML = withData.map((l) => { distBody.innerHTML = withData.map(({ l, vis }) => {
const buckets = metricBuckets(l.series, distMetric); const buckets = metricBuckets(vis, distMetric);
const total = buckets.reduce((n, b) => n + b.n, 0); const total = buckets.reduce((n, b) => n + b.n, 0);
const name = l.name || `${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}`; const name = l.name || `${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}`;
const strip = total ? distStrip(buckets, distCount) : `<p class="muted">No data for this metric.</p>`; const strip = total ? distStrip(buckets, distCount) : `<p class="muted">No data for this metric.</p>`;
@ -580,6 +612,31 @@ basisToggle.addEventListener("click", (e) => {
renderResults(); renderResults();
}); });
// ---- time-of-year (season / month) filter ----
// Seasons are primary, months the nested suboption; both drive the same Set. A toggle
// re-ranks and re-buckets over data already in hand — no refetch. The dropdown isn't
// rebuilt on change (that would collapse it): the native checkboxes hold their own
// state, and we only re-derive the season indeterminate state + the summary text.
function buildSeasonFilter() {
seasonDd.innerHTML = seasonFilterDropdown(checkedMonths);
syncSeasonChecks(seasonDd, checkedMonths);
}
initSeasonExpand(seasonDd);
seasonDd.addEventListener("change", (e) => {
const cb = e.target.closest("input[type=checkbox]");
if (!cb || !applySeasonMonthChange(cb, checkedMonths)) return;
syncSeasonChecks(seasonDd, checkedMonths);
seasonDd.querySelector(".cal-dd-sum").textContent = seasonSummaryText(checkedMonths);
lsSet(CMP.months, monthsToMask(checkedMonths));
writeHashState();
renderResults();
renderDist();
if (fabSheet) fabSheet.setActive(monthsActive());
});
// Mobile: a floating pill slides the whole params panel up as a bottom sheet.
let fabSheet = null;
// Editing the dates doesn't load — it just arms Refresh (re-eval via renderAll). // Editing the dates doesn't load — it just arms Refresh (re-eval via renderAll).
startInput.max = endInput.max = `${new Date().getFullYear()}-12`; startInput.max = endInput.max = `${new Date().getFullYear()}-12`;
startInput.addEventListener("change", renderAll); startInput.addEventListener("change", renderAll);
@ -596,6 +653,7 @@ clickOpensPicker(startInput, endInput); // whole-field tap opens the month pic
if (hash.thi != null) thi = hash.thi; if (hash.thi != null) thi = hash.thi;
if (hash.basis) basis = hash.basis; if (hash.basis) basis = hash.basis;
if (hash.start && hash.end) range = { start: hash.start, end: hash.end }; if (hash.start && hash.end) range = { start: hash.start, end: hash.end };
if (hash.months) checkedMonths = hash.months;
} }
// Keep comfort ordered and inside tolerance even if a hand-edited link isn't. // Keep comfort ordered and inside tolerance even if a hand-edited link isn't.
if (lo > hi) { const t = lo; lo = hi; hi = t; } if (lo > hi) { const t = lo; lo = hi; hi = t; }
@ -606,6 +664,9 @@ clickOpensPicker(startInput, endInput); // whole-field tap opens the month pic
distToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b.dataset.metric === distMetric)); distToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b.dataset.metric === distMetric));
document.getElementById("cmp-dist-count").checked = distCount; document.getElementById("cmp-dist-count").checked = distCount;
startInput.value = range.start; endInput.value = range.end; startInput.value = range.start; endInput.value = range.end;
buildSeasonFilter();
fabSheet = initFilterSheet({ panel: params, label: "Filters" });
fabSheet.setActive(monthsActive());
// A shared link's locations win; else the last-used set; else seed from the spot // A shared link's locations win; else the last-used set; else seed from the spot
// the other views were on so the page isn't empty. All three auto-load on arrival // the other views were on so the page isn't empty. All three auto-load on arrival

72
static/filtersheet.js Normal file
View file

@ -0,0 +1,72 @@
// Mobile filter access: a small floating "Filters" pill (fixed, reachable from
// anywhere on the page) that slides the page's existing filter panel up as a bottom
// sheet. Desktop is untouched — the pill, scrim and grip are display:none above the
// phone breakpoint, so the panel stays inline exactly as before (all styling lives in
// the @media(max-width:640px) block in style.css). Shared by compare + calendar.
//
// initFilterSheet({ panel, label })
// panel — the filter panel element (becomes the sheet on mobile)
// label — text on the pill and the sheet header (e.g. "Filters")
// returns { setActive(bool), close() }
const FUNNEL = `<svg class="ff-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 5h18M6 12h12M10 19h4"/></svg>`;
export function initFilterSheet({ panel, label = "Filters" }) {
if (!panel) return { setActive() {}, close() {} };
// The pill lives in <body> so it floats over the whole page regardless of scroll.
const fab = document.createElement("button");
fab.type = "button";
fab.className = "filter-fab";
fab.setAttribute("aria-expanded", "false");
fab.innerHTML = `${FUNNEL}<span class="ff-label">${label}</span><span class="ff-dot" hidden aria-hidden="true"></span>`;
const scrim = document.createElement("div");
scrim.className = "filter-scrim";
scrim.hidden = true;
// A grab handle + title + close, prepended to the panel; only shown in sheet mode.
const grip = document.createElement("div");
grip.className = "filter-sheet-grip";
grip.innerHTML = `<span class="fsg-bar" aria-hidden="true"></span>` +
`<span class="fsg-title">${label}</span>` +
`<button type="button" class="fsg-close" aria-label="Close filters">✕</button>`;
panel.prepend(grip);
document.body.append(scrim, fab);
document.body.classList.add("has-filter-sheet"); // scopes the extra bottom padding
const isOpen = () => panel.classList.contains("open");
function open() {
panel.classList.add("open");
scrim.hidden = false;
fab.setAttribute("aria-expanded", "true");
}
function close() {
panel.classList.remove("open");
scrim.hidden = true;
fab.setAttribute("aria-expanded", "false");
}
fab.addEventListener("click", () => (isOpen() ? close() : open()));
scrim.addEventListener("click", close);
grip.querySelector(".fsg-close").addEventListener("click", close);
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && isOpen()) close(); });
// The pill only makes sense once the page has data to filter — the panel starts
// hidden and the page reveals it. Mirror that onto the pill, and drop the sheet if
// the panel goes away.
const reflect = () => {
fab.classList.toggle("ready", !panel.hidden);
if (panel.hidden && isOpen()) close();
};
new MutationObserver(reflect).observe(panel, { attributes: true, attributeFilter: ["hidden"] });
reflect();
return {
// Show a dot on the pill when a filter subset is active (so the applied filter is
// visible even with the sheet closed).
setActive(active) { fab.querySelector(".ff-dot").hidden = !active; },
close,
};
}

View file

@ -228,6 +228,120 @@ export function clickOpensPicker(...els) {
} }
} }
// ---- season / month time filter (shared by calendar + compare) ----
// Seasons are the primary, color-coded selector; the 12 months are the underlying
// unit and a nested suboption. The single source of truth on both pages is a Set of
// month indices (0=Jan … 11=Dec); a season is a roll-up over its three months.
export const seasonOf = (m) => (m === 11 || m <= 1) ? "winter" : m <= 4 ? "spring" : m <= 7 ? "summer" : "fall";
// [key, northern label, southern label]: the southern hemisphere sees the opposite
// season for the same calendar months, so only the display label flips.
export const SEASONS = [
["winter", "Winter", "Summer"], ["spring", "Spring", "Fall"],
["summer", "Summer", "Winter"], ["fall", "Fall", "Spring"],
];
export const monthsOfSeason = (key) =>
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].filter((m) => seasonOf(m) === key);
export const allMonths = () => new Set([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
// A Set<0..11> ⇄ a compact 12-char "0/1" bitmask (for localStorage + URL hashes).
export const monthsToMask = (set) =>
Array.from({ length: 12 }, (_, m) => (set.has(m) ? "1" : "0")).join("");
export function maskToMonths(mask) {
if (typeof mask !== "string" || !/^[01]{12}$/.test(mask)) return null; // invalid → caller defaults
const set = new Set();
for (let m = 0; m < 12; m++) if (mask[m] === "1") set.add(m);
return set;
}
// A collapsed summary of the month selection: "All year" / "None" / whole seasons
// ("Summer") / seasons plus stray months ("Summer +1 mo") / a short month list / a count.
export function seasonSummaryText(checkedMonths, opts = {}) {
const n = checkedMonths.size;
if (n === 0) return "None";
if (n === 12) return "All year";
const full = SEASONS.filter((s) => monthsOfSeason(s[0]).every((m) => checkedMonths.has(m)));
const inFull = new Set(full.flatMap((s) => monthsOfSeason(s[0])));
const extra = [...checkedMonths].filter((m) => !inFull.has(m));
const names = full.map((s) => (opts.southern ? s[2] : s[1]));
if (names.length && !extra.length) return names.join(", ");
if (names.length) return `${names.join(", ")} +${extra.length} mo`;
if (n <= 3) return [...checkedMonths].sort((a, b) => a - b).map((m) => MONTHS[m]).join(", ");
return `${n} months`;
}
// Markup for the season-primary time filter: a compact <details> (the shared
// .cal-dd dropdown) whose panel lists the four seasons as colored rows — each a
// season checkbox (checked/indeterminate by how many of its months are on) plus an
// expand button revealing that season's three month checkboxes (the suboption).
// Pages insert this, then call syncSeasonChecks() to set the indeterminate state and
// initSeasonExpand() once on a stable ancestor to wire the expand toggles.
export function seasonFilterDropdown(checkedMonths, opts = {}) {
const rows = SEASONS.map((s) => {
const key = s[0], label = opts.southern ? s[2] : s[1], ms = monthsOfSeason(key);
const on = ms.filter((m) => checkedMonths.has(m)).length;
const months = ms.map((m) =>
`<label class="cal-dd-opt month-opt"><input type="checkbox" data-month="${m}"${checkedMonths.has(m) ? " checked" : ""}>${MONTHS[m]}</label>`).join("");
return `<div class="season-row" data-season="${key}" style="--season:var(--season-${key})">
<div class="season-head">
<label class="season-check"><input type="checkbox" class="season-cb" data-season="${key}"${on === ms.length ? " checked" : ""}>
<span class="season-sw" aria-hidden="true"></span><span class="season-name">${label}</span></label>
<button type="button" class="season-expand" data-expand="${key}" aria-expanded="false">Months <span class="season-caret" aria-hidden="true"></span></button>
</div>
<div class="season-months" hidden>${months}</div>
</div>`;
}).join("");
return `<details class="cal-dd cal-season-dd" data-dd="season">
<summary>
<span class="cal-filter-label">Seasons</span>
<span class="cal-dd-sum">${seasonSummaryText(checkedMonths, opts)}</span>
<span class="cal-dd-caret" aria-hidden="true"></span>
</summary>
<div class="cal-dd-panel cal-season-panel">${rows}</div>
</details>`;
}
// Reflect the month Set onto the four season checkboxes: checked when all three of a
// season's months are on, indeterminate when one or two are (indeterminate is a JS
// property, so this must run after every render/change).
export function syncSeasonChecks(root, checkedMonths) {
root.querySelectorAll(".season-cb").forEach((cb) => {
const ms = monthsOfSeason(cb.dataset.season);
const on = ms.filter((m) => checkedMonths.has(m)).length;
cb.checked = on === ms.length;
cb.indeterminate = on > 0 && on < ms.length;
});
}
// Apply a season/month checkbox change to the month Set. Returns true if the event
// was a season/month toggle (so the caller knows to persist + re-render).
export function applySeasonMonthChange(cb, checkedMonths) {
if (cb.dataset.season) {
const ms = monthsOfSeason(cb.dataset.season);
if (cb.checked) ms.forEach((m) => checkedMonths.add(m));
else ms.forEach((m) => checkedMonths.delete(m));
return true;
}
if (cb.dataset.month != null) {
const m = +cb.dataset.month;
if (cb.checked) checkedMonths.add(m); else checkedMonths.delete(m);
return true;
}
return false;
}
// Wire the per-season "Months" expand toggles (delegated on a stable ancestor).
export function initSeasonExpand(container) {
container.addEventListener("click", (e) => {
const btn = e.target.closest(".season-expand");
if (!btn || !container.contains(btn)) return;
const row = btn.closest(".season-row");
const wrap = row.querySelector(".season-months");
const show = wrap.hidden;
wrap.hidden = !show;
btn.setAttribute("aria-expanded", String(show));
row.classList.toggle("expanded", show);
});
}
// ---- weather summary ---- // ---- weather summary ----
// Monochrome line icons (currentColor) — no emoji, matching the site's dark // Monochrome line icons (currentColor) — no emoji, matching the site's dark
// look. Feather-style paths. // look. Feather-style paths.

View file

@ -712,6 +712,50 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
} }
.cal-weather .cal-dd-group { grid-column: 1 / -1; } .cal-weather .cal-dd-group { grid-column: 1 / -1; }
/* --- season-primary time filter (shared: calendar Seasons dropdown + compare) ---
Seasons are the primary, color-coded selector; each expands to its three months
(the nested suboption). One month Set backs both; a season checkbox is checked when
all three of its months are on, indeterminate (native dash) when one or two are. */
:root {
--season-winter: #5b9bd5; --season-spring: #6fbf73;
--season-summer: #e6a52c; --season-fall: #c56a3a;
}
.cal-season-panel { min-width: 212px; max-height: 320px; overflow-y: auto; gap: 4px; }
.season-row { border-radius: 8px; }
.season-row.expanded { background: color-mix(in oklab, var(--season) 12%, transparent); }
.season-head { display: flex; align-items: center; gap: 8px; }
.season-check {
display: flex; align-items: center; gap: 9px; flex: 1 1 auto; cursor: pointer;
padding: 8px 10px; border-radius: 7px; min-height: 40px; font-size: 14px;
}
.season-check:hover { background: var(--surface-2); }
.season-check input { accent-color: var(--season); width: 17px; height: 17px; margin: 0; flex: 0 0 auto; }
.season-sw {
width: 12px; height: 12px; border-radius: 3px; background: var(--season); flex: 0 0 auto;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .25);
}
.season-name { font-weight: 600; }
.season-expand {
flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px; cursor: pointer;
background: transparent; border: 1px solid var(--border); border-radius: 7px;
color: var(--muted); font-size: 12px; padding: 6px 9px; min-height: 34px;
}
.season-expand:hover { color: var(--text); border-color: var(--season); }
.season-caret { font-size: 10px; transition: transform .15s ease; }
.season-expand[aria-expanded="true"] .season-caret { transform: rotate(180deg); }
.season-months {
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 2px;
padding: 2px 4px 6px; margin: 0 0 4px 12px; border-left: 2px solid var(--season);
}
.season-months .month-opt { font-size: 13px; padding: 7px 4px; min-height: 38px; justify-content: flex-start; }
.season-months .month-opt input { accent-color: var(--season); }
.cmp-season-dd .cal-dd { display: block; }
/* Mobile filter sheet the pill, scrim and grip stay hidden above the phone
breakpoint (switched on inside the max-width:640px block); desktop keeps the filter
panel inline. */
.filter-fab, .filter-scrim, .filter-sheet-grip { display: none; }
/* Category totals above the grid: a compact distribution strip. Each category is a /* Category totals above the grid: a compact distribution strip. Each category is a
column captioned with its name over its value (share by default, raw count when column captioned with its name over its value (share by default, raw count when
"Show count" is ticked, in that category's own status color) above a thin status- "Show count" is ticked, in that category's own status color) above a thin status-
@ -1082,8 +1126,56 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
.guide-metrics { grid-template-columns: 1fr; gap: 2px 0; } .guide-metrics { grid-template-columns: 1fr; gap: 2px 0; }
.guide-metrics dt { margin-top: 10px; } .guide-metrics dt { margin-top: 10px; }
.cmp-params { grid-template-columns: 1fr; padding: 14px; } .cmp-params { grid-template-columns: 1fr; }
.cmp-basis-block .metric-toggle button { flex: 1 0 22%; padding: 10px 4px; } .cmp-basis-block .metric-toggle button { flex: 1 0 22%; padding: 10px 4px; }
/* --- filters reachable from anywhere: a floating pill slides the page's filter
panel up as a bottom sheet (compare #cmp-params, calendar #cal-filters) --- */
.filter-fab {
display: none; position: fixed; z-index: 900;
left: 50%; transform: translateX(-50%);
bottom: calc(14px + env(safe-area-inset-bottom));
align-items: center; gap: 7px; padding: 9px 16px; min-height: 40px;
background: color-mix(in srgb, var(--surface) 92%, transparent);
color: var(--text); border: 1px solid var(--border); border-radius: 999px;
box-shadow: 0 6px 20px rgba(0, 0, 0, .38); backdrop-filter: blur(6px);
font-size: 13px; font-weight: 600; cursor: pointer;
}
.filter-fab.ready { display: inline-flex; }
.filter-fab .ff-ic { width: 16px; height: 16px; }
.filter-fab .ff-dot {
width: 8px; height: 8px; border-radius: 50%; background: var(--accent);
box-shadow: 0 0 0 2px var(--surface);
}
.filter-scrim { display: block; position: fixed; inset: 0; z-index: 950; background: rgba(0, 0, 0, .5); }
.filter-scrim[hidden] { display: none; }
/* The filter panel becomes a bottom sheet, off-screen until the pill opens it.
id selectors keep these above the pages' own class-based mobile rules. */
#cmp-params, #cal-filters {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 960;
max-width: none; margin: 0; border-radius: 16px 16px 0 0;
max-height: 85dvh; overflow-y: auto; transform: translateY(110%);
padding: 16px 16px calc(18px + env(safe-area-inset-bottom));
box-shadow: 0 -12px 40px rgba(0, 0, 0, .5);
}
#cmp-params.open, #cal-filters.open { transform: translateY(0); }
.filter-sheet-grip {
display: flex; align-items: center; gap: 10px; position: relative;
padding: 6px 2px 10px; margin-bottom: 10px; border-bottom: 1px solid var(--border);
}
.filter-sheet-grip .fsg-bar {
position: absolute; top: -6px; left: 50%; transform: translateX(-50%);
width: 36px; height: 4px; border-radius: 2px; background: var(--border);
}
.filter-sheet-grip .fsg-title { font-weight: 700; font-size: 15px; }
.filter-sheet-grip .fsg-close {
margin-left: auto; background: transparent; border: none; color: var(--muted);
font-size: 20px; line-height: 1; min-width: 40px; min-height: 40px; cursor: pointer;
}
/* Room so the pill never covers the last bit of content (only on pages with it). */
body.has-filter-sheet main { padding-bottom: 76px; }
.cmp-big b { font-size: 20px; } .cmp-big b { font-size: 20px; }
/* Baseline strip: tighten the name gutter and shrink text so the shared axis /* Baseline strip: tighten the name gutter and shrink text so the shared axis
still fits a phone; keep the axis ticks aligned to the narrower track. */ still fits a phone; keep the axis ticks aligned to the narrower track. */
@ -1092,3 +1184,8 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
.cmp-bl-axis { margin-left: 92px; } .cmp-bl-axis { margin-left: 92px; }
.cmp-key { font-size: 11.5px; gap: 5px 12px; } .cmp-key { font-size: 11.5px; gap: 5px 12px; }
} }
/* Slide the filter sheet only when motion is welcome (phones). */
@media (max-width: 640px) and (prefers-reduced-motion: no-preference) {
#cmp-params, #cal-filters { transition: transform .28s ease; }
}