// Thermograph calendar subpage — two years of daily grades as a month-grid heatmap. // Talks to the v2 API (/api/v2/calendar, /api/v2/geocode). import { loadLastLocation, saveLastLocation, locHash } from "./nav.js"; // The tooltip reads fmtTemp live, so a unit switch shows on the next hover. import { fmtTemp, onUnitChange } from "./units.js"; import { uv } from "./account.js"; // header sign-in entry + notification bell, and the API-version resolver import { TTL, chunkedFetch, prefetchViews } from "./cache.js"; import { initFindButton, setFindLabel } from "./mappicker.js"; import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, pctOrd, placeLabel, fmtPrecip, fmtPrecipTier, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd, CHUNK_MONTHS, buildChunks, clickOpensPicker, metricBuckets, distStrip, seasonFilterDropdown, seasonSummaryText, syncSeasonChecks, applySeasonMonthChange, initSeasonExpand, allMonths, monthsToMask, maskToMonths, tierKeySegs, GUIDE_LINK, weatherType as wxType, DRY_CAP } from "./shared.js"; import { initFilterSheet } from "./filtersheet.js"; // 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 }, // The unit lives in the strip's own top-right label now, so the metric name // doesn't repeat it (every other metric's title is unitless too). humid: { name: "Humid", label: "absolute humidity", fmt: fmtHumid }, wind: { name: "Wind", label: "wind speed", fmt: fmtWind }, gust: { name: "Gust", label: "wind gust", fmt: fmtWind }, }; const IC_POINTER = ``; // 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"], ]; // The shared weather summary, adapted to the calendar's compact day records // (dsr included, so a long-dry no-rain day reads "dry" rather than "clear"). const weatherType = (rec) => wxType(rec.tmax && rec.tmax.v, rec.precip && rec.precip.v, rec.dsr, rec.precip && rec.precip.c); 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 // Time-of-year filter: only months that are checked (AND whose year is checked) are // shown. Seasons are the primary, color-coded selector and months a nested suboption, // but the single source of truth is this Set of month indices (0=Jan … 11=Dec); a // season rolls up its three months. Southern-hemisphere labels flip (handled by the // shared helpers via `southern`). Persisted so a refresh keeps your selection. function loadCalMonths() { try { const m = maskToMonths(localStorage.getItem("thermograph:calMonths")); if (m && m.size) return m; } catch (e) {} return allMonths(); } let checkedMonths = loadCalMonths(); const southern = () => !!(selected && selected.lat < 0); const persistCalMonths = () => { try { localStorage.setItem("thermograph:calMonths", monthsToMask(checkedMonths)); } catch (e) {} }; 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). Defaults to January six years back → today; the // frontend splits any span into ~2-year server requests (see fetchCalendar), so a // 6-year default loads as a few chunks rather than the old server-capped last-24. const defaultCalStart = () => isoOfDate(new Date(new Date().getFullYear() - 6, 0, 1)); const defaultCalEnd = () => isoOfDate(new Date()); let rangeStart = defaultCalStart(), rangeEnd = defaultCalEnd(); // 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; } }); // Mouse only: hovering off the grid hides the preview. On touch the tooltip is // click-activated and must persist until a non-day tap (the handler above). // Registered once — #calendar persists across re-renders (only its cells are // replaced), so registering inside attachHover would stack a copy per render. calEl.addEventListener("pointerleave", (e) => { if (e.pointerType === "mouse") document.getElementById("cal-tip").hidden = true; }); // ---- 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"); initFindButton(findBtn, "Find a location", () => selected, (lat, lon) => selectLocation(lat, lon)); // ---- metric toggle ---- // An inline selector (above the grid) plus a synced copy in the filter sheet; the sheet // copy mirrors the inline 8 buttons (cloned). Either drives the shared `metric`. document.getElementById("metric-toggle-sheet").innerHTML = document.getElementById("metric-toggle").innerHTML; const syncColorButtons = () => document.querySelectorAll("#metric-toggle button, #metric-toggle-sheet button") .forEach((b) => b.classList.toggle("active", b.dataset.metric === metric)); function setColorMetric(m) { metric = m; try { localStorage.setItem("thermograph:calMetric", metric); } catch (err) {} syncColorButtons(); renderCalendar(); renderKey(); } syncColorButtons(); // reflect the restored metric on both (the HTML defaults to High) for (const id of ["metric-toggle", "metric-toggle-sheet"]) { document.getElementById(id).addEventListener("click", (e) => { const btn = e.target.closest("button[data-metric]"); if (btn) setColorMetric(btn.dataset.metric); }); } // ---- 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); }); // The totals chart now prints each category's average + range, so it follows the // °C/°F toggle (the grid itself stays tier-colored and reads temps live in tooltips). onUnitChange(() => { 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. (Seasons/months use the shared // seasonSummaryText.) 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(checkedMonths, { southern: southern() }); 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
so it needs no extra open/close wiring. const filterDropdown = (dd, label, attr, items, summary) => `
${label} ${summary}
${items.map(([val, lab, on]) => ``).join("")}
`; // 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 = () => `
Weather ${weatherSummaryText()}
Day feel, from the daily high
${FEEL_WORDS.map(([val, lab]) => ``).join("")}
Sky
${SKY_WORDS.map(([val, lab]) => ``).join("")}
`; 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 = seasonFilterDropdown(checkedMonths, { southern: southern() }) + filterDropdown("year", "Years", "year", years.map((y) => [y, y, checkedYears.has(y)]), yearSummaryText()) + weatherDropdown(); syncSeasonChecks(seasonYearEl, checkedMonths); // set the season indeterminate state } filtersEl.addEventListener("change", (e) => { const cb = e.target.closest("input[type=checkbox]"); if (!cb) return; if (applySeasonMonthChange(cb, checkedMonths)) { // a season or month toggle syncSeasonChecks(seasonYearEl, checkedMonths); persistCalMonths(); if (calSheet) calSheet.setActive(checkedMonths.size < 12); } 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(); }); // Wire the per-season "Months" expand toggles (delegated on the stable container), // and the mobile floating pill that slides the whole filter panel up as a sheet. initSeasonExpand(seasonYearEl); const calSheet = initFilterSheet({ panel: filtersEl, label: "Filters" }); calSheet.setActive(checkedMonths.size < 12); // Close any open filter dropdown when tapping outside it (native
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 ("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`; // 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); clickOpensPicker(startInput, endInput); // whole-field tap opens the month picker // 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; saveLastLocation(selected.lat, selected.lon, day); setCalSync(day); } // ---- fetch + render ---- function selectLocation(lat, lon) { selected = { lat, lon }; history.replaceState(null, "", locHash(lat, lon)); 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 = `

