2026-07-11 02:58:56 +00:00
|
|
|
|
// Compare view: line up several places over one date range and see which best
|
2026-07-11 22:33:38 +00:00
|
|
|
|
// 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.
|
2026-07-11 02:58:56 +00:00
|
|
|
|
//
|
2026-07-11 22:33:38 +00:00
|
|
|
|
// 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.
|
2026-07-11 02:58:56 +00:00
|
|
|
|
|
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
|
|
|
|
import { loadLastLocation, saveLastLocation } from "./nav.js";
|
|
|
|
|
|
import { getJSON, TTL } from "./cache.js";
|
|
|
|
|
|
import { initFindButton } from "./mappicker.js";
|
2026-07-11 22:33:38 +00:00
|
|
|
|
import { MONTHS, pad, monthStart, monthEnd, buildChunks,
|
|
|
|
|
|
clickOpensPicker, metricBuckets, distStrip } from "./shared.js";
|
2026-07-11 21:48:21 +00:00
|
|
|
|
import { fmtTemp, fmtDelta, onUnitChange } from "./units.js";
|
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47)
The page scripts each re-declared the shared presentation layer — the tier
color table existed in four places (app.js, calendar.js, day.js, style.css)
and the scale label tables in three (plus legend.html's own drifting copy),
alongside per-page copies of the dryness ramp, formatters, ord, todayISO,
esc, the weather icons/summary, month helpers and the 2-year range chunker.
~240 duplicated lines deleted (net -236 with the new module included).
- frontend/shared.js (IIFE, extends window.Thermograph): tier colors read
from style.css's :root custom properties at load — the CSS is now the
single source of truth; the JS map exists only because inline-SVG work
(chart + PNG export) needs literal values, with hex fallbacks for a
missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord,
todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker,
and the weatherType summary (dsr-aware; the day page just omits dsr).
- nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in
the shared classic-script scope, and a leaked locHash collided with page
destructuring (caught by the browser smoke, not by node --check).
locHash is now exported and used by all 8 former hand-built hash sites.
- mappicker.js: initFindButton/setFindLabel replace the Find-button block
each page rebuilt.
- legend.html renders its scales from the shared tables, so the guide can
no longer drift from what the app shows.
Verified: node --check on all JS; 108 backend tests; headless-Chromium
smoke over all five pages against live data — no console/page errors,
legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle,
calendar grid + key + metric switch, day 7 ladders + weather icon,
compare seeded rank card.
2026-07-11 20:21:48 +00:00
|
|
|
|
|
2026-07-11 02:58:56 +00:00
|
|
|
|
const CMP = {
|
2026-07-11 22:33:38 +00:00
|
|
|
|
lo: "thermograph:cmpLo",
|
|
|
|
|
|
hi: "thermograph:cmpHi",
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
tlo: "thermograph:cmpTlo",
|
|
|
|
|
|
thi: "thermograph:cmpThi",
|
2026-07-11 02:58:56 +00:00
|
|
|
|
basis: "thermograph:cmpBasis",
|
|
|
|
|
|
range: "thermograph:cmpRange",
|
|
|
|
|
|
locs: "thermograph:cmpLocs",
|
2026-07-11 22:33:38 +00:00
|
|
|
|
distMetric: "thermograph:cmpDistMetric",
|
|
|
|
|
|
distCount: "thermograph:cmpDistCount",
|
2026-07-11 02:58:56 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const lsGet = (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } };
|
|
|
|
|
|
const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch (e) {} };
|
|
|
|
|
|
|
|
|
|
|
|
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
2026-07-11 03:17:17 +00:00
|
|
|
|
const BASES = ["tmax", "tmin", "mean", "feels"];
|
2026-07-11 22:33:38 +00:00
|
|
|
|
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();
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
|
|
|
|
|
|
// 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();
|
2026-07-11 03:17:17 +00:00
|
|
|
|
let basis = BASES.includes(lsGet(CMP.basis)) ? lsGet(CMP.basis) : "tmax";
|
2026-07-11 02:58:56 +00:00
|
|
|
|
|
2026-07-11 22:33:38 +00:00
|
|
|
|
// 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";
|
|
|
|
|
|
|
2026-07-11 02:58:56 +00:00
|
|
|
|
// Locations being compared: {lat, lon, name, series|null, loading, error}.
|
2026-07-11 22:33:38 +00:00
|
|
|
|
// `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.
|
2026-07-11 02:58:56 +00:00
|
|
|
|
let locations = [];
|
|
|
|
|
|
// Bumped on every (re)load so a superseded in-flight fetch drops its result.
|
|
|
|
|
|
let loadToken = 0;
|
|
|
|
|
|
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
const BASIS_LABEL = { tmax: "daytime high", mean: "daily mean", tmin: "overnight low", feels: "feels-like high" };
|
2026-07-11 02:58:56 +00:00
|
|
|
|
|
|
|
|
|
|
// ---- date range (month granularity) ----
|
2026-07-11 03:17:17 +00:00
|
|
|
|
const isYM = (s) => typeof s === "string" && /^\d{4}-\d{2}$/.test(s);
|
2026-07-11 02:58:56 +00:00
|
|
|
|
function defaultRange() {
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
const s = new Date(now.getFullYear(), now.getMonth() - 11, 1); // last 12 whole months
|
|
|
|
|
|
return { start: `${s.getFullYear()}-${pad(s.getMonth() + 1)}`, end: `${now.getFullYear()}-${pad(now.getMonth() + 1)}` };
|
|
|
|
|
|
}
|
|
|
|
|
|
function loadRange() {
|
2026-07-11 03:17:17 +00:00
|
|
|
|
try { const o = JSON.parse(lsGet(CMP.range)); if (o && isYM(o.start) && isYM(o.end)) return o; } catch (e) {}
|
2026-07-11 02:58:56 +00:00
|
|
|
|
return defaultRange();
|
|
|
|
|
|
}
|
2026-07-11 03:17:17 +00:00
|
|
|
|
let range = loadRange(); // the APPLIED range (what loaded data reflects), "YYYY-MM"
|
2026-07-11 02:58:56 +00:00
|
|
|
|
|
|
|
|
|
|
// Pretty "Jul 2025 – Jun 2026" for a YYYY-MM range.
|
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47)
The page scripts each re-declared the shared presentation layer — the tier
color table existed in four places (app.js, calendar.js, day.js, style.css)
and the scale label tables in three (plus legend.html's own drifting copy),
alongside per-page copies of the dryness ramp, formatters, ord, todayISO,
esc, the weather icons/summary, month helpers and the 2-year range chunker.
~240 duplicated lines deleted (net -236 with the new module included).
- frontend/shared.js (IIFE, extends window.Thermograph): tier colors read
from style.css's :root custom properties at load — the CSS is now the
single source of truth; the JS map exists only because inline-SVG work
(chart + PNG export) needs literal values, with hex fallbacks for a
missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord,
todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker,
and the weatherType summary (dsr-aware; the day page just omits dsr).
- nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in
the shared classic-script scope, and a leaked locHash collided with page
destructuring (caught by the browser smoke, not by node --check).
locHash is now exported and used by all 8 former hand-built hash sites.
- mappicker.js: initFindButton/setFindLabel replace the Find-button block
each page rebuilt.
- legend.html renders its scales from the shared tables, so the guide can
no longer drift from what the app shows.
Verified: node --check on all JS; 108 backend tests; headless-Chromium
smoke over all five pages against live data — no console/page errors,
legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle,
calendar grid + key + metric switch, day 7 ladders + weather icon,
compare seeded rank card.
2026-07-11 20:21:48 +00:00
|
|
|
|
const monthLabel = (ym) => `${MONTHS[+ym.slice(5, 7) - 1]} ${ym.slice(0, 4)}`;
|
2026-07-11 02:58:56 +00:00
|
|
|
|
|
2026-07-11 03:17:17 +00:00
|
|
|
|
// ---- shareable URL state ----
|
2026-07-11 22:33:38 +00:00
|
|
|
|
// The hash carries the comparison: lo/hi=comfort range, b=basis, s/e=range,
|
2026-07-11 03:17:17 +00:00
|
|
|
|
// loc=lat,lon;lat,lon. Written on every state change; read once on load (a link
|
2026-07-11 22:33:38 +00:00
|
|
|
|
// 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.
|
2026-07-11 03:17:17 +00:00
|
|
|
|
function writeHashState() {
|
|
|
|
|
|
const p = new URLSearchParams();
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
p.set("lo", lo); p.set("hi", hi); p.set("tlo", tlo); p.set("thi", thi); p.set("b", basis);
|
2026-07-11 03:17:17 +00:00
|
|
|
|
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));
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
if (!["lo", "hi", "tlo", "thi", "c", "t", "b", "s", "e", "loc"].some((k) => p.has(k))) return null; // not a compare link
|
2026-07-11 03:17:17 +00:00
|
|
|
|
const st = {};
|
2026-07-11 22:33:38 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-07-11 03:17:17 +00:00
|
|
|
|
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");
|
|
|
|
|
|
if (p.has("loc")) {
|
|
|
|
|
|
st.locs = p.get("loc").split(";").map((pair) => {
|
|
|
|
|
|
const [a, b] = pair.split(",").map(Number);
|
|
|
|
|
|
return { lat: a, lon: b };
|
|
|
|
|
|
}).filter((l) => !isNaN(l.lat) && !isNaN(l.lon));
|
|
|
|
|
|
}
|
|
|
|
|
|
return st;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 02:58:56 +00:00
|
|
|
|
// ---- elements ----
|
|
|
|
|
|
const addBtn = document.getElementById("cmp-add");
|
2026-07-11 03:17:17 +00:00
|
|
|
|
const refreshBtn = document.getElementById("cmp-refresh");
|
2026-07-11 02:58:56 +00:00
|
|
|
|
const locList = document.getElementById("cmp-loc-list");
|
|
|
|
|
|
const params = document.getElementById("cmp-params");
|
|
|
|
|
|
const placeholder = document.getElementById("cmp-placeholder");
|
|
|
|
|
|
const head = document.getElementById("cmp-head");
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
const baseline = document.getElementById("cmp-baseline");
|
2026-07-11 02:58:56 +00:00
|
|
|
|
const results = document.getElementById("cmp-results");
|
2026-07-11 22:33:38 +00:00
|
|
|
|
const loInput = document.getElementById("cmp-lo");
|
|
|
|
|
|
const hiInput = document.getElementById("cmp-hi");
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
const tloInput = document.getElementById("cmp-tlo");
|
|
|
|
|
|
const thiInput = document.getElementById("cmp-thi");
|
2026-07-12 02:23:28 +00:00
|
|
|
|
const comfortValEl = document.getElementById("cmp-comfort-val");
|
|
|
|
|
|
const tolValEl = document.getElementById("cmp-tol-val");
|
|
|
|
|
|
const tickEls = {
|
|
|
|
|
|
tlo: document.getElementById("cmp-tick-tlo"), lo: document.getElementById("cmp-tick-lo"),
|
|
|
|
|
|
hi: document.getElementById("cmp-tick-hi"), thi: document.getElementById("cmp-tick-thi"),
|
|
|
|
|
|
};
|
2026-07-11 22:33:38 +00:00
|
|
|
|
const fillEl = document.getElementById("cmp-fill");
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
const fillTolEl = document.getElementById("cmp-fill-tol");
|
2026-07-11 02:58:56 +00:00
|
|
|
|
const basisToggle = document.getElementById("cmp-basis");
|
|
|
|
|
|
const startInput = document.getElementById("cmp-start");
|
|
|
|
|
|
const endInput = document.getElementById("cmp-end");
|
2026-07-11 22:33:38 +00:00
|
|
|
|
const distSection = document.getElementById("cmp-dist");
|
|
|
|
|
|
const distToggle = document.getElementById("cmp-dist-metric");
|
|
|
|
|
|
const distBody = document.getElementById("cmp-dist-body");
|
2026-07-11 02:58:56 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ---- data ----
|
2026-07-11 22:33:38 +00:00
|
|
|
|
// Fetch one location's range (chunked), keeping the raw daily records — the comfort
|
|
|
|
|
|
// ranking reads their values, the distribution their category classes.
|
2026-07-11 02:58:56 +00:00
|
|
|
|
async function fetchSeries(loc, token) {
|
|
|
|
|
|
const chunks = buildChunks(monthStart(range.start), monthEnd(range.end));
|
|
|
|
|
|
const days = [];
|
|
|
|
|
|
let place = null;
|
|
|
|
|
|
for (const ch of chunks) {
|
|
|
|
|
|
const url = `api/v2/calendar?lat=${loc.lat}&lon=${loc.lon}&start=${ch.start}&end=${ch.end}`;
|
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
|
|
|
|
const d = await getJSON(url, TTL.calendar, true);
|
2026-07-11 02:58:56 +00:00
|
|
|
|
if (token !== loadToken) return null; // superseded
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-07-11 22:33:38 +00:00
|
|
|
|
return { name: place, series: days };
|
2026-07-11 02:58:56 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function persistLocations() {
|
|
|
|
|
|
lsSet(CMP.locs, JSON.stringify(locations.map((l) => ({ lat: l.lat, lon: l.lon, name: l.name }))));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadLocation(loc, token) {
|
|
|
|
|
|
loc.loading = true; loc.error = null;
|
|
|
|
|
|
renderAll();
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetchSeries(loc, token);
|
2026-07-11 03:17:17 +00:00
|
|
|
|
if (!res || token !== loadToken) return; // superseded (a newer load owns this loc)
|
|
|
|
|
|
loc.name = res.name; loc.series = res.series;
|
2026-07-11 02:58:56 +00:00
|
|
|
|
} catch (e) {
|
2026-07-11 03:17:17 +00:00
|
|
|
|
if (token === loadToken) loc.error = e.message || "couldn't load";
|
2026-07-11 02:58:56 +00:00
|
|
|
|
}
|
2026-07-11 03:17:17 +00:00
|
|
|
|
if (token === loadToken) loc.loading = false;
|
2026-07-11 02:58:56 +00:00
|
|
|
|
persistLocations();
|
|
|
|
|
|
renderAll();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 03:17:17 +00:00
|
|
|
|
// Load every location that still needs data under the current range.
|
|
|
|
|
|
function loadPending(token) {
|
|
|
|
|
|
for (const loc of locations) if (!loc.series && !loc.loading) loadLocation(loc, token);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 02:58:56 +00:00
|
|
|
|
function addLocation(lat, lon) {
|
|
|
|
|
|
// Ignore a spot already on the list (same grid neighborhood).
|
|
|
|
|
|
if (locations.some((l) => Math.abs(l.lat - lat) < 0.05 && Math.abs(l.lon - lon) < 0.05)) return;
|
2026-07-11 14:50:48 +00:00
|
|
|
|
const loc = { lat, lon, name: null, series: null, loading: false, error: null };
|
|
|
|
|
|
locations.push(loc);
|
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
|
|
|
|
saveLastLocation(lat, lon);
|
2026-07-11 03:17:17 +00:00
|
|
|
|
persistLocations();
|
|
|
|
|
|
writeHashState();
|
|
|
|
|
|
renderAll(); // pending → the Refresh button appears; no fetch until it's tapped
|
2026-07-11 14:50:48 +00:00
|
|
|
|
resolveName(loc); // but resolve the neighborhood+city right away (before Refresh)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Resolve just the place name for a freshly-added location, independent of its
|
|
|
|
|
|
// (deferred, heavier) series load, so the chip flips from coordinates to the
|
|
|
|
|
|
// neighborhood + city immediately on add. Best-effort — the series load resolves
|
|
|
|
|
|
// the name too, so a failure here is harmless.
|
|
|
|
|
|
async function resolveName(loc) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`api/v2/place?lat=${loc.lat}&lon=${loc.lon}`);
|
|
|
|
|
|
if (!res.ok) return;
|
|
|
|
|
|
const d = await res.json();
|
|
|
|
|
|
// Skip if the location was removed meanwhile, or already got named by a load.
|
|
|
|
|
|
if (!locations.includes(loc) || loc.name || !d || !d.place) return;
|
|
|
|
|
|
loc.name = d.place;
|
|
|
|
|
|
persistLocations();
|
|
|
|
|
|
renderAll();
|
|
|
|
|
|
} catch (e) { /* best-effort — the series load will still resolve the name */ }
|
2026-07-11 02:58:56 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function removeLocation(i) {
|
|
|
|
|
|
locations.splice(i, 1);
|
|
|
|
|
|
persistLocations();
|
2026-07-11 03:17:17 +00:00
|
|
|
|
writeHashState();
|
2026-07-11 02:58:56 +00:00
|
|
|
|
renderAll();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 03:17:17 +00:00
|
|
|
|
// ---- pending / refresh state ----
|
|
|
|
|
|
const dateChanged = () => startInput.value !== range.start || endInput.value !== range.end;
|
|
|
|
|
|
const isPending = (l) => !l.series && !l.loading && !l.error;
|
|
|
|
|
|
const isDirty = () => locations.length > 0 && (dateChanged() || locations.some(isPending));
|
|
|
|
|
|
|
|
|
|
|
|
// The Refresh tap: adopt any edited dates (which invalidates every series) and load
|
|
|
|
|
|
// whatever now needs data.
|
|
|
|
|
|
function refresh() {
|
|
|
|
|
|
let s = startInput.value || range.start, e = endInput.value || range.end;
|
|
|
|
|
|
if (!isYM(s) || !isYM(e)) return;
|
|
|
|
|
|
if (s > e) { const t = s; s = e; e = t; }
|
|
|
|
|
|
const rangeChanged = s !== range.start || e !== range.end;
|
|
|
|
|
|
range = { start: s, end: e };
|
|
|
|
|
|
startInput.value = s; endInput.value = e;
|
|
|
|
|
|
lsSet(CMP.range, JSON.stringify(range));
|
2026-07-11 02:58:56 +00:00
|
|
|
|
const token = ++loadToken;
|
2026-07-11 03:17:17 +00:00
|
|
|
|
if (rangeChanged) for (const loc of locations) loc.series = null;
|
|
|
|
|
|
writeHashState();
|
|
|
|
|
|
loadPending(token);
|
|
|
|
|
|
renderAll();
|
2026-07-11 02:58:56 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- stats ----
|
2026-07-11 22:33:38 +00:00
|
|
|
|
// 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;
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
// "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;
|
2026-07-11 22:33:38 +00:00
|
|
|
|
return hi != null && lo != null ? (hi + lo) / 2 : null; // mean
|
2026-07-11 02:58:56 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
// A quantile (0–1) of an ascending-sorted array, linearly interpolated.
|
|
|
|
|
|
function quantile(sorted, q) {
|
|
|
|
|
|
if (!sorted.length) return null;
|
|
|
|
|
|
const i = (sorted.length - 1) * q, lo = Math.floor(i), hi = Math.ceil(i);
|
|
|
|
|
|
return lo === hi ? sorted[lo] : sorted[lo] + (sorted[hi] - sorted[lo]) * (i - lo);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
// 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.
|
2026-07-11 02:58:56 +00:00
|
|
|
|
function computeStats(series) {
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
let comf = 0, belowTol = 0, belowOut = 0, aboveTol = 0, aboveOut = 0;
|
|
|
|
|
|
let sumBelow = 0, sumAbove = 0, sumMiss = 0, sumTemp = 0, sumCredit = 0;
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
const temps = []; // every valid basis temp (°F), for the average + spread
|
2026-07-11 22:33:38 +00:00
|
|
|
|
for (const r of series) {
|
|
|
|
|
|
const t = basisTemp(r);
|
2026-07-11 02:58:56 +00:00
|
|
|
|
if (t == null || isNaN(t)) continue;
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
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++;
|
|
|
|
|
|
}
|
2026-07-11 02:58:56 +00:00
|
|
|
|
}
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
const n = temps.length;
|
2026-07-11 02:58:56 +00:00
|
|
|
|
if (!n) return null;
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
temps.sort((a, b) => a - b);
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
const below = belowTol + belowOut, above = aboveTol + aboveOut;
|
2026-07-11 02:58:56 +00:00
|
|
|
|
return {
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
n, comf, below, above, belowTol, belowOut, aboveTol, aboveOut,
|
|
|
|
|
|
score: 100 * sumCredit / n, // the match score we rank on
|
2026-07-11 02:58:56 +00:00
|
|
|
|
comfortPct: 100 * comf / n, belowPct: 100 * below / n, abovePct: 100 * above / n,
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
belowTolPct: 100 * belowTol / n, belowOutPct: 100 * belowOut / n,
|
|
|
|
|
|
aboveTolPct: 100 * aboveTol / n, aboveOutPct: 100 * aboveOut / n,
|
|
|
|
|
|
tolPct: 100 * (belowTol + aboveTol) / n,
|
2026-07-11 02:58:56 +00:00
|
|
|
|
avgBelow: below ? sumBelow / below : 0,
|
|
|
|
|
|
avgAbove: above ? sumAbove / above : 0,
|
2026-07-11 22:33:38 +00:00
|
|
|
|
mad: sumMiss / n,
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
// Baseline strip: the typical temperature (°F) and the middle-80% spread.
|
|
|
|
|
|
avgTemp: sumTemp / n, p10: quantile(temps, 0.1), p90: quantile(temps, 0.9),
|
2026-07-11 02:58:56 +00:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- render ----
|
|
|
|
|
|
const pct = (v) => `${v < 10 ? v.toFixed(1) : Math.round(v)}%`;
|
2026-07-11 21:48:21 +00:00
|
|
|
|
// Temperature *differences* (avg miss, typical miss) in the active unit.
|
|
|
|
|
|
const deg = (v) => fmtDelta(v);
|
2026-07-11 02:58:56 +00:00
|
|
|
|
|
|
|
|
|
|
function renderLocList() {
|
|
|
|
|
|
locList.innerHTML = locations.map((l, i) => {
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
let label, cls = "cmp-chip", lead = "";
|
|
|
|
|
|
// Coordinates show until the place name resolves. A spinning ring marks a live
|
|
|
|
|
|
// load; a "queued" dot marks a location waiting for the next Refresh tap. Once
|
|
|
|
|
|
// scored, l.name holds the resolved neighborhood + city.
|
Picker map: smaller labels, darker roads; location label coords→place (#27)
* Picker map: smaller city labels, darker/bolder roads (split tile layers)
Split the CARTO Voyager basemap into two raster layers so label size and road
weight are independent: a labels-free base upscaled via tileSize 512 + zoomOffset
-1 (bold, prominent roads) plus a labels-only layer at natural size on top (small,
crisp city names). The darken/saturate filter now targets the base layer only
(.mp-base: brightness .92, contrast 1.12), deepening the roads while leaving label
text at full contrast.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc
* Location label: show coordinates immediately, then resolve to place name
On selecting a location, write the raw coordinates to the location label right
away, so the user sees the picked spot instantly instead of a stale/blank label.
The existing post-fetch render already overwrites it with the reverse-geocoded
"neighborhood, city, region" string (data.place), so the label now reads
coords → place name live.
- app.js / calendar.js / day.js: set coords in the selection handler before the
fetch; the render/initOnce overwrite is unchanged.
- compare.js: show coordinates through the pending + loading states (instead of
"Locating…") so each chip's coords are replaced live by the place name once
scored.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 08:35:43 +00:00
|
|
|
|
const coords = `${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}`;
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
if (l.loading) { label = l.name || coords; cls += " loading"; lead = `<span class="cmp-spinner cmp-spinner-chip" aria-hidden="true"></span>`; }
|
2026-07-11 03:17:17 +00:00
|
|
|
|
else if (l.error) { label = "Couldn't load"; cls += " error"; }
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
else if (!l.series) { label = l.name || coords; cls += " pending"; lead = `<span class="cmp-chip-dot" aria-hidden="true"></span>`; }
|
2026-07-11 03:17:17 +00:00
|
|
|
|
else { label = l.name; }
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
return `<li class="${cls}">${lead}<span class="cmp-chip-name">${label}</span>` +
|
2026-07-11 02:58:56 +00:00
|
|
|
|
`<button type="button" class="cmp-chip-x" data-i="${i}" aria-label="Remove ${label}">×</button></li>`;
|
|
|
|
|
|
}).join("");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-12 01:49:22 +00:00
|
|
|
|
// The five day-buckets, ordered cold → hot: css color class, plain-language name,
|
|
|
|
|
|
// and the stat key holding its share of days. Drives both the bar and its key.
|
|
|
|
|
|
const BUCKETS = [
|
|
|
|
|
|
["cmp-seg-vcold", "Too cold", "belowOutPct"],
|
|
|
|
|
|
["cmp-seg-cool", "Cool", "belowTolPct"],
|
|
|
|
|
|
["cmp-seg-comf", "Comfortable", "comfortPct"],
|
|
|
|
|
|
["cmp-seg-warm", "Warm", "aboveTolPct"],
|
|
|
|
|
|
["cmp-seg-vhot", "Too hot", "aboveOutPct"],
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-07-12 00:49:06 +00:00
|
|
|
|
// A ranked place: the 0–100 match score up top, then one full-width bar showing how
|
2026-07-12 01:49:22 +00:00
|
|
|
|
// the place's days split across the five temperature buckets (cold → hot, in the
|
|
|
|
|
|
// site's own colors), and directly beneath it a key that names every bucket and its
|
|
|
|
|
|
// share — so even a 1% sliver on the bar reads clearly. The whole bar is 100% of the
|
|
|
|
|
|
// days, so more blue = runs cold, more red = runs hot.
|
2026-07-11 02:58:56 +00:00
|
|
|
|
function rankCard(loc, s, rank) {
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
const win = rank === 1;
|
2026-07-12 01:49:22 +00:00
|
|
|
|
const parts = BUCKETS.map(([cls, label, key]) => ({ cls, label, w: s[key] })).filter((p) => p.w > 0);
|
|
|
|
|
|
const bar = `<div class="cmp-bar" role="img" aria-label="of all days: ${parts.map((p) => `${pct(p.w)} ${p.label.toLowerCase()}`).join(", ")}">` +
|
|
|
|
|
|
parts.map((p) => `<span class="cmp-seg ${p.cls}" style="width:${p.w}%" title="${p.label} — ${pct(p.w)}"></span>`).join("") +
|
|
|
|
|
|
`</div>`;
|
|
|
|
|
|
// Key under the bar: a color swatch + name + share for every non-empty bucket, in
|
2026-07-12 02:23:28 +00:00
|
|
|
|
// the same cold→hot order as the bar, so small spans are still clearly labeled. On
|
|
|
|
|
|
// desktop each label is positioned at its segment's midpoint (the ends anchored to
|
|
|
|
|
|
// the bar edges); on mobile it falls back to a plain wrapping row.
|
|
|
|
|
|
let acc = 0;
|
2026-07-12 01:49:22 +00:00
|
|
|
|
const key = `<div class="cmp-key">` +
|
2026-07-12 02:23:28 +00:00
|
|
|
|
parts.map((p, i) => {
|
|
|
|
|
|
const mid = acc + p.w / 2; acc += p.w;
|
|
|
|
|
|
const pos = i === 0 ? "left:0" : i === parts.length - 1 ? "right:0" : `left:${mid}%`;
|
|
|
|
|
|
const edge = i === 0 ? " cmp-key-first" : i === parts.length - 1 ? " cmp-key-last" : "";
|
|
|
|
|
|
return `<span class="cmp-key-item${edge}" style="${pos}"><i class="cmp-sw ${p.cls}"></i>${p.label} <b>${pct(p.w)}</b></span>`;
|
|
|
|
|
|
}).join("") +
|
2026-07-12 00:49:06 +00:00
|
|
|
|
`</div>`;
|
2026-07-12 01:49:22 +00:00
|
|
|
|
const bands = `<div class="cmp-bands"><b>${pct(s.tolPct)}</b> within tolerance · typically <b>${deg(s.mad)}</b> off comfort</div>`;
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
const sub = win ? `<span class="cmp-best">★ Best match</span> · ${s.n} days` : `${s.n} days`;
|
|
|
|
|
|
return `<div class="cmp-card${win ? " cmp-win" : ""}">` +
|
2026-07-11 02:58:56 +00:00
|
|
|
|
`<div class="cmp-card-top">` +
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
`<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>` +
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
`<div class="cmp-big"><b>${Math.round(s.score)}</b><span>match / 100</span></div>` +
|
2026-07-11 02:58:56 +00:00
|
|
|
|
`</div>` +
|
2026-07-12 01:49:22 +00:00
|
|
|
|
bar + key + bands +
|
2026-07-11 02:58:56 +00:00
|
|
|
|
`</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
// A placeholder card shown while a location's data is still streaming in, so the
|
|
|
|
|
|
// results don't jump when it resolves. Animated by CSS (.cmp-skel).
|
|
|
|
|
|
const skeletonRow = () => `<div class="cmp-card cmp-skel" aria-hidden="true">` +
|
|
|
|
|
|
`<div class="cmp-skel-top"><span class="cmp-skel-badge"></span>` +
|
|
|
|
|
|
`<span class="cmp-skel-name"></span><span class="cmp-skel-big"></span></div>` +
|
|
|
|
|
|
`<div class="cmp-skel-bar"></div></div>`;
|
|
|
|
|
|
|
|
|
|
|
|
// Baseline strip: every place's typical basis temperature on one shared °F axis,
|
|
|
|
|
|
// with the comfort range shaded, so the places' baselines line up for direct
|
|
|
|
|
|
// comparison. Each row shows the average (dot) and the middle-80% spread (bar),
|
|
|
|
|
|
// colored by where the average sits relative to the comfort range.
|
|
|
|
|
|
function renderBaseline(scored) {
|
|
|
|
|
|
if (!scored.length) { baseline.hidden = true; baseline.innerHTML = ""; return; }
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
let min = Math.min(lo, tlo), max = Math.max(hi, thi);
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
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);
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
const bandL = posF(lo), bandR = posF(hi), tolL = posF(tlo), tolR = posF(thi);
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
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)}">` +
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
`<span class="cmp-bl-tol" style="left:${tolL}%;right:${100 - tolR}%"></span>` +
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
`<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>` +
|
|
|
|
|
|
`<span class="cmp-bl-val" style="left:${a}%">${fmtTemp(s.avgTemp)}</span>` +
|
|
|
|
|
|
`</div></div>`;
|
|
|
|
|
|
}).join("");
|
|
|
|
|
|
baseline.hidden = false;
|
|
|
|
|
|
baseline.innerHTML =
|
|
|
|
|
|
`<div class="cmp-bl-head">Typical temperature by place ` +
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
`<span class="hint">dot = average · bar = middle-80% of days · shaded = comfort, faint = tolerance</span></div>` +
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
`<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>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 02:58:56 +00:00
|
|
|
|
function renderResults() {
|
|
|
|
|
|
const loading = locations.filter((l) => l.loading).length;
|
|
|
|
|
|
const scored = locations
|
|
|
|
|
|
.map((l) => ({ loc: l, s: l.series ? computeStats(l.series) : null }))
|
|
|
|
|
|
.filter((x) => x.s);
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
// 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));
|
2026-07-11 02:58:56 +00:00
|
|
|
|
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
renderBaseline(scored);
|
|
|
|
|
|
|
2026-07-11 02:58:56 +00:00
|
|
|
|
if (!scored.length) {
|
|
|
|
|
|
head.hidden = true;
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
results.innerHTML = loading
|
|
|
|
|
|
? `<p class="cmp-loading"><span class="cmp-spinner"></span>Loading daily weather…</p>` +
|
|
|
|
|
|
Array.from({ length: Math.min(loading, 3) }, skeletonRow).join("")
|
|
|
|
|
|
: "";
|
2026-07-11 02:58:56 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Headline: the winner + the parameters it was judged under.
|
|
|
|
|
|
const win = scored[0];
|
|
|
|
|
|
const span = `${monthLabel(range.start)} – ${monthLabel(range.end)}`;
|
|
|
|
|
|
const lead = scored.length > 1
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
? `<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)}`;
|
2026-07-11 02:58:56 +00:00
|
|
|
|
head.hidden = false;
|
|
|
|
|
|
head.innerHTML = `<h2>${lead}</h2>` +
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
`<p class="meta">By ${BASIS_LABEL[basis]} · comfort ${fmtTemp(lo)}–${fmtTemp(hi)}, tolerance ${fmtTemp(tlo)}–${fmtTemp(thi)} · ${span}` +
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
`${loading ? ` · <span class="cmp-loading-inline"><span class="cmp-spinner"></span>loading ${loading} more…</span>` : ""}</p>`;
|
2026-07-11 02:58:56 +00:00
|
|
|
|
|
2026-07-12 01:49:22 +00:00
|
|
|
|
results.innerHTML = scored.map((x, i) => rankCard(x.loc, x.s, i + 1)).join("") +
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
(loading ? Array.from({ length: loading }, skeletonRow).join("") : "");
|
2026-07-11 02:58:56 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 22:33:38 +00:00
|
|
|
|
// ---- distribution (independent of the comfort filter) ----
|
|
|
|
|
|
// One calendar-style Record-Low→Record-High strip per loaded location for the
|
|
|
|
|
|
// selected metric; the share/count toggle flips every figure. The categories are
|
|
|
|
|
|
// percentiles, so the strip is unit-agnostic (no re-render on °C/°F).
|
|
|
|
|
|
function renderDist() {
|
|
|
|
|
|
const withData = locations.filter((l) => l.series && l.series.length);
|
|
|
|
|
|
if (!withData.length) { distSection.hidden = true; distBody.innerHTML = ""; return; }
|
|
|
|
|
|
distBody.innerHTML = withData.map((l) => {
|
|
|
|
|
|
const buckets = metricBuckets(l.series, distMetric);
|
|
|
|
|
|
const total = buckets.reduce((n, b) => n + b[2], 0);
|
|
|
|
|
|
const name = l.name || `${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}`;
|
|
|
|
|
|
const strip = total ? distStrip(buckets, distCount) : `<p class="muted">No data for this metric.</p>`;
|
|
|
|
|
|
return `<div class="cmp-dist-row">` +
|
|
|
|
|
|
`<div class="cmp-dist-name">${name}<span class="cmp-dist-days">${total.toLocaleString()} days</span></div>` +
|
|
|
|
|
|
strip + `</div>`;
|
|
|
|
|
|
}).join("");
|
|
|
|
|
|
distSection.hidden = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 02:58:56 +00:00
|
|
|
|
function renderAll() {
|
|
|
|
|
|
const has = locations.length > 0;
|
|
|
|
|
|
params.hidden = !has;
|
|
|
|
|
|
placeholder.hidden = has;
|
|
|
|
|
|
addBtn.querySelector("span").textContent = has ? "Add another location" : "Add location";
|
2026-07-11 03:17:17 +00:00
|
|
|
|
refreshBtn.hidden = !isDirty();
|
2026-07-11 02:58:56 +00:00
|
|
|
|
renderLocList();
|
|
|
|
|
|
renderResults();
|
2026-07-11 22:33:38 +00:00
|
|
|
|
renderDist();
|
2026-07-11 02:58:56 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- events ----
|
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
|
|
|
|
initFindButton(addBtn, "Add location",
|
|
|
|
|
|
() => (locations.length ? locations[locations.length - 1] : loadLastLocation()),
|
|
|
|
|
|
(lat, lon) => addLocation(lat, lon));
|
2026-07-11 02:58:56 +00:00
|
|
|
|
|
2026-07-11 03:17:17 +00:00
|
|
|
|
refreshBtn.addEventListener("click", refresh);
|
|
|
|
|
|
|
2026-07-11 02:58:56 +00:00
|
|
|
|
locList.addEventListener("click", (e) => {
|
|
|
|
|
|
const x = e.target.closest(".cmp-chip-x");
|
|
|
|
|
|
if (x) removeLocation(+x.dataset.i);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-11 22:33:38 +00:00
|
|
|
|
// ---- 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() {
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
tloInput.value = tlo; loInput.value = lo; hiInput.value = hi; thiInput.value = thi;
|
2026-07-12 02:23:28 +00:00
|
|
|
|
comfortValEl.textContent = `${fmtTemp(lo)}–${fmtTemp(hi)}`;
|
|
|
|
|
|
tolValEl.textContent = `${fmtTemp(tlo)}–${fmtTemp(thi)}`;
|
2026-07-11 22:33:38 +00:00
|
|
|
|
fillEl.style.left = `${pctPos(lo)}%`;
|
|
|
|
|
|
fillEl.style.right = `${100 - pctPos(hi)}%`;
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
fillTolEl.style.left = `${pctPos(tlo)}%`;
|
|
|
|
|
|
fillTolEl.style.right = `${100 - pctPos(thi)}%`;
|
2026-07-12 02:23:28 +00:00
|
|
|
|
// A temperature marker above each handle (the four band borders).
|
|
|
|
|
|
for (const [k, v] of [["tlo", tlo], ["lo", lo], ["hi", hi], ["thi", thi]]) {
|
|
|
|
|
|
tickEls[k].style.left = `${pctPos(v)}%`;
|
|
|
|
|
|
tickEls[k].textContent = fmtTemp(v);
|
|
|
|
|
|
}
|
2026-07-11 22:33:38 +00:00
|
|
|
|
}
|
|
|
|
|
|
function slicerChanged() {
|
|
|
|
|
|
lsSet(CMP.lo, String(lo)); lsSet(CMP.hi, String(hi));
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
lsSet(CMP.tlo, String(tlo)); lsSet(CMP.thi, String(thi));
|
2026-07-11 22:33:38 +00:00
|
|
|
|
syncSlicer();
|
2026-07-11 03:17:17 +00:00
|
|
|
|
writeHashState();
|
2026-07-11 22:33:38 +00:00
|
|
|
|
renderResults(); // the distribution is comfort-independent — no need to touch it
|
|
|
|
|
|
}
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
// 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(); });
|
2026-07-11 22:33:38 +00:00
|
|
|
|
|
|
|
|
|
|
// 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();
|
2026-07-11 02:58:56 +00:00
|
|
|
|
});
|
2026-07-11 22:33:38 +00:00
|
|
|
|
distSection.addEventListener("change", (e) => {
|
|
|
|
|
|
if (!e.target.closest("#cmp-dist-count")) return;
|
|
|
|
|
|
distCount = e.target.checked;
|
|
|
|
|
|
lsSet(CMP.distCount, distCount ? "1" : "0");
|
|
|
|
|
|
renderDist();
|
2026-07-11 02:58:56 +00:00
|
|
|
|
});
|
2026-07-11 21:48:21 +00:00
|
|
|
|
|
2026-07-11 22:33:38 +00:00
|
|
|
|
// Basis: instant re-rank over data already in hand — no refetch.
|
2026-07-11 02:58:56 +00:00
|
|
|
|
basisToggle.addEventListener("click", (e) => {
|
|
|
|
|
|
const btn = e.target.closest("button[data-basis]");
|
|
|
|
|
|
if (!btn) return;
|
|
|
|
|
|
basis = btn.dataset.basis;
|
|
|
|
|
|
lsSet(CMP.basis, basis);
|
|
|
|
|
|
basisToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b === btn));
|
2026-07-11 03:17:17 +00:00
|
|
|
|
writeHashState();
|
2026-07-11 02:58:56 +00:00
|
|
|
|
renderResults();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-11 03:17:17 +00:00
|
|
|
|
// Editing the dates doesn't load — it just arms Refresh (re-eval via renderAll).
|
2026-07-11 02:58:56 +00:00
|
|
|
|
startInput.max = endInput.max = `${new Date().getFullYear()}-12`;
|
2026-07-11 03:17:17 +00:00
|
|
|
|
startInput.addEventListener("change", renderAll);
|
|
|
|
|
|
endInput.addEventListener("change", renderAll);
|
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47)
The page scripts each re-declared the shared presentation layer — the tier
color table existed in four places (app.js, calendar.js, day.js, style.css)
and the scale label tables in three (plus legend.html's own drifting copy),
alongside per-page copies of the dryness ramp, formatters, ord, todayISO,
esc, the weather icons/summary, month helpers and the 2-year range chunker.
~240 duplicated lines deleted (net -236 with the new module included).
- frontend/shared.js (IIFE, extends window.Thermograph): tier colors read
from style.css's :root custom properties at load — the CSS is now the
single source of truth; the JS map exists only because inline-SVG work
(chart + PNG export) needs literal values, with hex fallbacks for a
missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord,
todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker,
and the weatherType summary (dsr-aware; the day page just omits dsr).
- nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in
the shared classic-script scope, and a leaked locHash collided with page
destructuring (caught by the browser smoke, not by node --check).
locHash is now exported and used by all 8 former hand-built hash sites.
- mappicker.js: initFindButton/setFindLabel replace the Find-button block
each page rebuilt.
- legend.html renders its scales from the shared tables, so the guide can
no longer drift from what the app shows.
Verified: node --check on all JS; 108 backend tests; headless-Chromium
smoke over all five pages against live data — no console/page errors,
legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle,
calendar grid + key + metric switch, day 7 ladders + weather icon,
compare seeded rank card.
2026-07-11 20:21:48 +00:00
|
|
|
|
clickOpensPicker(startInput, endInput); // whole-field tap opens the month picker
|
2026-07-11 02:58:56 +00:00
|
|
|
|
|
|
|
|
|
|
// ---- init ----
|
|
|
|
|
|
(function restore() {
|
2026-07-11 03:17:17 +00:00
|
|
|
|
const hash = readHashState();
|
|
|
|
|
|
if (hash) {
|
2026-07-11 22:33:38 +00:00
|
|
|
|
if (hash.lo != null) lo = hash.lo;
|
|
|
|
|
|
if (hash.hi != null) hi = hash.hi;
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
if (hash.tlo != null) tlo = hash.tlo;
|
|
|
|
|
|
if (hash.thi != null) thi = hash.thi;
|
2026-07-11 03:17:17 +00:00
|
|
|
|
if (hash.basis) basis = hash.basis;
|
|
|
|
|
|
if (hash.start && hash.end) range = { start: hash.start, end: hash.end };
|
|
|
|
|
|
}
|
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.
2026-07-12 00:25:36 +00:00
|
|
|
|
// 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);
|
2026-07-11 03:17:17 +00:00
|
|
|
|
|
2026-07-11 22:33:38 +00:00
|
|
|
|
syncSlicer();
|
2026-07-11 02:58:56 +00:00
|
|
|
|
basisToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b.dataset.basis === basis));
|
2026-07-11 22:33:38 +00:00
|
|
|
|
distToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b.dataset.metric === distMetric));
|
|
|
|
|
|
document.getElementById("cmp-dist-count").checked = distCount;
|
2026-07-11 02:58:56 +00:00
|
|
|
|
startInput.value = range.start; endInput.value = range.end;
|
|
|
|
|
|
|
2026-07-11 03:17:17 +00:00
|
|
|
|
// A shared link's locations win; else the last-used set; else seed from the spot
|
|
|
|
|
|
// the other views were on so the page isn't empty. All three auto-load on arrival
|
|
|
|
|
|
// (the tap-to-refresh rule is for later interactive edits, not the initial view).
|
|
|
|
|
|
let list = hash && hash.locs;
|
|
|
|
|
|
if (!list) { try { const saved = JSON.parse(lsGet(CMP.locs)); if (Array.isArray(saved)) list = saved; } catch (e) {} }
|
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
|
|
|
|
if (!list || !list.length) { const last = loadLastLocation(); list = last ? [{ lat: last.lat, lon: last.lon }] : []; }
|
2026-07-11 03:17:17 +00:00
|
|
|
|
|
|
|
|
|
|
locations = list
|
|
|
|
|
|
.filter((l) => l && typeof l.lat === "number" && typeof l.lon === "number")
|
|
|
|
|
|
.map((l) => ({ lat: l.lat, lon: l.lon, name: l.name || null, series: null, loading: false, error: null }));
|
|
|
|
|
|
|
|
|
|
|
|
writeHashState();
|
|
|
|
|
|
const token = ++loadToken;
|
|
|
|
|
|
loadPending(token);
|
2026-07-11 02:58:56 +00:00
|
|
|
|
renderAll();
|
|
|
|
|
|
})();
|