diff --git a/static/compare.js b/static/compare.js
index 8c6f4ad..456b9e8 100644
--- a/static/compare.js
+++ b/static/compare.js
@@ -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 0–1: 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 0–100 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 0–100 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 = `
` +
- `
` +
+ const seg = (cls, w) => (w > 0 ? `
` : "");
+ const bar = `
` +
+ `
${seg("cmp-seg-cold", s.belowOutPct)}${seg("cmp-seg-cool", s.belowTolPct)}
` +
`
` +
- `
`;
+ `
${seg("cmp-seg-warm", s.aboveTolPct)}${seg("cmp-seg-hot", s.aboveOutPct)}
`;
const cap =
`
` +
`${pct(s.belowPct)} colder${s.below ? ` · avg ${deg(s.avgBelow)} under` : ""}` +
`${s.above ? `avg ${deg(s.avgAbove)} over · ` : ""}${pct(s.abovePct)} warmer` +
`
`;
+ const bands = `
${pct(s.comfortPct)} in comfort · ${pct(s.tolPct)} in tolerance · typically off by ${deg(s.mad)}
`;
const sub = win ? `
★ Best match · ${s.n} days` : `${s.n} days`;
return `
` +
`
` +
`
${rank}` +
`
${loc.name}${sub}
` +
- `
${pct(s.comfortPct)}in comfort
` +
+ `
${Math.round(s.score)}match / 100
` +
`
` +
- bar + cap +
- `
Typical day misses comfort by ${deg(s.mad)}
` +
+ bar + cap + bands +
`
`;
}
@@ -344,19 +389,20 @@ const skeletonRow = () => `
` +
// 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 `
` +
`
${loc.name}` +
`
` +
+ `
` +
`
` +
`
` +
`
` +
@@ -366,7 +412,7 @@ function renderBaseline(scored) {
baseline.hidden = false;
baseline.innerHTML =
`
Typical temperature by place ` +
- `dot = average · bar = middle-80% of days · shaded = your comfort range
` +
+ `
dot = average · bar = middle-80% of days · shaded = comfort, faint = tolerance` +
`
${rows}
` +
`
${fmtTemp(min)}${fmtTemp((min + max) / 2)}${fmtTemp(max)}
`;
}
@@ -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
- ? `
${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)}`;
+ ? `
${win.loc.name} is the best match for ${fmtTemp(lo)}–${fmtTemp(hi)} — score ${Math.round(win.s.score)}/100`
+ : `
${win.loc.name}: match score ${Math.round(win.s.score)}/100 for ${fmtTemp(lo)}–${fmtTemp(hi)}`;
head.hidden = false;
head.innerHTML = `
${lead}
` +
- `
By ${BASIS_LABEL[basis]} · ${span}` +
+ `
By ${BASIS_LABEL[basis]} · comfort ${fmtTemp(lo)}–${fmtTemp(hi)}, tolerance ${fmtTemp(tlo)}–${fmtTemp(thi)} · ${span}` +
`${loading ? ` · loading ${loading} more…` : ""}
`;
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));
diff --git a/static/style.css b/static/style.css
index d35054d..1160aa9 100644
--- a/static/style.css
+++ b/static/style.css
@@ -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 0–100% 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; }