mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
The chips are labelled "in view" but were tallied from the alert query result. Alerts are fetched per state — one request instead of one per county — so the feed routinely carries warnings hundreds of miles away. A Kenosha-centred map reported "2x Tornado Warning" for tornadoes in Calumet and Winnebago, neither of them on screen and neither reachable, since the view is bounded to two counties. Warnings are now sorted into three states rather than two: - Unreachable: outside the bounded frame the map can ever show. Not drawn, not counted. This is what produced the phantom tornado count. - Reachable but off-screen: drawn, so it can slide into view at the edge as the frame widens, but not claimed as "in view". - On screen: tallied into the chips, recomputed on moveend/zoomend, because framing settles asynchronously and it is the settled zoom that decides what "in view" means. Bounds come off the GeoJSON coordinates directly, covering every ring of a MultiPolygon, rather than building a throwaway layer per feature to ask Leaflet for an extent. Asset version bumped to 3 so players holding the cached copy pick this up.
214 lines
8.6 KiB
JavaScript
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 = 3;
|
|
|
|
// 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();
|
|
}
|