screentinker/Examples/PIP-Weather-Radar/radar.js
ScreenTinker b8c127f766 Examples/weather-radar: keep the map centred and bounded
The auto-framing fitted the view to whatever warning polygons were active, so a
storm a few counties away pulled the frame out to cover it and the configured
area shrank to an unreadable corner of a half-state view. On signage that is
read at a glance, a map that wanders is worse than one that shows less.

Framing is now centred and bounded:

- The map never pans. The centre stays on the configured point and only the
  zoom responds, because the box handed to fitBounds is symmetric about home.
- Zoom-out is capped at `max_counties` (default 2) county-widths in every
  direction, with longitude scaled by cos(lat) so the budget is the same
  distance on the ground north and south.
- Warnings entirely outside that box are not chased at all; the configured view
  is held. Warnings clearing returns to it rather than staying parked on the
  last storm.
- A floor on the frame keeps one small cell overhead from zooming to street
  level, and fit padding drops to 24px, which on a PiP-sized overlay was
  discarding a third of the width per side.

The overlay assets are served max-age=14400, so a player that had already
loaded them kept the old copy for four hours and silently ignored a redeploy.
The page URL and its script tag now carry a version, documented to be bumped
together.

Tests cover the invariants against the shipped frameFor source rather than a
copy of it: centred after reframing, capped at the county budget, small cells
floored, distant storms not chased.
2026-07-27 12:26:08 -05:00

214 lines
8.6 KiB
JavaScript

'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;
}
// Keep in lockstep with the ?v= on the <script> tag in radar-overlay.html. Both are
// needed: this one re-fetches the page, that one re-fetches the script.
const ASSET_V = 2;
// 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.max_counties != null) q.set('maxcounties', String(o.max_counties));
q.set('v', String(ASSET_V)); // busts the cached overlay PAGE; the page busts its own JS
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, max_counties: cfg.max_counties,
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();
}