Extract the chart library and the chunked streaming fetch (#49)
app.js carried the whole SVG chart implementation (~380 lines: palette,
percentile fans, the shared line-series renderer, per-metric wrappers,
pointer hover) alongside its page logic. That moves to chart.js: the
drawing dimensions live there as live-bound module state behind
setChartWidth(); attachChartHover takes the day list as a parameter
instead of reaching into page globals. app.js keeps buildChart and the
PNG export — page orchestration — and drops to ~500 lines. Any page can
now plot a metric series (e.g. a history sparkline on the day page).
calendar.js's hand-rolled streaming loader — bounded-parallel pool,
token cancellation, stale-while-revalidate re-merge — becomes
cache.js chunkedFetch(urls, {ttl, concurrency, isCurrent, onResult});
fetchCalendar keeps only its intent (chunk URLs, day merge, contiguous-
prefix rendering). Same pool semantics, verbatim.
Verified: node --check; 108 backend tests; headless-Chromium smoke on
all five pages against live data (chart renders + metric toggle,
calendar grid streams + metric switch, ladders, compare, legend).
This commit is contained in:
parent
4d3a5a1053
commit
591f5ec495
4 changed files with 348 additions and 323 deletions
290
static/app.js
290
static/app.js
|
|
@ -1,8 +1,10 @@
|
||||||
// Weekly (map) page. Imports replace the old script-tag ordering contract.
|
// Weekly (map) page. Imports replace the old script-tag ordering contract.
|
||||||
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
|
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 { getJSON, TTL, prefetchViews } from "./cache.js";
|
||||||
import { initFindButton, setFindLabel } from "./mappicker.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,
|
import { TIER_COLORS, SCALE_TEMP, drynessColor, ord, esc, todayISO, placeLabel,
|
||||||
fmtPrecip, fmtWind, fmtHumid } from "./shared.js";
|
fmtPrecip, fmtWind, fmtHumid } from "./shared.js";
|
||||||
|
|
||||||
|
|
@ -305,64 +307,6 @@ function tierKeyHtml() {
|
||||||
<p class="muted tk-note"><a href="legend">Full guide to metrics & grades →</a></p>`;
|
<p class="muted tk-note"><a href="legend">Full guide to metrics & 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 = [];
|
let chartDays = [];
|
||||||
// Which chart category is shown. Persisted so a refresh keeps your selection.
|
// Which chart category is shown. Persisted so a refresh keeps your selection.
|
||||||
let chartMetric = localStorage.getItem("thermograph:chartMetric") || "tmax";
|
let chartMetric = localStorage.getItem("thermograph:chartMetric") || "tmax";
|
||||||
|
|
@ -370,11 +314,9 @@ let chartMetric = localStorage.getItem("thermograph:chartMetric") || "tmax";
|
||||||
function buildChart(data) {
|
function buildChart(data) {
|
||||||
const wrap = document.getElementById("chart-wrap");
|
const wrap = document.getElementById("chart-wrap");
|
||||||
const C = chartPalette();
|
const C = chartPalette();
|
||||||
// Drawing width tracks the on-screen box at a steady ~1.5 CSS px per SVG unit,
|
// Drawing width tracks the on-screen box (see chart.js) so wide monitors gain
|
||||||
// so text renders the same size everywhere while wide monitors gain plot room.
|
// plot room while phones keep the 720-unit floor (the SVG just scales down).
|
||||||
// Phones keep the 720-unit floor (the SVG just scales down).
|
setChartWidth(wrap.clientWidth);
|
||||||
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)
|
lastGraded = data; // the series currently drawn (for PNG export)
|
||||||
chartDays = [...data.recent].reverse(); // oldest -> newest
|
chartDays = [...data.recent].reverse(); // oldest -> newest
|
||||||
const days = chartDays, n = days.length;
|
const days = chartDays, n = days.length;
|
||||||
|
|
@ -447,225 +389,7 @@ function buildChart(data) {
|
||||||
try { localStorage.setItem("thermograph:chartMetric", chartMetric); } catch (e) {}
|
try { localStorage.setItem("thermograph:chartMetric", chartMetric); } catch (e) {}
|
||||||
buildChart(data);
|
buildChart(data);
|
||||||
});
|
});
|
||||||
attachChartHover(wrap);
|
attachChartHover(wrap, chartDays);
|
||||||
}
|
|
||||||
|
|
||||||
// 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 ? 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(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 ----
|
// ---- share ----
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
// 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.
|
// prefetch skip views that are already warm and only reach out for the ones missing.
|
||||||
export async function hasFreshCache(url, ttlMs) {
|
export async function hasFreshCache(url, ttlMs) {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
|
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
|
||||||
// The tooltip reads fmtTemp live, so a unit switch shows on the next hover.
|
// The tooltip reads fmtTemp live, so a unit switch shows on the next hover.
|
||||||
import { fmtTemp } from "./units.js";
|
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 { initFindButton, setFindLabel } from "./mappicker.js";
|
||||||
import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, ord,
|
import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, ord,
|
||||||
fmtPrecip, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd,
|
fmtPrecip, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd,
|
||||||
|
|
@ -398,47 +398,24 @@ async function fetchCalendar() {
|
||||||
renderKey();
|
renderKey();
|
||||||
};
|
};
|
||||||
|
|
||||||
const runChunk = async (i) => {
|
const urls = chunks.map((ch) => ch
|
||||||
const ch = chunks[i];
|
? `api/v2/calendar?${q}&start=${ch.start}&end=${ch.end}`
|
||||||
const url = ch
|
: `api/v2/calendar?${q}&months=24`);
|
||||||
? `api/v2/calendar?${q}&start=${ch.start}&end=${ch.end}`
|
// Bounded-parallel streaming load (see cache.js). Stale-while-revalidate
|
||||||
: `api/v2/calendar?${q}&months=24`;
|
// updates re-merge that chunk's days and repaint — the cell/place metadata is
|
||||||
try {
|
// identical across versions, and initOnce self-guards after the first chunk.
|
||||||
// Stale-while-revalidate: a stale cached chunk renders immediately; if the
|
await chunkedFetch(urls, {
|
||||||
// background revalidation finds changed data, re-merge that chunk's days
|
ttl: TTL.calendar,
|
||||||
// and repaint (the cell/place metadata is identical across versions).
|
concurrency: CHUNK_CONCURRENCY,
|
||||||
const d = await getJSON(url, TTL.calendar, true, (upd) => {
|
isCurrent: () => token === fetchToken,
|
||||||
if (token !== fetchToken) return;
|
onResult: (i, d) => {
|
||||||
results[i] = upd;
|
|
||||||
for (const x of upd.days) dayMap.set(x.date, x);
|
|
||||||
advance();
|
|
||||||
});
|
|
||||||
if (token !== fetchToken) return;
|
|
||||||
results[i] = d;
|
results[i] = d;
|
||||||
for (const x of d.days) dayMap.set(x.date, x);
|
if (!d.error) {
|
||||||
initOnce(d);
|
for (const x of d.days) dayMap.set(x.date, x);
|
||||||
advance();
|
initOnce(d);
|
||||||
} 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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
advance();
|
||||||
pump();
|
},
|
||||||
});
|
});
|
||||||
clearTimeout(spin);
|
clearTimeout(spin);
|
||||||
if (token !== fetchToken) return; // superseded while loading
|
if (token !== fetchToken) return; // superseded while loading
|
||||||
|
|
|
||||||
290
static/chart.js
Normal file
290
static/chart.js
Normal file
|
|
@ -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 += `<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).
|
||||||
|
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:
|
||||||
|
`<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.
|
||||||
|
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:
|
||||||
|
`<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.)
|
||||||
|
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:
|
||||||
|
`<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.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `<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(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 = 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 = `<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);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue