Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
|
|
|
|
// Shared presentation constants + small helpers for every page script — each
|
|
|
|
|
|
// constant and helper here previously existed as a copy in app.js, calendar.js,
|
|
|
|
|
|
// day.js, compare.js and/or legend.html; pages import what they need so a tier
|
|
|
|
|
|
// color, scale label or formatter has exactly one home.
|
2026-07-12 18:32:38 +00:00
|
|
|
|
import { fmtTemp } from "./units.js";
|
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
|
|
|
|
// ---- tier colors ----
|
|
|
|
|
|
// style.css's :root custom properties are the source of truth (they're not
|
|
|
|
|
|
// re-themed, so reading them once at load is safe). The JS map exists because
|
|
|
|
|
|
// inline-SVG work — chart building and the PNG export in particular — needs
|
|
|
|
|
|
// literal color values, where CSS variables can't resolve. The hex fallbacks
|
|
|
|
|
|
// only cover a missing/blocked stylesheet.
|
|
|
|
|
|
const FALLBACK = {
|
|
|
|
|
|
"rec-hot": "#8f0e20", "very-hot": "#dd6b52", "hot": "#ef9351", "warm": "#f6c18a",
|
|
|
|
|
|
"normal": "#4a9d5b",
|
|
|
|
|
|
"cool": "#92c5de", "cold": "#4393c3", "very-cold": "#2166ac", "rec-cold": "#0a2f6b",
|
|
|
|
|
|
"dry": "#c9a24a",
|
|
|
|
|
|
"wet-1": "#d9f0d3", "wet-2": "#b7e2b1", "wet-3": "#8fd18f", "wet-4": "#5cba9f",
|
|
|
|
|
|
"wet-5": "#35a1c0", "wet-6": "#2b8cbe", "wet-7": "#226bb3", "wet-8": "#164a97",
|
|
|
|
|
|
"wet-9": "#08306b",
|
|
|
|
|
|
};
|
|
|
|
|
|
const rootCss = getComputedStyle(document.documentElement);
|
|
|
|
|
|
export const TIER_COLORS = {};
|
|
|
|
|
|
for (const [cls, fb] of Object.entries(FALLBACK)) {
|
|
|
|
|
|
TIER_COLORS[cls] = rootCss.getPropertyValue(`--${cls}`).trim() || fb;
|
|
|
|
|
|
}
|
|
|
|
|
|
// The rain-intensity subset + the dry tint, for pages that color them apart.
|
|
|
|
|
|
export const PRECIP_COLORS = Object.fromEntries(
|
|
|
|
|
|
Object.entries(TIER_COLORS).filter(([c]) => c.startsWith("wet-")));
|
|
|
|
|
|
export const DRY_COLOR = TIER_COLORS.dry;
|
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47)
The page scripts each re-declared the shared presentation layer — the tier
color table existed in four places (app.js, calendar.js, day.js, style.css)
and the scale label tables in three (plus legend.html's own drifting copy),
alongside per-page copies of the dryness ramp, formatters, ord, todayISO,
esc, the weather icons/summary, month helpers and the 2-year range chunker.
~240 duplicated lines deleted (net -236 with the new module included).
- frontend/shared.js (IIFE, extends window.Thermograph): tier colors read
from style.css's :root custom properties at load — the CSS is now the
single source of truth; the JS map exists only because inline-SVG work
(chart + PNG export) needs literal values, with hex fallbacks for a
missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord,
todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker,
and the weatherType summary (dsr-aware; the day page just omits dsr).
- nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in
the shared classic-script scope, and a leaked locHash collided with page
destructuring (caught by the browser smoke, not by node --check).
locHash is now exported and used by all 8 former hand-built hash sites.
- mappicker.js: initFindButton/setFindLabel replace the Find-button block
each page rebuilt.
- legend.html renders its scales from the shared tables, so the guide can
no longer drift from what the app shows.
Verified: node --check on all JS; 108 backend tests; headless-Chromium
smoke over all five pages against live data — no console/page errors,
legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle,
calendar grid + key + metric switch, day 7 ladders + weather icon,
compare seeded rank card.
2026-07-11 20:21:48 +00:00
|
|
|
|
|
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
|
|
|
|
// ---- percentile tier scales, low → high ----
|
|
|
|
|
|
// Names are deliberately *relative* to each place's own climate history (a
|
|
|
|
|
|
// percentile), not absolute temperature words — see the README design note.
|
|
|
|
|
|
export const SCALE_TEMP = [
|
|
|
|
|
|
{ c: "rec-cold", label: "Near Record", range: "<1" },
|
|
|
|
|
|
{ c: "very-cold", label: "Very Low", range: "1–10" },
|
|
|
|
|
|
{ c: "cold", label: "Low", range: "10–25" },
|
|
|
|
|
|
{ c: "cool", label: "Below Normal", range: "25–40" },
|
|
|
|
|
|
{ c: "normal", label: "Normal", range: "40–60" },
|
|
|
|
|
|
{ c: "warm", label: "Above Normal", range: "60–75" },
|
|
|
|
|
|
{ c: "hot", label: "High", range: "75–90" },
|
|
|
|
|
|
{ c: "very-hot", label: "Very High", range: "90–99" },
|
|
|
|
|
|
{ c: "rec-hot", label: "Near Record", range: ">99" },
|
|
|
|
|
|
];
|
|
|
|
|
|
// Rain-intensity tiers by rain-day percentile (dry days show their dry streak).
|
|
|
|
|
|
export const SCALE_RAIN = [
|
|
|
|
|
|
{ c: "wet-1", label: "Trace", range: "<1" },
|
|
|
|
|
|
{ c: "wet-2", label: "Very Light", range: "1–10" },
|
|
|
|
|
|
{ c: "wet-3", label: "Light", range: "10–25" },
|
|
|
|
|
|
{ c: "wet-4", label: "Light–Mod", range: "25–40" },
|
|
|
|
|
|
{ c: "wet-5", label: "Moderate", range: "40–60" },
|
|
|
|
|
|
{ c: "wet-6", label: "Mod–Heavy", range: "60–75" },
|
|
|
|
|
|
{ c: "wet-7", label: "Heavy", range: "75–90" },
|
|
|
|
|
|
{ c: "wet-8", label: "Very Heavy", range: "90–99" },
|
|
|
|
|
|
{ c: "wet-9", label: "Extreme", range: ">99" },
|
|
|
|
|
|
];
|
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47)
The page scripts each re-declared the shared presentation layer — the tier
color table existed in four places (app.js, calendar.js, day.js, style.css)
and the scale label tables in three (plus legend.html's own drifting copy),
alongside per-page copies of the dryness ramp, formatters, ord, todayISO,
esc, the weather icons/summary, month helpers and the 2-year range chunker.
~240 duplicated lines deleted (net -236 with the new module included).
- frontend/shared.js (IIFE, extends window.Thermograph): tier colors read
from style.css's :root custom properties at load — the CSS is now the
single source of truth; the JS map exists only because inline-SVG work
(chart + PNG export) needs literal values, with hex fallbacks for a
missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord,
todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker,
and the weatherType summary (dsr-aware; the day page just omits dsr).
- nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in
the shared classic-script scope, and a leaked locHash collided with page
destructuring (caught by the browser smoke, not by node --check).
locHash is now exported and used by all 8 former hand-built hash sites.
- mappicker.js: initFindButton/setFindLabel replace the Find-button block
each page rebuilt.
- legend.html renders its scales from the shared tables, so the guide can
no longer drift from what the app shows.
Verified: node --check on all JS; 108 backend tests; headless-Chromium
smoke over all five pages against live data — no console/page errors,
legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle,
calendar grid + key + metric switch, day 7 ladders + weather icon,
compare seeded rank card.
2026-07-11 20:21:48 +00:00
|
|
|
|
|
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
|
|
|
|
// ---- dry-streak ramp ----
|
|
|
|
|
|
// "Days since last rain": rain days are blue; each dry day warms from a light
|
|
|
|
|
|
// base toward dark red, saturating at 14 consecutive dry days.
|
|
|
|
|
|
const DRY_BASE = [247, 217, 176], DRY_MAX = [122, 13, 26], DRY_CAP = 14;
|
|
|
|
|
|
const lerpRgb = (a, b, t) =>
|
|
|
|
|
|
`rgb(${a.map((x, i) => Math.round(x + (b[i] - x) * t)).join(",")})`;
|
|
|
|
|
|
export function drynessColor(dsr) {
|
|
|
|
|
|
if (dsr == null) return "";
|
|
|
|
|
|
if (dsr === 0) return "#2b7bba"; // measurable rain that day
|
|
|
|
|
|
return lerpRgb(DRY_BASE, DRY_MAX, Math.min(dsr, DRY_CAP) / DRY_CAP);
|
|
|
|
|
|
}
|
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47)
The page scripts each re-declared the shared presentation layer — the tier
color table existed in four places (app.js, calendar.js, day.js, style.css)
and the scale label tables in three (plus legend.html's own drifting copy),
alongside per-page copies of the dryness ramp, formatters, ord, todayISO,
esc, the weather icons/summary, month helpers and the 2-year range chunker.
~240 duplicated lines deleted (net -236 with the new module included).
- frontend/shared.js (IIFE, extends window.Thermograph): tier colors read
from style.css's :root custom properties at load — the CSS is now the
single source of truth; the JS map exists only because inline-SVG work
(chart + PNG export) needs literal values, with hex fallbacks for a
missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord,
todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker,
and the weatherType summary (dsr-aware; the day page just omits dsr).
- nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in
the shared classic-script scope, and a leaked locHash collided with page
destructuring (caught by the browser smoke, not by node --check).
locHash is now exported and used by all 8 former hand-built hash sites.
- mappicker.js: initFindButton/setFindLabel replace the Find-button block
each page rebuilt.
- legend.html renders its scales from the shared tables, so the guide can
no longer drift from what the app shows.
Verified: node --check on all JS; 108 backend tests; headless-Chromium
smoke over all five pages against live data — no console/page errors,
legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle,
calendar grid + key + metric switch, day 7 ladders + weather icon,
compare seeded rank card.
2026-07-11 20:21:48 +00:00
|
|
|
|
|
2026-07-11 22:33:38 +00:00
|
|
|
|
// ---- category distribution (shared by the calendar totals + the compare page) ----
|
|
|
|
|
|
// Bucket a set of daily records into the chosen metric's ordered categories
|
|
|
|
|
|
// (low→high), each entry [label, color, dayCount, scaleGroup]. Temperature-style
|
|
|
|
|
|
// metrics (High/Low/Feels/Humid/Wind/Gust) use the diverging Record-Low→Record-High
|
|
|
|
|
|
// tiers; precip uses the rain-intensity tiers plus a Dry bucket; dry-streak uses
|
|
|
|
|
|
// run-length bands. The scaleGroup ("wet"/"dry"/"temp") lets distStrip scale and
|
|
|
|
|
|
// percent wet vs. dry days apart so a lopsided count on one side can't crush the
|
|
|
|
|
|
// other. Categories are percentile classes on the records, so they're unit-agnostic.
|
|
|
|
|
|
export function metricBuckets(days, metric) {
|
|
|
|
|
|
if (metric === "dsr") {
|
|
|
|
|
|
const bands = [
|
|
|
|
|
|
["Rain day", drynessColor(0), (d) => d === 0],
|
|
|
|
|
|
["1–3 dry", drynessColor(2), (d) => d >= 1 && d <= 3],
|
|
|
|
|
|
["4–6 dry", drynessColor(5), (d) => d >= 4 && d <= 6],
|
|
|
|
|
|
["7–13 dry", drynessColor(10), (d) => d >= 7 && d <= 13],
|
|
|
|
|
|
["14+ dry", drynessColor(14), (d) => d >= 14],
|
|
|
|
|
|
];
|
|
|
|
|
|
const vals = days.map((r) => r.dsr).filter((d) => d != null);
|
|
|
|
|
|
// The lone "Rain day" (wet) scales apart from the dry-streak buckets (dry).
|
Category distribution: connected bar chart with per-tier avg + range (#76)
Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.
- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
and collects each category's actual values, not just a count, so distStrip
can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
°C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
toggle: wired in compare.js's onUnitChange and a new onUnitChange in
calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
from thin lines to connected bars with an in-bar range pill.
2026-07-12 04:31:29 +00:00
|
|
|
|
return bands.map(([label, color, fn], i) => {
|
|
|
|
|
|
const v = vals.filter(fn);
|
|
|
|
|
|
return { label, color, n: v.length, group: i === 0 ? "wet" : "dry", unit: "dsr", values: v };
|
|
|
|
|
|
});
|
2026-07-11 22:33:38 +00:00
|
|
|
|
}
|
|
|
|
|
|
if (metric === "precip") {
|
Category distribution: connected bar chart with per-tier avg + range (#76)
Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.
- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
and collects each category's actual values, not just a count, so distStrip
can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
°C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
toggle: wired in compare.js's onUnitChange and a new onUnitChange in
calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
from thin lines to connected bars with an in-bar range pill.
2026-07-12 04:31:29 +00:00
|
|
|
|
const recs = days.map((r) => r.precip).filter(Boolean); // {v, c}
|
|
|
|
|
|
const dry = recs.filter((x) => x.c === "dry");
|
|
|
|
|
|
// Dry days scale independently of the rain-intensity buckets (wet). `cls`
|
|
|
|
|
|
// (e.g. "wet-5") stays a CSS hook for nudging a crowded label (.ct-wet-5).
|
|
|
|
|
|
const out = [{ label: "Dry", color: drynessColor(7), n: dry.length, group: "dry", unit: "precip", values: dry.map((x) => x.v) }];
|
|
|
|
|
|
for (const t of SCALE_RAIN) {
|
|
|
|
|
|
const g = recs.filter((x) => x.c === t.c);
|
|
|
|
|
|
out.push({ label: t.label, color: PRECIP_COLORS[t.c], n: g.length, group: "wet", cls: t.c, unit: "precip", values: g.map((x) => x.v) });
|
|
|
|
|
|
}
|
|
|
|
|
|
return out;
|
2026-07-11 22:33:38 +00:00
|
|
|
|
}
|
Category distribution: connected bar chart with per-tier avg + range (#76)
Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.
- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
and collects each category's actual values, not just a count, so distStrip
can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
°C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
toggle: wired in compare.js's onUnitChange and a new onUnitChange in
calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
from thin lines to connected bars with an in-bar range pill.
2026-07-12 04:31:29 +00:00
|
|
|
|
// Temperature-style metrics: percentile tiers on the class, plus the actual values
|
|
|
|
|
|
// per tier for the average + min–max range. The value's unit follows the metric.
|
|
|
|
|
|
const unit = metric === "humid" ? "humid" : (metric === "wind" || metric === "gust") ? "wind" : "temp";
|
|
|
|
|
|
const recs = days.map((r) => r[metric]).filter(Boolean); // {v, c}
|
|
|
|
|
|
return SCALE_TEMP.map((t) => {
|
|
|
|
|
|
const g = recs.filter((x) => x.c === t.c);
|
|
|
|
|
|
return { label: t.label, color: TIER_COLORS[t.c], n: g.length, group: "temp", unit, values: g.map((x) => x.v) };
|
|
|
|
|
|
});
|
2026-07-11 22:33:38 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-12 18:32:38 +00:00
|
|
|
|
// Format a single metric value in the metric's own unit — temps follow the active
|
|
|
|
|
|
// °C/°F unit; humidity, wind, precip and dry-streak are fixed-unit.
|
Category distribution: connected bar chart with per-tier avg + range (#76)
Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.
- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
and collects each category's actual values, not just a count, so distStrip
can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
°C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
toggle: wired in compare.js's onUnitChange and a new onUnitChange in
calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
from thin lines to connected bars with an in-bar range pill.
2026-07-12 04:31:29 +00:00
|
|
|
|
function fmtMetricVal(unit, v) {
|
|
|
|
|
|
if (v == null || isNaN(v)) return "";
|
|
|
|
|
|
if (unit === "temp") return fmtTemp(v);
|
|
|
|
|
|
if (unit === "humid") return `${Math.round(v)} g/m³`;
|
|
|
|
|
|
if (unit === "wind") return `${Math.round(v)} mph`;
|
|
|
|
|
|
if (unit === "precip") return `${v.toFixed(2)}″`;
|
|
|
|
|
|
if (unit === "dsr") return `${Math.round(v)}d`;
|
|
|
|
|
|
return `${Math.round(v)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Render buckets from metricBuckets() as a connected bar chart, one bar per category
|
2026-07-12 18:32:38 +00:00
|
|
|
|
// low→high: the category's average value sits above its bar, its Max/Min values inside
|
Category distribution: connected bar chart with per-tier avg + range (#76)
Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.
- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
and collects each category's actual values, not just a count, so distStrip
can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
°C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
toggle: wired in compare.js's onUnitChange and a new onUnitChange in
calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
from thin lines to connected bars with an in-bar range pill.
2026-07-12 04:31:29 +00:00
|
|
|
|
// it, and the share (or raw count when showCount is set) with the category name below.
|
|
|
|
|
|
// Bar height encodes frequency within the bucket's scale group (wet/dry/temp scale
|
2026-07-12 18:32:38 +00:00
|
|
|
|
// apart), floored so the in-bar label stays legible. Assumes ≥1 day (callers guard).
|
2026-07-11 22:33:38 +00:00
|
|
|
|
export function distStrip(buckets, showCount) {
|
Category distribution: connected bar chart with per-tier avg + range (#76)
Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.
- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
and collects each category's actual values, not just a count, so distStrip
can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
°C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
toggle: wired in compare.js's onUnitChange and a new onUnitChange in
calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
from thin lines to connected bars with an in-bar range pill.
2026-07-12 04:31:29 +00:00
|
|
|
|
const total = buckets.reduce((s, b) => s + b.n, 0);
|
2026-07-11 22:33:38 +00:00
|
|
|
|
const groupTotal = {}, groupCount = {}, maxByGroup = {};
|
|
|
|
|
|
for (const b of buckets) {
|
Category distribution: connected bar chart with per-tier avg + range (#76)
Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.
- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
and collects each category's actual values, not just a count, so distStrip
can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
°C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
toggle: wired in compare.js's onUnitChange and a new onUnitChange in
calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
from thin lines to connected bars with an in-bar range pill.
2026-07-12 04:31:29 +00:00
|
|
|
|
groupTotal[b.group] = (groupTotal[b.group] || 0) + b.n;
|
|
|
|
|
|
groupCount[b.group] = (groupCount[b.group] || 0) + 1;
|
|
|
|
|
|
maxByGroup[b.group] = Math.max(maxByGroup[b.group] || 1, b.n);
|
2026-07-11 22:33:38 +00:00
|
|
|
|
}
|
|
|
|
|
|
const pctText = (n, group) => {
|
|
|
|
|
|
const d = groupCount[group] > 1 ? (groupTotal[group] || 1) : total;
|
|
|
|
|
|
const p = 100 * n / d, r = Math.round(p);
|
|
|
|
|
|
return n > 0 && r === 0 ? `${p.toFixed(1)}%` : `${r}%`;
|
|
|
|
|
|
};
|
|
|
|
|
|
const valText = (n, group) => !n ? "0" : (showCount ? n.toLocaleString() : pctText(n, group));
|
2026-07-12 18:32:38 +00:00
|
|
|
|
const BAR_MIN = 30, BAR_MAX = 74; // px — the floor keeps the two-line Max/Min label readable
|
Category distribution: connected bar chart with per-tier avg + range (#76)
Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.
- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
and collects each category's actual values, not just a count, so distStrip
can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
°C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
toggle: wired in compare.js's onUnitChange and a new onUnitChange in
calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
from thin lines to connected bars with an in-bar range pill.
2026-07-12 04:31:29 +00:00
|
|
|
|
const cols = buckets.map((b) => {
|
|
|
|
|
|
let avg = null, lo = null, hi = null;
|
|
|
|
|
|
if (b.values && b.values.length) {
|
|
|
|
|
|
let s = 0; lo = Infinity; hi = -Infinity;
|
|
|
|
|
|
for (const v of b.values) { s += v; if (v < lo) lo = v; if (v > hi) hi = v; }
|
|
|
|
|
|
avg = s / b.values.length;
|
|
|
|
|
|
}
|
|
|
|
|
|
const h = b.n ? Math.round(BAR_MIN + (BAR_MAX - BAR_MIN) * (b.n / maxByGroup[b.group])) : 0;
|
|
|
|
|
|
const bar = h
|
2026-07-12 18:32:38 +00:00
|
|
|
|
? `<div class="ct-bar" style="height:${h}px">${lo != null
|
|
|
|
|
|
? `<span class="ct-range">` +
|
|
|
|
|
|
`<span class="ct-mm"><span class="ct-mm-k">Max</span>${fmtMetricVal(b.unit, hi)}</span>` +
|
|
|
|
|
|
`<span class="ct-mm"><span class="ct-mm-k">Min</span>${fmtMetricVal(b.unit, lo)}</span>` +
|
|
|
|
|
|
`</span>`
|
|
|
|
|
|
: ""}</div>`
|
Category distribution: connected bar chart with per-tier avg + range (#76)
Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.
- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
and collects each category's actual values, not just a count, so distStrip
can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
°C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
toggle: wired in compare.js's onUnitChange and a new onUnitChange in
calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
from thin lines to connected bars with an in-bar range pill.
2026-07-12 04:31:29 +00:00
|
|
|
|
: `<div class="ct-bar ct-bar-0"></div>`;
|
|
|
|
|
|
return `<div class="ct-col${b.cls ? ` ct-${b.cls}` : ""}" style="--c:${b.color}" ` +
|
|
|
|
|
|
`title="${b.label} · ${pctText(b.n, b.group)} (${b.n})${avg != null ? ` · avg ${fmtMetricVal(b.unit, avg)}` : ""}">` +
|
|
|
|
|
|
`<span class="ct-top">${avg != null ? fmtMetricVal(b.unit, avg) : ""}</span>` +
|
|
|
|
|
|
bar +
|
|
|
|
|
|
`<span class="ct-cap"><span class="ct-num${b.n ? "" : " ct-num-zero"}">${valText(b.n, b.group)}</span>` +
|
|
|
|
|
|
`<span class="ct-label">${b.label}</span></span></div>`;
|
2026-07-11 22:33:38 +00:00
|
|
|
|
}).join("");
|
Category distribution: connected bar chart with per-tier avg + range (#76)
Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.
- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
and collects each category's actual values, not just a count, so distStrip
can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
°C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
toggle: wired in compare.js's onUnitChange and a new onUnitChange in
calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
from thin lines to connected bars with an in-bar range pill.
2026-07-12 04:31:29 +00:00
|
|
|
|
return `<div class="ct-strip ct-connected">${cols}</div>`;
|
2026-07-11 22:33:38 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
|
|
|
|
// ---- formatters & tiny utils ----
|
|
|
|
|
|
// (Temperatures go through nav.js's unit-aware fmtTemp; these are unit-agnostic.)
|
|
|
|
|
|
export const fmtPrecip = (v) => (v == null ? "—" : `${v.toFixed(2)}"`);
|
|
|
|
|
|
export const fmtWind = (v) => (v == null ? "—" : `${Math.round(v)} mph`);
|
|
|
|
|
|
export const fmtHumid = (v) => (v == null ? "—" : `${v.toFixed(1)} g/m³`);
|
|
|
|
|
|
export const ord = (n) => {
|
|
|
|
|
|
const r = Math.round(n), s = ["th", "st", "nd", "rd"], v = r % 100;
|
|
|
|
|
|
return r + (s[(v - 20) % 10] || s[v] || s[0]);
|
|
|
|
|
|
};
|
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
|
|
|
|
|
|
|
|
|
|
// A percentile as a display ordinal. Mirrors grading.pct_ordinal() in the
|
|
|
|
|
|
// backend — floored into 1..99, because an empirical percentile is a rank
|
|
|
|
|
|
// against the sample and can round to 100 (or 0), and "100th percentile" reads
|
|
|
|
|
|
// as a measurement error rather than "as extreme as it has ever been".
|
|
|
|
|
|
// Every percentile shown anywhere goes through this, so the Day page, the
|
|
|
|
|
|
// calendar tooltip, the chart, the city pages and the homepage strip can't
|
|
|
|
|
|
// disagree about the same reading.
|
|
|
|
|
|
export const pctOrd = (n) => {
|
|
|
|
|
|
const v = Number(n);
|
|
|
|
|
|
if (!Number.isFinite(v)) return "—";
|
|
|
|
|
|
return ord(Math.min(99, Math.max(1, Math.round(v))));
|
|
|
|
|
|
};
|
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
|
|
|
|
export const todayISO = () => new Date().toISOString().slice(0, 10);
|
|
|
|
|
|
export const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
|
|
|
|
// The heading label for a graded response: the resolved place name, or the
|
|
|
|
|
|
// cell-center coordinates while (or if) no name resolves.
|
|
|
|
|
|
export const placeLabel = (data) =>
|
|
|
|
|
|
data.place || `${data.cell.center_lat.toFixed(3)}, ${data.cell.center_lon.toFixed(3)}`;
|
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47)
The page scripts each re-declared the shared presentation layer — the tier
color table existed in four places (app.js, calendar.js, day.js, style.css)
and the scale label tables in three (plus legend.html's own drifting copy),
alongside per-page copies of the dryness ramp, formatters, ord, todayISO,
esc, the weather icons/summary, month helpers and the 2-year range chunker.
~240 duplicated lines deleted (net -236 with the new module included).
- frontend/shared.js (IIFE, extends window.Thermograph): tier colors read
from style.css's :root custom properties at load — the CSS is now the
single source of truth; the JS map exists only because inline-SVG work
(chart + PNG export) needs literal values, with hex fallbacks for a
missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord,
todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker,
and the weatherType summary (dsr-aware; the day page just omits dsr).
- nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in
the shared classic-script scope, and a leaked locHash collided with page
destructuring (caught by the browser smoke, not by node --check).
locHash is now exported and used by all 8 former hand-built hash sites.
- mappicker.js: initFindButton/setFindLabel replace the Find-button block
each page rebuilt.
- legend.html renders its scales from the shared tables, so the guide can
no longer drift from what the app shows.
Verified: node --check on all JS; 108 backend tests; headless-Chromium
smoke over all five pages against live data — no console/page errors,
legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle,
calendar grid + key + metric switch, day 7 ladders + weather icon,
compare seeded rank card.
2026-07-11 20:21:48 +00:00
|
|
|
|
|
2026-07-12 06:32:10 +00:00
|
|
|
|
// A place label is "neighbourhood, city, region, country" (comma-joined by the
|
|
|
|
|
|
// backend). For display we lead with the first segment and mute the rest:
|
|
|
|
|
|
// namePrimary → the lead segment; nameParts → { primary, rest } with rest joined by
|
|
|
|
|
|
// " · " (e.g. "Seattle · Washington · United States"). Tolerant of 1–4 parts.
|
|
|
|
|
|
export const namePrimary = (name) =>
|
|
|
|
|
|
String(name || "").split(",")[0].trim() || String(name || "");
|
|
|
|
|
|
export function nameParts(name) {
|
|
|
|
|
|
const segs = String(name || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
|
|
|
|
return { primary: segs[0] || String(name || ""), rest: segs.slice(1).join(" · ") };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
|
|
|
|
// ---- dates & range chunking ----
|
|
|
|
|
|
export const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
|
|
|
|
export const pad = (n) => String(n).padStart(2, "0");
|
|
|
|
|
|
export const isoOfDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
|
|
|
|
export const monthStart = (ym) => `${ym}-01`; // 1st of a "YYYY-MM"
|
|
|
|
|
|
export const monthEnd = (ym) => isoOfDate(new Date(+ym.slice(0, 4), +ym.slice(5, 7), 0));
|
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47)
The page scripts each re-declared the shared presentation layer — the tier
color table existed in four places (app.js, calendar.js, day.js, style.css)
and the scale label tables in three (plus legend.html's own drifting copy),
alongside per-page copies of the dryness ramp, formatters, ord, todayISO,
esc, the weather icons/summary, month helpers and the 2-year range chunker.
~240 duplicated lines deleted (net -236 with the new module included).
- frontend/shared.js (IIFE, extends window.Thermograph): tier colors read
from style.css's :root custom properties at load — the CSS is now the
single source of truth; the JS map exists only because inline-SVG work
(chart + PNG export) needs literal values, with hex fallbacks for a
missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord,
todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker,
and the weatherType summary (dsr-aware; the day page just omits dsr).
- nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in
the shared classic-script scope, and a leaked locHash collided with page
destructuring (caught by the browser smoke, not by node --check).
locHash is now exported and used by all 8 former hand-built hash sites.
- mappicker.js: initFindButton/setFindLabel replace the Find-button block
each page rebuilt.
- legend.html renders its scales from the shared tables, so the guide can
no longer drift from what the app shows.
Verified: node --check on all JS; 108 backend tests; headless-Chromium
smoke over all five pages against live data — no console/page errors,
legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle,
calendar grid + key + metric switch, day 7 ladders + weather icon,
compare seeded rank card.
2026-07-11 20:21:48 +00:00
|
|
|
|
|
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
|
|
|
|
// Split [startIso, endIso] into consecutive ≤2-year windows (oldest first) so
|
|
|
|
|
|
// each stays within the calendar endpoint's per-request cap; the calendar grid
|
|
|
|
|
|
// and the compare series both fill chunk by chunk.
|
|
|
|
|
|
export const CHUNK_MONTHS = 24;
|
|
|
|
|
|
export function buildChunks(startIso, endIso) {
|
|
|
|
|
|
const chunks = [];
|
|
|
|
|
|
let s = startIso, guard = 0;
|
|
|
|
|
|
while (s <= endIso && guard++ < 200) {
|
|
|
|
|
|
const sd = new Date(s + "T00:00:00");
|
|
|
|
|
|
const ed = new Date(sd.getFullYear(), sd.getMonth() + CHUNK_MONTHS, sd.getDate());
|
|
|
|
|
|
ed.setDate(ed.getDate() - 1); // 24 months inclusive
|
|
|
|
|
|
let e = isoOfDate(ed);
|
|
|
|
|
|
if (e > endIso) e = endIso;
|
|
|
|
|
|
chunks.push({ start: s, end: e });
|
|
|
|
|
|
const ns = new Date(e + "T00:00:00"); ns.setDate(ns.getDate() + 1);
|
|
|
|
|
|
s = isoOfDate(ns);
|
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47)
The page scripts each re-declared the shared presentation layer — the tier
color table existed in four places (app.js, calendar.js, day.js, style.css)
and the scale label tables in three (plus legend.html's own drifting copy),
alongside per-page copies of the dryness ramp, formatters, ord, todayISO,
esc, the weather icons/summary, month helpers and the 2-year range chunker.
~240 duplicated lines deleted (net -236 with the new module included).
- frontend/shared.js (IIFE, extends window.Thermograph): tier colors read
from style.css's :root custom properties at load — the CSS is now the
single source of truth; the JS map exists only because inline-SVG work
(chart + PNG export) needs literal values, with hex fallbacks for a
missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord,
todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker,
and the weatherType summary (dsr-aware; the day page just omits dsr).
- nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in
the shared classic-script scope, and a leaked locHash collided with page
destructuring (caught by the browser smoke, not by node --check).
locHash is now exported and used by all 8 former hand-built hash sites.
- mappicker.js: initFindButton/setFindLabel replace the Find-button block
each page rebuilt.
- legend.html renders its scales from the shared tables, so the guide can
no longer drift from what the app shows.
Verified: node --check on all JS; 108 backend tests; headless-Chromium
smoke over all five pages against live data — no console/page errors,
legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle,
calendar grid + key + metric switch, day 7 ladders + weather icon,
compare seeded rank card.
2026-07-11 20:21:48 +00:00
|
|
|
|
}
|
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
|
|
|
|
return chunks;
|
|
|
|
|
|
}
|
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47)
The page scripts each re-declared the shared presentation layer — the tier
color table existed in four places (app.js, calendar.js, day.js, style.css)
and the scale label tables in three (plus legend.html's own drifting copy),
alongside per-page copies of the dryness ramp, formatters, ord, todayISO,
esc, the weather icons/summary, month helpers and the 2-year range chunker.
~240 duplicated lines deleted (net -236 with the new module included).
- frontend/shared.js (IIFE, extends window.Thermograph): tier colors read
from style.css's :root custom properties at load — the CSS is now the
single source of truth; the JS map exists only because inline-SVG work
(chart + PNG export) needs literal values, with hex fallbacks for a
missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord,
todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker,
and the weatherType summary (dsr-aware; the day page just omits dsr).
- nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in
the shared classic-script scope, and a leaked locHash collided with page
destructuring (caught by the browser smoke, not by node --check).
locHash is now exported and used by all 8 former hand-built hash sites.
- mappicker.js: initFindButton/setFindLabel replace the Find-button block
each page rebuilt.
- legend.html renders its scales from the shared tables, so the guide can
no longer drift from what the app shows.
Verified: node --check on all JS; 108 backend tests; headless-Chromium
smoke over all five pages against live data — no console/page errors,
legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle,
calendar grid + key + metric switch, day 7 ladders + weather icon,
compare seeded rank card.
2026-07-11 20:21:48 +00:00
|
|
|
|
|
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
|
|
|
|
// Clicking anywhere in a month/date field opens the native picker. Desktop
|
|
|
|
|
|
// Chrome otherwise only opens it from the tiny calendar glyph and just focuses
|
|
|
|
|
|
// a segment; showPicker is missing in some browsers (Safari/Firefox) — ignore.
|
|
|
|
|
|
export function clickOpensPicker(...els) {
|
|
|
|
|
|
for (const el of els) {
|
|
|
|
|
|
el.addEventListener("click", () => { try { el.showPicker(); } catch (e) {} });
|
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47)
The page scripts each re-declared the shared presentation layer — the tier
color table existed in four places (app.js, calendar.js, day.js, style.css)
and the scale label tables in three (plus legend.html's own drifting copy),
alongside per-page copies of the dryness ramp, formatters, ord, todayISO,
esc, the weather icons/summary, month helpers and the 2-year range chunker.
~240 duplicated lines deleted (net -236 with the new module included).
- frontend/shared.js (IIFE, extends window.Thermograph): tier colors read
from style.css's :root custom properties at load — the CSS is now the
single source of truth; the JS map exists only because inline-SVG work
(chart + PNG export) needs literal values, with hex fallbacks for a
missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord,
todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker,
and the weatherType summary (dsr-aware; the day page just omits dsr).
- nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in
the shared classic-script scope, and a leaked locHash collided with page
destructuring (caught by the browser smoke, not by node --check).
locHash is now exported and used by all 8 former hand-built hash sites.
- mappicker.js: initFindButton/setFindLabel replace the Find-button block
each page rebuilt.
- legend.html renders its scales from the shared tables, so the guide can
no longer drift from what the app shows.
Verified: node --check on all JS; 108 backend tests; headless-Chromium
smoke over all five pages against live data — no console/page errors,
legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle,
calendar grid + key + metric switch, day 7 ladders + weather icon,
compare seeded rank card.
2026-07-11 20:21:48 +00:00
|
|
|
|
}
|
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
|
|
|
|
}
|
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47)
The page scripts each re-declared the shared presentation layer — the tier
color table existed in four places (app.js, calendar.js, day.js, style.css)
and the scale label tables in three (plus legend.html's own drifting copy),
alongside per-page copies of the dryness ramp, formatters, ord, todayISO,
esc, the weather icons/summary, month helpers and the 2-year range chunker.
~240 duplicated lines deleted (net -236 with the new module included).
- frontend/shared.js (IIFE, extends window.Thermograph): tier colors read
from style.css's :root custom properties at load — the CSS is now the
single source of truth; the JS map exists only because inline-SVG work
(chart + PNG export) needs literal values, with hex fallbacks for a
missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord,
todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker,
and the weatherType summary (dsr-aware; the day page just omits dsr).
- nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in
the shared classic-script scope, and a leaked locHash collided with page
destructuring (caught by the browser smoke, not by node --check).
locHash is now exported and used by all 8 former hand-built hash sites.
- mappicker.js: initFindButton/setFindLabel replace the Find-button block
each page rebuilt.
- legend.html renders its scales from the shared tables, so the guide can
no longer drift from what the app shows.
Verified: node --check on all JS; 108 backend tests; headless-Chromium
smoke over all five pages against live data — no console/page errors,
legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle,
calendar grid + key + metric switch, day 7 ladders + weather icon,
compare seeded rank card.
2026-07-11 20:21:48 +00:00
|
|
|
|
|
2026-07-12 05:33:09 +00:00
|
|
|
|
// ---- season / month time filter (shared by calendar + compare) ----
|
|
|
|
|
|
// Seasons are the primary, color-coded selector; the 12 months are the underlying
|
|
|
|
|
|
// unit and a nested suboption. The single source of truth on both pages is a Set of
|
|
|
|
|
|
// month indices (0=Jan … 11=Dec); a season is a roll-up over its three months.
|
|
|
|
|
|
export const seasonOf = (m) => (m === 11 || m <= 1) ? "winter" : m <= 4 ? "spring" : m <= 7 ? "summer" : "fall";
|
|
|
|
|
|
// [key, northern label, southern label]: the southern hemisphere sees the opposite
|
|
|
|
|
|
// season for the same calendar months, so only the display label flips.
|
|
|
|
|
|
export const SEASONS = [
|
|
|
|
|
|
["winter", "Winter", "Summer"], ["spring", "Spring", "Fall"],
|
|
|
|
|
|
["summer", "Summer", "Winter"], ["fall", "Fall", "Spring"],
|
|
|
|
|
|
];
|
|
|
|
|
|
export const monthsOfSeason = (key) =>
|
|
|
|
|
|
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].filter((m) => seasonOf(m) === key);
|
|
|
|
|
|
export const allMonths = () => new Set([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
|
|
|
|
|
|
// A Set<0..11> ⇄ a compact 12-char "0/1" bitmask (for localStorage + URL hashes).
|
|
|
|
|
|
export const monthsToMask = (set) =>
|
|
|
|
|
|
Array.from({ length: 12 }, (_, m) => (set.has(m) ? "1" : "0")).join("");
|
|
|
|
|
|
export function maskToMonths(mask) {
|
|
|
|
|
|
if (typeof mask !== "string" || !/^[01]{12}$/.test(mask)) return null; // invalid → caller defaults
|
|
|
|
|
|
const set = new Set();
|
|
|
|
|
|
for (let m = 0; m < 12; m++) if (mask[m] === "1") set.add(m);
|
|
|
|
|
|
return set;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// A collapsed summary of the month selection: "All year" / "None" / whole seasons
|
|
|
|
|
|
// ("Summer") / seasons plus stray months ("Summer +1 mo") / a short month list / a count.
|
|
|
|
|
|
export function seasonSummaryText(checkedMonths, opts = {}) {
|
|
|
|
|
|
const n = checkedMonths.size;
|
|
|
|
|
|
if (n === 0) return "None";
|
|
|
|
|
|
if (n === 12) return "All year";
|
|
|
|
|
|
const full = SEASONS.filter((s) => monthsOfSeason(s[0]).every((m) => checkedMonths.has(m)));
|
|
|
|
|
|
const inFull = new Set(full.flatMap((s) => monthsOfSeason(s[0])));
|
|
|
|
|
|
const extra = [...checkedMonths].filter((m) => !inFull.has(m));
|
|
|
|
|
|
const names = full.map((s) => (opts.southern ? s[2] : s[1]));
|
|
|
|
|
|
if (names.length && !extra.length) return names.join(", ");
|
|
|
|
|
|
if (names.length) return `${names.join(", ")} +${extra.length} mo`;
|
|
|
|
|
|
if (n <= 3) return [...checkedMonths].sort((a, b) => a - b).map((m) => MONTHS[m]).join(", ");
|
|
|
|
|
|
return `${n} months`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Markup for the season-primary time filter: a compact <details> (the shared
|
|
|
|
|
|
// .cal-dd dropdown) whose panel lists the four seasons as colored rows — each a
|
|
|
|
|
|
// season checkbox (checked/indeterminate by how many of its months are on) plus an
|
|
|
|
|
|
// expand button revealing that season's three month checkboxes (the suboption).
|
|
|
|
|
|
// Pages insert this, then call syncSeasonChecks() to set the indeterminate state and
|
|
|
|
|
|
// initSeasonExpand() once on a stable ancestor to wire the expand toggles.
|
|
|
|
|
|
export function seasonFilterDropdown(checkedMonths, opts = {}) {
|
|
|
|
|
|
const rows = SEASONS.map((s) => {
|
|
|
|
|
|
const key = s[0], label = opts.southern ? s[2] : s[1], ms = monthsOfSeason(key);
|
|
|
|
|
|
const on = ms.filter((m) => checkedMonths.has(m)).length;
|
|
|
|
|
|
const months = ms.map((m) =>
|
|
|
|
|
|
`<label class="cal-dd-opt month-opt"><input type="checkbox" data-month="${m}"${checkedMonths.has(m) ? " checked" : ""}>${MONTHS[m]}</label>`).join("");
|
|
|
|
|
|
return `<div class="season-row" data-season="${key}" style="--season:var(--season-${key})">
|
|
|
|
|
|
<div class="season-head">
|
|
|
|
|
|
<label class="season-check"><input type="checkbox" class="season-cb" data-season="${key}"${on === ms.length ? " checked" : ""}>
|
|
|
|
|
|
<span class="season-sw" aria-hidden="true"></span><span class="season-name">${label}</span></label>
|
|
|
|
|
|
<button type="button" class="season-expand" data-expand="${key}" aria-expanded="false">Months <span class="season-caret" aria-hidden="true">▾</span></button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="season-months" hidden>${months}</div>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
}).join("");
|
2026-07-12 07:11:21 +00:00
|
|
|
|
// A "Full year" select-all sits above the four seasons: checked when all 12 months
|
|
|
|
|
|
// are on, cleared to none when unticked (indeterminate state set by syncSeasonChecks).
|
|
|
|
|
|
const allYear = `<label class="season-check season-allyear"><input type="checkbox" class="allyear-cb" data-allyear${checkedMonths.size === 12 ? " checked" : ""}>
|
|
|
|
|
|
<span class="season-sw allyear-sw" aria-hidden="true"></span><span class="season-name">Full year</span></label>`;
|
2026-07-12 05:33:09 +00:00
|
|
|
|
return `<details class="cal-dd cal-season-dd" data-dd="season">
|
|
|
|
|
|
<summary>
|
|
|
|
|
|
<span class="cal-filter-label">Seasons</span>
|
|
|
|
|
|
<span class="cal-dd-sum">${seasonSummaryText(checkedMonths, opts)}</span>
|
|
|
|
|
|
<span class="cal-dd-caret" aria-hidden="true">▾</span>
|
|
|
|
|
|
</summary>
|
2026-07-12 07:11:21 +00:00
|
|
|
|
<div class="cal-dd-panel cal-season-panel">${allYear}${rows}</div>
|
2026-07-12 05:33:09 +00:00
|
|
|
|
</details>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Reflect the month Set onto the four season checkboxes: checked when all three of a
|
|
|
|
|
|
// season's months are on, indeterminate when one or two are (indeterminate is a JS
|
|
|
|
|
|
// property, so this must run after every render/change).
|
|
|
|
|
|
export function syncSeasonChecks(root, checkedMonths) {
|
|
|
|
|
|
root.querySelectorAll(".season-cb").forEach((cb) => {
|
|
|
|
|
|
const ms = monthsOfSeason(cb.dataset.season);
|
|
|
|
|
|
const on = ms.filter((m) => checkedMonths.has(m)).length;
|
|
|
|
|
|
cb.checked = on === ms.length;
|
|
|
|
|
|
cb.indeterminate = on > 0 && on < ms.length;
|
|
|
|
|
|
});
|
2026-07-12 07:11:21 +00:00
|
|
|
|
const ay = root.querySelector(".allyear-cb");
|
|
|
|
|
|
if (ay) {
|
|
|
|
|
|
ay.checked = checkedMonths.size === 12;
|
|
|
|
|
|
ay.indeterminate = checkedMonths.size > 0 && checkedMonths.size < 12;
|
|
|
|
|
|
}
|
2026-07-12 05:33:09 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Apply a season/month checkbox change to the month Set. Returns true if the event
|
|
|
|
|
|
// was a season/month toggle (so the caller knows to persist + re-render).
|
|
|
|
|
|
export function applySeasonMonthChange(cb, checkedMonths) {
|
2026-07-12 07:11:21 +00:00
|
|
|
|
if (cb.dataset.allyear != null) {
|
|
|
|
|
|
if (cb.checked) allMonths().forEach((m) => checkedMonths.add(m));
|
|
|
|
|
|
else checkedMonths.clear();
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
2026-07-12 05:33:09 +00:00
|
|
|
|
if (cb.dataset.season) {
|
|
|
|
|
|
const ms = monthsOfSeason(cb.dataset.season);
|
|
|
|
|
|
if (cb.checked) ms.forEach((m) => checkedMonths.add(m));
|
|
|
|
|
|
else ms.forEach((m) => checkedMonths.delete(m));
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (cb.dataset.month != null) {
|
|
|
|
|
|
const m = +cb.dataset.month;
|
|
|
|
|
|
if (cb.checked) checkedMonths.add(m); else checkedMonths.delete(m);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Wire the per-season "Months" expand toggles (delegated on a stable ancestor).
|
|
|
|
|
|
export function initSeasonExpand(container) {
|
|
|
|
|
|
container.addEventListener("click", (e) => {
|
|
|
|
|
|
const btn = e.target.closest(".season-expand");
|
|
|
|
|
|
if (!btn || !container.contains(btn)) return;
|
|
|
|
|
|
const row = btn.closest(".season-row");
|
|
|
|
|
|
const wrap = row.querySelector(".season-months");
|
|
|
|
|
|
const show = wrap.hidden;
|
|
|
|
|
|
wrap.hidden = !show;
|
|
|
|
|
|
btn.setAttribute("aria-expanded", String(show));
|
|
|
|
|
|
row.classList.toggle("expanded", show);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
|
|
|
|
// ---- weather summary ----
|
|
|
|
|
|
// Monochrome line icons (currentColor) — no emoji, matching the site's dark
|
|
|
|
|
|
// look. Feather-style paths.
|
|
|
|
|
|
const WX = (paths) =>
|
|
|
|
|
|
`<svg class="wx" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
|
|
|
|
|
|
const WX_CLOUD = `<path d="M20 16.6A5 5 0 0 0 18 7h-1.26A8 8 0 1 0 4 15.25"/>`;
|
|
|
|
|
|
export const WX_ICONS = {
|
|
|
|
|
|
sun: WX(`<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.2" y1="4.2" x2="5.6" y2="5.6"/><line x1="18.4" y1="18.4" x2="19.8" y2="19.8"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.2" y1="19.8" x2="5.6" y2="18.4"/><line x1="18.4" y1="5.6" x2="19.8" y2="4.2"/>`),
|
|
|
|
|
|
drizzle: WX(`${WX_CLOUD}<line x1="8" y1="19" x2="8" y2="21"/><line x1="16" y1="19" x2="16" y2="21"/><line x1="12" y1="20" x2="12" y2="22"/>`),
|
|
|
|
|
|
rain: WX(`${WX_CLOUD}<line x1="8" y1="19" x2="8" y2="22"/><line x1="16" y1="19" x2="16" y2="22"/><line x1="12" y1="19" x2="12" y2="23"/>`),
|
|
|
|
|
|
storm: WX(`<path d="M19 16.9A5 5 0 0 0 18 7h-1.26a8 8 0 1 0-11.62 9"/><polyline points="13 11 9 17 15 17 11 23"/>`),
|
|
|
|
|
|
snow: WX(`<path d="M20 17.6A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25"/><line x1="8" y1="18" x2="8" y2="18"/><line x1="12" y1="20" x2="12" y2="20"/><line x1="16" y1="18" x2="16" y2="18"/><line x1="12" y1="16" x2="12" y2="16"/>`),
|
|
|
|
|
|
};
|
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47)
The page scripts each re-declared the shared presentation layer — the tier
color table existed in four places (app.js, calendar.js, day.js, style.css)
and the scale label tables in three (plus legend.html's own drifting copy),
alongside per-page copies of the dryness ramp, formatters, ord, todayISO,
esc, the weather icons/summary, month helpers and the 2-year range chunker.
~240 duplicated lines deleted (net -236 with the new module included).
- frontend/shared.js (IIFE, extends window.Thermograph): tier colors read
from style.css's :root custom properties at load — the CSS is now the
single source of truth; the JS map exists only because inline-SVG work
(chart + PNG export) needs literal values, with hex fallbacks for a
missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord,
todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker,
and the weatherType summary (dsr-aware; the day page just omits dsr).
- nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in
the shared classic-script scope, and a leaked locHash collided with page
destructuring (caught by the browser smoke, not by node --check).
locHash is now exported and used by all 8 former hand-built hash sites.
- mappicker.js: initFindButton/setFindLabel replace the Find-button block
each page rebuilt.
- legend.html renders its scales from the shared tables, so the guide can
no longer drift from what the app shows.
Verified: node --check on all JS; 108 backend tests; headless-Chromium
smoke over all five pages against live data — no console/page errors,
legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle,
calendar grid + key + metric switch, day 7 ladders + weather icon,
compare seeded rank card.
2026-07-11 20:21:48 +00:00
|
|
|
|
|
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
|
|
|
|
// Plain-language "what was the day like" descriptor from the raw values: a
|
|
|
|
|
|
// temperature word (from the daily high, °F) crossed with a sky/precip word
|
|
|
|
|
|
// (precip in inches — falling as snow at/below freezing). `dsr` is optional:
|
|
|
|
|
|
// when a long dry streak is known, a no-rain day reads "dry" instead of
|
|
|
|
|
|
// "clear" (the calendar passes it; the day page doesn't track streaks).
|
|
|
|
|
|
export function weatherType(t, p, dsr) {
|
|
|
|
|
|
const wet = p != null && p >= 0.01;
|
|
|
|
|
|
const freezing = t != null && t <= 34;
|
|
|
|
|
|
const temp = t == null ? "" :
|
|
|
|
|
|
t >= 95 ? "Scorching" : t >= 85 ? "Hot" : t >= 72 ? "Warm" :
|
|
|
|
|
|
t >= 58 ? "Mild" : t >= 45 ? "Cool" : t >= 32 ? "Cold" : "Frigid";
|
|
|
|
|
|
let sky, skyKey, icon;
|
|
|
|
|
|
if (wet && freezing) { sky = p >= 0.3 ? "heavy snow" : "snow"; skyKey = "snow"; icon = WX_ICONS.snow; }
|
|
|
|
|
|
else if (wet && p >= 1.0) { sky = "downpour"; skyKey = "downpour"; icon = WX_ICONS.storm; }
|
|
|
|
|
|
else if (wet && p >= 0.3) { sky = "rain"; skyKey = "rain"; icon = WX_ICONS.rain; }
|
|
|
|
|
|
else if (wet) { sky = "light rain"; skyKey = "drizzle"; icon = WX_ICONS.drizzle; }
|
|
|
|
|
|
else {
|
|
|
|
|
|
skyKey = dsr != null && dsr >= 10 ? "dry" : "clear";
|
|
|
|
|
|
sky = skyKey === "dry" ? "dry" : "clear";
|
|
|
|
|
|
icon = WX_ICONS.sun;
|
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47)
The page scripts each re-declared the shared presentation layer — the tier
color table existed in four places (app.js, calendar.js, day.js, style.css)
and the scale label tables in three (plus legend.html's own drifting copy),
alongside per-page copies of the dryness ramp, formatters, ord, todayISO,
esc, the weather icons/summary, month helpers and the 2-year range chunker.
~240 duplicated lines deleted (net -236 with the new module included).
- frontend/shared.js (IIFE, extends window.Thermograph): tier colors read
from style.css's :root custom properties at load — the CSS is now the
single source of truth; the JS map exists only because inline-SVG work
(chart + PNG export) needs literal values, with hex fallbacks for a
missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord,
todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker,
and the weatherType summary (dsr-aware; the day page just omits dsr).
- nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in
the shared classic-script scope, and a leaked locHash collided with page
destructuring (caught by the browser smoke, not by node --check).
locHash is now exported and used by all 8 former hand-built hash sites.
- mappicker.js: initFindButton/setFindLabel replace the Find-button block
each page rebuilt.
- legend.html renders its scales from the shared tables, so the guide can
no longer drift from what the app shows.
Verified: node --check on all JS; 108 backend tests; headless-Chromium
smoke over all five pages against live data — no console/page errors,
legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle,
calendar grid + key + metric switch, day 7 ladders + weather icon,
compare seeded rank card.
2026-07-11 20:21:48 +00:00
|
|
|
|
}
|
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
|
|
|
|
const text = !temp ? sky : wet ? `${temp}, ${sky}` : `${temp} & ${sky}`;
|
|
|
|
|
|
return { icon, text: text.charAt(0).toUpperCase() + text.slice(1),
|
|
|
|
|
|
feel: temp.toLowerCase(), sky: skyKey };
|
|
|
|
|
|
}
|