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 `
`;
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. -->
-
-
-
-
-
-
-
- A day "hits comfort" when it lands within this many degrees.
+
+
+
+
+
+
+ Days landing in this range hit comfort; below it is colder, above it is warmer.
@@ -99,6 +98,29 @@
+
+
+
+
+
+ Distribution by
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/compare.js b/static/compare.js
index f795db5..023edaf 100644
--- a/static/compare.js
+++ b/static/compare.js
@@ -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 63–73° (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 =
`
` +
@@ -303,8 +331,8 @@ function renderResults() {
const win = scored[0];
const span = `${monthLabel(range.start)} – ${monthLabel(range.end)}`;
const lead = scored.length > 1
- ? `${win.loc.name} best matches ${fmtTemp(comfort)} — ${pct(win.s.comfortPct)} of days within ±${fmtDelta(tol)}`
- : `${win.loc.name}: ${pct(win.s.comfortPct)} of days within ±${fmtDelta(tol)} of ${fmtTemp(comfort)}`;
+ ? `${win.loc.name} best fits ${fmtTemp(lo)}–${fmtTemp(hi)} — ${pct(win.s.comfortPct)} of days in range`
+ : `${win.loc.name}: ${pct(win.s.comfortPct)} of days land in ${fmtTemp(lo)}–${fmtTemp(hi)}`;
head.hidden = false;
head.innerHTML = `
`;
@@ -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) : `
No data for this metric.
`;
+ return `
` +
+ `
${name}${total.toLocaleString()} days
` +
+ strip + `
`;
+ }).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
diff --git a/static/shared.js b/static/shared.js
index 14be7cc..c12f3ae 100644
--- a/static/shared.js
+++ b/static/shared.js
@@ -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],
+ ["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);
+ // 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 "Light–Mod"/"Mod–Heavy" — 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 ? `` : "";
+ // `cls` (a rain-tier key on precip) becomes a ct- hook for label nudging.
+ return `
` +
+ `${label}` +
+ `${valText(n, group)}${line}
`;
+ }).join("");
+ return `
${cols}
`;
+}
+
// ---- 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)}"`);
diff --git a/static/style.css b/static/style.css
index 6eae42b..de13b53 100644
--- a/static/style.css
+++ b/static/style.css
@@ -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) {