mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 09:23:16 -06:00
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>
207 lines
8.2 KiB
JavaScript
207 lines
8.2 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;
|
|
}
|
|
|
|
// 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();
|
|
}
|