screentinker/Examples/PIP-USD-Cap-Alert-Monitor-NOAA/cap-parse.js
screentinker 0b138f10c6
Add PiP overlay example recipes (#132)
Self-contained examples for the PiP overlay API (POST /api/pip), each
with a CSP-safe query-param overlay (external JS), config.example.json,
zero runtime deps, an offline test, and a README:

- PIP-Announce-Broadcast    manual one-shot message to a screen/group
- PIP-Weather-Widget        Open-Meteo current conditions (keyless)
- PIP-Air-Quality           Open-Meteo US AQI widget (keyless)
- PIP-Crypto-Ticker         CoinGecko price strip (keyless)
- PIP-News-Ticker           scrolling RSS/Atom headlines
- PIP-Room-Status-Calendar  ICS-driven Available/Busy room sign
- PIP-Event-Countdown       client-side countdown, auto-clears at zero
- PIP-Welcome-Board         rotating welcome/birthday cards from CSV
- PIP-Fundraiser-Thermometer goal-progress bar from local/URL JSON
- PIP-QR-Rotator            rotating QR codes, encoded client-side
- PIP-Incident-Webhook      event-driven: red on firing, clear on resolved

Also includes the CAP-AU (NSW RFS) and US NWS/NOAA emergency-alert
monitors that push expiry-aware PiP overlays.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:20:37 -05:00

184 lines
7.3 KiB
JavaScript

'use strict';
// CAP-AU parser for the NSW RFS "majorIncidentsCAP" feed (and other CAP-AU sources that
// wrap their alerts the same way). Three jobs:
// 1. Unwrap the EDXL-DE envelope and pull out each embedded CAP <alert>.
// 2. Normalise the bits we actually gate/render on (AlertLevel lives in <parameter>,
// NOT in CAP <severity> — RFS leaves severity "Unknown" for routine incidents).
// 3. Geofence: is a given screen's lat/lon inside an alert's <area>? CAP coordinates
// are "lat,lon" (note: the REVERSE of GeoJSON's lon,lat) — this module keeps the
// flip in one place so callers never have to think about it.
const { XMLParser } = require('fast-xml-parser');
const parser = new XMLParser({
ignoreAttributes: false,
removeNSPrefix: true, // EDXLDistribution and alert sit in different namespaces
parseTagValue: false, // keep everything as strings; we coerce deliberately
trimValues: true,
});
// Always work with arrays even when the XML has a single child.
function arr(x) {
if (x === undefined || x === null) return [];
return Array.isArray(x) ? x : [x];
}
// Pull the <parameter> name/value pairs into a flat map. This is where the useful,
// already-structured fields live (AlertLevel, IncidentType, Status, ...), so we read
// these instead of regexing the HTML-encoded <description> blob.
function paramsToMap(info) {
const out = {};
for (const p of arr(info && info.parameter)) {
if (p && p.valueName != null) out[String(p.valueName)] = p.value == null ? '' : String(p.value);
}
return out;
}
// Parse a CAP "<polygon>" string ("lat,lon lat,lon ...") into [{lat, lon}, ...].
function parsePolygon(str) {
if (!str) return null;
const pts = String(str).trim().split(/\s+/).map((pair) => {
const [lat, lon] = pair.split(',').map(Number);
return Number.isFinite(lat) && Number.isFinite(lon) ? { lat, lon } : null;
}).filter(Boolean);
return pts.length >= 3 ? pts : null;
}
// Parse a CAP "<circle>" string ("lat,lon radiusKm"). RFS often emits radius 0 (a point),
// which can never contain anything, so callers should treat a 0-radius circle as "no
// usable circle" and rely on the polygon.
function parseCircle(str) {
if (!str) return null;
const [center, radius] = String(str).trim().split(/\s+/);
const [lat, lon] = (center || '').split(',').map(Number);
const km = Number(radius);
if (![lat, lon, km].every(Number.isFinite)) return null;
return { lat, lon, km };
}
// Ray-casting point-in-polygon. We map lon -> x and lat -> y so the algorithm is ordinary
// planar; that mapping is the ONE place the CAP lat,lon order is reconciled.
function pointInPolygon(pt, poly) {
const x = pt.lon, y = pt.lat;
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const xi = poly[i].lon, yi = poly[i].lat;
const xj = poly[j].lon, yj = poly[j].lat;
const intersect = (yi > y) !== (yj > y) &&
x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
if (intersect) inside = !inside;
}
return inside;
}
function haversineKm(a, b) {
const R = 6371;
const toRad = (d) => (d * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLon = toRad(b.lon - a.lon);
const lat1 = toRad(a.lat), lat2 = toRad(b.lat);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(h));
}
// Does {lat, lon} fall inside this alert's area? Polygon first; fall back to a non-zero
// circle. Returns false when the alert has no usable geometry.
function pointInAlertArea(point, alert) {
if (alert.polygon && pointInPolygon(point, alert.polygon)) return true;
if (alert.circle && alert.circle.km > 0 && haversineKm(point, alert.circle) <= alert.circle.km) return true;
return false;
}
// Flatten one embedded CAP <alert> into the shape the monitor works with.
function normaliseAlert(a) {
const info = Array.isArray(a.info) ? a.info[0] : a.info || {};
const area = Array.isArray(info.area) ? info.area[0] : info.area || {};
const params = paramsToMap(info);
return {
identifier: a.identifier != null ? String(a.identifier) : null,
msgType: a.msgType || null, // Alert | Update | Cancel
sent: a.sent || null,
headline: info.headline || params.IncidentName || '(no headline)',
event: info.event || null,
category: info.category || null,
responseType: info.responseType || null, // mostly "Monitor" in this feed
severity: info.severity || null, // mostly "Unknown" — do NOT gate on this
expires: info.expires || null,
web: info.web || null,
// RFS-specific, the field that actually carries urgency:
alertLevel: params.AlertLevel || null, // Planned Burn | Advice | Watch and Act | Emergency Warning
incidentType: params.IncidentType || null,
status: params.Status || null,
size: params.Fireground || params.Size || null,
council: params.CouncilArea || params.Location || null,
isFire: (params.IsFire || '').toLowerCase() === 'yes',
polygon: parsePolygon(area.polygon),
circle: parseCircle(area.circle),
areaDesc: area.areaDesc || null,
params,
};
}
// Parse a full feed body (EDXL-DE wrapping embedded CAP alerts) into normalised alerts.
function parseFeed(xml) {
const root = parser.parse(xml);
const dist = root.EDXLDistribution || root.Distribution || null;
const alerts = [];
if (dist) {
for (const co of arr(dist.contentObject)) {
const embedded = co && co.xmlContent && co.xmlContent.embeddedXMLContent;
for (const e of arr(embedded)) {
for (const al of arr(e && e.alert)) alerts.push(normaliseAlert(al));
}
}
} else {
// Fallback: a bare CAP feed (no EDXL envelope).
for (const al of arr(root.alert)) alerts.push(normaliseAlert(al));
}
return alerts;
}
// Has this alert's <expires> passed? (Treats missing/unparseable expiry as "not expired".)
function isExpired(alert, now = Date.now()) {
if (!alert.expires) return false;
const t = Date.parse(alert.expires);
return Number.isFinite(t) && t <= now;
}
// The gate: should this alert put something on a screen at `point`?
// - msgType must be Alert/Update (Cancel clears, never shows)
// - not expired
// - AlertLevel is at or above the configured threshold
// - the screen falls inside the alert area
// Returns { show: bool, reason } so callers can log why something did/didn't fire.
const DEFAULT_LEVELS = ['Watch and Act', 'Emergency Warning'];
function shouldShow(alert, point, opts = {}) {
const levels = opts.alertLevels || DEFAULT_LEVELS;
const now = opts.now || Date.now();
if (alert.msgType === 'Cancel') return { show: false, reason: 'cancelled' };
if (isExpired(alert, now)) return { show: false, reason: 'expired' };
if (!alert.alertLevel || !levels.includes(alert.alertLevel)) {
return { show: false, reason: `alertLevel "${alert.alertLevel}" below threshold` };
}
if (!alert.polygon && !(alert.circle && alert.circle.km > 0)) {
return { show: false, reason: 'no usable geometry' };
}
if (!pointInAlertArea(point, alert)) return { show: false, reason: 'outside area' };
return { show: true, reason: 'in-area, at/above threshold' };
}
module.exports = {
parseFeed,
normaliseAlert,
parsePolygon,
parseCircle,
pointInPolygon,
pointInAlertArea,
haversineKm,
isExpired,
shouldShow,
DEFAULT_LEVELS,
};