Grading daily weather across the range…

`; 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 endInput.value = reqEnd.slice(0, 7); document.getElementById("metric-block").hidden = false; locLabel.textContent = placeLabel(d); locLabel.hidden = false; setFindLabel(findBtn, "Change location"); 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 urls = chunks.map((ch) => ch ? uv(`calendar?${q}&start=${ch.start}&end=${ch.end}`) : uv(`calendar?${q}&months=24`)); // Bounded-parallel streaming load (see cache.js). Stale-while-revalidate // updates re-merge that chunk's days and repaint — the cell/place metadata is // identical across versions, and initOnce self-guards after the first chunk. await chunkedFetch(urls, { ttl: TTL.calendar, concurrency: CHUNK_CONCURRENCY, isCurrent: () => token === fetchToken, onResult: (i, d) => { results[i] = d; if (!d.error) { for (const x of d.days) dayMap.set(x.date, x); initOnce(d); } advance(); }, }); clearTimeout(spin); if (token !== fetchToken) return; // superseded while loading if (!data) { // every chunk failed const firstErr = results.find((r) => r && r.error); calHead.innerHTML = `

${firstErr ? firstErr.error.message : "Failed to load."}

`; return; } renderHead(chunks.length - donePrefix, false); // final head (flags any gap) prefetchViews(selected.lat, selected.lon, ["calendar"]); // warm weekly/day/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 place = placeLabel(data); const years = ((new Date(data.range.end) - new Date(data.range.start)) / 3.15576e10); let note = ""; if (remaining > 0 && loading) { note = ` · loading ${remaining} more block${remaining === 1 ? "" : "s"}…`; } else if (remaining > 0) { note = ` · ${remaining} block${remaining === 1 ? "" : "s"} failed to load`; } calHead.innerHTML = `

${place}

${data.range.start} → ${data.range.end} · ${data.days.length.toLocaleString()} days graded (~${years.toFixed(1)} yr)${note}

${IC_POINTER} Tap or click any day for its weather and grades

`; } 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; } // Time-of-year / year filters: skip months whose month or year is unchecked. if (!checkedMonths.has(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 += `
`; 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 = `${day}`; cells += rec ? `
${num}
` : `
${num}
`; } html += `

${MONTHS[m]} ${y}

SMTWTFS
${cells}
`; m++; if (m > 11) { m = 0; y++; } } calEl.innerHTML = html || `

No months match the selected months/years.

