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); +}