diff --git a/static/calendar.html b/static/calendar.html index 9f6e69f..f335bf4 100644 --- a/static/calendar.html +++ b/static/calendar.html @@ -19,6 +19,7 @@ Weekly Calendar Day + Compare @@ -60,14 +61,6 @@ - - +
+
+ Locations + + +
+
+ + + + + + +
+

Add two or more places to see which one best matches your comfort temperature + across a date range — the share of days that land in your comfort band, how + often it runs colder or warmer, and by how much.

+
+ +
+ + + + + + + + diff --git a/static/compare.js b/static/compare.js new file mode 100644 index 0000000..113f978 --- /dev/null +++ b/static/compare.js @@ -0,0 +1,326 @@ +"use strict"; +// 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. +// +// The location set and the date range are the only things that require a data +// load. Comfort temperature, band width and the judged temperature are all pure +// re-computations over data already in hand, so moving those re-ranks instantly. + +const CMP = { + comfort: "thermograph:cmpComfort", + tol: "thermograph:cmpTol", + basis: "thermograph:cmpBasis", + range: "thermograph:cmpRange", + locs: "thermograph:cmpLocs", +}; + +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)); +let comfort = clamp(+lsGet(CMP.comfort) || 68, 30, 100); +let tol = clamp(lsGet(CMP.tol) == null ? 5 : +lsGet(CMP.tol), 0, 15); +let basis = ["tmax", "tmin", "mean", "feels"].includes(lsGet(CMP.basis)) ? lsGet(CMP.basis) : "tmax"; + +// 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). +let locations = []; +// Bumped on every (re)load so a superseded in-flight fetch drops its result. +let loadToken = 0; + +const BASIS_LABEL = { tmax: "daytime high", mean: "daily mean", tmin: "overnight low", feels: "feels-like" }; + +// ---- date range (month granularity) ---- +const pad = (n) => String(n).padStart(2, "0"); +function defaultRange() { + const now = new Date(); + const s = new Date(now.getFullYear(), now.getMonth() - 11, 1); // last 12 whole months + return { start: `${s.getFullYear()}-${pad(s.getMonth() + 1)}`, end: `${now.getFullYear()}-${pad(now.getMonth() + 1)}` }; +} +function loadRange() { + try { const o = JSON.parse(lsGet(CMP.range)); if (o && o.start && o.end) return o; } catch (e) {} + return defaultRange(); +} +let range = loadRange(); // {start, end} as "YYYY-MM" + +const monthStart = (ym) => `${ym}-01`; +const isoOfDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +const monthEnd = (ym) => isoOfDate(new Date(+ym.slice(0, 4), +ym.slice(5, 7), 0)); + +// Pretty "Jul 2025 – Jun 2026" for a YYYY-MM range. +const MON = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +const monthLabel = (ym) => `${MON[+ym.slice(5, 7) - 1]} ${ym.slice(0, 4)}`; + +// Split a [startIso, endIso] span into consecutive ≤2-year windows so each stays +// within the calendar endpoint's per-request cap (mirrors the Calendar view). +const CHUNK_MONTHS = 24; +function buildChunks(startIso, endIso) { + const out = []; + let s = startIso, guard = 0; + while (s <= endIso && guard++ < 200) { + const sd = new Date(s + "T00:00:00"); + const ed = new Date(sd.getFullYear(), sd.getMonth() + CHUNK_MONTHS, sd.getDate()); + ed.setDate(ed.getDate() - 1); + let e = isoOfDate(ed); + if (e > endIso) e = endIso; + out.push({ start: s, end: e }); + const ns = new Date(e + "T00:00:00"); ns.setDate(ns.getDate() + 1); + s = isoOfDate(ns); + } + return out; +} + +// ---- elements ---- +const addBtn = document.getElementById("cmp-add"); +const locList = document.getElementById("cmp-loc-list"); +const params = document.getElementById("cmp-params"); +const placeholder = document.getElementById("cmp-placeholder"); +const head = document.getElementById("cmp-head"); +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 basisToggle = document.getElementById("cmp-basis"); +const startInput = document.getElementById("cmp-start"); +const endInput = document.getElementById("cmp-end"); +const refreshBtn = document.getElementById("cmp-refresh"); + +addBtn.innerHTML = `${window.LocationPicker.PIN_ICON} Add location`; + +// ---- data ---- +// Fetch one location's range (chunked) and reduce it to the compact per-day series. +async function fetchSeries(loc, token) { + const chunks = buildChunks(monthStart(range.start), monthEnd(range.end)); + const days = []; + let place = null; + for (const ch of chunks) { + const url = `api/v2/calendar?lat=${loc.lat}&lon=${loc.lon}&start=${ch.start}&end=${ch.end}`; + const d = await window.Thermograph.getJSON(url, window.Thermograph.TTL.calendar, true); + 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); + } + 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, + })), + }; +} + +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); + if (!res || token !== loadToken) return; + loc.name = res.name; loc.series = res.series; loc.loading = false; + } catch (e) { + loc.loading = false; loc.error = e.message || "couldn't load"; + } + persistLocations(); + renderAll(); +} + +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; + const loc = { lat, lon, name: null, series: null, loading: true, error: null }; + locations.push(loc); + window.Thermograph.saveLastLocation(lat, lon); + loadLocation(loc, loadToken); +} + +function removeLocation(i) { + locations.splice(i, 1); + persistLocations(); + renderAll(); +} + +// Refetch every location's series (used when the date range changes). +function reloadAll() { + const token = ++loadToken; + for (const loc of locations) { loc.series = null; loadLocation(loc, token); } +} + +// ---- 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 +} + +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); + 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; } + else comf++; + } + if (!n) return null; + return { + n, comf, below, above, + 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, + }; +} + +// ---- render ---- +const pct = (v) => `${v < 10 ? v.toFixed(1) : Math.round(v)}%`; +const deg = (v) => `${Math.round(v)}°`; + +function renderLocList() { + locList.innerHTML = locations.map((l, i) => { + const label = l.loading ? "Locating…" : l.error ? "Couldn't load" : (l.name || `${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}`); + const cls = "cmp-chip" + (l.loading ? " loading" : "") + (l.error ? " error" : ""); + return `
  • ${label}` + + `
  • `; + }).join(""); +} + +function rankCard(loc, s, rank) { + const bar = ``; + const stats = + `
    ` + + `
    Colder${pct(s.belowPct)}${s.below ? `avg ${deg(s.avgBelow)} below` : "—"}
    ` + + `
    In comfort${pct(s.comfortPct)}±${tol}° of ${comfort}°
    ` + + `
    Warmer${pct(s.abovePct)}${s.above ? `avg ${deg(s.avgAbove)} above` : "—"}
    ` + + `
    `; + return `
    ` + + `
    ` + + `#${rank}` + + `
    ${loc.name}${s.n} days
    ` + + `
    ${pct(s.comfortPct)}in comfort
    ` + + `
    ` + + bar + stats + + `
    Typical day misses comfort by ${deg(s.mad)}
    ` + + `
    `; +} + +function renderResults() { + const loading = locations.filter((l) => l.loading).length; + const scored = locations + .map((l) => ({ loc: l, s: l.series ? computeStats(l.series) : null })) + .filter((x) => x.s); + // Best comfort share first; break ties by the smaller typical miss. + scored.sort((a, b) => (b.s.comfortPct - a.s.comfortPct) || (a.s.mad - b.s.mad)); + + if (!scored.length) { + head.hidden = true; + results.innerHTML = loading ? `

    Loading daily weather…

    ` : ""; + return; + } + + // Headline: the winner + the parameters it was judged under. + const win = scored[0]; + const span = `${monthLabel(range.start)} – ${monthLabel(range.end)}`; + const lead = scored.length > 1 + ? `${win.loc.name} best matches ${comfort}° — ${pct(win.s.comfortPct)} of days within ±${tol}°` + : `${win.loc.name}: ${pct(win.s.comfortPct)} of days within ±${tol}° of ${comfort}°`; + head.hidden = false; + head.innerHTML = `

    ${lead}

    ` + + `

    By ${BASIS_LABEL[basis]} · ${span}${loading ? ` · loading ${loading} more…` : ""}

    `; + + results.innerHTML = scored.map((x, i) => rankCard(x.loc, x.s, i + 1)).join(""); +} + +function renderAll() { + const has = locations.length > 0; + params.hidden = !has; + placeholder.hidden = has; + addBtn.querySelector("span").textContent = has ? "Add another location" : "Add location"; + renderLocList(); + renderResults(); +} + +// ---- events ---- +addBtn.addEventListener("click", () => { + const cur = locations.length ? locations[locations.length - 1] : window.Thermograph.loadLastLocation(); + window.LocationPicker.open(cur, (lat, lon) => addLocation(lat, lon)); +}); + +locList.addEventListener("click", (e) => { + const x = e.target.closest(".cmp-chip-x"); + if (x) removeLocation(+x.dataset.i); +}); + +// Comfort / band / basis: instant re-rank, no refetch. +comfortInput.addEventListener("input", () => { + comfort = +comfortInput.value; + comfortVal.textContent = `${comfort}°`; + lsSet(CMP.comfort, String(comfort)); + renderResults(); +}); +tolInput.addEventListener("input", () => { + tol = +tolInput.value; + tolVal.textContent = `±${tol}°`; + lsSet(CMP.tol, String(tol)); + renderResults(); +}); +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)); + renderResults(); +}); + +// Date range: editing reveals the reload button; confirming refetches everything. +startInput.max = endInput.max = `${new Date().getFullYear()}-12`; +function markDirty() { refreshBtn.hidden = locations.length === 0; } +startInput.addEventListener("change", markDirty); +endInput.addEventListener("change", markDirty); +refreshBtn.addEventListener("click", () => { + let s = startInput.value || range.start, e = endInput.value || range.end; + if (s > e) { const t = s; s = e; e = t; } + range = { start: s, end: e }; + startInput.value = s; endInput.value = e; + lsSet(CMP.range, JSON.stringify(range)); + refreshBtn.hidden = true; + reloadAll(); +}); + +// ---- init ---- +(function restore() { + comfortInput.value = comfort; comfortVal.textContent = `${comfort}°`; + tolInput.value = tol; tolVal.textContent = `±${tol}°`; + basisToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b.dataset.basis === basis)); + startInput.value = range.start; endInput.value = range.end; + + let saved = null; + try { saved = JSON.parse(lsGet(CMP.locs)); } catch (e) {} + if (Array.isArray(saved) && saved.length) { + locations = saved + .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: true, error: null })); + for (const loc of locations) loadLocation(loc, loadToken); + } else { + // Seed from the spot the other views last looked at, so Compare isn't empty. + const last = window.Thermograph.loadLastLocation(); + if (last) addLocation(last.lat, last.lon); + } + renderAll(); +})(); diff --git a/static/day.html b/static/day.html index a563a3b..bc0d08f 100644 --- a/static/day.html +++ b/static/day.html @@ -19,6 +19,7 @@ Weekly Calendar Day + Compare diff --git a/static/index.html b/static/index.html index 00b46a1..eee41e2 100644 --- a/static/index.html +++ b/static/index.html @@ -19,6 +19,7 @@ Weekly Calendar Day + Compare diff --git a/static/legend.html b/static/legend.html index 1db1cc9..49fcbfa 100644 --- a/static/legend.html +++ b/static/legend.html @@ -18,6 +18,7 @@ Weekly Calendar Day + Compare @@ -52,12 +53,8 @@
    High / Low
    The day's highest and lowest air temperature (°F).
    Feels
    Apparent temperature — what the air actually felt like once humidity - and wind are factored in. Every day has a felt high (heat-index territory) and a - felt low (wind-chill territory); both are shown in the day tooltip, each graded - against its own history. The Feels color uses whichever side sits further from your - comfort temperature — set with the slider on the Calendar page (0–100°F, - default 65°F) — so sweltering days grade by their felt high and bitter days by - their felt low.
    + and wind are factored in, taking whichever apparent extreme (heat-index high or + wind-chill low) sits further from a temperate baseline, graded against its own history.
    Humid
    Absolute humidity: grams of water vapor per cubic meter of air (g/m³).
    Wind
    Average sustained wind speed (mph).
    Gust
    Peak wind gust for the day (mph).
    diff --git a/static/style.css b/static/style.css index 8dd58f7..0e324e5 100644 --- a/static/style.css +++ b/static/style.css @@ -725,17 +725,6 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; } } .cal-weather .cal-dd-group { grid-column: 1 / -1; } -/* Comfort temperature slider (Feels metric). 44px-tall slider = full touch target. */ -.comfort-row { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 12px; margin: 10px 0 0; } -.comfort-row[hidden] { display: none; } /* display:flex would defeat the hidden attr */ -.comfort-row label { font-size: 13px; color: var(--muted); flex: 0 0 auto; } -.comfort-row label b { color: var(--text); font-size: 14px; } -.comfort-row input[type="range"] { - flex: 1 1 180px; min-width: 150px; height: 44px; margin: 0; - accent-color: var(--accent); background: transparent; -} -.comfort-row .hint { flex: 1 1 100%; margin: 0; font-size: 12px; color: var(--muted); } - /* Category totals above the grid: a compact distribution strip. Each category is a column captioned with its name over its value (share by default, raw count when "Show count" is ticked, in that category's own status color) above a thin status- @@ -802,3 +791,75 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; } .guide-metrics { grid-template-columns: 1fr; gap: 2px 0; } .guide-metrics dt { margin-top: 10px; } } + +/* ===== Compare view ===== */ +.cmp-locs { flex: 1 1 340px; } +.cmp-loc-list { list-style: none; margin: 8px 0 12px; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; } +.cmp-loc-list:empty { margin: 0; } +.cmp-chip { + display: inline-flex; align-items: center; gap: 6px; + background: var(--surface-2); border: 1px solid var(--border); + border-radius: 999px; padding: 5px 5px 5px 14px; font-size: 14px; +} +.cmp-chip.loading { color: var(--muted); } +.cmp-chip.error { border-color: var(--very-hot); color: var(--very-hot); } +.cmp-chip-name { font-weight: 600; } +.cmp-chip-x { + border: none; background: transparent; color: var(--muted); cursor: pointer; + font-size: 20px; line-height: 1; width: 30px; height: 30px; border-radius: 999px; +} +.cmp-chip-x:hover { color: var(--text); background: var(--border); } + +/* Parameter panel: comfort target, band, judged temperature, date range. */ +.cmp-params { + display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: 18px 24px; margin: 0 0 22px; padding: 16px; + border: 1px solid var(--border); border-radius: 12px; background: var(--surface); +} +.cmp-params[hidden] { display: none; } +.cmp-param > label { display: block; font-size: 13px; color: var(--muted); margin: 0 0 8px; } +.cmp-param > label b { color: var(--text); font-size: 15px; } +.cmp-param input[type="range"] { + width: 100%; height: 44px; margin: 0; accent-color: var(--accent); background: transparent; +} +.cmp-param .hint { display: block; margin: 4px 0 0; font-size: 12px; color: var(--muted); max-width: none; } +.cmp-basis-block .metric-toggle { margin-top: 2px; } +.cmp-basis-block .metric-toggle button { flex: 1 0 auto; padding: 10px 14px; } +.cmp-range { align-content: start; } + +/* Ranked result cards. */ +.cmp-results { display: grid; gap: 14px; } +.cmp-card { border: 1px solid var(--border); border-radius: 12px; background: var(--surface); padding: 16px 18px; } +.cmp-card:first-child { border-color: var(--accent); } +.cmp-card-top { display: flex; align-items: center; gap: 12px; } +.cmp-rank { font-size: 15px; font-weight: 700; color: var(--muted); flex: 0 0 auto; } +.cmp-card:first-child .cmp-rank { color: var(--accent); } +.cmp-card-name { flex: 1 1 auto; min-width: 0; } +.cmp-name { display: block; font-size: 16px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; } +.cmp-daycount { font-size: 12px; color: var(--muted); } +.cmp-big { flex: 0 0 auto; text-align: right; } +.cmp-big b { display: block; font-size: 22px; color: var(--normal); line-height: 1.1; } +.cmp-big span { font-size: 10.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; } + +.cmp-bar { display: flex; height: 14px; border-radius: 7px; overflow: hidden; margin: 12px 0 10px; background: var(--surface-2); } +.cmp-seg { display: block; height: 100%; } +.cmp-below { background: var(--cold); } +.cmp-comfort { background: var(--normal); } +.cmp-above { background: var(--hot); } + +.cmp-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; } +.cmp-stat { text-align: center; padding: 8px 4px; border-radius: 8px; background: var(--surface-2); } +.cmp-k { display: block; font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .03em; } +.cmp-v { display: block; font-size: 18px; font-weight: 700; } +.cmp-d { display: block; font-size: 11.5px; color: var(--muted); } +.cmp-s-below .cmp-v { color: var(--cold); } +.cmp-s-comfort .cmp-v { color: var(--normal); } +.cmp-s-above .cmp-v { color: var(--hot); } +.cmp-mad { margin: 12px 0 0; font-size: 13px; color: var(--muted); } +.cmp-mad b { color: var(--text); } + +@media (max-width: 640px) { + .cmp-params { grid-template-columns: 1fr; padding: 14px; } + .cmp-basis-block .metric-toggle button { flex: 1 0 22%; padding: 10px 4px; } + .cmp-big b { font-size: 20px; } +}