Initial commit: app +
VPS deploy pipeline
This commit is contained in:
commit
3dce838800
10 changed files with 3173 additions and 0 deletions
780
static/app.js
Normal file
780
static/app.js
Normal 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 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 & 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: "1–10" },
|
||||
{ cls: "cold", label: "Low", range: "10–25" },
|
||||
{ cls: "cool", label: "Below Normal", range: "25–40" },
|
||||
{ cls: "normal", label: "Normal", range: "40–60" },
|
||||
{ cls: "warm", label: "Above Normal", range: "60–75" },
|
||||
{ cls: "hot", label: "High", range: "75–90" },
|
||||
{ cls: "very-hot", label: "Very High", range: "90–99" },
|
||||
{ cls: "rec-hot", label: "Near Record", range: ">99" },
|
||||
];
|
||||
|
||||
function tierKeyHtml() {
|
||||
// Highest tier first (Near Record hot → Near Record cold).
|
||||
const segs = [...GRADE_SCALE].reverse().map((t) =>
|
||||
`<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 & 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
// Build a table of graded days as SVG markup, stacked below the chart.
|
||||
function exportTableSvg(C) {
|
||||
const rows = lastGraded.recent; // newest first (matches the on-screen table)
|
||||
const rowH = 27, headH = 42, padB = 16;
|
||||
const top = H + 10;
|
||||
const cDate = PL, cPcp = PL + 150, cHigh = PL + 322, cLow = PL + 494;
|
||||
const totalH = top + headH + rows.length * rowH + padB;
|
||||
|
||||
const cell = (x, g, isPcp) => {
|
||||
if (!g) return `<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
91
static/calendar.html
Normal 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
847
static/calendar.js
Normal 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: "1–10" },
|
||||
{ c: "cold", label: "Low", range: "10–25" },
|
||||
{ c: "cool", label: "Below Normal", range: "25–40" },
|
||||
{ c: "normal", label: "Normal", range: "40–60" },
|
||||
{ c: "warm", label: "Above Normal", range: "60–75" },
|
||||
{ c: "hot", label: "High", range: "75–90" },
|
||||
{ c: "very-hot", label: "Very High", range: "90–99" },
|
||||
{ c: "rec-hot", label: "Near Record", range: ">99" },
|
||||
];
|
||||
// Rain-intensity tiers by rain-day percentile (dry days are shown via dry streak).
|
||||
const SCALE_RAIN = [
|
||||
{ c: "wet-1", label: "Trace", range: "<1" },
|
||||
{ c: "wet-2", label: "Very Light", range: "1–10" },
|
||||
{ c: "wet-3", label: "Light", range: "10–25" },
|
||||
{ c: "wet-4", label: "Light–Mod", range: "25–40" },
|
||||
{ c: "wet-5", label: "Moderate", range: "40–60" },
|
||||
{ c: "wet-6", label: "Mod–Heavy", range: "60–75" },
|
||||
{ c: "wet-7", label: "Heavy", range: "75–90" },
|
||||
{ c: "wet-8", label: "Very Heavy", range: "90–99" },
|
||||
{ c: "wet-9", label: "Extreme", range: ">99" },
|
||||
];
|
||||
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
const fmtTemp = (v) => (v == null ? "—" : `${Math.round(v)}°`);
|
||||
const fmtPrecip = (v) => (v == null ? "—" : `${v.toFixed(2)}"`);
|
||||
const fmtWind = (v) => (v == null ? "—" : `${Math.round(v)} mph`);
|
||||
const fmtHumid = (v) => (v == null ? "—" : `${v.toFixed(1)} g/m³`);
|
||||
|
||||
// The diverging-temperature-scale metrics (colored exactly like High/Low), with a
|
||||
// display name for the key + tooltip and the formatter for their values.
|
||||
const TEMP_METRIC_INFO = {
|
||||
tmax: { name: "High", label: "daily high", fmt: fmtTemp },
|
||||
tmin: { name: "Low", label: "daily low", fmt: fmtTemp },
|
||||
feels: { name: "Feels", label: "feels like (felt high or low furthest from your comfort temp)", fmt: fmtTemp },
|
||||
humid: { name: "Humid", label: "absolute humidity (g/m³ of water vapor)", fmt: fmtHumid },
|
||||
wind: { name: "Wind", label: "wind speed", fmt: fmtWind },
|
||||
gust: { name: "Gust", label: "wind gust", fmt: fmtWind },
|
||||
};
|
||||
// The server grades at most ~2 years per request; longer spans are split into
|
||||
// successive 2-year chunks the frontend requests one at a time, filling the grid
|
||||
// as each arrives.
|
||||
const CHUNK_MONTHS = 24;
|
||||
const ord = (n) => {
|
||||
const r = Math.round(n), s = ["th", "st", "nd", "rd"], v = r % 100;
|
||||
return r + (s[(v - 20) % 10] || s[v] || s[0]);
|
||||
};
|
||||
|
||||
// Monochrome line icons (currentColor) — no emoji, so the summary matches the
|
||||
// site's dark/official look. Feather-style paths.
|
||||
const WX = (paths) =>
|
||||
`<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],
|
||||
["1–3 dry", drynessColor(2), (d) => d >= 1 && d <= 3],
|
||||
["4–6 dry", drynessColor(5), (d) => d >= 4 && d <= 6],
|
||||
["7–13 dry", drynessColor(10), (d) => d >= 7 && d <= 13],
|
||||
["14+ dry", drynessColor(14), (d) => d >= 14],
|
||||
];
|
||||
const vals = days.map((r) => r.dsr).filter((d) => d != null);
|
||||
buckets = B.map(([label, color, fn]) => [label, color, vals.filter(fn).length]);
|
||||
title = "dry streak";
|
||||
} else if (metric === "precip") {
|
||||
const classes = days.map((r) => r.precip && r.precip.c).filter(Boolean);
|
||||
buckets = [["Dry", drynessColor(7), classes.filter((c) => c === "dry").length]]
|
||||
.concat(SCALE_RAIN.map((t) =>
|
||||
[t.label, PRECIP_COLORS[t.c], classes.filter((c) => c === t.c).length]));
|
||||
title = "rain intensity";
|
||||
} else {
|
||||
const classes = days
|
||||
.map((r) => { const g = metric === "feels" ? feelsPick(r) : r[metric]; return g && g.c; })
|
||||
.filter(Boolean);
|
||||
buckets = SCALE_TEMP.map((t) =>
|
||||
[t.label, TIER_COLORS[t.c], classes.filter((c) => c === t.c).length]);
|
||||
title = (TEMP_METRIC_INFO[metric] || {}).label || "value";
|
||||
}
|
||||
|
||||
const total = buckets.reduce((n, b) => n + b[2], 0);
|
||||
const pctText = (n) => { const r = Math.round(100 * n / total); return n > 0 && r === 0 ? "<1%" : `${r}%`; };
|
||||
const head = wActive
|
||||
? `${days.length.toLocaleString()} of ${visible.length.toLocaleString()} shown days match the weather filter — by ${title}:`
|
||||
: `${visible.length.toLocaleString()} days shown — by ${title}:`;
|
||||
if (!total) {
|
||||
totEl.innerHTML = `<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 & 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
54
static/day.html
Normal 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
215
static/day.js
Normal 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
57
static/index.html
Normal 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 sq 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 sq mi cell, builds a ±7-day seasonal distribution for each
|
||||
day of the year, and grades recent weather by where it falls on that distribution.</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
95
static/legend.html
Normal 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 ±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° day can be “Above Normal” in one place or season and
|
||||
“Below Normal” in another. That's why the categories read
|
||||
“Above/Below Normal”, “High/Low”, and “Near Record” —
|
||||
never “hot” or “cold”.</p>
|
||||
</section>
|
||||
|
||||
<section class="guide-block">
|
||||
<h2>Grade scale — percentile of the local ±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
|
||||
“Heavy” 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 (°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 (0–100°F,
|
||||
default 65°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³).</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="./">← 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", "1–10"],
|
||||
["cold", "Low", "10–25"], ["cool", "Below Normal", "25–40"],
|
||||
["normal", "Normal", "40–60"], ["warm", "Above Normal", "60–75"],
|
||||
["hot", "High", "75–90"], ["very-hot", "Very High", "90–99"],
|
||||
["rec-hot", "Near Record", ">99"],
|
||||
];
|
||||
const RAIN = [
|
||||
["wet-1", "Trace", "<1"], ["wet-2", "Very Light", "1–10"], ["wet-3", "Light", "10–25"],
|
||||
["wet-4", "Light–Mod", "25–40"], ["wet-5", "Moderate", "40–60"], ["wet-6", "Mod–Heavy", "60–75"],
|
||||
["wet-7", "Heavy", "75–90"], ["wet-8", "Very Heavy", "90–99"], ["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
139
static/mappicker.js
Normal 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">×</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: "© 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
123
static/nav.js
Normal 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
772
static/style.css
Normal 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; }
|
||||
}
|
||||
Loading…
Reference in a new issue