Initial commit: app +

VPS deploy pipeline
This commit is contained in:
Emi Griffith 2026-07-10 17:29:47 -07:00
commit 3dce838800
10 changed files with 3173 additions and 0 deletions

780
static/app.js Normal file
View file

@ -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} <span>Find a location</span>`;
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 = `<p class="spinner">Fetching decades of climate history…</p>`;
// 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 = `<p class="error">${err.message}</p>`;
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: `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`,
download: `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`,
calendar: `<svg class="ic ic-lg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`,
};
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} <span>Change location</span>`;
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
? `<span class="nc-grade" style="--cat:${cat}">${g.grade}</span> · normal ${normalVal}`
: `normal ${normalVal}`;
return `<a class="normal-card" href="${dayHref}">
<h3>${label}</h3>
<div class="big"${g ? ` style="--cat:${cat}"` : ""}>${big}</div>
<div class="range">${sub}</div>
</a>`;
};
results.innerHTML = `
<div class="loc-head">
<div class="loc-title">
<h2>${placeLabel(data)}</h2>
<p class="meta">${data.place ? coords + " · " : ""}~${cell.area_sq_mi} sq mi cell · climatology from ${yrs}
(${c.n_samples.toLocaleString()} days in the ±7-day window)</p>
</div>
<div class="share">
<button id="btn-link" title="Copy a link back to this exact view">${IC.link} Copy link</button>
<button id="btn-png" title="Download the trend chart as an image">${IC.download} Chart</button>
</div>
</div>
<a class="cta-cal" href="calendar#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}">
<span class="cta-cal-icon" aria-hidden="true">${IC.calendar}</span>
<span class="cta-cal-text">
<span class="cta-cal-title">Check historical data</span>
<span class="cta-cal-sub">The last 2 years, graded day by day</span>
</span>
<span class="cta-cal-arrow" aria-hidden="true"></span>
</a>
<div class="section-title">${data.target_date} vs normal (±7&nbsp;days) tap a metric for the full breakdown</div>
<div class="normals">
${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)}
</div>
<div id="series"></div>
`;
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 `<span class="${cls}"${dd}>—</span>`;
const col = TIER_COLORS[g.class] || "";
return `<span class="${cls}"${dd} style="--c:${col}">${fmt(g.value)}</span>`;
}
// 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 = `<div class="rd-corner"></div>` + 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 `<div class="${cls}" data-date="${d.date}" title="See the full breakdown for this day"><b>${dow}</b><span>${md}</span></div>`;
}).join("");
const body = RD_METRICS.map(([key, label, fmt]) =>
`<div class="rd-mlabel">${label}</div>` +
rows.map((d) => rdCell(d[key], fmt, d.date, isFc(d), d.date === todayDate)).join("")
).join("");
return `<div class="rd-scroll"><div class="rd-grid" style="${cols}">${header}${body}</div></div>`;
}
// ---- 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 = `
<div class="section-title">Trend vs normal${hasFc ? " · recent → forecast" : ""}</div>
<div id="chart-wrap"></div>
${tierKeyHtml()}
<div class="section-title">Daily, graded${hasFc ? " — recent &amp; 7-day forecast" : ""}</div>
<div class="rd-orient"><span> Older</span><span>Newer </span></div>
${daysTable([...combined].reverse(), today) || '<p class="muted">Nothing to grade.</p>'}
`;
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: "110" },
{ cls: "cold", label: "Low", range: "1025" },
{ cls: "cool", label: "Below Normal", range: "2540" },
{ cls: "normal", label: "Normal", range: "4060" },
{ cls: "warm", label: "Above Normal", range: "6075" },
{ cls: "hot", label: "High", range: "7590" },
{ cls: "very-hot", label: "Very High", range: "9099" },
{ 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) =>
`<div class="tk-seg">
<span class="tk-swatch" style="background:${TIER_COLORS[t.cls]}"></span>
<span class="tk-label">${t.label}</span>
</div>`).join("");
return `<div class="section-title">Grade scale · low → high vs local normal</div>
<div class="tier-key">${segs}</div>
<p class="muted tk-note"><a href="legend">Full guide to metrics &amp; grades </a></p>`;
}
// #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 += `<text x="${xFor(i).toFixed(1)}" y="${xLabY}" text-anchor="middle" font-size="10.5" fill="${C.muted}">${dt.getMonth() + 1}/${dt.getDate()}</text>`;
});
// shared: hover hit targets over the plot
let hits = "";
days.forEach((_, i) => {
const w = n === 1 ? PW : PW / (n - 1);
hits += `<rect class="hit" data-i="${i}" x="${(xFor(i) - w / 2).toFixed(1)}" y="${plotTop}" width="${w.toFixed(1)}" height="${plotBot - plotTop}" fill="transparent"/>`;
});
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 = `<line x1="${xd}" y1="${plotTop}" x2="${xd}" y2="${plotBot}" stroke="${C.accent}" stroke-width="1.2" stroke-dasharray="4 3" opacity="0.85"/>`
+ `<text x="${xd}" y="${plotTop - 3}" text-anchor="middle" font-size="9" font-weight="700" fill="${C.accent}">forecast →</text>`;
}
const title = `${placeLabel(data)} · through ${data.target_date}`;
const svg = `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" font-family="system-ui, -apple-system, Segoe UI, sans-serif" role="img" aria-label="${built.aria}">
<rect x="0" y="0" width="${W}" height="${H}" fill="${C.surface}"/>
<text x="${PL - 8}" y="24" font-size="12.5" font-weight="700" fill="${built.color}">${built.subtitle}</text>
<text x="${W - PR}" y="24" text-anchor="end" font-size="11" fill="${C.muted}">${title}</text>
${built.content}
${todayDiv}
${xlab}
<line id="crosshair" x1="0" y1="${plotTop}" x2="0" y2="${plotBot}" stroke="${C.muted}" stroke-width="1" stroke-dasharray="3 3" opacity="0"/>
${hits}
</svg>`;
const btn = (m, label) => `<button type="button" data-metric="${m}"${chartMetric === m ? ' class="active"' : ""}>${label}</button>`;
wrap.innerHTML = `
<div class="chart-toggle" id="chart-toggle" role="group" aria-label="Chart metric">
${btn("tmax", "High")}${btn("tmin", "Low")}${btn("feels", "Feels")}${btn("humid", "Humid")}${btn("wind", "Wind")}${btn("gust", "Gust")}${btn("precip", "Precip")}${btn("dry", "Dry")}
</div>
<div class="chart-legend">${built.legend}</div>
${svg}
<div id="chart-tip" class="chart-tip" hidden></div>`;
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 += `<line x1="${PL}" y1="${y}" x2="${W - PR}" y2="${y}" stroke="${C.grid}" stroke-width="1"/>`;
grid += `<text x="${PL - 8}" y="${+y + 4}" text-anchor="end" font-size="11" fill="${C.muted}">${fmtAxis(v)}</text>`;
}
// 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 ? `<path d="${top + bot}Z" fill="${hexA(TIER_COLORS[cls], 0.4)}"/>` : "";
};
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
? `<path d="${normTrace("p50")}" fill="none" stroke="${color}" stroke-width="1.2" stroke-dasharray="4 4" opacity="0.55"/>` : "";
const dots = days.map((d, i) => {
const v = getVal(d); if (v == null || isNaN(v)) return "";
return `<circle cx="${xFor(i).toFixed(1)}" cy="${yT(v).toFixed(1)}" r="3.4" fill="${dotColor ? dotColor(d) : color}" stroke="${C.surface}" stroke-width="1.6"/>`;
}).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 ? "" :
`<tspan x="${x}" dy="9" font-size="7.5" font-weight="600" fill="${C.muted}">${ord(pv)}</tspan>`;
return `<text x="${x}" y="${vy}" text-anchor="middle" font-size="8.5" font-weight="700" fill="${C.ink}" stroke="${C.surface}" stroke-width="2.4" paint-order="stroke" stroke-linejoin="round">${fmtLabel(v)}${pct}</text>`;
}).join("");
const content = grid + fanSvg + medianSvg +
`<path d="${valTrace()}" fill="none" stroke="${color}" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round"/>` +
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:
`<span><i style="background:${s.color}"></i>${s.label} (dashed = median)</span>` +
`<span><i class="swatch-band" style="background:${hexA(TIER_COLORS.normal, 0.5)};border-color:${TIER_COLORS.normal}"></i>Band shaded by grade tier</span>` +
`<span class="legend-note">Points labeled value · percentile — grade from the band tier (scale below)</span>`,
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:
`<span><i style="background:${C.precip}"></i>Precipitation (dashed = median)</span>` +
`<span><i class="swatch-band" style="background:${hexA(TIER_COLORS["wet-6"], 0.5)};border-color:${TIER_COLORS["wet-6"]}"></i>Band shaded by rain-intensity tier</span>` +
`<span class="legend-note">Rain days labeled amount · percentile (rain scale below)</span>`,
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:
`<span><i style="background:${drynessColor(9)}"></i>Days since last rain — dry streak</span>` +
`<span><i style="background:${drynessColor(0)}"></i>Rain that day (streak = 0)</span>`,
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 `<div><b style="color:${color}">${label}</b> —</div>`;
const pct = g.percentile == null ? "" : ` · ${ord(g.percentile)} pct`;
const gc = TIER_COLORS[g.class] || "";
return `<div><b style="color:${color}">${label}</b> ${g.value}${unit}${pct} <span class="tt-grade" style="color:${gc}">${g.grade}</span></div>`;
};
// 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 = `<div class="tt-date">${dt.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })}</div>
${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)}
<div><b>Dry</b> ${d.dsr == null ? "" : d.dsr === 0 ? "rain today" : `${d.dsr} d since rain`}</div>
<div class="tt-norm">normal ${nt ? Math.round(nt.p50) : "—"}° / ${ni ? Math.round(ni.p50) : "—"}°</div>`;
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// 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 `<text x="${x}" y="0" font-size="12" fill="${C.muted}">—</text>`;
const color = TIER_COLORS[g.class] || C.ink;
const v = isPcp ? `${g.value.toFixed(2)}"` : `${Math.round(g.value)}°`;
return `<text x="${x}" y="0"><tspan font-size="13" font-weight="700" fill="${C.ink}">${v}</tspan>` +
`<tspan dx="7" font-size="11" font-weight="600" fill="${color}">${esc(g.grade)}</tspan></text>`;
};
const th = (x, t) =>
`<text x="${x}" y="${top + 30}" font-size="10.5" font-weight="700" letter-spacing="0.05em" fill="${C.muted}">${t}</text>`;
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 += `<g transform="translate(0 ${y})">` +
(i ? `<line x1="${PL}" y1="-7" x2="${W - PR}" y2="-7" stroke="${C.grid}" stroke-width="1"/>` : "") +
`<text x="${cDate}" y="0" font-size="12.5" fill="${C.ink}">${esc(dstr)}</text>` +
cell(cPcp, d.precip, true) + cell(cHigh, d.tmax, false) + cell(cLow, d.tmin, false) +
`</g>`;
});
const markup = `
<text x="${PL}" y="${top + 8}" font-size="13" font-weight="700" fill="${C.ink}">Recent days, graded ${esc(placeLabel(lastGraded))}</text>
${th(cDate, "DATE")}${th(cPcp, "PRECIP")}${th(cHigh, "HIGH")}${th(cLow, "LOW")}
<line x1="${PL}" y1="${top + headH - 9}" x2="${W - PR}" y2="${top + headH - 9}" stroke="${C.grid}" stroke-width="1.5"/>
${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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${totalH}" width="${W * 2}" height="${totalH * 2}" font-family="system-ui, -apple-system, Segoe UI, sans-serif">` +
`<rect x="0" y="0" width="${W}" height="${totalH}" fill="${C.surface}"/>` +
chartInner + markup + `</svg>`;
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();

