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.
This commit is contained in:
ScreenTinker 2026-07-27 12:26:08 -05:00
parent d6f81171c2
commit b8c127f766
6 changed files with 106 additions and 4 deletions

View file

@ -97,6 +97,7 @@ NODE_TLS_REJECT_UNAUTHORIZED=0 node radar.js
| `mode` | `"on_warning"` | `"on_warning"` = show only during qualifying warnings; `"always"` = always on |
| `lat`, `lon` | — | Map center **and** the NWS `?point=` used to detect warnings |
| `zoom` | `8` | Leaflet zoom; ~8 ≈ a county/metro |
| `max_counties` | `2` | How far the auto-framing may pull back from the centre, in county-widths. Warnings inside that box are framed; one entirely outside it is not chased, so a distant storm can't zoom your area down to nothing. |
| `area_label` | — | Shown in the overlay header |
| `states` | `[]` | 2-letter codes used to fetch warning polygons (`?area=ST`). Empty → `?point=` |
| `events` | Tornado/Severe Tstorm/Flash Flood/Flood Warning | Which warnings qualify & are drawn |

View file

@ -10,6 +10,7 @@
"lat": 43.0389,
"lon": -87.9065,
"zoom": 8,
"max_counties": 2,
"states": ["WI"],
"events": ["Tornado Warning", "Severe Thunderstorm Warning", "Flash Flood Warning", "Flood Warning"],

View file

@ -51,6 +51,9 @@
</div>
<div class="legend" id="legend"></div>
<script src="/leaflet.js"></script>
<script src="/radar-overlay.js"></script>
<!-- ?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>
</body>
</html>

View file

@ -13,6 +13,16 @@
var events = (q.get('events') || '').split(',').map(function (s) { return s.trim(); }).filter(Boolean);
if (!events.length) events = DEFAULT_EVENTS.slice();
// How far the auto-framing is ever allowed to pull back, expressed in county-widths from
// the configured centre. Without this, a warning several counties away drags the frame
// out to cover it and your own area shrinks to nothing — the map ends up showing half a
// state at a zoom where local weather is unreadable. 2 counties in every direction keeps
// "where I am" recognisable while still catching storms about to arrive.
var maxCounties = parseFloat(q.get('maxcounties'));
if (!isFinite(maxCounties) || maxCounties <= 0) maxCounties = 2;
var COUNTY_DEG = 0.35; // ~24 mi, a typical US county
var MIN_HALF_LAT = 0.18; // ~12 mi; a floor so one small cell can't over-zoom
var EVENT_COLORS = {
'Tornado Warning': '#FF2D2D',
'Severe Thunderstorm Warning': '#FFD12E',
@ -22,9 +32,37 @@
var DEFAULT_COLOR = '#FF8A1F';
function colorFor(ev) { return EVENT_COLORS[ev] || DEFAULT_COLOR; }
// Decide the frame for a set of warning polygons.
//
// The map NEVER PANS. Signage is watched at a glance from across a room, and a view that
// slides to wherever the weather is stops being "my area" — you lose the landmarks you
// orient by. So the centre is pinned to the configured point and only the ZOOM responds:
// the box we hand to fitBounds is always symmetric about home.
//
// Returns null to mean "nothing worth reframing for — hold the configured view", which is
// the case both when there are no warnings and when they are all outside the home frame.
function frameFor(b) {
if (!b || !b.isValid || !b.isValid()) return null;
if (!homeFrame.intersects(b)) return null; // a storm three counties over is not chased
// How far from home the warning actually reaches, capped at the home frame. Taking the
// max of the two sides is what keeps the box symmetric, and therefore centred.
var halfLat = Math.min(Math.max(Math.abs(b.getNorth() - lat), Math.abs(lat - b.getSouth())), padLat);
var halfLon = Math.min(Math.max(Math.abs(b.getEast() - lon), Math.abs(lon - b.getWest())), padLon);
// Floor it so a single small cell overhead doesn't slam the map to street level.
halfLat = Math.max(halfLat, MIN_HALF_LAT);
halfLon = Math.max(halfLon, MIN_HALF_LAT / Math.max(0.2, Math.cos(lat * Math.PI / 180)));
return L.latLngBounds([lat - halfLat, lon - halfLon], [lat + halfLat, lon + halfLon]);
}
document.getElementById('area').textContent = area;
var map = L.map('map', { zoomControl: false, attributionControl: true, fadeAnimation: false }).setView([lat, lon], zoom);
// The widest frame the auto-fit may ever produce. Longitude degrees shrink toward the
// poles, so scale them by cos(lat) to keep the box square-ish on the ground.
var padLat = COUNTY_DEG * maxCounties;
var padLon = padLat / Math.max(0.2, Math.cos(lat * Math.PI / 180));
var homeFrame = L.latLngBounds([lat - padLat, lon - padLon], [lat + padLat, lon + padLon]);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', {
subdomains: 'abcd', maxZoom: 19,
attribution: '&copy; OpenStreetMap &copy; CARTO · Radar: RainViewer · Alerts: NWS/NOAA',
@ -146,9 +184,18 @@
var fitKey = feats.map(function (f) { return (f.properties || {}).id; }).sort().join('|');
if (fitKey !== loadWarnings._fitKey) {
loadWarnings._fitKey = fitKey;
try { map.fitBounds(warnLayer.getBounds(), { padding: [70, 70], maxZoom: 9 }); } catch (e) {}
// Padding is small on purpose: the frame is already the answer, and 70px of inset
// on a PiP-sized overlay throws away a third of the width on each side.
try {
var frame = frameFor(warnLayer.getBounds());
if (frame) map.fitBounds(frame, { padding: [24, 24], maxZoom: 9 });
else map.setView([lat, lon], zoom);
} catch (e) {}
}
} else {
// Warnings cleared: go back to the configured view instead of staying parked on
// wherever the last storm happened to be.
if (loadWarnings._fitKey) map.setView([lat, lon], zoom);
loadWarnings._fitKey = null;
}
renderChips(counts);

View file

@ -65,12 +65,18 @@ function qualifies(alert, opts = {}) {
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 = 2;
// 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(','));
@ -113,7 +119,8 @@ if (require.main === module) {
}
const overlayUri = buildOverlayUri(OVERLAY_BASE, {
lat: cfg.lat, lon: cfg.lon, zoom: cfg.zoom || 8, area: cfg.area_label, states: cfg.states, events: EVENTS,
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 }

View file

@ -36,12 +36,55 @@ const url = r.frameTileUrl('https://tilecache.rainviewer.com', '/v2/radar/abc',
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, area: 'Milwaukee County, WI', states: ['WI'], events: EV,
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}`);