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:
parent
b49489e730
commit
428dfebe83
3 changed files with 121 additions and 47 deletions
|
|
@ -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 & 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">
|
||||
|
|
|
|||
|
|
@ -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 = `<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));
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue