Cache the weather forecast for offline fallback
Radar flyout now also shows a 3-day Open-Meteo forecast (free, no API key) for the map's current center. Radar frames and the forecast fetch independently — one being unreachable doesn't hide the other. The last successful forecast is cached in localStorage and shown (with a "cached from" note) when the live fetch fails, so there's still something useful once actually offline. Radar tile images themselves aren't cached — that needs a Service Worker, left as a separate follow-up (see TODO.adoc).
This commit is contained in:
1 parent
707e71caf1
commit
8d090b0774
3 files changed
+173
-18
No files matched your search
@@ -193,6 +193,36 @@ html, body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.radar-control__forecast {
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.radar-control__forecast-day {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 2px 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.radar-control__forecast-day span:first-child {
|
||||
width: 2.2em;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.radar-control__forecast-day span:nth-child(3) {
|
||||
margin-left: auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.radar-control__forecast-stale {
|
||||
margin: 4px 0 0;
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.basemap-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
||||
+141
-17
@@ -2,6 +2,34 @@ import { RADAR_ICON } from "/js/icons.js";
|
||||
|
||||
const RAINVIEWER_API = "https://api.rainviewer.com/public/weather-maps.json";
|
||||
|
||||
// Open-Meteo: free, no API key. Only the forecast (small JSON) is cached
|
||||
// here, in localStorage — the radar tile images above aren't: actually
|
||||
// serving those without a live network needs a Service Worker (intercepting
|
||||
// the tile requests and answering from a cache), which is its own separate
|
||||
// piece of infrastructure (registration, cache strategy, invalidation) and
|
||||
// deliberately left for later rather than folded into this. A cached
|
||||
// forecast still gives *something* useful once genuinely offline, which the
|
||||
// radar animation above can't.
|
||||
const FORECAST_API = "https://api.open-meteo.com/v1/forecast";
|
||||
const FORECAST_DAYS = 3;
|
||||
const FORECAST_CACHE_KEY = "maptool:forecast";
|
||||
|
||||
// A small subset of WMO weather codes (open-meteo's `weather_code`) mapped
|
||||
// to one glyph each — not the full table, just enough to tell clear/cloudy/
|
||||
// rain/snow/storm apart at a glance.
|
||||
const WEATHER_EMOJI = [
|
||||
[[0], "☀️"],
|
||||
[[1, 2, 3], "⛅"],
|
||||
[[45, 48], "🌫️"],
|
||||
[[51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82], "🌧️"],
|
||||
[[71, 73, 75, 77, 85, 86], "❄️"],
|
||||
[[95, 96, 99], "⛈️"],
|
||||
];
|
||||
|
||||
function weatherEmoji(code) {
|
||||
return WEATHER_EMOJI.find(([codes]) => codes.includes(code))?.[1] ?? "🌡️";
|
||||
}
|
||||
|
||||
// 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").
|
||||
@@ -42,14 +70,17 @@ export class RadarControl {
|
||||
<input type="range" class="radar-control__scrub" min="0" max="0" step="1" value="0">
|
||||
<span class="radar-control__time"></span>
|
||||
</div>
|
||||
<div class="radar-control__forecast"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this._icon = this._container.querySelector(".sidebar-icon");
|
||||
this._flyout = this._container.querySelector(".sidebar-flyout");
|
||||
this._playback = this._container.querySelector(".radar-control__playback");
|
||||
this._playButton = this._container.querySelector(".radar-control__play");
|
||||
this._scrub = this._container.querySelector(".radar-control__scrub");
|
||||
this._time = this._container.querySelector(".radar-control__time");
|
||||
this._forecastEl = this._container.querySelector(".radar-control__forecast");
|
||||
|
||||
this._icon.addEventListener("click", () => this._setEnabled(!this._enabled));
|
||||
this._playButton.addEventListener("click", () => this._togglePlayback());
|
||||
@@ -69,30 +100,87 @@ export class RadarControl {
|
||||
this._removeLayers();
|
||||
this._frames = [];
|
||||
this._updateFrameUI();
|
||||
this._renderForecast(null);
|
||||
return;
|
||||
}
|
||||
|
||||
this._icon.disabled = true;
|
||||
let frames;
|
||||
try {
|
||||
frames = await fetchFrames();
|
||||
} catch (err) {
|
||||
this._enabled = false;
|
||||
this._icon.classList.remove("active");
|
||||
this._icon.disabled = false;
|
||||
return;
|
||||
}
|
||||
// Independent fetches: RainViewer being unreachable shouldn't hide a
|
||||
// forecast that Open-Meteo still answered, or vice versa — especially
|
||||
// since only the forecast has a cached fallback to fall back to.
|
||||
const [framesResult, forecastResult] = await Promise.allSettled([fetchFrames(), this._loadForecast()]);
|
||||
this._icon.disabled = false;
|
||||
|
||||
// The icon may have been clicked again (disabling) while the fetch was
|
||||
// in flight.
|
||||
if (!this._enabled || !frames.length) return;
|
||||
// The icon may have been clicked again (disabling) while the fetches
|
||||
// were in flight.
|
||||
if (!this._enabled) return;
|
||||
|
||||
const frames = framesResult.status === "fulfilled" ? framesResult.value : [];
|
||||
const forecast = forecastResult.status === "fulfilled" ? forecastResult.value : null;
|
||||
|
||||
// Nothing at all came back (both offline, no cached forecast either) —
|
||||
// same "turn back off" story the radar-only fetch used to have on its
|
||||
// own failure.
|
||||
if (frames.length === 0 && !forecast) {
|
||||
this._enabled = false;
|
||||
this._icon.classList.remove("active");
|
||||
} else if (frames.length > 0) {
|
||||
this._frames = frames;
|
||||
this._frameIndex = frames.length - 1;
|
||||
this._addLayers();
|
||||
this._startAnimation();
|
||||
}
|
||||
|
||||
this._frames = frames;
|
||||
this._frameIndex = frames.length - 1;
|
||||
this._addLayers();
|
||||
this._updateFrameUI();
|
||||
this._startAnimation();
|
||||
this._renderForecast(forecast);
|
||||
}
|
||||
|
||||
// cachedAt is null for a fresh fetch, or the cache's own timestamp when
|
||||
// falling back to it — _renderForecast uses that to show a "cached" note
|
||||
// instead of claiming this is live data.
|
||||
async _loadForecast() {
|
||||
const { lat, lng } = this._map.getCenter();
|
||||
try {
|
||||
const data = await fetchForecast(lat, lng);
|
||||
writeCachedForecast(data);
|
||||
return { data, cachedAt: null };
|
||||
} catch (err) {
|
||||
const cached = readCachedForecast();
|
||||
if (!cached) throw err;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
_renderForecast(result) {
|
||||
if (!result) {
|
||||
this._forecastEl.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, cachedAt } = result;
|
||||
const days = data.daily.time.map((date, i) => ({
|
||||
date,
|
||||
code: data.daily.weather_code[i],
|
||||
max: Math.round(data.daily.temperature_2m_max[i]),
|
||||
min: Math.round(data.daily.temperature_2m_min[i]),
|
||||
precip: data.daily.precipitation_probability_max[i],
|
||||
}));
|
||||
|
||||
const rows = days
|
||||
.map(
|
||||
(day) => `
|
||||
<div class="radar-control__forecast-day">
|
||||
<span>${new Date(day.date).toLocaleDateString([], { weekday: "short" })}</span>
|
||||
<span>${weatherEmoji(day.code)}</span>
|
||||
<span>${day.max}° / ${day.min}°</span>
|
||||
<span>${day.precip}%</span>
|
||||
</div>`,
|
||||
)
|
||||
.join("");
|
||||
const staleNote = cachedAt
|
||||
? `<p class="radar-control__forecast-stale">Cached from ${new Date(cachedAt).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}</p>`
|
||||
: "";
|
||||
this._forecastEl.innerHTML = rows + staleNote;
|
||||
}
|
||||
|
||||
_addLayers() {
|
||||
@@ -164,7 +252,10 @@ export class RadarControl {
|
||||
}
|
||||
|
||||
_updateFrameUI() {
|
||||
this._flyout.hidden = this._frames.length === 0;
|
||||
// Shown whenever there's radar frames, a forecast, or both — either
|
||||
// one on its own is still worth showing the flyout for.
|
||||
this._flyout.hidden = !this._enabled;
|
||||
this._playback.hidden = this._frames.length === 0;
|
||||
this._scrub.max = String(Math.max(0, this._frames.length - 1));
|
||||
this._scrub.value = String(this._frameIndex);
|
||||
const frame = this._frames[this._frameIndex];
|
||||
@@ -192,6 +283,39 @@ async function fetchFrames() {
|
||||
return past.slice(-FRAME_COUNT).map((frame) => ({ ...frame, host }));
|
||||
}
|
||||
|
||||
async function fetchForecast(lat, lon) {
|
||||
const url = new URL(FORECAST_API);
|
||||
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");
|
||||
url.searchParams.set("timezone", "auto");
|
||||
url.searchParams.set("forecast_days", String(FORECAST_DAYS));
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Open-Meteo request failed: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// localStorage, not IndexedDB: this is one small JSON object (a few days of
|
||||
// daily figures), not worth a heavier async storage API for. Wrapped since
|
||||
// private browsing / storage-blocked contexts can throw on access, not just
|
||||
// on being full — a forecast is a nice-to-have, never worth crashing over.
|
||||
function writeCachedForecast(data) {
|
||||
try {
|
||||
localStorage.setItem(FORECAST_CACHE_KEY, JSON.stringify({ data, cachedAt: Date.now() }));
|
||||
} catch (err) {
|
||||
// Ignore — see above.
|
||||
}
|
||||
}
|
||||
|
||||
function readCachedForecast() {
|
||||
try {
|
||||
const raw = localStorage.getItem(FORECAST_CACHE_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function firstSymbolLayerId(map) {
|
||||
const layers = map.getStyle()?.layers || [];
|
||||
const symbolLayer = layers.find((layer) => layer.type === "symbol");
|
||||
|
||||
Reference in new issue
Block a user