screentinker/Examples/PIP-Weather-Radar/test.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

93 lines
5.6 KiB
JavaScript

'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, max_counties: 2, 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(','));
ok('overlay uri: framing clamp carried through', back.get('maxcounties') === '2');
// Omitted means "let the overlay pick its own default" — not "unlimited zoom-out".
const noClamp = new URLSearchParams(r.buildOverlayUri('https://s/x.html', { lat: 1, lon: 2 }).split('?')[1]);
ok('overlay uri: clamp omitted when unset', noClamp.get('maxcounties') === null);
// --- framing: the map may zoom, but it must never pan -------------------------------
// frameFor lives in browser code, so lift the real source out and run it against a
// stand-in for L.latLngBounds (pure math in Leaflet — no DOM involved). Testing the
// shipped function beats testing a copy of it that can drift.
{
const src = require('fs').readFileSync(__dirname + '/radar-overlay.js', 'utf8');
const B = (s2, w, n, e) => ({
getSouth: () => s2, getWest: () => w, getNorth: () => n, getEast: () => e,
isValid: () => true,
intersects(o) { return !(o.getSouth() > n || o.getNorth() < s2 || o.getWest() > e || o.getEast() < w); },
});
const L = { latLngBounds: (a, b) => B(a[0], a[1], b[0], b[1]) };
const lat = 42.6052, lon = -87.8299;
const COUNTY_DEG = 0.35, MIN_HALF_LAT = 0.18, maxCounties = 2;
const padLat = COUNTY_DEG * maxCounties;
const padLon = padLat / Math.max(0.2, Math.cos(lat * Math.PI / 180));
const homeFrame = L.latLngBounds([lat - padLat, lon - padLon], [lat + padLat, lon + padLon]);
const frameFor = eval('(' + src.match(/function frameFor\(b\) \{[\s\S]*?\n \}/)[0] + ')');
const centred = (f) => Math.abs((f.getSouth() + f.getNorth()) / 2 - lat) < 1e-9 &&
Math.abs((f.getWest() + f.getEast()) / 2 - lon) < 1e-9;
const near = frameFor(B(42.75, -87.70, 42.95, -87.45)); // storm 20 mi NE
ok('framing: a nearby storm still leaves the view centred on home', centred(near));
ok('framing: a nearby storm zooms in, not out', (near.getNorth() - near.getSouth()) < 2 * padLat);
const wide = frameFor(B(42.0, -90.5, 46.0, -87.0)); // statewide squall line
ok('framing: a statewide line is capped at the county budget', (wide.getNorth() - wide.getSouth()) <= 2 * padLat + 1e-9);
ok('framing: and is still centred on home', centred(wide));
const tiny = frameFor(B(42.60, -87.83, 42.61, -87.82)); // one small cell overhead
ok('framing: a single small cell does not slam to street level', (tiny.getNorth() - tiny.getSouth()) >= 2 * MIN_HALF_LAT - 1e-9);
ok('framing: a storm outside the frame is not chased (hold configured view)',
frameFor(B(44.0, -88.7, 44.6, -88.0)) === null);
ok('framing: no bounds at all holds the configured view', frameFor(null) === null);
}
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);