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,
|
2026-07-12 05:33:09 +00:00
|
|
|
|
clickOpensPicker, metricBuckets, distStrip,
|
|
|
|
|
|
seasonFilterDropdown, seasonSummaryText, syncSeasonChecks,
|
|
|
|
|
|
applySeasonMonthChange, initSeasonExpand,
|
2026-07-12 06:32:10 +00:00
|
|
|
|
allMonths, monthsToMask, maskToMonths,
|
|
|
|
|
|
namePrimary, nameParts } from "./shared.js";
|
2026-07-11 21:48:21 +00:00
|
|
|
|
import { fmtTemp, fmtDelta, onUnitChange } from "./units.js";
|
2026-07-22 18:50:00 +00:00
|
|
|
|
import { uv } from "./account.js"; // header sign-in entry + notification bell, and the API-version resolver
|
2026-07-12 05:33:09 +00:00
|
|
|
|
import { initFilterSheet } from "./filtersheet.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",
|
2026-07-12 05:33:09 +00:00
|
|
|
|
months: "thermograph:cmpMonths",
|
2026-07-11 02:58:56 +00:00
|
|
|
|
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-12 05:33:09 +00:00
|
|
|
|
// Time-of-year filter: only days whose month is checked count toward the ranking and
|
|
|
|
|
|
// the distribution. Seasons are the primary selector, months the nested suboption;
|
|
|
|
|
|
// the single source of truth is this Set of month indices (0=Jan … 11=Dec). Restored
|
|
|
|
|
|
// from a 12-char bitmask, defaulting to all 12 months (no filtering).
|
|
|
|
|
|
function loadMonths() {
|
|
|
|
|
|
const m = maskToMonths(lsGet(CMP.months));
|
|
|
|
|
|
return m && m.size ? m : allMonths();
|
|
|
|
|
|
}
|
|
|
|
|
|
let checkedMonths = loadMonths();
|
|
|
|
|
|
const monthsActive = () => checkedMonths.size < 12;
|
|
|
|
|
|
const monthOf = (r) => +r.date.slice(5, 7) - 1;
|
|
|
|
|
|
// A location's days narrowed to the checked months (pass-through when all 12 are on).
|
|
|
|
|
|
function visibleSeries(loc) {
|
|
|
|
|
|
if (!loc.series) return null;
|
|
|
|
|
|
if (!monthsActive()) return loc.series;
|
|
|
|
|
|
return loc.series.filter((r) => r.date && checkedMonths.has(monthOf(r)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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();
|
2026-07-12 06:32:10 +00:00
|
|
|
|
// January six years back → the present month (a broad seasonal sample by default).
|
|
|
|
|
|
return { start: `${now.getFullYear() - 6}-01`, end: `${now.getFullYear()}-${pad(now.getMonth() + 1)}` };
|
2026-07-11 02:58:56 +00:00
|
|
|
|
}
|
|
|
|
|
|
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);
|
2026-07-12 05:33:09 +00:00
|
|
|
|
if (monthsActive()) p.set("m", monthsToMask(checkedMonths)); // omit when all 12 (clean links)
|
2026-07-11 03:17:17 +00:00
|
|
|
|
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));
|
2026-07-12 05:33:09 +00:00
|
|
|
|
if (!["lo", "hi", "tlo", "thi", "c", "t", "b", "s", "e", "m", "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");
|
2026-07-12 05:33:09 +00:00
|
|
|
|
if (p.has("m")) { const ms = maskToMonths(p.get("m")); if (ms && ms.size) st.months = ms; }
|
2026-07-11 03:17:17 +00:00
|
|
|
|
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-12 06:32:10 +00:00
|
|
|
|
// A second Load/Refresh lives inside the params sheet's date-range block, so editing
|
|
|
|
|
|
// the range on mobile (where the panel is a bottom sheet) can reload without closing.
|
|
|
|
|
|
const refreshBtnSheet = document.getElementById("cmp-refresh-sheet");
|
|
|
|
|
|
const refreshBtns = [refreshBtn, refreshBtnSheet];
|
2026-07-11 02:58:56 +00:00
|
|
|
|
const locList = document.getElementById("cmp-loc-list");
|
|
|
|
|
|
const params = document.getElementById("cmp-params");
|
2026-07-12 07:11:21 +00:00
|
|
|
|
const comfortWrap = document.getElementById("cmp-comfort"); // now inline, above the sheet
|
2026-07-11 02:58:56 +00:00
|
|
|
|
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");
|
2026-07-12 05:33:09 +00:00
|
|
|
|
const seasonDd = document.getElementById("cmp-season-dd");
|
2026-07-11 02:58:56 +00:00
|
|
|
|
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");
|
2026-07-12 07:45:12 +00:00
|
|
|
|
const distToggleSheet = document.getElementById("cmp-dist-metric-sheet");
|
|
|
|
|
|
// The sheet copy mirrors the inline selector's 8 buttons (cloned) and stays in sync.
|
|
|
|
|
|
distToggleSheet.innerHTML = distToggle.innerHTML;
|
2026-07-11 22:33:38 +00:00
|
|
|
|
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) {
|
2026-07-22 18:50:00 +00:00
|
|
|
|
const url = uv(`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 {
|
2026-07-22 18:50:00 +00:00
|
|
|
|
const res = await fetch(uv(`place?lat=${loc.lat}&lon=${loc.lon}`), { credentials: "include" });
|
2026-07-11 14:50:48 +00:00
|
|
|
|
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; }
|
2026-07-12 06:32:10 +00:00
|
|
|
|
// The chip shows just the lead segment (compact, several per row); the full name
|
|
|
|
|
|
// stays in the title + aria-label and on the ranked card below.
|
|
|
|
|
|
return `<li class="${cls}">${lead}<span class="cmp-chip-name" title="${label}">${namePrimary(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-12 06:32:10 +00:00
|
|
|
|
// The place name as a hierarchy: the lead segment bold, the rest (city · region ·
|
|
|
|
|
|
// country) on a muted line beneath — the one spot that shows the full name.
|
|
|
|
|
|
function nameHierarchy(name) {
|
|
|
|
|
|
const { primary, rest } = nameParts(name);
|
|
|
|
|
|
return `<span class="cmp-name"><b>${primary}</b>${rest ? `<span class="cmp-name-sub">${rest}</span>` : ""}</span>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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(", ")}">` +
|
Remove em-dashes from site copy and tighten the prose (#206)
Replace em-dashes in user-facing copy across the server-rendered pages,
static frontend views, and the strings the app injects at runtime, using
colons, commas, parentheses or full stops as the context wants. Data
placeholder glyphs (a lone "—" for a missing reading) are left alone,
since a hyphen there reads as a minus sign in temperature columns.
Also tighten the high-visibility surfaces (home hero and meta, about,
privacy, city and records ledes, glossary blurbs) toward a plainer,
more direct voice while keeping every factual claim intact.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 01:48:33 +00:00
|
|
|
|
parts.map((p) => `<span class="cmp-seg ${p.cls}" style="width:${p.w}%" title="${p.label}: ${pct(p.w)}"></span>`).join("") +
|
2026-07-12 01:49:22 +00:00
|
|
|
|
`</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
|
2026-07-12 21:05:01 +00:00
|
|
|
|
// desktop each label wants its segment's midpoint (recorded in data-mid); spreadKeys
|
|
|
|
|
|
// then de-collides them after layout. On mobile it falls back to a plain wrapping row.
|
2026-07-12 02:23:28 +00:00
|
|
|
|
let acc = 0;
|
2026-07-12 01:49:22 +00:00
|
|
|
|
const key = `<div class="cmp-key">` +
|
2026-07-12 21:05:01 +00:00
|
|
|
|
parts.map((p) => {
|
2026-07-12 02:23:28 +00:00
|
|
|
|
const mid = acc + p.w / 2; acc += p.w;
|
2026-07-12 21:05:01 +00:00
|
|
|
|
return `<span class="cmp-key-item" data-mid="${mid}" style="left:${mid}%"><i class="cmp-sw ${p.cls}"></i>${p.label} <b>${pct(p.w)}</b></span>`;
|
2026-07-12 02:23:28 +00:00
|
|
|
|
}).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>` +
|
2026-07-12 06:32:10 +00:00
|
|
|
|
`<div class="cmp-card-name">${nameHierarchy(loc.name)}<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">` +
|
2026-07-12 06:32:10 +00:00
|
|
|
|
`<span class="cmp-bl-name" title="${loc.name}">${namePrimary(loc.name)}</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
|
|
|
|
`<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
|
2026-07-12 05:33:09 +00:00
|
|
|
|
.map((l) => ({ loc: l, s: l.series ? computeStats(visibleSeries(l)) : null }))
|
2026-07-11 02:58:56 +00:00
|
|
|
|
.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;
|
2026-07-12 05:33:09 +00:00
|
|
|
|
// Loaded places can score empty when the month filter excludes all their days.
|
|
|
|
|
|
const filteredOut = !loading && monthsActive() && locations.some((l) => l.series && l.series.length);
|
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-12 05:33:09 +00:00
|
|
|
|
: filteredOut
|
|
|
|
|
|
? `<p class="muted">No days fall in the selected months. Widen the time-of-year filter.</p>`
|
|
|
|
|
|
: "";
|
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)}`;
|
2026-07-12 06:32:10 +00:00
|
|
|
|
const winName = namePrimary(win.loc.name);
|
2026-07-11 02:58:56 +00:00
|
|
|
|
const lead = scored.length > 1
|
Remove em-dashes from site copy and tighten the prose (#206)
Replace em-dashes in user-facing copy across the server-rendered pages,
static frontend views, and the strings the app injects at runtime, using
colons, commas, parentheses or full stops as the context wants. Data
placeholder glyphs (a lone "—" for a missing reading) are left alone,
since a hyphen there reads as a minus sign in temperature columns.
Also tighten the high-visibility surfaces (home hero and meta, about,
privacy, city and records ledes, glossary blurbs) toward a plainer,
more direct voice while keeping every factual claim intact.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 01:48:33 +00:00
|
|
|
|
? `<b>${winName}</b> is the best match for ${fmtTemp(lo)}–${fmtTemp(hi)}: score ${Math.round(win.s.score)}/100`
|
2026-07-12 06:32:10 +00:00
|
|
|
|
: `<b>${winName}</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-12 20:56:06 +00:00
|
|
|
|
spreadKeys();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-12 21:05:01 +00:00
|
|
|
|
// The desktop key wants each label centred on its segment's midpoint, but small or
|
|
|
|
|
|
// adjacent buckets make labels overlap. After layout, de-collide each row with the
|
|
|
|
|
|
// classic 1-D label placement: keep every label at its ideal midpoint, and only where
|
|
|
|
|
|
// labels would touch, merge them into a block positioned to minimise the total shift
|
|
|
|
|
|
// from their ideals — so an isolated label never moves, and a cluster centres on its
|
|
|
|
|
|
// members rather than sliding to one edge. Finally clamp the run inside the bar.
|
2026-07-12 20:56:06 +00:00
|
|
|
|
// Mobile uses a plain wrapping row, so there we just clear any positions we applied.
|
|
|
|
|
|
function spreadKeys() {
|
|
|
|
|
|
const desktop = window.matchMedia("(min-width: 641px)").matches;
|
2026-07-12 21:05:01 +00:00
|
|
|
|
const GAP = 10;
|
2026-07-12 20:56:06 +00:00
|
|
|
|
results.querySelectorAll(".cmp-key").forEach((keyEl) => {
|
|
|
|
|
|
const items = [...keyEl.children];
|
|
|
|
|
|
if (!desktop) {
|
2026-07-12 21:05:01 +00:00
|
|
|
|
items.forEach((el) => { el.style.left = el.style.transform = ""; });
|
2026-07-12 20:56:06 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-07-12 21:05:01 +00:00
|
|
|
|
if (!items.length) return;
|
|
|
|
|
|
const W = keyEl.clientWidth;
|
|
|
|
|
|
// Each label's ideal left edge = its segment midpoint minus half its width.
|
|
|
|
|
|
const labs = items.map((el) => {
|
|
|
|
|
|
const w = el.offsetWidth;
|
|
|
|
|
|
return { el, w, ideal: (parseFloat(el.dataset.mid) / 100) * W - w / 2 };
|
2026-07-12 20:56:06 +00:00
|
|
|
|
});
|
2026-07-12 21:05:01 +00:00
|
|
|
|
// Sweep left→right, growing a block whenever the next label would overlap the
|
|
|
|
|
|
// current one. A block records Σ(idealₖ − offsetₖ) and its count so its left edge
|
|
|
|
|
|
// = that mean, the least-squares spot for its members.
|
|
|
|
|
|
const blocks = [];
|
|
|
|
|
|
for (const it of labs) {
|
|
|
|
|
|
let b = { items: [it], left: it.ideal, width: it.w, sum: it.ideal, n: 1 };
|
|
|
|
|
|
blocks.push(b);
|
|
|
|
|
|
while (blocks.length >= 2) {
|
|
|
|
|
|
const a = blocks[blocks.length - 2], cur = blocks[blocks.length - 1];
|
|
|
|
|
|
if (a.left + a.width + GAP <= cur.left) break; // no overlap → settled
|
|
|
|
|
|
const offset = a.width + GAP; // cur's items shift this far into a
|
|
|
|
|
|
a.sum += cur.sum - cur.n * offset;
|
|
|
|
|
|
a.n += cur.n;
|
|
|
|
|
|
a.width += GAP + cur.width;
|
|
|
|
|
|
a.items = a.items.concat(cur.items);
|
|
|
|
|
|
a.left = a.sum / a.n;
|
|
|
|
|
|
blocks.pop();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Clamp the run inside the bar: off the left wall forward, off the right wall back.
|
|
|
|
|
|
let minLeft = 0;
|
|
|
|
|
|
for (const b of blocks) { if (b.left < minLeft) b.left = minLeft; minLeft = b.left + b.width + GAP; }
|
|
|
|
|
|
let maxRight = W;
|
|
|
|
|
|
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
|
|
|
|
const b = blocks[i];
|
|
|
|
|
|
if (b.left + b.width > maxRight) b.left = maxRight - b.width;
|
|
|
|
|
|
maxRight = b.left - GAP;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Lay each block's labels out left→right from the block's left edge.
|
|
|
|
|
|
for (const b of blocks) {
|
|
|
|
|
|
let x = b.left;
|
|
|
|
|
|
for (const it of b.items) {
|
|
|
|
|
|
it.el.style.left = `${x + it.w / 2}px`;
|
|
|
|
|
|
it.el.style.transform = "translateX(-50%)";
|
|
|
|
|
|
x += it.w + GAP;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-12 20:56:06 +00:00
|
|
|
|
});
|
2026-07-11 02:58:56 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 22:33:38 +00:00
|
|
|
|
// ---- distribution (independent of the comfort filter) ----
|
Category distribution: connected bar chart with per-tier avg + range (#76)
Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.
- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
and collects each category's actual values, not just a count, so distStrip
can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
°C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
toggle: wired in compare.js's onUnitChange and a new onUnitChange in
calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
from thin lines to connected bars with an in-bar range pill.
2026-07-12 04:31:29 +00:00
|
|
|
|
// One calendar-style Record-Low→Record-High connected bar chart per loaded location
|
|
|
|
|
|
// for the selected metric; the share/count toggle flips every figure. Category names
|
|
|
|
|
|
// are percentile tiers, but each bar now shows the tier's average value + range, so it
|
|
|
|
|
|
// re-renders on the °C/°F toggle (wired below).
|
2026-07-11 22:33:38 +00:00
|
|
|
|
function renderDist() {
|
2026-07-12 05:33:09 +00:00
|
|
|
|
const withData = locations
|
|
|
|
|
|
.map((l) => ({ l, vis: visibleSeries(l) }))
|
|
|
|
|
|
.filter((x) => x.vis && x.vis.length);
|
2026-07-11 22:33:38 +00:00
|
|
|
|
if (!withData.length) { distSection.hidden = true; distBody.innerHTML = ""; return; }
|
2026-07-12 05:33:09 +00:00
|
|
|
|
distBody.innerHTML = withData.map(({ l, vis }) => {
|
|
|
|
|
|
const buckets = metricBuckets(vis, distMetric);
|
Category distribution: connected bar chart with per-tier avg + range (#76)
Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.
- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
and collects each category's actual values, not just a count, so distStrip
can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
°C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
toggle: wired in compare.js's onUnitChange and a new onUnitChange in
calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
from thin lines to connected bars with an in-bar range pill.
2026-07-12 04:31:29 +00:00
|
|
|
|
const total = buckets.reduce((n, b) => n + b.n, 0);
|
2026-07-12 06:32:10 +00:00
|
|
|
|
const name = namePrimary(l.name) || `${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}`;
|
2026-07-11 22:33:38 +00:00
|
|
|
|
const strip = total ? distStrip(buckets, distCount) : `<p class="muted">No data for this metric.</p>`;
|
|
|
|
|
|
return `<div class="cmp-dist-row">` +
|
2026-07-12 06:32:10 +00:00
|
|
|
|
`<div class="cmp-dist-name" title="${l.name || ""}">${name}<span class="cmp-dist-days">${total.toLocaleString()} days</span></div>` +
|
2026-07-11 22:33:38 +00:00
|
|
|
|
strip + `</div>`;
|
|
|
|
|
|
}).join("");
|
|
|
|
|
|
distSection.hidden = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 02:58:56 +00:00
|
|
|
|
function renderAll() {
|
|
|
|
|
|
const has = locations.length > 0;
|
|
|
|
|
|
params.hidden = !has;
|
2026-07-12 07:11:21 +00:00
|
|
|
|
comfortWrap.hidden = !has;
|
2026-07-11 02:58:56 +00:00
|
|
|
|
placeholder.hidden = has;
|
|
|
|
|
|
addBtn.querySelector("span").textContent = has ? "Add another location" : "Add location";
|
2026-07-12 06:32:10 +00:00
|
|
|
|
for (const b of refreshBtns) b.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-12 06:32:10 +00:00
|
|
|
|
for (const b of refreshBtns) b.addEventListener("click", refresh);
|
2026-07-11 03:17:17 +00:00
|
|
|
|
|
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
|
Category distribution: connected bar chart with per-tier avg + range (#76)
Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.
- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
and collects each category's actual values, not just a count, so distStrip
can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
°C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
toggle: wired in compare.js's onUnitChange and a new onUnitChange in
calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
from thin lines to connected bars with an in-bar range pill.
2026-07-12 04:31:29 +00:00
|
|
|
|
// API's unit) — so re-render the range label, the cards, and the distribution chart
|
|
|
|
|
|
// (its per-category averages + ranges are in the active unit).
|
|
|
|
|
|
onUnitChange(() => { syncSlicer(); renderResults(); renderDist(); });
|
2026-07-11 22:33:38 +00:00
|
|
|
|
|
2026-07-12 20:56:06 +00:00
|
|
|
|
// Label widths (and the desktop/mobile threshold) move with the viewport, so
|
|
|
|
|
|
// re-spread the rank-card keys after the layout settles.
|
|
|
|
|
|
let spreadRaf = 0;
|
|
|
|
|
|
window.addEventListener("resize", () => {
|
|
|
|
|
|
cancelAnimationFrame(spreadRaf);
|
|
|
|
|
|
spreadRaf = requestAnimationFrame(spreadKeys);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-12 07:45:12 +00:00
|
|
|
|
// Distribution metric: an inline selector (by the chart) plus a synced copy in the
|
|
|
|
|
|
// filter sheet. Either one drives the shared distMetric; both reflect the active state.
|
|
|
|
|
|
const syncDistButtons = () =>
|
|
|
|
|
|
document.querySelectorAll("#cmp-dist-metric button, #cmp-dist-metric-sheet button")
|
|
|
|
|
|
.forEach((b) => b.classList.toggle("active", b.dataset.metric === distMetric));
|
|
|
|
|
|
function setDistMetric(m) {
|
|
|
|
|
|
distMetric = m;
|
2026-07-11 22:33:38 +00:00
|
|
|
|
lsSet(CMP.distMetric, distMetric);
|
2026-07-12 07:45:12 +00:00
|
|
|
|
syncDistButtons();
|
2026-07-11 22:33:38 +00:00
|
|
|
|
renderDist();
|
2026-07-12 07:45:12 +00:00
|
|
|
|
}
|
|
|
|
|
|
for (const t of [distToggle, distToggleSheet]) {
|
|
|
|
|
|
t.addEventListener("click", (e) => {
|
|
|
|
|
|
const btn = e.target.closest("button[data-metric]");
|
|
|
|
|
|
if (btn) setDistMetric(btn.dataset.metric);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
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-12 05:33:09 +00:00
|
|
|
|
// ---- time-of-year (season / month) filter ----
|
|
|
|
|
|
// Seasons are primary, months the nested suboption; both drive the same Set. A toggle
|
|
|
|
|
|
// re-ranks and re-buckets over data already in hand — no refetch. The dropdown isn't
|
|
|
|
|
|
// rebuilt on change (that would collapse it): the native checkboxes hold their own
|
|
|
|
|
|
// state, and we only re-derive the season indeterminate state + the summary text.
|
|
|
|
|
|
function buildSeasonFilter() {
|
|
|
|
|
|
seasonDd.innerHTML = seasonFilterDropdown(checkedMonths);
|
|
|
|
|
|
syncSeasonChecks(seasonDd, checkedMonths);
|
|
|
|
|
|
}
|
|
|
|
|
|
initSeasonExpand(seasonDd);
|
|
|
|
|
|
seasonDd.addEventListener("change", (e) => {
|
|
|
|
|
|
const cb = e.target.closest("input[type=checkbox]");
|
|
|
|
|
|
if (!cb || !applySeasonMonthChange(cb, checkedMonths)) return;
|
|
|
|
|
|
syncSeasonChecks(seasonDd, checkedMonths);
|
|
|
|
|
|
seasonDd.querySelector(".cal-dd-sum").textContent = seasonSummaryText(checkedMonths);
|
|
|
|
|
|
lsSet(CMP.months, monthsToMask(checkedMonths));
|
|
|
|
|
|
writeHashState();
|
|
|
|
|
|
renderResults();
|
|
|
|
|
|
renderDist();
|
|
|
|
|
|
if (fabSheet) fabSheet.setActive(monthsActive());
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Mobile: a floating pill slides the whole params panel up as a bottom sheet.
|
|
|
|
|
|
let fabSheet = null;
|
|
|
|
|
|
|
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 };
|
2026-07-12 05:33:09 +00:00
|
|
|
|
if (hash.months) checkedMonths = hash.months;
|
2026-07-11 03:17:17 +00:00
|
|
|
|
}
|
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-12 07:45:12 +00:00
|
|
|
|
syncDistButtons(); // active state on both the inline + sheet distribution selectors
|
2026-07-11 22:33:38 +00:00
|
|
|
|
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-12 05:33:09 +00:00
|
|
|
|
buildSeasonFilter();
|
2026-07-12 07:45:12 +00:00
|
|
|
|
fabSheet = initFilterSheet({ panel: params, label: "Filters" });
|
2026-07-12 05:33:09 +00:00
|
|
|
|
fabSheet.setActive(monthsActive());
|
2026-07-11 02:58:56 +00:00
|
|
|
|
|
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();
|
|
|
|
|
|
})();
|