91
static/calendar.html Normal file
View file

@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Thermograph — 2-year calendar</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header>
<div class="brand">
<span class="logo"></span>
<div>
<h1>Thermograph · Calendar</h1>
<p class="tag">Two years of daily grades vs local climate history</p>
</div>
<nav class="view-nav" aria-label="Views">
<a href="./" data-view="map">Weekly</a>
<a href="calendar" data-view="calendar" class="active">Calendar</a>
<a href="day" data-view="day">Day</a>
</nav>
</div>
</header>
<main>
<section class="controls">
<div class="find-bar">
<button type="button" id="find-btn" class="find-btn"></button>
<span id="loc-label" class="loc-label" hidden></span>
</div>
</section>
<div id="cal-head" class="cal-head" hidden></div>
<!-- Time filters: month/year range + season + year, each a compact dropdown. -->
<div id="cal-filters" class="cal-filters" hidden>
<div class="cal-range" id="cal-range">
<div class="cal-range-head">
<span class="cal-filter-label">Range</span>
<span class="hint">longer spans load 2 years at a time</span>
</div>
<label>From <input id="range-start" type="month" /></label>
<label>To <input id="range-end" type="month" /></label>
<button type="button" id="range-refresh" class="range-refresh" hidden>Refresh dates ↻</button>
</div>
<div id="cal-seasonyear" class="cal-seasonyear"></div>
</div>
<!-- Metric selector sits below the time filters, right above the grid it colors. -->
<div id="metric-block" class="metric-block" hidden>
<span class="cal-filter-label">Color the calendar by</span>
<div class="metric-toggle" id="metric-toggle" role="group" aria-label="Color the calendar by">
<button type="button" data-metric="tmax" class="active">High</button>
<button type="button" data-metric="tmin">Low</button>
<button type="button" data-metric="feels">Feels</button>
<button type="button" data-metric="humid">Humid</button>
<button type="button" data-metric="wind">Wind</button>
<button type="button" data-metric="gust">Gust</button>
<button type="button" data-metric="precip">Precip</button>
<button type="button" data-metric="dsr">Dry streak</button>
</div>
<!-- Shown only for the Feels metric: the comfort temperature the felt
high/low are measured against (whichever is further away colors the day). -->
<div id="comfort-row" class="comfort-row" hidden>
<label for="comfort-input">Comfort temp <b id="comfort-val"></b></label>
<input id="comfort-input" type="range" min="0" max="100" step="1" />
<span class="hint">Days are colored by the felt high or felt low — whichever
is further from your comfort temperature.</span>
</div>
</div>
<!-- Category totals for the current metric across the shown (and weather-
filtered) days: a stacked share bar + per-category percentages. -->
<div id="cal-totals" class="cal-totals" hidden></div>
<div class="placeholder" id="cal-placeholder">
<p>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.</p>
</div>
<div id="calendar" class="calendar"></div>
<div id="cal-key"></div>
<div id="cal-tip" class="chart-tip" hidden></div>
</main>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="nav.js"></script>
<script src="mappicker.js"></script>
<script src="calendar.js"></script>
</body>
</html>

