mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Examples/weather-radar: count only the warnings actually on screen
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:
parent
b8c127f766
commit
6ee1c96b04
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 = {}) {
|
||||
|
|
|
|||
|
|
@ -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 ❌');
|
||||
|
|
|
|||
Loading…
Reference in a new issue