// 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, toWind, fmtPrecip, fmtWind, fmtHumid, windUnit, humidUnit } from "./units.js"; import { TIER_COLORS, drynessColor, pctOrd } 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"], p95: ["p95", "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. // `kind` picks the formatter and says whether the value converts; the labels that // name a unit are built per render so they follow the active unit system. const SERIES = { tmax: (C) => ({ color: C.hi, label: "Daily high", sfx: "°", kind: "temp" }), tmin: (C) => ({ color: C.lo, label: "Daily low", sfx: "°", kind: "temp" }), feels: (C) => ({ color: C.feels, label: "Feels like", sfx: "°", kind: "temp" }), humid: (C) => ({ color: C.humid, label: `Absolute humidity (${humidUnit()})`, sfx: "", kind: "humid" }), wind: (C) => ({ color: C.wind, label: `Wind speed (${windUnit()})`, sfx: "", kind: "wind" }), gust: (C) => ({ color: C.gust, label: `Wind gust (${windUnit()})`, sfx: "", kind: "wind" }), }; // 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"], ]; // Eight rain-intensity tiers (post-split): Trace / Light / Brisk / Typical / Heavy // (60-90) / Very Heavy (90-95) / Severe (95-99) / Extreme. Each band maps a rain-day // percentile range to its calendar tier colour; the p75 vertex inside the single // Heavy tier keeps the fan following the p75 contour. const RAIN_FAN = [ ["p95", "p99", "wet-8"], ["p90", "p95", "wet-7"], ["p75", "p90", "wet-6"], ["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 ? "" : `${pctOrd(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); // Labels show the active unit; the plot keeps its imperial domain — only the // labels convert — so point positions and the fan geometry hold. const fmtV = (v) => (s.kind === "temp" ? `${Math.round(toUnit(v))}${s.sfx}` : s.kind === "wind" ? `${Math.round(toWind(v))}` : `${Math.round(v)}`); 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.class && d.precip.class !== "dry" ? (TIER_COLORS[d.precip.class] || C.precip) : (drynessColor(d.dsr) || C.precip)), fmtAxis: (v) => fmtPrecip(v, false), fmtLabel: (v) => fmtPrecip(v, false), 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(); // Every measurable line converts to the active unit system; `kind` picks how. const TIP_FMT = { temp: (v) => `${Math.round(toUnit(v))}°`, humid: (v) => fmtHumid(v), wind: (v) => fmtWind(v), precip: (v) => fmtPrecip(v), }; const pctLine = (label, g, kind, color) => { if (!g) return `
${label}
`; const pct = g.percentile == null ? "" : ` · ${pctOrd(g.percentile)} pct`; const gc = TIER_COLORS[g.class] || ""; return `
${label} ${TIP_FMT[kind](g.value)}${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, "temp", C.hi)} ${pctLine("Low", d.tmin, "temp", C.lo)} ${pctLine("Feels", d.feels, "temp", C.feels)} ${pctLine("Humidity", d.humid, "humid", C.humid)} ${pctLine("Wind", d.wind, "wind", C.wind)} ${pctLine("Gust", d.gust, "wind", C.gust)} ${pctLine("Precip", d.precip, "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); }