847
static/calendar.js Normal file
View file

@ -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: "110" },
{ c: "cold", label: "Low", range: "1025" },
{ c: "cool", label: "Below Normal", range: "2540" },
{ c: "normal", label: "Normal", range: "4060" },
{ c: "warm", label: "Above Normal", range: "6075" },
{ c: "hot", label: "High", range: "7590" },
{ c: "very-hot", label: "Very High", range: "9099" },
{ c: "rec-hot", label: "Near Record", range: ">99" },
];
// Rain-intensity tiers by rain-day percentile (dry days are shown via dry streak).
const SCALE_RAIN = [
{ c: "wet-1", label: "Trace", range: "<1" },
{ c: "wet-2", label: "Very Light", range: "110" },
{ c: "wet-3", label: "Light", range: "1025" },
{ c: "wet-4", label: "LightMod", range: "2540" },
{ c: "wet-5", label: "Moderate", range: "4060" },
{ c: "wet-6", label: "ModHeavy", range: "6075" },
{ c: "wet-7", label: "Heavy", range: "7590" },
{ c: "wet-8", label: "Very Heavy", range: "9099" },
{ c: "wet-9", label: "Extreme", range: ">99" },
];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
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) =>
`<svg class="wx" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
const IC_POINTER = `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/></svg>`;
const WX_CLOUD = `<path d="M20 16.6A5 5 0 0 0 18 7h-1.26A8 8 0 1 0 4 15.25"/>`;
const WX_ICONS = {
sun: WX(`<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.2" y1="4.2" x2="5.6" y2="5.6"/><line x1="18.4" y1="18.4" x2="19.8" y2="19.8"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.2" y1="19.8" x2="5.6" y2="18.4"/><line x1="18.4" y1="5.6" x2="19.8" y2="4.2"/>`),
drizzle: WX(`${WX_CLOUD}<line x1="8" y1="19" x2="8" y2="21"/><line x1="16" y1="19" x2="16" y2="21"/><line x1="12" y1="20" x2="12" y2="22"/>`),
rain: WX(`${WX_CLOUD}<line x1="8" y1="19" x2="8" y2="22"/><line x1="16" y1="19" x2="16" y2="22"/><line x1="12" y1="19" x2="12" y2="23"/>`),
storm: WX(`<path d="M19 16.9A5 5 0 0 0 18 7h-1.26a8 8 0 1 0-11.62 9"/><polyline points="13 11 9 17 15 17 11 23"/>`),
snow: WX(`<path d="M20 17.6A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25"/><line x1="8" y1="18" x2="8" y2="18"/><line x1="12" y1="20" x2="12" y2="20"/><line x1="16" y1="18" x2="16" y2="18"/><line x1="12" y1="16" x2="12" y2="16"/>`),
};
// Plain-language "what was the day like" descriptor from the raw values:
// a temperature word (from the daily high, °F) crossed with a sky/precip word
// (from precipitation, inches — falling as snow when it's at/below freezing).
// The filterable day-type vocabulary (the weather filter's two checkbox groups).
// Keys match what weatherType() reports for each day.
const FEEL_WORDS = [
["scorching", "Scorching"], ["hot", "Hot"], ["warm", "Warm"], ["mild", "Mild"],
["cool", "Cool"], ["cold", "Cold"], ["frigid", "Frigid"],
];
const SKY_WORDS = [
["clear", "Clear"], ["dry", "Dry spell"], ["drizzle", "Light rain"],
["rain", "Rain"], ["downpour", "Downpour"], ["snow", "Snow"],
];
function weatherType(rec) {
const t = rec.tmax ? rec.tmax.v : null; // daily high, °F
const p = rec.precip ? rec.precip.v : null; // precipitation, inches
const wet = p != null && p >= 0.01;
const freezing = t != null && t <= 34;
const temp = t == null ? "" :
t >= 95 ? "Scorching" : t >= 85 ? "Hot" : t >= 72 ? "Warm" :
t >= 58 ? "Mild" : t >= 45 ? "Cool" : t >= 32 ? "Cold" : "Frigid";
let sky, skyKey, icon;
if (wet && freezing) { sky = p >= 0.3 ? "heavy snow" : "snow"; skyKey = "snow"; icon = WX_ICONS.snow; }
else if (wet && p >= 1.0) { sky = "downpour"; skyKey = "downpour"; icon = WX_ICONS.storm; }
else if (wet && p >= 0.3) { sky = "rain"; skyKey = "rain"; icon = WX_ICONS.rain; }
else if (wet) { sky = "light rain"; skyKey = "drizzle"; icon = WX_ICONS.drizzle; }
else {
skyKey = rec.dsr != null && rec.dsr >= 10 ? "dry" : "clear";
sky = skyKey === "dry" ? "dry" : "clear";
icon = WX_ICONS.sun;
}
const text = !temp ? sky : wet ? `${temp}, ${sky}` : `${temp} & ${sky}`;
return { icon, text: text.charAt(0).toUpperCase() + text.slice(1),
feel: temp.toLowerCase(), sky: skyKey };
}
let selected = null; // {lat, lon}
let data = null; // last /api/v2/calendar response
// Coloring metric. Persisted so a refresh keeps your selection.
let metric = localStorage.getItem("thermograph:calMetric") || "tmax"; // tmax|tmin|feels|wind|gust|precip|dsr
// 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} <span>Find a location</span>`;
findBtn.addEventListener("click", () => {
window.LocationPicker.open(selected, (lat, lon) => selectLocation(lat, lon));
});
// ---- metric toggle ----
// Reflect the restored metric on the toggle (the HTML defaults to High).
document.querySelectorAll("#metric-toggle button").forEach((b) =>
b.classList.toggle("active", b.dataset.metric === metric));
// ---- 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 <details> so it needs no extra open/close wiring.
const filterDropdown = (dd, label, attr, items, summary) => `
<details class="cal-dd" data-dd="${dd}">
<summary>
<span class="cal-filter-label">${label}</span>
<span class="cal-dd-sum">${summary}</span>
<span class="cal-dd-caret" aria-hidden="true"></span>
</summary>
<div class="cal-dd-panel">
${items.map(([val, lab, on]) =>
`<label class="cal-dd-opt"><input type="checkbox" data-${attr}="${val}"${on ? " checked" : ""}>${lab}</label>`).join("")}
</div>
</details>`;
// The weather filter: one dropdown, two checkbox groups (day feel + sky), so a
// pick like "Mild or Warm · Clear" finds exactly the days you'd love. Sits after
// (below) the season/year dropdowns and spans the full filter row.
const weatherDropdown = () => `
<details class="cal-dd cal-weather" data-dd="weather">
<summary>
<span class="cal-filter-label">Weather</span>
<span class="cal-dd-sum">${weatherSummaryText()}</span>
<span class="cal-dd-caret" aria-hidden="true"></span>
</summary>
<div class="cal-dd-panel">
<div class="cal-dd-group">Day feel from the daily high</div>
${FEEL_WORDS.map(([val, lab]) =>
`<label class="cal-dd-opt"><input type="checkbox" data-wfeel="${val}"${checkedFeels.has(val) ? " checked" : ""}>${lab}</label>`).join("")}
<div class="cal-dd-group">Sky</div>
${SKY_WORDS.map(([val, lab]) =>
`<label class="cal-dd-opt"><input type="checkbox" data-wsky="${val}"${checkedSkies.has(val) ? " checked" : ""}>${lab}</label>`).join("")}
</div>
</details>`;
function renderFilters() {
const years = calYears();
checkedYears = new Set(years); // reset to all available whenever the range changes
filterYears = years;
filtersEl.hidden = false;
// The month/year range picker stays as static HTML (keeps its listeners); only the
// season/year/weather dropdowns are rebuilt here, since the year set depends on
// the span (the weather sets survive the rebuild — they're module state).
seasonYearEl.innerHTML =
filterDropdown("season", "Seasons", "season",
SEASONS.map((s) => [s[0], s[1], checkedSeasons.has(s[0])]), seasonSummaryText()) +
filterDropdown("year", "Years", "year",
years.map((y) => [y, y, checkedYears.has(y)]), yearSummaryText()) +
weatherDropdown();
}
filtersEl.addEventListener("change", (e) => {
const cb = e.target.closest("input[type=checkbox]");
if (!cb) return;
if (cb.dataset.season) {
cb.checked ? checkedSeasons.add(cb.dataset.season) : checkedSeasons.delete(cb.dataset.season);
} else if (cb.dataset.year) {
const y = +cb.dataset.year;
cb.checked ? checkedYears.add(y) : checkedYears.delete(y);
} else if (cb.dataset.wfeel) {
cb.checked ? checkedFeels.add(cb.dataset.wfeel) : checkedFeels.delete(cb.dataset.wfeel);
} else if (cb.dataset.wsky) {
cb.checked ? checkedSkies.add(cb.dataset.wsky) : checkedSkies.delete(cb.dataset.wsky);
}
updateFilterSummaries();
renderCalendar();
});
// Close any open filter dropdown when tapping outside it (native <details> stays
// open otherwise). Doesn't touch the day tooltip's own outside-tap handler.
document.addEventListener("pointerdown", (e) => {
seasonYearEl.querySelectorAll("details.cal-dd[open]").forEach((d) => {
if (!d.contains(e.target)) d.open = false;
});
}, true);
// ---- date-range picker (month + year only) ----
// The inputs are <input type="month"> ("YYYY-MM"); a picked month is expanded to a
// full-month ISO span so every displayed month is shown in full (no partial weeks).
const startInput = document.getElementById("range-start");
const endInput = document.getElementById("range-end");
const refreshBtn = document.getElementById("range-refresh");
// Selectable bounds: 1970 through the current year. Fixed (not derived from the
// loaded span) so the To field always reaches the present; the actual data starts
// ~1980 and ends a few days ago, and out-of-range picks are clamped server-side.
startInput.min = endInput.min = "1970-01";
startInput.max = endInput.max = `${new Date().getFullYear()}-12`;
const isoOfDate = (d) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const monthStart = (ym) => `${ym}-01`; // 1st of the month
const monthEnd = (ym) => { // last day of the month
const d = new Date(+ym.slice(0, 4), +ym.slice(5, 7), 0);
return isoOfDate(d);
};
// Split [startIso, endIso] into consecutive ≤2-year windows (oldest first) so each
// stays within the server's per-request cap. The grid fills chunk by chunk.
function buildChunks(startIso, endIso) {
const chunks = [];
let s = startIso, guard = 0;
while (s <= endIso && guard++ < 200) {
const sd = new Date(s + "T00:00:00");
const ed = new Date(sd.getFullYear(), sd.getMonth() + CHUNK_MONTHS, sd.getDate());
ed.setDate(ed.getDate() - 1); // 24 months inclusive
let e = isoOfDate(ed);
if (e > endIso) e = endIso;
chunks.push({ start: s, end: e });
const ns = new Date(e + "T00:00:00"); ns.setDate(ns.getDate() + 1);
s = isoOfDate(ns);
}
return chunks;
}
// Editing a date doesn't fetch — it reveals the Refresh button so the user
// confirms before a (possibly multi-request) load kicks off.
function markRangeDirty() { refreshBtn.hidden = false; }
startInput.addEventListener("change", markRangeDirty);
endInput.addEventListener("change", markRangeDirty);
// Confirm: read the pickers into the active range and (re)load.
function applyRange() {
let s = startInput.value ? monthStart(startInput.value) : null;
let e = endInput.value ? monthEnd(endInput.value) : null;
if (s && e && s > e) { const t = s; s = e; e = t; } // keep start ≤ end
rangeStart = s; rangeEnd = e;
saveCalRange(s, e); // keep these dates across refreshes
syncDayToOtherViews(); // push the To-month to Weekly/Day
refreshBtn.hidden = true;
if (selected) fetchCalendar();
}
refreshBtn.addEventListener("click", applyRange);
// A To-Date change updates the day the Weekly/Day views open on: the 1st of the
// To month, or today if that month is still in the future (no data ahead of today).
function syncDayToOtherViews() {
if (!selected || !rangeEnd) return;
const toFirst = rangeEnd.slice(0, 7) + "-01";
const todayIso = isoOfDate(new Date());
const day = toFirst > todayIso ? todayIso : toFirst;
window.Thermograph.saveLastLocation(selected.lat, selected.lon, day);
setCalSync(day);
}
// ---- fetch + render ----
function selectLocation(lat, lon) {
selected = { lat, lon };
history.replaceState(null, "", `#lat=${lat.toFixed(5)}&lon=${lon.toFixed(5)}`);
window.Thermograph.saveLastLocation(lat, lon); // keep date for the other views
fetchCalendar();
}
// Up to this many chunk requests are in flight at once. The server grades every
// chunk against one shared, already-cached history record, so a few parallel
// requests cut wall-clock time on long spans without extra upstream fetches.
const CHUNK_CONCURRENCY = 4;
async function fetchCalendar() {
if (!selected) return;
const token = ++fetchToken; // supersede any in-flight chunked load
placeholder.hidden = true;
refreshBtn.hidden = true;
calHead.hidden = false;
calHead.innerHTML = `<p class="spinner">Grading daily weather across the range…</p>`;
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 <input type=month>
endInput.value = reqEnd.slice(0, 7);
document.getElementById("metric-block").hidden = false;
locLabel.textContent = d.place || `${d.cell.center_lat.toFixed(3)}, ${d.cell.center_lon.toFixed(3)}`;
locLabel.hidden = false;
findBtn.innerHTML = `${window.LocationPicker.PIN_ICON} <span>Change location</span>`;
renderFilters();
};
// Advance the visible span over the contiguous run of loaded chunks from the
// oldest, so the grid fills top-down with no gaps even when chunks finish out of
// order. A failed chunk halts the visible prefix at its position.
const advance = () => {
while (donePrefix < chunks.length && results[donePrefix] && !results[donePrefix].error) donePrefix++;
if (donePrefix === 0 || !data) return;
data.days = [...dayMap.values()];
data.range = { start: results[0].range.start, end: results[donePrefix - 1].range.end };
renderHead(chunks.length - donePrefix, true);
renderCalendar();
renderKey();
};
const runChunk = async (i) => {
const ch = chunks[i];
const url = ch
? `api/v2/calendar?${q}&start=${ch.start}&end=${ch.end}`
: `api/v2/calendar?${q}&months=24`;
try {
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 = `<p class="error">${firstErr ? firstErr.error.message : "Failed to load."}</p>`;
return;
}
renderHead(chunks.length - donePrefix, false); // final head (flags any gap)
window.Thermograph.prefetchViews(selected.lat, selected.lon, "calendar"); // warm weekly/forecast
}
// Header summary. While `loading`, `remaining` > 0 shows a "loading N more blocks"
// note; once loading has finished, a leftover `remaining` flags chunks that failed.
function renderHead(remaining, loading) {
const cell = data.cell;
const place = data.place || `${cell.center_lat.toFixed(3)}, ${cell.center_lon.toFixed(3)}`;
const years = ((new Date(data.range.end) - new Date(data.range.start)) / 3.15576e10);
let note = "";
if (remaining > 0 && loading) {
note = ` · <span class="cal-loading">loading ${remaining} more block${remaining === 1 ? "" : "s"}…</span>`;
} else if (remaining > 0) {
note = ` · <span class="error">${remaining} block${remaining === 1 ? "" : "s"} failed to load</span>`;
}
calHead.innerHTML = `
<h2>${place}</h2>
<p class="meta">${data.range.start} ${data.range.end} · ${data.days.length.toLocaleString()} days graded
(~${years.toFixed(1)} yr) · ~${cell.area_sq_mi} sq mi cell${note}</p>
<p class="cal-hint">${IC_POINTER} Tap or click any day for its weather and grades</p>`;
}
function renderCalendar() {
if (!data) return;
const byDate = new Map(data.days.map((d) => [d.date, d]));
const start = new Date(data.range.start + "T00:00:00");
const end = new Date(data.range.end + "T00:00:00");
let y = start.getFullYear(), m = start.getMonth();
const endY = end.getFullYear(), endM = end.getMonth();
let html = "";
const wActive = weatherFilterActive();
const visible = []; // every graded day in a shown month: {rec, pass}
while (y < endY || (y === endY && m <= endM)) {
// Only render months that are fully inside the graded range — never a partial
// boundary month showing a stray day or two. The range is month-based, so a
// clipped edge month (e.g. data starting mid-June) is dropped entirely.
const mFirst = new Date(y, m, 1), mLast = new Date(y, m + 1, 0);
if (mFirst < start || mLast > end) {
m++; if (m > 11) { m = 0; y++; }
continue;
}
// Season/year filters: skip months whose season or year is unchecked.
if (!checkedSeasons.has(seasonOf(m)) || (checkedYears && !checkedYears.has(y))) {
m++; if (m > 11) { m = 0; y++; }
continue;
}
const lead = new Date(y, m, 1).getDay(); // 0 = Sunday
const dim = new Date(y, m + 1, 0).getDate();
let cells = "";
for (let i = 0; i < lead; i++) cells += `<div class="cal-cell empty"></div>`;
for (let day = 1; day <= dim; day++) {
const iso = `${y}-${String(m + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const rec = byDate.get(iso);
let bg = "", pass = true;
if (rec) {
if (metric === "dsr") {
bg = drynessColor(rec.dsr);
} else if (metric === "precip") {
const g = rec.precip;
// Dry days take their dry-streak color; rainy days take the blue ramp.
bg = !g ? "" : g.c === "dry" ? drynessColor(rec.dsr) : PRECIP_COLORS[g.c];
} else {
// 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 = `<span class="cal-daynum">${day}</span>`;
cells += rec
? `<div class="cal-cell filled${pass ? "" : " dim"}" data-date="${iso}"${bg ? ` style="background:${bg}"` : ""}>${num}</div>`
: `<div class="cal-cell empty">${num}</div>`;
}
html += `<div class="cal-month"><h4>${MONTHS[m]} ${y}</h4>
<div class="cal-dows"><span>S</span><span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span></div>
<div class="cal-grid">${cells}</div></div>`;
m++; if (m > 11) { m = 0; y++; }
}
calEl.innerHTML = html || `<p class="muted">No months match the selected seasons/years.</p>`;
renderTotals(visible);
attachHover(byDate);
}
// ---- category totals ----
// "What share of the shown days lands in each category?" for the current metric,
// rendered above the grid as a stacked share bar + per-category percentages.
// When the weather filter is narrowing, only matching days are totaled (and the
// title says how many matched) — so it doubles as a "days you'd love" counter.
function renderTotals(visible) {
const totEl = document.getElementById("cal-totals");
if (!visible.length) { totEl.hidden = true; totEl.innerHTML = ""; return; }
const wActive = weatherFilterActive();
const days = wActive ? visible.filter((v) => v.pass).map((v) => v.rec) : visible.map((v) => v.rec);
// Buckets low→high for the metric: [label, color, count-fn].
let buckets, title;
if (metric === "dsr") {
const B = [
["Rain day", drynessColor(0), (d) => d === 0],
["13 dry", drynessColor(2), (d) => d >= 1 && d <= 3],
["46 dry", drynessColor(5), (d) => d >= 4 && d <= 6],
["713 dry", drynessColor(10), (d) => d >= 7 && d <= 13],
["14+ dry", drynessColor(14), (d) => d >= 14],
];
const vals = days.map((r) => r.dsr).filter((d) => d != null);
buckets = B.map(([label, color, fn]) => [label, color, vals.filter(fn).length]);
title = "dry streak";
} else if (metric === "precip") {
const classes = days.map((r) => r.precip && r.precip.c).filter(Boolean);
buckets = [["Dry", drynessColor(7), classes.filter((c) => c === "dry").length]]
.concat(SCALE_RAIN.map((t) =>
[t.label, PRECIP_COLORS[t.c], classes.filter((c) => c === t.c).length]));
title = "rain intensity";
} else {
const classes = days
.map((r) => { const g = 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 = `<div class="section-title">${head}</div><p class="muted">No matching days.</p>`;
totEl.hidden = false;
return;
}
const bar = buckets.filter((b) => b[2] > 0).map(([label, color, n]) =>
`<span style="width:${(100 * n / total).toFixed(2)}%;background:${color}" title="${label} · ${pctText(n)} (${n})"></span>`).join("");
const chips = buckets.filter((b) => b[2] > 0).map(([label, color, n]) =>
`<div class="tk-seg"><span class="tk-swatch" style="background:${color}"></span>
<span class="tk-label">${label}</span><span class="ct-pct">${pctText(n)}</span></div>`).join("");
totEl.innerHTML = `<div class="section-title">${head}</div>
<div class="ct-bar">${bar}</div>
<div class="tier-key">${chips}</div>`;
totEl.hidden = false;
}
const GUIDE_LINK = `<p class="muted tk-note"><a href="legend">Full guide to metrics &amp; grades →</a></p>`;
function renderKey() {
if (metric === "dsr") {
const steps = [["Rain", 0], ["1 day", 1], ["4", 4], ["7", 7], ["10", 10], ["14+", 14]];
const segs = steps.map(([lab, d]) =>
`<div class="tk-seg"><span class="tk-swatch" style="background:${drynessColor(d)}"></span>
<span class="tk-label">${lab}</span></div>`).join("");
keyEl.innerHTML = `<div class="section-title">Days since last rain — dry streak (caps at ${DRY_CAP} days)</div>
<div class="tier-key">${segs}</div>${GUIDE_LINK}`;
return;
}
if (metric === "precip") {
const rain = [...SCALE_RAIN].reverse().map((t) =>
`<div class="tk-seg"><span class="tk-swatch" style="background:${PRECIP_COLORS[t.c]}"></span>
<span class="tk-label">${t.label}</span></div>`).join("");
const dry = [["≤1 day", 1], ["7", 7], ["14+", 14]].map(([l, d]) =>
`<div class="tk-seg"><span class="tk-swatch" style="background:${drynessColor(d)}"></span>
<span class="tk-label">${l}</span></div>`).join("");
keyEl.innerHTML = `<div class="section-title">Rain intensity · light → heavy</div>
<div class="tier-key">${rain}</div>
<div class="section-title" style="margin-top:10px">Dry days · days since rain</div>
<div class="tier-key">${dry}</div>${GUIDE_LINK}`;
return;
}
const label = (TEMP_METRIC_INFO[metric] || {}).label || "value";
// Highest tier first (Near Record hot → Near Record cold).
const segs = [...SCALE_TEMP].reverse().map((t) =>
`<div class="tk-seg"><span class="tk-swatch" style="background:${TIER_COLORS[t.c]}"></span>
<span class="tk-label">${t.label}</span></div>`).join("");
keyEl.innerHTML = `<div class="section-title">Coloring by ${label} · low → high vs local normal</div>
<div class="tier-key">${segs}</div>${GUIDE_LINK}`;
}
// ---- tooltip ----
function attachHover(byDate) {
const tip = document.getElementById("cal-tip");
// The row for the metric currently being viewed is bolded and moved to the top,
// so it's easy to match a cell's color to the stat it represents. Its category
// (grade) text is tinted with the very color that represents that day's value —
// bold + saturated for the active metric, lifted/dimmed for the rest (see CSS).
// Each metric is one row of the mini grid: name · value(+pct) · category.
// `activeKey` is the 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 `<span class="tt-name${rc}">${name}</span><span class="tt-val${rc}">—</span><span class="tt-cat${rc}"></span>`;
const pct = g.pct == null ? "" : `<span class="tt-pct"> · ${ord(g.pct)} pct</span>`;
const catCls = key === activeKey ? "tt-cat tt-cat-active" : "tt-cat";
const style = color ? ` style="--cat:${color}"` : "";
return `<span class="tt-name${rc}">${name}</span><span class="tt-val${rc}">${fmt(g.v)}${pct}</span><span class="${catCls}${rc}"${style}>${g.g}</span>`;
};
const show = (cell, e) => {
const rec = byDate.get(cell.dataset.date);
if (!rec) return;
activeKey = metric === "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", `<span class="tt-name${rc}">Dry streak</span><span class="${catCls} tt-span2${rc}" style="--cat:${drynessColor(rec.dsr)}">${dsrStr}</span>`]);
}
// active metric first, the rest keep their order
rows.sort((a, b) => (b[0] === activeKey) - (a[0] === activeKey));
const wt = weatherType(rec);
tip.innerHTML = `<div class="tt-date">${dt.toLocaleDateString(undefined, { weekday: "short", year: "numeric", month: "short", day: "numeric" })}</div>
<div class="tt-type">${wt.icon} ${wt.text}</div>
<div class="tt-grid">${rows.map((r) => r[1]).join("")}</div>`;
tip.hidden = false;
const pad = 14, w = tip.offsetWidth || 170, h = tip.offsetHeight || 70;
let x = e.clientX + pad, ty = e.clientY + pad;
if (x + w > window.innerWidth) x = e.clientX - w - pad;
if (ty + h > window.innerHeight) ty = e.clientY - h - pad;
// Keep it on-screen even when flipped past an edge (narrow phone screens).
x = Math.max(pad, Math.min(x, window.innerWidth - w - pad));
ty = Math.max(pad, Math.min(ty, window.innerHeight - h - pad));
tip.style.left = x + "px";
tip.style.top = ty + "px";
};
// Clicking a day opens its full percentile breakdown. On a mouse, one click
// navigates (the tooltip is just a hover preview). On touch there's no hover, so
// the first tap only raises the tooltip and a second tap on the same day confirms.
const openDay = (cell) => {
location.href = `day#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}&date=${cell.dataset.date}`;
};
calEl.querySelectorAll(".cal-cell.filled").forEach((c) => {
c.addEventListener("pointerenter", (e) => { if (e.pointerType === "mouse") show(c, e); });
c.addEventListener("pointerdown", (e) => { lastPointer = e.pointerType; show(c, e); });
c.addEventListener("click", () => {
if (lastPointer === "mouse") return openDay(c);
if (armedCell === c) openDay(c); // second tap on the previewed day → open
else armedCell = c; // first tap only showed the tooltip
});
});
// Mouse only: hovering off the grid hides the preview. On touch the tooltip is
// click-activated and must persist until a non-day tap (see the document handler).
calEl.addEventListener("pointerleave", (e) => { if (e.pointerType === "mouse") tip.hidden = true; });
}
// ---- restore (hash from a shared link, else the last place you looked at) ----
(function restore() {
const loc = window.Thermograph.loadLastLocation();
if (!loc) return;
const saved = loadCalRange();
const sel = loc.date; // the shared selected day (from the hash or last-visited store)
if (sel && sel !== getCalSync()) {
// The selected day changed on another view → align the To-month to it. Keep the
// saved From unless the new To is at/before it, in which case pull From 2 years
// back so the window stays valid (start ≤ end). Month/year granularity only.
const d = new Date(sel + "T00:00:00");
const y = d.getFullYear(), m = d.getMonth();
rangeEnd = isoOfDate(new Date(y, m + 1, 0)); // last day of the To month
const toFirst = `${y}-${String(m + 1).padStart(2, "0")}-01`;
const savedFrom = saved && saved.start ? saved.start : null;
rangeStart = (savedFrom && toFirst > savedFrom)
? savedFrom // To after From → keep From
: isoOfDate(new Date(y - 2, m, 1)); // To ≤ From (or none) → same month, 2yr back
saveCalRange(rangeStart, rangeEnd);
setCalSync(sel);
} else if (saved) {
rangeStart = saved.start; rangeEnd = saved.end; // refresh / in-sync → keep range
}
selectLocation(loc.lat, loc.lon);
})();

54
static/day.html Normal file
View file

@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Thermograph — a single day</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header>
<div class="brand">
<span class="logo"></span>
<div>
<h1>Thermograph · Day</h1>
<p class="tag">Every percentile boundary for one day vs its ±7-day climate window</p>
</div>
<nav class="view-nav" aria-label="Views">
<a href="./" data-view="map">Weekly</a>
<a href="calendar" data-view="calendar">Calendar</a>
<a href="day" data-view="day" class="active">Day</a>
</nav>
</div>
</header>
<main>
<section class="controls">
<div class="find-bar">
<button type="button" id="find-btn" class="find-btn"></button>
<span id="loc-label" class="loc-label" hidden></span>
</div>
<div class="day-nav">
<button type="button" id="prev-day" class="day-step" title="Previous day" aria-label="Previous day"></button>
<label class="day-date">Day
<input id="date-input" type="date" />
</label>
<button type="button" id="next-day" class="day-step" title="Next day" aria-label="Next day"></button>
</div>
</section>
<div id="day-head" class="cal-head" hidden></div>
<div class="placeholder" id="day-placeholder">
<p>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.</p>
</div>
<div id="day-body"></div>
</main>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="nav.js"></script>
<script src="mappicker.js"></script>
<script src="day.js"></script>
</body>
</html>

215
static/day.js Normal file
View file

@ -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) =>
`<svg class="wx" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
const WX_CLOUD = `<path d="M20 16.6A5 5 0 0 0 18 7h-1.26A8 8 0 1 0 4 15.25"/>`;
const WX_ICONS = {
sun: WX(`<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.2" y1="4.2" x2="5.6" y2="5.6"/><line x1="18.4" y1="18.4" x2="19.8" y2="19.8"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.2" y1="19.8" x2="5.6" y2="18.4"/><line x1="18.4" y1="5.6" x2="19.8" y2="4.2"/>`),
drizzle: WX(`${WX_CLOUD}<line x1="8" y1="19" x2="8" y2="21"/><line x1="16" y1="19" x2="16" y2="21"/><line x1="12" y1="20" x2="12" y2="22"/>`),
rain: WX(`${WX_CLOUD}<line x1="8" y1="19" x2="8" y2="22"/><line x1="16" y1="19" x2="16" y2="22"/><line x1="12" y1="19" x2="12" y2="23"/>`),
storm: WX(`<path d="M19 16.9A5 5 0 0 0 18 7h-1.26a8 8 0 1 0-11.62 9"/><polyline points="13 11 9 17 15 17 11 23"/>`),
snow: WX(`<path d="M20 17.6A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25"/><line x1="8" y1="18" x2="8" y2="18"/><line x1="12" y1="20" x2="12" y2="20"/><line x1="16" y1="18" x2="16" y2="18"/><line x1="12" y1="16" x2="12" y2="16"/>`),
};
// Plain-language "what 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} <span>Find a location</span>`;
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 = `<p class="spinner">Building the percentile breakdown…</p>`;
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 = `<p class="error">${err.message}</p>`;
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} <span>Change location</span>`;
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 = `
<h2>${nice}</h2>
${wt ? `<p class="day-weather">${wt.icon} ${wt.text}</p>` : ""}
<p class="meta">${place} · ~${cell.area_sq_mi} sq mi cell · ±7-day window
· ${d.n_samples.toLocaleString()} days (${yrs})</p>`;
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 = `<span class="day-obs" style="--cat:${tierColor(obs.class)}">${fmt(obs.value)}</span>
<span class="day-obs-meta">${obs.grade}${pct}</span>`;
} else {
summary = `<span class="day-obs-meta">No observation for this day yet</span>`;
}
// 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 = `<span class="ladder-note-left">${noteLeft}</span>` +
`<span class="ladder-range">Historical Range ${fmt(m.ladder.min)}${fmt(m.ladder.max)}</span>`;
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
? `<span class="ladder-mark">◀ ${fmt(obs.value)}</span>` : "";
return `<div class="ladder-row${active}">
<span class="ladder-sw" style="background:${tierColor(t.c)}"></span>
<span class="ladder-label" style="--cat:${tierColor(t.c)}">${t.label}</span>
<span class="ladder-pct">${pct}</span>
<span class="ladder-val">${val}</span>
<span class="ladder-mk">${marker}</span>
</div>`;
}).join("");
return `<section class="ladder-card">
<div class="ladder-head">
<h3>${title}</h3>
<div class="ladder-summary">${summary}</div>
</div>
<p class="ladder-note">${note}</p>
<div class="ladder">${rows}</div>
</section>`;
}
// ---- 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();
}
})();

57
static/index.html Normal file
View file

@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Thermograph — how unusual is your weather?</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header>
<div class="brand">
<span class="logo"></span>
<div>
<h1>Thermograph</h1>
<p class="tag">Grading today's weather against ~45 years of local climate history</p>
</div>
<nav class="view-nav" aria-label="Views">
<a href="./" data-view="map" class="active">Weekly</a>
<a href="calendar" data-view="calendar">Calendar</a>
<a href="day" data-view="day">Day</a>
</nav>
</div>
</header>
<main>
<section class="controls">
<div class="find-bar">
<button type="button" id="find-btn" class="find-btn"></button>
<span id="loc-label" class="loc-label" hidden></span>
</div>
<div class="date-row">
<label>Target date
<input id="date-input" type="date" />
</label>
<button type="button" id="today-btn" class="today-chip" title="Reset the target date to today">Today</button>
<span class="hint">Cells are ~4&nbsp;sq&nbsp;mi. <a href="legend">What do the grades mean? →</a></span>
</div>
</section>
<section id="panel" class="panel panel-full">
<div class="placeholder" id="placeholder">
<p>Find a location to begin.</p>
<p class="muted">Thermograph fetches decades of daily highs, lows, and precipitation
for your ~4&nbsp;sq&nbsp;mi cell, builds a &plusmn;7-day seasonal distribution for each
day of the year, and grades recent weather by where it falls on that distribution.</p>
</div>
<div id="results" hidden></div>
</section>
</main>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="nav.js"></script>
<script src="mappicker.js"></script>
<script src="app.js"></script>
</body>
</html>

95
static/legend.html Normal file
View file

@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Thermograph — reading the grades</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header>
<div class="brand">
<span class="logo"></span>
<div>
<h1>Thermograph · Guide</h1>
<p class="tag">What the metrics and percentile grades mean</p>
</div>
<nav class="view-nav" aria-label="Views">
<a href="./" data-view="map">Weekly</a>
<a href="calendar" data-view="calendar">Calendar</a>
<a href="day" data-view="day">Day</a>
</nav>
</div>
</header>
<main class="guide">
<section class="guide-block">
<h2>How the grades work</h2>
<p>Every grade is <b>relative to this place's own climate history</b>, not an absolute
temperature. For each day Thermograph builds a &plusmn;7-day seasonal distribution from
decades of local records, then reports the <b>percentile</b> where the day falls in it.</p>
<p>So a 60&deg; day can be &ldquo;Above Normal&rdquo; in one place or season and
&ldquo;Below Normal&rdquo; in another. That's why the categories read
&ldquo;Above/Below Normal&rdquo;, &ldquo;High/Low&rdquo;, and &ldquo;Near Record&rdquo;
never &ldquo;hot&rdquo; or &ldquo;cold&rdquo;.</p>
</section>
<section class="guide-block">
<h2>Grade scale — percentile of the local &plusmn;7-day history</h2>
<div class="guide-scale" id="temp-scale"></div>
</section>
<section class="guide-block">
<h2>Rain intensity — percentile among rain days</h2>
<p class="muted">Precipitation is graded only across days that actually saw rain, so
&ldquo;Heavy&rdquo; means heavy <i>for a rainy day here</i>. Dry days are colored by
their dry streak instead (below).</p>
<div class="guide-scale" id="rain-scale"></div>
</section>
<section class="guide-block">
<h2>The metrics</h2>
<dl class="guide-metrics">
<dt>High / Low</dt><dd>The day's highest and lowest air temperature (&deg;F).</dd>
<dt>Feels</dt><dd>Apparent temperature — what the air actually felt like once humidity
and wind are factored in. Every day has a felt <b>high</b> (heat-index territory) and a
felt <b>low</b> (wind-chill territory); both are shown in the day tooltip, each graded
against its own history. The Feels color uses whichever side sits <b>further from your
comfort temperature</b> — set with the slider on the Calendar page (0100&deg;F,
default 65&deg;F) — so sweltering days grade by their felt high and bitter days by
their felt low.</dd>
<dt>Humid</dt><dd>Absolute humidity: grams of water vapor per cubic meter of air (g/m&sup3;).</dd>
<dt>Wind</dt><dd>Average sustained wind speed (mph).</dd>
<dt>Gust</dt><dd>Peak wind gust for the day (mph).</dd>
<dt>Precip</dt><dd>Total precipitation (inches), graded by intensity among rain days.</dd>
<dt>Dry streak</dt><dd>Consecutive days since the last measurable rain — the color deepens
the longer it's been dry, and resets to blue on a rain day.</dd>
</dl>
</section>
<p class="guide-back"><a href="./">&larr; Back to Thermograph</a></p>
</main>
<script src="nav.js"></script>
<script>
// The scale rows mirror the tiers used across the app (see calendar.js / app.js).
const TEMP = [
["rec-cold", "Near Record", "<1"], ["very-cold", "Very Low", "110"],
["cold", "Low", "1025"], ["cool", "Below Normal", "2540"],
["normal", "Normal", "4060"], ["warm", "Above Normal", "6075"],
["hot", "High", "7590"], ["very-hot", "Very High", "9099"],
["rec-hot", "Near Record", ">99"],
];
const RAIN = [
["wet-1", "Trace", "<1"], ["wet-2", "Very Light", "110"], ["wet-3", "Light", "1025"],
["wet-4", "LightMod", "2540"], ["wet-5", "Moderate", "4060"], ["wet-6", "ModHeavy", "6075"],
["wet-7", "Heavy", "7590"], ["wet-8", "Very Heavy", "9099"], ["wet-9", "Extreme", ">99"],
];
const row = ([cls, label, range]) =>
`<div class="guide-seg"><span class="guide-sw" style="background:var(--${cls})"></span>` +
`<span class="guide-lbl">${label}</span><span class="guide-rng">${range}</span></div>`;
document.getElementById("temp-scale").innerHTML = TEMP.map(row).join("");
document.getElementById("rain-scale").innerHTML = RAIN.map(row).join("");
</script>
</body>
</html>

139
static/mappicker.js Normal file
View file

@ -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 = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>`;
function build() {
overlay = document.createElement("div");
overlay.className = "mp-overlay";
overlay.hidden = true;
overlay.innerHTML = `
<div class="mp-modal" role="dialog" aria-modal="true" aria-label="Pick a location">
<div class="mp-head">
<h2>Find a location</h2>
<button type="button" class="mp-close" aria-label="Close">&times;</button>
</div>
<form class="mp-search" autocomplete="off">
<input type="text" placeholder="Search a US or Canadian place…" autocomplete="off" />
<button type="submit">Search</button>
<ul class="mp-sug" hidden></ul>
</form>
<div class="mp-map"></div>
<div class="mp-foot">
<span class="mp-hint">Search above, or tap the map to drop a pin.</span>
<button type="button" class="mp-confirm" disabled>Use this location</button>
</div>
</div>`;
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}<span class="sub"> — ${[r.admin1, r.country].filter(Boolean).join(", ")}</span>`;
// 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: "&copy; 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 };
})();

123
static/nav.js Normal file
View file

@ -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 };

772
static/style.css Normal file
View file

@ -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 <details>) --- */
.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; }
}