From 3dce83880005541f23043dcf6f468630a21295ec Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Fri, 10 Jul 2026 17:29:47 -0700 Subject: [PATCH] Initial commit: app + VPS deploy pipeline --- static/app.js | 780 +++++++++++++++++++++++++++++++++++++++ static/calendar.html | 91 +++++ static/calendar.js | 847 +++++++++++++++++++++++++++++++++++++++++++ static/day.html | 54 +++ static/day.js | 215 +++++++++++ static/index.html | 57 +++ static/legend.html | 95 +++++ static/mappicker.js | 139 +++++++ static/nav.js | 123 +++++++ static/style.css | 772 +++++++++++++++++++++++++++++++++++++++ 10 files changed, 3173 insertions(+) create mode 100644 static/app.js create mode 100644 static/calendar.html create mode 100644 static/calendar.js create mode 100644 static/day.html create mode 100644 static/day.js create mode 100644 static/index.html create mode 100644 static/legend.html create mode 100644 static/mappicker.js create mode 100644 static/nav.js create mode 100644 static/style.css diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..e27d13c --- /dev/null +++ b/static/app.js @@ -0,0 +1,780 @@ +"use strict"; + +let selected = null; // {lat, lon} + +const dateInput = document.getElementById("date-input"); +const todayBtn = document.getElementById("today-btn"); +const todayISO = () => new Date().toISOString().slice(0, 10); +dateInput.value = todayISO(); +dateInput.max = dateInput.value; + +// The "Today" chip snaps the target date back to today; it stays lit while the +// target already is today, so it doubles as a "you're viewing now" indicator. +function syncTodayBtn() { + todayBtn.classList.toggle("active", dateInput.value === todayISO()); +} +syncTodayBtn(); +todayBtn.addEventListener("click", () => { + const t = todayISO(); + if (dateInput.value === t) return; + dateInput.value = t; + dateInput.max = t; + syncTodayBtn(); + if (selected) runGrade(); +}); + +function selectLocation(lat, lon) { + selected = { lat, lon }; + runGrade(); +} + +// ---- 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} Find a location`; +findBtn.addEventListener("click", () => { + window.LocationPicker.open(selected, (lat, lon) => selectLocation(lat, lon)); +}); + +dateInput.addEventListener("change", () => { syncTodayBtn(); selected && runGrade(); }); + +// ---- grade request + render ---- +const placeholder = document.getElementById("placeholder"); +const results = document.getElementById("results"); +let lastGraded = null; // most recent /api/grade response (for the PNG export) + +// Clicking a graded day (any cell or its date header) opens its full breakdown. +results.addEventListener("click", (e) => { + const row = e.target.closest(".rd-grid [data-date]"); + if (!row || !selected) return; + location.href = `day#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}&date=${row.dataset.date}`; +}); + +async function runGrade() { + if (!selected) return; + updateHash(); + placeholder.hidden = true; + results.hidden = false; + results.innerHTML = `

Fetching decades of climate history…

`; + + // Omit the date when it's today, so the URL (and cache key) matches the prefetch + // fired from the other views — a same-tab navigation then reuses the response. + const q = `lat=${selected.lat}&lon=${selected.lon}`; + const url = dateInput.value === todayISO() ? `api/grade?${q}` : `api/grade?${q}&date=${dateInput.value}`; + let data; + try { + data = await window.Thermograph.getJSON(url, window.Thermograph.TTL.grade); + } catch (err) { + results.innerHTML = `

${err.message}

`; + return; + } + render(data); +} + +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³`); +// Compact cell formatters for the dense recent/forecast table (units live in the +// column headers, so values stay short). Precip shows a dot for a dry day. +const cTemp = (v) => (v == null ? "—" : `${Math.round(v)}°`); +const cHum = (v) => (v == null ? "—" : `${Math.round(v)}%`); +const cWind = (v) => (v == null ? "—" : `${Math.round(v)}`); +const cRain = (v) => (v == null ? "—" : v > 0 ? v.toFixed(2) : "·"); + +// Monochrome inline icons (currentColor) so the UI chrome matches the dark theme +// instead of using colorful emoji. Feather-style line paths. +const IC = { + link: ``, + download: ``, + calendar: ``, +}; + +function placeLabel(data) { + return data.place || `${data.cell.center_lat.toFixed(3)}, ${data.cell.center_lon.toFixed(3)}`; +} + +function render(data) { + recentData = data; + forecastData = null; // invalidate any forecast from a previous location/date + // Reflect the chosen place beside the Find button (which becomes "Change"). + locLabel.textContent = placeLabel(data); + locLabel.hidden = false; + findBtn.innerHTML = `${window.LocationPicker.PIN_ICON} Change location`; + const c = data.climatology; + const cell = data.cell; + const yrs = c.year_range ? `${c.year_range[0]}–${c.year_range[1]}` : "—"; + const coords = `${cell.center_lat.toFixed(3)}, ${cell.center_lon.toFixed(3)}`; + + // Each card compares the target day's value to the normal (median) for that + // metric, tinted by its grade, and links to the full single-day breakdown. + const today = data.recent[0] || null; + const dayHref = `day#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}&date=${data.target_date}`; + const cmpCard = (label, key, clim, fmt) => { + const g = today ? today[key] : null; + const normalVal = clim ? fmt(clim.p50) : "—"; + const cat = g ? (TIER_COLORS[g.class] || "") : ""; + const big = g ? fmt(g.value) : normalVal; + const sub = g + ? `${g.grade} · normal ${normalVal}` + : `normal ${normalVal}`; + return ` +

${label}

+
${big}
+
${sub}
+
`; + }; + + results.innerHTML = ` +
+
+

${placeLabel(data)}

+

${data.place ? coords + " · " : ""}~${cell.area_sq_mi} sq mi cell · climatology from ${yrs} + (${c.n_samples.toLocaleString()} days in the ±7-day window)

