Compare: comfort range slicer + per-location climate distribution (#62)

* 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
This commit is contained in:
Emi Griffith 2026-07-11 15:33:38 -07:00 committed by GitHub
parent c65ee15c0d
commit 2662fa38e5
5 changed files with 298 additions and 172 deletions

View file

@ -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],
["13 dry", drynessColor(2), (d) => d >= 1 && d <= 3],
["46 dry", drynessColor(5), (d) => d >= 4 && d <= 6],
["713 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 "LightMod"/"ModHeavy" — 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 =
`<label class="ct-count-toggle"><input type="checkbox" id="ct-count-cb"` +
`${showCount ? " checked" : ""} /> Show count</label>`;
const header = `<div class="ct-head"><div class="section-title">${head}</div>${countToggle}</div>`;
if (!total) {
totEl.innerHTML = `${header}<p class="muted">No matching days.</p>`;
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 ? `<span class="ct-line" style="height:${h}px"></span>` : "";
// 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 `<div class="ct-col${cls ? ` ct-${cls}` : ""}" style="--c:${color}" ` +
`title="${label} · ${pctText(n, group)} (${n})">` +
`<span class="ct-cap"><span class="ct-label">${label}</span>` +
`<span class="ct-num${n ? "" : " ct-num-zero"}">${valText(n, group)}</span></span>` +
`${line}</div>`;
}).join("");
totEl.innerHTML = `${header}
<div class="ct-strip" style="--line-max:${LINE_MAX}px">${cols}</div>`;
totEl.innerHTML = total
? `${header}\n ${distStrip(buckets, showCount)}`
: `${header}<p class="muted">No matching days.</p>`;
totEl.hidden = false;
}

View file

@ -59,14 +59,13 @@
the date range or the location set trigger a data load. -->
<div id="cmp-params" class="cmp-params" hidden>
<div class="cmp-param cmp-comfort">
<label for="cmp-comfort">Comfort temperature <b id="cmp-comfort-val"></b></label>
<input id="cmp-comfort" type="range" min="30" max="100" step="1" />
<label>Comfort range <b id="cmp-range-val"></b></label>
<div class="dual-range" id="cmp-slicer">
<div class="dual-track"><span class="dual-fill" id="cmp-fill"></span></div>
<input id="cmp-lo" type="range" min="30" max="100" step="1" aria-label="Comfort range lower bound" />
<input id="cmp-hi" type="range" min="30" max="100" step="1" aria-label="Comfort range upper bound" />
</div>
<div class="cmp-param cmp-band">
<label for="cmp-tol">Comfort band <b id="cmp-tol-val"></b></label>
<input id="cmp-tol" type="range" min="0" max="15" step="1" />
<span class="hint">A day "hits comfort" when it lands within this many degrees.</span>
<span class="hint">Days landing in this range hit comfort; below it is colder, above it is warmer.</span>
</div>
<div class="cmp-param cmp-basis-block">
@ -99,6 +98,29 @@
</div>
<div id="cmp-results" class="cmp-results"></div>
<!-- Climate distribution, independent of the comfort filter: how each place's
days spread across a metric's Record-Low→Record-High categories, mirroring
the calendar's totals strip. Its own metric + share/count toggles. -->
<section id="cmp-dist" class="cmp-dist" hidden>
<div class="ct-head">
<div class="cmp-dist-controls">
<span class="cal-filter-label">Distribution by</span>
<div class="metric-toggle" id="cmp-dist-metric" role="group" aria-label="Distribution metric">
<button type="button" data-metric="tmax" class="active">High</button>
<button type="button" data-metric="tmin">Low</button>
<button type="button" data-metric="feels">Feels</button>
<button type="button" data-metric="humid">Humid</button>
<button type="button" data-metric="wind">Wind</button>
<button type="button" data-metric="gust">Gust</button>
<button type="button" data-metric="precip">Precip</button>
<button type="button" data-metric="dsr">Dry streak</button>
</div>
</div>
<label class="ct-count-toggle"><input type="checkbox" id="cmp-dist-count" /> Show count</label>
</div>
<div id="cmp-dist-body" class="cmp-dist-body"></div>
</section>
</main>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>

