thermograph/static/shared.js

420 lines
22 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.
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
import { fmtTemp, fmtPrecip, fmtWind, fmtHumid, getUnit } 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-1", label: "Trace", range: "<1" },
{ c: "wet-2", label: "Very Light", range: "110" },
{ c: "wet-3", label: "Light", range: "1025" },
{ c: "wet-4", label: "LightMod", range: "2540" },
{ c: "wet-5", label: "Moderate", range: "4060" },
{ c: "wet-6", label: "ModHeavy", range: "6075" },
{ c: "wet-7", label: "Heavy", range: "7590" },
{ c: "wet-8", label: "Very Heavy", range: "9099" },
{ 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], 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
// ---- 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);
// 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)}`;
}
// Render buckets from metricBuckets() as a connected bar chart, one bar per category
// low→high: the category's average value sits above its bar, its Max/Min values inside
// 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), floored so the in-bar label stays legible. Assumes ≥1 day (callers guard).
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));
const BAR_MIN = 30, BAR_MAX = 74; // px — the floor keeps the two-line Max/Min label readable
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
? `<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>`
: `<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>`;
}).join("");
return `<div class="ct-strip ct-connected">${cols}</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))));
};
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, "&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
// 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 };
}