Files
maptool/public/js/radar.js
T
randy 8d090b0774 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).
2026-09-09 09:42:10 +02:00

324 lines
11 KiB
JavaScript

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").
const FRAME_COUNT = 7;
const FRAME_INTERVAL_MS = 600;
const OPACITY = 0.7;
const ATTRIBUTION =
'Weather data &copy; <a href="https://www.rainviewer.com" target="_blank">RainViewer</a>';
// A sidebar icon that fetches RainViewer's recent precipitation frames on
// demand and animates them as a raster overlay, with play/pause and a scrub
// slider in a flyout. No API key needed — see docs/architecture.adoc for
// RainViewer's usage terms.
export class RadarControl {
constructor(map) {
this._map = map;
this._frames = [];
this._frameIndex = 0;
this._enabled = false;
this._playing = false;
this._timer = null;
// setStyle() (switching the Online/Offline base map) tears down all
// custom sources and layers — put the radar overlay back afterwards.
map.on("style.load", () => {
if (this._enabled && this._frames.length) this._addLayers();
});
}
mount(sidebar) {
this._container = document.createElement("div");
this._container.className = "sidebar-item";
this._container.innerHTML = `
<button type="button" class="sidebar-icon" title="Radar" aria-label="Radar">${RADAR_ICON}</button>
<div class="sidebar-flyout" hidden>
<div class="radar-control__playback">
<button type="button" class="radar-control__play" aria-label="Play">&#9654;</button>
<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());
this._scrub.addEventListener("input", () => {
this._stopAnimation();
this._showFrame(Number(this._scrub.value));
});
sidebar.appendChild(this._container);
}
async _setEnabled(enabled) {
this._enabled = enabled;
this._icon.classList.toggle("active", enabled);
if (!enabled) {
this._stopAnimation();
this._removeLayers();
this._frames = [];
this._updateFrameUI();
this._renderForecast(null);
return;
}
this._icon.disabled = true;
// 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 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._updateFrameUI();
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() {
const beforeId = firstSymbolLayerId(this._map);
this._frames.forEach((frame, i) => {
const id = layerId(i);
if (this._map.getSource(id)) return;
this._map.addSource(id, {
type: "raster",
tiles: [tileUrl(frame)],
tileSize: 256,
maxzoom: 7,
attribution: ATTRIBUTION,
});
this._map.addLayer(
{
id,
type: "raster",
source: id,
paint: { "raster-opacity": i === this._frameIndex ? OPACITY : 0 },
},
beforeId,
);
});
}
_removeLayers() {
this._frames.forEach((_, i) => {
const id = layerId(i);
if (this._map.getLayer(id)) this._map.removeLayer(id);
if (this._map.getSource(id)) this._map.removeSource(id);
});
}
_showFrame(index) {
this._frameIndex = index;
this._frames.forEach((_, i) => {
const id = layerId(i);
if (this._map.getLayer(id)) {
this._map.setPaintProperty(id, "raster-opacity", i === index ? OPACITY : 0);
}
});
this._updateFrameUI();
}
_startAnimation() {
if (this._frames.length < 2) return;
this._playing = true;
this._playButton.textContent = "⏸";
this._playButton.setAttribute("aria-label", "Pause");
this._timer = setInterval(() => {
this._showFrame((this._frameIndex + 1) % this._frames.length);
}, FRAME_INTERVAL_MS);
}
_stopAnimation() {
this._playing = false;
this._playButton.textContent = "▶";
this._playButton.setAttribute("aria-label", "Play");
if (this._timer) {
clearInterval(this._timer);
this._timer = null;
}
}
_togglePlayback() {
if (this._playing) this._stopAnimation();
else this._startAnimation();
}
_updateFrameUI() {
// 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];
this._time.textContent = frame
? new Date(frame.time * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
: "";
}
}
function layerId(i) {
return `radar-frame-${i}`;
}
function tileUrl(frame) {
// Color scheme 2 (Universal Blue), smoothing on, no snow overlay — see
// docs/architecture.adoc for the full RainViewer tile URL reference.
return `${frame.host}${frame.path}/256/{z}/{x}/{y}/2/1_0.png`;
}
async function fetchFrames() {
const response = await fetch(RAINVIEWER_API);
const data = await response.json();
const host = data.host || "https://tilecache.rainviewer.com";
const past = (data.radar && data.radar.past) || [];
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");
return symbolLayer ? symbolLayer.id : undefined;
}