`; renderCalInsights(); renderTotals(visible); attachHover(byDate); } const FULL_MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; // Plain-language climatology insights for the currently selected month(s), built // from `data.months_climate` (per-month rain/sun frequencies + typical highs over // the full history). Includes a cross-month "Nx more likely to rain" comparison // against the driest month of the year — the numbers travelers actually plan on. function renderCalInsights() { const el = document.getElementById("cal-insights"); const mc = data && data.months_climate; const sel = mc ? [...checkedMonths].sort((a, b) => a - b) : []; // 0-based indices const entries = sel.map((mi) => mc[String(mi + 1)]).filter(Boolean); if (!entries.length) { el.hidden = true; el.innerHTML = ""; return; } const avg = (a) => (a.length ? a.reduce((s, x) => s + x, 0) / a.length : null); const tmax = avg(entries.map((e) => e.tmax_med).filter((v) => v != null)); const rainFreq = avg(entries.map((e) => e.rain_freq).filter((v) => v != null)); const sunFreq = avg(entries.map((e) => e.sun_freq).filter((v) => v != null)); const rainyDays = entries.map((e) => e.rainy_days).filter((v) => v != null) .reduce((s, x) => s + x, 0); const label = seasonSummaryText(checkedMonths, { southern: southern() }); const lines = []; if (tmax != null) { lines.push(`
  • Typical highs near ${fmtTemp(tmax, true)}.
  • `); } if (sunFreq != null) { const pct = Math.round(sunFreq * 100); const emoji = sunFreq >= 0.6 ? "☀️" : sunFreq >= 0.3 ? "⛅" : "☁️"; lines.push(`
  • Sunny about ${pct}% of days.
  • `); } if (rainFreq != null) { let emoji, advice; if (rainFreq < 0.15) { emoji = "🌤️"; advice = "mostly dry — you can probably leave the umbrella at home."; } else if (rainFreq < 0.4) { emoji = "🌦️"; advice = "pack a compact umbrella just in case."; } else { emoji = "🌧️"; advice = "bring your favorite umbrella or raincoat!"; } lines.push(`
  • About ${Math.round(rainyDays)} rainy days across ${label} — ${advice}
  • `); // Cross-month comparison: selected months vs the driest single month of the // year (skip when the whole year is selected, or the multiple is trivial). if (checkedMonths.size < 12) { let driestK = null, driestFreq = Infinity; for (const [k, e] of Object.entries(mc)) { if (e.rain_freq != null && e.rain_freq < driestFreq) { driestFreq = e.rain_freq; driestK = k; } } if (driestK && driestFreq > 0) { const mult = rainFreq / driestFreq; if (mult >= 1.5) { const dName = FULL_MONTHS[+driestK - 1]; lines.push(`
  • You're about ${mult.toFixed(mult < 10 ? 1 : 0)}× more likely to see rain than in ${dName}.
  • `); } } } } if (!lines.length) { el.hidden = true; el.innerHTML = ""; return; } el.innerHTML = `

    Historical weather for ${label}

    `; el.hidden = false; } // ---- 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); 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.n, 0); 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 = ``; const header = `
    ${head}
    ${countToggle}
    `; totEl.innerHTML = total ? `${header}\n ${distStrip(buckets, showCount)}` : `${header}

    No matching days.

    `; totEl.hidden = false; } function renderKey() { if (metric === "dsr") { const steps = [["Rain", 0], ["1 day", 1], ["4", 4], ["7", 7], ["10", 10], ["14+", 14]]; const segs = tierKeySegs(steps.map(([label, d]) => ({ color: drynessColor(d), label }))); keyEl.innerHTML = `
    Days since last rain: dry streak (caps at ${DRY_CAP} days)
    ${segs}
    ${GUIDE_LINK}`; return; } if (metric === "precip") { const rain = tierKeySegs([...SCALE_RAIN].reverse().map((t) => ({ color: PRECIP_COLORS[t.c], label: t.label }))); const dry = tierKeySegs([["≤1 day", 1], ["7", 7], ["14+", 14]].map(([label, d]) => ({ color: drynessColor(d), label }))); keyEl.innerHTML = `
    Rain intensity · light → heavy
    ${rain}
    Dry days · days since rain
    ${dry}
    ${GUIDE_LINK}`; return; } const label = (TEMP_METRIC_INFO[metric] || {}).label || "value"; // Highest tier first (Near Record hot → Near Record cold). const segs = tierKeySegs([...SCALE_TEMP].reverse().map((t) => ({ color: TIER_COLORS[t.c], label: t.label }))); keyEl.innerHTML = `
    Coloring by ${label} · low → high vs local normal
    ${segs}
    ${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 `${name}`; const pct = g.pct == null ? "" : ` · ${pctOrd(g.pct)} pct`; const catCls = key === activeKey ? "tt-cat tt-cat-active" : "tt-cat"; const style = color ? ` style="--cat:${color}"` : ""; return `${name}${fmt(g.v)}${pct}${g.g}`; }; 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, (v) => fmtPrecipTier(v, rec.precip && rec.precip.c), 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", `Dry streak${dsrStr}`]); } // active metric first, the rest keep their order rows.sort((a, b) => (b[0] === activeKey) - (a[0] === activeKey)); const wt = weatherType(rec); tip.innerHTML = `
    ${dt.toLocaleDateString(undefined, { weekday: "short", year: "numeric", month: "short", day: "numeric" })}
    ${wt.icon} ${wt.text}
    ${rows.map((r) => r[1]).join("")}
    `; 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${locHash(selected.lat, selected.lon, 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 }); }); } // ---- restore (hash from a shared link, else the last place you looked at) ---- (function restore() { const loc = 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 - 6, m, 1)); // To ≤ From (or none) → same month, 6yr 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); })();