+
+ +
+ + + + Check historical data + The last 2 years, graded day by day + + + +
${data.target_date} vs normal (±7 days) — tap a metric for the full breakdown
+
+ ${cmpCard("High", "tmax", c.tmax, fmtTemp)} + ${cmpCard("Low", "tmin", c.tmin, fmtTemp)} + ${cmpCard("Feels", "feels", c.feels, fmtTemp)} + ${cmpCard("Humidity", "humid", c.humid, fmtHumid)} + ${cmpCard("Wind", "wind", c.wind, fmtWind)} + ${cmpCard("Gust", "gust", c.gust, fmtWind)} + ${cmpCard("Precip", "precip", c.precip, fmtPrecip)} +
+
+ `; + + renderSeries(); // fills #series (chart + graded-days timeline) from recent data + fetchForecast(); // merge the 7-day forecast into the timeline when it arrives + window.Thermograph.prefetchViews(selected.lat, selected.lon, "grade"); // warm calendar/forecast + document.getElementById("btn-link").onclick = copyShareLink; + document.getElementById("btn-png").onclick = downloadChartPng; +} + +// One tinted value cell: background + value colored by the day's grade tier for +// that metric (the same diverging scale as everywhere else). Carries the date so +// tapping any cell opens that day's full breakdown. +function rdCell(g, fmt, date, isFc, isToday) { + const dd = date ? ` data-date="${date}"` : ""; + const cls = "rd-c" + (isFc ? " rd-fc" : isToday ? " rd-today" : ""); + if (!g) return ``; + const col = TIER_COLORS[g.class] || ""; + return `${fmt(g.value)}`; +} + +// Compact "graded days" table — a ROW per metric, a COLUMN per day (newest first). +// Wide runs (e.g. 2 weeks) scroll horizontally inside their own container; the +// metric-label column stays pinned. Tapping any cell/day opens that day's detail. +const RD_METRICS = [ + ["tmax", "Hi", cTemp], ["tmin", "Lo", cTemp], ["feels", "Feel", cTemp], + ["humid", "Hum", cHum], ["wind", "Wind", cWind], ["gust", "Gust", cWind], ["precip", "Rain", cRain], +]; +function daysTable(rows, todayDate) { + if (!rows.length) return ""; + const isFc = (d) => todayDate && d.date > todayDate; // future = forecast columns + const cols = `grid-template-columns: 46px repeat(${rows.length}, minmax(42px, 1fr));`; + const header = `
` + rows.map((d) => { + const dt = new Date(d.date + "T00:00:00"); + const dow = dt.toLocaleDateString(undefined, { weekday: "short" }); + const md = dt.toLocaleDateString(undefined, { month: "numeric", day: "numeric" }); + const cls = "rd-colh" + (isFc(d) ? " rd-fc" : d.date === todayDate ? " rd-today" : ""); + return `
${dow}${md}
`; + }).join(""); + const body = RD_METRICS.map(([key, label, fmt]) => + `
${label}
` + + rows.map((d) => rdCell(d[key], fmt, d.date, isFc(d), d.date === todayDate)).join("") + ).join(""); + return `
${header}${body}
`; +} + +// ---- Recent + Forecast timeline ---- +// The header normals stay fixed on the target day; the chart + graded-days table +// below show one continuous timeline: recent history through the 7-day forecast, +// chronological, opened with today at the left edge. +let recentData = null; // /grade response (past) — the base render +let forecastData = null; // /forecast response (next 7 days), fetched in the background + +const addDaysISO = (iso, n) => { + const d = new Date(iso + "T00:00:00Z"); + d.setUTCDate(d.getUTCDate() + n); + return d.toISOString().slice(0, 10); +}; + +// Newest-first overall: forecast (furthest→nearest) then past (newest→oldest). +// The forecast is only merged while it *continues* the observed run with no gap — +// so viewing a past week (whose recent days end well before today's live forecast) +// shows just that week instead of a disconnected forecast block after a date gap. +// Bounded to within 13 days of the anchored date and at most a week of forecast. +function combinedDays() { + const past = recentData ? recentData.recent : []; // newest-first; newest = target_date + const rawFut = forecastData ? forecastData.recent : []; // furthest-first (tomorrow…+7 reversed) + if (!rawFut.length || !past.length) return [...rawFut, ...past]; + + const limit = addDaysISO(recentData.target_date, 13); // forecast must fall within 13 days + let prev = past[0].date; // last observed day (the anchor) + const kept = []; + for (const f of [...rawFut].reverse()) { // walk oldest→newest + if (kept.length >= 7 || f.date > limit) break; // cap a week / 13-day window + if (f.date !== addDaysISO(prev, 1)) break; // a gap — stop merging the forecast + kept.push(f); + prev = f.date; + } + return [...kept.reverse(), ...past]; // restore furthest-first ordering +} + +async function fetchForecast() { + if (!selected) return; + const { lat, lon } = selected; + try { + const d = await window.Thermograph.getJSON(`api/v2/forecast?lat=${lat}&lon=${lon}`, window.Thermograph.TTL.forecast); + if (!selected || selected.lat !== lat || selected.lon !== lon) return; // user moved on + forecastData = d; + renderSeries(); + } catch (err) { /* forecast is a bonus — keep the recent view on failure */ } +} + +function renderSeries() { + const host = document.getElementById("series"); + if (!host || !recentData) return; + const combined = combinedDays(); // newest-first + const chartData = { ...recentData, recent: combined }; + const today = recentData.target_date; + const hasFc = combined.some((d) => d.date > today); // forecast days actually merged in + host.innerHTML = ` +
Trend vs normal${hasFc ? " · recent → forecast" : ""}
+
+ ${tierKeyHtml()} +
Daily, graded${hasFc ? " — recent & 7-day forecast" : ""}
+
← OlderNewer →
+ ${daysTable([...combined].reverse(), today) || '

Nothing to grade.

'} + `; + buildChart(chartData); + scrollTableToToday(host, today, hasFc); +} + +// Open the timeline with today's column pinned at the left edge (after the sticky +// metric labels); before the forecast loads, just show the most-recent day. +function scrollTableToToday(host, today, hasFc) { + const scroll = host.querySelector(".rd-scroll"); + if (!scroll) return; + requestAnimationFrame(() => { + const col = hasFc && scroll.querySelector(`.rd-colh[data-date="${today}"]`); + scroll.scrollLeft = col ? Math.max(0, col.offsetLeft - 46) : scroll.scrollWidth; + }); +} + +// ---- trend chart (self-contained inline SVG) ---- +// Tier css class -> literal color (mirrors style.css; needed because the chart is +// rasterized to PNG, where CSS variables aren't available). +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", + "dry": "#c9a24a", + "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", +}; + +// Percentile grade scale, low -> high. Names are deliberately *relative* to each +// place's own climate history (a percentile), not absolute temperature words like +// "hot"/"cold" — see the README design note. +const GRADE_SCALE = [ + { cls: "rec-cold", label: "Near Record", range: "<1" }, + { cls: "very-cold", label: "Very Low", range: "1–10" }, + { cls: "cold", label: "Low", range: "10–25" }, + { cls: "cool", label: "Below Normal", range: "25–40" }, + { cls: "normal", label: "Normal", range: "40–60" }, + { cls: "warm", label: "Above Normal", range: "60–75" }, + { cls: "hot", label: "High", range: "75–90" }, + { cls: "very-hot", label: "Very High", range: "90–99" }, + { cls: "rec-hot", label: "Near Record", range: ">99" }, +]; + +function tierKeyHtml() { + // Highest tier first (Near Record hot → Near Record cold). + const segs = [...GRADE_SCALE].reverse().map((t) => + `
+ + ${t.label} +
`).join(""); + return `
Grade scale · low → high vs local normal
+
${segs}
+

Full guide to metrics & grades →

`; +} + +// #rrggbb -> rgba() with alpha, so band tints derive from the same site colors. +const hexA = (hex, a) => { + let h = (hex || "").trim().replace("#", ""); + if (h.length === 3) h = h.split("").map((c) => c + c).join(""); + const n = parseInt(h || "888888", 16); + return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`; +}; + +function chartPalette() { + // Read straight from the site's CSS variables so the chart matches the rest of + // the UI and follows the light/dark theme automatically. hiBand/loBand are + // applied once per tier ring (p1-99, p10-90, p25-75, p40-60) and stack, so keep + // each layer light — the center (Normal) darkens naturally. + const css = getComputedStyle(document.documentElement); + const v = (name, f) => (css.getPropertyValue(name).trim() || f); + const dark = matchMedia("(prefers-color-scheme: dark)").matches; + const hi = v("--very-hot", "#dd6b52"); // daily-high series = warm tier red + const lo = v("--cold", "#4393c3"); // daily-low series = cool tier blue + return { + surface: v("--surface-2", dark ? "#1e242c" : "#eef1f5"), + ink: v("--text", dark ? "#e7ecf2" : "#1a2029"), + muted: v("--muted", dark ? "#9aa6b2" : "#5b6673"), + grid: v("--border", dark ? "#2a323c" : "#dde3ea"), + accent: v("--accent", "#f0803c"), + hi, lo, + // Distinct line/point accents for the three added series; the graded fan + // behind them still uses the shared diverging tier colors (same as temps). + feels: v("--accent", "#f0803c"), // "feels like" = orange accent + humid: "#2ea9bf", // humidity = cyan + wind: "#5aa9a3", // wind speed = teal + gust: "#8f86d8", // wind gust = periwinkle + precip: v("--wet-6", "#2b8cbe"), + hiBand: hexA(hi, dark ? 0.15 : 0.11), + loBand: hexA(lo, dark ? 0.17 : 0.12), + }; +} + +const W = 720, H = 340; +const PL = 46, PR = 62, plotTop = 48, plotBot = 288, xLabY = 308; +const PW = W - PL - PR; +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]); +}; + +// Read a percentile from a normals object, falling back to nearby keys so an +// older/partial API response (missing p1/p30/p70/p99) still renders a chart. +const PCT_FALLBACK = { + p1: ["p1", "p10", "min", "p50"], p10: ["p10", "p50"], + p25: ["p25", "p30", "p50"], p40: ["p40", "p30", "p50"], + p60: ["p60", "p70", "p50"], p75: ["p75", "p70", "p50"], + p90: ["p90", "p50"], p99: ["p99", "p90", "max", "p50"], +}; +const pget = (o, k) => { + for (const kk of (PCT_FALLBACK[k] || [k])) if (o && o[kk] != null) return o[kk]; + return null; +}; + +let chartDays = []; +// Which chart category is shown. Persisted so a refresh keeps your selection. +let chartMetric = localStorage.getItem("thermograph:chartMetric") || "tmax"; + +function buildChart(data) { + const wrap = document.getElementById("chart-wrap"); + const C = chartPalette(); + lastGraded = data; // the series currently drawn (for PNG export) + chartDays = [...data.recent].reverse(); // oldest -> newest + const days = chartDays, n = days.length; + if (!n) { wrap.innerHTML = ""; return; } + const xFor = (i) => (n === 1 ? PL + PW / 2 : PL + (i * PW) / (n - 1)); + + // shared: x date labels + const step = n > 9 ? 2 : 1; + let xlab = ""; + days.forEach((d, i) => { + if (i % step && i !== n - 1) return; + const dt = new Date(d.date + "T00:00:00"); + xlab += `${dt.getMonth() + 1}/${dt.getDate()}`; + }); + // shared: hover hit targets over the plot + let hits = ""; + days.forEach((_, i) => { + const w = n === 1 ? PW : PW / (n - 1); + hits += ``; + }); + + const built = chartMetric === "precip" ? precipChart(days, n, xFor, C) + : chartMetric === "dry" ? dryChart(days, n, xFor, C) + : tempChart(chartMetric, days, n, xFor, C); + + // Divider between the last observed day and the forecast, when the chart spans both. + let todayDiv = ""; + const tIdx = days.findIndex((d) => d.date === data.target_date); + if (tIdx >= 0 && tIdx < n - 1) { + const xd = ((xFor(tIdx) + xFor(tIdx + 1)) / 2).toFixed(1); + todayDiv = `` + + `forecast →`; + } + + const title = `${placeLabel(data)} · through ${data.target_date}`; + const svg = ` + + ${built.subtitle} + ${title} + ${built.content} + ${todayDiv} + ${xlab} + + ${hits} + `; + + const btn = (m, label) => ``; + wrap.innerHTML = ` +
+ ${btn("tmax", "High")}${btn("tmin", "Low")}${btn("feels", "Feels")}${btn("humid", "Humid")}${btn("wind", "Wind")}${btn("gust", "Gust")}${btn("precip", "Precip")}${btn("dry", "Dry")} +
+
${built.legend}
+ ${svg} + `; + + document.getElementById("chart-toggle").addEventListener("click", (e) => { + const b = e.target.closest("button[data-metric]"); + if (!b || b.dataset.metric === chartMetric) return; + chartMetric = b.dataset.metric; + try { localStorage.setItem("thermograph:chartMetric", chartMetric); } catch (e) {} + buildChart(data); + }); + attachChartHover(wrap); +} + +// Per-series display meta for the diverging-scale metrics. `sfx` is the unit +// appended to axis + point labels ("°"); wind carries its unit in the subtitle +// instead of on every point to keep the plot uncluttered. +const SERIES = { + tmax: (C) => ({ color: C.hi, label: "Daily high", sfx: "°" }), + tmin: (C) => ({ color: C.lo, label: "Daily low", sfx: "°" }), + feels: (C) => ({ color: C.feels, label: "Feels like", sfx: "°" }), + humid: (C) => ({ color: C.humid, label: "Absolute humidity (g/m³)", sfx: "" }), + wind: (C) => ({ color: C.wind, label: "Wind speed (mph)", sfx: "" }), + gust: (C) => ({ color: C.gust, label: "Wind gust (mph)", sfx: "" }), +}; + +// Percentile "fan" tiers — bands between successive percentile marks, colored to +// match the calendar. Temperature is diverging (cold→green→hot); precipitation +// uses the sequential rain-intensity ramp. +const TEMP_FAN = [ + ["p90", "p99", "very-hot"], ["p75", "p90", "hot"], ["p60", "p75", "warm"], + ["p40", "p60", "normal"], ["p25", "p40", "cool"], ["p10", "p25", "cold"], ["p1", "p10", "very-cold"], +]; +const RAIN_FAN = [ + ["p90", "p99", "wet-8"], ["p75", "p90", "wet-7"], ["p60", "p75", "wet-6"], + ["p40", "p60", "wet-5"], ["p25", "p40", "wet-4"], ["p10", "p25", "wet-3"], ["p1", "p10", "wet-2"], +]; + +// Shared line-series chart: an optional shaded percentile fan + dashed median, the +// value line with dots, and labeled points — so temperature, wind, humidity, +// precipitation and dry streak all read in one consistent style. +function lineChart(cfg) { + const { days, n, xFor, C, key, color, fan, hasNormals, baseZero, + getVal, getPct, dotColor, fmtAxis, fmtLabel, labelOn, + subtitle, legend, aria } = cfg; + + // y-range: the plotted values plus the fan extremes (p1/p99) when present. + const vals = []; + days.forEach((d) => { + const v = getVal(d); if (v != null && !isNaN(v)) vals.push(v); + if (hasNormals && d.normals && d.normals[key]) { + const u = pget(d.normals[key], "p99"), l = pget(d.normals[key], "p1"); + if (u != null) vals.push(u); if (l != null) vals.push(l); + } + }); + let finite = vals.filter((v) => v != null && !isNaN(v)); + if (!finite.length) finite = [0, 1]; + let lo = Math.min(...finite), hi = Math.max(...finite); + if (baseZero) lo = Math.min(lo, 0); + if (hi <= lo) hi = lo + 1; + const pad = baseZero ? Math.max((hi - lo) * 0.12, 0.01) : Math.max((hi - lo) * 0.08, 3); + hi += pad; if (!baseZero) lo -= pad; + const yT = (v) => plotBot - ((v - lo) / (hi - lo)) * (plotBot - plotTop); + + let grid = ""; + for (let t = 0; t <= 4; t++) { + const v = lo + ((hi - lo) * t) / 4, y = yT(v).toFixed(1); + grid += ``; + grid += `${fmtAxis(v)}`; + } + + // Shaded fan bands between percentile marks (the "highlights"), same as the calendar tiers. + const band = (dnKey, upKey, cls) => { + let top = "", bot = ""; + days.forEach((d, i) => { const u = d.normals && pget(d.normals[key], upKey); if (u == null) return; + top += (top ? "L" : "M") + xFor(i).toFixed(1) + " " + yT(u).toFixed(1) + " "; }); + for (let i = n - 1; i >= 0; i--) { const dd = days[i].normals && pget(days[i].normals[key], dnKey); if (dd == null) continue; + bot += "L" + xFor(i).toFixed(1) + " " + yT(dd).toFixed(1) + " "; } + return top ? `` : ""; + }; + const fanSvg = (hasNormals && fan) ? fan.map((t) => band(t[0], t[1], t[2])).join("") : ""; + + const normTrace = (pctKey) => { + let p = "", st = false; + days.forEach((d, i) => { const v = d.normals && pget(d.normals[key], pctKey); if (v == null) return; + p += (st ? "L" : "M") + xFor(i).toFixed(1) + " " + yT(v).toFixed(1) + " "; st = true; }); + return p; + }; + const valTrace = () => { + let p = "", st = false; + days.forEach((d, i) => { const v = getVal(d); if (v == null || isNaN(v)) { st = false; return; } + p += (st ? "L" : "M") + xFor(i).toFixed(1) + " " + yT(v).toFixed(1) + " "; st = true; }); + return p; + }; + const medianSvg = hasNormals + ? `` : ""; + + const dots = days.map((d, i) => { + const v = getVal(d); if (v == null || isNaN(v)) return ""; + return ``; + }).join(""); + const labels = days.map((d, i) => { + const v = getVal(d); if (v == null || isNaN(v)) return ""; + if (labelOn && !labelOn(v, d)) return ""; + const x = xFor(i).toFixed(1), y = yT(v); + const vy = (y - plotTop < 26 ? y + 15 : y - 15).toFixed(1); // flip below near the top + const pv = getPct ? getPct(d) : null; + const pct = pv == null ? "" : + `${ord(pv)}`; + return `${fmtLabel(v)}${pct}`; + }).join(""); + + const content = grid + fanSvg + medianSvg + + `` + + dots + labels; + + return { content, legend, color, subtitle, aria }; +} + +// One temperature-scale series (High/Low/Feels/Wind/Gust). +function tempChart(key, days, n, xFor, C) { + const s = SERIES[key](C); + return lineChart({ + days, n, xFor, C, key, color: s.color, fan: TEMP_FAN, hasNormals: true, baseZero: false, + getVal: (d) => (d[key] ? d[key].value : null), + getPct: (d) => (d[key] ? d[key].percentile : null), + fmtAxis: (v) => `${Math.round(v)}${s.sfx}`, + fmtLabel: (v) => `${Math.round(v)}${s.sfx}`, + legend: + `${s.label} (dashed = median)` + + `Band shaded by grade tier` + + `Points labeled value · percentile — grade from the band tier (scale below)`, + subtitle: `${s.label} — recent trend vs normal`, + aria: `Recent ${s.label.toLowerCase()} versus the normal range.`, + }); +} + +// Precipitation as a line vs its normal range — same fan/median/labels style as +// temperature, so the metrics read consistently. Rain days are labeled; dry days +// sit on the baseline. The fan is the rain-intensity tier ramp. +function precipChart(days, n, xFor, C) { + return lineChart({ + days, n, xFor, C, key: "precip", color: C.precip, fan: RAIN_FAN, hasNormals: true, baseZero: true, + getVal: (d) => (d.precip ? d.precip.value : null), + getPct: (d) => (d.precip ? d.precip.percentile : null), + dotColor: (d) => (d.precip && d.precip.value > 0 ? (TIER_COLORS[d.precip.class] || C.precip) : C.precip), + fmtAxis: (v) => `${v.toFixed(2)}"`, + fmtLabel: (v) => `${v.toFixed(2)}"`, + labelOn: (v) => v > 0, // only label rain days; dry days rest on the baseline + legend: + `Precipitation (dashed = median)` + + `Band shaded by rain-intensity tier` + + `Rain days labeled amount · percentile (rain scale below)`, + subtitle: "Precipitation — daily totals vs normal", + aria: "Daily precipitation totals versus the normal range.", + }); +} + +// "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. +// (Mirrors the calendar's dry-streak ramp.) +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); +} + +// Dry streak as a line: days since last rain, dots warming with the streak; a rain +// day drops the line to 0 with a blue dot. (No climatology fan — it's not graded.) +function dryChart(days, n, xFor, C) { + return lineChart({ + days, n, xFor, C, key: "dry", color: drynessColor(9), fan: null, hasNormals: false, baseZero: true, + getVal: (d) => (d.dsr == null ? null : d.dsr), + getPct: () => null, + dotColor: (d) => drynessColor(d.dsr), + fmtAxis: (v) => `${Math.round(v)}d`, + fmtLabel: (v) => `${Math.round(v)}`, + labelOn: (v) => v > 0, // label the streak length; rain days (0) just show the dot + legend: + `Days since last rain — dry streak` + + `Rain that day (streak = 0)`, + subtitle: "Dry streak — days since last measurable rain", + aria: "Days since last measurable rain, as a line.", + }); +} + +function attachChartHover(wrap) { + const svg = wrap.querySelector("svg"); + const tip = wrap.querySelector("#chart-tip"); + const cross = wrap.querySelector("#crosshair"); + const C = chartPalette(); + const pctLine = (label, g, unit, color) => { + if (!g) return `
${label}
`; + const pct = g.percentile == null ? "" : ` · ${ord(g.percentile)} pct`; + const gc = TIER_COLORS[g.class] || ""; + return `
${label} ${g.value}${unit}${pct} ${g.grade}
`; + }; + + // Pointer events cover mouse, touch and pen with one path, so tapping/dragging + // the chart works on phones (mousemove never fires on touchscreens). + const showTip = (r, e) => { + const d = chartDays[+r.dataset.i]; + const x = +r.getAttribute("x") + +r.getAttribute("width") / 2; + cross.setAttribute("x1", x); cross.setAttribute("x2", x); cross.setAttribute("opacity", "0.7"); + const dt = new Date(d.date + "T00:00:00"); + const nt = d.normals.tmax, ni = d.normals.tmin; + tip.innerHTML = `
${dt.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })}
+ ${pctLine("High", d.tmax, "°", C.hi)} + ${pctLine("Low", d.tmin, "°", C.lo)} + ${pctLine("Feels", d.feels, "°", C.feels)} + ${pctLine("Humidity", d.humid, " g/m³", C.humid)} + ${pctLine("Wind", d.wind, " mph", C.wind)} + ${pctLine("Gust", d.gust, " mph", C.gust)} + ${pctLine("Precip", d.precip, '"', C.precip)} +
Dry ${d.dsr == null ? "—" : d.dsr === 0 ? "rain today" : `${d.dsr} d since rain`}
+
normal ${nt ? Math.round(nt.p50) : "—"}° / ${ni ? Math.round(ni.p50) : "—"}°
`; + tip.hidden = false; + const box = wrap.getBoundingClientRect(); + let px = e.clientX - box.left + 12; + if (px > box.width - 160) px = e.clientX - box.left - 160; + tip.style.left = Math.max(4, px) + "px"; + tip.style.top = Math.max(8, e.clientY - box.top - 10) + "px"; + }; + + wrap.querySelectorAll("rect.hit").forEach((r) => { + r.addEventListener("pointermove", (e) => showTip(r, e)); + r.addEventListener("pointerdown", (e) => showTip(r, e)); + }); + const hide = () => { tip.hidden = true; cross.setAttribute("opacity", "0"); }; + svg.addEventListener("pointerleave", hide); + svg.addEventListener("pointerup", hide); +} + +// ---- share ---- +function copyShareLink() { + if (!selected) return; + const url = `${location.origin}${location.pathname}#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}&date=${dateInput.value}`; + navigator.clipboard.writeText(url).then( + () => flashBtn("btn-link", "✓ Copied!"), + () => { prompt("Copy this link:", url); } + ); +} + +const esc = (s) => String(s).replace(/&/g, "&").replace(//g, ">"); + +// Build a table of graded days as SVG markup, stacked below the chart. +function exportTableSvg(C) { + const rows = lastGraded.recent; // newest first (matches the on-screen table) + const rowH = 27, headH = 42, padB = 16; + const top = H + 10; + const cDate = PL, cPcp = PL + 150, cHigh = PL + 322, cLow = PL + 494; + const totalH = top + headH + rows.length * rowH + padB; + + const cell = (x, g, isPcp) => { + if (!g) return ``; + const color = TIER_COLORS[g.class] || C.ink; + const v = isPcp ? `${g.value.toFixed(2)}"` : `${Math.round(g.value)}°`; + return `${v}` + + `${esc(g.grade)}`; + }; + + const th = (x, t) => + `${t}`; + + let body = ""; + rows.forEach((d, i) => { + const y = top + headH + i * rowH; + const dt = new Date(d.date + "T00:00:00"); + const dstr = dt.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }); + body += `` + + (i ? `` : "") + + `${esc(dstr)}` + + cell(cPcp, d.precip, true) + cell(cHigh, d.tmax, false) + cell(cLow, d.tmin, false) + + ``; + }); + + const markup = ` + Recent days, graded — ${esc(placeLabel(lastGraded))} + ${th(cDate, "DATE")}${th(cPcp, "PRECIP")}${th(cHigh, "HIGH")}${th(cLow, "LOW")} + + ${body}`; + return { markup, totalH }; +} + +function downloadChartPng() { + const chart = document.querySelector("#chart-wrap svg"); + if (!chart || !lastGraded) return; + const C = chartPalette(); + + // Chart body minus interactive-only layers. + const clone = chart.cloneNode(true); + const cross = clone.querySelector("#crosshair"); if (cross) cross.remove(); + clone.querySelectorAll("rect.hit").forEach((r) => r.remove()); + const chartInner = clone.innerHTML; + + const { markup, totalH } = exportTableSvg(C); + const xml = `` + + `` + + chartInner + markup + ``; + + const url = URL.createObjectURL(new Blob([xml], { type: "image/svg+xml;charset=utf-8" })); + const img = new Image(); + img.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = W * 2; canvas.height = totalH * 2; + canvas.getContext("2d").drawImage(img, 0, 0); + URL.revokeObjectURL(url); + canvas.toBlob((blob) => { + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `thermograph_${dateInput.value}.png`; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + flashBtn("btn-png", "✓ Saved"); + }, "image/png"); + }; + img.src = url; +} + +function flashBtn(id, text) { + const b = document.getElementById(id); + if (!b) return; + const orig = b.innerHTML; // preserve the inline icon markup + b.textContent = text; + setTimeout(() => (b.innerHTML = orig), 1600); +} + +function updateHash() { + if (!selected) return; + history.replaceState(null, "", `#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}&date=${dateInput.value}`); + window.Thermograph.saveLastLocation(selected.lat, selected.lon, dateInput.value); +} + +// Start where you left off: an explicit link (hash) wins, otherwise the last +// place you looked at (remembered across views), otherwise the default US map. +function restoreFromHash() { + const loc = window.Thermograph.loadLastLocation(); + if (!loc) return; + if (loc.date) dateInput.value = loc.date; + syncTodayBtn(); + selectLocation(loc.lat, loc.lon); +} +restoreFromHash(); diff --git a/static/calendar.html b/static/calendar.html new file mode 100644 index 0000000..9f6e69f --- /dev/null +++ b/static/calendar.html @@ -0,0 +1,91 @@ + + + + + + Thermograph — 2-year calendar + + + + +
+
+ +
+