View file

@ -1,29 +1,35 @@
// Compare view: line up several places over one date range and see which best
// matches a comfort temperature. For each location we pull the same daily record
// the Calendar uses (api/v2/calendar) and, per day, take a chosen temperature
// (daytime high / daily mean / overnight low / feels-like) and measure it against
// the comfort target. A day "hits comfort" when it lands within a tolerance band;
// otherwise it's colder or warmer, and we track by how much.
// fits a comfort temperature *range*. For each location we pull the same daily
// record the Calendar uses (api/v2/calendar) and, per day, take a chosen
// temperature (daytime high / daily mean / overnight low / feels-like) and test it
// against the comfort range [lo, hi]: inside the range "hits comfort"; below lo is
// colder, above hi is warmer, and we track by how much.
//
// The whole comparison — locations, comfort target, band, judged temperature and
// date range — lives in the URL hash, so a link reproduces exactly what you see.
// Comfort / band / judged-temperature re-rank instantly (all values are already in
// hand). Editing the date range or the location set doesn't refetch on its own:
// it arms the Refresh button, and the load runs when the user taps it.
// Below the ranked cards a separate climate-distribution section shows how each
// place's days spread across a metric's Record-Low→Record-High categories (its own
// metric + share/count toggles), reusing the calendar's totals strip.
//
// The comparison — locations, comfort range, judged temperature and date range —
// lives in the URL hash, so a link reproduces exactly what you see. The range and
// judged-temperature re-rank instantly (all values are already in hand). Editing
// the date range or the location set doesn't refetch on its own: it arms the
// Refresh button, and the load runs when the user taps it.
import { loadLastLocation, saveLastLocation } from "./nav.js";
import { getJSON, TTL } from "./cache.js";
import { initFindButton } from "./mappicker.js";
import { MONTHS, pad, isoOfDate, monthStart, monthEnd, buildChunks,
clickOpensPicker } from "./shared.js";
import { MONTHS, pad, monthStart, monthEnd, buildChunks,
clickOpensPicker, metricBuckets, distStrip } from "./shared.js";
import { fmtTemp, fmtDelta, onUnitChange } from "./units.js";
const CMP = {
comfort: "thermograph:cmpComfort",
tol: "thermograph:cmpTol",
lo: "thermograph:cmpLo",
hi: "thermograph:cmpHi",
basis: "thermograph:cmpBasis",
range: "thermograph:cmpRange",
locs: "thermograph:cmpLocs",
distMetric: "thermograph:cmpDistMetric",
distCount: "thermograph:cmpDistCount",
};
const lsGet = (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } };
@ -31,13 +37,30 @@ const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch (e) {} };
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
const BASES = ["tmax", "tmin", "mean", "feels"];
let comfort = clamp(+lsGet(CMP.comfort) || 68, 30, 100);
let tol = clamp(lsGet(CMP.tol) == null ? 5 : +lsGet(CMP.tol), 0, 15);
const R_MIN = 30, R_MAX = 100; // comfort-range slider bounds (°F)
// The comfort range [lo, hi] in °F. Restored from the new lo/hi keys, else migrated
// from the old comfort-point + band (c ± t), else the default 6373° (was 68 ± 5).
function loadRange2() {
let lo = +lsGet(CMP.lo), hi = +lsGet(CMP.hi);
if (lo >= R_MIN && hi <= R_MAX && lo <= hi) return { lo, hi };
const c = +lsGet("thermograph:cmpComfort"), t = +lsGet("thermograph:cmpTol");
if (c >= R_MIN && c <= R_MAX && t >= 0) return { lo: clamp(c - t, R_MIN, R_MAX), hi: clamp(c + t, R_MIN, R_MAX) };
return { lo: 63, hi: 73 };
}
let { lo, hi } = loadRange2();
let basis = BASES.includes(lsGet(CMP.basis)) ? lsGet(CMP.basis) : "tmax";
// 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"];
let distMetric = DIST_METRICS.includes(lsGet(CMP.distMetric)) ? lsGet(CMP.distMetric) : "tmax";
let distCount = lsGet(CMP.distCount) === "1";
// Locations being compared: {lat, lon, name, series|null, loading, error}.
// `series` is a compact per-day array of {hi, lo, feels} (°F, any may be null).
// A location with no series and not loading is "pending" — it needs a Refresh tap.
// `series` is the array of raw daily records from /api/v2/calendar (each metric an
// {v, c, …} object) — the comfort ranking reads the values, the distribution the
// category classes. No series and not loading is "pending" — it needs a Refresh tap.
let locations = [];
// Bumped on every (re)load so a superseded in-flight fetch drops its result.
let loadToken = 0;
@ -61,23 +84,30 @@ let range = loadRange(); // the APPLIED range (what loaded data reflects), "YY
const monthLabel = (ym) => `${MONTHS[+ym.slice(5, 7) - 1]} ${ym.slice(0, 4)}`;
// ---- shareable URL state ----
// The hash carries the full comparison: c=comfort, t=band, b=basis, s/e=range,
// The hash carries the comparison: lo/hi=comfort range, b=basis, s/e=range,
// loc=lat,lon;lat,lon. Written on every state change; read once on load (a link
// wins over localStorage). A plain lat/lon hash from cross-view nav is ignored
// here and handled by the seed path instead.
// wins over localStorage). Old links used c=comfort + t=band; those still resolve
// (converted to a range). A plain lat/lon hash from cross-view nav is ignored here
// and handled by the seed path instead.
function writeHashState() {
const p = new URLSearchParams();
p.set("c", comfort); p.set("t", tol); p.set("b", basis);
p.set("lo", lo); p.set("hi", hi); p.set("b", basis);
p.set("s", range.start); p.set("e", range.end);
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 (!["c", "t", "b", "s", "e", "loc"].some((k) => p.has(k))) return null; // not a compare link
if (!["lo", "hi", "c", "t", "b", "s", "e", "loc"].some((k) => p.has(k))) return null; // not a compare link
const st = {};
if (p.has("c")) st.comfort = clamp(+p.get("c") || 68, 30, 100);
if (p.has("t") && p.get("t") !== "") st.tol = clamp(+p.get("t"), 0, 15);
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);
if (!isNaN(l) && !isNaN(h)) { st.lo = Math.min(l, h); st.hi = Math.max(l, h); }
} else if (p.has("c")) { // legacy comfort-point + band link
const c = clamp(+p.get("c") || 68, R_MIN, R_MAX);
const t = p.get("t") !== null && p.get("t") !== "" ? clamp(+p.get("t"), 0, 15) : 5;
st.lo = clamp(c - t, R_MIN, R_MAX); st.hi = clamp(c + t, R_MIN, R_MAX);
}
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");
@ -98,17 +128,21 @@ const params = document.getElementById("cmp-params");
const placeholder = document.getElementById("cmp-placeholder");
const head = document.getElementById("cmp-head");
const results = document.getElementById("cmp-results");
const comfortInput = document.getElementById("cmp-comfort");
const comfortVal = document.getElementById("cmp-comfort-val");
const tolInput = document.getElementById("cmp-tol");
const tolVal = document.getElementById("cmp-tol-val");
const loInput = document.getElementById("cmp-lo");
const hiInput = document.getElementById("cmp-hi");
const rangeVal = document.getElementById("cmp-range-val");
const fillEl = document.getElementById("cmp-fill");
const basisToggle = document.getElementById("cmp-basis");
const startInput = document.getElementById("cmp-start");
const endInput = document.getElementById("cmp-end");
const distSection = document.getElementById("cmp-dist");
const distToggle = document.getElementById("cmp-dist-metric");
const distBody = document.getElementById("cmp-dist-body");
// ---- data ----
// Fetch one location's range (chunked) and reduce it to the compact per-day series.
// Fetch one location's range (chunked), keeping the raw daily records — the comfort
// ranking reads their values, the distribution their category classes.
async function fetchSeries(loc, token) {
const chunks = buildChunks(monthStart(range.start), monthEnd(range.end));
const days = [];
@ -120,14 +154,7 @@ async function fetchSeries(loc, token) {
place = place || d.place || `${d.cell.center_lat.toFixed(2)}, ${d.cell.center_lon.toFixed(2)}`;
for (const r of d.days) days.push(r);
}
return {
name: place,
series: days.map((r) => ({
hi: r.tmax ? r.tmax.v : null,
lo: r.tmin ? r.tmin.v : null,
feels: r.feels ? r.feels.v : null,
})),
};
return { name: place, series: days };
}
function persistLocations() {
@ -213,23 +240,24 @@ function refresh() {
}
// ---- stats ----
function basisTemp(pt) {
if (basis === "tmax") return pt.hi;
if (basis === "tmin") return pt.lo;
if (basis === "feels") return pt.feels;
return pt.hi != null && pt.lo != null ? (pt.hi + pt.lo) / 2 : null; // mean
// The judged temperature for one raw daily record, per the basis toggle.
function basisTemp(r) {
const hi = r.tmax ? r.tmax.v : null, lo = r.tmin ? r.tmin.v : null;
if (basis === "tmax") return hi;
if (basis === "tmin") return lo;
if (basis === "feels") return r.feels ? r.feels.v : null;
return hi != null && lo != null ? (hi + lo) / 2 : null; // mean
}
function computeStats(series) {
let n = 0, below = 0, above = 0, comf = 0, sumBelow = 0, sumAbove = 0, sumAbs = 0;
for (const pt of series) {
const t = basisTemp(pt);
let n = 0, below = 0, above = 0, comf = 0, sumBelow = 0, sumAbove = 0, sumMiss = 0;
for (const r of series) {
const t = basisTemp(r);
if (t == null || isNaN(t)) continue;
n++;
const d = t - comfort;
sumAbs += Math.abs(d);
if (d < -tol) { below++; sumBelow += -d; }
else if (d > tol) { above++; sumAbove += d; }
// Distance outside the comfort range (0 when inside) drives the "typical miss".
if (t < lo) { below++; const m = lo - t; sumBelow += m; sumMiss += m; }
else if (t > hi) { above++; const m = t - hi; sumAbove += m; sumMiss += m; }
else comf++;
}
if (!n) return null;
@ -238,7 +266,7 @@ function computeStats(series) {
comfortPct: 100 * comf / n, belowPct: 100 * below / n, abovePct: 100 * above / n,
avgBelow: below ? sumBelow / below : 0,
avgAbove: above ? sumAbove / above : 0,
mad: sumAbs / n,
mad: sumMiss / n,
};
}
@ -271,7 +299,7 @@ function rankCard(loc, s, rank) {
const stats =
`<div class="cmp-stats">` +
`<div class="cmp-stat cmp-s-below"><span class="cmp-k">Colder</span><span class="cmp-v">${pct(s.belowPct)}</span><span class="cmp-d">${s.below ? `avg ${deg(s.avgBelow)} below` : "—"}</span></div>` +
`<div class="cmp-stat cmp-s-comfort"><span class="cmp-k">In comfort</span><span class="cmp-v">${pct(s.comfortPct)}</span><span class="cmp-d">±${fmtDelta(tol)} of ${fmtTemp(comfort)}</span></div>` +
`<div class="cmp-stat cmp-s-comfort"><span class="cmp-k">In comfort</span><span class="cmp-v">${pct(s.comfortPct)}</span><span class="cmp-d">${fmtTemp(lo)}${fmtTemp(hi)}</span></div>` +
`<div class="cmp-stat cmp-s-above"><span class="cmp-k">Warmer</span><span class="cmp-v">${pct(s.abovePct)}</span><span class="cmp-d">${s.above ? `avg ${deg(s.avgAbove)} above` : "—"}</span></div>` +
`</div>`;
return `<div class="cmp-card">` +
@ -303,8 +331,8 @@ function renderResults() {
const win = scored[0];
const span = `${monthLabel(range.start)} ${monthLabel(range.end)}`;
const lead = scored.length > 1
? `<b>${win.loc.name}</b> best matches ${fmtTemp(comfort)}${pct(win.s.comfortPct)} of days within ±${fmtDelta(tol)}`
: `<b>${win.loc.name}</b>: ${pct(win.s.comfortPct)} of days within ±${fmtDelta(tol)} of ${fmtTemp(comfort)}`;
? `<b>${win.loc.name}</b> best fits ${fmtTemp(lo)}${fmtTemp(hi)}${pct(win.s.comfortPct)} of days in range`
: `<b>${win.loc.name}</b>: ${pct(win.s.comfortPct)} of days land in ${fmtTemp(lo)}${fmtTemp(hi)}`;
head.hidden = false;
head.innerHTML = `<h2>${lead}</h2>` +
`<p class="meta">By ${BASIS_LABEL[basis]} · ${span}${loading ? ` · loading ${loading} more…` : ""}</p>`;
@ -312,6 +340,25 @@ function renderResults() {
results.innerHTML = scored.map((x, i) => rankCard(x.loc, x.s, i + 1)).join("");
}
// ---- distribution (independent of the comfort filter) ----
// One calendar-style Record-Low→Record-High strip per loaded location for the
// selected metric; the share/count toggle flips every figure. The categories are
// percentiles, so the strip is unit-agnostic (no re-render on °C/°F).
function renderDist() {
const withData = locations.filter((l) => l.series && l.series.length);
if (!withData.length) { distSection.hidden = true; distBody.innerHTML = ""; return; }
distBody.innerHTML = withData.map((l) => {
const buckets = metricBuckets(l.series, distMetric);
const total = buckets.reduce((n, b) => n + b[2], 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>`;
return `<div class="cmp-dist-row">` +
`<div class="cmp-dist-name">${name}<span class="cmp-dist-days">${total.toLocaleString()} days</span></div>` +
strip + `</div>`;
}).join("");
distSection.hidden = false;
}
function renderAll() {
const has = locations.length > 0;
params.hidden = !has;
@ -320,6 +367,7 @@ function renderAll() {
refreshBtn.hidden = !isDirty();
renderLocList();
renderResults();
renderDist();
}
// ---- events ----
@ -334,30 +382,47 @@ locList.addEventListener("click", (e) => {
if (x) removeLocation(+x.dataset.i);
});
// Comfort / band / basis: instant re-rank over data already in hand — no refetch.
comfortInput.addEventListener("input", () => {
comfort = +comfortInput.value;
comfortVal.textContent = fmtTemp(comfort);
lsSet(CMP.comfort, String(comfort));
// ---- comfort range slicer ----
// Two range inputs overlaid on a shared track; keep lo ≤ hi and mirror the selection
// in the label + fill. State stays in °F; the range re-ranks instantly (no refetch).
const pctPos = (v) => (v - R_MIN) / (R_MAX - R_MIN) * 100;
function syncSlicer() {
loInput.value = lo; hiInput.value = hi;
rangeVal.textContent = `${fmtTemp(lo)} ${fmtTemp(hi)}`;
fillEl.style.left = `${pctPos(lo)}%`;
fillEl.style.right = `${100 - pctPos(hi)}%`;
}
function slicerChanged() {
lsSet(CMP.lo, String(lo)); lsSet(CMP.hi, String(hi));
syncSlicer();
writeHashState();
renderResults();
renderResults(); // the distribution is comfort-independent — no need to touch it
}
loInput.addEventListener("input", () => { lo = Math.min(+loInput.value, hi); slicerChanged(); });
hiInput.addEventListener("input", () => { hi = Math.max(+hiInput.value, lo); slicerChanged(); });
// The °F/°C toggle only flips what's shown — the range and series stay in °F (the
// API's unit) — so just re-render the range label and the cards. (The distribution
// strip is percentile categories, so units don't affect it.)
onUnitChange(() => { syncSlicer(); renderResults(); });
// Distribution: its own metric selector + share/count toggle, re-rendering in place.
distToggle.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-metric]");
if (!btn) return;
distMetric = btn.dataset.metric;
lsSet(CMP.distMetric, distMetric);
distToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b === btn));
renderDist();
});
tolInput.addEventListener("input", () => {
tol = +tolInput.value;
tolVal.textContent = `±${fmtDelta(tol)}`;
lsSet(CMP.tol, String(tol));
writeHashState();
renderResults();
distSection.addEventListener("change", (e) => {
if (!e.target.closest("#cmp-dist-count")) return;
distCount = e.target.checked;
lsSet(CMP.distCount, distCount ? "1" : "0");
renderDist();
});
// The °F/°C toggle only flips what's shown — comfort/tol/series all stay in °F
// (the API's unit), so there's nothing to refetch or re-rank, just re-render the
// numbers: the two slider labels and every card.
onUnitChange(() => {
comfortVal.textContent = fmtTemp(comfort);
tolVal.textContent = `±${fmtDelta(tol)}`;
renderResults();
});
// Basis: instant re-rank over data already in hand — no refetch.
basisToggle.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-basis]");
if (!btn) return;
@ -378,15 +443,16 @@ clickOpensPicker(startInput, endInput); // whole-field tap opens the month pic
(function restore() {
const hash = readHashState();
if (hash) {
if (hash.comfort != null) comfort = hash.comfort;
if (hash.tol != null) tol = hash.tol;
if (hash.lo != null) lo = hash.lo;
if (hash.hi != null) hi = hash.hi;
if (hash.basis) basis = hash.basis;
if (hash.start && hash.end) range = { start: hash.start, end: hash.end };
}
comfortInput.value = comfort; comfortVal.textContent = fmtTemp(comfort);
tolInput.value = tol; tolVal.textContent = `±${fmtDelta(tol)}`;
syncSlicer();
basisToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b.dataset.basis === basis));
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;
// A shared link's locations win; else the last-used set; else seed from the spot

View file

@ -66,6 +66,76 @@ export function drynessColor(dsr) {
return lerpRgb(DRY_BASE, DRY_MAX, Math.min(dsr, DRY_CAP) / DRY_CAP);
}
// ---- category distribution (shared by the calendar totals + the compare page) ----
// Bucket a set of daily records into the chosen metric's ordered categories
// (low→high), each entry [label, color, dayCount, scaleGroup]. Temperature-style
// metrics (High/Low/Feels/Humid/Wind/Gust) use the diverging Record-Low→Record-High
// tiers; precip uses the rain-intensity tiers plus a Dry bucket; dry-streak uses
// run-length bands. The scaleGroup ("wet"/"dry"/"temp") lets distStrip scale and
// percent wet vs. dry days apart so a lopsided count on one side can't crush the
// other. Categories are percentile classes on the records, so they're unit-agnostic.
export function metricBuckets(days, metric) {
if (metric === "dsr") {
const bands = [
["Rain day", drynessColor(0), (d) => d === 0],
["13 dry", drynessColor(2), (d) => d >= 1 && d <= 3],
["46 dry", drynessColor(5), (d) => d >= 4 && d <= 6],
["713 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);
// The lone "Rain day" (wet) scales apart from the dry-streak buckets (dry).
return bands.map(([label, color, fn], i) => [label, color, vals.filter(fn).length, i === 0 ? "wet" : "dry"]);
}
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), 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 "LightMod"/"ModHeavy" — can be nudged
// in CSS (.ct-wet-5) without brittle text matching.
return [["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]));
}
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.
return SCALE_TEMP.map((t) => [t.label, TIER_COLORS[t.c], classes.filter((c) => c === t.c).length, "temp"]);
}
// Render buckets from metricBuckets() as the compact distribution strip: one
// status-colored line per category, low→high, each figure printed above as a share
// (default) or, when showCount is set, the raw day count. Bar heights and shares are
// scoped to each bucket's scale group (index 3): wet and dry days scale apart, and a
// multi-bar group shows each bar's share within that group, while a lone opposing
// column (Dry on precip, Rain day on dry streak) stays a share of all shown days
// (a group-relative percentage there would trivially read 100%). Temperature tiers
// share one group, so they scale together. Assumes ≥1 day (callers guard empty).
export function distStrip(buckets, showCount) {
const total = buckets.reduce((n, b) => n + b[2], 0);
const groupTotal = {}, groupCount = {}, maxByGroup = {};
for (const b of buckets) {
groupTotal[b[3]] = (groupTotal[b[3]] || 0) + b[2];
groupCount[b[3]] = (groupCount[b[3]] || 0) + 1;
maxByGroup[b[3]] = Math.max(maxByGroup[b[3]] || 1, b[2]);
}
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}%`;
};
const valText = (n, group) => !n ? "0" : (showCount ? n.toLocaleString() : pctText(n, group));
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 ? `<span class="ct-line" style="height:${h}px"></span>` : "";
// `cls` (a rain-tier key on precip) becomes a ct-<cls> hook for label nudging.
return `<div class="ct-col${cls ? ` ct-${cls}` : ""}" style="--c:${color}" ` +
`title="${label} · ${pctText(n, group)} (${n})">` +
`<span class="ct-cap"><span class="ct-label">${label}</span>` +
`<span class="ct-num${n ? "" : " ct-num-zero"}">${valText(n, group)}</span></span>${line}</div>`;
}).join("");
return `<div class="ct-strip" style="--line-max:${LINE_MAX}px">${cols}</div>`;
}
// ---- formatters & tiny utils ----
// (Temperatures go through nav.js's unit-aware fmtTemp; these are unit-agnostic.)
export const fmtPrecip = (v) => (v == null ? "—" : `${v.toFixed(2)}"`);

View file

@ -855,6 +855,46 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
.cmp-mad { margin: 12px 0 0; font-size: 13px; color: var(--muted); }
.cmp-mad b { color: var(--text); }
/* Dual-handle comfort slicer: two range inputs overlaid on a shared track, with a
fill marking the selected span. Only the thumbs take pointer events, so both
handles stay grabbable even where the inputs overlap. */
.dual-range { position: relative; height: 44px; }
.dual-range .dual-track {
position: absolute; left: 0; right: 0; top: 50%; transform: translateY(-50%);
height: 6px; border-radius: 3px; background: var(--surface-2);
}
.dual-range .dual-fill { position: absolute; top: 0; bottom: 0; border-radius: 3px; background: var(--accent); }
.dual-range input[type="range"] {
position: absolute; left: 0; top: 0; width: 100%; height: 100%; margin: 0;
-webkit-appearance: none; appearance: none; background: transparent; pointer-events: none;
}
.dual-range input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none; pointer-events: auto;
width: 22px; height: 22px; border-radius: 50%; background: var(--accent);
border: 2px solid var(--surface); box-shadow: 0 1px 3px rgba(0, 0, 0, .35); cursor: pointer;
}
.dual-range input[type="range"]::-moz-range-thumb {
pointer-events: auto; width: 22px; height: 22px; border-radius: 50%; background: var(--accent);
border: 2px solid var(--surface); box-shadow: 0 1px 3px rgba(0, 0, 0, .35); cursor: pointer;
}
.dual-range input[type="range"]::-webkit-slider-runnable-track { background: transparent; border: none; }
.dual-range input[type="range"]::-moz-range-track { background: transparent; border: none; }
/* Climate distribution below the ranked cards: a metric toggle + share/count toggle,
then one calendar-style distribution strip (.ct-strip, shared) per location. */
.cmp-dist { margin: 24px 0 8px; }
.cmp-dist[hidden] { display: none; }
.cmp-dist .ct-head { align-items: center; }
.cmp-dist-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; }
.cmp-dist-controls .cal-filter-label { margin: 0; }
.cmp-dist .metric-toggle { margin: 0; }
.cmp-dist-body { display: grid; gap: 12px; margin-top: 14px; }
.cmp-dist-row {
border: 1px solid var(--border); border-radius: 12px; background: var(--surface); padding: 12px 16px 6px;
}
.cmp-dist-name { display: flex; align-items: baseline; gap: 8px; font-size: 14px; font-weight: 700; }
.cmp-dist-days { font-size: 12px; font-weight: 400; color: var(--muted); }
/* --- phones / narrow screens every mobile override lives in this one
block (they used to be scattered across four) --- */
@media (max-width: 640px) {