const RAINVIEWER_API = "https://api.rainviewer.com/public/weather-maps.json"; // 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'; // A MapLibre IControl: a "Radar" checkbox that fetches RainViewer's recent // precipitation frames on demand and animates them as a raster overlay, with // play/pause and a scrub slider. 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(); }); } onAdd() { this._container = document.createElement("div"); this._container.className = "maplibregl-ctrl maplibregl-ctrl-group radar-control"; this._container.innerHTML = ` `; this._checkbox = this._container.querySelector(".radar-control__checkbox"); 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._checkbox.addEventListener("change", () => this._setEnabled(this._checkbox.checked)); this._playButton.addEventListener("click", () => this._togglePlayback()); this._scrub.addEventListener("input", () => { this._stopAnimation(); this._showFrame(Number(this._scrub.value)); }); return this._container; } onRemove() { this._stopAnimation(); this._removeLayers(); this._container.remove(); } async _setEnabled(enabled) { this._enabled = enabled; if (!enabled) { this._stopAnimation(); this._removeLayers(); this._frames = []; this._updateFrameUI(); return; } this._checkbox.disabled = true; let frames; try { frames = await fetchFrames(); } catch (err) { this._enabled = false; this._checkbox.checked = false; this._checkbox.disabled = false; return; } this._checkbox.disabled = false; // The checkbox may have been switched off again while the fetch was in // flight. if (!this._enabled || !frames.length) return; this._frames = frames; this._frameIndex = frames.length - 1; this._addLayers(); this._updateFrameUI(); this._startAnimation(); } _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 })); } function firstSymbolLayerId(map) { const layers = map.getStyle()?.layers || []; const symbolLayer = layers.find((layer) => layer.type === "symbol"); return symbolLayer ? symbolLayer.id : undefined; }