The homepage strip floored percentiles into 1-99 while the Day page rendered
"100th pct" for the same reading. Rather than teach the Day page a second copy
of the rule, put it in grading.pct_ordinal() and have every surface defer to it:
the Day page, the calendar tooltip, the chart labels, the city pages and the
strip.
Two bugs fell out of unifying them:
- The city pages rendered `{{ c.percentile }}th pct` against a float, so all
~1000 of them showed "97.1th pct", "66.4th pct", "16.5th pct" — and would say
"1th"/"2th"/"3th" for the low tail. Live in prod.
- Python's round() is half-to-even and JavaScript's Math.round is half-up, so
16.5 gave "16th" server-side and "17th" client-side. The Python helper uses
floor(x + 0.5) to match the frontend exactly; a cross-language check over every
.5 boundary now agrees on all of them.
The frontend mirrors the rule as pctOrd() in shared.js, replacing the bare ord()
at all five percentile call sites (day.js, calendar.js, chart.js x2, app.js).
ord() stays as the general ordinal helper it always was.
290 lines
15 KiB
JavaScript
290 lines
15 KiB
JavaScript
// 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, 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"], 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}">${pctOrd(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 ? "" : ` · ${pctOrd(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);
|
|
}
|