thermograph/static/shared.js
Emi Griffith f3ba056738 Compare/calendar mobile polish: overflow fix, sheet load button, 6-yr default, country in names (#80)
- Fix the mobile width blow-out: the distribution grid item (.cmp-dist-row) had no
  min-width:0, so each row expanded to the 9–10-column connected bar chart's
  min-content and widened the whole page (zoom-out, clipped filter sheet). Add
  grid-template-columns: minmax(0,1fr) + min-width:0 so the strip scrolls inside its
  own .ct-strip, and tighten .ct-col to 34px on phones.
- Add a Load/Refresh button inside the compare filter sheet's date-range block, wired
  to the same refresh()/isDirty(); editing the range on mobile no longer needs the
  sheet closed. The existing button stays in the controls (loads added places).
- Default date range on both pages is now January six years back → the present. On
  the calendar this is an explicit chunked range (the months=24 path is server-capped
  at ~2yr).
- Names now include country: reverse_geocode appends it to the place label; revgeo
  cache key gets a v2 prefix and PAYLOAD_VER bumps to p2 so labels/payloads
  repopulate.
- Location names read as a hierarchy: compact chips show just the lead segment (full
  name in the title/aria), and each ranked card shows the lead segment bold over a
  muted "city · region · country" line.

Frontend + a small backend name/cache change; no schema migration.
2026-07-12 06:32:10 +00:00

394 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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, toUnit } 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: "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" },
];
// ---- 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);
}
// ---- 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) };
});
}
// Format a single metric value / a minmax range in the metric's own unit — temps
// follow the active °C/°F unit; humidity, wind, precip and dry-streak are fixed-unit.
function fmtMetricVal(unit, v) {
if (v == null || isNaN(v)) return "";
if (unit === "temp") return fmtTemp(v);
if (unit === "humid") return `${Math.round(v)} g/m³`;
if (unit === "wind") return `${Math.round(v)} mph`;
if (unit === "precip") return `${v.toFixed(2)}`;
if (unit === "dsr") return `${Math.round(v)}d`;
return `${Math.round(v)}`;
}
function fmtMetricRange(unit, lo, hi) {
if (lo == null) return "";
if (lo === hi) return fmtMetricVal(unit, lo);
if (unit === "temp") return `${Math.round(toUnit(lo))}${Math.round(toUnit(hi))}°`;
if (unit === "humid") return `${Math.round(lo)}${Math.round(hi)}`;
if (unit === "wind") return `${Math.round(lo)}${Math.round(hi)} mph`;
if (unit === "precip") return `${lo.toFixed(2)}${hi.toFixed(2)}`;
if (unit === "dsr") return `${Math.round(lo)}${Math.round(hi)}d`;
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 value sits above its bar, the minmax range 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 range 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 = 26, BAR_MAX = 74; // px — the floor keeps the in-bar range 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">${fmtMetricRange(b.unit, lo, hi)}</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>`;
}
// ---- formatters & tiny utils ----
// (Temperatures go through nav.js's unit-aware fmtTemp; these are unit-agnostic.)
export const fmtPrecip = (v) => (v == null ? "—" : `${v.toFixed(2)}"`);
export const fmtWind = (v) => (v == null ? "—" : `${Math.round(v)} mph`);
export const fmtHumid = (v) => (v == null ? "—" : `${v.toFixed(1)} g/m³`);
export const ord = (n) => {
const r = Math.round(n), s = ["th", "st", "nd", "rd"], v = r % 100;
return r + (s[(v - 20) % 10] || s[v] || s[0]);
};
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)}`;
// 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(" · ") };
}
// ---- 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 <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("");
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">${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;
});
}
// 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.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) =>
`<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"/>`),
};
// 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 };
}