diff --git a/static/app.js b/static/app.js
index f7953f9..d91de51 100644
--- a/static/app.js
+++ b/static/app.js
@@ -1,8 +1,10 @@
// Weekly (map) page. Imports replace the old script-tag ordering contract.
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
-import { fmtTemp, toUnit, onUnitChange } from "./units.js";
+import { fmtTemp, onUnitChange } from "./units.js";
import { getJSON, TTL, prefetchViews } from "./cache.js";
import { initFindButton, setFindLabel } from "./mappicker.js";
+import { W, PW, H, PL, PR, plotTop, plotBot, xLabY, setChartWidth, chartPalette,
+ tempChart, precipChart, dryChart, attachChartHover } from "./chart.js";
import { TIER_COLORS, SCALE_TEMP, drynessColor, ord, esc, todayISO, placeLabel,
fmtPrecip, fmtWind, fmtHumid } from "./shared.js";
@@ -305,64 +307,6 @@ function tierKeyHtml() {
Full guide to metrics & grades →
`;
}
-// #rrggbb -> rgba() with alpha, so band tints derive from the same site colors.
-const hexA = (hex, a) => {
- let h = (hex || "").trim().replace("#", "");
- if (h.length === 3) h = h.split("").map((c) => c + c).join("");
- const n = parseInt(h || "888888", 16);
- return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`;
-};
-
-function chartPalette() {
- // Read straight from the site's CSS variables so the chart matches the rest of
- // the UI and follows the light/dark theme automatically. hiBand/loBand are
- // applied once per tier ring (p1-99, p10-90, p25-75, p40-60) and stack, so keep
- // each layer light — the center (Normal) darkens naturally.
- const css = getComputedStyle(document.documentElement);
- const v = (name, f) => (css.getPropertyValue(name).trim() || f);
- const dark = matchMedia("(prefers-color-scheme: dark)").matches;
- const hi = v("--very-hot", "#dd6b52"); // daily-high series = warm tier red
- const lo = v("--cold", "#4393c3"); // daily-low series = cool tier blue
- return {
- surface: v("--surface-2", dark ? "#1e242c" : "#eef1f5"),
- ink: v("--text", dark ? "#e7ecf2" : "#1a2029"),
- muted: v("--muted", dark ? "#9aa6b2" : "#5b6673"),
- grid: v("--border", dark ? "#2a323c" : "#dde3ea"),
- accent: v("--accent", "#f0803c"),
- hi, lo,
- // Distinct line/point accents for the three added series; the graded fan
- // behind them still uses the shared diverging tier colors (same as temps).
- feels: v("--accent", "#f0803c"), // "feels like" = orange accent
- humid: "#2ea9bf", // humidity = cyan
- wind: "#5aa9a3", // wind speed = teal
- gust: "#8f86d8", // wind gust = periwinkle
- precip: v("--wet-6", "#2b8cbe"),
- hiBand: hexA(hi, dark ? 0.15 : 0.11),
- loBand: hexA(lo, dark ? 0.17 : 0.12),
- };
-}
-
-// 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";
@@ -370,11 +314,9 @@ 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;
+ // Drawing width tracks the on-screen box (see chart.js) so wide monitors gain
+ // plot room while phones keep the 720-unit floor (the SVG just scales down).
+ setChartWidth(wrap.clientWidth);
lastGraded = data; // the series currently drawn (for PNG export)
chartDays = [...data.recent].reverse(); // oldest -> newest
const days = chartDays, n = days.length;
@@ -447,225 +389,7 @@ function buildChart(data) {
try { localStorage.setItem("thermograph:chartMetric", chartMetric); } catch (e) {}
buildChart(data);
});
- attachChartHover(wrap);
-}
-
-// Per-series display meta for the diverging-scale metrics. `sfx` is the unit
-// appended to axis + point labels ("°"); wind carries its unit in the subtitle
-// instead of on every point to keep the plot uncluttered.
-const SERIES = {
- tmax: (C) => ({ color: C.hi, label: "Daily high", sfx: "°" }),
- tmin: (C) => ({ color: C.lo, label: "Daily low", sfx: "°" }),
- feels: (C) => ({ color: C.feels, label: "Feels like", sfx: "°" }),
- humid: (C) => ({ color: C.humid, label: "Absolute humidity (g/m³)", sfx: "" }),
- wind: (C) => ({ color: C.wind, label: "Wind speed (mph)", sfx: "" }),
- gust: (C) => ({ color: C.gust, label: "Wind gust (mph)", sfx: "" }),
-};
-
-// Percentile "fan" tiers — bands between successive percentile marks, colored to
-// match the calendar. Temperature is diverging (cold→green→hot); precipitation
-// uses the sequential rain-intensity ramp.
-const TEMP_FAN = [
- ["p90", "p99", "very-hot"], ["p75", "p90", "hot"], ["p60", "p75", "warm"],
- ["p40", "p60", "normal"], ["p25", "p40", "cool"], ["p10", "p25", "cold"], ["p1", "p10", "very-cold"],
-];
-const RAIN_FAN = [
- ["p90", "p99", "wet-8"], ["p75", "p90", "wet-7"], ["p60", "p75", "wet-6"],
- ["p40", "p60", "wet-5"], ["p25", "p40", "wet-4"], ["p10", "p25", "wet-3"], ["p1", "p10", "wet-2"],
-];
-
-// Shared line-series chart: an optional shaded percentile fan + dashed median, the
-// value line with dots, and labeled points — so temperature, wind, humidity,
-// precipitation and dry streak all read in one consistent style.
-function lineChart(cfg) {
- const { days, n, xFor, C, key, color, fan, hasNormals, baseZero,
- getVal, getPct, dotColor, fmtAxis, fmtLabel, labelOn,
- subtitle, legend, aria } = cfg;
-
- // y-range: the plotted values plus the fan extremes (p1/p99) when present.
- const vals = [];
- days.forEach((d) => {
- const v = getVal(d); if (v != null && !isNaN(v)) vals.push(v);
- if (hasNormals && d.normals && d.normals[key]) {
- const u = pget(d.normals[key], "p99"), l = pget(d.normals[key], "p1");
- if (u != null) vals.push(u); if (l != null) vals.push(l);
- }
- });
- let finite = vals.filter((v) => v != null && !isNaN(v));
- if (!finite.length) finite = [0, 1];
- let lo = Math.min(...finite), hi = Math.max(...finite);
- if (baseZero) lo = Math.min(lo, 0);
- if (hi <= lo) hi = lo + 1;
- const pad = baseZero ? Math.max((hi - lo) * 0.12, 0.01) : Math.max((hi - lo) * 0.08, 3);
- hi += pad; if (!baseZero) lo -= pad;
- const yT = (v) => plotBot - ((v - lo) / (hi - lo)) * (plotBot - plotTop);
-
- let grid = "";
- for (let t = 0; t <= 4; t++) {
- const v = lo + ((hi - lo) * t) / 4, y = yT(v).toFixed(1);
- grid += ``;
- grid += `${fmtAxis(v)}`;
- }
-
- // Shaded fan bands between percentile marks (the "highlights"), same as the calendar tiers.
- const band = (dnKey, upKey, cls) => {
- let top = "", bot = "";
- days.forEach((d, i) => { const u = d.normals && pget(d.normals[key], upKey); if (u == null) return;
- top += (top ? "L" : "M") + xFor(i).toFixed(1) + " " + yT(u).toFixed(1) + " "; });
- for (let i = n - 1; i >= 0; i--) { const dd = days[i].normals && pget(days[i].normals[key], dnKey); if (dd == null) continue;
- bot += "L" + xFor(i).toFixed(1) + " " + yT(dd).toFixed(1) + " "; }
- return top ? `` : "";
- };
- const fanSvg = (hasNormals && fan) ? fan.map((t) => band(t[0], t[1], t[2])).join("") : "";
-
- const normTrace = (pctKey) => {
- let p = "", st = false;
- days.forEach((d, i) => { const v = d.normals && pget(d.normals[key], pctKey); if (v == null) return;
- p += (st ? "L" : "M") + xFor(i).toFixed(1) + " " + yT(v).toFixed(1) + " "; st = true; });
- return p;
- };
- const valTrace = () => {
- let p = "", st = false;
- days.forEach((d, i) => { const v = getVal(d); if (v == null || isNaN(v)) { st = false; return; }
- p += (st ? "L" : "M") + xFor(i).toFixed(1) + " " + yT(v).toFixed(1) + " "; st = true; });
- return p;
- };
- const medianSvg = hasNormals
- ? `` : "";
-
- const dots = days.map((d, i) => {
- const v = getVal(d); if (v == null || isNaN(v)) return "";
- return ``;
- }).join("");
- const labels = days.map((d, i) => {
- const v = getVal(d); if (v == null || isNaN(v)) return "";
- if (labelOn && !labelOn(v, d)) return "";
- const x = xFor(i).toFixed(1), y = yT(v);
- const vy = (y - plotTop < 26 ? y + 15 : y - 15).toFixed(1); // flip below near the top
- const pv = getPct ? getPct(d) : null;
- const pct = pv == null ? "" :
- `${ord(pv)}`;
- return `${fmtLabel(v)}${pct}`;
- }).join("");
-
- const content = grid + fanSvg + medianSvg +
- `` +
- dots + labels;
-
- return { content, legend, color, subtitle, aria };
-}
-
-// One temperature-scale series (High/Low/Feels/Wind/Gust).
-function tempChart(key, days, n, xFor, C) {
- const s = SERIES[key](C);
- // 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 ? 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:
- `${s.label} (dashed = median)` +
- `Band shaded by grade tier` +
- `Points labeled value · percentile — grade from the band tier (scale below)`,
- subtitle: `${s.label} — recent trend vs normal`,
- aria: `Recent ${s.label.toLowerCase()} versus the normal range.`,
- });
-}
-
-// Precipitation as a line vs its normal range — same fan/median/labels style as
-// temperature, so the metrics read consistently. Rain days are labeled; dry days
-// sit on the baseline. The fan is the rain-intensity tier ramp.
-function precipChart(days, n, xFor, C) {
- return lineChart({
- days, n, xFor, C, key: "precip", color: C.precip, fan: RAIN_FAN, hasNormals: true, baseZero: true,
- getVal: (d) => (d.precip ? d.precip.value : null),
- getPct: (d) => (d.precip ? d.precip.percentile : null),
- // 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:
- `Precipitation (dashed = median)` +
- `Band shaded by rain-intensity tier` +
- `Rain days labeled amount · percentile (rain scale below)`,
- subtitle: "Precipitation — daily totals vs normal",
- aria: "Daily precipitation totals versus the normal range.",
- });
-}
-
-// Dry streak as a line: days since last rain, dots warming with the streak; a rain
-// day drops the line to 0 with a blue dot. (No climatology fan — it's not graded.)
-function dryChart(days, n, xFor, C) {
- return lineChart({
- days, n, xFor, C, key: "dry", color: drynessColor(9), fan: null, hasNormals: false, baseZero: true,
- getVal: (d) => (d.dsr == null ? null : d.dsr),
- getPct: () => null,
- dotColor: (d) => drynessColor(d.dsr),
- fmtAxis: (v) => `${Math.round(v)}d`,
- fmtLabel: (v) => `${Math.round(v)}`,
- labelOn: (v) => v > 0, // label the streak length; rain days (0) just show the dot
- legend:
- `Days since last rain — dry streak` +
- `Rain that day (streak = 0)`,
- subtitle: "Dry streak — days since last measurable rain",
- aria: "Days since last measurable rain, as a line.",
- });
-}
-
-function attachChartHover(wrap) {
- const svg = wrap.querySelector("svg");
- const tip = wrap.querySelector("#chart-tip");
- const cross = wrap.querySelector("#crosshair");
- const C = chartPalette();
- const pctLine = (label, g, unit, color) => {
- if (!g) return `${label} —
`;
- const pct = g.percentile == null ? "" : ` · ${ord(g.percentile)} pct`;
- const gc = TIER_COLORS[g.class] || "";
- // "°" marks a temperature line — show it in the active unit; others are as-is.
- const val = unit === "°" ? Math.round(toUnit(g.value)) : g.value;
- return `${label} ${val}${unit}${pct} ${g.grade}
`;
- };
-
- // Pointer events cover mouse, touch and pen with one path, so tapping/dragging
- // the chart works on phones (mousemove never fires on touchscreens).
- const showTip = (r, e) => {
- const d = chartDays[+r.dataset.i];
- const x = +r.getAttribute("x") + +r.getAttribute("width") / 2;
- cross.setAttribute("x1", x); cross.setAttribute("x2", x); cross.setAttribute("opacity", "0.7");
- const dt = new Date(d.date + "T00:00:00");
- const nt = d.normals.tmax, ni = d.normals.tmin;
- tip.innerHTML = `${dt.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })}
- ${pctLine("High", d.tmax, "°", C.hi)}
- ${pctLine("Low", d.tmin, "°", C.lo)}
- ${pctLine("Feels", d.feels, "°", C.feels)}
- ${pctLine("Humidity", d.humid, " g/m³", C.humid)}
- ${pctLine("Wind", d.wind, " mph", C.wind)}
- ${pctLine("Gust", d.gust, " mph", C.gust)}
- ${pctLine("Precip", d.precip, '"', C.precip)}
- Dry ${d.dsr == null ? "—" : d.dsr === 0 ? "rain today" : `${d.dsr} d since rain`}
- normal ${nt ? fmtTemp(nt.p50) : "—"} / ${ni ? fmtTemp(ni.p50) : "—"}
`;
- tip.hidden = false;
- const box = wrap.getBoundingClientRect();
- let px = e.clientX - box.left + 12;
- if (px > box.width - 160) px = e.clientX - box.left - 160;
- tip.style.left = Math.max(4, px) + "px";
- tip.style.top = Math.max(8, e.clientY - box.top - 10) + "px";
- };
-
- wrap.querySelectorAll("rect.hit").forEach((r) => {
- r.addEventListener("pointermove", (e) => showTip(r, e));
- r.addEventListener("pointerdown", (e) => showTip(r, e));
- });
- const hide = () => { tip.hidden = true; cross.setAttribute("opacity", "0"); };
- svg.addEventListener("pointerleave", hide);
- svg.addEventListener("pointerup", hide);
+ attachChartHover(wrap, chartDays);
}
// ---- share ----
diff --git a/static/cache.js b/static/cache.js
index e329e24..592f10b 100644
--- a/static/cache.js
+++ b/static/cache.js
@@ -128,6 +128,40 @@ export async function getJSON(url, ttlMs = 600000, persist = false, onUpdate = n
catch (e) { if (rec) return rec.d; throw e; } // offline/unreachable → stale beats an error
}
+// Load an ordered list of URLs through the cache with a bounded-parallel pool.
+// Results (or {error}) are reported in place via onResult(i, result, isUpdate) —
+// including stale-while-revalidate updates — so callers can render the
+// contiguous loaded prefix while later chunks are still in flight. `isCurrent`
+// is the caller's supersession guard: once it returns false the pool stops
+// launching and reports nothing more. Resolves when all chunks settle (or the
+// caller is superseded).
+export async function chunkedFetch(urls, { ttl, concurrency = 4, isCurrent = () => true, onResult }) {
+ const run = async (i) => {
+ try {
+ const d = await getJSON(urls[i], ttl, true, (upd) => {
+ if (isCurrent()) onResult(i, upd, true);
+ });
+ if (isCurrent()) onResult(i, d, false);
+ } catch (err) {
+ if (isCurrent()) onResult(i, { error: err }, false);
+ }
+ };
+ await new Promise((resolve) => {
+ let launched = 0, settled = 0;
+ const pump = () => {
+ if (!isCurrent()) return resolve();
+ while (launched < urls.length && launched - settled < concurrency) {
+ run(launched++).finally(() => {
+ settled++;
+ if (settled === urls.length) resolve();
+ else pump();
+ });
+ }
+ };
+ pump();
+ });
+}
+
// Is there already a usable (fresh, same-day) cache entry for this URL? Lets the
// prefetch skip views that are already warm and only reach out for the ones missing.
export async function hasFreshCache(url, ttlMs) {
diff --git a/static/calendar.js b/static/calendar.js
index 94bc13f..37ed0cd 100644
--- a/static/calendar.js
+++ b/static/calendar.js
@@ -3,7 +3,7 @@
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
// The tooltip reads fmtTemp live, so a unit switch shows on the next hover.
import { fmtTemp } from "./units.js";
-import { getJSON, TTL, prefetchViews } from "./cache.js";
+import { TTL, chunkedFetch, prefetchViews } from "./cache.js";
import { initFindButton, setFindLabel } from "./mappicker.js";
import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, ord,
fmtPrecip, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd,
@@ -398,47 +398,24 @@ async function fetchCalendar() {
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 {
- // Stale-while-revalidate: a stale cached chunk renders immediately; if the
- // background revalidation finds changed data, re-merge that chunk's days
- // and repaint (the cell/place metadata is identical across versions).
- const d = await getJSON(url, TTL.calendar, true, (upd) => {
- if (token !== fetchToken) return;
- results[i] = upd;
- for (const x of upd.days) dayMap.set(x.date, x);
- advance();
- });
- if (token !== fetchToken) return;
+ const urls = chunks.map((ch) => ch
+ ? `api/v2/calendar?${q}&start=${ch.start}&end=${ch.end}`
+ : `api/v2/calendar?${q}&months=24`);
+ // Bounded-parallel streaming load (see cache.js). Stale-while-revalidate
+ // updates re-merge that chunk's days and repaint — the cell/place metadata is
+ // identical across versions, and initOnce self-guards after the first chunk.
+ await chunkedFetch(urls, {
+ ttl: TTL.calendar,
+ concurrency: CHUNK_CONCURRENCY,
+ isCurrent: () => token === fetchToken,
+ onResult: (i, d) => {
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();
- });
+ if (!d.error) {
+ for (const x of d.days) dayMap.set(x.date, x);
+ initOnce(d);
}
- };
- pump();
+ advance();
+ },
});
clearTimeout(spin);
if (token !== fetchToken) return; // superseded while loading
diff --git a/static/chart.js b/static/chart.js
new file mode 100644
index 0000000..225f26a
--- /dev/null
+++ b/static/chart.js
@@ -0,0 +1,290 @@
+// The trend chart: a shared line-series renderer (percentile fan + median +
+// value trace + labeled points) with per-metric wrappers, the site-matched
+// palette, and the pointer-driven hover. Extracted from app.js so any page can
+// plot a metric series; app.js keeps only its page orchestration (buildChart).
+import { fmtTemp, toUnit } from "./units.js";
+import { TIER_COLORS, drynessColor, ord } from "./shared.js";
+
+// #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})`;
+};
+
+export 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 (setChartWidth), so a
+// wide monitor gets a genuinely wider drawing — more room between days — instead
+// of the same 720-unit canvas magnified. Live bindings: importers see updates.
+export let W = 720;
+export const H = 340;
+export const PL = 46, PR = 62, plotTop = 48, plotBot = 288, xLabY = 308;
+export let PW = W - PL - PR;
+
+// 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.
+export function setChartWidth(clientWidth) {
+ W = Math.max(720, Math.min(1400, Math.round((clientWidth || 720) / 1.5)));
+ 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;
+};
+
+
+// Per-series display meta for the diverging-scale metrics. `sfx` is the unit
+// appended to axis + point labels ("°"); wind carries its unit in the subtitle
+// instead of on every point to keep the plot uncluttered.
+const SERIES = {
+ tmax: (C) => ({ color: C.hi, label: "Daily high", sfx: "°" }),
+ tmin: (C) => ({ color: C.lo, label: "Daily low", sfx: "°" }),
+ feels: (C) => ({ color: C.feels, label: "Feels like", sfx: "°" }),
+ humid: (C) => ({ color: C.humid, label: "Absolute humidity (g/m³)", sfx: "" }),
+ wind: (C) => ({ color: C.wind, label: "Wind speed (mph)", sfx: "" }),
+ gust: (C) => ({ color: C.gust, label: "Wind gust (mph)", sfx: "" }),
+};
+
+// Percentile "fan" tiers — bands between successive percentile marks, colored to
+// match the calendar. Temperature is diverging (cold→green→hot); precipitation
+// uses the sequential rain-intensity ramp.
+const TEMP_FAN = [
+ ["p90", "p99", "very-hot"], ["p75", "p90", "hot"], ["p60", "p75", "warm"],
+ ["p40", "p60", "normal"], ["p25", "p40", "cool"], ["p10", "p25", "cold"], ["p1", "p10", "very-cold"],
+];
+const RAIN_FAN = [
+ ["p90", "p99", "wet-8"], ["p75", "p90", "wet-7"], ["p60", "p75", "wet-6"],
+ ["p40", "p60", "wet-5"], ["p25", "p40", "wet-4"], ["p10", "p25", "wet-3"], ["p1", "p10", "wet-2"],
+];
+
+// Shared line-series chart: an optional shaded percentile fan + dashed median, the
+// value line with dots, and labeled points — so temperature, wind, humidity,
+// precipitation and dry streak all read in one consistent style.
+function lineChart(cfg) {
+ const { days, n, xFor, C, key, color, fan, hasNormals, baseZero,
+ getVal, getPct, dotColor, fmtAxis, fmtLabel, labelOn,
+ subtitle, legend, aria } = cfg;
+
+ // y-range: the plotted values plus the fan extremes (p1/p99) when present.
+ const vals = [];
+ days.forEach((d) => {
+ const v = getVal(d); if (v != null && !isNaN(v)) vals.push(v);
+ if (hasNormals && d.normals && d.normals[key]) {
+ const u = pget(d.normals[key], "p99"), l = pget(d.normals[key], "p1");
+ if (u != null) vals.push(u); if (l != null) vals.push(l);
+ }
+ });
+ let finite = vals.filter((v) => v != null && !isNaN(v));
+ if (!finite.length) finite = [0, 1];
+ let lo = Math.min(...finite), hi = Math.max(...finite);
+ if (baseZero) lo = Math.min(lo, 0);
+ if (hi <= lo) hi = lo + 1;
+ const pad = baseZero ? Math.max((hi - lo) * 0.12, 0.01) : Math.max((hi - lo) * 0.08, 3);
+ hi += pad; if (!baseZero) lo -= pad;
+ const yT = (v) => plotBot - ((v - lo) / (hi - lo)) * (plotBot - plotTop);
+
+ let grid = "";
+ for (let t = 0; t <= 4; t++) {
+ const v = lo + ((hi - lo) * t) / 4, y = yT(v).toFixed(1);
+ grid += ``;
+ grid += `${fmtAxis(v)}`;
+ }
+
+ // Shaded fan bands between percentile marks (the "highlights"), same as the calendar tiers.
+ const band = (dnKey, upKey, cls) => {
+ let top = "", bot = "";
+ days.forEach((d, i) => { const u = d.normals && pget(d.normals[key], upKey); if (u == null) return;
+ top += (top ? "L" : "M") + xFor(i).toFixed(1) + " " + yT(u).toFixed(1) + " "; });
+ for (let i = n - 1; i >= 0; i--) { const dd = days[i].normals && pget(days[i].normals[key], dnKey); if (dd == null) continue;
+ bot += "L" + xFor(i).toFixed(1) + " " + yT(dd).toFixed(1) + " "; }
+ return top ? `` : "";
+ };
+ const fanSvg = (hasNormals && fan) ? fan.map((t) => band(t[0], t[1], t[2])).join("") : "";
+
+ const normTrace = (pctKey) => {
+ let p = "", st = false;
+ days.forEach((d, i) => { const v = d.normals && pget(d.normals[key], pctKey); if (v == null) return;
+ p += (st ? "L" : "M") + xFor(i).toFixed(1) + " " + yT(v).toFixed(1) + " "; st = true; });
+ return p;
+ };
+ const valTrace = () => {
+ let p = "", st = false;
+ days.forEach((d, i) => { const v = getVal(d); if (v == null || isNaN(v)) { st = false; return; }
+ p += (st ? "L" : "M") + xFor(i).toFixed(1) + " " + yT(v).toFixed(1) + " "; st = true; });
+ return p;
+ };
+ const medianSvg = hasNormals
+ ? `` : "";
+
+ const dots = days.map((d, i) => {
+ const v = getVal(d); if (v == null || isNaN(v)) return "";
+ return ``;
+ }).join("");
+ const labels = days.map((d, i) => {
+ const v = getVal(d); if (v == null || isNaN(v)) return "";
+ if (labelOn && !labelOn(v, d)) return "";
+ const x = xFor(i).toFixed(1), y = yT(v);
+ const vy = (y - plotTop < 26 ? y + 15 : y - 15).toFixed(1); // flip below near the top
+ const pv = getPct ? getPct(d) : null;
+ const pct = pv == null ? "" :
+ `${ord(pv)}`;
+ return `${fmtLabel(v)}${pct}`;
+ }).join("");
+
+ const content = grid + fanSvg + medianSvg +
+ `` +
+ dots + labels;
+
+ return { content, legend, color, subtitle, aria };
+}
+
+// One temperature-scale series (High/Low/Feels/Wind/Gust).
+export 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 ? 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:
+ `${s.label} (dashed = median)` +
+ `Band shaded by grade tier` +
+ `Points labeled value · percentile — grade from the band tier (scale below)`,
+ subtitle: `${s.label} — recent trend vs normal`,
+ aria: `Recent ${s.label.toLowerCase()} versus the normal range.`,
+ });
+}
+
+// Precipitation as a line vs its normal range — same fan/median/labels style as
+// temperature, so the metrics read consistently. Rain days are labeled; dry days
+// sit on the baseline. The fan is the rain-intensity tier ramp.
+export 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:
+ `Precipitation (dashed = median)` +
+ `Band shaded by rain-intensity tier` +
+ `Rain days labeled amount · percentile (rain scale below)`,
+ subtitle: "Precipitation — daily totals vs normal",
+ aria: "Daily precipitation totals versus the normal range.",
+ });
+}
+
+// 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.)
+export function dryChart(days, n, xFor, C) {
+ return lineChart({
+ days, n, xFor, C, key: "dry", color: drynessColor(9), fan: null, hasNormals: false, baseZero: true,
+ getVal: (d) => (d.dsr == null ? null : d.dsr),
+ getPct: () => null,
+ dotColor: (d) => drynessColor(d.dsr),
+ fmtAxis: (v) => `${Math.round(v)}d`,
+ fmtLabel: (v) => `${Math.round(v)}`,
+ labelOn: (v) => v > 0, // label the streak length; rain days (0) just show the dot
+ legend:
+ `Days since last rain — dry streak` +
+ `Rain that day (streak = 0)`,
+ subtitle: "Dry streak — days since last measurable rain",
+ aria: "Days since last measurable rain, as a line.",
+ });
+}
+
+export function attachChartHover(wrap, days) {
+ const svg = wrap.querySelector("svg");
+ const tip = wrap.querySelector("#chart-tip");
+ const cross = wrap.querySelector("#crosshair");
+ const C = chartPalette();
+ const pctLine = (label, g, unit, color) => {
+ if (!g) return `${label} —
`;
+ const pct = g.percentile == null ? "" : ` · ${ord(g.percentile)} pct`;
+ const gc = TIER_COLORS[g.class] || "";
+ // "°" marks a temperature line — show it in the active unit; others are as-is.
+ const val = unit === "°" ? Math.round(toUnit(g.value)) : g.value;
+ return `${label} ${val}${unit}${pct} ${g.grade}
`;
+ };
+
+ // Pointer events cover mouse, touch and pen with one path, so tapping/dragging
+ // the chart works on phones (mousemove never fires on touchscreens).
+ const showTip = (r, e) => {
+ const d = days[+r.dataset.i];
+ const x = +r.getAttribute("x") + +r.getAttribute("width") / 2;
+ cross.setAttribute("x1", x); cross.setAttribute("x2", x); cross.setAttribute("opacity", "0.7");
+ const dt = new Date(d.date + "T00:00:00");
+ const nt = d.normals.tmax, ni = d.normals.tmin;
+ tip.innerHTML = `${dt.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })}
+ ${pctLine("High", d.tmax, "°", C.hi)}
+ ${pctLine("Low", d.tmin, "°", C.lo)}
+ ${pctLine("Feels", d.feels, "°", C.feels)}
+ ${pctLine("Humidity", d.humid, " g/m³", C.humid)}
+ ${pctLine("Wind", d.wind, " mph", C.wind)}
+ ${pctLine("Gust", d.gust, " mph", C.gust)}
+ ${pctLine("Precip", d.precip, '"', C.precip)}
+ Dry ${d.dsr == null ? "—" : d.dsr === 0 ? "rain today" : `${d.dsr} d since rain`}
+ normal ${nt ? fmtTemp(nt.p50) : "—"} / ${ni ? fmtTemp(ni.p50) : "—"}
`;
+ tip.hidden = false;
+ const box = wrap.getBoundingClientRect();
+ let px = e.clientX - box.left + 12;
+ if (px > box.width - 160) px = e.clientX - box.left - 160;
+ tip.style.left = Math.max(4, px) + "px";
+ tip.style.top = Math.max(8, e.clientY - box.top - 10) + "px";
+ };
+
+ wrap.querySelectorAll("rect.hit").forEach((r) => {
+ r.addEventListener("pointermove", (e) => showTip(r, e));
+ r.addEventListener("pointerdown", (e) => showTip(r, e));
+ });
+ const hide = () => { tip.hidden = true; cross.setAttribute("opacity", "0"); };
+ svg.addEventListener("pointerleave", hide);
+ svg.addEventListener("pointerup", hide);
+}