thermograph/static/app.js
Emi Griffith a251b0df23 Extract shared.js: one home for tier colors, scales, formatters, helpers (#47)
The page scripts each re-declared the shared presentation layer — the tier
color table existed in four places (app.js, calendar.js, day.js, style.css)
and the scale label tables in three (plus legend.html's own drifting copy),
alongside per-page copies of the dryness ramp, formatters, ord, todayISO,
esc, the weather icons/summary, month helpers and the 2-year range chunker.
~240 duplicated lines deleted (net -236 with the new module included).

- frontend/shared.js (IIFE, extends window.Thermograph): tier colors read
  from style.css's :root custom properties at load — the CSS is now the
  single source of truth; the JS map exists only because inline-SVG work
  (chart + PNG export) needs literal values, with hex fallbacks for a
  missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord,
  todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker,
  and the weatherType summary (dsr-aware; the day page just omits dsr).
- nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in
  the shared classic-script scope, and a leaked locHash collided with page
  destructuring (caught by the browser smoke, not by node --check).
  locHash is now exported and used by all 8 former hand-built hash sites.
- mappicker.js: initFindButton/setFindLabel replace the Find-button block
  each page rebuilt.
- legend.html renders its scales from the shared tables, so the guide can
  no longer drift from what the app shows.

Verified: node --check on all JS; 108 backend tests; headless-Chromium
smoke over all five pages against live data — no console/page errors,
legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle,
calendar grid + key + metric switch, day 7 ladders + weather icon,
compare seeded rank card.
2026-07-11 20:21:48 +00:00

778 lines
38 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";
// Shared tier colors, scales, formatters and helpers (single home: shared.js).
const { TIER_COLORS, SCALE_TEMP, drynessColor, ord, esc, todayISO, placeLabel,
fmtPrecip, fmtWind, fmtHumid, locHash } = window.Thermograph;
let selected = null; // {lat, lon}
const dateInput = document.getElementById("date-input");
const todayBtn = document.getElementById("today-btn");
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 };
// Show the raw coordinates immediately; render() replaces them with the resolved
// neighborhood + city name once the grade fetch returns.
locLabel.textContent = `${lat.toFixed(3)}, ${lon.toFixed(3)}`;
locLabel.hidden = false;
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");
window.LocationPicker.initFindButton(findBtn, "Find a location",
() => 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${locHash(selected.lat, selected.lon, row.dataset.date)}`;
});
let gradeSeq = 0; // supersedes an in-flight load when the user picks a new spot/date
async function runGrade() {
if (!selected) return;
updateHash();
placeholder.hidden = true;
results.hidden = false;
const seq = ++gradeSeq;
// Delay the spinner: a cache-served render lands in a few ms, so blanking the
// panel immediately would just flash. Only a genuinely slow (network) load
// ever shows it.
const spin = setTimeout(() => {
if (seq === gradeSeq) results.innerHTML = `<p class="spinner">Fetching decades of climate history…</p>`;
}, 150);
// 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 {
// Stale-while-revalidate: a stale cached copy renders immediately and the
// update callback repaints if the background revalidation finds new data.
data = await window.Thermograph.getJSON(url, window.Thermograph.TTL.grade, false, (upd) => {
if (seq === gradeSeq) render(upd);
});
} catch (err) {
clearTimeout(spin);
if (seq !== gradeSeq) return;
results.innerHTML = `<p class="error">${err.message}</p>`;
return;
}
clearTimeout(spin);
if (seq !== gradeSeq) return;
render(data);
}
// Temps flow through the shared unit formatter (°F from the API, shown in the
// active unit); the others are unit-agnostic.
const fmtTemp = (v) => window.Thermograph.fmtTemp(v);
// 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) => window.Thermograph.fmtTemp(v); // active unit, bare degree
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 render(data) {
recentData = data;
// The place name appears once — as the results heading (<h2>) below, which also
// carries the coordinates + climatology context. The top label only holds the
// transient coordinates written on selection, so hide it now that the named
// results have rendered (otherwise the name would show twice).
locLabel.hidden = true;
locLabel.textContent = "";
window.LocationPicker.setFindLabel(findBtn, "Change location");
const c = data.climatology;
const cell = data.cell;
const yrs = c.year_range ? `${c.year_range[0]}${c.year_range[1]}` : "—";
const coords = `${cell.center_lat.toFixed(3)}, ${cell.center_lon.toFixed(3)}`;
// Each card compares the target day's value to the normal (median) for that
// metric, tinted by its grade, and links to the full single-day breakdown. The
// window now runs past the target into the forecast, so the target is looked up
// by date rather than taken as the newest row.
const targetDay = data.recent.find((d) => d.date === data.target_date) || null;
const dayHref = `day${locHash(selected.lat, selected.lon, data.target_date)}`;
const cmpCard = (label, key, clim, fmt) => {
const g = targetDay ? targetDay[key] : null;
const normalVal = clim ? fmt(clim.p50) : "—";
const cat = g ? (TIER_COLORS[g.class] || "") : "";
const big = g ? fmt(g.value) : normalVal;
const grade = g
? `<span class="nc-grade" style="--cat:${cat}">${g.grade}</span>`
: "";
return `<a class="normal-card" href="${dayHref}">
<div class="nc-head"><h3>${label}</h3>${grade}</div>
<div class="big"${g ? ` style="--cat:${cat}"` : ""}>${big}</div>
<div class="range">normal: <b>${normalVal}</b></div>
</a>`;
};
results.innerHTML = `
<div class="loc-head">
<div class="loc-title">
<h2>${placeLabel(data)}</h2>
<ul class="loc-meta">
${data.place ? `<li><b>${coords}</b></li>` : ""}
<li>Climatology from <b>${yrs}</b></li>
<li><b>${c.n_samples.toLocaleString()}</b> days sample size (day ±7)</li>
</ul>
</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${locHash(selected.lat, selected.lon)}">
<span class="cta-cal-icon" aria-hidden="true">${IC.calendar}</span>
<span>Check historical data</span>
<span class="cta-cal-arrow" aria-hidden="true">→</span>
</a>
<div class="section-title">${data.target_date} vs a typical day — 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 the window
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, targetDate) {
if (!rows.length) return "";
const todayDate = todayISO();
const isFc = (d) => d.date > todayDate; // future = forecast columns
const isTarget = (d) => d.date === targetDate; // the graded target day (orange marker)
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" + (isTarget(d) ? " rd-today" : isFc(d) ? " rd-fc" : "");
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), isTarget(d))
: rdCell(d[key], fmt, d.date, isFc(d), isTarget(d))).join("")
).join("");
return `<div class="rd-scroll"><div class="rd-grid" style="${cols}">${header}${body}</div></div>`;
}
// ---- graded-days timeline ----
// The header normals stay fixed on the target day; the chart + graded-days table
// below show one continuous window built server-side: two weeks of history before
// the target, the target itself (orange marker), then the days after it — real
// observations when they're in the past, the forward forecast when they run into
// the future (see backend _build_grade).
let recentData = null; // /grade response — the whole window, newest-first
// Repaint everything in the newly-picked unit.
window.Thermograph.onUnitChange(() => {
if (recentData) render(recentData);
});
// Rebuild the chart when the window is resized so its drawing width keeps
// tracking the container (debounced — resize fires continuously while dragging).
let resizeTimer = null;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => { if (recentData) renderSeries(); }, 160);
});
function renderSeries() {
const host = document.getElementById("series");
if (!host || !recentData) return;
const days = recentData.recent; // newest-first: history · target · after/forecast
const target = recentData.target_date;
const hasFc = days.some((d) => d.date > todayISO()); // window runs into the forecast
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([...days].reverse(), target) || '<p class="muted">Nothing to grade.</p>'}
`;
buildChart(recentData);
scrollTableToTarget(host, target);
}
// Open the timeline centered on the target day's column (the orange marker), so
// the two weeks of history and the days after it are both in view.
function scrollTableToTarget(host, target) {
const scroll = host.querySelector(".rd-scroll");
if (!scroll) return;
requestAnimationFrame(() => {
const col = scroll.querySelector(`.rd-colh[data-date="${target}"]`);
if (!col) { scroll.scrollLeft = scroll.scrollWidth; return; }
scroll.scrollLeft = Math.max(0, col.offsetLeft - (scroll.clientWidth - col.offsetWidth) / 2);
});
}
// ---- trend chart (self-contained inline SVG) ----
function tierKeyHtml() {
// 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("");
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),
};
}
// W/PW are recomputed per build from the container width (see buildChart), so a
// wide monitor gets a genuinely wider drawing — more room between days — instead
// of the same 720-unit canvas magnified.
let W = 720;
const H = 340;
const PL = 46, PR = 62, plotTop = 48, plotBot = 288, xLabY = 308;
let PW = W - PL - PR;
// 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();
// Drawing width tracks the on-screen box at a steady ~1.5 CSS px per SVG unit,
// so text renders the same size everywhere while wide monitors gain plot room.
// Phones keep the 720-unit floor (the SVG just scales down).
W = Math.max(720, Math.min(1400, Math.round((wrap.clientWidth || 720) / 1.5)));
PW = W - PL - PR;
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);
// Solid orange marker line ON the target day, plus a dashed "forecast →" divider
// where the window crosses from the past into the future — drawn only when it's
// clearly separated from the target marker (i.e. the target is in the past; when
// the target is today the marker itself already sits at that boundary).
let marks = "";
const tIdx = days.findIndex((d) => d.date === data.target_date);
if (tIdx >= 0) {
const xt = xFor(tIdx).toFixed(1);
const td = new Date(data.target_date + "T00:00:00");
marks += `<line x1="${xt}" y1="${plotTop}" x2="${xt}" y2="${plotBot}" stroke="${C.accent}" stroke-width="1.8" opacity="0.9"/>`
+ `<text x="${xt}" y="${plotTop - 3}" text-anchor="middle" font-size="9" font-weight="700" fill="${C.accent}">${td.getMonth() + 1}/${td.getDate()}</text>`;
}
const fIdx = days.findIndex((d) => d.date > todayISO()); // first forecast (future) day
if (fIdx > tIdx + 1) {
const xd = ((xFor(fIdx - 1) + xFor(fIdx)) / 2).toFixed(1);
marks += `<line x1="${xd}" y1="${plotTop}" x2="${xd}" y2="${plotBot}" stroke="${C.accent}" stroke-width="1.1" stroke-dasharray="4 3" opacity="0.55"/>`
+ `<text x="${xd}" y="${plotTop - 3}" text-anchor="middle" font-size="8.5" font-weight="700" fill="${C.accent}" opacity="0.8">forecast →</text>`;
}
const title = `${placeLabel(data)} · ${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}
${marks}
${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);
// Temperature series (° suffix) show in the active unit; wind/gust stay in mph.
// The plot keeps its °F domain — only the labels convert — so positions hold.
const isTemp = s.sfx === "°";
const fmtV = (v) => `${Math.round(isTemp ? window.Thermograph.toUnit(v) : v)}${s.sfx}`;
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: fmtV,
fmtLabel: fmtV,
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.",
});
}
// 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] || "";
// "°" marks a temperature line — show it in the active unit; others are as-is.
const val = unit === "°" ? Math.round(window.Thermograph.toUnit(g.value)) : g.value;
return `<div><b style="color:${color}">${label}</b> ${val}${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 ? fmtTemp(nt.p50) : "—"} / ${ni ? fmtTemp(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}${locHash(selected.lat, selected.lon, dateInput.value)}`;
navigator.clipboard.writeText(url).then(
() => flashBtn("btn-link", "✓ Copied!"),
() => { prompt("Copy this link:", url); }
);
}
// 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)}"` : fmtTemp(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, "", locHash(selected.lat, selected.lon, 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 world 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();