Compare: match-score ranking, tolerance range, fix Feels basis (#69)

Ranking by raw comfort% tied extreme places (Death Valley ranked #2 just
because 26% of days happened to land in-band). Replace it with a weighted
score that folds in tolerance days and miss magnitude, add an outer
tolerance range, and fix the Feels basis.

- Match score (0–100): each day scores full credit inside comfort [lo,hi],
  partial credit inside the tolerance range (ramped 1→0 by how far past the
  comfort edge it lands), zero beyond tolerance. Rank by the mean; tie-break
  by smaller typical miss. Death Valley's hot days now score ~0 so it drops
  to last. The score is the card's headline number.
- Tolerance range [tlo, thi] as an outer dual-thumb pair on the same track
  (four handles, tlo ≤ lo ≤ hi ≤ thi), persisted + in the share hash.
  Defaults to comfort widened 10° each side. The diverging bar now splits
  each side into a within-tolerance band (lighter, near centre) and a
  beyond-tolerance band (saturated), and the baseline strip shades the
  tolerance range faintly around the comfort band.
- Feels basis now judges the felt daytime high (fmax) instead of the
  combined `feels` metric, which reports whichever apparent extreme is
  furthest from 65°F — in mild climates that flips to the cold overnight
  low in winter and made comfortable days nearly vanish.
This commit is contained in:
Emi Griffith 2026-07-11 17:25:36 -07:00 committed by GitHub
parent b49489e730
commit 428dfebe83
3 changed files with 121 additions and 47 deletions

View file

@ -59,13 +59,18 @@
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>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>
<label>Comfort &amp; tolerance <b id="cmp-range-val"></b></label>
<div class="dual-range quad-range" id="cmp-slicer">
<div class="dual-track">
<span class="dual-fill dual-fill-tol" id="cmp-fill-tol"></span>
<span class="dual-fill" id="cmp-fill"></span>
</div>
<input id="cmp-tlo" type="range" min="30" max="100" step="1" class="thumb-tol" aria-label="Tolerance range lower bound" />
<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" />
<input id="cmp-thi" type="range" min="30" max="100" step="1" class="thumb-tol" aria-label="Tolerance range upper bound" />
</div>
<span class="hint">Days landing in this range hit comfort; below it is colder, above it is warmer.</span>
<span class="hint">Inner handles are your ideal comfort range; outer handles are the tolerance range — days there still count, ramped by how far off they land.</span>
</div>
<div class="cmp-param cmp-basis-block">

View file

@ -25,6 +25,8 @@ import { fmtTemp, fmtDelta, onUnitChange } from "./units.js";
const CMP = {
lo: "thermograph:cmpLo",
hi: "thermograph:cmpHi",
tlo: "thermograph:cmpTlo",
thi: "thermograph:cmpThi",
basis: "thermograph:cmpBasis",
range: "thermograph:cmpRange",
locs: "thermograph:cmpLocs",
@ -49,6 +51,17 @@ function loadRange2() {
return { lo: 63, hi: 73 };
}
let { lo, hi } = loadRange2();
// Outer tolerance range [tlo, thi], with tlo ≤ lo ≤ hi ≤ thi. Days outside comfort
// but inside tolerance still count toward the match score (ramped by how far past
// comfort they land); days beyond tolerance count as a full miss. Restored from
// storage, else defaulting to comfort widened by 10° on each side.
function loadTol() {
const t = { tlo: +lsGet(CMP.tlo), thi: +lsGet(CMP.thi) };
if (t.tlo >= R_MIN && t.thi <= R_MAX && t.tlo <= lo && t.thi >= hi) return t;
return { tlo: clamp(lo - 10, R_MIN, R_MAX), thi: clamp(hi + 10, R_MIN, R_MAX) };
}
let { tlo, thi } = loadTol();
let basis = BASES.includes(lsGet(CMP.basis)) ? lsGet(CMP.basis) : "tmax";
// Distribution section (independent of comfort): which metric to bucket by, and
@ -65,7 +78,7 @@ let locations = [];
// Bumped on every (re)load so a superseded in-flight fetch drops its result.
let loadToken = 0;
const BASIS_LABEL = { tmax: "daytime high", mean: "daily mean", tmin: "overnight low", feels: "feels-like" };
const BASIS_LABEL = { tmax: "daytime high", mean: "daily mean", tmin: "overnight low", feels: "feels-like high" };
// ---- date range (month granularity) ----
const isYM = (s) => typeof s === "string" && /^\d{4}-\d{2}$/.test(s);
@ -91,14 +104,14 @@ const monthLabel = (ym) => `${MONTHS[+ym.slice(5, 7) - 1]} ${ym.slice(0, 4)}`;
// and handled by the seed path instead.
function writeHashState() {
const p = new URLSearchParams();
p.set("lo", lo); p.set("hi", hi); p.set("b", basis);
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 (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", "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", "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);
@ -108,6 +121,11 @@ function readHashState() {
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 (p.has("tlo") || p.has("thi")) {
const a = clamp(+p.get("tlo"), R_MIN, R_MAX), b = clamp(+p.get("thi"), R_MIN, R_MAX);
if (!isNaN(a)) st.tlo = a;
if (!isNaN(b)) st.thi = b;
}
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");
@ -131,8 +149,11 @@ const baseline = document.getElementById("cmp-baseline");
const results = document.getElementById("cmp-results");
const loInput = document.getElementById("cmp-lo");
const hiInput = document.getElementById("cmp-hi");
const tloInput = document.getElementById("cmp-tlo");
const thiInput = document.getElementById("cmp-thi");
const rangeVal = document.getElementById("cmp-range-val");
const fillEl = document.getElementById("cmp-fill");
const fillTolEl = document.getElementById("cmp-fill-tol");
const basisToggle = document.getElementById("cmp-basis");
const startInput = document.getElementById("cmp-start");
const endInput = document.getElementById("cmp-end");
@ -246,7 +267,11 @@ 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;
// "Feels" judges the felt daytime high (fmax) — the apparent-temperature peak of
// the day. The combined `feels` metric instead reports whichever apparent extreme
// is furthest from 65°F, so in mild climates it flips to the cold overnight low in
// winter and makes comfortable days vanish; fmax is the like-for-like daytime feel.
if (basis === "feels") return r.fmax ? r.fmax.v : null;
return hi != null && lo != null ? (hi + lo) / 2 : null; // mean
}
@ -257,25 +282,42 @@ function quantile(sorted, q) {
return lo === hi ? sorted[lo] : sorted[lo] + (sorted[hi] - sorted[lo]) * (i - lo);
}
// Score every day 01: full credit inside comfort [lo,hi]; inside tolerance it
// ramps 1→0 by how far past the comfort edge it lands (so a near miss beats a far
// one); beyond tolerance it's 0. The mean of those credits is the 0100 match
// score we rank on — it folds in comfort days, tolerance days, and miss magnitude.
function computeStats(series) {
let below = 0, above = 0, comf = 0, sumBelow = 0, sumAbove = 0, sumMiss = 0, sumTemp = 0;
let comf = 0, belowTol = 0, belowOut = 0, aboveTol = 0, aboveOut = 0;
let sumBelow = 0, sumAbove = 0, sumMiss = 0, sumTemp = 0, sumCredit = 0;
const temps = []; // every valid basis temp (°F), for the average + spread
for (const r of series) {
const t = basisTemp(r);
if (t == null || isNaN(t)) continue;
temps.push(t);
sumTemp += t;
// 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++;
temps.push(t); sumTemp += t;
if (t >= lo && t <= hi) { comf++; sumCredit += 1; continue; }
const cold = t < lo;
const d = cold ? lo - t : t - hi; // degrees past the comfort edge
const margin = cold ? lo - tlo : thi - hi; // tolerance width on this side
sumMiss += d;
if (cold) sumBelow += d; else sumAbove += d;
if (margin > 0 && d <= margin) { // within tolerance: partial credit
sumCredit += 1 - d / margin;
if (cold) belowTol++; else aboveTol++;
} else { // beyond tolerance: a full miss
if (cold) belowOut++; else aboveOut++;
}
}
const n = temps.length;
if (!n) return null;
temps.sort((a, b) => a - b);
const below = belowTol + belowOut, above = aboveTol + aboveOut;
return {
n, comf, below, above,
n, comf, below, above, belowTol, belowOut, aboveTol, aboveOut,
score: 100 * sumCredit / n, // the match score we rank on
comfortPct: 100 * comf / n, belowPct: 100 * below / n, abovePct: 100 * above / n,
belowTolPct: 100 * belowTol / n, belowOutPct: 100 * belowOut / n,
aboveTolPct: 100 * aboveTol / n, aboveOutPct: 100 * aboveOut / n,
tolPct: 100 * (belowTol + aboveTol) / n,
avgBelow: below ? sumBelow / below : 0,
avgAbove: above ? sumAbove / above : 0,
mad: sumMiss / n,
@ -305,29 +347,32 @@ function renderLocList() {
}).join("");
}
// A ranked place: the comfort share up top, then a diverging bar whose colder days
// grow left of a fixed center (the comfort zone) and warmer days grow right — so a
// place that runs cold vs one that runs hot read as mirror images on a shared scale.
// A ranked place: the 0100 match score up top, then a diverging bar whose colder
// days grow left of a fixed center (the comfort zone) and warmer days grow right.
// Each side is split into a tolerance band (lighter, nearest the center) and a
// beyond-tolerance band (saturated, further out), so how far a place misses — not
// just how often — is visible, and a cold place and a hot one mirror each other.
function rankCard(loc, s, rank) {
const win = rank === 1;
const bar = `<div class="cmp-dv" role="img" aria-label="${pct(s.belowPct)} colder, ${pct(s.comfortPct)} in comfort, ${pct(s.abovePct)} warmer">` +
`<div class="cmp-dv-side cmp-dv-left"><span class="cmp-dv-fill cmp-below" style="width:${s.belowPct}%"></span></div>` +
const seg = (cls, w) => (w > 0 ? `<span class="cmp-dv-seg ${cls}" style="width:${w}%"></span>` : "");
const bar = `<div class="cmp-dv" role="img" aria-label="match score ${Math.round(s.score)} of 100; ${pct(s.belowPct)} colder, ${pct(s.comfortPct)} in comfort, ${pct(s.abovePct)} warmer">` +
`<div class="cmp-dv-side cmp-dv-left">${seg("cmp-seg-cold", s.belowOutPct)}${seg("cmp-seg-cool", s.belowTolPct)}</div>` +
`<span class="cmp-dv-mid" aria-hidden="true"></span>` +
`<div class="cmp-dv-side cmp-dv-right"><span class="cmp-dv-fill cmp-above" style="width:${s.abovePct}%"></span></div></div>`;
`<div class="cmp-dv-side cmp-dv-right">${seg("cmp-seg-warm", s.aboveTolPct)}${seg("cmp-seg-hot", s.aboveOutPct)}</div></div>`;
const cap =
`<div class="cmp-dv-cap">` +
`<span class="cmp-cap cmp-cap-below">${pct(s.belowPct)} colder${s.below ? ` · avg ${deg(s.avgBelow)} under` : ""}</span>` +
`<span class="cmp-cap cmp-cap-above">${s.above ? `avg ${deg(s.avgAbove)} over · ` : ""}${pct(s.abovePct)} warmer</span>` +
`</div>`;
const bands = `<div class="cmp-bands">${pct(s.comfortPct)} in comfort · ${pct(s.tolPct)} in tolerance · typically off by <b>${deg(s.mad)}</b></div>`;
const sub = win ? `<span class="cmp-best">★ Best match</span> · ${s.n} days` : `${s.n} days`;
return `<div class="cmp-card${win ? " cmp-win" : ""}">` +
`<div class="cmp-card-top">` +
`<span class="cmp-rank">${rank}</span>` +
`<div class="cmp-card-name"><span class="cmp-name">${loc.name}</span><span class="cmp-daycount">${sub}</span></div>` +
`<div class="cmp-big"><b>${pct(s.comfortPct)}</b><span>in comfort</span></div>` +
`<div class="cmp-big"><b>${Math.round(s.score)}</b><span>match / 100</span></div>` +
`</div>` +
bar + cap +
`<div class="cmp-mad">Typical day misses comfort by <b>${deg(s.mad)}</b></div>` +
bar + cap + bands +
`</div>`;
}
@ -344,19 +389,20 @@ const skeletonRow = () => `<div class="cmp-card cmp-skel" aria-hidden="true">` +
// colored by where the average sits relative to the comfort range.
function renderBaseline(scored) {
if (!scored.length) { baseline.hidden = true; baseline.innerHTML = ""; return; }
let min = lo, max = hi;
let min = Math.min(lo, tlo), max = Math.max(hi, thi);
for (const { s } of scored) { min = Math.min(min, s.p10); max = Math.max(max, s.p90); }
const pad = Math.max(3, (max - min) * 0.06); // keep markers off the edges
min -= pad; max += pad;
const span = max - min || 1;
const posF = (v) => clamp((v - min) / span * 100, 0, 100);
const bandL = posF(lo), bandR = posF(hi);
const bandL = posF(lo), bandR = posF(hi), tolL = posF(tlo), tolR = posF(thi);
const posClass = (t) => (t < lo ? "cmp-below" : t > hi ? "cmp-above" : "cmp-comfort");
const rows = scored.map(({ loc, s }) => {
const cls = posClass(s.avgTemp), a = posF(s.avgTemp);
return `<div class="cmp-bl-row">` +
`<span class="cmp-bl-name">${loc.name}</span>` +
`<div class="cmp-bl-track" role="img" aria-label="${loc.name}: average ${fmtTemp(s.avgTemp)}, typical ${fmtTemp(s.p10)} to ${fmtTemp(s.p90)}">` +
`<span class="cmp-bl-tol" style="left:${tolL}%;right:${100 - tolR}%"></span>` +
`<span class="cmp-bl-band" style="left:${bandL}%;right:${100 - bandR}%"></span>` +
`<span class="cmp-bl-spread ${cls}" style="left:${posF(s.p10)}%;right:${100 - posF(s.p90)}%"></span>` +
`<span class="cmp-bl-dot ${cls}" style="left:${a}%"></span>` +
@ -366,7 +412,7 @@ function renderBaseline(scored) {
baseline.hidden = false;
baseline.innerHTML =
`<div class="cmp-bl-head">Typical temperature by place ` +
`<span class="hint">dot = average · bar = middle-80% of days · shaded = your comfort range</span></div>` +
`<span class="hint">dot = average · bar = middle-80% of days · shaded = comfort, faint = tolerance</span></div>` +
`<div class="cmp-bl-rows">${rows}</div>` +
`<div class="cmp-bl-axis"><span>${fmtTemp(min)}</span><span>${fmtTemp((min + max) / 2)}</span><span>${fmtTemp(max)}</span></div>`;
}
@ -376,8 +422,8 @@ function renderResults() {
const scored = locations
.map((l) => ({ loc: l, s: l.series ? computeStats(l.series) : null }))
.filter((x) => x.s);
// Best comfort share first; break ties by the smaller typical miss.
scored.sort((a, b) => (b.s.comfortPct - a.s.comfortPct) || (a.s.mad - b.s.mad));
// 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));
renderBaseline(scored);
@ -394,11 +440,11 @@ 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 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)}`;
? `<b>${win.loc.name}</b> is the best match for ${fmtTemp(lo)}${fmtTemp(hi)}score ${Math.round(win.s.score)}/100`
: `<b>${win.loc.name}</b>: match score ${Math.round(win.s.score)}/100 for ${fmtTemp(lo)}${fmtTemp(hi)}`;
head.hidden = false;
head.innerHTML = `<h2>${lead}</h2>` +
`<p class="meta">By ${BASIS_LABEL[basis]} · ${span}` +
`<p class="meta">By ${BASIS_LABEL[basis]} · comfort ${fmtTemp(lo)}${fmtTemp(hi)}, tolerance ${fmtTemp(tlo)}${fmtTemp(thi)} · ${span}` +
`${loading ? ` · <span class="cmp-loading-inline"><span class="cmp-spinner"></span>loading ${loading} more…</span>` : ""}</p>`;
results.innerHTML = scored.map((x, i) => rankCard(x.loc, x.s, i + 1)).join("") +
@ -452,19 +498,26 @@ locList.addEventListener("click", (e) => {
// 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)}`;
tloInput.value = tlo; loInput.value = lo; hiInput.value = hi; thiInput.value = thi;
rangeVal.textContent = `${fmtTemp(lo)}${fmtTemp(hi)} · tolerance ${fmtTemp(tlo)}${fmtTemp(thi)}`;
fillEl.style.left = `${pctPos(lo)}%`;
fillEl.style.right = `${100 - pctPos(hi)}%`;
fillTolEl.style.left = `${pctPos(tlo)}%`;
fillTolEl.style.right = `${100 - pctPos(thi)}%`;
}
function slicerChanged() {
lsSet(CMP.lo, String(lo)); lsSet(CMP.hi, String(hi));
lsSet(CMP.tlo, String(tlo)); lsSet(CMP.thi, String(thi));
syncSlicer();
writeHashState();
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(); });
// Comfort thumbs stay inside the tolerance thumbs; tolerance thumbs stay outside
// comfort — so the four handles can never cross (tlo ≤ lo ≤ hi ≤ thi).
loInput.addEventListener("input", () => { lo = clamp(+loInput.value, tlo, hi); slicerChanged(); });
hiInput.addEventListener("input", () => { hi = clamp(+hiInput.value, lo, thi); slicerChanged(); });
tloInput.addEventListener("input", () => { tlo = Math.min(+tloInput.value, lo); slicerChanged(); });
thiInput.addEventListener("input", () => { thi = Math.max(+thiInput.value, hi); 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
@ -510,9 +563,14 @@ clickOpensPicker(startInput, endInput); // whole-field tap opens the month pic
if (hash) {
if (hash.lo != null) lo = hash.lo;
if (hash.hi != null) hi = hash.hi;
if (hash.tlo != null) tlo = hash.tlo;
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 };
}
// Keep comfort ordered and inside tolerance even if a hand-edited link isn't.
if (lo > hi) { const t = lo; lo = hi; hi = t; }
tlo = Math.min(tlo, lo); thi = Math.max(thi, hi);
syncSlicer();
basisToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b.dataset.basis === basis));

View file

@ -848,21 +848,22 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
left of it, warmer days grow right. Both halves share one 0100% scale, so a
cold-skewed place and a hot-skewed one mirror each other and compare at a glance. */
.cmp-dv { display: flex; align-items: stretch; height: 16px; margin: 14px 0 8px; }
.cmp-dv-side { flex: 1 1 0; position: relative; background: var(--surface-2); overflow: hidden; }
.cmp-dv-left { border-radius: 8px 0 0 8px; }
.cmp-dv-right { border-radius: 0 8px 8px 0; }
.cmp-dv-fill { position: absolute; top: 0; bottom: 0; }
.cmp-dv-left .cmp-dv-fill { right: 0; } /* colder grows leftward from center */
.cmp-dv-right .cmp-dv-fill { left: 0; } /* warmer grows rightward from center */
.cmp-dv-fill.cmp-below { background: var(--cold); }
.cmp-dv-fill.cmp-above { background: var(--hot); }
.cmp-dv-side { flex: 1 1 0; display: flex; background: var(--surface-2); overflow: hidden; }
.cmp-dv-left { border-radius: 8px 0 0 8px; justify-content: flex-end; } /* colder packs toward center */
.cmp-dv-right { border-radius: 0 8px 8px 0; justify-content: flex-start; } /* warmer packs toward center */
.cmp-dv-seg { height: 100%; flex: 0 0 auto; }
/* Nearest the centre = within tolerance (lighter); further out = beyond it (saturated). */
.cmp-seg-cool { background: var(--cool); }
.cmp-seg-cold { background: var(--cold); }
.cmp-seg-warm { background: var(--warm); }
.cmp-seg-hot { background: var(--hot); }
.cmp-dv-mid { flex: 0 0 4px; background: var(--normal); } /* the comfort-zone marker */
.cmp-dv-cap { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; }
.cmp-cap-below { color: var(--cold); }
.cmp-cap-above { color: var(--hot); text-align: right; }
.cmp-mad { margin: 10px 0 0; font-size: 13px; color: var(--muted); }
.cmp-mad b { color: var(--text); }
.cmp-bands { margin: 8px 0 0; font-size: 12.5px; color: var(--muted); }
.cmp-bands b { color: var(--text); }
/* Baseline strip: every place's typical basis temperature on one shared °F axis,
with the comfort range shaded, so the places' baselines line up side by side. */
@ -874,6 +875,7 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
.cmp-bl-row { display: grid; grid-template-columns: 120px 1fr; align-items: center; gap: 12px; }
.cmp-bl-name { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cmp-bl-track { position: relative; height: 26px; }
.cmp-bl-tol { position: absolute; top: 0; bottom: 0; border-radius: 3px; background: color-mix(in srgb, var(--normal) 9%, transparent); }
.cmp-bl-band {
position: absolute; top: 0; bottom: 0; border-radius: 3px;
background: color-mix(in srgb, var(--normal) 20%, transparent);
@ -942,6 +944,15 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
.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; }
/* Quad-handle variant: an outer tolerance range around the inner comfort range on
the same track. The tolerance fill sits behind the comfort fill; tolerance thumbs
are hollow (a ring) so they read as secondary, and the comfort thumbs stack above
them so the inner handles always win where two thumbs overlap. */
.quad-range .dual-fill-tol { background: color-mix(in srgb, var(--accent) 28%, transparent); }
.quad-range #cmp-lo, .quad-range #cmp-hi { z-index: 2; }
.quad-range input.thumb-tol::-webkit-slider-thumb { width: 18px; height: 18px; background: var(--surface); border: 2px solid var(--accent); }
.quad-range input.thumb-tol::-moz-range-thumb { width: 18px; height: 18px; background: var(--surface); border: 2px solid var(--accent); }
/* 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; }