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:
parent
e11fc73f46
commit
d38bf0192c
6 changed files with 390 additions and 29 deletions
|
|
@ -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 <details> 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() {
|
|||
<div class="cal-grid">${cells}</div></div>`;
|
||||
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);
|
||||
attachHover(byDate);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,12 @@
|
|||
</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="cal-range-head">
|
||||
<span class="cal-filter-label">Date range</span>
|
||||
|
|
|
|||
|
|
@ -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,9 +477,13 @@ 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
|
||||
? `<p class="cmp-loading"><span class="cmp-spinner"></span>Loading daily weather…</p>` +
|
||||
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;
|
||||
}
|
||||
|
|
@ -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) : `<p class="muted">No data for this metric.</p>`;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
72
static/filtersheet.js
Normal file
72
static/filtersheet.js
Normal 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,
|
||||
};
|
||||
}
|
||||
114
static/shared.js
114
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 <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 ----
|
||||
// Monochrome line icons (currentColor) — no emoji, matching the site's dark
|
||||
// look. Feather-style paths.
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue