From d38bf0192c18ff54d6d2e40f7c6d6ebff9933177 Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Sat, 11 Jul 2026 22:33:09 -0700 Subject: [PATCH] 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. --- static/calendar.js | 53 ++++++++++++-------- static/compare.html | 6 +++ static/compare.js | 75 ++++++++++++++++++++++++--- static/filtersheet.js | 72 ++++++++++++++++++++++++++ static/shared.js | 114 ++++++++++++++++++++++++++++++++++++++++++ static/style.css | 99 +++++++++++++++++++++++++++++++++++- 6 files changed, 390 insertions(+), 29 deletions(-) create mode 100644 static/filtersheet.js diff --git a/static/calendar.js b/static/calendar.js index 3843df7..737e9f0 100644 --- a/static/calendar.js +++ b/static/calendar.js @@ -8,7 +8,10 @@ import { initFindButton, setFindLabel } from "./mappicker.js"; import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, ord, fmtPrecip, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd, CHUNK_MONTHS, buildChunks, clickOpensPicker, metricBuckets, distStrip, + seasonFilterDropdown, seasonSummaryText, syncSeasonChecks, + applySeasonMonthChange, initSeasonExpand, allMonths, monthsToMask, maskToMonths, weatherType as wxType } from "./shared.js"; +import { initFilterSheet } from "./filtersheet.js"; // The diverging-temperature-scale metrics (colored exactly like High/Low), with a // 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. 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. -// Keys group months (Dec–Feb = "winter", etc.); in the southern hemisphere those -// same months are the opposite season, so only the display label flips (s[2]). -const SEASONS = [["winter", "Winter", "Summer"], ["spring", "Spring", "Fall"], ["summer", "Summer", "Winter"], ["fall", "Fall", "Spring"]]; -const seasonOf = (m) => (m === 11 || m <= 1) ? "winter" : m <= 4 ? "spring" : m <= 7 ? "summer" : "fall"; -const seasonLabel = (s) => (selected && selected.lat < 0 ? s[2] : s[1]); -let checkedSeasons = new Set(SEASONS.map((s) => s[0])); // persists across reloads +// Time-of-year filter: only months that are checked (AND whose year is checked) are +// shown. Seasons are the primary, color-coded selector and months a nested suboption, +// but the single source of truth is this Set of month indices (0=Jan … 11=Dec); a +// season rolls up its three months. Southern-hemisphere labels flip (handled by the +// shared helpers via `southern`). Persisted so a refresh keeps your selection. +function loadCalMonths() { + 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 // 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) // A collapsed summary of a multi-select: "All" when everything's on, "None" when -// nothing is, otherwise the picked labels joined. -function 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(", "); -} +// nothing is, otherwise the picked labels joined. (Seasons/months use the shared +// seasonSummaryText.) function yearSummaryText() { const n = checkedYears ? checkedYears.size : 0; 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 y = seasonYearEl.querySelector('[data-dd="year"] .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 (w) w.textContent = weatherSummaryText(); } @@ -255,18 +259,20 @@ function renderFilters() { // 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). seasonYearEl.innerHTML = - filterDropdown("season", "Seasons", "season", - SEASONS.map((s) => [s[0], seasonLabel(s), checkedSeasons.has(s[0])]), seasonSummaryText()) + + seasonFilterDropdown(checkedMonths, { southern: southern() }) + filterDropdown("year", "Years", "year", years.map((y) => [y, y, checkedYears.has(y)]), yearSummaryText()) + weatherDropdown(); + syncSeasonChecks(seasonYearEl, checkedMonths); // set the season indeterminate state } filtersEl.addEventListener("change", (e) => { const cb = e.target.closest("input[type=checkbox]"); if (!cb) return; - if (cb.dataset.season) { - cb.checked ? checkedSeasons.add(cb.dataset.season) : checkedSeasons.delete(cb.dataset.season); + if (applySeasonMonthChange(cb, checkedMonths)) { // a season or month toggle + syncSeasonChecks(seasonYearEl, checkedMonths); + persistCalMonths(); + if (calSheet) calSheet.setActive(checkedMonths.size < 12); } else if (cb.dataset.year) { const y = +cb.dataset.year; cb.checked ? checkedYears.add(y) : checkedYears.delete(y); @@ -278,6 +284,11 @@ filtersEl.addEventListener("change", (e) => { updateFilterSummaries(); 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
stays // 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++; } continue; } - // Season/year filters: skip months whose season or year is unchecked. - if (!checkedSeasons.has(seasonOf(m)) || (checkedYears && !checkedYears.has(y))) { + // Time-of-year / year filters: skip months whose month or year is unchecked. + if (!checkedMonths.has(m) || (checkedYears && !checkedYears.has(y))) { m++; if (m > 11) { m = 0; y++; } continue; } @@ -509,7 +520,7 @@ function renderCalendar() {
${cells}
`; m++; if (m > 11) { m = 0; y++; } } - calEl.innerHTML = html || `

