mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 09:23:16 -06:00
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:
parent
0b138f10c6
commit
44a0fff0ed
5
Examples/PIP-Weather-Radar/.gitignore
vendored
Normal file
5
Examples/PIP-Weather-Radar/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
config.json
|
||||
node_modules/
|
||||
package-lock.json
|
||||
leaflet.js
|
||||
leaflet.css
|
||||
114
Examples/PIP-Weather-Radar/README.md
Normal file
114
Examples/PIP-Weather-Radar/README.md
Normal 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.
|
||||
23
Examples/PIP-Weather-Radar/config.example.json
Normal file
23
Examples/PIP-Weather-Radar/config.example.json
Normal 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)"
|
||||
}
|
||||
12
Examples/PIP-Weather-Radar/package.json
Normal file
12
Examples/PIP-Weather-Radar/package.json
Normal 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" }
|
||||
}
|
||||
56
Examples/PIP-Weather-Radar/radar-overlay.html
Normal file
56
Examples/PIP-Weather-Radar/radar-overlay.html
Normal 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>
|
||||
171
Examples/PIP-Weather-Radar/radar-overlay.js
Normal file
171
Examples/PIP-Weather-Radar/radar-overlay.js
Normal 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: '© 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('<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('');
|
||||
})();
|
||||
})();
|
||||
206
Examples/PIP-Weather-Radar/radar.js
Normal file
206
Examples/PIP-Weather-Radar/radar.js
Normal 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();
|
||||
}
|
||||
49
Examples/PIP-Weather-Radar/test.js
Normal file
49
Examples/PIP-Weather-Radar/test.js
Normal 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);
|
||||
12
Examples/PIP-Weather-Radar/vendor-leaflet.sh
Executable file
12
Examples/PIP-Weather-Radar/vendor-leaflet.sh
Executable 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."
|
||||
Loading…
Reference in a new issue