thermograph/frontend/static/shared.js

495 lines
26 KiB
JavaScript
Raw Normal View History

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.
import { fmtTemp, fmtPrecip, fmtWind, fmtHumid, getUnit, windUnit, precipUnit,
Report rain in whole millimetres; lift tier spans out of the bars (#192) Two fixes to how measurements read. Rain goes back to millimetres, rendered as whole numbers — that is how rainfall is reported, and a tenth of a millimetre is below what the source resolves. Inches keep two decimals, since a whole inch of rain is a lot to round to. The distribution strip's low-high span moves from inside each bar to just under the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min 17°" pill clipped — it had already been shrunk to 8px to cope, and on a high-rainfall city with longer values it lost both ends of the label, because the text is centred and overflow-hidden. Above the bar it has the full column width and needs no scrim to stay legible over nine tier colours. The span is now bare numbers ("17-62"), since the average directly above it carries the unit; repeating " g/m³" on both ends is what made it too wide in the first place. It uses the same precision as that average, or the two lines disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero so precip's ten columns still fit a phone. With nothing inside the bars, the height floor drops from 30px to 14px: it only has to keep a non-empty bucket visible now, so the heights encode frequency more honestly. Category names get 2px of side padding, which stops long neighbours ("Light-Mod" beside "Moderate") reading as one word. Verified at 390/412/1920 in both themes across all four calendar metrics and the compare strips, in imperial and metric: nothing clips. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00
toUnit, toWind, toPrecip } from "./units.js";
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
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: "110" },
{ c: "cold", label: "Low", range: "1025" },
{ c: "cool", label: "Below Normal", range: "2540" },
{ c: "normal", label: "Normal", range: "4060" },
{ c: "warm", label: "Above Normal", range: "6075" },
{ c: "hot", label: "High", range: "7590" },
{ c: "very-hot", label: "Very High", range: "9099" },
{ 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-2", label: "Trace", range: "<10" },
2026-07-11 20:28:33 +00:00
{ c: "wet-3", label: "Light", range: "1025" },
{ c: "wet-4", label: "Brisk", range: "2540" },
{ c: "wet-5", label: "Typical", range: "4060" },
{ c: "wet-6", label: "Heavy", range: "6090" },
{ c: "wet-7", label: "Very Heavy", range: "9095" },
{ c: "wet-8", label: "Severe", range: "9599" },
2026-07-11 20:28:33 +00:00
{ 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
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];
export const DRY_CAP = 14;
2026-07-11 20:28:33 +00:00
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
// ---- 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],
["13 dry", drynessColor(2), (d) => d >= 1 && d <= 3],
["46 dry", drynessColor(5), (d) => d >= 4 && d <= 6],
["713 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).
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 };
});
}
if (metric === "precip") {
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;
}
// Temperature-style metrics: percentile tiers on the class, plus the actual values
// per tier for the average + minmax 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) };
});
}
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
// Format a single metric value in the metric's own unit. Everything measurable
// follows the active unit system via units.js; a dry-streak is a day count and
// has no unit to follow.
function fmtMetricVal(unit, v) {
if (v == null || isNaN(v)) return "";
if (unit === "temp") return fmtTemp(v);
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
if (unit === "humid") return fmtHumid(v);
if (unit === "wind") return fmtWind(v);
Report rain in whole millimetres; lift tier spans out of the bars (#192) Two fixes to how measurements read. Rain goes back to millimetres, rendered as whole numbers — that is how rainfall is reported, and a tenth of a millimetre is below what the source resolves. Inches keep two decimals, since a whole inch of rain is a lot to round to. The distribution strip's low-high span moves from inside each bar to just under the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min 17°" pill clipped — it had already been shrunk to 8px to cope, and on a high-rainfall city with longer values it lost both ends of the label, because the text is centred and overflow-hidden. Above the bar it has the full column width and needs no scrim to stay legible over nine tier colours. The span is now bare numbers ("17-62"), since the average directly above it carries the unit; repeating " g/m³" on both ends is what made it too wide in the first place. It uses the same precision as that average, or the two lines disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero so precip's ten columns still fit a phone. With nothing inside the bars, the height floor drops from 30px to 14px: it only has to keep a non-empty bucket visible now, so the heights encode frequency more honestly. Category names get 2px of side padding, which stops long neighbours ("Light-Mod" beside "Moderate") reading as one word. Verified at 390/412/1920 in both themes across all four calendar metrics and the compare strips, in imperial and metric: nothing clips. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00
// The tier bar is tight on width, so precip keeps the ″ glyph over " mm"/" in".
if (unit === "precip") return `${fmtPrecip(v, false)}${getUnit() === "C" ? "mm" : "″"}`;
if (unit === "dsr") return `${Math.round(v)}d`;
return `${Math.round(v)}`;
}
// The value of a bucket's average with no unit — the unit is shown once at the top
// of the strip (stripUnitLabel), not repeated on every bar. Temperature keeps its
// degree glyph, which is iconic and narrow; the wordy units (" g/m³", " mph") are
// exactly what made these labels clip on a phone.
function fmtMetricBare(unit, v) {
if (v == null || isNaN(v)) return "";
if (unit === "temp") return fmtTemp(v); // "40°"
if (unit === "humid") return v.toFixed(1); // "4.0"
if (unit === "wind") return fmtWind(v, false); // "5"
if (unit === "precip") return fmtPrecip(v, false);// "0.00" / "5"
if (unit === "dsr") return `${Math.round(v)}`; // "9", header supplies "days"
return `${Math.round(v)}`;
}
// The unit label for a whole strip, shown once at its top. Reacts to the active
// unit system, so it flips with the toggle alongside the values.
function stripUnitLabel(unit) {
if (unit === "temp") return `°${getUnit()}`;
if (unit === "humid") return "g/m³";
if (unit === "wind") return windUnit();
if (unit === "precip") return precipUnit();
if (unit === "dsr") return "days";
return "";
}
Report rain in whole millimetres; lift tier spans out of the bars (#192) Two fixes to how measurements read. Rain goes back to millimetres, rendered as whole numbers — that is how rainfall is reported, and a tenth of a millimetre is below what the source resolves. Inches keep two decimals, since a whole inch of rain is a lot to round to. The distribution strip's low-high span moves from inside each bar to just under the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min 17°" pill clipped — it had already been shrunk to 8px to cope, and on a high-rainfall city with longer values it lost both ends of the label, because the text is centred and overflow-hidden. Above the bar it has the full column width and needs no scrim to stay legible over nine tier colours. The span is now bare numbers ("17-62"), since the average directly above it carries the unit; repeating " g/m³" on both ends is what made it too wide in the first place. It uses the same precision as that average, or the two lines disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero so precip's ten columns still fit a phone. With nothing inside the bars, the height floor drops from 30px to 14px: it only has to keep a non-empty bucket visible now, so the heights encode frequency more honestly. Category names get 2px of side padding, which stops long neighbours ("Light-Mod" beside "Moderate") reading as one word. Verified at 390/412/1920 in both themes across all four calendar metrics and the compare strips, in imperial and metric: nothing clips. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00
// The lowhigh span for a tier, as bare numbers. No unit: this line sits directly
// under the average, which carries it, and a column is ~38px wide on a phone —
// repeating " g/m³" on both ends is what made the old in-bar label clip.
// Two decimals, trailing zeros dropped, and no leading zero on a sub-1 value —
// ".93" instead of "0.93". Inch rainfall is almost all sub-1, and that leading
// character is the one that pushed the range past a phone column's width.
const _trim = (n) => parseFloat(n.toFixed(2)).toString().replace(/^0\./, ".");
function fmtMetricRange(unit, lo, hi) {
if (lo == null || hi == null || isNaN(lo) || isNaN(hi)) return "";
if (unit === "temp") return `${Math.round(toUnit(lo))}${Math.round(toUnit(hi))}`;
if (unit === "wind") return `${Math.round(toWind(lo))}${Math.round(toWind(hi))}`;
if (unit === "humid") return `${Math.round(lo)}${Math.round(hi)}`;
if (unit === "precip") {
// Same precision the average above it uses — whole millimetres, hundredths of
// an inch — or the two lines disagree ("52mm" over "12.7136.91").
const f = (v) => _trim(getUnit() === "C" ? Math.round(toPrecip(v)) : toPrecip(v));
return `${f(lo)}${f(hi)}`;
}
return `${Math.round(lo)}${Math.round(hi)}`;
}
// Render buckets from metricBuckets() as a connected bar chart, one bar per category
Report rain in whole millimetres; lift tier spans out of the bars (#192) Two fixes to how measurements read. Rain goes back to millimetres, rendered as whole numbers — that is how rainfall is reported, and a tenth of a millimetre is below what the source resolves. Inches keep two decimals, since a whole inch of rain is a lot to round to. The distribution strip's low-high span moves from inside each bar to just under the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min 17°" pill clipped — it had already been shrunk to 8px to cope, and on a high-rainfall city with longer values it lost both ends of the label, because the text is centred and overflow-hidden. Above the bar it has the full column width and needs no scrim to stay legible over nine tier colours. The span is now bare numbers ("17-62"), since the average directly above it carries the unit; repeating " g/m³" on both ends is what made it too wide in the first place. It uses the same precision as that average, or the two lines disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero so precip's ten columns still fit a phone. With nothing inside the bars, the height floor drops from 30px to 14px: it only has to keep a non-empty bucket visible now, so the heights encode frequency more honestly. Category names get 2px of side padding, which stops long neighbours ("Light-Mod" beside "Moderate") reading as one word. Verified at 390/412/1920 in both themes across all four calendar metrics and the compare strips, in imperial and metric: nothing clips. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00
// low→high: the category's average sits above its bar with the lowhigh span just
// under 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 apart). Assumes ≥1 day (callers guard).
//
// The span used to sit *inside* the bar as a "Max 62° / Min 17°" pill. A column is
// ~38px on a phone, so it clipped (and had already been shrunk to 8px to cope);
// above the bar it has the full column width and needs no contrast trick to stay
// readable over nine different tier colours.
export function distStrip(buckets, showCount) {
const total = buckets.reduce((s, b) => s + b.n, 0);
const groupTotal = {}, groupCount = {}, maxByGroup = {};
for (const b of buckets) {
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);
}
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));
Report rain in whole millimetres; lift tier spans out of the bars (#192) Two fixes to how measurements read. Rain goes back to millimetres, rendered as whole numbers — that is how rainfall is reported, and a tenth of a millimetre is below what the source resolves. Inches keep two decimals, since a whole inch of rain is a lot to round to. The distribution strip's low-high span moves from inside each bar to just under the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min 17°" pill clipped — it had already been shrunk to 8px to cope, and on a high-rainfall city with longer values it lost both ends of the label, because the text is centred and overflow-hidden. Above the bar it has the full column width and needs no scrim to stay legible over nine tier colours. The span is now bare numbers ("17-62"), since the average directly above it carries the unit; repeating " g/m³" on both ends is what made it too wide in the first place. It uses the same precision as that average, or the two lines disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero so precip's ten columns still fit a phone. With nothing inside the bars, the height floor drops from 30px to 14px: it only has to keep a non-empty bucket visible now, so the heights encode frequency more honestly. Category names get 2px of side padding, which stops long neighbours ("Light-Mod" beside "Moderate") reading as one word. Verified at 390/412/1920 in both themes across all four calendar metrics and the compare strips, in imperial and metric: nothing clips. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00
// px. Nothing lives inside the bar any more, so the floor only has to keep a
// non-empty bucket visible rather than hold two lines of text — which lets the
// heights encode frequency more honestly than the old 30px floor did.
const BAR_MIN = 14, BAR_MAX = 74;
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
Report rain in whole millimetres; lift tier spans out of the bars (#192) Two fixes to how measurements read. Rain goes back to millimetres, rendered as whole numbers — that is how rainfall is reported, and a tenth of a millimetre is below what the source resolves. Inches keep two decimals, since a whole inch of rain is a lot to round to. The distribution strip's low-high span moves from inside each bar to just under the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min 17°" pill clipped — it had already been shrunk to 8px to cope, and on a high-rainfall city with longer values it lost both ends of the label, because the text is centred and overflow-hidden. Above the bar it has the full column width and needs no scrim to stay legible over nine tier colours. The span is now bare numbers ("17-62"), since the average directly above it carries the unit; repeating " g/m³" on both ends is what made it too wide in the first place. It uses the same precision as that average, or the two lines disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero so precip's ten columns still fit a phone. With nothing inside the bars, the height floor drops from 30px to 14px: it only has to keep a non-empty bucket visible now, so the heights encode frequency more honestly. Category names get 2px of side padding, which stops long neighbours ("Light-Mod" beside "Moderate") reading as one word. Verified at 390/412/1920 in both themes across all four calendar metrics and the compare strips, in imperial and metric: nothing clips. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00
? `<div class="ct-bar" style="height:${h}px"></div>`
: `<div class="ct-bar ct-bar-0"></div>`;
// Dry precip days are all zero, so a "00" range is noise — the bar and its
// share say all there is to say.
const isDryPrecip = b.unit === "precip" && b.group === "dry";
const range = (lo != null && !isDryPrecip) ? fmtMetricRange(b.unit, lo, hi) : "";
return `<div class="ct-col${b.cls ? ` ct-${b.cls}` : ""}" style="--c:${b.color}" ` +
Report rain in whole millimetres; lift tier spans out of the bars (#192) Two fixes to how measurements read. Rain goes back to millimetres, rendered as whole numbers — that is how rainfall is reported, and a tenth of a millimetre is below what the source resolves. Inches keep two decimals, since a whole inch of rain is a lot to round to. The distribution strip's low-high span moves from inside each bar to just under the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min 17°" pill clipped — it had already been shrunk to 8px to cope, and on a high-rainfall city with longer values it lost both ends of the label, because the text is centred and overflow-hidden. Above the bar it has the full column width and needs no scrim to stay legible over nine tier colours. The span is now bare numbers ("17-62"), since the average directly above it carries the unit; repeating " g/m³" on both ends is what made it too wide in the first place. It uses the same precision as that average, or the two lines disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero so precip's ten columns still fit a phone. With nothing inside the bars, the height floor drops from 30px to 14px: it only has to keep a non-empty bucket visible now, so the heights encode frequency more honestly. Category names get 2px of side padding, which stops long neighbours ("Light-Mod" beside "Moderate") reading as one word. Verified at 390/412/1920 in both themes across all four calendar metrics and the compare strips, in imperial and metric: nothing clips. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00
`title="${b.label} · ${pctText(b.n, b.group)} (${b.n})${avg != null ? ` · avg ${fmtMetricVal(b.unit, avg)}` : ""}` +
`${lo != null ? ` · ${fmtMetricVal(b.unit, lo)} to ${fmtMetricVal(b.unit, hi)}` : ""}">` +
`<span class="ct-top">${avg != null ? fmtMetricBare(b.unit, avg) : ""}</span>` +
Report rain in whole millimetres; lift tier spans out of the bars (#192) Two fixes to how measurements read. Rain goes back to millimetres, rendered as whole numbers — that is how rainfall is reported, and a tenth of a millimetre is below what the source resolves. Inches keep two decimals, since a whole inch of rain is a lot to round to. The distribution strip's low-high span moves from inside each bar to just under the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min 17°" pill clipped — it had already been shrunk to 8px to cope, and on a high-rainfall city with longer values it lost both ends of the label, because the text is centred and overflow-hidden. Above the bar it has the full column width and needs no scrim to stay legible over nine tier colours. The span is now bare numbers ("17-62"), since the average directly above it carries the unit; repeating " g/m³" on both ends is what made it too wide in the first place. It uses the same precision as that average, or the two lines disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero so precip's ten columns still fit a phone. With nothing inside the bars, the height floor drops from 30px to 14px: it only has to keep a non-empty bucket visible now, so the heights encode frequency more honestly. Category names get 2px of side padding, which stops long neighbours ("Light-Mod" beside "Moderate") reading as one word. Verified at 390/412/1920 in both themes across all four calendar metrics and the compare strips, in imperial and metric: nothing clips. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00
`<span class="ct-range">${range}</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>`;
}).join("");
// The unit once, at the top of the strip — not repeated on every bar, where the
// wordy ones ("g/m³", "km/h") clipped a ~38px phone column. Bare on dry-streak
// and any unitless metric.
const unit = stripUnitLabel(buckets[0] ? buckets[0].unit : "");
const cap = unit ? `<div class="ct-unit">${unit}</div>` : "";
return `<div class="ct-plot">${cap}<div class="ct-strip ct-connected">${cols}</div></div>`;
}
2026-07-11 20:28:33 +00:00
// ---- formatters & tiny utils ----
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 measure follows the active unit system; units.js owns the conversions and
// these are re-exported so the pages that already import them here keep working.
// (Re-exporting the imported bindings, not `export … from` — this module already
// imports them above, and declaring both is a duplicate-binding SyntaxError.)
export { fmtPrecip, fmtWind, fmtHumid };
2026-07-11 20:28:33 +00:00
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]);
};
// 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))));
};
// Today in the viewer's LOCAL zone (see isoOfDate below). A UTC date rolls a day
// early/late for UTC± viewers past local midnight, putting "today" and the
// date-picker max out of reach of the day they're actually living in.
export const todayISO = () => isoOfDate(new Date());
2026-07-11 20:28:33 +00:00
export const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// 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
// The grade/scale key strip: one ".tk-seg" (colored swatch + label) per item.
// Callers resolve each item's color (TIER_COLORS / PRECIP_COLORS / drynessColor)
// and pass { color, label } pairs, so this stays free of any color-map coupling.
export const tierKeySegs = (items) => items.map((it) =>
`<div class="tk-seg"><span class="tk-swatch" style="background:${it.color}"></span>` +
`<span class="tk-label">${it.label}</span></div>`).join("");
// The "full guide" footer link shared under every grade-scale key.
export const GUIDE_LINK =
`<p class="muted tk-note"><a href="legend">Full guide to metrics &amp; grades →</a></p>`;
// 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 14 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(" · ") };
}
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
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
}
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
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
}
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
// ---- 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("");
// 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>`;
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>
<div class="cal-dd-panel cal-season-panel">${allYear}${rows}</div>
</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;
});
const ay = root.querySelector(".allyear-cb");
if (ay) {
ay.checked = checkedMonths.size === 12;
ay.indeterminate = checkedMonths.size > 0 && checkedMonths.size < 12;
}
}
// 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) {
if (cb.dataset.allyear != null) {
if (cb.checked) allMonths().forEach((m) => checkedMonths.add(m));
else checkedMonths.clear();
return true;
}
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);
});
}
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
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
}
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 };
}