import { RADAR_CLOUD_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 © RainViewer'; // An Overlays panel item (see overlays.js) that fetches RainViewer's recent // precipitation frames on demand and animates them as a raster overlay, // with play/pause and a scrub slider revealed by expanding the item. 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(); }); } // li: the
  • this item owns (see // overlays.js) — built here rather than in the static markup since this // control is the only thing that knows its own internal structure // (playback/forecast), same as before when it built its own sidebar // flyout. mount(li) { li.innerHTML = `
    ${RADAR_CLOUD_ICON}
    `; this._checkbox = li.querySelector(".overlays-panel__toggle"); this._nameButton = li.querySelector(".overlays-panel__name"); this._details = li.querySelector(".overlays-panel__details"); this._playback = li.querySelector(".radar-control__playback"); this._playButton = li.querySelector(".radar-control__play"); this._scrub = li.querySelector(".radar-control__scrub"); this._time = li.querySelector(".radar-control__time"); this._forecastEl = li.querySelector(".radar-control__forecast"); this._checkbox.addEventListener("change", (event) => this._setEnabled(event.target.checked)); // Separate from the checkbox on purpose — expanding to see the scrub/ // forecast shouldn't also flip the layer on, and turning the layer on // shouldn't force the details open (a viewer who never cares to scrub // history/see the forecast keeps a one-line item either way). this._nameButton.addEventListener("click", () => { const expanding = this._details.hidden; this._details.hidden = !expanding; this._nameButton.setAttribute("aria-expanded", String(expanding)); }); this._playButton.addEventListener("click", () => this._togglePlayback()); this._scrub.addEventListener("input", () => { this._stopAnimation(); this._showFrame(Number(this._scrub.value)); }); } async _setEnabled(enabled) { this._enabled = enabled; if (!enabled) { this._stopAnimation(); this._removeLayers(); this._frames = []; this._updateFrameUI(); this._renderForecast(null); return; } this._checkbox.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._checkbox.disabled = false; // The checkbox may have been unchecked 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._checkbox.checked = false; } 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) => `
    ${new Date(day.date).toLocaleDateString([], { weekday: "short" })} ${weatherEmoji(day.code)} ${day.max}° / ${day.min}° ${day.precip}%
    `, ) .join(""); const staleNote = cachedAt ? `

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

    ` : ""; 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() { 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; }