Thermograph · Calendar

+

Two years of daily grades vs local climate history

+
+ +
+
+ +
+
+
+ + +
+
+ + + + + + + + + + + + +
+

Search a place — or open this from a graded location on the map — to see how + every day of the last two years graded against that spot's own climate history.

+
+
+
+ +
+ + + + + + + diff --git a/static/calendar.js b/static/calendar.js new file mode 100644 index 0000000..aa5e360 --- /dev/null +++ b/static/calendar.js @@ -0,0 +1,847 @@ +"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 (felt high or low furthest from your comfort temp)", 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) => + ``; +const IC_POINTER = ``; +const WX_CLOUD = ``; +const WX_ICONS = { + sun: WX(``), + drizzle: WX(`${WX_CLOUD}`), + rain: WX(`${WX_CLOUD}`), + storm: WX(``), + snow: WX(``), +}; + +// 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 + +// The user's comfort temperature (°F). The Feels metric shows whichever apparent +// extreme — the felt high (fmax) or felt low (fmin) — sits further from this, +// so the same slider position works for "too hot" and "too cold" alike. +const COMFORT_KEY = "thermograph:comfortF"; +let comfort = Math.min(100, Math.max(0, +localStorage.getItem(COMFORT_KEY) || 65)); + +// Which side of the day the Feels metric reports for this record: the graded +// apparent high or low, whichever is further from the comfort temp. Falls back +// to the server's combined `feels` for responses cached before the split. +function feelsKey(rec) { + const hi = rec.fmax, lo = rec.fmin; + if (hi && lo) return (hi.v - comfort) >= (comfort - lo.v) ? "fmax" : "fmin"; + return hi ? "fmax" : lo ? "fmin" : "feels"; +} +const feelsPick = (rec) => rec[feelsKey(rec)] || null; + +// 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} Find a location`; +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)); + +// ---- comfort temperature slider (Feels metric only) ---- +const comfortRow = document.getElementById("comfort-row"); +const comfortInput = document.getElementById("comfort-input"); +const comfortVal = document.getElementById("comfort-val"); +comfortInput.value = comfort; +comfortVal.textContent = `${comfort}°F`; +comfortRow.hidden = metric !== "feels"; +// Live-recolor while dragging: the grades for both felt sides are already in the +// payload, so moving the slider only re-picks a side per day — no refetch. +comfortInput.addEventListener("input", () => { + comfort = +comfortInput.value; + comfortVal.textContent = `${comfort}°F`; + try { localStorage.setItem(COMFORT_KEY, String(comfort)); } catch (err) {} + renderCalendar(); +}); + +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)); + comfortRow.hidden = metric !== "feels"; + renderCalendar(); + renderKey(); +}); + +// ---- 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
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 = + 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
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`; +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; + calHead.innerHTML = `

Grading daily weather across the range…

`; + calEl.innerHTML = ""; + keyEl.innerHTML = ""; + data = null; + + 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 = d.place || `${d.cell.center_lat.toFixed(3)}, ${d.cell.center_lon.toFixed(3)}`; + locLabel.hidden = false; + findBtn.innerHTML = `${window.LocationPicker.PIN_ICON} 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 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 { + const d = await window.Thermograph.getJSON(url, window.Thermograph.TTL.calendar, true); + 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(); + }); + 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) + 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 = ` · 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) · ~${cell.area_sq_mi} sq mi cell${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; + } + // 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 += `
`; + 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 { + // Feels re-picks its side (felt high vs low) around the comfort temp. + const g = metric === "feels" ? feelsPick(rec) : 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 seasons/years.

`; + 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"); + 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 = metric === "feels" ? feelsPick(r) : 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]); + title = (TEMP_METRIC_INFO[metric] || {}).label || "value"; + } + + const total = buckets.reduce((n, b) => n + b[2], 0); + const pctText = (n) => { const r = Math.round(100 * n / total); return n > 0 && r === 0 ? "<1%" : `${r}%`; }; + 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}:`; + if (!total) { + totEl.innerHTML = `
${head}

No matching days.

