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).
2026-07-11 20:33:06 +00:00
|
|
|
// 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).
|
Follow the unit system for precipitation and wind too (#190)
Defaulting climate pages to the city's temperature convention left every page
half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now
drives every measure — picking °C also gives mm and km/h.
Absolute humidity stays g/m³ in both systems; there is no imperial unit for it
anyone would recognise, so converting it would only make the page worse.
units.js becomes the single source for all of it. The formatting logic existed in
three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set
inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks
and tooltip — which is why the units drifted apart in the first place. shared.js
now re-exports from units.js so its importers are unchanged, and chart.js carries
a per-series unit `kind` in place of sniffing for a "°" suffix, which could not
have extended to three unit families.
Server-rendered pages gain the carrier they were missing: precipitation and wind
now render as <span class="precip" data-precip-in> / <span class="wind"
data-wind-mph>, the same contract temperature already had, so climate.js can
repaint them on toggle and the attribute stays imperial as the source of truth.
Precision follows the unit: millimetres get one decimal where inches get two.
0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision
the source doesn't have. Wind rounds half-up to match Math.round, as temperature
already does.
The weekly table's wind and rain cells are bare numbers to keep the grid narrow,
so their row labels now carry the unit and rebuild per render. Static copy that
names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong
for °C readers since the country defaults landed — is marked up with
data-unit-label and painted from the same source.
The chart keeps its imperial domain; only labels convert, so point positions and
the fan geometry are untouched.
Push and email bodies are still imperial-only (notify.py): they are composed
server-side with no client to repaint them and no per-user unit stored, which is
the same limitation temperature already has there. Left for a follow-up.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
|
|
|
import { fmtTemp, toUnit, toWind, fmtPrecip, fmtWind, fmtHumid,
|
|
|
|
|
windUnit, humidUnit } from "./units.js";
|
Render every percentile through one rule (#186)
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.
2026-07-18 09:08:28 +00:00
|
|
|
import { TIER_COLORS, drynessColor, pctOrd } from "./shared.js";
|
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).
2026-07-11 20:33:06 +00:00
|
|
|
|
|
|
|
|
// #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"],
|
2026-07-24 23:13:36 +00:00
|
|
|
p90: ["p90", "p50"], p95: ["p95", "p90", "p50"], p99: ["p99", "p90", "max", "p50"],
|
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).
2026-07-11 20:33:06 +00:00
|
|
|
};
|
|
|
|
|
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.
|
Follow the unit system for precipitation and wind too (#190)
Defaulting climate pages to the city's temperature convention left every page
half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now
drives every measure — picking °C also gives mm and km/h.
Absolute humidity stays g/m³ in both systems; there is no imperial unit for it
anyone would recognise, so converting it would only make the page worse.
units.js becomes the single source for all of it. The formatting logic existed in
three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set
inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks
and tooltip — which is why the units drifted apart in the first place. shared.js
now re-exports from units.js so its importers are unchanged, and chart.js carries
a per-series unit `kind` in place of sniffing for a "°" suffix, which could not
have extended to three unit families.
Server-rendered pages gain the carrier they were missing: precipitation and wind
now render as <span class="precip" data-precip-in> / <span class="wind"
data-wind-mph>, the same contract temperature already had, so climate.js can
repaint them on toggle and the attribute stays imperial as the source of truth.
Precision follows the unit: millimetres get one decimal where inches get two.
0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision
the source doesn't have. Wind rounds half-up to match Math.round, as temperature
already does.
The weekly table's wind and rain cells are bare numbers to keep the grid narrow,
so their row labels now carry the unit and rebuild per render. Static copy that
names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong
for °C readers since the country defaults landed — is marked up with
data-unit-label and painted from the same source.
The chart keeps its imperial domain; only labels convert, so point positions and
the fan geometry are untouched.
Push and email bodies are still imperial-only (notify.py): they are composed
server-side with no client to repaint them and no per-user unit stored, which is
the same limitation temperature already has there. Left for a follow-up.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
|
|
|
// `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.
|
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).
2026-07-11 20:33:06 +00:00
|
|
|
const SERIES = {
|
Follow the unit system for precipitation and wind too (#190)
Defaulting climate pages to the city's temperature convention left every page
half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now
drives every measure — picking °C also gives mm and km/h.
Absolute humidity stays g/m³ in both systems; there is no imperial unit for it
anyone would recognise, so converting it would only make the page worse.
units.js becomes the single source for all of it. The formatting logic existed in
three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set
inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks
and tooltip — which is why the units drifted apart in the first place. shared.js
now re-exports from units.js so its importers are unchanged, and chart.js carries
a per-series unit `kind` in place of sniffing for a "°" suffix, which could not
have extended to three unit families.
Server-rendered pages gain the carrier they were missing: precipitation and wind
now render as <span class="precip" data-precip-in> / <span class="wind"
data-wind-mph>, the same contract temperature already had, so climate.js can
repaint them on toggle and the attribute stays imperial as the source of truth.
Precision follows the unit: millimetres get one decimal where inches get two.
0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision
the source doesn't have. Wind rounds half-up to match Math.round, as temperature
already does.
The weekly table's wind and rain cells are bare numbers to keep the grid narrow,
so their row labels now carry the unit and rebuild per render. Static copy that
names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong
for °C readers since the country defaults landed — is marked up with
data-unit-label and painted from the same source.
The chart keeps its imperial domain; only labels convert, so point positions and
the fan geometry are untouched.
Push and email bodies are still imperial-only (notify.py): they are composed
server-side with no client to repaint them and no per-user unit stored, which is
the same limitation temperature already has there. Left for a follow-up.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
|
|
|
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" }),
|
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).
2026-07-11 20:33:06 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 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"],
|
|
|
|
|
];
|
2026-07-24 23:13:36 +00:00
|
|
|
// 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.
|
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).
2026-07-11 20:33:06 +00:00
|
|
|
const RAIN_FAN = [
|
2026-07-24 23:13:36 +00:00
|
|
|
["p95", "p99", "wet-8"], ["p90", "p95", "wet-7"], ["p75", "p90", "wet-6"], ["p60", "p75", "wet-6"],
|
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).
2026-07-11 20:33:06 +00:00
|
|
|
["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 ? "" :
|
Render every percentile through one rule (#186)
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.
2026-07-18 09:08:28 +00:00
|
|
|
`<tspan x="${x}" dy="9" font-size="7.5" font-weight="600" fill="${C.muted}">${pctOrd(pv)}</tspan>`;
|
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).
2026-07-11 20:33:06 +00:00
|
|
|
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);
|
Follow the unit system for precipitation and wind too (#190)
Defaulting climate pages to the city's temperature convention left every page
half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now
drives every measure — picking °C also gives mm and km/h.
Absolute humidity stays g/m³ in both systems; there is no imperial unit for it
anyone would recognise, so converting it would only make the page worse.
units.js becomes the single source for all of it. The formatting logic existed in
three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set
inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks
and tooltip — which is why the units drifted apart in the first place. shared.js
now re-exports from units.js so its importers are unchanged, and chart.js carries
a per-series unit `kind` in place of sniffing for a "°" suffix, which could not
have extended to three unit families.
Server-rendered pages gain the carrier they were missing: precipitation and wind
now render as <span class="precip" data-precip-in> / <span class="wind"
data-wind-mph>, the same contract temperature already had, so climate.js can
repaint them on toggle and the attribute stays imperial as the source of truth.
Precision follows the unit: millimetres get one decimal where inches get two.
0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision
the source doesn't have. Wind rounds half-up to match Math.round, as temperature
already does.
The weekly table's wind and rain cells are bare numbers to keep the grid narrow,
so their row labels now carry the unit and rebuild per render. Static copy that
names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong
for °C readers since the country defaults landed — is marked up with
data-unit-label and painted from the same source.
The chart keeps its imperial domain; only labels convert, so point positions and
the fan geometry are untouched.
Push and email bodies are still imperial-only (notify.py): they are composed
server-side with no client to repaint them and no per-user unit stored, which is
the same limitation temperature already has there. Left for a follow-up.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
|
|
|
// 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)}`);
|
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).
2026-07-11 20:33:06 +00:00
|
|
|
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>` +
|
Remove em-dashes from site copy and tighten the prose (#206)
Replace em-dashes in user-facing copy across the server-rendered pages,
static frontend views, and the strings the app injects at runtime, using
colons, commas, parentheses or full stops as the context wants. Data
placeholder glyphs (a lone "—" for a missing reading) are left alone,
since a hyphen there reads as a minus sign in temperature columns.
Also tighten the high-visibility surfaces (home hero and meta, about,
privacy, city and records ledes, glossary blurbs) toward a plainer,
more direct voice while keeping every factual claim intact.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 01:48:33 +00:00
|
|
|
`<span class="legend-note">Points labeled value · percentile; grade from the band tier (scale below)</span>`,
|
|
|
|
|
subtitle: `${s.label}: recent trend vs normal`,
|
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).
2026-07-11 20:33:06 +00:00
|
|
|
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.
|
2026-07-24 23:13:36 +00:00
|
|
|
dotColor: (d) => (d.precip && d.precip.class && d.precip.class !== "dry" ? (TIER_COLORS[d.precip.class] || C.precip) : (drynessColor(d.dsr) || C.precip)),
|
Follow the unit system for precipitation and wind too (#190)
Defaulting climate pages to the city's temperature convention left every page
half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now
drives every measure — picking °C also gives mm and km/h.
Absolute humidity stays g/m³ in both systems; there is no imperial unit for it
anyone would recognise, so converting it would only make the page worse.
units.js becomes the single source for all of it. The formatting logic existed in
three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set
inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks
and tooltip — which is why the units drifted apart in the first place. shared.js
now re-exports from units.js so its importers are unchanged, and chart.js carries
a per-series unit `kind` in place of sniffing for a "°" suffix, which could not
have extended to three unit families.
Server-rendered pages gain the carrier they were missing: precipitation and wind
now render as <span class="precip" data-precip-in> / <span class="wind"
data-wind-mph>, the same contract temperature already had, so climate.js can
repaint them on toggle and the attribute stays imperial as the source of truth.
Precision follows the unit: millimetres get one decimal where inches get two.
0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision
the source doesn't have. Wind rounds half-up to match Math.round, as temperature
already does.
The weekly table's wind and rain cells are bare numbers to keep the grid narrow,
so their row labels now carry the unit and rebuild per render. Static copy that
names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong
for °C readers since the country defaults landed — is marked up with
data-unit-label and painted from the same source.
The chart keeps its imperial domain; only labels convert, so point positions and
the fan geometry are untouched.
Push and email bodies are still imperial-only (notify.py): they are composed
server-side with no client to repaint them and no per-user unit stored, which is
the same limitation temperature already has there. Left for a follow-up.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
|
|
|
fmtAxis: (v) => fmtPrecip(v, false),
|
|
|
|
|
fmtLabel: (v) => fmtPrecip(v, false),
|
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).
2026-07-11 20:33:06 +00:00
|
|
|
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>`,
|
Remove em-dashes from site copy and tighten the prose (#206)
Replace em-dashes in user-facing copy across the server-rendered pages,
static frontend views, and the strings the app injects at runtime, using
colons, commas, parentheses or full stops as the context wants. Data
placeholder glyphs (a lone "—" for a missing reading) are left alone,
since a hyphen there reads as a minus sign in temperature columns.
Also tighten the high-visibility surfaces (home hero and meta, about,
privacy, city and records ledes, glossary blurbs) toward a plainer,
more direct voice while keeping every factual claim intact.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 01:48:33 +00:00
|
|
|
subtitle: "Precipitation: daily totals vs normal",
|
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).
2026-07-11 20:33:06 +00:00
|
|
|
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:
|
Remove em-dashes from site copy and tighten the prose (#206)
Replace em-dashes in user-facing copy across the server-rendered pages,
static frontend views, and the strings the app injects at runtime, using
colons, commas, parentheses or full stops as the context wants. Data
placeholder glyphs (a lone "—" for a missing reading) are left alone,
since a hyphen there reads as a minus sign in temperature columns.
Also tighten the high-visibility surfaces (home hero and meta, about,
privacy, city and records ledes, glossary blurbs) toward a plainer,
more direct voice while keeping every factual claim intact.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 01:48:33 +00:00
|
|
|
`<span><i style="background:${drynessColor(9)}"></i>Days since last rain: dry streak</span>` +
|
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).
2026-07-11 20:33:06 +00:00
|
|
|
`<span><i style="background:${drynessColor(0)}"></i>Rain that day (streak = 0)</span>`,
|
Remove em-dashes from site copy and tighten the prose (#206)
Replace em-dashes in user-facing copy across the server-rendered pages,
static frontend views, and the strings the app injects at runtime, using
colons, commas, parentheses or full stops as the context wants. Data
placeholder glyphs (a lone "—" for a missing reading) are left alone,
since a hyphen there reads as a minus sign in temperature columns.
Also tighten the high-visibility surfaces (home hero and meta, about,
privacy, city and records ledes, glossary blurbs) toward a plainer,
more direct voice while keeping every factual claim intact.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 01:48:33 +00:00
|
|
|
subtitle: "Dry streak: days since last measurable rain",
|
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).
2026-07-11 20:33:06 +00:00
|
|
|
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();
|
Follow the unit system for precipitation and wind too (#190)
Defaulting climate pages to the city's temperature convention left every page
half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now
drives every measure — picking °C also gives mm and km/h.
Absolute humidity stays g/m³ in both systems; there is no imperial unit for it
anyone would recognise, so converting it would only make the page worse.
units.js becomes the single source for all of it. The formatting logic existed in
three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set
inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks
and tooltip — which is why the units drifted apart in the first place. shared.js
now re-exports from units.js so its importers are unchanged, and chart.js carries
a per-series unit `kind` in place of sniffing for a "°" suffix, which could not
have extended to three unit families.
Server-rendered pages gain the carrier they were missing: precipitation and wind
now render as <span class="precip" data-precip-in> / <span class="wind"
data-wind-mph>, the same contract temperature already had, so climate.js can
repaint them on toggle and the attribute stays imperial as the source of truth.
Precision follows the unit: millimetres get one decimal where inches get two.
0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision
the source doesn't have. Wind rounds half-up to match Math.round, as temperature
already does.
The weekly table's wind and rain cells are bare numbers to keep the grid narrow,
so their row labels now carry the unit and rebuild per render. Static copy that
names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong
for °C readers since the country defaults landed — is marked up with
data-unit-label and painted from the same source.
The chart keeps its imperial domain; only labels convert, so point positions and
the fan geometry are untouched.
Push and email bodies are still imperial-only (notify.py): they are composed
server-side with no client to repaint them and no per-user unit stored, which is
the same limitation temperature already has there. Left for a follow-up.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
|
|
|
// 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) => {
|
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).
2026-07-11 20:33:06 +00:00
|
|
|
if (!g) return `<div><b style="color:${color}">${label}</b> —</div>`;
|
Render every percentile through one rule (#186)
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.
2026-07-18 09:08:28 +00:00
|
|
|
const pct = g.percentile == null ? "" : ` · ${pctOrd(g.percentile)} pct`;
|
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).
2026-07-11 20:33:06 +00:00
|
|
|
const gc = TIER_COLORS[g.class] || "";
|
Follow the unit system for precipitation and wind too (#190)
Defaulting climate pages to the city's temperature convention left every page
half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now
drives every measure — picking °C also gives mm and km/h.
Absolute humidity stays g/m³ in both systems; there is no imperial unit for it
anyone would recognise, so converting it would only make the page worse.
units.js becomes the single source for all of it. The formatting logic existed in
three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set
inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks
and tooltip — which is why the units drifted apart in the first place. shared.js
now re-exports from units.js so its importers are unchanged, and chart.js carries
a per-series unit `kind` in place of sniffing for a "°" suffix, which could not
have extended to three unit families.
Server-rendered pages gain the carrier they were missing: precipitation and wind
now render as <span class="precip" data-precip-in> / <span class="wind"
data-wind-mph>, the same contract temperature already had, so climate.js can
repaint them on toggle and the attribute stays imperial as the source of truth.
Precision follows the unit: millimetres get one decimal where inches get two.
0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision
the source doesn't have. Wind rounds half-up to match Math.round, as temperature
already does.
The weekly table's wind and rain cells are bare numbers to keep the grid narrow,
so their row labels now carry the unit and rebuild per render. Static copy that
names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong
for °C readers since the country defaults landed — is marked up with
data-unit-label and painted from the same source.
The chart keeps its imperial domain; only labels convert, so point positions and
the fan geometry are untouched.
Push and email bodies are still imperial-only (notify.py): they are composed
server-side with no client to repaint them and no per-user unit stored, which is
the same limitation temperature already has there. Left for a follow-up.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
|
|
|
return `<div><b style="color:${color}">${label}</b> ${TIP_FMT[kind](g.value)}${pct} <span class="tt-grade" style="color:${gc}">${g.grade}</span></div>`;
|
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).
2026-07-11 20:33:06 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 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>
|
Follow the unit system for precipitation and wind too (#190)
Defaulting climate pages to the city's temperature convention left every page
half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now
drives every measure — picking °C also gives mm and km/h.
Absolute humidity stays g/m³ in both systems; there is no imperial unit for it
anyone would recognise, so converting it would only make the page worse.
units.js becomes the single source for all of it. The formatting logic existed in
three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set
inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks
and tooltip — which is why the units drifted apart in the first place. shared.js
now re-exports from units.js so its importers are unchanged, and chart.js carries
a per-series unit `kind` in place of sniffing for a "°" suffix, which could not
have extended to three unit families.
Server-rendered pages gain the carrier they were missing: precipitation and wind
now render as <span class="precip" data-precip-in> / <span class="wind"
data-wind-mph>, the same contract temperature already had, so climate.js can
repaint them on toggle and the attribute stays imperial as the source of truth.
Precision follows the unit: millimetres get one decimal where inches get two.
0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision
the source doesn't have. Wind rounds half-up to match Math.round, as temperature
already does.
The weekly table's wind and rain cells are bare numbers to keep the grid narrow,
so their row labels now carry the unit and rebuild per render. Static copy that
names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong
for °C readers since the country defaults landed — is marked up with
data-unit-label and painted from the same source.
The chart keeps its imperial domain; only labels convert, so point positions and
the fan geometry are untouched.
Push and email bodies are still imperial-only (notify.py): they are composed
server-side with no client to repaint them and no per-user unit stored, which is
the same limitation temperature already has there. Left for a follow-up.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
|
|
|
${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)}
|
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).
2026-07-11 20:33:06 +00:00
|
|
|
<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);
|
|
|
|
|
}
|