// 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, toUnit, toWind, toPrecip } from "./units.js"; // ---- 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; // ---- percentile tier scales, low → high ---- // Names are deliberately *relative* to each place's own climate history (a // percentile), not absolute temperature words — see the README design note. export const SCALE_TEMP = [ { c: "rec-cold", label: "Near Record", range: "<1" }, { c: "very-cold", label: "Very Low", range: "1–10" }, { c: "cold", label: "Low", range: "10–25" }, { c: "cool", label: "Below Normal", range: "25–40" }, { c: "normal", label: "Normal", range: "40–60" }, { c: "warm", label: "Above Normal", range: "60–75" }, { c: "hot", label: "High", range: "75–90" }, { c: "very-hot", label: "Very High", range: "90–99" }, { c: "rec-hot", label: "Near Record", range: ">99" }, ]; // Rain-intensity tiers by rain-day percentile (dry days show their dry streak). export const SCALE_RAIN = [ { c: "wet-1", label: "Trace", range: "<1" }, { c: "wet-2", label: "Very Light", range: "1–10" }, { c: "wet-3", label: "Light", range: "10–25" }, { c: "wet-4", label: "Light–Mod", range: "25–40" }, { c: "wet-5", label: "Moderate", range: "40–60" }, { c: "wet-6", label: "Mod–Heavy", range: "60–75" }, { c: "wet-7", label: "Heavy", range: "75–90" }, { c: "wet-8", label: "Very Heavy", range: "90–99" }, { c: "wet-9", label: "Extreme", range: ">99" }, ]; // ---- 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; 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); } // ---- category distribution (shared by the calendar totals + the compare page) ---- // Bucket a set of daily records into the chosen metric's ordered categories // (low→high), each entry [label, color, dayCount, scaleGroup]. Temperature-style // metrics (High/Low/Feels/Humid/Wind/Gust) use the diverging Record-Low→Record-High // tiers; precip uses the rain-intensity tiers plus a Dry bucket; dry-streak uses // run-length bands. The scaleGroup ("wet"/"dry"/"temp") lets distStrip scale and // percent wet vs. dry days apart so a lopsided count on one side can't crush the // other. Categories are percentile classes on the records, so they're unit-agnostic. export function metricBuckets(days, metric) { if (metric === "dsr") { const bands = [ ["Rain day", drynessColor(0), (d) => d === 0], ["1–3 dry", drynessColor(2), (d) => d >= 1 && d <= 3], ["4–6 dry", drynessColor(5), (d) => d >= 4 && d <= 6], ["7–13 dry", drynessColor(10), (d) => d >= 7 && d <= 13], ["14+ dry", drynessColor(14), (d) => d >= 14], ]; const vals = days.map((r) => r.dsr).filter((d) => d != null); // The lone "Rain day" (wet) scales apart from the dry-streak buckets (dry). 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 + min–max range. The value's unit follows the metric. const unit = metric === "humid" ? "humid" : (metric === "wind" || metric === "gust") ? "wind" : "temp"; const recs = days.map((r) => r[metric]).filter(Boolean); // {v, c} return SCALE_TEMP.map((t) => { const g = recs.filter((x) => x.c === t.c); return { label: t.label, color: TIER_COLORS[t.c], n: g.length, group: "temp", unit, values: g.map((x) => x.v) }; }); } // 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); 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)}`; } // 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 ""; } // The low–high 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.7–136.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 // low→high: the category's average sits above its bar with the low–high 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)); // 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 ? `
` : `
`; const range = lo != null ? fmtMetricRange(b.unit, lo, hi) : ""; return `
` + `${avg != null ? fmtMetricBare(b.unit, avg) : ""}` + `${range}` + bar + `${valText(b.n, b.group)}` + `${b.label}
`; }).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 ? `
${unit}
` : ""; return `
${cap}
${cols}
`; } // ---- formatters & tiny utils ---- // 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 }; 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)))); }; export const todayISO = () => new Date().toISOString().slice(0, 10); export const esc = (s) => String(s).replace(/&/g, "&").replace(//g, ">"); // The heading label for a graded response: the resolved place name, or the // cell-center coordinates while (or if) no name resolves. export const placeLabel = (data) => data.place || `${data.cell.center_lat.toFixed(3)}, ${data.cell.center_lon.toFixed(3)}`; // A place label is "neighbourhood, city, region, country" (comma-joined by the // backend). For display we lead with the first segment and mute the rest: // namePrimary → the lead segment; nameParts → { primary, rest } with rest joined by // " · " (e.g. "Seattle · Washington · United States"). Tolerant of 1–4 parts. export const namePrimary = (name) => String(name || "").split(",")[0].trim() || String(name || ""); export function nameParts(name) { const segs = String(name || "").split(",").map((s) => s.trim()).filter(Boolean); return { primary: segs[0] || String(name || ""), rest: segs.slice(1).join(" · ") }; } // ---- 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)); // 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); } return chunks; } // 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) {} }); } } // ---- 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
(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) => ``).join(""); return `
`; }).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 = ``; return `
Seasons ${seasonSummaryText(checkedMonths, opts)}
${allYear}${rows}
`; } // 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); }); } // ---- weather summary ---- // Monochrome line icons (currentColor) — no emoji, matching the site's dark // look. Feather-style paths. const WX = (paths) => ``; const WX_CLOUD = ``; export const WX_ICONS = { sun: WX(``), drizzle: WX(`${WX_CLOUD}`), rain: WX(`${WX_CLOUD}`), storm: WX(``), snow: WX(``), }; // 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; } const text = !temp ? sky : wet ? `${temp}, ${sky}` : `${temp} & ${sky}`; return { icon, text: text.charAt(0).toUpperCase() + text.slice(1), feel: temp.toLowerCase(), sky: skyKey }; }