857 lines
43 KiB
JavaScript
857 lines
43 KiB
JavaScript
"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: "1–10" },
|
||
{ c: "cold", label: "Low", range: "10–25" },
|
||
{ c: "cool", label: "Below Normal", range: "25–40" },
|
||
{ c: "normal", label: "Normal", range: "40–60" },
|
||
{ c: "warm", label: "Above Normal", range: "60–75" },
|
||
{ c: "hot", label: "High", range: "75–90" },
|
||
{ c: "very-hot", label: "Very High", range: "90–99" },
|
||
{ 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: "1–10" },
|
||
{ c: "wet-3", label: "Light", range: "10–25" },
|
||
{ c: "wet-4", label: "Light–Mod", range: "25–40" },
|
||
{ c: "wet-5", label: "Moderate", range: "40–60" },
|
||
{ c: "wet-6", label: "Mod–Heavy", range: "60–75" },
|
||
{ c: "wet-7", label: "Heavy", range: "75–90" },
|
||
{ c: "wet-8", label: "Very Heavy", range: "90–99" },
|
||
{ c: "wet-9", label: "Extreme", range: ">99" },
|
||
];
|
||
|
||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||
const fmtTemp = (v) => (v == null ? "—" : `${Math.round(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
|
||
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],
|
||
["1–3 dry", drynessColor(2), (d) => d >= 1 && d <= 3],
|
||
["4–6 dry", drynessColor(5), (d) => d >= 4 && d <= 6],
|
||
["7–13 dry", drynessColor(10), (d) => d >= 7 && d <= 13],
|
||
["14+ dry", drynessColor(14), (d) => d >= 14],
|
||
];
|
||
const vals = days.map((r) => r.dsr).filter((d) => d != null);
|
||
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 & 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);
|
||
})();
|