diff --git a/public/css/style.css b/public/css/style.css index 1e701d7..484ab2b 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -218,6 +218,29 @@ html, body { border-top: 1px solid #eee; } +.radar-control__forecast-hours { + display: flex; + gap: 10px; + overflow-x: auto; + padding-bottom: 6px; + margin-bottom: 4px; + border-bottom: 1px solid #eee; +} + +.radar-control__forecast-hour { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + flex: 0 0 auto; + font-variant-numeric: tabular-nums; +} + +.radar-control__forecast-hour span:first-child { + color: #888; + font-size: 11px; +} + .radar-control__forecast-day { display: flex; align-items: center; diff --git a/public/js/radar.js b/public/js/radar.js index f141d54..00dd5a9 100644 --- a/public/js/radar.js +++ b/public/js/radar.js @@ -12,6 +12,7 @@ const RAINVIEWER_API = "https://api.rainviewer.com/public/weather-maps.json"; // radar animation above can't. const FORECAST_API = "https://api.open-meteo.com/v1/forecast"; const FORECAST_DAYS = 3; +const HOURLY_FORECAST_COUNT = 8; const FORECAST_CACHE_KEY = "maptool:forecast"; // A small subset of WMO weather codes (open-meteo's `weather_code`) mapped @@ -30,6 +31,21 @@ function weatherEmoji(code) { return WEATHER_EMOJI.find(([codes]) => codes.includes(code))?.[1] ?? "🌡️"; } +// A rolling window from "now" rather than "rest of today" — the latter +// shrinks to nothing right before midnight and balloons to ~24 entries just +// after, for no good reason. +function nextHours(hourly, count) { + const now = new Date(); + return hourly.time + .map((time, i) => ({ + time, + code: hourly.weather_code[i], + temp: Math.round(hourly.temperature_2m[i]), + })) + .filter((hour) => new Date(hour.time) >= now) + .slice(0, count); +} + // Matches the default window used by github.com/jpettitt/weather-radar-card // (60 minutes of history at RainViewer's native 10-minute cadence = 7 // frames, the last one being "now"). @@ -180,7 +196,17 @@ export class RadarControl { precip: data.daily.precipitation_probability_max[i], })); - const rows = days + const hourRows = nextHours(data.hourly, HOURLY_FORECAST_COUNT) + .map( + (hour) => ` +
+ ${new Date(hour.time).toLocaleTimeString([], { hour: "2-digit" })} + ${weatherEmoji(hour.code)} + ${hour.temp}° +
`, + ) + .join(""); + const dayRows = days .map( (day) => `
@@ -194,7 +220,7 @@ export class RadarControl { const staleNote = cachedAt ? `

Cached from ${new Date(cachedAt).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}

` : ""; - this._forecastEl.innerHTML = rows + staleNote; + this._forecastEl.innerHTML = `
${hourRows}
` + dayRows + staleNote; } _addLayers() { @@ -299,6 +325,10 @@ async function fetchForecast(lat, lon) { url.searchParams.set("latitude", lat); url.searchParams.set("longitude", lon); url.searchParams.set("daily", "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max"); + // Same call, same cached JSON (writeCachedForecast below) — the next-few- + // hours strip alongside the multi-day outlook rather than a second + // request/cache entry. + url.searchParams.set("hourly", "weather_code,temperature_2m"); url.searchParams.set("timezone", "auto"); url.searchParams.set("forecast_days", String(FORECAST_DAYS)); const response = await fetch(url);