Examples/weather-radar: count only the warnings actually on screen
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run

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.
This commit is contained in:
ScreenTinker 2026-07-27 12:29:28 -05:00
parent b8c127f766
commit 6ee1c96b04
4 changed files with 109 additions and 5 deletions

View file

@ -54,6 +54,6 @@
<!-- ?v= is a cache-buster, NOT decoration. These assets are served max-age=14400, so a
player that loaded the old file keeps it for four hours and silently ignores a
redeploy. Bump this AND `ASSET_V` in radar.js together whenever the JS changes. -->
<script src="/radar-overlay.js?v=2"></script>
<script src="/radar-overlay.js?v=3"></script>
</body>
</html>

View file

@ -54,6 +54,40 @@
return L.latLngBounds([lat - halfLat, lon - halfLon], [lat + halfLat, lon + halfLon]);
}
// Bounds of a GeoJSON warning polygon, computed straight off the coordinates. Cheaper
// than building a throwaway L.geoJSON layer per feature just to ask for its extent.
function boundsOf(f) {
var g = f && f.geometry; if (!g) return null;
var polys = g.type === 'Polygon' ? [g.coordinates] : g.coordinates;
var s = 90, w = 180, n = -90, e = -180;
polys.forEach(function (poly) {
poly.forEach(function (ring) {
ring.forEach(function (pt) {
var x = pt[0], y = pt[1];
if (y < s) s = y; if (y > n) n = y;
if (x < w) w = x; if (x > e) e = x;
});
});
});
return (n >= s && e >= w) ? L.latLngBounds([s, w], [n, e]) : null;
}
// The chips claim to describe what is "in view", so they have to be tallied from the
// map's ACTUAL viewport, not from the query result. The alert feed is fetched per STATE
// (one request instead of one per county), so it routinely returns warnings hundreds of
// miles away — reporting those as "2x Tornado Warning" over a map that shows neither of
// them is worse than saying nothing.
function visibleCounts() {
var view = map.getBounds(), counts = {};
drawn.forEach(function (f) {
if (!f.__b || !f.__b.intersects(view)) return;
var ev = (f.properties || {}).event;
counts[ev] = (counts[ev] || 0) + 1;
});
return counts;
}
function refreshChips() { renderChips(visibleCounts()); }
document.getElementById('area').textContent = area;
var map = L.map('map', { zoomControl: false, attributionControl: true, fadeAnimation: false }).setView([lat, lon], zoom);
@ -119,6 +153,7 @@
// ---- live NWS warning polygons ----------------------------------------------------
var warnLayer = null;
var drawn = []; // features actually on the map, each stamped with __b bounds
var chipsEl = document.getElementById('chips');
function shortHeadline(h) { h = h || ''; return h.length > 90 ? h.slice(0, 87) + '…' : h; }
@ -153,7 +188,10 @@
Promise.allSettled(alertUrls().map(function (u) {
return fetch(u, { headers: { Accept: 'application/geo+json' } }).then(function (r) { return r.json(); });
})).then(function (results) {
var seen = {}, feats = [], counts = {};
// Slightly larger than homeFrame: fitBounds padding can push the viewport a hair
// past it, and a polygon popping in blank at the edge looks like a bug.
var reachable = homeFrame.pad(0.3);
var seen = {}, feats = [];
results.forEach(function (res) {
if (res.status !== 'fulfilled' || !res.value || !res.value.features) return;
res.value.features.forEach(function (f) {
@ -162,10 +200,15 @@
if (events.indexOf(p.event) === -1) return;
var id = p.id || (f.id || JSON.stringify(g).slice(0, 40));
if (seen[id]) return; seen[id] = 1;
// The map can never travel outside homeFrame, so a warning that misses it is not
// merely off-screen now — it is unreachable. Don't draw it and don't count it.
var b = boundsOf(f);
if (!b || !b.intersects(reachable)) return;
f.__b = b;
feats.push(f);
counts[p.event] = (counts[p.event] || 0) + 1;
});
});
drawn = feats;
if (warnLayer) { map.removeLayer(warnLayer); warnLayer = null; }
if (feats.length) {
warnLayer = L.geoJSON({ type: 'FeatureCollection', features: feats }, {
@ -198,10 +241,16 @@
if (loadWarnings._fitKey) map.setView([lat, lon], zoom);
loadWarnings._fitKey = null;
}
renderChips(counts);
// After framing, not before: fitBounds/setView change what "in view" means, and
// moveend fires once the view settles.
refreshChips();
}).catch(function (e) { if (window.console) console.warn('warnings load failed', e && e.message); });
}
// The zoom set by framing decides what "in view" means, and fitBounds settles
// asynchronously — so retally once the map stops moving rather than guessing.
map.on('moveend zoomend', refreshChips);
// ---- go ---------------------------------------------------------------------------
loadRadar();
loadWarnings();

View file

@ -67,7 +67,7 @@ function qualifies(alert, opts = {}) {
// 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;
const ASSET_V = 3;
// Build the overlay iframe URL with the area/config encoded in the query string.
function buildOverlayUri(base, o = {}) {

View file

@ -86,6 +86,61 @@ ok('overlay uri: clamp omitted when unset', noClamp.get('maxcounties') === null)
ok('framing: no bounds at all holds the configured view', frameFor(null) === null);
}
// --- the chips must describe the screen, not the query ------------------------------
// Alerts are fetched per STATE (one request, not one per county), so the feed routinely
// carries warnings hundreds of miles away. Counting those produced "2x Tornado Warning"
// over a map on which neither tornado was visible.
{
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,
pad(r) { const dy = (n - s2) * r, dx = (e - w) * r; return B(s2 - dy, w - dx, n + dy, e + dx); },
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, 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 lift = (n) => {
const m = src.match(new RegExp('function ' + n + '\\([a-z]*\\) \\{[\\s\\S]*?\\n \\}'));
if (!m) throw new Error('could not lift ' + n);
return eval('(' + m[0] + ')');
};
const boundsOf = lift('boundsOf');
const box = (s2, w, n, e) => ({ properties: { event: 'Tornado Warning' },
geometry: { type: 'Polygon', coordinates: [[[w, s2], [e, s2], [e, n], [w, n], [w, s2]]] } });
const b = boundsOf(box(42.5, -87.9, 42.7, -87.7));
ok('alerts: polygon bounds are read off the coordinates',
Math.abs(b.getSouth() - 42.5) < 1e-9 && Math.abs(b.getEast() - (-87.7)) < 1e-9);
ok('alerts: a geometry-less alert yields no bounds', boundsOf({ properties: {} }) === null);
// A MultiPolygon must cover every ring, not just the first.
const multi = boundsOf({ properties: {}, geometry: { type: 'MultiPolygon', coordinates: [
[[[-87.9, 42.5], [-87.7, 42.5], [-87.7, 42.7], [-87.9, 42.7], [-87.9, 42.5]]],
[[[-88.3, 43.1], [-88.1, 43.1], [-88.1, 43.3], [-88.3, 43.3], [-88.3, 43.1]]]] } });
ok('alerts: a multipolygon spans all of its rings',
Math.abs(multi.getNorth() - 43.3) < 1e-9 && Math.abs(multi.getWest() - (-88.3)) < 1e-9);
const reachable = homeFrame.pad(0.3);
const nearby = boundsOf(box(42.55, -87.9, 42.75, -87.6)); // Kenosha/Racine
const upstate = boundsOf(box(44.0, -88.7, 44.6, -88.0)); // Calumet/Winnebago
ok('alerts: a local warning is reachable and counted', nearby.intersects(reachable));
ok('alerts: THE BUG — a warning 100 mi away is neither drawn nor counted',
!upstate.intersects(reachable));
// Reachable-but-off-screen is a real third state: drawn so it can slide in at the edge,
// but not claimed as "in view" until it actually is.
const edge = boundsOf(box(43.30, -88.2, 43.45, -88.0));
const tightView = L.latLngBounds([lat - 0.2, lon - 0.28], [lat + 0.2, lon + 0.28]);
ok('alerts: reachable but off-screen is drawn, yet not counted as in view',
edge.intersects(reachable) && !edge.intersects(tightView));
}
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 ❌');