thermograph/static/app.js
2026-07-10 18:41:36 -07:00

800 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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). Dry days label the running dry streak
// (see precipCell) rather than the rain amount.
const cTemp = (v) => (v == null ? "—" : `${Math.round(v)}°`);
const cHum = (v) => (v == null ? "—" : `${Math.round(v)}%`);
const cWind = (v) => (v == null ? "—" : `${Math.round(v)}`);
const cRain = (v) => (v == null ? "—" : v > 0 ? v.toFixed(2) : "·");
// Monochrome inline icons (currentColor) so the UI chrome matches the dark theme
// instead of using colorful emoji. Feather-style line paths.
const IC = {
link: `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`,
download: `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`,
calendar: `<svg class="ic ic-lg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`,
};
function placeLabel(data) {
return data.place || `${data.cell.center_lat.toFixed(3)}, ${data.cell.center_lon.toFixed(3)}`;
}
function render(data) {
recentData = data;
forecastData = null; // invalidate any forecast from a previous location/date
// Reflect the chosen place beside the Find button (which becomes "Change").
locLabel.textContent = placeLabel(data);
locLabel.hidden = false;
findBtn.innerHTML = `${window.LocationPicker.PIN_ICON} <span>Change location</span>`;
const c = data.climatology;
const cell = data.cell;
const yrs = c.year_range ? `${c.year_range[0]}${c.year_range[1]}` : "—";
const coords = `${cell.center_lat.toFixed(3)}, ${cell.center_lon.toFixed(3)}`;
// Each card compares the target day's value to the normal (median) for that
// metric, tinted by its grade, and links to the full single-day breakdown.
const today = data.recent[0] || null;
const dayHref = `day#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}&date=${data.target_date}`;
const cmpCard = (label, key, clim, fmt) => {
const g = today ? today[key] : null;
const normalVal = clim ? fmt(clim.p50) : "—";
const cat = g ? (TIER_COLORS[g.class] || "") : "";
const big = g ? fmt(g.value) : normalVal;
const sub = g
? `<span class="nc-grade" style="--cat:${cat}">${g.grade}</span> · normal ${normalVal}`
: `normal ${normalVal}`;
return `<a class="normal-card" href="${dayHref}">
<h3>${label}</h3>
<div class="big"${g ? ` style="--cat:${cat}"` : ""}>${big}</div>
<div class="range">${sub}</div>
</a>`;
};
results.innerHTML = `
<div class="loc-head">
<div class="loc-title">
<h2>${placeLabel(data)}</h2>
<p class="meta">${data.place ? coords + " · " : ""}~${cell.area_sq_mi} sq mi cell · climatology from ${yrs}
(${c.n_samples.toLocaleString()} days in the ±7-day window)</p>
</div>
<div class="share">
<button id="btn-link" title="Copy a link back to this exact view">${IC.link} Copy link</button>
<button id="btn-png" title="Download the trend chart as an image">${IC.download} Chart</button>
</div>
</div>
<a class="cta-cal" href="calendar#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}">
<span class="cta-cal-icon" aria-hidden="true">${IC.calendar}</span>
<span class="cta-cal-text">
<span class="cta-cal-title">Check historical data</span>
<span class="cta-cal-sub">The last 2 years, graded day by day</span>
</span>
<span class="cta-cal-arrow" aria-hidden="true">→</span>
</a>
<div class="section-title">${data.target_date} vs normal (±7&nbsp;days) — tap a metric for the full breakdown</div>
<div class="normals">
${cmpCard("High", "tmax", c.tmax, fmtTemp)}
${cmpCard("Low", "tmin", c.tmin, fmtTemp)}
${cmpCard("Feels", "feels", c.feels, fmtTemp)}
${cmpCard("Humidity", "humid", c.humid, fmtHumid)}
${cmpCard("Wind", "wind", c.wind, fmtWind)}
${cmpCard("Gust", "gust", c.gust, fmtWind)}
${cmpCard("Precip", "precip", c.precip, fmtPrecip)}
</div>
<div id="series"></div>
`;
renderSeries(); // fills #series (chart + graded-days timeline) from recent data
fetchForecast(); // merge the 7-day forecast into the timeline when it arrives
window.Thermograph.prefetchViews(selected.lat, selected.lon, "grade"); // warm calendar/forecast
document.getElementById("btn-link").onclick = copyShareLink;
document.getElementById("btn-png").onclick = downloadChartPng;
}
// One tinted value cell: background + value colored by the day's grade tier for
// that metric (the same diverging scale as everywhere else). Carries the date so
// tapping any cell opens that day's full breakdown.
function rdCell(g, fmt, date, isFc, isToday) {
const dd = date ? ` data-date="${date}"` : "";
const cls = "rd-c" + (isFc ? " rd-fc" : isToday ? " rd-today" : "");
if (!g) return `<span class="${cls}"${dd}>—</span>`;
const col = TIER_COLORS[g.class] || "";
return `<span class="${cls}"${dd} style="--c:${col}">${fmt(g.value)}</span>`;
}
// Precip cell: rain days show the amount (tinted by intensity tier); dry days
// label the running dry streak — "3d" = 3 days since measurable rain, warmed by
// the same dryness ramp as the chart — instead of a bare dot.
function precipCell(d, isFc, isToday) {
const g = d.precip;
const dd = ` data-date="${d.date}"`;
const cls = "rd-c" + (isFc ? " rd-fc" : isToday ? " rd-today" : "");
if (!g) return `<span class="${cls}"${dd}>—</span>`;
if (g.value === 0 && d.dsr != null && d.dsr > 0) {
return `<span class="${cls}"${dd} style="--c:${drynessColor(d.dsr)}" title="${d.dsr} days since measurable rain">${d.dsr}d</span>`;
}
const col = TIER_COLORS[g.class] || "";
return `<span class="${cls}"${dd} style="--c:${col}">${cRain(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) => key === "precip"
? precipCell(d, isFc(d), d.date === todayDate)
: rdCell(d[key], fmt, d.date, isFc(d), d.date === todayDate)).join("")
).join("");
return `<div class="rd-scroll"><div class="rd-grid" style="${cols}">${header}${body}</div></div>`;
}
// ---- Recent + Forecast timeline ----
// The header normals stay fixed on the target day; the chart + graded-days table
// below show one continuous timeline: recent history through the 7-day forecast,
// chronological, opened with today at the left edge.
let recentData = null; // /grade response (past) — the base render
let forecastData = null; // /forecast response (next 7 days), fetched in the background
const addDaysISO = (iso, n) => {
const d = new Date(iso + "T00:00:00Z");
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10);
};
// Newest-first overall: forecast (furthest→nearest) then past (newest→oldest).
// The forecast is only merged while it *continues* the observed run with no gap —
// so viewing a past week (whose recent days end well before today's live forecast)
// shows just that week instead of a disconnected forecast block after a date gap.
// Bounded to within 13 days of the anchored date and at most a week of forecast.
function combinedDays() {
const past = recentData ? recentData.recent : []; // newest-first; newest = target_date
const rawFut = forecastData ? forecastData.recent : []; // furthest-first (tomorrow…+7 reversed)
if (!rawFut.length || !past.length) return [...rawFut, ...past];
const limit = addDaysISO(recentData.target_date, 13); // forecast must fall within 13 days
let prev = past[0].date; // last observed day (the anchor)
const kept = [];
for (const f of [...rawFut].reverse()) { // walk oldest→newest
if (kept.length >= 7 || f.date > limit) break; // cap a week / 13-day window
if (f.date !== addDaysISO(prev, 1)) break; // a gap — stop merging the forecast
kept.push(f);
prev = f.date;
}
return [...kept.reverse(), ...past]; // restore furthest-first ordering
}
async function fetchForecast() {
if (!selected) return;
const { lat, lon } = selected;
try {
const d = await window.Thermograph.getJSON(`api/v2/forecast?lat=${lat}&lon=${lon}`, window.Thermograph.TTL.forecast);
if (!selected || selected.lat !== lat || selected.lon !== lon) return; // user moved on
forecastData = d;
renderSeries();
} catch (err) { /* forecast is a bonus — keep the recent view on failure */ }
}
function renderSeries() {
const host = document.getElementById("series");
if (!host || !recentData) return;
const combined = combinedDays(); // newest-first
const chartData = { ...recentData, recent: combined };
const today = recentData.target_date;
const hasFc = combined.some((d) => d.date > today); // forecast days actually merged in
host.innerHTML = `
<div class="section-title">Trend vs normal${hasFc ? " · recent → forecast" : ""}</div>
<div id="chart-wrap"></div>
${tierKeyHtml()}
<div class="section-title">Daily, graded${hasFc ? " — recent &amp; 7-day forecast" : ""}</div>
<div class="rd-orient"><span>← Older</span><span>Newer →</span></div>
${daysTable([...combined].reverse(), today) || '<p class="muted">Nothing to grade.</p>'}
`;
buildChart(chartData);
scrollTableToToday(host, today, hasFc);
}
// Open the timeline with today's column pinned at the left edge (after the sticky
// metric labels); before the forecast loads, just show the most-recent day.
function scrollTableToToday(host, today, hasFc) {
const scroll = host.querySelector(".rd-scroll");
if (!scroll) return;
requestAnimationFrame(() => {
const col = hasFc && scroll.querySelector(`.rd-colh[data-date="${today}"]`);
scroll.scrollLeft = col ? Math.max(0, col.offsetLeft - 46) : scroll.scrollWidth;
});
}
// ---- trend chart (self-contained inline SVG) ----
// Tier css class -> literal color (mirrors style.css; needed because the chart is
// rasterized to PNG, where CSS variables aren't available).
const TIER_COLORS = {
"rec-hot": "#8f0e20", "very-hot": "#dd6b52", "hot": "#ef9351", "warm": "#f6c18a",
"normal": "#4a9d5b",
"cool": "#92c5de", "cold": "#4393c3", "very-cold": "#2166ac", "rec-cold": "#0a2f6b",
"dry": "#c9a24a",
"wet-1": "#d9f0d3", "wet-2": "#b7e2b1", "wet-3": "#8fd18f", "wet-4": "#5cba9f", "wet-5": "#35a1c0",
"wet-6": "#2b8cbe", "wet-7": "#226bb3", "wet-8": "#164a97", "wet-9": "#08306b",
};
// Percentile grade scale, low -> high. Names are deliberately *relative* to each
// place's own climate history (a percentile), not absolute temperature words like
// "hot"/"cold" — see the README design note.
const GRADE_SCALE = [
{ cls: "rec-cold", label: "Near Record", range: "<1" },
{ cls: "very-cold", label: "Very Low", range: "110" },
{ cls: "cold", label: "Low", range: "1025" },
{ cls: "cool", label: "Below Normal", range: "2540" },
{ cls: "normal", label: "Normal", range: "4060" },
{ cls: "warm", label: "Above Normal", range: "6075" },
{ cls: "hot", label: "High", range: "7590" },
{ cls: "very-hot", label: "Very High", range: "9099" },
{ cls: "rec-hot", label: "Near Record", range: ">99" },
];
function tierKeyHtml() {
// Highest tier first (Near Record hot → Near Record cold).
const segs = [...GRADE_SCALE].reverse().map((t) =>
`<div class="tk-seg">
<span class="tk-swatch" style="background:${TIER_COLORS[t.cls]}"></span>
<span class="tk-label">${t.label}</span>
</div>`).join("");
return `<div class="section-title">Grade scale · low → high vs local normal</div>
<div class="tier-key">${segs}</div>
<p class="muted tk-note"><a href="legend">Full guide to metrics &amp; grades →</a></p>`;
}
// #rrggbb -> rgba() with alpha, so band tints derive from the same site colors.
const hexA = (hex, a) => {
let h = (hex || "").trim().replace("#", "");
if (h.length === 3) h = h.split("").map((c) => c + c).join("");
const n = parseInt(h || "888888", 16);
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`;
};
function chartPalette() {
// Read straight from the site's CSS variables so the chart matches the rest of
// the UI and follows the light/dark theme automatically. hiBand/loBand are
// applied once per tier ring (p1-99, p10-90, p25-75, p40-60) and stack, so keep
// each layer light — the center (Normal) darkens naturally.
const css = getComputedStyle(document.documentElement);
const v = (name, f) => (css.getPropertyValue(name).trim() || f);
const dark = matchMedia("(prefers-color-scheme: dark)").matches;
const hi = v("--very-hot", "#dd6b52"); // daily-high series = warm tier red
const lo = v("--cold", "#4393c3"); // daily-low series = cool tier blue
return {
surface: v("--surface-2", dark ? "#1e242c" : "#eef1f5"),
ink: v("--text", dark ? "#e7ecf2" : "#1a2029"),
muted: v("--muted", dark ? "#9aa6b2" : "#5b6673"),
grid: v("--border", dark ? "#2a323c" : "#dde3ea"),
accent: v("--accent", "#f0803c"),
hi, lo,
// Distinct line/point accents for the three added series; the graded fan
// behind them still uses the shared diverging tier colors (same as temps).
feels: v("--accent", "#f0803c"), // "feels like" = orange accent
humid: "#2ea9bf", // humidity = cyan
wind: "#5aa9a3", // wind speed = teal
gust: "#8f86d8", // wind gust = periwinkle
precip: v("--wet-6", "#2b8cbe"),
hiBand: hexA(hi, dark ? 0.15 : 0.11),
loBand: hexA(lo, dark ? 0.17 : 0.12),
};
}
const W = 720, H = 340;
const PL = 46, PR = 62, plotTop = 48, plotBot = 288, xLabY = 308;
const PW = W - PL - PR;
const ord = (n) => {
const r = Math.round(n), s = ["th", "st", "nd", "rd"], v = r % 100;
return r + (s[(v - 20) % 10] || s[v] || s[0]);
};
// Read a percentile from a normals object, falling back to nearby keys so an
// older/partial API response (missing p1/p30/p70/p99) still renders a chart.
const PCT_FALLBACK = {
p1: ["p1", "p10", "min", "p50"], p10: ["p10", "p50"],
p25: ["p25", "p30", "p50"], p40: ["p40", "p30", "p50"],
p60: ["p60", "p70", "p50"], p75: ["p75", "p70", "p50"],
p90: ["p90", "p50"], p99: ["p99", "p90", "max", "p50"],
};
const pget = (o, k) => {
for (const kk of (PCT_FALLBACK[k] || [k])) if (o && o[kk] != null) return o[kk];
return null;
};
let chartDays = [];
// Which chart category is shown. Persisted so a refresh keeps your selection.
let chartMetric = localStorage.getItem("thermograph:chartMetric") || "tmax";
function buildChart(data) {
const wrap = document.getElementById("chart-wrap");
const C = chartPalette();
lastGraded = data; // the series currently drawn (for PNG export)
chartDays = [...data.recent].reverse(); // oldest -> newest
const days = chartDays, n = days.length;
if (!n) { wrap.innerHTML = ""; return; }
const xFor = (i) => (n === 1 ? PL + PW / 2 : PL + (i * PW) / (n - 1));
// shared: x date labels
const step = n > 9 ? 2 : 1;
let xlab = "";
days.forEach((d, i) => {
if (i % step && i !== n - 1) return;
const dt = new Date(d.date + "T00:00:00");
xlab += `<text x="${xFor(i).toFixed(1)}" y="${xLabY}" text-anchor="middle" font-size="10.5" fill="${C.muted}">${dt.getMonth() + 1}/${dt.getDate()}</text>`;
});
// shared: hover hit targets over the plot
let hits = "";
days.forEach((_, i) => {
const w = n === 1 ? PW : PW / (n - 1);
hits += `<rect class="hit" data-i="${i}" x="${(xFor(i) - w / 2).toFixed(1)}" y="${plotTop}" width="${w.toFixed(1)}" height="${plotBot - plotTop}" fill="transparent"/>`;
});
const built = chartMetric === "precip" ? precipChart(days, n, xFor, C)
: chartMetric === "dry" ? dryChart(days, n, xFor, C)
: tempChart(chartMetric, days, n, xFor, C);
// Divider between the last observed day and the forecast, when the chart spans both.
let todayDiv = "";
const tIdx = days.findIndex((d) => d.date === data.target_date);
if (tIdx >= 0 && tIdx < n - 1) {
const xd = ((xFor(tIdx) + xFor(tIdx + 1)) / 2).toFixed(1);
todayDiv = `<line x1="${xd}" y1="${plotTop}" x2="${xd}" y2="${plotBot}" stroke="${C.accent}" stroke-width="1.2" stroke-dasharray="4 3" opacity="0.85"/>`
+ `<text x="${xd}" y="${plotTop - 3}" text-anchor="middle" font-size="9" font-weight="700" fill="${C.accent}">forecast →</text>`;
}
const title = `${placeLabel(data)} · through ${data.target_date}`;
const svg = `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" font-family="system-ui, -apple-system, Segoe UI, sans-serif" role="img" aria-label="${built.aria}">
<rect x="0" y="0" width="${W}" height="${H}" fill="${C.surface}"/>
<text x="${PL - 8}" y="24" font-size="12.5" font-weight="700" fill="${built.color}">${built.subtitle}</text>
<text x="${W - PR}" y="24" text-anchor="end" font-size="11" fill="${C.muted}">${title}</text>
${built.content}
${todayDiv}
${xlab}
<line id="crosshair" x1="0" y1="${plotTop}" x2="0" y2="${plotBot}" stroke="${C.muted}" stroke-width="1" stroke-dasharray="3 3" opacity="0"/>
${hits}
</svg>`;
const btn = (m, label) => `<button type="button" data-metric="${m}"${chartMetric === m ? ' class="active"' : ""}>${label}</button>`;
wrap.innerHTML = `
<div class="chart-toggle" id="chart-toggle" role="group" aria-label="Chart metric">
${btn("tmax", "High")}${btn("tmin", "Low")}${btn("feels", "Feels")}${btn("humid", "Humid")}${btn("wind", "Wind")}${btn("gust", "Gust")}${btn("precip", "Precip")}${btn("dry", "Dry")}
</div>
<div class="chart-legend">${built.legend}</div>
${svg}
<div id="chart-tip" class="chart-tip" hidden></div>`;
document.getElementById("chart-toggle").addEventListener("click", (e) => {
const b = e.target.closest("button[data-metric]");
if (!b || b.dataset.metric === chartMetric) return;
chartMetric = b.dataset.metric;
try { localStorage.setItem("thermograph:chartMetric", chartMetric); } catch (e) {}
buildChart(data);
});
attachChartHover(wrap);
}
// Per-series display meta for the diverging-scale metrics. `sfx` is the unit
// appended to axis + point labels ("°"); wind carries its unit in the subtitle
// instead of on every point to keep the plot uncluttered.
const SERIES = {
tmax: (C) => ({ color: C.hi, label: "Daily high", sfx: "°" }),
tmin: (C) => ({ color: C.lo, label: "Daily low", sfx: "°" }),
feels: (C) => ({ color: C.feels, label: "Feels like", sfx: "°" }),
humid: (C) => ({ color: C.humid, label: "Absolute humidity (g/m³)", sfx: "" }),
wind: (C) => ({ color: C.wind, label: "Wind speed (mph)", sfx: "" }),
gust: (C) => ({ color: C.gust, label: "Wind gust (mph)", sfx: "" }),
};
// Percentile "fan" tiers — bands between successive percentile marks, colored to
// match the calendar. Temperature is diverging (cold→green→hot); precipitation
// uses the sequential rain-intensity ramp.
const TEMP_FAN = [
["p90", "p99", "very-hot"], ["p75", "p90", "hot"], ["p60", "p75", "warm"],
["p40", "p60", "normal"], ["p25", "p40", "cool"], ["p10", "p25", "cold"], ["p1", "p10", "very-cold"],
];
const RAIN_FAN = [
["p90", "p99", "wet-8"], ["p75", "p90", "wet-7"], ["p60", "p75", "wet-6"],
["p40", "p60", "wet-5"], ["p25", "p40", "wet-4"], ["p10", "p25", "wet-3"], ["p1", "p10", "wet-2"],
];
// Shared line-series chart: an optional shaded percentile fan + dashed median, the
// value line with dots, and labeled points — so temperature, wind, humidity,
// precipitation and dry streak all read in one consistent style.
function lineChart(cfg) {
const { days, n, xFor, C, key, color, fan, hasNormals, baseZero,
getVal, getPct, dotColor, fmtAxis, fmtLabel, labelOn,
subtitle, legend, aria } = cfg;
// y-range: the plotted values plus the fan extremes (p1/p99) when present.
const vals = [];
days.forEach((d) => {
const v = getVal(d); if (v != null && !isNaN(v)) vals.push(v);
if (hasNormals && d.normals && d.normals[key]) {
const u = pget(d.normals[key], "p99"), l = pget(d.normals[key], "p1");
if (u != null) vals.push(u); if (l != null) vals.push(l);
}
});
let finite = vals.filter((v) => v != null && !isNaN(v));
if (!finite.length) finite = [0, 1];
let lo = Math.min(...finite), hi = Math.max(...finite);
if (baseZero) lo = Math.min(lo, 0);
if (hi <= lo) hi = lo + 1;
const pad = baseZero ? Math.max((hi - lo) * 0.12, 0.01) : Math.max((hi - lo) * 0.08, 3);
hi += pad; if (!baseZero) lo -= pad;
const yT = (v) => plotBot - ((v - lo) / (hi - lo)) * (plotBot - plotTop);
let grid = "";
for (let t = 0; t <= 4; t++) {
const v = lo + ((hi - lo) * t) / 4, y = yT(v).toFixed(1);
grid += `<line x1="${PL}" y1="${y}" x2="${W - PR}" y2="${y}" stroke="${C.grid}" stroke-width="1"/>`;
grid += `<text x="${PL - 8}" y="${+y + 4}" text-anchor="end" font-size="11" fill="${C.muted}">${fmtAxis(v)}</text>`;
}
// Shaded fan bands between percentile marks (the "highlights"), same as the calendar tiers.
const band = (dnKey, upKey, cls) => {
let top = "", bot = "";
days.forEach((d, i) => { const u = d.normals && pget(d.normals[key], upKey); if (u == null) return;
top += (top ? "L" : "M") + xFor(i).toFixed(1) + " " + yT(u).toFixed(1) + " "; });
for (let i = n - 1; i >= 0; i--) { const dd = days[i].normals && pget(days[i].normals[key], dnKey); if (dd == null) continue;
bot += "L" + xFor(i).toFixed(1) + " " + yT(dd).toFixed(1) + " "; }
return top ? `<path d="${top + bot}Z" fill="${hexA(TIER_COLORS[cls], 0.4)}"/>` : "";
};
const fanSvg = (hasNormals && fan) ? fan.map((t) => band(t[0], t[1], t[2])).join("") : "";
const normTrace = (pctKey) => {
let p = "", st = false;
days.forEach((d, i) => { const v = d.normals && pget(d.normals[key], pctKey); if (v == null) return;
p += (st ? "L" : "M") + xFor(i).toFixed(1) + " " + yT(v).toFixed(1) + " "; st = true; });
return p;
};
const valTrace = () => {
let p = "", st = false;
days.forEach((d, i) => { const v = getVal(d); if (v == null || isNaN(v)) { st = false; return; }
p += (st ? "L" : "M") + xFor(i).toFixed(1) + " " + yT(v).toFixed(1) + " "; st = true; });
return p;
};
const medianSvg = hasNormals
? `<path d="${normTrace("p50")}" fill="none" stroke="${color}" stroke-width="1.2" stroke-dasharray="4 4" opacity="0.55"/>` : "";
const dots = days.map((d, i) => {
const v = getVal(d); if (v == null || isNaN(v)) return "";
return `<circle cx="${xFor(i).toFixed(1)}" cy="${yT(v).toFixed(1)}" r="3.4" fill="${dotColor ? dotColor(d) : color}" stroke="${C.surface}" stroke-width="1.6"/>`;
}).join("");
const labels = days.map((d, i) => {
const v = getVal(d); if (v == null || isNaN(v)) return "";
if (labelOn && !labelOn(v, d)) return "";
const x = xFor(i).toFixed(1), y = yT(v);
const vy = (y - plotTop < 26 ? y + 15 : y - 15).toFixed(1); // flip below near the top
const pv = getPct ? getPct(d) : null;
const pct = pv == null ? "" :
`<tspan x="${x}" dy="9" font-size="7.5" font-weight="600" fill="${C.muted}">${ord(pv)}</tspan>`;
return `<text x="${x}" y="${vy}" text-anchor="middle" font-size="8.5" font-weight="700" fill="${C.ink}" stroke="${C.surface}" stroke-width="2.4" paint-order="stroke" stroke-linejoin="round">${fmtLabel(v)}${pct}</text>`;
}).join("");
const content = grid + fanSvg + medianSvg +
`<path d="${valTrace()}" fill="none" stroke="${color}" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round"/>` +
dots + labels;
return { content, legend, color, subtitle, aria };
}
// One temperature-scale series (High/Low/Feels/Wind/Gust).
function tempChart(key, days, n, xFor, C) {
const s = SERIES[key](C);
return lineChart({
days, n, xFor, C, key, color: s.color, fan: TEMP_FAN, hasNormals: true, baseZero: false,
getVal: (d) => (d[key] ? d[key].value : null),
getPct: (d) => (d[key] ? d[key].percentile : null),
fmtAxis: (v) => `${Math.round(v)}${s.sfx}`,
fmtLabel: (v) => `${Math.round(v)}${s.sfx}`,
legend:
`<span><i style="background:${s.color}"></i>${s.label} (dashed = median)</span>` +
`<span><i class="swatch-band" style="background:${hexA(TIER_COLORS.normal, 0.5)};border-color:${TIER_COLORS.normal}"></i>Band shaded by grade tier</span>` +
`<span class="legend-note">Points labeled value · percentile — grade from the band tier (scale below)</span>`,
subtitle: `${s.label} — recent trend vs normal`,
aria: `Recent ${s.label.toLowerCase()} versus the normal range.`,
});
}
// Precipitation as a line vs its normal range — same fan/median/labels style as
// temperature, so the metrics read consistently. Rain days are labeled; dry days
// sit on the baseline. The fan is the rain-intensity tier ramp.
function precipChart(days, n, xFor, C) {
return lineChart({
days, n, xFor, C, key: "precip", color: C.precip, fan: RAIN_FAN, hasNormals: true, baseZero: true,
getVal: (d) => (d.precip ? d.precip.value : null),
getPct: (d) => (d.precip ? d.precip.percentile : null),
// Rain days take their intensity-tier color; no-rain days warm with the dry
// streak (tan→red) so a dry spell reads as dry, matching the Dry chart.
dotColor: (d) => (d.precip && d.precip.value > 0 ? (TIER_COLORS[d.precip.class] || C.precip) : (drynessColor(d.dsr) || C.precip)),
fmtAxis: (v) => `${v.toFixed(2)}"`,
fmtLabel: (v) => `${v.toFixed(2)}"`,
labelOn: (v) => v > 0, // only label rain days; dry days rest on the baseline
legend:
`<span><i style="background:${C.precip}"></i>Precipitation (dashed = median)</span>` +
`<span><i class="swatch-band" style="background:${hexA(TIER_COLORS["wet-6"], 0.5)};border-color:${TIER_COLORS["wet-6"]}"></i>Band shaded by rain-intensity tier</span>` +
`<span class="legend-note">Rain days labeled amount · percentile (rain scale below)</span>`,
subtitle: "Precipitation — daily totals vs normal",
aria: "Daily precipitation totals versus the normal range.",
});
}
// "Days since last rain" dry-streak color: rain days are blue; each dry day warms
// from a light base toward dark red, saturating at 14 consecutive dry days.
// (Mirrors the calendar's dry-streak ramp.)
const DRY_BASE = [247, 217, 176], DRY_MAX = [122, 13, 26], DRY_CAP = 14;
const lerpRgb = (a, b, t) => `rgb(${a.map((x, i) => Math.round(x + (b[i] - x) * t)).join(",")})`;
function drynessColor(dsr) {
if (dsr == null) return "";
if (dsr === 0) return "#2b7bba"; // measurable rain that day
return lerpRgb(DRY_BASE, DRY_MAX, Math.min(dsr, DRY_CAP) / DRY_CAP);
}
// Dry streak as a line: days since last rain, dots warming with the streak; a rain
// day drops the line to 0 with a blue dot. (No climatology fan — it's not graded.)
function dryChart(days, n, xFor, C) {
return lineChart({
days, n, xFor, C, key: "dry", color: drynessColor(9), fan: null, hasNormals: false, baseZero: true,
getVal: (d) => (d.dsr == null ? null : d.dsr),
getPct: () => null,
dotColor: (d) => drynessColor(d.dsr),
fmtAxis: (v) => `${Math.round(v)}d`,
fmtLabel: (v) => `${Math.round(v)}`,
labelOn: (v) => v > 0, // label the streak length; rain days (0) just show the dot
legend:
`<span><i style="background:${drynessColor(9)}"></i>Days since last rain — dry streak</span>` +
`<span><i style="background:${drynessColor(0)}"></i>Rain that day (streak = 0)</span>`,
subtitle: "Dry streak — days since last measurable rain",
aria: "Days since last measurable rain, as a line.",
});
}
function attachChartHover(wrap) {
const svg = wrap.querySelector("svg");
const tip = wrap.querySelector("#chart-tip");
const cross = wrap.querySelector("#crosshair");
const C = chartPalette();
const pctLine = (label, g, unit, color) => {
if (!g) return `<div><b style="color:${color}">${label}</b> —</div>`;
const pct = g.percentile == null ? "" : ` · ${ord(g.percentile)} pct`;
const gc = TIER_COLORS[g.class] || "";
return `<div><b style="color:${color}">${label}</b> ${g.value}${unit}${pct} <span class="tt-grade" style="color:${gc}">${g.grade}</span></div>`;
};
// Pointer events cover mouse, touch and pen with one path, so tapping/dragging
// the chart works on phones (mousemove never fires on touchscreens).
const showTip = (r, e) => {
const d = chartDays[+r.dataset.i];
const x = +r.getAttribute("x") + +r.getAttribute("width") / 2;
cross.setAttribute("x1", x); cross.setAttribute("x2", x); cross.setAttribute("opacity", "0.7");
const dt = new Date(d.date + "T00:00:00");
const nt = d.normals.tmax, ni = d.normals.tmin;
tip.innerHTML = `<div class="tt-date">${dt.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })}</div>
${pctLine("High", d.tmax, "°", C.hi)}
${pctLine("Low", d.tmin, "°", C.lo)}
${pctLine("Feels", d.feels, "°", C.feels)}
${pctLine("Humidity", d.humid, " g/m³", C.humid)}
${pctLine("Wind", d.wind, " mph", C.wind)}
${pctLine("Gust", d.gust, " mph", C.gust)}
${pctLine("Precip", d.precip, '"', C.precip)}
<div><b>Dry</b> ${d.dsr == null ? "—" : d.dsr === 0 ? "rain today" : `${d.dsr} d since rain`}</div>
<div class="tt-norm">normal ${nt ? Math.round(nt.p50) : "—"}° / ${ni ? Math.round(ni.p50) : "—"}°</div>`;
tip.hidden = false;
const box = wrap.getBoundingClientRect();
let px = e.clientX - box.left + 12;
if (px > box.width - 160) px = e.clientX - box.left - 160;
tip.style.left = Math.max(4, px) + "px";
tip.style.top = Math.max(8, e.clientY - box.top - 10) + "px";
};
wrap.querySelectorAll("rect.hit").forEach((r) => {
r.addEventListener("pointermove", (e) => showTip(r, e));
r.addEventListener("pointerdown", (e) => showTip(r, e));
});
const hide = () => { tip.hidden = true; cross.setAttribute("opacity", "0"); };
svg.addEventListener("pointerleave", hide);
svg.addEventListener("pointerup", hide);
}
// ---- share ----
function copyShareLink() {
if (!selected) return;
const url = `${location.origin}${location.pathname}#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}&date=${dateInput.value}`;
navigator.clipboard.writeText(url).then(
() => flashBtn("btn-link", "✓ Copied!"),
() => { prompt("Copy this link:", url); }
);
}
const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// Build a table of graded days as SVG markup, stacked below the chart.
function exportTableSvg(C) {
const rows = lastGraded.recent; // newest first (matches the on-screen table)
const rowH = 27, headH = 42, padB = 16;
const top = H + 10;
const cDate = PL, cPcp = PL + 150, cHigh = PL + 322, cLow = PL + 494;
const totalH = top + headH + rows.length * rowH + padB;
const cell = (x, g, isPcp) => {
if (!g) return `<text x="${x}" y="0" font-size="12" fill="${C.muted}">—</text>`;
const color = TIER_COLORS[g.class] || C.ink;
const v = isPcp ? `${g.value.toFixed(2)}"` : `${Math.round(g.value)}°`;
return `<text x="${x}" y="0"><tspan font-size="13" font-weight="700" fill="${C.ink}">${v}</tspan>` +
`<tspan dx="7" font-size="11" font-weight="600" fill="${color}">${esc(g.grade)}</tspan></text>`;
};
const th = (x, t) =>
`<text x="${x}" y="${top + 30}" font-size="10.5" font-weight="700" letter-spacing="0.05em" fill="${C.muted}">${t}</text>`;
let body = "";
rows.forEach((d, i) => {
const y = top + headH + i * rowH;
const dt = new Date(d.date + "T00:00:00");
const dstr = dt.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
body += `<g transform="translate(0 ${y})">` +
(i ? `<line x1="${PL}" y1="-7" x2="${W - PR}" y2="-7" stroke="${C.grid}" stroke-width="1"/>` : "") +
`<text x="${cDate}" y="0" font-size="12.5" fill="${C.ink}">${esc(dstr)}</text>` +
cell(cPcp, d.precip, true) + cell(cHigh, d.tmax, false) + cell(cLow, d.tmin, false) +
`</g>`;
});
const markup = `
<text x="${PL}" y="${top + 8}" font-size="13" font-weight="700" fill="${C.ink}">Recent days, graded — ${esc(placeLabel(lastGraded))}</text>
${th(cDate, "DATE")}${th(cPcp, "PRECIP")}${th(cHigh, "HIGH")}${th(cLow, "LOW")}
<line x1="${PL}" y1="${top + headH - 9}" x2="${W - PR}" y2="${top + headH - 9}" stroke="${C.grid}" stroke-width="1.5"/>
${body}`;
return { markup, totalH };
}
function downloadChartPng() {
const chart = document.querySelector("#chart-wrap svg");
if (!chart || !lastGraded) return;
const C = chartPalette();
// Chart body minus interactive-only layers.
const clone = chart.cloneNode(true);
const cross = clone.querySelector("#crosshair"); if (cross) cross.remove();
clone.querySelectorAll("rect.hit").forEach((r) => r.remove());
const chartInner = clone.innerHTML;
const { markup, totalH } = exportTableSvg(C);
const xml = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${totalH}" width="${W * 2}" height="${totalH * 2}" font-family="system-ui, -apple-system, Segoe UI, sans-serif">` +
`<rect x="0" y="0" width="${W}" height="${totalH}" fill="${C.surface}"/>` +
chartInner + markup + `</svg>`;
const url = URL.createObjectURL(new Blob([xml], { type: "image/svg+xml;charset=utf-8" }));
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = W * 2; canvas.height = totalH * 2;
canvas.getContext("2d").drawImage(img, 0, 0);
URL.revokeObjectURL(url);
canvas.toBlob((blob) => {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `thermograph_${dateInput.value}.png`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
flashBtn("btn-png", "✓ Saved");
}, "image/png");
};
img.src = url;
}
function flashBtn(id, text) {
const b = document.getElementById(id);
if (!b) return;
const orig = b.innerHTML; // preserve the inline icon markup
b.textContent = text;
setTimeout(() => (b.innerHTML = orig), 1600);
}
function updateHash() {
if (!selected) return;
history.replaceState(null, "", `#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}&date=${dateInput.value}`);
window.Thermograph.saveLastLocation(selected.lat, selected.lon, dateInput.value);
}
// Start where you left off: an explicit link (hash) wins, otherwise the last
// place you looked at (remembered across views), otherwise the default US map.
function restoreFromHash() {
const loc = window.Thermograph.loadLastLocation();
if (!loc) return;
if (loc.date) dateInput.value = loc.date;
syncTodayBtn();
selectLocation(loc.lat, loc.lon);
}
restoreFromHash();