`; + totEl.hidden = false; + return; + } + const bar = buckets.filter((b) => b[2] > 0).map(([label, color, n]) => + ``).join(""); + const chips = buckets.filter((b) => b[2] > 0).map(([label, color, n]) => + `
+ ${label}${pctText(n)}
`).join(""); + totEl.innerHTML = `
${head}
+
${bar}
+
${chips}
`; + totEl.hidden = false; +} + +const GUIDE_LINK = `

Full guide to metrics & grades →

`; + +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]) => + `
+ ${lab}
`).join(""); + keyEl.innerHTML = `
Days since last rain — dry streak (caps at ${DRY_CAP} days)
+
${segs}
${GUIDE_LINK}`; + return; + } + if (metric === "precip") { + const rain = [...SCALE_RAIN].reverse().map((t) => + `
+ ${t.label}
`).join(""); + const dry = [["≤1 day", 1], ["7", 7], ["14+", 14]].map(([l, d]) => + `
+ ${l}
`).join(""); + 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 = [...SCALE_TEMP].reverse().map((t) => + `
+ ${t.label}
`).join(""); + 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 effective row to bold: the metric itself, or — for Feels — + // whichever felt side (fmax/fmin) the comfort temp picked for this day. + 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 ? "" : ` · ${ord(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 === "feels" ? feelsKey(rec) : 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`; + // Both felt extremes get a row (the apparent high and low); older cached + // responses without the split fall back to the single combined Feels row. + const feelsRows = (rec.fmax || rec.fmin) + ? [["fmax", line("fmax", "Feels Hi", rec.fmax, fmtTemp, tempColor(rec.fmax))], + ["fmin", line("fmin", "Feels Lo", rec.fmin, fmtTemp, tempColor(rec.fmin))]] + : [["feels", line("feels", "Feels", rec.feels, fmtTemp, tempColor(rec.feels))]]; + const rows = [ + ["tmax", line("tmax", "High", rec.tmax, fmtTemp, tempColor(rec.tmax))], + ["tmin", line("tmin", "Low", rec.tmin, fmtTemp, tempColor(rec.tmin))], + ...feelsRows, + ["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", `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#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); +})(); diff --git a/static/day.html b/static/day.html new file mode 100644 index 0000000..a563a3b --- /dev/null +++ b/static/day.html @@ -0,0 +1,54 @@ + + + + + + Thermograph — a single day + + + + +
+
+ +
+

Thermograph · Day

+

Every percentile boundary for one day vs its ±7-day climate window

+
+ +
+
+ +
+
+
+ + +
+
+ + + +
+
+ + +
+

Pick a place and a day — or open this from a day on the map or calendar — to see + the value at every tier boundary in that day's ±7-day climate window.

+
+
+
+ + + + + + + diff --git a/static/day.js b/static/day.js new file mode 100644 index 0000000..f0e4a6c --- /dev/null +++ b/static/day.js @@ -0,0 +1,215 @@ +"use strict"; +// Thermograph single-day detail subpage — for one day + location, shows the value +// at every percentile tier boundary in that day-of-year's ±7-day climate window, +// and where the observed values land. Talks to /api/v2/day + /api/v2/geocode. +// Self-contained; re-declares the small shared tier constants (like calendar.js) +// so the page stays independent of app.js. + +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", +}; +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", +}; +const DRY_COLOR = "#c9a24a"; + +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³`); +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]); +}; + +// Color a tier the same way the calendar cell would be colored for it. +const tierColor = (c) => (c === "dry" ? DRY_COLOR : TIER_COLORS[c] || PRECIP_COLORS[c] || ""); + +// Monochrome line icons (currentColor) — no emoji, matching the site's dark look. +const WX = (paths) => + ``; +const WX_CLOUD = ``; +const WX_ICONS = { + sun: WX(``), + drizzle: WX(`${WX_CLOUD}`), + rain: WX(`${WX_CLOUD}`), + storm: WX(``), + snow: WX(``), +}; + +// Plain-language "what the day was like" descriptor from the observed values — +// mirrors the calendar tooltip. (No dry-streak here, so no-rain days read "clear".) +function weatherType(t, p) { + 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, icon; + if (wet && freezing) { sky = p >= 0.3 ? "heavy snow" : "snow"; icon = WX_ICONS.snow; } + else if (wet && p >= 1.0) { sky = "downpour"; icon = WX_ICONS.storm; } + else if (wet && p >= 0.3) { sky = "rain"; icon = WX_ICONS.rain; } + else if (wet) { sky = "light rain"; icon = WX_ICONS.drizzle; } + else { sky = "clear"; icon = WX_ICONS.sun; } + const text = !temp ? sky : wet ? `${temp}, ${sky}` : `${temp} & ${sky}`; + return { icon, text: text.charAt(0).toUpperCase() + text.slice(1) }; +} + +let selected = null; // {lat, lon} +let curDate = null; // "YYYY-MM-DD" currently shown +let latest = null; // newest date available in the record +const todayISO = () => new Date().toISOString().slice(0, 10); + +const placeholder = document.getElementById("day-placeholder"); +const dayHead = document.getElementById("day-head"); +const dayBody = document.getElementById("day-body"); +const dateInput = document.getElementById("date-input"); + +// ---- 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} Find a location`; +findBtn.addEventListener("click", () => { + window.LocationPicker.open(selected, (lat, lon) => { selected = { lat, lon }; fetchDay(); }); +}); + +// ---- date navigation ---- +dateInput.addEventListener("change", () => { if (dateInput.value) { curDate = dateInput.value; fetchDay(); } }); +document.getElementById("prev-day").addEventListener("click", () => stepDay(-1)); +document.getElementById("next-day").addEventListener("click", () => stepDay(1)); + +function stepDay(delta) { + if (!curDate) return; + const d = new Date(curDate + "T00:00:00"); + d.setDate(d.getDate() + delta); + const iso = d.toISOString().slice(0, 10); + if (iso > todayISO()) return; // don't step past today + curDate = iso; + fetchDay(); +} + +// ---- fetch + render ---- +async function fetchDay() { + if (!selected) return; + const dateQ = curDate ? `&date=${curDate}` : ""; + history.replaceState(null, "", `#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}${curDate ? `&date=${curDate}` : ""}`); + placeholder.hidden = true; + dayHead.hidden = false; + dayHead.innerHTML = `

Building the percentile breakdown…

`; + dayBody.innerHTML = ""; + let data; + try { + data = await window.Thermograph.getJSON(`api/v2/day?lat=${selected.lat}&lon=${selected.lon}${dateQ}`, window.Thermograph.TTL.day); + } catch (err) { + dayHead.innerHTML = `

${err.message}

`; + return; + } + latest = data.latest; + curDate = data.detail.date; + dateInput.value = curDate; + // Allow navigating up to today (recent days beyond the archive come from the + // forecast bundle), not just the latest fully-archived day. + dateInput.max = todayISO(); + document.getElementById("next-day").disabled = curDate >= todayISO(); + window.Thermograph.saveLastLocation(selected.lat, selected.lon, curDate); + render(data); + window.Thermograph.prefetchViews(selected.lat, selected.lon, "day"); // warm weekly/calendar/forecast +} + +function render(data) { + const d = data.detail; + const cell = data.cell; + const place = data.place || `${cell.center_lat.toFixed(3)}, ${cell.center_lon.toFixed(3)}`; + locLabel.textContent = place; + locLabel.hidden = false; + findBtn.innerHTML = `${window.LocationPicker.PIN_ICON} Change location`; + const dt = new Date(d.date + "T00:00:00"); + const nice = dt.toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" }); + const yrs = d.year_range ? `${d.year_range[0]}–${d.year_range[1]}` : "—"; + // Weather summary from the observed high + precip (omitted for days with no obs yet). + const th = d.metrics.tmax.obs ? d.metrics.tmax.obs.value : null; + const pr = d.metrics.precip.obs ? d.metrics.precip.obs.value : null; + const wt = th != null || pr != null ? weatherType(th, pr) : null; + dayHead.innerHTML = ` +

${nice}

+ ${wt ? `

${wt.icon} ${wt.text}

` : ""} +

${place} · ~${cell.area_sq_mi} sq mi cell · ±7-day window + · ${d.n_samples.toLocaleString()} days (${yrs})

`; + + dayBody.innerHTML = [ + ladderCard("Daily high", d.metrics.tmax, fmtTemp, "temp"), + ladderCard("Daily low", d.metrics.tmin, fmtTemp, "temp"), + ladderCard("Feels like", d.metrics.feels, fmtTemp, "temp"), + ladderCard("Humidity", d.metrics.humid, fmtHumid, "temp"), + ladderCard("Wind speed", d.metrics.wind, fmtWind, "temp"), + ladderCard("Wind gust", d.metrics.gust, fmtWind, "temp"), + ladderCard("Precipitation", d.metrics.precip, fmtPrecip, "precip"), + ].join(""); +} + +// One metric card: an observed summary line + the tier ladder (highest tier on +// top). The tier the observed value falls into is highlighted with its value. +function ladderCard(title, m, fmt, kind) { + if (!m || !m.ladder) return ""; + const obs = m.obs; + const activeClass = obs ? obs.class : null; + + let summary; + if (obs) { + const pct = obs.percentile == null ? "" : ` · ${ord(obs.percentile)} pct`; + summary = `${fmt(obs.value)} + ${obs.grade}${pct}`; + } else { + summary = `No observation for this day yet`; + } + + // Left: a per-metric summary. Right: the bold, right-aligned Historical Range. + const noteLeft = kind === "temp" + ? `median ${fmt(m.ladder.median)}` + : `${m.ladder.rain_days.toLocaleString()} rain days · dry ${m.ladder.dry_pct}%`; + const note = `${noteLeft}` + + `Historical Range ${fmt(m.ladder.min)}–${fmt(m.ladder.max)}`; + + const rows = m.ladder.tiers.map((t) => { + const active = t.c === activeClass ? " active" : ""; + // Dry has no rain percentile — leave that column blank and show the threshold + // as the value. Other tiers show their percentile range and value range. + let val, pct = t.range; + if (t.c === "dry") { val = t.range; pct = ""; } + else if (t.hi == null) val = `> ${fmt(t.lo)}`; // top tier: strictly beyond p99 + else if (t.lo == null) val = `< ${fmt(t.hi)}`; // bottom tier: strictly below p1 + else val = `${fmt(t.lo)}–${fmt(t.hi)}`; + const marker = t.c === activeClass && obs + ? `◀ ${fmt(obs.value)}` : ""; + return `
+ + ${t.label} + ${pct} + ${val} + ${marker} +
`; + }).join(""); + + return `
+
+

${title}

+
${summary}
+
+

${note}

+
${rows}
+
`; +} + +// ---- restore (hash from a shared link, else the last place you looked at) ---- +(function restore() { + const loc = window.Thermograph.loadLastLocation(); + if (loc) { + selected = { lat: loc.lat, lon: loc.lon }; + curDate = loc.date || todayISO(); // default to today when no date is given + fetchDay(); + } +})(); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..00b46a1 --- /dev/null +++ b/static/index.html @@ -0,0 +1,57 @@ + + + + + + Thermograph — how unusual is your weather? + + + + +
+
+ +
+

Thermograph

+

Grading today's weather against ~45 years of local climate history

+
+ +
+
+ +
+
+
+ + +
+
+ + + Cells are ~4 sq mi. What do the grades mean? → +
+
+ +
+
+

Find a location to begin.

+

Thermograph fetches decades of daily highs, lows, and precipitation + for your ~4 sq mi cell, builds a ±7-day seasonal distribution for each + day of the year, and grades recent weather by where it falls on that distribution.

+
+ +
+
+ + + + + + + diff --git a/static/legend.html b/static/legend.html new file mode 100644 index 0000000..1db1cc9 --- /dev/null +++ b/static/legend.html @@ -0,0 +1,95 @@ + + + + + + Thermograph — reading the grades + + + +
+
+ +
+

Thermograph · Guide

+

What the metrics and percentile grades mean

+
+ +
+
+ +
+
+

How the grades work

+

Every grade is relative to this place's own climate history, not an absolute + temperature. For each day Thermograph builds a ±7-day seasonal distribution from + decades of local records, then reports the percentile where the day falls in it.

+

So a 60° day can be “Above Normal” in one place or season and + “Below Normal” in another. That's why the categories read + “Above/Below Normal”, “High/Low”, and “Near Record” — + never “hot” or “cold”.

+
+ +
+

Grade scale — percentile of the local ±7-day history

+
+
+ +
+

Rain intensity — percentile among rain days

+

Precipitation is graded only across days that actually saw rain, so + “Heavy” means heavy for a rainy day here. Dry days are colored by + their dry streak instead (below).

+
+
+ +
+

The metrics

+
+
High / Low
The day's highest and lowest air temperature (°F).
+
Feels
Apparent temperature — what the air actually felt like once humidity + and wind are factored in. Every day has a felt high (heat-index territory) and a + felt low (wind-chill territory); both are shown in the day tooltip, each graded + against its own history. The Feels color uses whichever side sits further from your + comfort temperature — set with the slider on the Calendar page (0–100°F, + default 65°F) — so sweltering days grade by their felt high and bitter days by + their felt low.
+
Humid
Absolute humidity: grams of water vapor per cubic meter of air (g/m³).
+
Wind
Average sustained wind speed (mph).
+
Gust
Peak wind gust for the day (mph).
+
Precip
Total precipitation (inches), graded by intensity among rain days.
+
Dry streak
Consecutive days since the last measurable rain — the color deepens + the longer it's been dry, and resets to blue on a rain day.
+
+
+ +

← Back to Thermograph

+
+ + + + + diff --git a/static/mappicker.js b/static/mappicker.js new file mode 100644 index 0000000..edb17ee --- /dev/null +++ b/static/mappicker.js @@ -0,0 +1,139 @@ +"use strict"; +// Shared modal "location picker". Every view's Find button opens this overlay: +// a search box + a Leaflet map. Searching a place jumps the map (and a matching +// result selects it immediately); tapping anywhere on the map drops a pin the user +// can fine-tune before confirming. Picking closes the overlay and hands the chosen +// lat/lon back to the page via the onPick callback. +// +// Loaded on every page AFTER leaflet.js and BEFORE that page's own script. The map +// itself is created lazily the first time the picker opens (and reused after), so +// pages that never open it pay nothing. +(function () { + let overlay, mapEl, map, marker, pin = null, onPickCb = null; + let searchForm, searchInput, sugEl, confirmBtn, hintEl; + + const PIN_ICON = ``; + + function build() { + overlay = document.createElement("div"); + overlay.className = "mp-overlay"; + overlay.hidden = true; + overlay.innerHTML = ` + `; + document.body.appendChild(overlay); + + mapEl = overlay.querySelector(".mp-map"); + searchForm = overlay.querySelector(".mp-search"); + searchInput = searchForm.querySelector("input"); + sugEl = overlay.querySelector(".mp-sug"); + confirmBtn = overlay.querySelector(".mp-confirm"); + hintEl = overlay.querySelector(".mp-hint"); + + overlay.querySelector(".mp-close").onclick = close; + // Tapping the dimmed backdrop (outside the modal) closes the picker. + overlay.addEventListener("pointerdown", (e) => { if (e.target === overlay) close(); }); + confirmBtn.onclick = () => finish(pin); + + searchForm.addEventListener("submit", async (e) => { + e.preventDefault(); + const q = searchInput.value.trim(); + if (!q) return; + try { + const res = await fetch(`api/v2/geocode?q=${encodeURIComponent(q)}`); + const d = await res.json(); + renderSug(d.results || []); + } catch (err) { /* leave the map as the fallback way to pick */ } + }); + // Hide the suggestions when tapping elsewhere in the modal (but not the map, + // which has its own tap handler). + overlay.addEventListener("pointerdown", (e) => { + if (!e.target.closest(".mp-search")) sugEl.hidden = true; + }, true); + document.addEventListener("keydown", (e) => { + if (overlay && !overlay.hidden && e.key === "Escape") close(); + }); + } + + function renderSug(list) { + const usca = list.filter((r) => ["US", "CA"].includes(r.country_code)); + const shown = usca.length ? usca : list; + sugEl.innerHTML = ""; + if (!shown.length) { sugEl.hidden = true; return; } + shown.forEach((r) => { + const li = document.createElement("li"); + li.innerHTML = `${r.name} — ${[r.admin1, r.country].filter(Boolean).join(", ")}`; + // A picked search result is an unambiguous choice — drop the pin, recenter, + // and select it right away (the map is there for manual/fine picks). + li.onclick = () => { + sugEl.hidden = true; + searchInput.value = r.name; + setPin(r.lat, r.lon, 10); + finish({ lat: r.lat, lon: r.lon }); + }; + sugEl.appendChild(li); + }); + sugEl.hidden = false; + } + + function setPin(lat, lon, zoom) { + pin = { lat, lon }; + if (marker) marker.setLatLng([lat, lon]); + else marker = L.marker([lat, lon]).addTo(map); + map.setView([lat, lon], zoom || map.getZoom()); + confirmBtn.disabled = false; + hintEl.textContent = "Pin set — confirm below, or tap elsewhere to move it."; + } + + function finish(p) { + if (!p) return; + const cb = onPickCb; + close(); + if (cb) cb(p.lat, p.lon); + } + + function open(cur, onPick) { + if (!overlay) build(); + onPickCb = onPick; + pin = null; + confirmBtn.disabled = true; + sugEl.hidden = true; + searchInput.value = ""; + hintEl.textContent = "Search above, or tap the map to drop a pin."; + overlay.hidden = false; + + if (!map) { + map = L.map(mapEl, { worldCopyJump: true }).setView([44.5, -95], 4); + L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 18, attribution: "© OpenStreetMap contributors", + }).addTo(map); + map.on("click", (e) => setPin(e.latlng.lat, e.latlng.lng)); + } + // Leaflet measures the container on creation; it's zero-sized while hidden, so + // recalc once the modal is actually visible and (if reopening on a known spot) + // drop a pin there as the starting point. + setTimeout(() => { + map.invalidateSize(); + if (cur && cur.lat != null && cur.lon != null) setPin(cur.lat, cur.lon, 10); + searchInput.focus(); + }, 60); + } + + function close() { if (overlay) overlay.hidden = true; } + + window.LocationPicker = { open, PIN_ICON }; +})(); diff --git a/static/nav.js b/static/nav.js new file mode 100644 index 0000000..cef0f40 --- /dev/null +++ b/static/nav.js @@ -0,0 +1,123 @@ +"use strict"; +// Shared cross-view navigation + last-location memory. Loaded on all three pages +// (map, calendar, day) before that page's own script. It remembers the most +// recently selected spot in localStorage and keeps the header's view links +// (Map · Calendar · Day) pointing at that spot — so switching views, or coming +// back to the map, lands you where you were instead of an empty US-wide map. + +const LOC_KEY = "thermograph:loc"; + +function readStored() { + try { + const o = JSON.parse(localStorage.getItem(LOC_KEY)); + if (o && typeof o.lat === "number" && typeof o.lon === "number") return o; + } catch (e) {} + return null; +} + +// Where should this page start? A hash (a shared/opened link) always wins; +// otherwise fall back to the last place the user looked at. +function loadLastLocation() { + const p = new URLSearchParams(location.hash.slice(1)); + const lat = parseFloat(p.get("lat")), lon = parseFloat(p.get("lon")); + if (!isNaN(lat) && !isNaN(lon)) return { lat, lon, date: p.get("date") || null }; + return readStored(); +} + +// Remember a spot. Passing no `date` preserves whatever date was stored before, +// so hopping through the calendar (which has no date) doesn't wipe the day you +// were on elsewhere. +function saveLastLocation(lat, lon, date) { + let d = date; + if (d === undefined) { const prev = readStored(); d = prev ? prev.date : null; } + try { localStorage.setItem(LOC_KEY, JSON.stringify({ lat, lon, date: d || null })); } catch (e) {} + refreshNav(lat, lon, d); +} + +function locHash(lat, lon, date) { + let h = `#lat=${lat.toFixed(5)}&lon=${lon.toFixed(5)}`; + if (date) h += `&date=${date}`; + return h; +} + +// Point every header view-link at the current spot so a tap keeps your place. +function refreshNav(lat, lon, date) { + if (lat == null || lon == null) return; + const h = locHash(lat, lon, date); + document.querySelectorAll(".view-nav a[data-view]").forEach((a) => { + a.href = (a.dataset.base || "") + h; + }); +} + +(function initNav() { + document.querySelectorAll(".view-nav a[data-view]").forEach((a) => { + a.dataset.base = a.getAttribute("href"); + }); + const loc = loadLastLocation(); + if (loc) refreshNav(loc.lat, loc.lon, loc.date); +})(); + +// ---- shared response cache + cross-view prefetch ---- +// Cached API JSON lets the three views (a full page load each) reuse data instead +// of refetching, and the server caches upstream so a client miss is still cheap. +// The calendar cache is *persistent* (localStorage) so a page refresh — or coming +// back to a location viewed earlier — repaints instantly instead of regrading. +const TTL = { grade: 600000, forecast: 1800000, calendar: 21600000, day: 600000 }; // calendar: 6h + +// Evict the oldest N "tg:" entries from a Storage (ordered by stored timestamp) to +// make room when it hits quota. +function evictOldestCache(store, n) { + const items = []; + for (let i = 0; i < store.length; i++) { + const k = store.key(i); + if (!k || !k.startsWith("tg:")) continue; + let t = 0; + try { t = JSON.parse(store.getItem(k)).t || 0; } catch (e) {} + items.push([k, t]); + } + items.sort((a, b) => a[1] - b[1]); + for (let i = 0; i < Math.min(n, items.length); i++) store.removeItem(items[i][0]); +} + +function putCache(store, key, s) { + try { store.setItem(key, s); } + catch (e) { evictOldestCache(store, 8); try { store.setItem(key, s); } catch (e2) {} } +} + +// `persist` picks localStorage (survives tab close / navigating back to a prior +// spot); otherwise sessionStorage (per-tab). The read TTL governs freshness. +async function getJSON(url, ttlMs = 600000, persist = false) { + const store = persist ? localStorage : sessionStorage; + const key = "tg:" + url; + try { + const raw = store.getItem(key); + if (raw) { const o = JSON.parse(raw); if (Date.now() - o.t < ttlMs) return o.d; } + } catch (e) {} + const res = await fetch(url); + const d = await res.json(); + if (!res.ok) { const err = new Error(d.detail || "request failed"); err.status = res.status; throw err; } + try { + const s = JSON.stringify({ t: Date.now(), d }); + if (s.length < 1500000) putCache(store, key, s); // skip caching very large payloads + } catch (e) {} + return d; +} + +// After the landing page's own data is up, warm the OTHER two views (server- and +// session-cached, so cheap) so switching to them is instant. Deduped per spot. +let _prefetched = ""; +function prefetchViews(lat, lon, skip) { + const tag = `${lat.toFixed(4)},${lon.toFixed(4)},${skip}`; + if (_prefetched === tag) return; + _prefetched = tag; + const q = `lat=${lat}&lon=${lon}`; + const warm = () => { + if (skip !== "grade") getJSON(`api/grade?${q}`, TTL.grade).catch(() => {}); + if (skip !== "forecast") getJSON(`api/v2/forecast?${q}`, TTL.forecast).catch(() => {}); + if (skip !== "calendar") getJSON(`api/v2/calendar?${q}&months=24`, TTL.calendar, true).catch(() => {}); + }; + // Yield first so the landing view finishes rendering before we fetch the rest. + setTimeout(warm, 350); +} + +window.Thermograph = { loadLastLocation, saveLastLocation, getJSON, prefetchViews, TTL }; diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..b5edc16 --- /dev/null +++ b/static/style.css @@ -0,0 +1,772 @@ +:root { + --bg: #0f1216; + --surface: #171b21; + --surface-2: #1e242c; + --border: #2a323c; + --text: #e7ecf2; + --muted: #9aa6b2; + --accent: #f0803c; + + /* temperature grades: 9-step diverging cold -> green -> hot (colorblind-safe). + rec-* are the "Near Record" danger tiers — deliberately dark/saturated. */ + --rec-cold: #0a2f6b; + --very-cold: #2166ac; + --cold: #4393c3; + --cool: #92c5de; + --normal: #4a9d5b; /* middle of the diverging temp scale = green */ + --warm: #f6c18a; + --hot: #ef9351; + --very-hot: #dd6b52; + --rec-hot: #8f0e20; + + /* precipitation: "dry" + 9 rain-intensity tiers, light green -> teal -> dark navy */ + --dry: #c9a24a; + --wet-1: #d9f0d3; + --wet-2: #b7e2b1; + --wet-3: #8fd18f; + --wet-4: #5cba9f; + --wet-5: #35a1c0; /* middle of the rain scale */ + --wet-6: #2b8cbe; + --wet-7: #226bb3; + --wet-8: #164a97; + --wet-9: #08306b; +} + +@media (prefers-color-scheme: light) { + :root { + --bg: #f4f6f9; + --surface: #ffffff; + --surface-2: #eef1f5; + --border: #dde3ea; + --text: #1a2029; + --muted: #5b6673; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.45; +} + +header { + padding: 18px 24px; + border-bottom: 1px solid var(--border); + background: var(--surface); +} +.brand { display: flex; align-items: center; gap: 14px; } +.logo { + font-size: 30px; + color: var(--accent); + transform: rotate(90deg); + display: inline-block; +} +h1 { margin: 0; font-size: 22px; letter-spacing: -0.02em; } +.tag { margin: 2px 0 0; color: var(--muted); font-size: 13px; } + +main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; } + +.controls { margin-bottom: 18px; display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start; } +.search { position: relative; flex: 1 1 340px; display: flex; gap: 8px; } +.search input { + flex: 1; min-width: 0; padding: 11px 14px; border-radius: 10px; + border: 1px solid var(--border); background: var(--surface); color: var(--text); + font-size: 16px; /* >=16px keeps iOS Safari from zooming on focus */ +} +.search button, .date-row button { + padding: 11px 18px; border-radius: 10px; border: none; + background: var(--accent); color: #1a1206; font-weight: 600; cursor: pointer; +} +.suggestions { + position: absolute; top: 52px; left: 0; right: 78px; z-index: 500; + list-style: none; margin: 0; padding: 4px; + background: var(--surface); border: 1px solid var(--border); border-radius: 10px; + box-shadow: 0 12px 30px rgba(0,0,0,.35); +} +.suggestions li { padding: 9px 12px; border-radius: 7px; cursor: pointer; font-size: 14px; } +.suggestions li:hover { background: var(--surface-2); } +.suggestions .sub { color: var(--muted); font-size: 12px; } + +.date-row { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; } +.date-row label { font-size: 13px; color: var(--muted); display: flex; flex-direction: column; gap: 4px; } +.date-row input { + padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border); + background: var(--surface); color: var(--text); font-size: 16px; +} +/* "Today" quick-reset chip beside the target date. Outlined when the target is a + past date (a clickable "jump to today"); filled accent while already on today. */ +.date-row .today-chip { + align-self: flex-end; padding: 9px 14px; border-radius: 10px; min-height: 40px; + font-size: 13px; font-weight: 600; cursor: pointer; + background: transparent; color: var(--muted); border: 1px solid var(--border); + transition: background .12s ease, color .12s ease, border-color .12s ease; +} +.date-row .today-chip:hover { border-color: var(--accent); color: var(--text); } +.date-row .today-chip.active { + background: var(--accent); color: #1a1206; border-color: var(--accent); cursor: default; +} + +.hint { color: var(--muted); font-size: 12.5px; max-width: 220px; } + +.layout { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } +@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } } + +#map { + height: 560px; border-radius: 14px; border: 1px solid var(--border); + z-index: 1; +} + +.panel { + height: 560px; overflow-y: auto; + background: var(--surface); border: 1px solid var(--border); border-radius: 14px; + padding: 20px; +} +@media (max-width: 900px) { .panel { height: auto; } } + +.placeholder p { color: var(--text); } +.muted { color: var(--muted); font-size: 13.5px; } + +/* --- results --- */ +.loc-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 16px; } +.loc-head h2 { margin: 0 0 2px; font-size: 18px; } +.loc-head .meta { color: var(--muted); font-size: 12.5px; margin: 0; } +.share { display: flex; gap: 6px; flex-shrink: 0; flex-wrap: wrap; } +.share button, .share .share-btn { + padding: 6px 11px; border-radius: 8px; border: 1px solid var(--border); + background: var(--surface-2); color: var(--text); font-size: 12px; cursor: pointer; + white-space: nowrap; text-decoration: none; display: inline-flex; align-items: center; gap: 5px; +} + +/* Inline monochrome icons (currentColor) used in place of emoji. */ +.ic { width: 1em; height: 1em; vertical-align: -0.14em; flex-shrink: 0; } +.ic-lg { width: 26px; height: 26px; } +.wx { width: 1.15em; height: 1.15em; vertical-align: -0.2em; } +.share button:hover, .share .share-btn:hover { border-color: var(--accent); } + +/* Prominent call-to-action into the 2-year calendar — the primary next step, + so it reads unmistakably as a tappable button (full width, big touch target). */ +.cta-cal { + display: flex; align-items: center; gap: 13px; + width: 100%; margin: 0 0 20px; padding: 14px 16px; min-height: 60px; + border: none; border-radius: 13px; text-decoration: none; + color: #1a1206; background: linear-gradient(135deg, #f79556, #ea722c); + box-shadow: 0 4px 16px rgba(240, 128, 60, .3); + transition: transform .12s ease, box-shadow .12s ease, filter .12s ease; +} +.cta-cal:hover { transform: translateY(-1px); box-shadow: 0 7px 22px rgba(240, 128, 60, .42); filter: brightness(1.04); } +.cta-cal:active { transform: translateY(0); box-shadow: 0 3px 10px rgba(240, 128, 60, .34); } +.cta-cal:focus-visible { outline: 3px solid var(--text); outline-offset: 2px; } +.cta-cal-icon { font-size: 26px; line-height: 1; flex-shrink: 0; } +.cta-cal-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.cta-cal-title { font-size: 16px; font-weight: 800; letter-spacing: .01em; } +.cta-cal-sub { font-size: 12.5px; font-weight: 600; opacity: .8; } +.cta-cal-arrow { margin-left: auto; font-size: 22px; font-weight: 800; flex-shrink: 0; } + +#chart-wrap { position: relative; margin-bottom: 22px; } +#chart-wrap svg { width: 100%; height: auto; display: block; border-radius: 10px; touch-action: pan-y; } +/* High / Low / Weather metric toggle above the trend chart; active tab takes the + metric's own color so the control matches the chart it drives. */ +.chart-toggle { + display: flex; flex-wrap: wrap; border: 1px solid var(--border); border-radius: 10px; + overflow: hidden; margin-bottom: 10px; +} +.chart-toggle button { + padding: 8px 18px; background: var(--surface); color: var(--text); border: none; + cursor: pointer; font-size: 13.5px; font-weight: 600; min-height: 40px; +} +.chart-toggle button + button { border-left: 1px solid var(--border); } +.chart-toggle button.active[data-metric="tmax"] { background: var(--very-hot); color: #1a1206; } +.chart-toggle button.active[data-metric="tmin"] { background: var(--cold); color: #10222f; } +.chart-toggle button.active[data-metric="feels"] { background: var(--accent); color: #1a1206; } +.chart-toggle button.active[data-metric="humid"] { background: #2ea9bf; color: #04222a; } +.chart-toggle button.active[data-metric="wind"] { background: #5aa9a3; color: #08201e; } +.chart-toggle button.active[data-metric="gust"] { background: #8f86d8; color: #100c26; } +.chart-toggle button.active[data-metric="precip"] { background: var(--wet-6); color: #fff; } +.chart-toggle button.active[data-metric="dry"] { background: #c9a24a; color: #241a04; } +.chart-legend { + display: flex; flex-wrap: wrap; gap: 14px; font-size: 12px; color: var(--muted); margin-bottom: 8px; +} +.chart-legend span { display: inline-flex; align-items: center; gap: 6px; } +.chart-legend i { width: 12px; height: 12px; border-radius: 3px; display: inline-block; } +.chart-legend i.swatch-band { border: 1px solid; border-radius: 3px; } +.chart-legend .legend-note { font-style: italic; opacity: .85; } +.chart-tip { + position: absolute; z-index: 10; pointer-events: none; + background: var(--surface); border: 1px solid var(--border); border-radius: 9px; + padding: 8px 10px; font-size: 12px; line-height: 1.5; min-width: 148px; + box-shadow: 0 8px 24px rgba(0,0,0,.35); +} +.chart-tip .tt-date { font-weight: 700; margin-bottom: 3px; } +.chart-tip .tt-grade { color: var(--muted); } +.chart-tip .tt-norm { color: var(--muted); margin-top: 3px; border-top: 1px solid var(--border); padding-top: 3px; } + +/* Flex (not fixed 3-col) so 7 cards fill each row evenly — no lonely trailing card. */ +.normals { + display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 22px; +} +.normal-card { + flex: 1 1 130px; + background: var(--surface-2); border: 1px solid var(--border); + border-radius: 11px; padding: 12px 14px; + text-decoration: none; color: var(--text); cursor: pointer; + transition: border-color .12s ease, background .12s ease; +} +.normal-card:hover { border-color: var(--accent); background: var(--surface); } +.normal-card h3 { margin: 0 0 6px; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); } +/* Big value is the day's own reading, tinted by its grade (lifted toward the text + color so dark tiers stay legible); the sub line shows the grade + normal median. */ +.normal-card .big { font-size: 22px; font-weight: 800; color: color-mix(in oklab, var(--cat, var(--text)), var(--text) 15%); } +.normal-card .range { font-size: 12px; color: var(--muted); } +.normal-card .nc-grade { font-weight: 700; color: color-mix(in oklab, var(--cat, var(--muted)), var(--text) 28%); } + +.section-title { + font-size: 12px; text-transform: uppercase; letter-spacing: .05em; + color: var(--muted); margin: 4px 0 12px; +} + +/* grade-scale key (low -> high, relative categories) — swatch + name only; + the percentile figures live on the full guide, not here. */ +.tier-key { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: -2px 0 8px; } +.tk-seg { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; } +.tk-swatch { width: 14px; height: 10px; border-radius: 3px; flex-shrink: 0; } +.tk-label { font-weight: 600; } +.tk-note { margin: 0 0 16px; font-size: 12.5px; } + +/* --- shared header view switcher (Map · Calendar · Day) --- */ +/* The bar is split into equal, fully-clickable segments (one per page) with a + clear divider between them, so the whole width is a set of obvious tabs. */ +.view-nav { + margin-left: auto; align-self: center; display: inline-flex; gap: 0; + background: var(--surface-2); border: 1px solid var(--border); + border-radius: 11px; padding: 3px; +} +.view-nav a { + flex: 1; color: var(--muted); text-decoration: none; font-size: 14px; font-weight: 600; + padding: 8px 18px; border-radius: 8px; white-space: nowrap; position: relative; + display: inline-flex; align-items: center; justify-content: center; min-height: 38px; +} +/* Divider line between adjacent tabs. Hidden around the highlighted (active) tab + so its pill reads clean against the bar. */ +.view-nav a + a::before { + content: ""; position: absolute; left: 0; top: 16%; bottom: 16%; + width: 2px; border-radius: 2px; background: var(--border); +} +.view-nav a.active::before, +.view-nav a.active + a::before { background: transparent; } +.view-nav a:hover { color: var(--text); } +.view-nav a.active { background: var(--accent); color: #fff; } + +/* --- calendar subpage --- */ +.metric-toggle { + display: flex; flex-wrap: wrap; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; +} +.metric-toggle button { + padding: 10px 16px; background: var(--surface); color: var(--text); + border: none; cursor: pointer; font-size: 14px; min-height: 44px; white-space: nowrap; +} +.metric-toggle button + button { border-left: 1px solid var(--border); } +.metric-toggle button.active { background: var(--accent); color: #1a1206; font-weight: 600; } + +/* Metric selector block — sits below the time filters, labeling the grid it colors. */ +.metric-block { margin: 0 0 18px; } +.metric-block .cal-filter-label { display: block; margin: 0 0 8px; } + +/* Month/year range picker (calendar). 16px inputs avoid iOS zoom-on-focus. */ +.cal-range { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 12px; } +/* Range label + "up to 3 years" hint share one row spanning both input columns. */ +.cal-range-head { + grid-column: 1 / -1; + display: flex; align-items: baseline; justify-content: space-between; gap: 10px; +} +.cal-range label { + display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--muted); +} +.cal-range input { + width: 100%; font-size: 16px; padding: 8px 10px; border-radius: 8px; min-height: 40px; + border: 1px solid var(--border); background: var(--surface); color: var(--text); +} +.cal-range .hint { margin: 0; flex: 0 0 auto; max-width: none; font-size: 12px; color: var(--muted); } +/* Appears under the pickers once the user edits a date; no request is made until + it's tapped. Spans both columns so it reads as a clear confirm step. */ +.range-refresh { + grid-column: 1 / -1; margin-top: 2px; + padding: 10px 16px; min-height: 44px; border-radius: 9px; cursor: pointer; + font-size: 15px; font-weight: 700; + background: var(--accent); color: #1a1206; border: 1px solid var(--accent); +} +.range-refresh:hover { filter: brightness(1.05); } + +/* Time-filter panel: month/year range + season + year checklists, stacked in a + tidy column so nothing balloons on wide screens or leaves gaps on phones. */ +.cal-filters { + display: flex; flex-direction: column; gap: 16px; + max-width: 560px; margin: 0 0 18px; padding: 14px 16px; + border: 1px solid var(--border); border-radius: 12px; background: var(--surface); +} +/* Season + year dropdowns fill the row evenly — no dead space to their right. */ +.cal-seasonyear { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.cal-filter-group { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.cal-filter-label { + font-size: 11px; text-transform: uppercase; letter-spacing: .04em; + color: var(--muted); font-weight: 700; margin-right: 2px; +} +.cal-chip { + display: inline-flex; align-items: center; gap: 7px; cursor: pointer; user-select: none; + font-size: 13px; padding: 6px 11px; min-height: 36px; + border: 1px solid var(--border); border-radius: 9px; background: var(--surface); +} +.cal-chip input { accent-color: var(--accent); width: 16px; height: 16px; margin: 0; } +.cal-chip:has(input:checked) { + border-color: var(--accent); + background: color-mix(in oklab, var(--accent), transparent 88%); +} + +.cal-head { margin-bottom: 16px; } +.cal-head h2 { margin: 0 0 2px; font-size: 18px; } +.cal-head .meta { color: var(--muted); font-size: 12.5px; margin: 0; } +/* "loading N more blocks…" note while a long span streams in chunk by chunk. */ +.cal-loading { color: var(--accent); font-weight: 600; } +/* Nudge to interact with the grid — wording covers both tap (mobile) and click. */ +.cal-hint { + display: inline-flex; align-items: center; gap: 6px; + margin: 10px 0 0; padding: 6px 11px; font-size: 13px; font-weight: 600; + color: var(--accent); background: color-mix(in oklab, var(--accent), transparent 88%); + border: 1px solid color-mix(in oklab, var(--accent), transparent 70%); + border-radius: 999px; +} + +/* Months stacked vertically (one per row), with roomier cells. */ +.calendar { display: flex; flex-direction: column; gap: 18px; margin-bottom: 22px; align-items: stretch; } +/* Each month fills the available width (capped on wide desktops so the squares + don't balloon); the 7 columns split it evenly and cells stay square. */ +.cal-month { width: 100%; max-width: 560px; } +.cal-month h4 { margin: 0 0 6px; font-size: 14px; color: var(--text); font-weight: 700; } +.cal-dows, .cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 5px; } +.cal-dows { margin-bottom: 3px; } +.cal-dows span { font-size: 11px; color: var(--muted); text-align: center; line-height: 1; } +.cal-cell { position: relative; aspect-ratio: 1; border-radius: 6px; background: var(--surface-2); } +.cal-cell.empty { background: transparent; } +.cal-cell.filled { cursor: pointer; } +/* Day-of-month number, corner-anchored. White with a dark halo so it reads on any + tier color; on transparent (no-record) tiles fall back to a plain muted number. */ +.cal-daynum { + position: absolute; top: 2px; left: 4px; font-size: 10px; font-weight: 700; + line-height: 1; color: #fff; pointer-events: none; + text-shadow: 0 0 2px rgba(0, 0, 0, .9), 0 1px 1px rgba(0, 0, 0, .7); +} +.cal-cell.empty .cal-daynum { color: var(--muted); text-shadow: none; } +.cal-cell.filled:hover { outline: 2px solid var(--text); outline-offset: 1px; } + +/* Fixed positioning so the tooltip tracks the viewport (clientX/Y) even when the + page is scrolled far down the calendar. */ +#cal-tip { position: fixed; max-width: calc(100vw - 20px); } +/* Mini grid of the day's stats: metric name · value(+pct) · category. */ +#cal-tip .tt-grid { + display: grid; grid-template-columns: auto auto minmax(0, 1fr); + column-gap: 12px; row-gap: 3px; align-items: baseline; +} +#cal-tip .tt-name { font-weight: 700; } +#cal-tip .tt-val { white-space: nowrap; } +#cal-tip .tt-pct { color: var(--muted); } +/* Dry streak has no percentile, so its text spans the value + category columns. */ +#cal-tip .tt-span2 { grid-column: 2 / -1; } +/* The stat currently being viewed is bolded and separated from the rest, so it's + easy to match a cell's color to the stat it represents. The border spans all + three columns because it's set on every cell of the active row. */ +#cal-tip .tt-name.tt-r-active, #cal-tip .tt-val.tt-r-active { font-weight: 800; } +#cal-tip .tt-r-active { padding-bottom: 5px; border-bottom: 1px solid var(--border); } +/* Category (grade) text, right-aligned, is tinted by --cat — the color that + represents that day's value for the metric. The active calendar metric gets it + bold and near full saturation; the others are lifted toward --text so they stay + readable and read as secondary. color-mix keeps every tier legible on both. */ +#cal-tip .tt-cat { + justify-self: end; text-align: right; font-weight: 600; + color: color-mix(in oklab, var(--cat, var(--muted)), var(--text) 50%); +} +#cal-tip .tt-cat-active { + font-weight: 800; + color: color-mix(in oklab, var(--cat, var(--muted)), var(--text) 12%); +} +/* Plain-language "what the day was like" summary title, sits just under the date. */ +#cal-tip .tt-type { + font-size: 15px; font-weight: 800; margin: 2px 0 6px; + padding-bottom: 6px; border-bottom: 1px solid var(--border); +} + +/* --- single-day detail subpage --- */ +.day-weather { margin: 2px 0 4px; font-size: 16px; font-weight: 800; } +.day-nav { display: flex; align-items: flex-end; gap: 8px; } +.day-date { font-size: 13px; color: var(--muted); display: flex; flex-direction: column; gap: 4px; } +.day-date input { + padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border); + background: var(--surface); color: var(--text); font-size: 16px; +} +.day-step { + min-width: 44px; min-height: 44px; padding: 0 12px; border-radius: 10px; + border: 1px solid var(--border); background: var(--surface-2); color: var(--text); + font-size: 22px; line-height: 1; cursor: pointer; +} +.day-step:hover:not(:disabled) { border-color: var(--accent); } +.day-step:disabled { opacity: .4; cursor: default; } + +.ladder-card { + background: var(--surface); border: 1px solid var(--border); + border-radius: 13px; padding: 16px 16px 12px; margin-bottom: 16px; +} +.ladder-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; } +.ladder-head h3 { margin: 0; font-size: 15px; } +.ladder-summary { display: inline-flex; align-items: baseline; gap: 8px; } +.day-obs { + font-size: 20px; font-weight: 800; + color: color-mix(in oklab, var(--cat, var(--text)), var(--text) 12%); +} +.day-obs-meta { font-size: 12.5px; color: var(--muted); } +.ladder-note { + display: flex; justify-content: space-between; align-items: baseline; gap: 12px; + margin: 4px 0 12px; font-size: 12px; color: var(--muted); +} +.ladder-note-left { min-width: 0; } +/* Historical range: bold, pinned to the right edge of the card. */ +.ladder-range { font-weight: 700; color: var(--text); white-space: nowrap; } + +/* Tier ladder: swatch · label · percentile range · value range · observed marker. + Fixed column widths (not auto) so every row shares the same tracks — the + percentile ranges and value ranges line up down the whole ladder. The negative + margin cancels the row's own padding so the swatches sit on the same left edge + as the card title and the subheader note above. */ +.ladder { display: flex; flex-direction: column; gap: 3px; } +.ladder-row { + display: grid; grid-template-columns: 14px 128px 64px 1fr 60px; + align-items: center; column-gap: 10px; padding: 6px 8px; margin: 0 -8px; + border-radius: 8px; font-size: 13px; +} +.ladder-row.active { + background: color-mix(in oklab, var(--accent), transparent 86%); + outline: 1px solid color-mix(in oklab, var(--accent), transparent 55%); +} +.ladder-sw { width: 14px; height: 14px; border-radius: 4px; } +.ladder-label { font-weight: 700; color: color-mix(in oklab, var(--cat, var(--text)), var(--text) 22%); } +.ladder-pct { color: var(--muted); font-size: 12px; } +.ladder-val { justify-self: end; font-variant-numeric: tabular-nums; white-space: nowrap; } +/* Observed marker: stays in the rightmost column but reads from its left edge, + so the arrow sits right beside the value range it points at. */ +.ladder-mk { justify-self: start; min-width: 0; } +.ladder-mark { color: var(--accent); font-weight: 800; white-space: nowrap; font-size: 12.5px; } + +@media (max-width: 640px) { + .calendar { gap: 16px; } + .metric-toggle { width: 100%; } + /* 7 metrics wrap to ~4 per row on a phone instead of overflowing one line. */ + .metric-toggle button { flex: 1 0 25%; padding: 11px 4px; font-size: 12px; } + /* Compress the day tooltip so it fits narrow phone screens without cutting off. */ + #cal-tip { padding: 7px 9px; min-width: 0; font-size: 11.5px; line-height: 1.4; } + #cal-tip .tt-type { font-size: 13.5px; margin: 1px 0 5px; padding-bottom: 5px; } + #cal-tip .tt-grid { column-gap: 8px; row-gap: 2px; } + /* Day ladder on mobile: tighten the fixed tracks but keep every column (including + the percentile range) so the ranges still line up down the ladder. */ + .day-nav { width: 100%; } + .day-date { flex: 1; } + .ladder-row { grid-template-columns: 13px 90px 54px 1fr 48px; column-gap: 8px; font-size: 12.5px; } +} + +/* Recent | Forecast time-range switch above the trend chart. */ +.series-toggle { + display: inline-flex; border: 1px solid var(--border); border-radius: 10px; + overflow: hidden; margin-bottom: 14px; +} +.series-toggle button { + padding: 8px 22px; background: var(--surface); color: var(--text); border: none; + cursor: pointer; font-size: 13.5px; font-weight: 600; min-height: 40px; +} +.series-toggle button + button { border-left: 1px solid var(--border); } +.series-toggle button.active { background: var(--accent); color: #1a1206; } + +/* Compact "graded days" table: a ROW per metric, a COLUMN per day. Wide runs + scroll horizontally in their own container; the metric-label column is pinned. */ +.rd-scroll { position: relative; overflow-x: auto; margin: 0 0 6px; padding-bottom: 4px; } +.rd-grid { display: grid; gap: 3px; align-items: stretch; min-width: min-content; } +/* Simple scroll-orientation cue for the timeline: oldest at left, newest at right. */ +.rd-orient { + display: flex; justify-content: space-between; margin: 0 0 8px; + font-size: 11.5px; font-weight: 600; letter-spacing: .03em; color: var(--muted); +} +/* Forecast (future) columns: accent-tinted headers. The accent divider marks the + start of *today's* column (the anchored day), with the forecast to its right. */ +.rd-colh.rd-fc b { color: var(--accent); } +.rd-colh.rd-fc span { color: color-mix(in oklab, var(--accent), var(--muted) 45%); } +.rd-colh.rd-today b { color: var(--accent); } +.rd-c.rd-fc { opacity: .9; } +.rd-colh.rd-today, +.rd-c.rd-today { box-shadow: inset 2px 0 0 var(--accent); } +.rd-corner { + position: sticky; left: 0; z-index: 3; background: var(--surface); +} +.rd-mlabel { + position: sticky; left: 0; z-index: 2; background: var(--surface); + display: flex; align-items: center; padding-right: 6px; + font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; + color: var(--muted); +} +.rd-colh { + display: flex; flex-direction: column; align-items: center; justify-content: flex-end; + gap: 0; padding-bottom: 3px; font-size: 10px; color: var(--muted); + line-height: 1.15; white-space: nowrap; cursor: pointer; +} +.rd-colh b { color: var(--text); font-size: 11.5px; font-weight: 700; } +.rd-c { + display: flex; align-items: center; justify-content: center; + min-height: 30px; border-radius: 6px; font-size: 12px; font-weight: 700; + font-variant-numeric: tabular-nums; cursor: pointer; + background: color-mix(in oklab, var(--c, var(--surface-2)), transparent 74%); + color: color-mix(in oklab, var(--c, var(--text)), var(--text) 28%); +} +.rd-c[data-date]:hover { filter: brightness(1.18); } + +/* grade colors */ +.g-rec-cold { color: var(--rec-cold); } .m-rec-cold { background: var(--rec-cold); } +.g-very-cold { color: var(--very-cold); } .m-very-cold { background: var(--very-cold); } +.g-cold { color: var(--cold); } .m-cold { background: var(--cold); } +.g-cool { color: var(--cool); } .m-cool { background: var(--cool); } +.g-normal { color: var(--normal); } .m-normal { background: var(--normal); } +.g-warm { color: var(--warm); } .m-warm { background: var(--warm); } +.g-hot { color: var(--hot); } .m-hot { background: var(--hot); } +.g-very-hot { color: var(--very-hot); } .m-very-hot { background: var(--very-hot); } +.g-rec-hot { color: var(--rec-hot); } .m-rec-hot { background: var(--rec-hot); } +.g-dry { color: var(--dry); } .m-dry { background: var(--dry); } +.g-wet-1 { color: var(--wet-1); } .m-wet-1 { background: var(--wet-1); } +.g-wet-2 { color: var(--wet-2); } .m-wet-2 { background: var(--wet-2); } +.g-wet-3 { color: var(--wet-3); } .m-wet-3 { background: var(--wet-3); } +.g-wet-4 { color: var(--wet-4); } .m-wet-4 { background: var(--wet-4); } +.g-wet-5 { color: var(--wet-5); } .m-wet-5 { background: var(--wet-5); } +.g-wet-6 { color: var(--wet-6); } .m-wet-6 { background: var(--wet-6); } +.g-wet-7 { color: var(--wet-7); } .m-wet-7 { background: var(--wet-7); } +.g-wet-8 { color: var(--wet-8); } .m-wet-8 { background: var(--wet-8); } +.g-wet-9 { color: var(--wet-9); } .m-wet-9 { background: var(--wet-9); } + +/* "Near Record" danger tiers: fill the whole grade cell to make it pop. */ +.grade.danger { border-color: transparent; } +.grade.danger .lbl, .grade.danger .val, .grade.danger .band { color: #fff; } +.grade.danger .meter { background: rgba(255,255,255,.28); } +.grade.d-rec-hot { background: var(--rec-hot); } +.grade.d-rec-cold { background: var(--rec-cold); } + +.error { color: #e06a6a; font-size: 14px; } +.spinner { color: var(--muted); font-size: 14px; } +.legend { margin-top: 18px; font-size: 12px; color: var(--muted); } +.legend b { color: var(--text); font-weight: 600; } + +/* --- phones / narrow screens --- */ +@media (max-width: 640px) { + header { padding: 14px 16px; } + h1 { font-size: 19px; } + .tag { font-size: 12px; } + + /* Let the header wrap so the view switcher drops to its own full-width row + (and stays a comfortable touch target) instead of crowding the title. */ + .brand { flex-wrap: wrap; } + /* Full-width bar; each tab is an equal, fully-tappable third. */ + .view-nav { margin-left: 0; width: 100%; } + .view-nav a { min-height: 44px; padding: 8px 12px; } + + main { padding: 14px 14px 40px; } + .controls { gap: 12px; margin-bottom: 14px; } + + /* Map + panel stack (from the 900px rule); shrink the map so results are + reachable with less scrolling on a phone. */ + #map { height: 44vh; min-height: 240px; } + .panel { padding: 16px; } + + /* Give buttons a comfortable touch target (~44px). */ + .search button, .date-row button { padding: 12px 16px; min-height: 44px; } + .share button { padding: 9px 12px; font-size: 12.5px; min-height: 38px; } + + .date-row { gap: 10px; width: 100%; } + .hint { max-width: none; flex-basis: 100%; } + + .loc-head { flex-wrap: wrap; } + .share { flex-wrap: wrap; } + + /* Keep the normal cards legible when very narrow. */ + .normals { gap: 8px; } + .normal-card { padding: 10px 11px; } + .normal-card .big { font-size: 19px; } + + /* Compact table + series toggle tuned for phone width. */ + .series-toggle { width: 100%; } + .series-toggle button { flex: 1; } + /* Chart metric toggle: force an even 4-per-row grid so the buttons line up + (without this the flex-wrap sizes each to its label and the rows go ragged). */ + .chart-toggle { width: 100%; } + .chart-toggle button { flex: 1 0 25%; padding: 9px 4px; font-size: 12px; } + .rd-c { min-height: 30px; font-size: 11px; border-radius: 5px; } + .rd-mlabel { font-size: 10px; padding-right: 4px; } + .rd-colh { font-size: 9px; } + .rd-colh b { font-size: 10.5px; } +} + +/* --- Find button + current-place label (opens the modal map picker) --- */ +.find-bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; flex: 1 1 340px; } +.find-btn { + display: inline-flex; align-items: center; gap: 8px; + padding: 11px 18px; border-radius: 10px; border: none; cursor: pointer; + background: var(--accent); color: #1a1206; font-weight: 700; font-size: 15px; min-height: 44px; +} +.find-btn svg { width: 18px; height: 18px; flex-shrink: 0; } +.find-btn:hover { filter: brightness(1.05); } +.loc-label { color: var(--text); font-weight: 600; font-size: 14px; } + +/* Weekly panel is now full width (the map lives in the picker, not inline). */ +.panel-full { height: auto; overflow: visible; } + +/* Link styling for the compact legend notes + guide page. */ +.tk-note a, .hint a, .guide-back a { color: var(--accent); text-decoration: none; font-weight: 600; } +.tk-note a:hover, .hint a:hover, .guide-back a:hover { text-decoration: underline; } + +/* --- shared modal location picker --- */ +.mp-overlay { + position: fixed; inset: 0; z-index: 1000; + background: rgba(0, 0, 0, .55); + display: flex; align-items: center; justify-content: center; padding: 16px; +} +.mp-overlay[hidden] { display: none; } +.mp-modal { + width: 100%; max-width: 560px; max-height: 92vh; + display: flex; flex-direction: column; + background: var(--surface); border: 1px solid var(--border); + border-radius: 16px; overflow: hidden; box-shadow: 0 24px 60px rgba(0, 0, 0, .5); +} +.mp-head { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 16px; border-bottom: 1px solid var(--border); +} +.mp-head h2 { margin: 0; font-size: 16px; } +.mp-close { + border: none; background: transparent; color: var(--muted); cursor: pointer; + font-size: 26px; line-height: 1; padding: 0 4px; min-width: 40px; min-height: 40px; +} +.mp-close:hover { color: var(--text); } +.mp-search { position: relative; display: flex; gap: 8px; padding: 12px 16px 8px; } +.mp-search input { + flex: 1; min-width: 0; padding: 11px 14px; border-radius: 10px; + border: 1px solid var(--border); background: var(--bg); color: var(--text); font-size: 16px; +} +.mp-search button { + padding: 11px 16px; border-radius: 10px; border: none; min-height: 44px; cursor: pointer; + background: var(--accent); color: #1a1206; font-weight: 600; +} +.mp-sug { + position: absolute; top: 100%; left: 16px; right: 16px; z-index: 5; + list-style: none; margin: 4px 0 0; padding: 4px; max-height: 240px; overflow-y: auto; + background: var(--surface); border: 1px solid var(--border); border-radius: 10px; + box-shadow: 0 12px 30px rgba(0, 0, 0, .4); +} +.mp-sug li { padding: 10px 12px; border-radius: 7px; cursor: pointer; font-size: 14px; } +.mp-sug li:hover { background: var(--surface-2); } +.mp-sug .sub { color: var(--muted); font-size: 12px; } +.mp-map { + flex: 1 1 auto; min-height: 300px; margin: 4px 16px; z-index: 0; + border-radius: 12px; overflow: hidden; border: 1px solid var(--border); +} +.mp-foot { + display: flex; align-items: center; justify-content: space-between; gap: 12px; + padding: 12px 16px 16px; flex-wrap: wrap; +} +.mp-hint { color: var(--muted); font-size: 12.5px; flex: 1 1 auto; min-width: 0; } +.mp-confirm { + padding: 11px 18px; border-radius: 10px; border: none; min-height: 44px; cursor: pointer; + background: var(--accent); color: #1a1206; font-weight: 700; +} +.mp-confirm:disabled { opacity: .45; cursor: default; } + +/* --- calendar Season/Year filters as compact dropdowns (native
) --- */ +.cal-dd { position: relative; min-width: 0; } +.cal-dd summary { + display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; + list-style: none; padding: 8px 12px; min-height: 40px; + border: 1px solid var(--border); border-radius: 9px; background: var(--surface); +} +.cal-dd summary::-webkit-details-marker { display: none; } +.cal-dd summary .cal-filter-label { flex: 0 0 auto; } +.cal-dd[open] summary { border-color: var(--accent); } +/* Summary value truncates rather than wrapping to a second line in the narrow cell. */ +.cal-dd-sum { + flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + font-size: 13px; color: var(--text); font-weight: 600; +} +/* Push the caret to the right edge so summaries read cleanly at full width. */ +.cal-dd-caret { margin-left: auto; color: var(--muted); font-size: 11px; transition: transform .15s ease; } +.cal-dd[open] .cal-dd-caret { transform: rotate(180deg); } +.cal-dd-panel { + position: absolute; top: calc(100% + 6px); left: 0; z-index: 20; min-width: 168px; + display: flex; flex-direction: column; gap: 2px; padding: 6px; + background: var(--surface); border: 1px solid var(--border); border-radius: 10px; + box-shadow: 0 12px 30px rgba(0, 0, 0, .35); +} +.cal-dd-opt { + display: flex; align-items: center; gap: 9px; cursor: pointer; + font-size: 14px; padding: 8px 10px; border-radius: 7px; min-height: 40px; +} +.cal-dd-opt:hover { background: var(--surface-2); } +.cal-dd-opt input { accent-color: var(--accent); width: 17px; height: 17px; margin: 0; } +/* Group headings inside a dropdown panel (the weather filter's Feel / Sky halves). */ +.cal-dd-group { + font-size: 11px; text-transform: uppercase; letter-spacing: .04em; + color: var(--muted); font-weight: 700; padding: 8px 10px 2px; +} +/* The weather filter spans the full filter row beneath Seasons + Years, and its + panel is two columns so 13 checkboxes don't make a skyscraper on phones. */ +.cal-weather { grid-column: 1 / -1; } +.cal-weather .cal-dd-panel { + left: 0; right: 0; display: grid; grid-template-columns: 1fr 1fr; column-gap: 6px; +} +.cal-weather .cal-dd-group { grid-column: 1 / -1; } + +/* Comfort temperature slider (Feels metric). 44px-tall slider = full touch target. */ +.comfort-row { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 12px; margin: 10px 0 0; } +.comfort-row[hidden] { display: none; } /* display:flex would defeat the hidden attr */ +.comfort-row label { font-size: 13px; color: var(--muted); flex: 0 0 auto; } +.comfort-row label b { color: var(--text); font-size: 14px; } +.comfort-row input[type="range"] { + flex: 1 1 180px; min-width: 150px; height: 44px; margin: 0; + accent-color: var(--accent); background: transparent; +} +.comfort-row .hint { flex: 1 1 100%; margin: 0; font-size: 12px; color: var(--muted); } + +/* Category totals above the grid: stacked share bar + per-category chips. */ +.cal-totals { max-width: 560px; margin: 0 0 18px; } +.ct-bar { + display: flex; height: 14px; border-radius: 7px; overflow: hidden; + margin: 6px 0 8px; background: var(--surface-2); +} +.ct-bar span { display: block; height: 100%; } +.ct-pct { color: var(--muted); font-size: 12px; } + +/* Days filtered out by the weather filter fade back so matches pop. */ +.cal-cell.dim { opacity: .15; } + +/* --- guide / legend page --- */ +.guide { max-width: 760px; } +.guide-block { margin: 0 0 26px; } +.guide-block h2 { font-size: 16px; margin: 0 0 10px; } +.guide-block p { font-size: 14px; margin: 0 0 10px; } +.guide-scale { display: flex; flex-wrap: wrap; gap: 8px 18px; } +.guide-seg { display: inline-flex; align-items: center; gap: 8px; font-size: 13.5px; } +.guide-sw { width: 22px; height: 14px; border-radius: 4px; flex-shrink: 0; } +.guide-lbl { font-weight: 600; } +.guide-rng { color: var(--muted); font-size: 12px; } +.guide-metrics { display: grid; grid-template-columns: max-content 1fr; gap: 8px 18px; margin: 0; } +.guide-metrics dt { font-weight: 700; color: var(--text); } +.guide-metrics dd { margin: 0; color: var(--muted); font-size: 14px; } +.guide-back { margin-top: 8px; } + +@media (max-width: 640px) { + .mp-overlay { padding: 0; } + .mp-modal { max-width: none; max-height: 100vh; height: 100vh; border-radius: 0; border: none; } + .find-bar { width: 100%; } + .guide-metrics { grid-template-columns: 1fr; gap: 2px 0; } + .guide-metrics dt { margin-top: 10px; } +}