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>
This commit is contained in:
ScreenTinker 2026-06-18 20:58:52 -05:00
parent 0b138f10c6
commit 44a0fff0ed
9 changed files with 648 additions and 0 deletions

5
Examples/PIP-Weather-Radar/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
config.json
node_modules/
package-lock.json
leaflet.js
leaflet.css

View file

@ -0,0 +1,114 @@
# PIP-Weather-Radar
A TV-news-style **live weather radar** PiP overlay for ScreenTinker — a dark county map
with **animated precipitation radar** and **live NWS warning polygons** drawn on top
(tornado = red, severe thunderstorm = yellow, flash flood = teal, flood = green), exactly
like a local station's radar.
Its headline trick is **`mode: "on_warning"`**: it watches the National Weather Service
and only **"cuts to radar"** when a qualifying warning actually covers your area — then it
**clears itself** when the warnings expire or drop. (Or run `mode: "always"` to keep the
radar up permanently, e.g. for an ops/EOC wall.)
```
radar.js (Node) radar-overlay.html (player iframe)
────────────── ─────────────────────────────────
poll NWS for warnings ── show/clear ─▶ CARTO dark basemap
at your point + animated RainViewer radar loop
(mode on_warning) + live NWS warning polygons + HUD
```
Everything is **keyless** and has **zero Node dependencies**. Map rendering uses
[Leaflet](https://leafletjs.com/) (MIT), vendored locally.
## Data sources & attribution
The overlay shows attribution on-map; please keep it. Sources:
- **Basemap:** © OpenStreetMap contributors, © CARTO
- **Radar:** [RainViewer](https://www.rainviewer.com/) public weather-maps API
- **Warnings/alerts:** US National Weather Service / NOAA (`api.weather.gov`)
> ⚠️ **Disclaimer:** this is an informational visualization, **not** an official warning
> system. Radar and alert data can be delayed or incomplete. Do not rely on it for
> life-safety decisions — follow official NWS alerts and local emergency guidance.
## Why it works (CSP)
The overlay is served from your signage server, whose CSP is `script-src 'self'` — so the
map library is **vendored** (loaded same-origin), not from a CDN. The same CSP allows
`img-src https:` and `connect-src https:`, so the overlay can pull tiles and `fetch()` the
radar + alert JSON directly (both send `Access-Control-Allow-Origin: *`). No server change
needed.
## Files
| File | Purpose |
|------|---------|
| `radar.js` | Poller/pusher: decides when to show/clear the radar PiP; exports pure helpers |
| `radar-overlay.html` / `radar-overlay.js` | The map overlay (served same-origin, external JS per CSP) |
| `vendor-leaflet.sh` | Downloads `leaflet.js` + `leaflet.css` into this dir |
| `config.example.json` | Copy to `config.json` and fill in |
| `test.js` | Offline unit test (`npm test`) |
## Setup
1. **Vendor Leaflet:**
```bash
./vendor-leaflet.sh
```
2. **Copy the overlay + Leaflet into your signage server's frontend dir** (so they're
served same-origin as the player):
```bash
cp radar-overlay.html radar-overlay.js leaflet.js leaflet.css /path/to/screentinker/frontend/
```
3. **Configure:**
```bash
cp config.example.json config.json
# edit: api_base, api_token (st_ token with 'full' scope), overlay_base_url
# (https://<server>/radar-overlay.html), device_id, and your area:
# area_label, lat, lon, zoom, states (for the alert query), events
```
4. **Run:**
```bash
npm start # or: node radar.js
```
### Local quick-start (self-signed dev server)
```bash
./vendor-leaflet.sh
cp radar-overlay.html radar-overlay.js leaflet.js leaflet.css ../../frontend/
cp config.example.json config.json
# set in config.json:
# api_base="https://localhost:3443/"
# api_token="<your st_ full-scope token>"
# overlay_base_url="https://localhost:3443/radar-overlay.html"
# device_id="<your device or group id>"
NODE_TLS_REJECT_UNAUTHORIZED=0 node radar.js
```
## Config
| Key | Default | Notes |
|-----|---------|-------|
| `mode` | `"on_warning"` | `"on_warning"` = show only during qualifying warnings; `"always"` = always on |
| `lat`, `lon` | — | Map center **and** the NWS `?point=` used to detect warnings |
| `zoom` | `8` | Leaflet zoom; ~8 ≈ a county/metro |
| `area_label` | — | Shown in the overlay header |
| `states` | `[]` | 2-letter codes used to fetch warning polygons (`?area=ST`). Empty → `?point=` |
| `events` | Tornado/Severe Tstorm/Flash Flood/Flood Warning | Which warnings qualify & are drawn |
| `poll_interval_sec` | `60` | How often `radar.js` checks NWS |
| `position`/`width`/`height`/`border_radius` | center / 1100×720 / 12 | PiP box |
| `noaa_user_agent` | — | NWS asks for a contact in the User-Agent |
> The **overlay** fetches warnings by `states` (so the polygons stay visible across the
> map), while **`radar.js`** decides show/clear from the `?point=` at your `lat`/`lon`.
> Set `lat`/`lon` inside the area you care about and list its `states`.
## Test
```bash
npm test # RESULT: PASS ✅
```
Covers the warning gate (event/expiry/geometry), the color map, the RainViewer tile-URL
builder, and the overlay-URI round-trip. No network.

View file

@ -0,0 +1,23 @@
{
"mode": "on_warning",
"api_base": "https://signage.example.com",
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
"overlay_base_url": "https://signage.example.com/radar-overlay.html",
"device_id": "DEVICE_OR_GROUP_ID",
"area_label": "Milwaukee County, WI",
"lat": 43.0389,
"lon": -87.9065,
"zoom": 8,
"states": ["WI"],
"events": ["Tornado Warning", "Severe Thunderstorm Warning", "Flash Flood Warning", "Flood Warning"],
"poll_interval_sec": 60,
"position": "bottom-right",
"width": 760,
"height": 540,
"border_radius": 12,
"noaa_user_agent": "ScreenTinker-Weather-Radar (you@example.com)"
}

View file

@ -0,0 +1,12 @@
{
"name": "pip-weather-radar",
"version": "0.1.0",
"description": "Example: a TV-news-style live weather radar PiP overlay (Leaflet + RainViewer + NWS warning polygons) that can cut to radar during severe weather.",
"type": "commonjs",
"main": "radar.js",
"scripts": {
"start": "node radar.js",
"test": "node test.js"
},
"engines": { "node": ">=18" }
}

View file

@ -0,0 +1,56 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Live Weather Radar</title>
<!-- Vendored Leaflet, served same-origin so the server CSP (script-src 'self') allows it. -->
<link rel="stylesheet" href="/leaflet.css">
<style>
html, body { margin: 0; height: 100%; background: #0b0d10; overflow: hidden; }
#map { position: absolute; inset: 0; background: #0b0d10; }
/* darker, calmer Leaflet attribution to match the TV look */
.leaflet-control-attribution { background: rgba(10,12,16,.6) !important; color: #9aa3ad !important; font-size: 10px; }
.leaflet-control-attribution a { color: #c9d2db !important; }
.hud { position: absolute; left: 0; right: 0; top: 0; z-index: 1000; pointer-events: none;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: #fff; }
.bar { display: flex; align-items: center; gap: 14px; padding: 12px 18px;
background: linear-gradient(180deg, rgba(8,10,13,.92), rgba(8,10,13,0)); }
.live { display: flex; align-items: center; gap: 9px; font-weight: 800; letter-spacing: .06em;
text-transform: uppercase; font-size: clamp(15px, 2.4vw, 24px); }
.dot { width: 13px; height: 13px; border-radius: 50%; background: #FF2D2D;
box-shadow: 0 0 0 0 rgba(255,45,45,.7); animation: pulse 1.2s ease-out infinite; }
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(255,45,45,.7) } 70% { box-shadow: 0 0 0 12px rgba(255,45,45,0) } 100% { box-shadow: 0 0 0 0 rgba(255,45,45,0) } }
.area { font-weight: 600; font-size: clamp(13px, 2vw, 20px); opacity: .92; }
.spacer { flex: 1; }
.clock { font-variant-numeric: tabular-nums; font-weight: 600; font-size: clamp(12px, 1.8vw, 18px);
color: #cfe8ff; opacity: .9; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 18px 10px; }
.chip { pointer-events: none; font-size: clamp(11px, 1.6vw, 15px); font-weight: 700; color: #0b0d10;
padding: 4px 10px; border-radius: 999px; box-shadow: 0 2px 8px rgba(0,0,0,.4); }
.chip.none { background: #2c3340; color: #aeb6c0; font-weight: 600; }
.legend { position: absolute; right: 12px; bottom: 26px; z-index: 1000; pointer-events: none;
background: rgba(10,12,16,.72); border-radius: 10px; padding: 8px 10px; font-family: system-ui, sans-serif;
color: #dfe6ee; font-size: 11px; line-height: 1.5; }
.legend .row { display: flex; align-items: center; gap: 7px; }
.legend .sw { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }
</style>
</head>
<body>
<div id="map"></div>
<div class="hud">
<div class="bar">
<span class="live"><span class="dot"></span>Live Radar</span>
<span class="area" id="area"></span>
<span class="spacer"></span>
<span class="clock" id="clock"></span>
</div>
<div class="chips" id="chips"></div>
</div>
<div class="legend" id="legend"></div>
<script src="/leaflet.js"></script>
<script src="/radar-overlay.js"></script>
</body>
</html>

View file

@ -0,0 +1,171 @@
/* 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('');
})();
})();

View file

@ -0,0 +1,206 @@
'use strict';
// TV-news-style live weather radar PiP overlay.
//
// node radar.js [path/to/config.json]
//
// Two modes (config.mode):
// "always" - keep the radar overlay on screen permanently.
// "on_warning" - (default) poll the NWS and only "cut to radar" when a qualifying
// warning (Tornado / Severe Thunderstorm / Flash Flood / Flood, by
// default) covers the configured point; clear it when none remain.
//
// The overlay page (radar-overlay.html) does the actual map drawing in the player's
// browser: CARTO basemap + animated RainViewer radar + live NWS warning polygons. This
// script just decides WHEN to show it and pushes/clears the PiP. Node 18+ (global fetch),
// needs an st_ API token with the 'full' scope.
const fs = require('fs');
const path = require('path');
// ---- pure, offline-testable helpers -------------------------------------------------
const EVENT_COLORS = {
'Tornado Warning': '#FF2D2D',
'Severe Thunderstorm Warning': '#FFD12E',
'Flash Flood Warning': '#25D0C0',
'Flood Warning': '#46C766',
};
const DEFAULT_COLOR = '#FF8A1F';
function colorForEvent(event) { return EVENT_COLORS[event] || DEFAULT_COLOR; }
// Normalise a NWS GeoJSON FeatureCollection into the minimal shape we gate on.
function normaliseFeatureCollection(json) {
const obj = typeof json === 'string' ? JSON.parse(json) : json;
const feats = (obj && Array.isArray(obj.features)) ? obj.features : [];
return feats.map((f) => {
const p = (f && f.properties) || {};
const g = (f && f.geometry) || null;
return {
identifier: p.id || (f && f.id) || null,
event: p.event || null,
severity: p.severity || 'Unknown',
expires: p.expires || p.ends || null,
headline: p.headline || p.event || '',
hasGeometry: !!(g && (g.type === 'Polygon' || g.type === 'MultiPolygon')),
};
});
}
function isExpired(expires, now) {
if (!expires) return false;
const t = Date.parse(expires);
return Number.isFinite(t) && t <= now;
}
// Show-worthy if it's one of the configured warning events, still active, and has a
// polygon we can actually draw on the map.
function qualifies(alert, opts = {}) {
const events = opts.events || Object.keys(EVENT_COLORS);
const now = opts.now || Date.now();
if (!alert || !alert.event) return false;
if (!events.includes(alert.event)) return false;
if (!alert.hasGeometry) return false;
if (isExpired(alert.expires, now)) return false;
return true;
}
// Build the overlay iframe URL with the area/config encoded in the query string.
function buildOverlayUri(base, o = {}) {
const q = new URLSearchParams();
if (o.lat != null) q.set('lat', String(o.lat));
if (o.lon != null) q.set('lon', String(o.lon));
if (o.zoom != null) q.set('zoom', String(o.zoom));
if (o.area) q.set('area', o.area);
if (Array.isArray(o.states) && o.states.length) q.set('states', o.states.join(','));
if (Array.isArray(o.events) && o.events.length) q.set('events', o.events.join(','));
const sep = base.includes('?') ? '&' : '?';
return `${base}${sep}${q.toString()}`;
}
// RainViewer tile URL for one radar frame. size/color/smooth/snow per their public API.
function frameTileUrl(host, framePath, z, x, y, opt = {}) {
const size = opt.size || 256, color = opt.color != null ? opt.color : 4;
const smooth = opt.smooth != null ? opt.smooth : 1, snow = opt.snow != null ? opt.snow : 1;
return `${host}${framePath}/${size}/${z}/${x}/${y}/${color}/${smooth}_${snow}.png`;
}
module.exports = {
EVENT_COLORS, DEFAULT_COLOR, colorForEvent,
normaliseFeatureCollection, isExpired, qualifies, buildOverlayUri, frameTileUrl,
};
// ---- live monitor (only when run directly) ------------------------------------------
if (require.main === module) {
const configPath = process.argv[2] || path.join(__dirname, 'config.json');
let cfg;
try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
catch (e) { console.error(`Could not read config at ${configPath}: ${e.message}`); process.exit(1); }
const MODE = (cfg.mode || 'on_warning').toLowerCase();
const POLL_SEC = cfg.poll_interval_sec || 60;
const API_BASE = (cfg.api_base || '').replace(/\/$/, '');
const API_TOKEN = cfg.api_token;
const OVERLAY_BASE = cfg.overlay_base_url;
const DEVICE = cfg.device_id;
const EVENTS = cfg.events || Object.keys(EVENT_COLORS);
const UA = cfg.noaa_user_agent || 'ScreenTinker-Weather-Radar (set contact in config)';
if (!API_BASE || !API_TOKEN || !OVERLAY_BASE || !DEVICE) {
console.error('config must set api_base, api_token, overlay_base_url, and device_id.');
process.exit(1);
}
const overlayUri = buildOverlayUri(OVERLAY_BASE, {
lat: cfg.lat, lon: cfg.lon, zoom: cfg.zoom || 8, area: cfg.area_label, states: cfg.states, events: EVENTS,
});
let active = null; // { pip_id }
async function pipShow() {
const body = {
device_id: DEVICE, type: 'web', uri: overlayUri,
position: cfg.position || 'center',
width: cfg.width || 1100, height: cfg.height || 720,
duration: 0, border_radius: cfg.border_radius != null ? cfg.border_radius : 12,
title: cfg.area_label ? `Live Radar — ${cfg.area_label}` : 'Live Radar',
};
const res = await fetch(`${API_BASE}/api/pip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok || !json.pip_id) throw new Error(`pip show failed (${res.status}): ${json.error || 'unknown'}`);
return json.pip_id;
}
async function pipClear() {
if (!active) return;
const res = await fetch(`${API_BASE}/api/pip/clear`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
body: JSON.stringify({ device_id: DEVICE, pip_id: active.pip_id }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(`pip clear failed (${res.status}): ${json.error || 'unknown'}`);
}
active = null;
}
async function show(reason) {
if (active) return;
const pip_id = await pipShow();
active = { pip_id };
console.log(`[${new Date().toISOString()}] SHOW radar on ${DEVICE} pip=${pip_id}${reason}`);
}
async function clear(reason) {
if (!active) return;
const id = active.pip_id;
await pipClear();
console.log(`[${new Date().toISOString()}] CLEAR radar pip=${id}${reason}`);
}
async function fetchActive(now) {
// Geofenced by NWS at the point; we still re-check qualifies() for event/expiry/geometry.
const p = `${Number(cfg.lat).toFixed(4)},${Number(cfg.lon).toFixed(4)}`;
const url = `https://api.weather.gov/alerts/active?point=${encodeURIComponent(p)}`;
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/geo+json' } });
if (!res.ok) throw new Error(`NWS HTTP ${res.status}`);
const alerts = normaliseFeatureCollection(await res.text());
return alerts.filter((a) => qualifies(a, { events: EVENTS, now }));
}
async function tick() {
const now = Date.now();
if (MODE === 'always') { await show('mode=always'); return; }
let hits;
try { hits = await fetchActive(now); }
catch (e) { console.error(`[${new Date().toISOString()}] NWS fetch error: ${e.message}`); return; }
if (hits.length) {
const top = hits.slice().sort((a, b) => EVENTS.indexOf(a.event) - EVENTS.indexOf(b.event))[0];
await show(`${hits.length} warning(s): ${top.event}${top.headline}`).catch((e) =>
console.error(`show error: ${e.message}`));
} else {
await clear('no qualifying warnings').catch((e) => console.error(`clear error: ${e.message}`));
}
}
async function main() {
console.log(`Weather-radar PiP monitor — mode=${MODE}, poll ${POLL_SEC}s`);
console.log(` area: ${cfg.area_label || `${cfg.lat},${cfg.lon}`} events: ${EVENTS.join(', ')}`);
console.log(` overlay: ${overlayUri}`);
await tick();
const timer = setInterval(tick, POLL_SEC * 1000);
async function shutdown() {
clearInterval(timer);
try { await clear('shutting down'); } catch { /* best effort */ }
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
main();
}

View file

@ -0,0 +1,49 @@
'use strict';
const r = require('./radar');
let pass = true;
const checks = [];
function ok(name, cond) { checks.push([name, !!cond]); if (!cond) pass = false; }
// fixture: NWS-style FeatureCollection
const now = Date.parse('2026-06-18T22:00:00Z');
const fc = {
type: 'FeatureCollection',
features: [
{ id: 'A', properties: { id: 'A', event: 'Tornado Warning', severity: 'Extreme', expires: '2026-06-18T22:30:00Z', headline: 'TOR until 5:30' }, geometry: { type: 'Polygon', coordinates: [[[0, 0]]] } },
{ id: 'B', properties: { id: 'B', event: 'Flood Warning', severity: 'Severe', expires: '2026-06-18T21:00:00Z', headline: 'expired' }, geometry: { type: 'Polygon', coordinates: [[[0, 0]]] } },
{ id: 'C', properties: { id: 'C', event: 'Heat Advisory', severity: 'Moderate', expires: '2026-06-19T00:00:00Z', headline: 'not a warning' }, geometry: { type: 'Polygon', coordinates: [[[0, 0]]] } },
{ id: 'D', properties: { id: 'D', event: 'Severe Thunderstorm Warning', severity: 'Severe', expires: '2026-06-18T22:45:00Z', headline: 'SVR' }, geometry: null },
],
};
const alerts = r.normaliseFeatureCollection(fc);
const byId = Object.fromEntries(alerts.map((a) => [a.identifier, a]));
ok('normalise parses 4', alerts.length === 4);
ok('normalise reads geometry flag', byId.A.hasGeometry === true && byId.D.hasGeometry === false);
const EV = ['Tornado Warning', 'Severe Thunderstorm Warning', 'Flash Flood Warning', 'Flood Warning'];
ok('qualifies: active tornado w/ polygon', r.qualifies(byId.A, { events: EV, now }) === true);
ok('qualifies: expired excluded', r.qualifies(byId.B, { events: EV, now }) === false);
ok('qualifies: non-listed event excluded', r.qualifies(byId.C, { events: EV, now }) === false);
ok('qualifies: missing geometry excluded', r.qualifies(byId.D, { events: EV, now }) === false);
ok('color: tornado red', r.colorForEvent('Tornado Warning') === '#FF2D2D');
ok('color: svr yellow', r.colorForEvent('Severe Thunderstorm Warning') === '#FFD12E');
ok('color: unknown -> default', r.colorForEvent('Dust Storm Warning') === r.DEFAULT_COLOR);
const url = r.frameTileUrl('https://tilecache.rainviewer.com', '/v2/radar/abc', 5, 8, 12);
ok('rainviewer tile url', url === 'https://tilecache.rainviewer.com/v2/radar/abc/256/5/8/12/4/1_1.png');
const uri = r.buildOverlayUri('https://s/radar-overlay.html', {
lat: 43.0389, lon: -87.9065, zoom: 8, area: 'Milwaukee County, WI', states: ['WI'], events: EV,
});
const back = new URLSearchParams(uri.split('?')[1]);
ok('overlay uri: lat/lon round-trip', back.get('lat') === '43.0389' && back.get('lon') === '-87.9065');
ok('overlay uri: area round-trip', back.get('area') === 'Milwaukee County, WI');
ok('overlay uri: states/events joined', back.get('states') === 'WI' && back.get('events') === EV.join(','));
console.log(`Weather-Radar checks (${checks.filter((c) => c[1]).length}/${checks.length}):`);
for (const [name, good] of checks) console.log(` ${good ? '✓' : '✗'} ${name}`);
console.log('\nRESULT:', pass ? 'PASS ✅' : 'FAIL ❌');
process.exit(pass ? 0 : 1);

View file

@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Download Leaflet (MIT) into this directory so it can be served same-origin from the
# signage server (the server CSP is script-src 'self', so a CDN won't load).
set -eu
VER=1.9.4
base="https://unpkg.com/leaflet@${VER}/dist"
cd "$(dirname "$0")"
echo "fetching Leaflet ${VER}..."
curl -fsSL "${base}/leaflet.js" -o leaflet.js
curl -fsSL "${base}/leaflet.css" -o leaflet.css
echo "ok: $(wc -c < leaflet.js) bytes leaflet.js, $(wc -c < leaflet.css) bytes leaflet.css"
echo "next: copy leaflet.js, leaflet.css, radar-overlay.html, radar-overlay.js into your signage server's frontend dir."