From 2662fa38e5be92db3dc825ccc286fb86c7eb4c36 Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Sat, 11 Jul 2026 15:33:38 -0700 Subject: [PATCH] Compare: comfort range slicer + per-location climate distribution (#62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Compare: comfort range slicer + per-location climate distribution Replace the comfort-point + band sliders with a single dual-handle range slicer: a day hits comfort when its judged temperature lands in [lo, hi]; below lo is colder, above hi is warmer. State is the range in °F, so old c/t links and stored prefs migrate to a range. Ranking, average miss, and the typical-miss figure are all computed against the range. Add a climate-distribution section below the ranked cards, independent of the comfort filter: its own metric selector (High/Low/Feels/Humid/Wind/ Gust/Precip/Dry streak) plus a share/count toggle render one Record-Low→ Record-High distribution strip per location, mirroring the calendar. Extract the calendar's category bucketing and distribution strip into shared metricBuckets/distStrip so both pages share one implementation; calendar output is unchanged. * Carry the calendar's wet/dry scale-group logic into the shared distribution helpers * Carry the precip strip's per-tier label hook into the shared distribution helper --- static/calendar.js | 100 +++----------------- static/compare.html | 38 ++++++-- static/compare.js | 222 ++++++++++++++++++++++++++++---------------- static/shared.js | 70 ++++++++++++++ static/style.css | 40 ++++++++ 5 files changed, 298 insertions(+), 172 deletions(-) diff --git a/static/calendar.js b/static/calendar.js index ca46bc2..79c9c2c 100644 --- a/static/calendar.js +++ b/static/calendar.js @@ -7,7 +7,7 @@ import { TTL, chunkedFetch, prefetchViews } from "./cache.js"; 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, + CHUNK_MONTHS, buildChunks, clickOpensPicker, metricBuckets, distStrip, weatherType as wxType } from "./shared.js"; // The diverging-temperature-scale metrics (colored exactly like High/Low), with a @@ -523,99 +523,27 @@ function renderTotals(visible) { const wActive = weatherFilterActive(); const days = wActive ? visible.filter((v) => v.pass).map((v) => v.rec) : visible.map((v) => v.rec); - // Buckets low→high for the metric: [label, color, count-fn]. - let buckets, title; - if (metric === "dsr") { - const B = [ - ["Rain day", drynessColor(0), (d) => d === 0], - ["1–3 dry", drynessColor(2), (d) => d >= 1 && d <= 3], - ["4–6 dry", drynessColor(5), (d) => d >= 4 && d <= 6], - ["7–13 dry", drynessColor(10), (d) => d >= 7 && d <= 13], - ["14+ dry", drynessColor(14), (d) => d >= 14], - ]; - const vals = days.map((r) => r.dsr).filter((d) => d != null); - // Group (index 3) sets which categories share a height scale: the lone "Rain day" - // (wet) is scaled apart from the dry-streak buckets so it can't flatten them. - buckets = B.map(([label, color, fn], i) => - [label, color, vals.filter(fn).length, i === 0 ? "wet" : "dry"]); - title = "dry streak"; - } else if (metric === "precip") { - const classes = days.map((r) => r.precip && r.precip.c).filter(Boolean); - // Dry days scale independently of the rain-intensity buckets (wet) so a big dry - // count doesn't crush the rain bars — and vice versa. - // Index 4 carries the rain-tier key (e.g. "wet-5") so a single crowded label — - // "Moderate", wedged between the two-line "Light–Mod"/"Mod–Heavy" — can be nudged - // in CSS without brittle text matching. - buckets = [["Dry", drynessColor(7), classes.filter((c) => c === "dry").length, "dry"]] - .concat(SCALE_RAIN.map((t) => - [t.label, PRECIP_COLORS[t.c], classes.filter((c) => c === t.c).length, "wet", t.c])); - title = "rain intensity"; - } else { - const classes = days - .map((r) => { const g = r[metric]; return g && g.c; }) - .filter(Boolean); - // Temperature tiers all share one scale group, so they scale together as before. - buckets = SCALE_TEMP.map((t) => - [t.label, TIER_COLORS[t.c], classes.filter((c) => c === t.c).length, "temp"]); - title = (TEMP_METRIC_INFO[metric] || {}).label || "value"; - } - + const title = metric === "dsr" ? "dry streak" + : metric === "precip" ? "rain intensity" + : (TEMP_METRIC_INFO[metric] || {}).label || "value"; + // Category distribution (low→high) for the metric; the bucketing and the strip + // visual — the wet/dry scale-group split and the per-tier label hook — are shared + // with the compare page. Category names live in the color key below the grid too. + const buckets = metricBuckets(days, metric); const total = buckets.reduce((n, b) => n + b[2], 0); - // Per-group subtotals + membership, so a bar's share can be scoped to its own group. - const groupTotal = {}, groupCount = {}; - for (const b of buckets) { - groupTotal[b[3]] = (groupTotal[b[3]] || 0) + b[2]; - groupCount[b[3]] = (groupCount[b[3]] || 0) + 1; - } - // A multi-bar group (rain intensities on precip, streak lengths on dry streak) shows - // each bar's share *within that group* — matching the per-group bar heights. A lone - // opposing column (Dry on precip, Rain day on dry streak) stays a share of ALL shown - // days, since a group-relative percentage there would trivially read 100%. - const pctText = (n, group) => { - const d = groupCount[group] > 1 ? (groupTotal[group] || 1) : total; - const p = 100 * n / d, r = Math.round(p); - return n > 0 && r === 0 ? `${p.toFixed(1)}%` : `${r}%`; - }; - // The strip prints either each category's share (default) or its raw day count, - // toggled by the "Show count" checkbox rendered in the header (state: showCount). - const valText = (n, group) => !n ? "0" : (showCount ? n.toLocaleString() : pctText(n, group)); + const head = wActive ? `${days.length.toLocaleString()} of ${visible.length.toLocaleString()} shown days match the weather filter — by ${title}:` : `${visible.length.toLocaleString()} days shown — by ${title}:`; + // The strip prints each category's share by default, or the raw day count when the + // "Show count" checkbox (state: showCount) is ticked. const countToggle = ``; const header = `
${head}
${countToggle}
`; - if (!total) { - totEl.innerHTML = `${header}

No matching days.

`; - totEl.hidden = false; - return; - } - // A compact distribution strip: one thin status-colored line per category, ordered - // low→high like the color scale, with each value printed above it in that category's - // own color. Every category — including the neutral "Normal" center of the diverging - // temperature scale — draws a line, so the strip reads as a full distribution. - // Category names live in the color key below the grid too, so this stays terse. - // Line heights scale to the tallest category *within each scale group* (index 3): - // for precip/dry-streak, wet and dry days scale independently, so a lopsided count - // on one side doesn't shrink the opposing side's bars into invisibility. Temperature - // tiers all sit in one group, so they still scale against each other as before. - const maxByGroup = {}; - for (const b of buckets) maxByGroup[b[3]] = Math.max(maxByGroup[b[3]] || 1, b[2]); - const LINE_MAX = 30; // px — height of the tallest category in its group - const cols = buckets.map(([label, color, n, group, cls]) => { - const h = !n ? 0 : Math.max(2, Math.round(LINE_MAX * n / maxByGroup[group])); - const line = h ? `` : ""; - // Caption stacks the category name over its value; the name reserves two lines - // so the value figures stay aligned across columns whether a name wraps or not. - return `
` + - `${label}` + - `${valText(n, group)}` + - `${line}
`; - }).join(""); - totEl.innerHTML = `${header} -
${cols}
`; + totEl.innerHTML = total + ? `${header}\n ${distStrip(buckets, showCount)}` + : `${header}

No matching days.

`; totEl.hidden = false; } diff --git a/static/compare.html b/static/compare.html index c26b925..2598665 100644 --- a/static/compare.html +++ b/static/compare.html @@ -59,14 +59,13 @@ the date range or the location set trigger a data load. -->