thermograph/static/calendar.js
Emi Griffith 052b0a3e26 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

863 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use strict";
// Thermograph calendar subpage — two years of daily grades as a month-grid heatmap.
// Talks to the v2 API (/api/v2/calendar, /api/v2/geocode). Self-contained; it
// intentionally re-declares the small shared tier constants so it stays independent
// of app.js (the map page).
// 9-step diverging temperature scale (cold navy -> green -> hot red).
const TIER_COLORS = {
"rec-hot": "#8f0e20", "very-hot": "#dd6b52", "hot": "#ef9351", "warm": "#f6c18a",
"normal": "#4a9d5b",
"cool": "#92c5de", "cold": "#4393c3", "very-cold": "#2166ac", "rec-cold": "#0a2f6b",
};
// 9-step rain-intensity ramp (rain-day percentile): light green -> teal -> dark navy.
const PRECIP_COLORS = {
"wet-1": "#d9f0d3", "wet-2": "#b7e2b1", "wet-3": "#8fd18f", "wet-4": "#5cba9f", "wet-5": "#35a1c0",
"wet-6": "#2b8cbe", "wet-7": "#226bb3", "wet-8": "#164a97", "wet-9": "#08306b",
};
// "Days since last rain" dry-streak color: rain days are blue; each dry day warms
// from a light base toward dark red, saturating at 14 consecutive dry days.
const DRY_BASE = [247, 217, 176], DRY_MAX = [122, 13, 26], DRY_CAP = 14;
const lerpRgb = (a, b, t) =>
`rgb(${a.map((x, i) => Math.round(x + (b[i] - x) * t)).join(",")})`;
function drynessColor(dsr) {
if (dsr == null) return "";
if (dsr === 0) return "#2b7bba"; // measurable rain that day
return lerpRgb(DRY_BASE, DRY_MAX, Math.min(dsr, DRY_CAP) / DRY_CAP);
}
// low -> high percentile scale, per metric (temp vs precip use different classes)
const SCALE_TEMP = [
{ c: "rec-cold", label: "Near Record", range: "<1" },
{ c: "very-cold", label: "Very Low", range: "110" },
{ c: "cold", label: "Low", range: "1025" },
{ c: "cool", label: "Below Normal", range: "2540" },
{ c: "normal", label: "Normal", range: "4060" },
{ c: "warm", label: "Above Normal", range: "6075" },
{ c: "hot", label: "High", range: "7590" },
{ c: "very-hot", label: "Very High", range: "9099" },
{ c: "rec-hot", label: "Near Record", range: ">99" },
];
// Rain-intensity tiers by rain-day percentile (dry days are shown via dry streak).
const SCALE_RAIN = [
{ c: "wet-1", label: "Trace", range: "<1" },
{ c: "wet-2", label: "Very Light", range: "110" },
{ c: "wet-3", label: "Light", range: "1025" },
{ c: "wet-4", label: "LightMod", range: "2540" },
{ c: "wet-5", label: "Moderate", range: "4060" },
{ c: "wet-6", label: "ModHeavy", range: "6075" },
{ c: "wet-7", label: "Heavy", range: "7590" },
{ c: "wet-8", label: "Very Heavy", range: "9099" },
{ c: "wet-9", label: "Extreme", range: ">99" },
];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
// Temps go through the shared unit formatter (the day tooltip reads it live, so a
// unit switch shows on the next hover); the others are unit-agnostic.
const fmtTemp = (v) => window.Thermograph.fmtTemp(v);
const fmtPrecip = (v) => (v == null ? "—" : `${v.toFixed(2)}"`);
const fmtWind = (v) => (v == null ? "—" : `${Math.round(v)} mph`);
const fmtHumid = (v) => (v == null ? "—" : `${v.toFixed(1)} g/m³`);
// The diverging-temperature-scale metrics (colored exactly like High/Low), with a
// display name for the key + tooltip and the formatter for their values.
const TEMP_METRIC_INFO = {
tmax: { name: "High", label: "daily high", fmt: fmtTemp },
tmin: { name: "Low", label: "daily low", fmt: fmtTemp },
feels: { name: "Feels", label: "feels like (apparent temperature)", fmt: fmtTemp },
humid: { name: "Humid", label: "absolute humidity (g/m³ of water vapor)", fmt: fmtHumid },
wind: { name: "Wind", label: "wind speed", fmt: fmtWind },
gust: { name: "Gust", label: "wind gust", fmt: fmtWind },
};
// The server grades at most ~2 years per request; longer spans are split into
// successive 2-year chunks the frontend requests one at a time, filling the grid
// as each arrives.
const CHUNK_MONTHS = 24;
const ord = (n) => {
const r = Math.round(n), s = ["th", "st", "nd", "rd"], v = r % 100;
return r + (s[(v - 20) % 10] || s[v] || s[0]);
};
// Monochrome line icons (currentColor) — no emoji, so the summary matches the
// site's dark/official look. Feather-style paths.
const WX = (paths) =>
`<svg class="wx" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
const IC_POINTER = `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/></svg>`;
const WX_CLOUD = `<path d="M20 16.6A5 5 0 0 0 18 7h-1.26A8 8 0 1 0 4 15.25"/>`;
const WX_ICONS = {
sun: WX(`<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.2" y1="4.2" x2="5.6" y2="5.6"/><line x1="18.4" y1="18.4" x2="19.8" y2="19.8"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.2" y1="19.8" x2="5.6" y2="18.4"/><line x1="18.4" y1="5.6" x2="19.8" y2="4.2"/>`),
drizzle: WX(`${WX_CLOUD}<line x1="8" y1="19" x2="8" y2="21"/><line x1="16" y1="19" x2="16" y2="21"/><line x1="12" y1="20" x2="12" y2="22"/>`),
rain: WX(`${WX_CLOUD}<line x1="8" y1="19" x2="8" y2="22"/><line x1="16" y1="19" x2="16" y2="22"/><line x1="12" y1="19" x2="12" y2="23"/>`),
storm: WX(`<path d="M19 16.9A5 5 0 0 0 18 7h-1.26a8 8 0 1 0-11.62 9"/><polyline points="13 11 9 17 15 17 11 23"/>`),
snow: WX(`<path d="M20 17.6A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25"/><line x1="8" y1="18" x2="8" y2="18"/><line x1="12" y1="20" x2="12" y2="20"/><line x1="16" y1="18" x2="16" y2="18"/><line x1="12" y1="16" x2="12" y2="16"/>`),
};
// Plain-language "what was the day like" descriptor from the raw values:
// a temperature word (from the daily high, °F) crossed with a sky/precip word
// (from precipitation, inches — falling as snow when it's at/below freezing).
// The filterable day-type vocabulary (the weather filter's two checkbox groups).
// Keys match what weatherType() reports for each day.
const FEEL_WORDS = [
["scorching", "Scorching"], ["hot", "Hot"], ["warm", "Warm"], ["mild", "Mild"],
["cool", "Cool"], ["cold", "Cold"], ["frigid", "Frigid"],
];
const SKY_WORDS = [
["clear", "Clear"], ["dry", "Dry spell"], ["drizzle", "Light rain"],
["rain", "Rain"], ["downpour", "Downpour"], ["snow", "Snow"],
];
function weatherType(rec) {
const t = rec.tmax ? rec.tmax.v : null; // daily high, °F
const p = rec.precip ? rec.precip.v : null; // precipitation, inches
const wet = p != null && p >= 0.01;
const freezing = t != null && t <= 34;
const temp = t == null ? "" :
t >= 95 ? "Scorching" : t >= 85 ? "Hot" : t >= 72 ? "Warm" :
t >= 58 ? "Mild" : t >= 45 ? "Cool" : t >= 32 ? "Cold" : "Frigid";
let sky, skyKey, icon;
if (wet && freezing) { sky = p >= 0.3 ? "heavy snow" : "snow"; skyKey = "snow"; icon = WX_ICONS.snow; }
else if (wet && p >= 1.0) { sky = "downpour"; skyKey = "downpour"; icon = WX_ICONS.storm; }
else if (wet && p >= 0.3) { sky = "rain"; skyKey = "rain"; icon = WX_ICONS.rain; }
else if (wet) { sky = "light rain"; skyKey = "drizzle"; icon = WX_ICONS.drizzle; }
else {
skyKey = rec.dsr != null && rec.dsr >= 10 ? "dry" : "clear";
sky = skyKey === "dry" ? "dry" : "clear";
icon = WX_ICONS.sun;
}
const text = !temp ? sky : wet ? `${temp}, ${sky}` : `${temp} & ${sky}`;
return { icon, text: text.charAt(0).toUpperCase() + text.slice(1),
feel: temp.toLowerCase(), sky: skyKey };
}
let selected = null; // {lat, lon}
let data = null; // last /api/v2/calendar response
// Coloring metric. Persisted so a refresh keeps your selection.
let metric = localStorage.getItem("thermograph:calMetric") || "tmax"; // tmax|tmin|feels|wind|gust|precip|dsr
// Season/year filters: only months whose season AND year are checked are shown.
const SEASONS = [["winter", "Winter"], ["spring", "Spring"], ["summer", "Summer"], ["fall", "Fall"]];
const seasonOf = (m) => (m === 11 || m <= 1) ? "winter" : m <= 4 ? "spring" : m <= 7 ? "summer" : "fall";
let checkedSeasons = new Set(SEASONS.map((s) => s[0])); // persists across reloads
let checkedYears = null; // reset to all available on each fetch
// Weather filter (day feel × sky). Unlike seasons/years it filters DAYS, not
// months: a day must match a checked feel AND a checked sky, or it's dimmed on
// the grid (and left out of the category totals) — a "find days like this" tool.
let checkedFeels = new Set(FEEL_WORDS.map((w) => w[0]));
let checkedSkies = new Set(SKY_WORDS.map((w) => w[0]));
const weatherFilterActive = () =>
checkedFeels.size < FEEL_WORDS.length || checkedSkies.size < SKY_WORDS.length;
function weatherPass(rec) {
const wt = weatherType(rec);
return (!wt.feel || checkedFeels.has(wt.feel)) && checkedSkies.has(wt.sky);
}
// Custom date range (ISO strings) or null = default last-24-months. Any span is
// allowed; the frontend splits it into ~2-year server requests (see fetchCalendar).
let rangeStart = null, rangeEnd = null;
// The full requested span (drives the filters + pickers); data.range tracks only
// the portion graded so far, which grows as chunks arrive.
let reqStart = null, reqEnd = null;
// Bumped on every new fetch so an in-flight chunked load abandons itself when a
// newer selection/range supersedes it.
let fetchToken = 0;
// The last range the user explicitly picked, persisted so a page refresh (or a
// return visit) keeps the same dates instead of snapping back to the last 2 years.
const CAL_RANGE_KEY = "thermograph:calRange";
function saveCalRange(s, e) {
try {
if (s && e) localStorage.setItem(CAL_RANGE_KEY, JSON.stringify({ start: s, end: e }));
else localStorage.removeItem(CAL_RANGE_KEY);
} catch (err) {}
}
function loadCalRange() {
try {
const o = JSON.parse(localStorage.getItem(CAL_RANGE_KEY));
if (o && o.start && o.end) return o;
} catch (err) {}
return null;
}
// The shared "selected day" (thermograph:loc.date) that the calendar last aligned
// its To-month to. Lets restore tell a plain refresh (day unchanged since we set
// it) apart from a real change made on the Weekly/Day view — needed because a
// future To-month clamps the shared day to today, so day-month ≠ To-month.
const CAL_SYNC_KEY = "thermograph:calDateSync";
function getCalSync() { try { return localStorage.getItem(CAL_SYNC_KEY); } catch (e) { return null; } }
function setCalSync(d) { try { d ? localStorage.setItem(CAL_SYNC_KEY, d) : localStorage.removeItem(CAL_SYNC_KEY); } catch (e) {} }
// Day-open interaction state (see attachHover). Module-scoped so the "tap outside
// to dismiss" listener can be registered once, not re-added on every re-render.
let armedCell = null, lastPointer = "mouse";
const placeholder = document.getElementById("cal-placeholder");
const calHead = document.getElementById("cal-head");
const calEl = document.getElementById("calendar");
const keyEl = document.getElementById("cal-key");
// A click-activated (touch) tooltip stays up until something that isn't a day is
// tapped: tapping another day moves it (that cell's own handler re-shows it),
// while tapping empty space, a header, the key, or anywhere off the grid dismisses
// it and cancels a pending "second tap to open". Registered once (not per re-render).
document.addEventListener("pointerdown", (e) => {
if (!e.target.closest(".cal-cell.filled")) {
document.getElementById("cal-tip").hidden = true;
armedCell = null;
}
});
// ---- location picker ----
// The Find button opens the shared modal map picker; choosing a spot loads it.
const findBtn = document.getElementById("find-btn");
const locLabel = document.getElementById("loc-label");
findBtn.innerHTML = `${window.LocationPicker.PIN_ICON} <span>Find a location</span>`;
findBtn.addEventListener("click", () => {
window.LocationPicker.open(selected, (lat, lon) => selectLocation(lat, lon));
});
// ---- metric toggle ----
// Reflect the restored metric on the toggle (the HTML defaults to High).
document.querySelectorAll("#metric-toggle button").forEach((b) =>
b.classList.toggle("active", b.dataset.metric === metric));
document.getElementById("metric-toggle").addEventListener("click", (e) => {
const btn = e.target.closest("button[data-metric]");
if (!btn) return;
metric = btn.dataset.metric;
try { localStorage.setItem("thermograph:calMetric", metric); } catch (err) {}
document.querySelectorAll("#metric-toggle button").forEach((b) => b.classList.toggle("active", b === btn));
renderCalendar();
renderKey();
});
// ---- category totals: share vs. count toggle ----
// The strip normally prints each category's share; ticking "Show count" swaps every
// figure for the raw day count. State is persisted and applied by renderTotals; the
// checkbox lives inside the (re-rendered) totals block, so the listener is delegated
// on the stable #cal-totals container and re-renders from the last shown days.
let showCount = false;
try { showCount = localStorage.getItem("thermograph:calShowCount") === "1"; } catch (e) {}
let lastVisible = null;
document.getElementById("cal-totals").addEventListener("change", (e) => {
if (!e.target.closest("#ct-count-cb")) return;
showCount = e.target.checked;
try { localStorage.setItem("thermograph:calShowCount", showCount ? "1" : "0"); } catch (err) {}
if (lastVisible) renderTotals(lastVisible);
});
// ---- season / year filters ----
const filtersEl = document.getElementById("cal-filters");
const seasonYearEl = document.getElementById("cal-seasonyear");
function calYears() {
// Years span the full requested range, not just the portion graded so far, so
// the Years dropdown is complete from the first chunk on.
const sy = new Date((reqStart || data.range.start) + "T00:00:00").getFullYear();
const ey = new Date((reqEnd || data.range.end) + "T00:00:00").getFullYear();
const out = []; for (let y = sy; y <= ey; y++) out.push(y);
return out;
}
let filterYears = []; // the year set behind the Years dropdown (for its summary)
// A collapsed summary of a multi-select: "All" when everything's on, "None" when
// nothing is, otherwise the picked labels joined.
function seasonSummaryText() {
if (checkedSeasons.size === SEASONS.length) return "All seasons";
if (checkedSeasons.size === 0) return "None";
return SEASONS.filter((s) => checkedSeasons.has(s[0])).map((s) => s[1]).join(", ");
}
function yearSummaryText() {
const n = checkedYears ? checkedYears.size : 0;
if (!filterYears.length || n === filterYears.length) return "All years";
if (n === 0) return "None";
return filterYears.filter((y) => checkedYears.has(y)).join(", ");
}
function weatherSummaryText() {
const allF = checkedFeels.size === FEEL_WORDS.length;
const allS = checkedSkies.size === SKY_WORDS.length;
if (allF && allS) return "All days";
if (checkedFeels.size === 0 || checkedSkies.size === 0) return "None";
const f = allF ? "Any feel" : FEEL_WORDS.filter((w) => checkedFeels.has(w[0])).map((w) => w[1]).join(", ");
const s = allS ? "any sky" : SKY_WORDS.filter((w) => checkedSkies.has(w[0])).map((w) => w[1]).join(", ");
return `${f} · ${s}`;
}
function updateFilterSummaries() {
const s = seasonYearEl.querySelector('[data-dd="season"] .cal-dd-sum');
const y = seasonYearEl.querySelector('[data-dd="year"] .cal-dd-sum');
const w = seasonYearEl.querySelector('[data-dd="weather"] .cal-dd-sum');
if (s) s.textContent = seasonSummaryText();
if (y) y.textContent = yearSummaryText();
if (w) w.textContent = weatherSummaryText();
}
// One filter as a compact dropdown: a summary button that expands to the checkbox
// list. Uses <details> so it needs no extra open/close wiring.
const filterDropdown = (dd, label, attr, items, summary) => `
<details class="cal-dd" data-dd="${dd}">
<summary>
<span class="cal-filter-label">${label}</span>
<span class="cal-dd-sum">${summary}</span>
<span class="cal-dd-caret" aria-hidden="true">▾</span>
</summary>
<div class="cal-dd-panel">
${items.map(([val, lab, on]) =>
`<label class="cal-dd-opt"><input type="checkbox" data-${attr}="${val}"${on ? " checked" : ""}>${lab}</label>`).join("")}
</div>
</details>`;
// The weather filter: one dropdown, two checkbox groups (day feel + sky), so a
// pick like "Mild or Warm · Clear" finds exactly the days you'd love. Sits after
// (below) the season/year dropdowns and spans the full filter row.
const weatherDropdown = () => `
<details class="cal-dd cal-weather" data-dd="weather">
<summary>
<span class="cal-filter-label">Weather</span>
<span class="cal-dd-sum">${weatherSummaryText()}</span>
<span class="cal-dd-caret" aria-hidden="true">▾</span>
</summary>
<div class="cal-dd-panel">
<div class="cal-dd-group">Day feel — from the daily high</div>
${FEEL_WORDS.map(([val, lab]) =>
`<label class="cal-dd-opt"><input type="checkbox" data-wfeel="${val}"${checkedFeels.has(val) ? " checked" : ""}>${lab}</label>`).join("")}
<div class="cal-dd-group">Sky</div>
${SKY_WORDS.map(([val, lab]) =>
`<label class="cal-dd-opt"><input type="checkbox" data-wsky="${val}"${checkedSkies.has(val) ? " checked" : ""}>${lab}</label>`).join("")}
</div>
</details>`;
function renderFilters() {
const years = calYears();
checkedYears = new Set(years); // reset to all available whenever the range changes
filterYears = years;
filtersEl.hidden = false;
// The month/year range picker stays as static HTML (keeps its listeners); only the
// season/year/weather dropdowns are rebuilt here, since the year set depends on
// the span (the weather sets survive the rebuild — they're module state).
seasonYearEl.innerHTML =
filterDropdown("season", "Seasons", "season",
SEASONS.map((s) => [s[0], s[1], checkedSeasons.has(s[0])]), seasonSummaryText()) +
filterDropdown("year", "Years", "year",
years.map((y) => [y, y, checkedYears.has(y)]), yearSummaryText()) +
weatherDropdown();
}
filtersEl.addEventListener("change", (e) => {
const cb = e.target.closest("input[type=checkbox]");
if (!cb) return;
if (cb.dataset.season) {
cb.checked ? checkedSeasons.add(cb.dataset.season) : checkedSeasons.delete(cb.dataset.season);
} else if (cb.dataset.year) {
const y = +cb.dataset.year;
cb.checked ? checkedYears.add(y) : checkedYears.delete(y);
} else if (cb.dataset.wfeel) {
cb.checked ? checkedFeels.add(cb.dataset.wfeel) : checkedFeels.delete(cb.dataset.wfeel);
} else if (cb.dataset.wsky) {
cb.checked ? checkedSkies.add(cb.dataset.wsky) : checkedSkies.delete(cb.dataset.wsky);
}
updateFilterSummaries();
renderCalendar();
});
// Close any open filter dropdown when tapping outside it (native <details> stays
// open otherwise). Doesn't touch the day tooltip's own outside-tap handler.
document.addEventListener("pointerdown", (e) => {
seasonYearEl.querySelectorAll("details.cal-dd[open]").forEach((d) => {
if (!d.contains(e.target)) d.open = false;
});
}, true);
// ---- date-range picker (month + year only) ----
// The inputs are <input type="month"> ("YYYY-MM"); a picked month is expanded to a
// full-month ISO span so every displayed month is shown in full (no partial weeks).
const startInput = document.getElementById("range-start");
const endInput = document.getElementById("range-end");
const refreshBtn = document.getElementById("range-refresh");
// Selectable bounds: 1970 through the current year. Fixed (not derived from the
// loaded span) so the To field always reaches the present; the actual data starts
// ~1980 and ends a few days ago, and out-of-range picks are clamped server-side.
startInput.min = endInput.min = "1970-01";
startInput.max = endInput.max = `${new Date().getFullYear()}-12`;
const isoOfDate = (d) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const monthStart = (ym) => `${ym}-01`; // 1st of the month
const monthEnd = (ym) => { // last day of the month
const d = new Date(+ym.slice(0, 4), +ym.slice(5, 7), 0);
return isoOfDate(d);
};
// Split [startIso, endIso] into consecutive ≤2-year windows (oldest first) so each
// stays within the server's per-request cap. The grid fills chunk by chunk.
function buildChunks(startIso, endIso) {
const chunks = [];
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); // 24 months inclusive
let e = isoOfDate(ed);
if (e > endIso) e = endIso;
chunks.push({ start: s, end: e });
const ns = new Date(e + "T00:00:00"); ns.setDate(ns.getDate() + 1);
s = isoOfDate(ns);
}
return chunks;
}
// Editing a date doesn't fetch — it reveals the Refresh button so the user
// confirms before a (possibly multi-request) load kicks off.
function markRangeDirty() { refreshBtn.hidden = false; }
startInput.addEventListener("change", markRangeDirty);
endInput.addEventListener("change", markRangeDirty);
// Confirm: read the pickers into the active range and (re)load.
function applyRange() {
let s = startInput.value ? monthStart(startInput.value) : null;
let e = endInput.value ? monthEnd(endInput.value) : null;
if (s && e && s > e) { const t = s; s = e; e = t; } // keep start ≤ end
rangeStart = s; rangeEnd = e;
saveCalRange(s, e); // keep these dates across refreshes
syncDayToOtherViews(); // push the To-month to Weekly/Day
refreshBtn.hidden = true;
if (selected) fetchCalendar();
}
refreshBtn.addEventListener("click", applyRange);
// A To-Date change updates the day the Weekly/Day views open on: the 1st of the
// To month, or today if that month is still in the future (no data ahead of today).
function syncDayToOtherViews() {
if (!selected || !rangeEnd) return;
const toFirst = rangeEnd.slice(0, 7) + "-01";
const todayIso = isoOfDate(new Date());
const day = toFirst > todayIso ? todayIso : toFirst;
window.Thermograph.saveLastLocation(selected.lat, selected.lon, day);
setCalSync(day);
}
// ---- fetch + render ----
function selectLocation(lat, lon) {
selected = { lat, lon };
history.replaceState(null, "", `#lat=${lat.toFixed(5)}&lon=${lon.toFixed(5)}`);
window.Thermograph.saveLastLocation(lat, lon); // keep date for the other views
// Show the raw coordinates immediately; they're replaced with the resolved
// neighborhood + city name once the first calendar chunk lands.
locLabel.textContent = `${lat.toFixed(3)}, ${lon.toFixed(3)}`;
locLabel.hidden = false;
fetchCalendar();
}
// Up to this many chunk requests are in flight at once. The server grades every
// chunk against one shared, already-cached history record, so a few parallel
// requests cut wall-clock time on long spans without extra upstream fetches.
const CHUNK_CONCURRENCY = 4;
async function fetchCalendar() {
if (!selected) return;
const token = ++fetchToken; // supersede any in-flight chunked load
placeholder.hidden = true;
refreshBtn.hidden = true;
calHead.hidden = false;
data = null;
// Delay the spinner: cache-served chunks render in a few ms, so clearing the
// grid immediately would just flash-blank a warm load. A genuinely slow
// (network) load still gets the spinner and a cleared grid.
const spin = setTimeout(() => {
if (token !== fetchToken || data) return;
calHead.innerHTML = `<p class="spinner">Grading daily weather across the range…</p>`;
calEl.innerHTML = "";
keyEl.innerHTML = "";
}, 150);
const q = `lat=${selected.lat}&lon=${selected.lon}`;
// A custom span is split into ≤2-year requests; the default (no range) is one
// last-24-months request that also discovers the latest available date.
const chunks = (rangeStart && rangeEnd) ? buildChunks(rangeStart, rangeEnd) : [null];
const dayMap = new Map();
const results = new Array(chunks.length); // per-chunk response (or {error}), by index
let donePrefix = 0; // chunks [0, donePrefix) are all loaded
// Set the location, pickers, and filters once — off whichever chunk lands first
// (cell/place are identical across chunks; the requested span drives the rest).
const initOnce = (d) => {
if (data) return;
data = d;
reqStart = rangeStart || d.range.start;
reqEnd = rangeEnd || d.range.end;
startInput.value = reqStart.slice(0, 7); // "YYYY-MM" for <input type=month>
endInput.value = reqEnd.slice(0, 7);
document.getElementById("metric-block").hidden = false;
locLabel.textContent = d.place || `${d.cell.center_lat.toFixed(3)}, ${d.cell.center_lon.toFixed(3)}`;
locLabel.hidden = false;
findBtn.innerHTML = `${window.LocationPicker.PIN_ICON} <span>Change location</span>`;
renderFilters();
};
// Advance the visible span over the contiguous run of loaded chunks from the
// oldest, so the grid fills top-down with no gaps even when chunks finish out of
// order. A failed chunk halts the visible prefix at its position.
const advance = () => {
while (donePrefix < chunks.length && results[donePrefix] && !results[donePrefix].error) donePrefix++;
if (donePrefix === 0 || !data) return;
data.days = [...dayMap.values()];
data.range = { start: results[0].range.start, end: results[donePrefix - 1].range.end };
renderHead(chunks.length - donePrefix, true);
renderCalendar();
renderKey();
};
const runChunk = async (i) => {
const ch = chunks[i];
const url = ch
? `api/v2/calendar?${q}&start=${ch.start}&end=${ch.end}`
: `api/v2/calendar?${q}&months=24`;
try {
// Stale-while-revalidate: a stale cached chunk renders immediately; if the
// background revalidation finds changed data, re-merge that chunk's days
// and repaint (the cell/place metadata is identical across versions).
const d = await window.Thermograph.getJSON(url, window.Thermograph.TTL.calendar, true, (upd) => {
if (token !== fetchToken) return;
results[i] = upd;
for (const x of upd.days) dayMap.set(x.date, x);
advance();
});
if (token !== fetchToken) return;
results[i] = d;
for (const x of d.days) dayMap.set(x.date, x);
initOnce(d);
advance();
} catch (err) {
if (token !== fetchToken) return;
results[i] = { error: err };
advance();
}
};
// Bounded-parallel pool: keep up to CHUNK_CONCURRENCY requests in flight.
await new Promise((resolve) => {
let launched = 0, settled = 0;
const pump = () => {
if (token !== fetchToken) return resolve();
while (launched < chunks.length && launched - settled < CHUNK_CONCURRENCY) {
runChunk(launched++).finally(() => {
settled++;
if (settled === chunks.length) resolve();
else pump();
});
}
};
pump();
});
clearTimeout(spin);
if (token !== fetchToken) return; // superseded while loading
if (!data) { // every chunk failed
const firstErr = results.find((r) => r && r.error);
calHead.innerHTML = `<p class="error">${firstErr ? firstErr.error.message : "Failed to load."}</p>`;
return;
}
renderHead(chunks.length - donePrefix, false); // final head (flags any gap)
window.Thermograph.prefetchViews(selected.lat, selected.lon, "calendar"); // warm weekly/forecast
}
// Header summary. While `loading`, `remaining` > 0 shows a "loading N more blocks"
// note; once loading has finished, a leftover `remaining` flags chunks that failed.
function renderHead(remaining, loading) {
const cell = data.cell;
const place = data.place || `${cell.center_lat.toFixed(3)}, ${cell.center_lon.toFixed(3)}`;
const years = ((new Date(data.range.end) - new Date(data.range.start)) / 3.15576e10);
let note = "";
if (remaining > 0 && loading) {
note = ` · <span class="cal-loading">loading ${remaining} more block${remaining === 1 ? "" : "s"}…</span>`;
} else if (remaining > 0) {
note = ` · <span class="error">${remaining} block${remaining === 1 ? "" : "s"} failed to load</span>`;
}
calHead.innerHTML = `
<h2>${place}</h2>
<p class="meta">${data.range.start}${data.range.end} · ${data.days.length.toLocaleString()} days graded
(~${years.toFixed(1)} yr)${note}</p>
<p class="cal-hint">${IC_POINTER} Tap or click any day for its weather and grades</p>`;
}
function renderCalendar() {
if (!data) return;
const byDate = new Map(data.days.map((d) => [d.date, d]));
const start = new Date(data.range.start + "T00:00:00");
const end = new Date(data.range.end + "T00:00:00");
let y = start.getFullYear(), m = start.getMonth();
const endY = end.getFullYear(), endM = end.getMonth();
let html = "";
const wActive = weatherFilterActive();
const visible = []; // every graded day in a shown month: {rec, pass}
while (y < endY || (y === endY && m <= endM)) {
// Only render months that are fully inside the graded range — never a partial
// boundary month showing a stray day or two. The range is month-based, so a
// clipped edge month (e.g. data starting mid-June) is dropped entirely.
const mFirst = new Date(y, m, 1), mLast = new Date(y, m + 1, 0);
if (mFirst < start || mLast > end) {
m++; if (m > 11) { m = 0; y++; }
continue;
}
// Season/year filters: skip months whose season or year is unchecked.
if (!checkedSeasons.has(seasonOf(m)) || (checkedYears && !checkedYears.has(y))) {
m++; if (m > 11) { m = 0; y++; }
continue;
}
const lead = new Date(y, m, 1).getDay(); // 0 = Sunday
const dim = new Date(y, m + 1, 0).getDate();
let cells = "";
for (let i = 0; i < lead; i++) cells += `<div class="cal-cell empty"></div>`;
for (let day = 1; day <= dim; day++) {
const iso = `${y}-${String(m + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const rec = byDate.get(iso);
let bg = "", pass = true;
if (rec) {
if (metric === "dsr") {
bg = drynessColor(rec.dsr);
} else if (metric === "precip") {
const g = rec.precip;
// Dry days take their dry-streak color; rainy days take the blue ramp.
bg = !g ? "" : g.c === "dry" ? drynessColor(rec.dsr) : PRECIP_COLORS[g.c];
} else {
const g = rec[metric];
bg = g ? TIER_COLORS[g.c] : "";
}
pass = !wActive || weatherPass(rec);
visible.push({ rec, pass });
}
// Days with no graded record (outside the range) render transparent, not as a
// grey tile — so every displayed month reads as clean color with no dead squares.
const num = `<span class="cal-daynum">${day}</span>`;
cells += rec
? `<div class="cal-cell filled${pass ? "" : " dim"}" data-date="${iso}"${bg ? ` style="background:${bg}"` : ""}>${num}</div>`
: `<div class="cal-cell empty">${num}</div>`;
}
html += `<div class="cal-month"><h4>${MONTHS[m]} ${y}</h4>
<div class="cal-dows"><span>S</span><span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span></div>
<div class="cal-grid">${cells}</div></div>`;
m++; if (m > 11) { m = 0; y++; }
}
calEl.innerHTML = html || `<p class="muted">No months match the selected seasons/years.</p>`;
renderTotals(visible);
attachHover(byDate);
}
// ---- category totals ----
// "What share of the shown days lands in each category?" for the current metric,
// rendered above the grid as a stacked share bar + per-category percentages.
// When the weather filter is narrowing, only matching days are totaled (and the
// title says how many matched) — so it doubles as a "days you'd love" counter.
function renderTotals(visible) {
const totEl = document.getElementById("cal-totals");
lastVisible = visible; // remembered so the "Show count" toggle can re-render in place
if (!visible.length) { totEl.hidden = true; totEl.innerHTML = ""; return; }
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],
["13 dry", drynessColor(2), (d) => d >= 1 && d <= 3],
["46 dry", drynessColor(5), (d) => d >= 4 && d <= 6],
["713 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);
buckets = B.map(([label, color, fn]) => [label, color, vals.filter(fn).length]);
title = "dry streak";
} else if (metric === "precip") {
const classes = days.map((r) => r.precip && r.precip.c).filter(Boolean);
buckets = [["Dry", drynessColor(7), classes.filter((c) => c === "dry").length]]
.concat(SCALE_RAIN.map((t) =>
[t.label, PRECIP_COLORS[t.c], classes.filter((c) => c === t.c).length]));
title = "rain intensity";
} else {
const classes = days
.map((r) => { const g = r[metric]; return g && g.c; })
.filter(Boolean);
buckets = SCALE_TEMP.map((t) =>
[t.label, TIER_COLORS[t.c], classes.filter((c) => c === t.c).length, t.c === "normal"]);
title = (TEMP_METRIC_INFO[metric] || {}).label || "value";
}
const total = buckets.reduce((n, b) => n + b[2], 0);
const pctText = (n) => { const p = 100 * n / total, 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) => !n ? "0" : (showCount ? n.toLocaleString() : pctText(n));
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}:`;
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.
const maxLine = Math.max(1, ...buckets.map((b) => b[2]));
const LINE_MAX = 30; // px — height of the most common category
const cols = buckets.map(([label, color, n]) => {
const h = !n ? 0 : Math.max(2, Math.round(LINE_MAX * n / maxLine));
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" style="--c:${color}" ` +
`title="${label} · ${pctText(n)} (${n})">` +
`<span class="ct-cap"><span class="ct-label">${label}</span>` +
`<span class="ct-num${n ? "" : " ct-num-zero"}">${valText(n)}</span></span>` +
`${line}</div>`;
}).join("");
totEl.innerHTML = `${header}
<div class="ct-strip" style="--line-max:${LINE_MAX}px">${cols}</div>`;
totEl.hidden = false;
}
const GUIDE_LINK = `<p class="muted tk-note"><a href="legend">Full guide to metrics &amp; grades →</a></p>`;
function renderKey() {
if (metric === "dsr") {
const steps = [["Rain", 0], ["1 day", 1], ["4", 4], ["7", 7], ["10", 10], ["14+", 14]];
const segs = steps.map(([lab, d]) =>
`<div class="tk-seg"><span class="tk-swatch" style="background:${drynessColor(d)}"></span>
<span class="tk-label">${lab}</span></div>`).join("");
keyEl.innerHTML = `<div class="section-title">Days since last rain — dry streak (caps at ${DRY_CAP} days)</div>
<div class="tier-key">${segs}</div>${GUIDE_LINK}`;
return;
}
if (metric === "precip") {
const rain = [...SCALE_RAIN].reverse().map((t) =>
`<div class="tk-seg"><span class="tk-swatch" style="background:${PRECIP_COLORS[t.c]}"></span>
<span class="tk-label">${t.label}</span></div>`).join("");
const dry = [["≤1 day", 1], ["7", 7], ["14+", 14]].map(([l, d]) =>
`<div class="tk-seg"><span class="tk-swatch" style="background:${drynessColor(d)}"></span>
<span class="tk-label">${l}</span></div>`).join("");
keyEl.innerHTML = `<div class="section-title">Rain intensity · light → heavy</div>
<div class="tier-key">${rain}</div>
<div class="section-title" style="margin-top:10px">Dry days · days since rain</div>
<div class="tier-key">${dry}</div>${GUIDE_LINK}`;
return;
}
const label = (TEMP_METRIC_INFO[metric] || {}).label || "value";
// Highest tier first (Near Record hot → Near Record cold).
const segs = [...SCALE_TEMP].reverse().map((t) =>
`<div class="tk-seg"><span class="tk-swatch" style="background:${TIER_COLORS[t.c]}"></span>
<span class="tk-label">${t.label}</span></div>`).join("");
keyEl.innerHTML = `<div class="section-title">Coloring by ${label} · low → high vs local normal</div>
<div class="tier-key">${segs}</div>${GUIDE_LINK}`;
}
// ---- tooltip ----
function attachHover(byDate) {
const tip = document.getElementById("cal-tip");
// The row for the metric currently being viewed is bolded and moved to the top,
// so it's easy to match a cell's color to the stat it represents. Its category
// (grade) text is tinted with the very color that represents that day's value —
// bold + saturated for the active metric, lifted/dimmed for the rest (see CSS).
// Each metric is one row of the mini grid: name · value(+pct) · category.
// `activeKey` is the row to bold: the metric currently being viewed.
let activeKey = metric;
const line = (key, name, g, fmt, color) => {
const rc = key === activeKey ? " tt-r-active" : "";
if (!g) return `<span class="tt-name${rc}">${name}</span><span class="tt-val${rc}">—</span><span class="tt-cat${rc}"></span>`;
const pct = g.pct == null ? "" : `<span class="tt-pct"> · ${ord(g.pct)} pct</span>`;
const catCls = key === activeKey ? "tt-cat tt-cat-active" : "tt-cat";
const style = color ? ` style="--cat:${color}"` : "";
return `<span class="tt-name${rc}">${name}</span><span class="tt-val${rc}">${fmt(g.v)}${pct}</span><span class="${catCls}${rc}"${style}>${g.g}</span>`;
};
const show = (cell, e) => {
const rec = byDate.get(cell.dataset.date);
if (!rec) return;
activeKey = metric;
const dt = new Date(rec.date + "T00:00:00");
// Color each metric the same way the calendar cell would be colored for it.
const tempColor = (g) => (g ? TIER_COLORS[g.c] : "");
const precipColor = !rec.precip ? "" : rec.precip.c === "dry" ? drynessColor(rec.dsr) : PRECIP_COLORS[rec.precip.c];
const dsrStr = rec.dsr == null ? null
: rec.dsr === 0 ? "Rain today"
: `${rec.dsr} day${rec.dsr === 1 ? "" : "s"} since rain`;
const rows = [
["tmax", line("tmax", "High", rec.tmax, fmtTemp, tempColor(rec.tmax))],
["tmin", line("tmin", "Low", rec.tmin, fmtTemp, tempColor(rec.tmin))],
["feels", line("feels", "Feels", rec.feels, fmtTemp, tempColor(rec.feels))],
["humid", line("humid", "Humid", rec.humid, fmtHumid, tempColor(rec.humid))],
["wind", line("wind", "Wind", rec.wind, fmtWind, tempColor(rec.wind))],
["gust", line("gust", "Gust", rec.gust, fmtWind, tempColor(rec.gust))],
["precip", line("precip", "Precip", rec.precip, fmtPrecip, precipColor)],
];
if (dsrStr) {
const rc = metric === "dsr" ? " tt-r-active" : "";
const catCls = metric === "dsr" ? "tt-cat tt-cat-active" : "tt-cat";
// No separate percentile — let the streak text span the value + category columns.
rows.push(["dsr", `<span class="tt-name${rc}">Dry streak</span><span class="${catCls} tt-span2${rc}" style="--cat:${drynessColor(rec.dsr)}">${dsrStr}</span>`]);
}
// active metric first, the rest keep their order
rows.sort((a, b) => (b[0] === activeKey) - (a[0] === activeKey));
const wt = weatherType(rec);
tip.innerHTML = `<div class="tt-date">${dt.toLocaleDateString(undefined, { weekday: "short", year: "numeric", month: "short", day: "numeric" })}</div>
<div class="tt-type">${wt.icon} ${wt.text}</div>
<div class="tt-grid">${rows.map((r) => r[1]).join("")}</div>`;
tip.hidden = false;
const pad = 14, w = tip.offsetWidth || 170, h = tip.offsetHeight || 70;
let x = e.clientX + pad, ty = e.clientY + pad;
if (x + w > window.innerWidth) x = e.clientX - w - pad;
if (ty + h > window.innerHeight) ty = e.clientY - h - pad;
// Keep it on-screen even when flipped past an edge (narrow phone screens).
x = Math.max(pad, Math.min(x, window.innerWidth - w - pad));
ty = Math.max(pad, Math.min(ty, window.innerHeight - h - pad));
tip.style.left = x + "px";
tip.style.top = ty + "px";
};
// Clicking a day opens its full percentile breakdown. On a mouse, one click
// navigates (the tooltip is just a hover preview). On touch there's no hover, so
// the first tap only raises the tooltip and a second tap on the same day confirms.
const openDay = (cell) => {
location.href = `day#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}&date=${cell.dataset.date}`;
};
calEl.querySelectorAll(".cal-cell.filled").forEach((c) => {
c.addEventListener("pointerenter", (e) => { if (e.pointerType === "mouse") show(c, e); });
c.addEventListener("pointerdown", (e) => { lastPointer = e.pointerType; show(c, e); });
c.addEventListener("click", () => {
if (lastPointer === "mouse") return openDay(c);
if (armedCell === c) openDay(c); // second tap on the previewed day → open
else armedCell = c; // first tap only showed the tooltip
});
});
// Mouse only: hovering off the grid hides the preview. On touch the tooltip is
// click-activated and must persist until a non-day tap (see the document handler).
calEl.addEventListener("pointerleave", (e) => { if (e.pointerType === "mouse") tip.hidden = true; });
}
// ---- restore (hash from a shared link, else the last place you looked at) ----
(function restore() {
const loc = window.Thermograph.loadLastLocation();
if (!loc) return;
const saved = loadCalRange();
const sel = loc.date; // the shared selected day (from the hash or last-visited store)
if (sel && sel !== getCalSync()) {
// The selected day changed on another view → align the To-month to it. Keep the
// saved From unless the new To is at/before it, in which case pull From 2 years
// back so the window stays valid (start ≤ end). Month/year granularity only.
const d = new Date(sel + "T00:00:00");
const y = d.getFullYear(), m = d.getMonth();
rangeEnd = isoOfDate(new Date(y, m + 1, 0)); // last day of the To month
const toFirst = `${y}-${String(m + 1).padStart(2, "0")}-01`;
const savedFrom = saved && saved.start ? saved.start : null;
rangeStart = (savedFrom && toFirst > savedFrom)
? savedFrom // To after From → keep From
: isoOfDate(new Date(y - 2, m, 1)); // To ≤ From (or none) → same month, 2yr back
saveCalRange(rangeStart, rangeEnd);
setCalSync(sel);
} else if (saved) {
rangeStart = saved.start; rangeEnd = saved.end; // refresh / in-sync → keep range
}
selectLocation(loc.lat, loc.lon);
})();