Compare: comfort range slicer + per-location climate distribution (#62)
* Compare: comfort range slicer + per-location climate distribution Replace the comfort-point + band sliders with a single dual-handle range slicer: a day hits comfort when its judged temperature lands in [lo, hi]; below lo is colder, above hi is warmer. State is the range in °F, so old c/t links and stored prefs migrate to a range. Ranking, average miss, and the typical-miss figure are all computed against the range. Add a climate-distribution section below the ranked cards, independent of the comfort filter: its own metric selector (High/Low/Feels/Humid/Wind/ Gust/Precip/Dry streak) plus a share/count toggle render one Record-Low→ Record-High distribution strip per location, mirroring the calendar. Extract the calendar's category bucketing and distribution strip into shared metricBuckets/distStrip so both pages share one implementation; calendar output is unchanged. * Carry the calendar's wet/dry scale-group logic into the shared distribution helpers * Carry the precip strip's per-tier label hook into the shared distribution helper
This commit is contained in:
parent
c65ee15c0d
commit
2662fa38e5
5 changed files with 298 additions and 172 deletions
|
|
@ -7,7 +7,7 @@ import { TTL, chunkedFetch, prefetchViews } from "./cache.js";
|
|||
import { initFindButton, setFindLabel } from "./mappicker.js";
|
||||
import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, ord,
|
||||
fmtPrecip, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd,
|
||||
CHUNK_MONTHS, buildChunks, clickOpensPicker,
|
||||
CHUNK_MONTHS, buildChunks, clickOpensPicker, metricBuckets, distStrip,
|
||||
weatherType as wxType } from "./shared.js";
|
||||
|
||||
// The diverging-temperature-scale metrics (colored exactly like High/Low), with a
|
||||
|
|
@ -523,99 +523,27 @@ function renderTotals(visible) {
|
|||
const wActive = weatherFilterActive();
|
||||
const days = wActive ? visible.filter((v) => v.pass).map((v) => v.rec) : visible.map((v) => v.rec);
|
||||
|
||||
// Buckets low→high for the metric: [label, color, count-fn].
|
||||
let buckets, title;
|
||||
if (metric === "dsr") {
|
||||
const B = [
|
||||
["Rain day", drynessColor(0), (d) => d === 0],
|
||||
["1–3 dry", drynessColor(2), (d) => d >= 1 && d <= 3],
|
||||
["4–6 dry", drynessColor(5), (d) => d >= 4 && d <= 6],
|
||||
["7–13 dry", drynessColor(10), (d) => d >= 7 && d <= 13],
|
||||
["14+ dry", drynessColor(14), (d) => d >= 14],
|
||||
];
|
||||
const vals = days.map((r) => r.dsr).filter((d) => d != null);
|
||||
// Group (index 3) sets which categories share a height scale: the lone "Rain day"
|
||||
// (wet) is scaled apart from the dry-streak buckets so it can't flatten them.
|
||||
buckets = B.map(([label, color, fn], i) =>
|
||||
[label, color, vals.filter(fn).length, i === 0 ? "wet" : "dry"]);
|
||||
title = "dry streak";
|
||||
} else if (metric === "precip") {
|
||||
const classes = days.map((r) => r.precip && r.precip.c).filter(Boolean);
|
||||
// Dry days scale independently of the rain-intensity buckets (wet) so a big dry
|
||||
// count doesn't crush the rain bars — and vice versa.
|
||||
// Index 4 carries the rain-tier key (e.g. "wet-5") so a single crowded label —
|
||||
// "Moderate", wedged between the two-line "Light–Mod"/"Mod–Heavy" — can be nudged
|
||||
// in CSS without brittle text matching.
|
||||
buckets = [["Dry", drynessColor(7), classes.filter((c) => c === "dry").length, "dry"]]
|
||||
.concat(SCALE_RAIN.map((t) =>
|
||||
[t.label, PRECIP_COLORS[t.c], classes.filter((c) => c === t.c).length, "wet", t.c]));
|
||||
title = "rain intensity";
|
||||
} else {
|
||||
const classes = days
|
||||
.map((r) => { const g = r[metric]; return g && g.c; })
|
||||
.filter(Boolean);
|
||||
// Temperature tiers all share one scale group, so they scale together as before.
|
||||
buckets = SCALE_TEMP.map((t) =>
|
||||
[t.label, TIER_COLORS[t.c], classes.filter((c) => c === t.c).length, "temp"]);
|
||||
title = (TEMP_METRIC_INFO[metric] || {}).label || "value";
|
||||
}
|
||||
|
||||
const title = metric === "dsr" ? "dry streak"
|
||||
: metric === "precip" ? "rain intensity"
|
||||
: (TEMP_METRIC_INFO[metric] || {}).label || "value";
|
||||
// Category distribution (low→high) for the metric; the bucketing and the strip
|
||||
// visual — the wet/dry scale-group split and the per-tier label hook — are shared
|
||||
// with the compare page. Category names live in the color key below the grid too.
|
||||
const buckets = metricBuckets(days, metric);
|
||||
const total = buckets.reduce((n, b) => n + b[2], 0);
|
||||
// Per-group subtotals + membership, so a bar's share can be scoped to its own group.
|
||||
const groupTotal = {}, groupCount = {};
|
||||
for (const b of buckets) {
|
||||
groupTotal[b[3]] = (groupTotal[b[3]] || 0) + b[2];
|
||||
groupCount[b[3]] = (groupCount[b[3]] || 0) + 1;
|
||||
}
|
||||
// A multi-bar group (rain intensities on precip, streak lengths on dry streak) shows
|
||||
// each bar's share *within that group* — matching the per-group bar heights. A lone
|
||||
// opposing column (Dry on precip, Rain day on dry streak) stays a share of ALL shown
|
||||
// days, since a group-relative percentage there would trivially read 100%.
|
||||
const pctText = (n, group) => {
|
||||
const d = groupCount[group] > 1 ? (groupTotal[group] || 1) : total;
|
||||
const p = 100 * n / d, r = Math.round(p);
|
||||
return n > 0 && r === 0 ? `${p.toFixed(1)}%` : `${r}%`;
|
||||
};
|
||||
// The strip prints either each category's share (default) or its raw day count,
|
||||
// toggled by the "Show count" checkbox rendered in the header (state: showCount).
|
||||
const valText = (n, group) => !n ? "0" : (showCount ? n.toLocaleString() : pctText(n, group));
|
||||
|
||||
const head = wActive
|
||||
? `${days.length.toLocaleString()} of ${visible.length.toLocaleString()} shown days match the weather filter — by ${title}:`
|
||||
: `${visible.length.toLocaleString()} days shown — by ${title}:`;
|
||||
// The strip prints each category's share by default, or the raw day count when the
|
||||
// "Show count" checkbox (state: showCount) is ticked.
|
||||
const countToggle =
|
||||
`<label class="ct-count-toggle"><input type="checkbox" id="ct-count-cb"` +
|
||||
`${showCount ? " checked" : ""} /> Show count</label>`;
|
||||
const header = `<div class="ct-head"><div class="section-title">${head}</div>${countToggle}</div>`;
|
||||
if (!total) {
|
||||
totEl.innerHTML = `${header}<p class="muted">No matching days.</p>`;
|
||||
totEl.hidden = false;
|
||||
return;
|
||||
}
|
||||
// A compact distribution strip: one thin status-colored line per category, ordered
|
||||
// low→high like the color scale, with each value printed above it in that category's
|
||||
// own color. Every category — including the neutral "Normal" center of the diverging
|
||||
// temperature scale — draws a line, so the strip reads as a full distribution.
|
||||
// Category names live in the color key below the grid too, so this stays terse.
|
||||
// Line heights scale to the tallest category *within each scale group* (index 3):
|
||||
// for precip/dry-streak, wet and dry days scale independently, so a lopsided count
|
||||
// on one side doesn't shrink the opposing side's bars into invisibility. Temperature
|
||||
// tiers all sit in one group, so they still scale against each other as before.
|
||||
const maxByGroup = {};
|
||||
for (const b of buckets) maxByGroup[b[3]] = Math.max(maxByGroup[b[3]] || 1, b[2]);
|
||||
const LINE_MAX = 30; // px — height of the tallest category in its group
|
||||
const cols = buckets.map(([label, color, n, group, cls]) => {
|
||||
const h = !n ? 0 : Math.max(2, Math.round(LINE_MAX * n / maxByGroup[group]));
|
||||
const line = h ? `<span class="ct-line" style="height:${h}px"></span>` : "";
|
||||
// Caption stacks the category name over its value; the name reserves two lines
|
||||
// so the value figures stay aligned across columns whether a name wraps or not.
|
||||
return `<div class="ct-col${cls ? ` ct-${cls}` : ""}" style="--c:${color}" ` +
|
||||
`title="${label} · ${pctText(n, group)} (${n})">` +
|
||||
`<span class="ct-cap"><span class="ct-label">${label}</span>` +
|
||||
`<span class="ct-num${n ? "" : " ct-num-zero"}">${valText(n, group)}</span></span>` +
|
||||
`${line}</div>`;
|
||||
}).join("");
|
||||
totEl.innerHTML = `${header}
|
||||
<div class="ct-strip" style="--line-max:${LINE_MAX}px">${cols}</div>`;
|
||||
totEl.innerHTML = total
|
||||
? `${header}\n ${distStrip(buckets, showCount)}`
|
||||
: `${header}<p class="muted">No matching days.</p>`;
|
||||
totEl.hidden = false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -59,14 +59,13 @@
|
|||
the date range or the location set trigger a data load. -->
|
||||
<div id="cmp-params" class="cmp-params" hidden>
|
||||
<div class="cmp-param cmp-comfort">
|
||||
<label for="cmp-comfort">Comfort temperature <b id="cmp-comfort-val"></b></label>
|
||||
<input id="cmp-comfort" type="range" min="30" max="100" step="1" />
|
||||
</div>
|
||||
|
||||
<div class="cmp-param cmp-band">
|
||||
<label for="cmp-tol">Comfort band <b id="cmp-tol-val"></b></label>
|
||||
<input id="cmp-tol" type="range" min="0" max="15" step="1" />
|
||||
<span class="hint">A day "hits comfort" when it lands within this many degrees.</span>
|
||||
<label>Comfort range <b id="cmp-range-val"></b></label>
|
||||
<div class="dual-range" id="cmp-slicer">
|
||||
<div class="dual-track"><span class="dual-fill" id="cmp-fill"></span></div>
|
||||
<input id="cmp-lo" type="range" min="30" max="100" step="1" aria-label="Comfort range lower bound" />
|
||||
<input id="cmp-hi" type="range" min="30" max="100" step="1" aria-label="Comfort range upper bound" />
|
||||
</div>
|
||||
<span class="hint">Days landing in this range hit comfort; below it is colder, above it is warmer.</span>
|
||||
</div>
|
||||
|
||||
<div class="cmp-param cmp-basis-block">
|
||||
|
|
@ -99,6 +98,29 @@
|
|||
</div>
|
||||
|
||||
<div id="cmp-results" class="cmp-results"></div>
|
||||
|
||||
<!-- Climate distribution, independent of the comfort filter: how each place's
|
||||
days spread across a metric's Record-Low→Record-High categories, mirroring
|
||||
the calendar's totals strip. Its own metric + share/count toggles. -->
|
||||
<section id="cmp-dist" class="cmp-dist" hidden>
|
||||
<div class="ct-head">
|
||||
<div class="cmp-dist-controls">
|
||||
<span class="cal-filter-label">Distribution by</span>
|
||||
<div class="metric-toggle" id="cmp-dist-metric" role="group" aria-label="Distribution metric">
|
||||
<button type="button" data-metric="tmax" class="active">High</button>
|
||||
<button type="button" data-metric="tmin">Low</button>
|
||||
<button type="button" data-metric="feels">Feels</button>
|
||||
<button type="button" data-metric="humid">Humid</button>
|
||||
<button type="button" data-metric="wind">Wind</button>
|
||||
<button type="button" data-metric="gust">Gust</button>
|
||||
<button type="button" data-metric="precip">Precip</button>
|
||||
<button type="button" data-metric="dsr">Dry streak</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="ct-count-toggle"><input type="checkbox" id="cmp-dist-count" /> Show count</label>
|
||||
</div>
|
||||
<div id="cmp-dist-body" class="cmp-dist-body"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
|
|
|
|||
|
|
@ -1,29 +1,35 @@
|
|||
// Compare view: line up several places over one date range and see which best
|
||||
// matches a comfort temperature. 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 measure it against
|
||||
// the comfort target. A day "hits comfort" when it lands within a tolerance band;
|
||||
// otherwise it's colder or warmer, and we track by how much.
|
||||
// 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.
|
||||
//
|
||||
// The whole comparison — locations, comfort target, band, judged temperature and
|
||||
// date range — lives in the URL hash, so a link reproduces exactly what you see.
|
||||
// Comfort / band / 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.
|
||||
// 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.
|
||||
|
||||
import { loadLastLocation, saveLastLocation } from "./nav.js";
|
||||
import { getJSON, TTL } from "./cache.js";
|
||||
import { initFindButton } from "./mappicker.js";
|
||||
import { MONTHS, pad, isoOfDate, monthStart, monthEnd, buildChunks,
|
||||
clickOpensPicker } from "./shared.js";
|
||||
import { MONTHS, pad, monthStart, monthEnd, buildChunks,
|
||||
clickOpensPicker, metricBuckets, distStrip } from "./shared.js";
|
||||
import { fmtTemp, fmtDelta, onUnitChange } from "./units.js";
|
||||
|
||||
const CMP = {
|
||||
comfort: "thermograph:cmpComfort",
|
||||
tol: "thermograph:cmpTol",
|
||||
lo: "thermograph:cmpLo",
|
||||
hi: "thermograph:cmpHi",
|
||||
basis: "thermograph:cmpBasis",
|
||||
range: "thermograph:cmpRange",
|
||||
locs: "thermograph:cmpLocs",
|
||||
distMetric: "thermograph:cmpDistMetric",
|
||||
distCount: "thermograph:cmpDistCount",
|
||||
};
|
||||
|
||||
const lsGet = (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } };
|
||||
|
|
@ -31,13 +37,30 @@ const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch (e) {} };
|
|||
|
||||
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
||||
const BASES = ["tmax", "tmin", "mean", "feels"];
|
||||
let comfort = clamp(+lsGet(CMP.comfort) || 68, 30, 100);
|
||||
let tol = clamp(lsGet(CMP.tol) == null ? 5 : +lsGet(CMP.tol), 0, 15);
|
||||
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();
|
||||
let basis = BASES.includes(lsGet(CMP.basis)) ? lsGet(CMP.basis) : "tmax";
|
||||
|
||||
// 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";
|
||||
|
||||
// Locations being compared: {lat, lon, name, series|null, loading, error}.
|
||||
// `series` is a compact per-day array of {hi, lo, feels} (°F, any may be null).
|
||||
// A location with no series and not loading is "pending" — it needs a Refresh tap.
|
||||
// `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.
|
||||
let locations = [];
|
||||
// Bumped on every (re)load so a superseded in-flight fetch drops its result.
|
||||
let loadToken = 0;
|
||||
|
|
@ -61,23 +84,30 @@ let range = loadRange(); // the APPLIED range (what loaded data reflects), "YY
|
|||
const monthLabel = (ym) => `${MONTHS[+ym.slice(5, 7) - 1]} ${ym.slice(0, 4)}`;
|
||||
|
||||
// ---- shareable URL state ----
|
||||
// The hash carries the full comparison: c=comfort, t=band, b=basis, s/e=range,
|
||||
// The hash carries the comparison: lo/hi=comfort range, b=basis, s/e=range,
|
||||
// loc=lat,lon;lat,lon. Written on every state change; read once on load (a link
|
||||
// wins over localStorage). A plain lat/lon hash from cross-view nav is ignored
|
||||
// here and handled by the seed path instead.
|
||||
// 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.
|
||||
function writeHashState() {
|
||||
const p = new URLSearchParams();
|
||||
p.set("c", comfort); p.set("t", tol); p.set("b", basis);
|
||||
p.set("lo", lo); p.set("hi", hi); p.set("b", basis);
|
||||
p.set("s", range.start); p.set("e", range.end);
|
||||
if (locations.length) p.set("loc", locations.map((l) => `${l.lat.toFixed(4)},${l.lon.toFixed(4)}`).join(";"));
|
||||
history.replaceState(null, "", "#" + p.toString());
|
||||
}
|
||||
function readHashState() {
|
||||
const p = new URLSearchParams(location.hash.slice(1));
|
||||
if (!["c", "t", "b", "s", "e", "loc"].some((k) => p.has(k))) return null; // not a compare link
|
||||
if (!["lo", "hi", "c", "t", "b", "s", "e", "loc"].some((k) => p.has(k))) return null; // not a compare link
|
||||
const st = {};
|
||||
if (p.has("c")) st.comfort = clamp(+p.get("c") || 68, 30, 100);
|
||||
if (p.has("t") && p.get("t") !== "") st.tol = clamp(+p.get("t"), 0, 15);
|
||||
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);
|
||||
}
|
||||
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");
|
||||
|
|
@ -98,17 +128,21 @@ const params = document.getElementById("cmp-params");
|
|||
const placeholder = document.getElementById("cmp-placeholder");
|
||||
const head = document.getElementById("cmp-head");
|
||||
const results = document.getElementById("cmp-results");
|
||||
const comfortInput = document.getElementById("cmp-comfort");
|
||||
const comfortVal = document.getElementById("cmp-comfort-val");
|
||||
const tolInput = document.getElementById("cmp-tol");
|
||||
const tolVal = document.getElementById("cmp-tol-val");
|
||||
const loInput = document.getElementById("cmp-lo");
|
||||
const hiInput = document.getElementById("cmp-hi");
|
||||
const rangeVal = document.getElementById("cmp-range-val");
|
||||
const fillEl = document.getElementById("cmp-fill");
|
||||
const basisToggle = document.getElementById("cmp-basis");
|
||||
const startInput = document.getElementById("cmp-start");
|
||||
const endInput = document.getElementById("cmp-end");
|
||||
const distSection = document.getElementById("cmp-dist");
|
||||
const distToggle = document.getElementById("cmp-dist-metric");
|
||||
const distBody = document.getElementById("cmp-dist-body");
|
||||
|
||||
|
||||
// ---- data ----
|
||||
// Fetch one location's range (chunked) and reduce it to the compact per-day series.
|
||||
// Fetch one location's range (chunked), keeping the raw daily records — the comfort
|
||||
// ranking reads their values, the distribution their category classes.
|
||||
async function fetchSeries(loc, token) {
|
||||
const chunks = buildChunks(monthStart(range.start), monthEnd(range.end));
|
||||
const days = [];
|
||||
|
|
@ -120,14 +154,7 @@ async function fetchSeries(loc, token) {
|
|||
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);
|
||||
}
|
||||
return {
|
||||
name: place,
|
||||
series: days.map((r) => ({
|
||||
hi: r.tmax ? r.tmax.v : null,
|
||||
lo: r.tmin ? r.tmin.v : null,
|
||||
feels: r.feels ? r.feels.v : null,
|
||||
})),
|
||||
};
|
||||
return { name: place, series: days };
|
||||
}
|
||||
|
||||
function persistLocations() {
|
||||
|
|
@ -213,23 +240,24 @@ function refresh() {
|
|||
}
|
||||
|
||||
// ---- stats ----
|
||||
function basisTemp(pt) {
|
||||
if (basis === "tmax") return pt.hi;
|
||||
if (basis === "tmin") return pt.lo;
|
||||
if (basis === "feels") return pt.feels;
|
||||
return pt.hi != null && pt.lo != null ? (pt.hi + pt.lo) / 2 : null; // mean
|
||||
// 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;
|
||||
if (basis === "feels") return r.feels ? r.feels.v : null;
|
||||
return hi != null && lo != null ? (hi + lo) / 2 : null; // mean
|
||||
}
|
||||
|
||||
function computeStats(series) {
|
||||
let n = 0, below = 0, above = 0, comf = 0, sumBelow = 0, sumAbove = 0, sumAbs = 0;
|
||||
for (const pt of series) {
|
||||
const t = basisTemp(pt);
|
||||
let n = 0, below = 0, above = 0, comf = 0, sumBelow = 0, sumAbove = 0, sumMiss = 0;
|
||||
for (const r of series) {
|
||||
const t = basisTemp(r);
|
||||
if (t == null || isNaN(t)) continue;
|
||||
n++;
|
||||
const d = t - comfort;
|
||||
sumAbs += Math.abs(d);
|
||||
if (d < -tol) { below++; sumBelow += -d; }
|
||||
else if (d > tol) { above++; sumAbove += d; }
|
||||
// Distance outside the comfort range (0 when inside) drives the "typical miss".
|
||||
if (t < lo) { below++; const m = lo - t; sumBelow += m; sumMiss += m; }
|
||||
else if (t > hi) { above++; const m = t - hi; sumAbove += m; sumMiss += m; }
|
||||
else comf++;
|
||||
}
|
||||
if (!n) return null;
|
||||
|
|
@ -238,7 +266,7 @@ function computeStats(series) {
|
|||
comfortPct: 100 * comf / n, belowPct: 100 * below / n, abovePct: 100 * above / n,
|
||||
avgBelow: below ? sumBelow / below : 0,
|
||||
avgAbove: above ? sumAbove / above : 0,
|
||||
mad: sumAbs / n,
|
||||
mad: sumMiss / n,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -271,7 +299,7 @@ function rankCard(loc, s, rank) {
|
|||
const stats =
|
||||
`<div class="cmp-stats">` +
|
||||
`<div class="cmp-stat cmp-s-below"><span class="cmp-k">Colder</span><span class="cmp-v">${pct(s.belowPct)}</span><span class="cmp-d">${s.below ? `avg ${deg(s.avgBelow)} below` : "—"}</span></div>` +
|
||||
`<div class="cmp-stat cmp-s-comfort"><span class="cmp-k">In comfort</span><span class="cmp-v">${pct(s.comfortPct)}</span><span class="cmp-d">±${fmtDelta(tol)} of ${fmtTemp(comfort)}</span></div>` +
|
||||
`<div class="cmp-stat cmp-s-comfort"><span class="cmp-k">In comfort</span><span class="cmp-v">${pct(s.comfortPct)}</span><span class="cmp-d">${fmtTemp(lo)}–${fmtTemp(hi)}</span></div>` +
|
||||
`<div class="cmp-stat cmp-s-above"><span class="cmp-k">Warmer</span><span class="cmp-v">${pct(s.abovePct)}</span><span class="cmp-d">${s.above ? `avg ${deg(s.avgAbove)} above` : "—"}</span></div>` +
|
||||
`</div>`;
|
||||
return `<div class="cmp-card">` +
|
||||
|
|
@ -303,8 +331,8 @@ function renderResults() {
|
|||
const win = scored[0];
|
||||
const span = `${monthLabel(range.start)} – ${monthLabel(range.end)}`;
|
||||
const lead = scored.length > 1
|
||||
? `<b>${win.loc.name}</b> best matches ${fmtTemp(comfort)} — ${pct(win.s.comfortPct)} of days within ±${fmtDelta(tol)}`
|
||||
: `<b>${win.loc.name}</b>: ${pct(win.s.comfortPct)} of days within ±${fmtDelta(tol)} of ${fmtTemp(comfort)}`;
|
||||
? `<b>${win.loc.name}</b> best fits ${fmtTemp(lo)}–${fmtTemp(hi)} — ${pct(win.s.comfortPct)} of days in range`
|
||||
: `<b>${win.loc.name}</b>: ${pct(win.s.comfortPct)} of days land in ${fmtTemp(lo)}–${fmtTemp(hi)}`;
|
||||
head.hidden = false;
|
||||
head.innerHTML = `<h2>${lead}</h2>` +
|
||||
`<p class="meta">By ${BASIS_LABEL[basis]} · ${span}${loading ? ` · loading ${loading} more…` : ""}</p>`;
|
||||
|
|
@ -312,6 +340,25 @@ function renderResults() {
|
|||
results.innerHTML = scored.map((x, i) => rankCard(x.loc, x.s, i + 1)).join("");
|
||||
}
|
||||
|
||||
// ---- distribution (independent of the comfort filter) ----
|
||||
// One calendar-style Record-Low→Record-High strip per loaded location for the
|
||||
// selected metric; the share/count toggle flips every figure. The categories are
|
||||
// percentiles, so the strip is unit-agnostic (no re-render on °C/°F).
|
||||
function renderDist() {
|
||||
const withData = locations.filter((l) => l.series && l.series.length);
|
||||
if (!withData.length) { distSection.hidden = true; distBody.innerHTML = ""; return; }
|
||||
distBody.innerHTML = withData.map((l) => {
|
||||
const buckets = metricBuckets(l.series, distMetric);
|
||||
const total = buckets.reduce((n, b) => n + b[2], 0);
|
||||
const name = l.name || `${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}`;
|
||||
const strip = total ? distStrip(buckets, distCount) : `<p class="muted">No data for this metric.</p>`;
|
||||
return `<div class="cmp-dist-row">` +
|
||||
`<div class="cmp-dist-name">${name}<span class="cmp-dist-days">${total.toLocaleString()} days</span></div>` +
|
||||
strip + `</div>`;
|
||||
}).join("");
|
||||
distSection.hidden = false;
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
const has = locations.length > 0;
|
||||
params.hidden = !has;
|
||||
|
|
@ -320,6 +367,7 @@ function renderAll() {
|
|||
refreshBtn.hidden = !isDirty();
|
||||
renderLocList();
|
||||
renderResults();
|
||||
renderDist();
|
||||
}
|
||||
|
||||
// ---- events ----
|
||||
|
|
@ -334,30 +382,47 @@ locList.addEventListener("click", (e) => {
|
|||
if (x) removeLocation(+x.dataset.i);
|
||||
});
|
||||
|
||||
// Comfort / band / basis: instant re-rank over data already in hand — no refetch.
|
||||
comfortInput.addEventListener("input", () => {
|
||||
comfort = +comfortInput.value;
|
||||
comfortVal.textContent = fmtTemp(comfort);
|
||||
lsSet(CMP.comfort, String(comfort));
|
||||
// ---- 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() {
|
||||
loInput.value = lo; hiInput.value = hi;
|
||||
rangeVal.textContent = `${fmtTemp(lo)} – ${fmtTemp(hi)}`;
|
||||
fillEl.style.left = `${pctPos(lo)}%`;
|
||||
fillEl.style.right = `${100 - pctPos(hi)}%`;
|
||||
}
|
||||
function slicerChanged() {
|
||||
lsSet(CMP.lo, String(lo)); lsSet(CMP.hi, String(hi));
|
||||
syncSlicer();
|
||||
writeHashState();
|
||||
renderResults();
|
||||
renderResults(); // the distribution is comfort-independent — no need to touch it
|
||||
}
|
||||
loInput.addEventListener("input", () => { lo = Math.min(+loInput.value, hi); slicerChanged(); });
|
||||
hiInput.addEventListener("input", () => { hi = Math.max(+hiInput.value, lo); slicerChanged(); });
|
||||
|
||||
// The °F/°C toggle only flips what's shown — the range and series stay in °F (the
|
||||
// API's unit) — so just re-render the range label and the cards. (The distribution
|
||||
// strip is percentile categories, so units don't affect it.)
|
||||
onUnitChange(() => { syncSlicer(); renderResults(); });
|
||||
|
||||
// Distribution: its own metric selector + share/count toggle, re-rendering in place.
|
||||
distToggle.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("button[data-metric]");
|
||||
if (!btn) return;
|
||||
distMetric = btn.dataset.metric;
|
||||
lsSet(CMP.distMetric, distMetric);
|
||||
distToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b === btn));
|
||||
renderDist();
|
||||
});
|
||||
tolInput.addEventListener("input", () => {
|
||||
tol = +tolInput.value;
|
||||
tolVal.textContent = `±${fmtDelta(tol)}`;
|
||||
lsSet(CMP.tol, String(tol));
|
||||
writeHashState();
|
||||
renderResults();
|
||||
distSection.addEventListener("change", (e) => {
|
||||
if (!e.target.closest("#cmp-dist-count")) return;
|
||||
distCount = e.target.checked;
|
||||
lsSet(CMP.distCount, distCount ? "1" : "0");
|
||||
renderDist();
|
||||
});
|
||||
|
||||
// The °F/°C toggle only flips what's shown — comfort/tol/series all stay in °F
|
||||
// (the API's unit), so there's nothing to refetch or re-rank, just re-render the
|
||||
// numbers: the two slider labels and every card.
|
||||
onUnitChange(() => {
|
||||
comfortVal.textContent = fmtTemp(comfort);
|
||||
tolVal.textContent = `±${fmtDelta(tol)}`;
|
||||
renderResults();
|
||||
});
|
||||
// Basis: instant re-rank over data already in hand — no refetch.
|
||||
basisToggle.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("button[data-basis]");
|
||||
if (!btn) return;
|
||||
|
|
@ -378,15 +443,16 @@ clickOpensPicker(startInput, endInput); // whole-field tap opens the month pic
|
|||
(function restore() {
|
||||
const hash = readHashState();
|
||||
if (hash) {
|
||||
if (hash.comfort != null) comfort = hash.comfort;
|
||||
if (hash.tol != null) tol = hash.tol;
|
||||
if (hash.lo != null) lo = hash.lo;
|
||||
if (hash.hi != null) hi = hash.hi;
|
||||
if (hash.basis) basis = hash.basis;
|
||||
if (hash.start && hash.end) range = { start: hash.start, end: hash.end };
|
||||
}
|
||||
|
||||
comfortInput.value = comfort; comfortVal.textContent = fmtTemp(comfort);
|
||||
tolInput.value = tol; tolVal.textContent = `±${fmtDelta(tol)}`;
|
||||
syncSlicer();
|
||||
basisToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b.dataset.basis === basis));
|
||||
distToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b.dataset.metric === distMetric));
|
||||
document.getElementById("cmp-dist-count").checked = distCount;
|
||||
startInput.value = range.start; endInput.value = range.end;
|
||||
|
||||
// A shared link's locations win; else the last-used set; else seed from the spot
|
||||
|
|
|
|||
|
|
@ -66,6 +66,76 @@ export function drynessColor(dsr) {
|
|||
return lerpRgb(DRY_BASE, DRY_MAX, Math.min(dsr, DRY_CAP) / DRY_CAP);
|
||||
}
|
||||
|
||||
// ---- category distribution (shared by the calendar totals + the compare page) ----
|
||||
// Bucket a set of daily records into the chosen metric's ordered categories
|
||||
// (low→high), each entry [label, color, dayCount, scaleGroup]. Temperature-style
|
||||
// metrics (High/Low/Feels/Humid/Wind/Gust) use the diverging Record-Low→Record-High
|
||||
// tiers; precip uses the rain-intensity tiers plus a Dry bucket; dry-streak uses
|
||||
// run-length bands. The scaleGroup ("wet"/"dry"/"temp") lets distStrip scale and
|
||||
// percent wet vs. dry days apart so a lopsided count on one side can't crush the
|
||||
// other. Categories are percentile classes on the records, so they're unit-agnostic.
|
||||
export function metricBuckets(days, metric) {
|
||||
if (metric === "dsr") {
|
||||
const bands = [
|
||||
["Rain day", drynessColor(0), (d) => d === 0],
|
||||
["1–3 dry", drynessColor(2), (d) => d >= 1 && d <= 3],
|
||||
["4–6 dry", drynessColor(5), (d) => d >= 4 && d <= 6],
|
||||
["7–13 dry", drynessColor(10), (d) => d >= 7 && d <= 13],
|
||||
["14+ dry", drynessColor(14), (d) => d >= 14],
|
||||
];
|
||||
const vals = days.map((r) => r.dsr).filter((d) => d != null);
|
||||
// The lone "Rain day" (wet) scales apart from the dry-streak buckets (dry).
|
||||
return bands.map(([label, color, fn], i) => [label, color, vals.filter(fn).length, i === 0 ? "wet" : "dry"]);
|
||||
}
|
||||
if (metric === "precip") {
|
||||
const classes = days.map((r) => r.precip && r.precip.c).filter(Boolean);
|
||||
// Dry days scale independently of the rain-intensity buckets (wet), and vice versa.
|
||||
// Index 4 carries the rain-tier key (e.g. "wet-5") so a single crowded label —
|
||||
// "Moderate", wedged between the two-line "Light–Mod"/"Mod–Heavy" — can be nudged
|
||||
// in CSS (.ct-wet-5) without brittle text matching.
|
||||
return [["Dry", drynessColor(7), classes.filter((c) => c === "dry").length, "dry"]]
|
||||
.concat(SCALE_RAIN.map((t) => [t.label, PRECIP_COLORS[t.c], classes.filter((c) => c === t.c).length, "wet", t.c]));
|
||||
}
|
||||
const classes = days.map((r) => { const g = r[metric]; return g && g.c; }).filter(Boolean);
|
||||
// Temperature tiers all share one scale group, so they scale together.
|
||||
return SCALE_TEMP.map((t) => [t.label, TIER_COLORS[t.c], classes.filter((c) => c === t.c).length, "temp"]);
|
||||
}
|
||||
|
||||
// Render buckets from metricBuckets() as the compact distribution strip: one
|
||||
// status-colored line per category, low→high, each figure printed above as a share
|
||||
// (default) or, when showCount is set, the raw day count. Bar heights and shares are
|
||||
// scoped to each bucket's scale group (index 3): wet and dry days scale apart, and a
|
||||
// multi-bar group shows each bar's share within that group, while a lone opposing
|
||||
// column (Dry on precip, Rain day on dry streak) stays a share of all shown days
|
||||
// (a group-relative percentage there would trivially read 100%). Temperature tiers
|
||||
// share one group, so they scale together. Assumes ≥1 day (callers guard empty).
|
||||
export function distStrip(buckets, showCount) {
|
||||
const total = buckets.reduce((n, b) => n + b[2], 0);
|
||||
const groupTotal = {}, groupCount = {}, maxByGroup = {};
|
||||
for (const b of buckets) {
|
||||
groupTotal[b[3]] = (groupTotal[b[3]] || 0) + b[2];
|
||||
groupCount[b[3]] = (groupCount[b[3]] || 0) + 1;
|
||||
maxByGroup[b[3]] = Math.max(maxByGroup[b[3]] || 1, b[2]);
|
||||
}
|
||||
const pctText = (n, group) => {
|
||||
const d = groupCount[group] > 1 ? (groupTotal[group] || 1) : total;
|
||||
const p = 100 * n / d, r = Math.round(p);
|
||||
return n > 0 && r === 0 ? `${p.toFixed(1)}%` : `${r}%`;
|
||||
};
|
||||
const valText = (n, group) => !n ? "0" : (showCount ? n.toLocaleString() : pctText(n, group));
|
||||
const LINE_MAX = 30; // px — height of the tallest category in its group
|
||||
const cols = buckets.map(([label, color, n, group, cls]) => {
|
||||
const h = !n ? 0 : Math.max(2, Math.round(LINE_MAX * n / maxByGroup[group]));
|
||||
const line = h ? `<span class="ct-line" style="height:${h}px"></span>` : "";
|
||||
// `cls` (a rain-tier key on precip) becomes a ct-<cls> hook for label nudging.
|
||||
return `<div class="ct-col${cls ? ` ct-${cls}` : ""}" style="--c:${color}" ` +
|
||||
`title="${label} · ${pctText(n, group)} (${n})">` +
|
||||
`<span class="ct-cap"><span class="ct-label">${label}</span>` +
|
||||
`<span class="ct-num${n ? "" : " ct-num-zero"}">${valText(n, group)}</span></span>${line}</div>`;
|
||||
}).join("");
|
||||
return `<div class="ct-strip" style="--line-max:${LINE_MAX}px">${cols}</div>`;
|
||||
}
|
||||
|
||||
// ---- formatters & tiny utils ----
|
||||
// (Temperatures go through nav.js's unit-aware fmtTemp; these are unit-agnostic.)
|
||||
export const fmtPrecip = (v) => (v == null ? "—" : `${v.toFixed(2)}"`);
|
||||
|
|
|
|||
|
|
@ -855,6 +855,46 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
|
|||
.cmp-mad { margin: 12px 0 0; font-size: 13px; color: var(--muted); }
|
||||
.cmp-mad b { color: var(--text); }
|
||||
|
||||
/* Dual-handle comfort slicer: two range inputs overlaid on a shared track, with a
|
||||
fill marking the selected span. Only the thumbs take pointer events, so both
|
||||
handles stay grabbable even where the inputs overlap. */
|
||||
.dual-range { position: relative; height: 44px; }
|
||||
.dual-range .dual-track {
|
||||
position: absolute; left: 0; right: 0; top: 50%; transform: translateY(-50%);
|
||||
height: 6px; border-radius: 3px; background: var(--surface-2);
|
||||
}
|
||||
.dual-range .dual-fill { position: absolute; top: 0; bottom: 0; border-radius: 3px; background: var(--accent); }
|
||||
.dual-range input[type="range"] {
|
||||
position: absolute; left: 0; top: 0; width: 100%; height: 100%; margin: 0;
|
||||
-webkit-appearance: none; appearance: none; background: transparent; pointer-events: none;
|
||||
}
|
||||
.dual-range input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; appearance: none; pointer-events: auto;
|
||||
width: 22px; height: 22px; border-radius: 50%; background: var(--accent);
|
||||
border: 2px solid var(--surface); box-shadow: 0 1px 3px rgba(0, 0, 0, .35); cursor: pointer;
|
||||
}
|
||||
.dual-range input[type="range"]::-moz-range-thumb {
|
||||
pointer-events: auto; width: 22px; height: 22px; border-radius: 50%; background: var(--accent);
|
||||
border: 2px solid var(--surface); box-shadow: 0 1px 3px rgba(0, 0, 0, .35); cursor: pointer;
|
||||
}
|
||||
.dual-range input[type="range"]::-webkit-slider-runnable-track { background: transparent; border: none; }
|
||||
.dual-range input[type="range"]::-moz-range-track { background: transparent; border: none; }
|
||||
|
||||
/* Climate distribution below the ranked cards: a metric toggle + share/count toggle,
|
||||
then one calendar-style distribution strip (.ct-strip, shared) per location. */
|
||||
.cmp-dist { margin: 24px 0 8px; }
|
||||
.cmp-dist[hidden] { display: none; }
|
||||
.cmp-dist .ct-head { align-items: center; }
|
||||
.cmp-dist-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; }
|
||||
.cmp-dist-controls .cal-filter-label { margin: 0; }
|
||||
.cmp-dist .metric-toggle { margin: 0; }
|
||||
.cmp-dist-body { display: grid; gap: 12px; margin-top: 14px; }
|
||||
.cmp-dist-row {
|
||||
border: 1px solid var(--border); border-radius: 12px; background: var(--surface); padding: 12px 16px 6px;
|
||||
}
|
||||
.cmp-dist-name { display: flex; align-items: baseline; gap: 8px; font-size: 14px; font-weight: 700; }
|
||||
.cmp-dist-days { font-size: 12px; font-weight: 400; color: var(--muted); }
|
||||
|
||||
/* --- phones / narrow screens — every mobile override lives in this one
|
||||
block (they used to be scattered across four) --- */
|
||||
@media (max-width: 640px) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue