From 44a0fff0ed224cd81d440d5913facd7cb7ced438 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Thu, 18 Jun 2026 20:58:52 -0500 Subject: [PATCH] Add PIP-Weather-Radar example (TV-style live radar overlay) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Examples/PIP-Weather-Radar/.gitignore | 5 + Examples/PIP-Weather-Radar/README.md | 114 ++++++++++ .../PIP-Weather-Radar/config.example.json | 23 ++ Examples/PIP-Weather-Radar/package.json | 12 + Examples/PIP-Weather-Radar/radar-overlay.html | 56 +++++ Examples/PIP-Weather-Radar/radar-overlay.js | 171 +++++++++++++++ Examples/PIP-Weather-Radar/radar.js | 206 ++++++++++++++++++ Examples/PIP-Weather-Radar/test.js | 49 +++++ Examples/PIP-Weather-Radar/vendor-leaflet.sh | 12 + 9 files changed, 648 insertions(+) create mode 100644 Examples/PIP-Weather-Radar/.gitignore create mode 100644 Examples/PIP-Weather-Radar/README.md create mode 100644 Examples/PIP-Weather-Radar/config.example.json create mode 100644 Examples/PIP-Weather-Radar/package.json create mode 100644 Examples/PIP-Weather-Radar/radar-overlay.html create mode 100644 Examples/PIP-Weather-Radar/radar-overlay.js create mode 100644 Examples/PIP-Weather-Radar/radar.js create mode 100644 Examples/PIP-Weather-Radar/test.js create mode 100755 Examples/PIP-Weather-Radar/vendor-leaflet.sh diff --git a/Examples/PIP-Weather-Radar/.gitignore b/Examples/PIP-Weather-Radar/.gitignore new file mode 100644 index 0000000..f4c7e8a --- /dev/null +++ b/Examples/PIP-Weather-Radar/.gitignore @@ -0,0 +1,5 @@ +config.json +node_modules/ +package-lock.json +leaflet.js +leaflet.css diff --git a/Examples/PIP-Weather-Radar/README.md b/Examples/PIP-Weather-Radar/README.md new file mode 100644 index 0000000..b3f08f0 --- /dev/null +++ b/Examples/PIP-Weather-Radar/README.md @@ -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:///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="" +# overlay_base_url="https://localhost:3443/radar-overlay.html" +# device_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. diff --git a/Examples/PIP-Weather-Radar/config.example.json b/Examples/PIP-Weather-Radar/config.example.json new file mode 100644 index 0000000..c2e0259 --- /dev/null +++ b/Examples/PIP-Weather-Radar/config.example.json @@ -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)" +} diff --git a/Examples/PIP-Weather-Radar/package.json b/Examples/PIP-Weather-Radar/package.json new file mode 100644 index 0000000..9df607d --- /dev/null +++ b/Examples/PIP-Weather-Radar/package.json @@ -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" } +} diff --git a/Examples/PIP-Weather-Radar/radar-overlay.html b/Examples/PIP-Weather-Radar/radar-overlay.html new file mode 100644 index 0000000..b2afed7 --- /dev/null +++ b/Examples/PIP-Weather-Radar/radar-overlay.html @@ -0,0 +1,56 @@ + + + + + +Live Weather Radar + + + + + +
+
+
+ Live Radar + + + +
+
+
+
+ + + + diff --git a/Examples/PIP-Weather-Radar/radar-overlay.js b/Examples/PIP-Weather-Radar/radar-overlay.js new file mode 100644 index 0000000..60391ef --- /dev/null +++ b/Examples/PIP-Weather-Radar/radar-overlay.js @@ -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: '© OpenStreetMap © 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('' + (p.event || 'Warning') + '
' + 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 '
' + ev + '
'; + }).join(''); + })(); +})(); diff --git a/Examples/PIP-Weather-Radar/radar.js b/Examples/PIP-Weather-Radar/radar.js new file mode 100644 index 0000000..5b77d85 --- /dev/null +++ b/Examples/PIP-Weather-Radar/radar.js @@ -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(); +} diff --git a/Examples/PIP-Weather-Radar/test.js b/Examples/PIP-Weather-Radar/test.js new file mode 100644 index 0000000..21f1dd4 --- /dev/null +++ b/Examples/PIP-Weather-Radar/test.js @@ -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); diff --git a/Examples/PIP-Weather-Radar/vendor-leaflet.sh b/Examples/PIP-Weather-Radar/vendor-leaflet.sh new file mode 100755 index 0000000..0c6bdb0 --- /dev/null +++ b/Examples/PIP-Weather-Radar/vendor-leaflet.sh @@ -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."