No months match the selected seasons/years.

`; + calEl.innerHTML = html || `

No months match the selected months/years.

`; renderTotals(visible); attachHover(byDate); } diff --git a/static/compare.html b/static/compare.html index 7208a4b..1534fa9 100644 --- a/static/compare.html +++ b/static/compare.html @@ -92,6 +92,12 @@ +
+ Time of year +
+ Count only days in these seasons/months — re-ranks instantly. +
+
Date range diff --git a/static/compare.js b/static/compare.js index ab9504f..714e491 100644 --- a/static/compare.js +++ b/static/compare.js @@ -19,8 +19,12 @@ import { loadLastLocation, saveLastLocation } from "./nav.js"; import { getJSON, TTL } from "./cache.js"; import { initFindButton } from "./mappicker.js"; 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 { initFilterSheet } from "./filtersheet.js"; const CMP = { lo: "thermograph:cmpLo", @@ -29,6 +33,7 @@ const CMP = { thi: "thermograph:cmpThi", basis: "thermograph:cmpBasis", range: "thermograph:cmpRange", + months: "thermograph:cmpMonths", locs: "thermograph:cmpLocs", distMetric: "thermograph:cmpDistMetric", distCount: "thermograph:cmpDistCount", @@ -64,6 +69,24 @@ function loadTol() { let { tlo, thi } = loadTol(); 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 // whether the strip prints shares or raw day counts. const DIST_METRICS = ["tmax", "tmin", "feels", "humid", "wind", "gust", "precip", "dsr"]; @@ -106,12 +129,13 @@ function writeHashState() { 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("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(";")); history.replaceState(null, "", "#" + p.toString()); } function readHashState() { 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 = {}; 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); @@ -129,6 +153,7 @@ function readHashState() { 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("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")) { st.locs = p.get("loc").split(";").map((pair) => { const [a, b] = pair.split(",").map(Number); @@ -160,6 +185,7 @@ const tickEls = { const fillEl = document.getElementById("cmp-fill"); const fillTolEl = document.getElementById("cmp-fill-tol"); const basisToggle = document.getElementById("cmp-basis"); +const seasonDd = document.getElementById("cmp-season-dd"); const startInput = document.getElementById("cmp-start"); const endInput = document.getElementById("cmp-end"); const distSection = document.getElementById("cmp-dist"); @@ -442,7 +468,7 @@ function renderBaseline(scored) { function renderResults() { const loading = locations.filter((l) => l.loading).length; 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); // 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)); @@ -451,10 +477,14 @@ function renderResults() { if (!scored.length) { 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 ? `

Loading daily weather…

` + Array.from({ length: Math.min(loading, 3) }, skeletonRow).join("") - : ""; + : filteredOut + ? `

No days fall in the selected months. Widen the time-of-year filter.

` + : ""; return; } @@ -479,10 +509,12 @@ function renderResults() { // 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). 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; } - distBody.innerHTML = withData.map((l) => { - const buckets = metricBuckets(l.series, distMetric); + distBody.innerHTML = withData.map(({ l, vis }) => { + const buckets = metricBuckets(vis, distMetric); const total = buckets.reduce((n, b) => n + b.n, 0); const name = l.name || `${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}`; const strip = total ? distStrip(buckets, distCount) : `

No data for this metric.

`; @@ -580,6 +612,31 @@ basisToggle.addEventListener("click", (e) => { 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). startInput.max = endInput.max = `${new Date().getFullYear()}-12`; 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.basis) basis = hash.basis; 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. 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)); document.getElementById("cmp-dist-count").checked = distCount; 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 // the other views were on so the page isn't empty. All three auto-load on arrival diff --git a/static/filtersheet.js b/static/filtersheet.js new file mode 100644 index 0000000..da9bccf --- /dev/null +++ b/static/filtersheet.js @@ -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 = ``; + +export function initFilterSheet({ panel, label = "Filters" }) { + if (!panel) return { setActive() {}, close() {} }; + + // The pill lives in 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}${label}`; + + 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 = `` + + `${label}` + + ``; + 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, + }; +} diff --git a/static/shared.js b/static/shared.js index 3c0417e..108797d 100644 --- a/static/shared.js +++ b/static/shared.js @@ -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
(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) => + ``).join(""); + return `
+
+ + +
+ +
`; + }).join(""); + return `
+ + Seasons + ${seasonSummaryText(checkedMonths, opts)} + + +
${rows}
+
`; +} + +// 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 ---- // Monochrome line icons (currentColor) — no emoji, matching the site's dark // look. Feather-style paths. diff --git a/static/style.css b/static/style.css index b1f5960..501f8f9 100644 --- a/static/style.css +++ b/static/style.css @@ -712,6 +712,50 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; } } .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 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- @@ -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 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; } + + /* --- 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; } /* 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. */ @@ -1092,3 +1184,8 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; } .cmp-bl-axis { margin-left: 92px; } .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; } +}