screentinker/Examples/PIP-Weather-Radar/radar-overlay.js
screentinker 89cbcac2cd
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Add PIP-Weather-Radar example (TV-style live radar overlay) (#133)
* Add PIP-Weather-Radar example (TV-style live radar overlay)

A "cut to radar" PiP recipe: a Leaflet map (vendored locally for the
CSP) with a CARTO dark basemap, an animated RainViewer radar loop, and
live NWS warning polygons drawn and color-coded (tornado/severe-tstorm/
flash-flood/flood) with a pulsing "LIVE RADAR" HUD, count chips, and a
legend. Auto-frames the view to the active warning polygon(s).

Two modes: "always" (radar always up) and "on_warning" (default) which
shows the radar only while a qualifying warning covers the configured
point and clears it when the warnings expire — like a station breaking
in during severe weather.

100% keyless / open data: RainViewer radar, CARTO/OSM basemap, NWS
alerts. Zero Node deps; Leaflet is vendored client-side via
vendor-leaflet.sh (gitignored). Offline test covers the warning gate,
color map, RainViewer tile-URL builder, and overlay-URI round-trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(radar): note Leaflet is vendored locally, not committed

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:05:19 -05:00

172 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Live weather radar overlay — runs in the player's iframe (same-origin, external per CSP).
CARTO dark basemap + animated RainViewer radar + live NWS warning polygons.
All inputs come from the URL query string; all network is via https (CSP allows it). */
(function () {
'use strict';
var q = new URLSearchParams(location.search);
var lat = parseFloat(q.get('lat')); if (!isFinite(lat)) lat = 39.5;
var lon = parseFloat(q.get('lon')); if (!isFinite(lon)) lon = -98.35;
var zoom = parseInt(q.get('zoom'), 10); if (!isFinite(zoom)) zoom = 8;
var area = (q.get('area') || '').trim();
var states = (q.get('states') || '').split(',').map(function (s) { return s.trim().toUpperCase(); }).filter(Boolean);
var DEFAULT_EVENTS = ['Tornado Warning', 'Severe Thunderstorm Warning', 'Flash Flood Warning', 'Flood Warning'];
var events = (q.get('events') || '').split(',').map(function (s) { return s.trim(); }).filter(Boolean);
if (!events.length) events = DEFAULT_EVENTS.slice();
var EVENT_COLORS = {
'Tornado Warning': '#FF2D2D',
'Severe Thunderstorm Warning': '#FFD12E',
'Flash Flood Warning': '#25D0C0',
'Flood Warning': '#46C766',
};
var DEFAULT_COLOR = '#FF8A1F';
function colorFor(ev) { return EVENT_COLORS[ev] || DEFAULT_COLOR; }
document.getElementById('area').textContent = area;
var map = L.map('map', { zoomControl: false, attributionControl: true, fadeAnimation: false }).setView([lat, lon], zoom);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', {
subdomains: 'abcd', maxZoom: 19,
attribution: '&copy; OpenStreetMap &copy; CARTO · Radar: RainViewer · Alerts: NWS/NOAA',
}).addTo(map);
// ---- animated radar (RainViewer) --------------------------------------------------
var frames = []; // [{time, path}]
var frameLayers = {}; // index -> L.tileLayer (lazy)
var cur = -1;
var animTimer = null;
var clockEl = document.getElementById('clock');
function frameUrl(host, path) {
return host + path + '/256/{z}/{x}/{y}/4/1_1.png';
}
function showFrame(host, i) {
if (!frames.length) return;
if (!frameLayers[i]) {
// RainViewer radar data tops out at native zoom 7; upscale beyond that
// instead of requesting unavailable ("zoom level not supported") tiles.
frameLayers[i] = L.tileLayer(frameUrl(host, frames[i].path), { opacity: 0, zIndex: 200, maxNativeZoom: 7, maxZoom: 19 }).addTo(map);
}
var next = frameLayers[i];
next.setOpacity(0.78);
if (cur !== -1 && cur !== i && frameLayers[cur]) frameLayers[cur].setOpacity(0);
cur = i;
var d = new Date(frames[i].time * 1000);
clockEl.textContent = 'Radar ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function animate(host) {
if (animTimer) clearInterval(animTimer);
var i = frames.length - 1;
showFrame(host, i);
animTimer = setInterval(function () {
i = (i + 1) % frames.length;
showFrame(host, i);
}, 650);
}
function loadRadar() {
fetch('https://api.rainviewer.com/public/weather-maps.json')
.then(function (r) { return r.json(); })
.then(function (d) {
var host = d.host;
var past = (d.radar && d.radar.past) || [];
if (!past.length) return;
// drop stale layers if the frame set changed
Object.keys(frameLayers).forEach(function (k) { map.removeLayer(frameLayers[k]); });
frameLayers = {}; cur = -1;
frames = past;
animate(host);
})
.catch(function (e) { /* keep the basemap; try again next cycle */ if (window.console) console.warn('radar load failed', e && e.message); });
}
// ---- live NWS warning polygons ----------------------------------------------------
var warnLayer = null;
var chipsEl = document.getElementById('chips');
function shortHeadline(h) { h = h || ''; return h.length > 90 ? h.slice(0, 87) + '…' : h; }
function renderChips(counts) {
chipsEl.innerHTML = '';
var any = false;
events.forEach(function (ev) {
var n = counts[ev] || 0;
if (!n) return;
any = true;
var c = document.createElement('span');
c.className = 'chip';
c.style.background = colorFor(ev);
c.textContent = n + '× ' + ev;
chipsEl.appendChild(c);
});
if (!any) {
var none = document.createElement('span');
none.className = 'chip none';
none.textContent = 'No active warnings in view';
chipsEl.appendChild(none);
}
}
function alertUrls() {
if (states.length) return states.map(function (s) { return 'https://api.weather.gov/alerts/active?area=' + encodeURIComponent(s); });
return ['https://api.weather.gov/alerts/active?point=' + encodeURIComponent(lat.toFixed(4) + ',' + lon.toFixed(4))];
}
function loadWarnings() {
Promise.allSettled(alertUrls().map(function (u) {
return fetch(u, { headers: { Accept: 'application/geo+json' } }).then(function (r) { return r.json(); });
})).then(function (results) {
var seen = {}, feats = [], counts = {};
results.forEach(function (res) {
if (res.status !== 'fulfilled' || !res.value || !res.value.features) return;
res.value.features.forEach(function (f) {
var p = f.properties || {}, g = f.geometry;
if (!g || (g.type !== 'Polygon' && g.type !== 'MultiPolygon')) return;
if (events.indexOf(p.event) === -1) return;
var id = p.id || (f.id || JSON.stringify(g).slice(0, 40));
if (seen[id]) return; seen[id] = 1;
feats.push(f);
counts[p.event] = (counts[p.event] || 0) + 1;
});
});
if (warnLayer) { map.removeLayer(warnLayer); warnLayer = null; }
if (feats.length) {
warnLayer = L.geoJSON({ type: 'FeatureCollection', features: feats }, {
style: function (f) {
var ev = (f.properties || {}).event;
return { color: colorFor(ev), weight: 3, opacity: 0.95, fillColor: colorFor(ev), fillOpacity: 0.12 };
},
onEachFeature: function (f, layer) {
var p = f.properties || {};
layer.bindTooltip('<b>' + (p.event || 'Warning') + '</b><br>' + shortHeadline(p.headline), { sticky: true });
},
}).addTo(map);
// TV-style auto-framing: fit the view to the warning polygon(s) so the boxes
// fill the frame. Only re-fit when the warning set changes (so the 60s refresh
// doesn't jitter the view); cap zoom so a single small box stays readable.
var fitKey = feats.map(function (f) { return (f.properties || {}).id; }).sort().join('|');
if (fitKey !== loadWarnings._fitKey) {
loadWarnings._fitKey = fitKey;
try { map.fitBounds(warnLayer.getBounds(), { padding: [70, 70], maxZoom: 9 }); } catch (e) {}
}
} else {
loadWarnings._fitKey = null;
}
renderChips(counts);
}).catch(function (e) { if (window.console) console.warn('warnings load failed', e && e.message); });
}
// ---- go ---------------------------------------------------------------------------
loadRadar();
loadWarnings();
setInterval(loadRadar, 4 * 60 * 1000);
setInterval(loadWarnings, 60 * 1000);
// legend
(function () {
var el = document.getElementById('legend');
el.innerHTML = events.map(function (ev) {
return '<div class="row"><span class="sw" style="background:' + colorFor(ev) + '"></span>' + ev + '</div>';
}).join('');
})();
})();