swnesbitt
Live bundle: encode radar textures as lossless WebP (method=4) instead of PNG — ~25% smaller bundles, lossless (gray values unchanged). Client loads textures as image/webp; logo + downloaded export stills stay PNG.
e049811
Raw
History Blame Contribute Delete
173 kB
"""
NEXRAD Level 2 — 0.5° sweep browser (SAILS-aware)
Hugging Face Space (Gradio)
Pulls one hour of archived NEXRAD Level 2 volumes from the AWS Open Data
buckets, extracts every ~0.5° sweep (including SAILS / MESO-SAILS
re-insertions), and displays them gate-natively: each sweep's polar grid is
shipped to the browser as a lossless grayscale PNG and reprojected per-pixel
in a WebGL fragment shader (4/3-earth beam model), so the native gate
geometry is preserved at any zoom level.
"""
import base64
import bz2
import collections
import datetime as dt
import hashlib
import time as time_mod
import gzip
import html as html_mod
import io
import json
import os
import re
import shutil
import tempfile
import threading
import zipfile
try:
import shapefile as _pyshp # pyshp, for live warning polygons
except Exception: # pragma: no cover
_pyshp = None
import traceback
import urllib.request
import xml.etree.ElementTree as ET
from concurrent.futures import (ProcessPoolExecutor, ThreadPoolExecutor,
as_completed)
import matplotlib
matplotlib.use("Agg")
import cmweather # noqa: F401 (registers ChaseSpectral & friends)
import cmasher # noqa: F401 (registers cmr.* colormaps, e.g. cmr.fusion_r)
import gradio as gr
import matplotlib.pyplot as plt
import numpy as np
import xradar as xd
from PIL import Image
# Velocity dealiasing: the Rust region_dealias (a port of Py-ART's region-based
# algorithm), used via its low-level sweep_folds() so no Py-ART is needed.
try:
import region_dealias as _region_dealias # noqa: F401
_HAVE_REGION_DEALIAS = True
except Exception:
_HAVE_REGION_DEALIAS = False
_DEALIAS_ENGINE = "Rust region-dealias" if _HAVE_REGION_DEALIAS else "(none)"
print(f"[region-dealias] velocity dealias engine = {_DEALIAS_ENGINE}"
+ (f" v{_region_dealias.__version__}" if _HAVE_REGION_DEALIAS else ""),
flush=True)
from xradar.io.backends import nexrad_level2 as _nx2
# --- patch: the last LDM record's size field is negative (signed) by spec;
# xradar reads it as unsigned -> MemoryError on most modern volumes.
_orig_init_record = _nx2.NEXRADLevel2File.init_record
def _init_record_fixed(self, recnum):
try:
return _orig_init_record(self, recnum)
except (MemoryError, OverflowError):
ldm = 0 if recnum < 134 else ((recnum - 134) // 120) + 1
if ldm >= len(self.bz2_record_indices):
return False
start = self.bz2_record_indices[ldm]
size = abs(int(self._fh[start:start + 4].view(">i4")[0]))
if self._fp is not None:
self._fp.seek(start + 4)
compressed = self._fp.read(size)
else:
compressed = self._fh[start + 4:start + 4 + size].tobytes()
dec = bz2.BZ2Decompressor()
self._ldm[ldm] = np.frombuffer(dec.decompress(compressed),
dtype=np.uint8)
return _orig_init_record(self, recnum)
_nx2.NEXRADLevel2File.init_record = _init_record_fixed
# --- patch 2: legacy Message 1 declares range-to-first-gate as signed
# halfwords (they're negative for split cuts, e.g. -375 m), but xradar reads
# them unsigned -> velocity sweeps land ~65 km downrange in pre-2008 data.
_nx2.MSG_1["sur_range_first"] = _nx2.SINT2
_nx2.MSG_1["doppler_range_first"] = _nx2.SINT2
# --- link previews: Gradio's `head=` is injected client-side, so social
# scrapers only see the static defaults baked into its index.html template.
# Patch the template on disk at startup.
APP_TITLE = "NEXRAD level 2 browser"
APP_DESC = ("Gate-native WebGL browsing of archived WSR-88D 0.5° scans "
"(SAILS-aware) from the AWS NEXRAD Level 2 archive.")
THUMB_URL = ("https://huggingface.co/spaces/snesbitt/nexrad-level2-browser/"
"resolve/main/thumbnail.png")
def _patch_og_template():
try:
import gradio as _g
p = os.path.join(os.path.dirname(_g.__file__),
"templates", "frontend", "index.html")
html = open(p).read()
html = html.replace('content="Gradio"', f'content="{APP_TITLE}"')
html = html.replace("Click to try out the app!", APP_DESC)
html = html.replace(
"https://raw.githubusercontent.com/gradio-app/gradio/main/js/"
"_website/src/lib/assets/img/header-image.jpg", THUMB_URL)
open(p, "w").write(html)
except Exception:
pass # non-fatal: previews fall back to Gradio defaults
_patch_og_template()
from sites import SITES # (icao, city, state) for all WSR-88D sites
from nexrad_sites import NEXRAD_COORDS # (lat, lon) per site — replaces pyart
SITE_CHOICES = [(f"{icao}{city.lower()}, {st.lower()}", icao)
for icao, city, st in SITES]
# ----------------------------------------------------------------------------- config
BUCKETS = [
"https://noaa-nexrad-level2.s3.amazonaws.com",
"https://unidata-nexrad-level2.s3.amazonaws.com",
]
FIELDS = {
"Reflectivity": dict(
pyart="reflectivity", cmap="ChaseSpectral", vmin=-30, vmax=80,
units="dBZ", label="Horizontal reflectivity factor", tick=10,
),
"Radial velocity": dict(
pyart="velocity", cmap="cmr.fusion_r", vmin=-40, vmax=40,
units="m/s", label="Radial velocity", tick=10,
),
"Differential reflectivity": dict(
pyart="differential_reflectivity", cmap="HomeyerRainbow",
vmin=-2, vmax=8, units="dB", label="Differential reflectivity", tick=2,
),
"Correlation coefficient": dict(
pyart="cross_correlation_ratio", cmap="RefDiff",
vmin=0.5, vmax=1.05, units="", label="Correlation coefficient", tick=0.1,
),
}
DEALIAS_NAME = "Radial velocity (dealiased)"
DEALIAS_CFG = dict(
pyart="velocity", cmap="cmr.fusion_r", vmin=-64, vmax=64,
units="m/s", label="Dealiased radial velocity", tick=16,
)
def _field_cfg(fn):
return DEALIAS_CFG if fn == DEALIAS_NAME else FIELDS[fn]
ELEV_MAX = 0.75 # deg — treat sweeps below this as the 0.5° split cut
LOW_SWEEP_TOL = 0.15 # deg — show only the lowest cut(s); a site's 0.3°
# supplemental cut then hides its 0.5° cut (e.g. KBUF)
MAX_FRAMES = 60 # safety cap on rendered sweeps
N_PROC = max(1, min(2, os.cpu_count() or 1)) # decode workers (CPU-bound)
# persistent raw-volume cache (survives across requests; LRU-pruned)
VOL_CACHE_DIR = os.path.join(tempfile.gettempdir(), "nexrad_vol_cache")
os.makedirs(VOL_CACHE_DIR, exist_ok=True)
VOL_CACHE_BYTES = 2 << 30 # 2 GiB
VOL_CACHE_MAX_AGE_S = 24 * 3600 # also evict raw volumes older than 24 h
# ---- live Level 2 chunk feed (Phase 1) --------------------------------------
# Same objects the NewNEXRADLevel2ObjectFilterable SNS topic announces, read
# anonymously: keys are SITE/VOL#/YYYYMMDD-HHMMSS-NNN-{S|I|E}.
CHUNK_BUCKET = "https://unidata-nexrad-level2-chunks.s3.amazonaws.com"
CHUNK_DIR = os.path.join(VOL_CACHE_DIR, "chunks")
os.makedirs(CHUNK_DIR, exist_ok=True)
_CHUNK_STATE = {} # site -> {vol:int -> meta dict}
_CHUNK_LOCK = threading.Lock()
_S3NS = {"s3": "http://s3.amazonaws.com/doc/2006-03-01/"}
def _s3_page(prefix, delimiter=None):
url = (f"{CHUNK_BUCKET}/?list-type=2&max-keys=1000"
f"&prefix={urllib.request.quote(prefix)}")
if delimiter:
url += f"&delimiter={delimiter}"
root = ET.fromstring(_http_get(url, timeout=30))
keys = [k.text for k in root.findall(".//s3:Contents/s3:Key", _S3NS)]
pres = [p.text for p in
root.findall(".//s3:CommonPrefixes/s3:Prefix", _S3NS)]
return keys, pres
_CHUNK_RE = re.compile(r"(\d{8})-(\d{6})-(\d{3})-([SIE])$")
def _chunk_list_vol(site, vol):
"""List one volume dir -> meta(start, keys ordered, complete)."""
keys, _ = _s3_page(f"{site}/{vol}/")
parsed = []
for k in keys:
m = _CHUNK_RE.search(k)
if m:
parsed.append((int(m.group(3)), m.group(4), k))
if not parsed:
return None
parsed.sort()
m0 = _CHUNK_RE.search(parsed[0][2])
start = dt.datetime.strptime(m0.group(1) + m0.group(2), "%Y%m%d%H%M%S")
return dict(vol=vol, start=start,
keys=[p[2] for p in parsed],
complete=any(p[1] == "E" for p in parsed))
def _chunk_recent_vols(site, window_min=65):
"""Volumes whose scan started in the trailing window, oldest->newest.
One dir listing + probes of only the newest (and still-open) volumes."""
_, pres = _s3_page(f"{site}/", delimiter="/")
nums = sorted(int(p.split("/")[1]) for p in pres if
p.split("/")[1].isdigit())
if not nums:
return []
# handle 0-999 rotation: if the set spans the wrap, low numbers are new
eff = ([n + 1000 if (max(nums) - min(nums) > 800 and n < 500) else n
for n in nums])
order = [n for _, n in sorted(zip(eff, nums), reverse=True)]
now = dt.datetime.utcnow()
state = _CHUNK_STATE.setdefault(site, {})
out = []
for vol in order[:18]:
meta = state.get(vol)
if meta is None or not meta["complete"]:
meta = _chunk_list_vol(site, vol)
if meta is None:
continue
with _CHUNK_LOCK:
state[vol] = meta
age_min = (now - meta["start"]).total_seconds() / 60.0
if age_min <= window_min:
out.append(meta)
elif age_min > window_min + 20:
break # everything older is out of window too
with _CHUNK_LOCK: # drop state for vols far outside the window
for vol in [v for v, m in state.items()
if (now - m["start"]).total_seconds() > 5400]:
state.pop(vol, None)
return sorted(out, key=lambda m: m["start"])
def _sync_chunks(site, meta):
"""Download missing chunks for one volume; returns ordered local paths."""
vdir = os.path.join(CHUNK_DIR, site, str(meta["vol"]))
os.makedirs(vdir, exist_ok=True)
paths = []
todo = []
for k in meta["keys"]:
p = os.path.join(vdir, os.path.basename(k))
paths.append(p)
if not os.path.exists(p):
todo.append((k, p))
def _dl(job):
k, p = job
try:
data = _http_get(f"{CHUNK_BUCKET}/{urllib.request.quote(k)}",
timeout=60)
tmp = p + ".part"
with open(tmp, "wb") as f:
f.write(data)
os.replace(tmp, p)
except Exception:
pass
if todo:
with ThreadPoolExecutor(max_workers=8) as ex:
list(ex.map(_dl, todo))
return [p for p in paths if os.path.exists(p)]
def fetch_live_chunks(site, window_min=65, progress=None):
"""Phase-1 API: trailing-window volumes as ordered local chunk paths.
Returns [dict(vol, start, complete, paths)] oldest->newest."""
vols = _chunk_recent_vols(site, window_min)
out = []
for i, meta in enumerate(vols):
if progress:
progress(0.05 + 0.9 * (i + 1) / max(1, len(vols)),
f"Syncing live chunks {i + 1}/{len(vols)} "
f"(vol {meta['vol']})…")
paths = _sync_chunks(site, meta)
if paths:
out.append(dict(vol=meta["vol"], start=meta["start"],
complete=meta["complete"], paths=paths))
return out
def _prune_chunk_cache(max_age_s=7200):
try:
now = time_mod.time()
for site_d in os.listdir(CHUNK_DIR):
sdir = os.path.join(CHUNK_DIR, site_d)
for vol_d in os.listdir(sdir):
vdir = os.path.join(sdir, vol_d)
if now - os.path.getmtime(vdir) > max_age_s:
shutil.rmtree(vdir, ignore_errors=True)
except Exception:
pass
CHUNK_SITES = {"KILX"} # (vestigial) every site now uses the chunk feed
_CHUNK_TTL = 600 # s — drop a site's decoded volumes ~10 min after
# it was last viewed (every site is on chunks now)
_CHUNK_FRAMES = {} # (site, vol) -> dict(n, complete, frames, ts)
_CHUNK_DEAL = {} # (site, vol) -> dealiased frames (complete vols)
def _concat_chunks(paths, out_path):
"""S+I+...+E chunks concatenated form a valid archive volume file."""
if not os.path.exists(out_path):
tmp = out_path + ".part"
with open(tmp, "wb") as w:
for p in paths:
with open(p, "rb") as r:
shutil.copyfileobj(r, w)
os.replace(tmp, out_path)
return out_path
def _sweep_nyquist(ds, fvar):
"""Per-sweep Nyquist velocity (m/s): prefer ds['nyquist_velocity'], else
max |velocity| after masking reserved codes. Clamped to a plausible range."""
try:
nv = np.asarray(ds["nyquist_velocity"].values, dtype=float)
nv = nv[np.isfinite(nv)]
if nv.size:
n = float(np.median(nv))
if 5.0 <= n <= 80.0:
return n
except Exception:
pass
try:
v = np.asarray(ds[fvar].values, dtype=np.float32)
rsv = _reserved_mask(ds, fvar, v)
if rsv is not None:
v = np.where(rsv, np.nan, v)
n = float(np.nanmax(np.abs(v)))
if 5.0 <= n <= 80.0:
return n
except Exception:
pass
return 0.0
def _open_nexrad_xr(src):
"""Open a NEXRAD Level 2 volume with the xradar fork. `src` is a local
archive-format file path or a list of live chunk paths."""
if isinstance(src, (list, tuple)):
return xd.io.open_nexradlevel2_datatree(list(src),
incomplete_sweep="drop")
return xd.io.open_nexradlevel2_datatree(_gunzip(src))
def _dealias_dtree(dtree, vol_label):
"""Region-based velocity dealiasing of the low Doppler cuts, Py-ART free:
the Rust region_dealias.sweep_folds() folds each velocity sweep decoded by
the xradar fork; rendered via polar_frame_xr."""
frames = []
if not _HAVE_REGION_DEALIAS:
return frames
fvar = XR_FIELD["velocity"]
try:
vcp = str(dtree.ds.attrs.get("scan_name", "") or "")
except Exception:
vcp = ""
sweeps = []
for name in sorted((c for c in dtree.children if c.startswith("sweep_")),
key=lambda n: int(n.split("_")[1])):
ds = dtree[name].ds
try:
el = float(ds["sweep_fixed_angle"].values)
except Exception:
continue
if el >= ELEV_MAX or fvar not in ds:
continue
if not np.isfinite(ds[fvar].values).any():
continue
sweeps.append(dict(name=name, ds=ds, el=el,
t=_np_dt(ds["time"].values.min())))
if not sweeps:
return frames
_lo = min(s["el"] for s in sweeps) # lowest cut only (e.g. KBUF 0.3°)
sweeps = [s for s in sweeps if s["el"] <= _lo + LOW_SWEEP_TOL]
sweeps = _dedup_doppler(sweeps, lambda s: _sweep_nyquist(s["ds"], fvar))
for s in sweeps:
ds = s["ds"]
try:
v = np.asarray(ds[fvar].values, dtype=np.float32)
nyq = _sweep_nyquist(ds, fvar)
if not (5.0 <= nyq <= 80.0):
continue
rsv = _reserved_mask(ds, fvar, v)
mask = ~np.isfinite(v)
if rsv is not None:
mask = mask | rsv
folds = _region_dealias.sweep_folds(v, mask, float(nyq),
rays_wrap_around=True)
vdeal = np.where(mask, np.nan, v + folds * (2.0 * nyq))
ds2 = ds.copy()
ds2[fvar] = ds2[fvar].copy(data=vdeal)
ds2[fvar].encoding = {} # dealiased = physical m/s; no code mask
fr = polar_frame_xr(ds2, fvar, DEALIAS_CFG, s["el"],
s["name"], vol_label, vcp)
if fr is not None:
frames.append(fr)
except Exception:
continue
return frames
def dealias_volume_file(src, vol_label):
"""Region-dealias one volume — a local archive file path OR a list of live
chunk paths — with the xradar fork + Rust region_dealias (no Py-ART)."""
try:
dtree = _open_nexrad_xr(src)
except Exception:
return []
try:
return _dealias_dtree(dtree, vol_label)
finally:
try:
del dtree
except Exception:
pass
# ---- live warnings (real-time mode) ----------------------------------------
WARN_SRC = ("https://mesonet.agron.iastate.edu/data/gis/shape/4326/us/"
"current_ww.zip")
WARN_JSON = os.path.join(VOL_CACHE_DIR, "warnings.json")
WARN_URL = "/gradio_api/file=" + WARN_JSON
# city label database (GeoNames-derived, shipped with the repo) is served
# from the static cache dir alongside warnings.json
CITIES_SRC = os.path.join(os.path.dirname(__file__), "cities.json")
CITIES_JSON = os.path.join(VOL_CACHE_DIR, "cities.json")
try:
if os.path.exists(CITIES_SRC):
shutil.copyfile(CITIES_SRC, CITIES_JSON)
except Exception:
pass
CITIES_URL = "/gradio_api/file=" + CITIES_JSON
# NEXRAD site locations for the in-map site picker: the curated WSR-88D list
# (sites.py) for IDs, with coordinates from Py-ART's station table. Excludes
# TDWR terminal radars (no Level 2 archive). Served like cities.json.
SITES_JSON = os.path.join(VOL_CACHE_DIR, "sites.json")
try:
_sites = []
for _icao, _city, _st in SITES:
v = NEXRAD_COORDS.get(_icao)
if v:
_sites.append([_icao, round(float(v[0]), 4),
round(float(v[1]), 4)])
_sites.sort()
with open(SITES_JSON, "w") as _f:
json.dump(_sites, _f)
except Exception:
pass
SITES_URL = "/gradio_api/file=" + SITES_JSON
def _fetch_warnings():
"""Pull IEM current watches/warnings, keep storm-based TOR/SVR warnings,
write a GeoJSON FeatureCollection next to the volume cache."""
if _pyshp is None:
return
buf = _http_get(WARN_SRC, timeout=30)
z = zipfile.ZipFile(io.BytesIO(buf))
base = [n for n in z.namelist() if n.endswith(".shp")][0][:-4]
rdr = _pyshp.Reader(shp=io.BytesIO(z.read(base + ".shp")),
dbf=io.BytesIO(z.read(base + ".dbf")),
shx=io.BytesIO(z.read(base + ".shx")))
now = dt.datetime.utcnow()
feats = []
for sr in rdr.iterShapeRecords():
d = sr.record.as_dict()
# TO = tornado, SV = severe t'storm, FF = flash flood — all storm-based
# polygon warnings (GTYPE=P). (Flood Warning FL.W is county-based.)
if not (d.get("SIG") == "W" and d.get("PHENOM") in ("TO", "SV", "FF")
and d.get("GTYPE") == "P"):
continue
# drop warnings already past their expiration (UTC YYYYMMDDHHMM) so
# they vanish from the loop instead of lingering until IEM republishes
exp = str(d.get("EXPIRED") or "")
try:
if len(exp) >= 12 and \
dt.datetime.strptime(exp[:12], "%Y%m%d%H%M") <= now:
continue
except Exception:
pass
feats.append(dict(
type="Feature",
properties=dict(ph=d.get("PHENOM"), wfo=d.get("WFO"),
etn=d.get("ETN"), exp=exp),
geometry=sr.shape.__geo_interface__))
feats.sort(key=lambda f: (str(f["properties"]["wfo"] or ""),
f["properties"]["etn"] or 0))
# etag from the (expiry-filtered) content, NOT the raw zip — so the client
# redraws and clears a warning the moment it drops out of the valid set
etag = hashlib.md5(
json.dumps(feats, sort_keys=True).encode()).hexdigest()[:12]
out = dict(type="FeatureCollection",
etag=etag,
updated=now.isoformat() + "Z",
features=feats)
tmp = WARN_JSON + ".part"
with open(tmp, "w") as f:
json.dump(out, f)
os.replace(tmp, WARN_JSON)
def _warn_poller():
while True:
try:
_fetch_warnings()
except Exception:
pass
time_mod.sleep(60)
# small static assets that live in VOL_CACHE_DIR but must never be LRU-pruned
# (cities.json is copied once at startup and never re-touched, so without this
# guard it sorts oldest and gets evicted as radar volumes fill the 2 GiB cap)
_CACHE_KEEP = {"cities.json", "warnings.json", "sites.json"}
def _prune_vol_cache():
try:
files = [(os.path.getmtime(p), os.path.getsize(p), p)
for p in (os.path.join(VOL_CACHE_DIR, f)
for f in os.listdir(VOL_CACHE_DIR))
if os.path.basename(p) not in _CACHE_KEEP
and os.path.isfile(p)]
files.sort(reverse=True)
now = time_mod.time()
total = 0
for mt, sz, p in files:
total += sz
if total > VOL_CACHE_BYTES or (now - mt) > VOL_CACHE_MAX_AGE_S:
os.remove(p)
except Exception:
pass
YEARS = [str(y) for y in range(1991, dt.datetime.utcnow().year + 1)][::-1]
MONTHS = [f"{m:02d}" for m in range(1, 13)]
DAYS = [f"{d:02d}" for d in range(1, 32)]
HOURS = [f"{h:02d}:00" for h in range(24)]
# ----------------------------------------------------------------------------- s3 access
def _http_get(url, timeout=60):
req = urllib.request.Request(url, headers={"User-Agent": "nexrad-browser/1.0"})
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.read()
def list_hour_keys(site, date, hour):
"""Return (bucket, [keys]) for volumes starting in the given UTC hour."""
prefix = f"{date:%Y/%m/%d}/{site}/{site}{date:%Y%m%d}_{hour:02d}"
last_err = None
any_ok = False
for bucket in BUCKETS:
url = f"{bucket}/?list-type=2&prefix={urllib.request.quote(prefix)}&max-keys=200"
try:
xml = _http_get(url, timeout=30)
except Exception as e: # 403 / network — try next bucket
last_err = e
continue
ns = {"s3": "http://s3.amazonaws.com/doc/2006-03-01/"}
root = ET.fromstring(xml)
any_ok = True
keys = [k.text for k in root.findall(".//s3:Contents/s3:Key", ns)]
keys = [k for k in keys if not k.endswith("_MDM") and "NXL2" not in k]
if keys:
return bucket, sorted(keys)
if last_err and not any_ok:
raise RuntimeError(f"Could not list archive buckets: {last_err}")
return None, []
def download_volume(bucket, key, dest_dir):
path = os.path.join(dest_dir, os.path.basename(key))
if not os.path.exists(path):
data = _http_get(f"{bucket}/{urllib.request.quote(key)}", timeout=120)
with open(path, "wb") as f:
f.write(data)
return path
def _safe_download(bucket, key, dest_dir):
try:
download_volume(bucket, key, dest_dir)
except Exception:
pass
# ----------------------------------------------------------------------------- radar processing
def sweep_datetime(radar, sweep):
units = radar.time["units"] # "seconds since YYYY-MM-DDTHH:MM:SSZ"
m = re.search(r"since\s+([0-9T:\-]+)", units)
base = dt.datetime.fromisoformat(m.group(1).rstrip("Z"))
s = radar.sweep_start_ray_index["data"][sweep]
return base + dt.timedelta(seconds=float(radar.time["data"][s]))
def _scale_u8(data, mask, field_cfg):
"""float field -> uint8 (0 = no data, 1..255 spans vmin..vmax)."""
scaled = np.clip(
(data - field_cfg["vmin"]) / (field_cfg["vmax"] - field_cfg["vmin"]), 0, 1)
vals = (scaled * 254 + 1).astype(np.uint8)
vals[mask] = 0
return vals
# gates whose reflectivity is below this (or with no reflectivity return at
# all) are treated as "no return" and rendered fully transparent — applied to
# every field, so V/ZDR/CC noise outside echo disappears too
Z_MIN_DBZ = -30.0
def _noreturn_mask(z):
"""True where there is no usable return (Z missing or < Z_MIN_DBZ)."""
zf = np.ma.filled(np.ma.masked_invalid(z), -1e3)
return ~(zf >= Z_MIN_DBZ)
def _regrid_az(az, vals):
"""Gather rays onto a uniform azimuth grid (every bin takes its nearest
ray, so no empty spokes). Returns (grid, naz)."""
daz = np.median(np.abs(np.diff(np.unwrap(np.radians(az))))) * 180 / np.pi
naz = 720 if daz < 0.75 else 360
order = np.argsort(az)
az_s = az[order]
centers = (np.arange(naz) + 0.5) * (360.0 / naz)
idx = np.searchsorted(az_s, centers)
lo = (idx - 1) % len(az_s)
hi = idx % len(az_s)
d_lo = np.abs((centers - az_s[lo] + 180) % 360 - 180)
d_hi = np.abs((centers - az_s[hi] + 180) % 360 - 180)
nearest = np.where(d_lo <= d_hi, lo, hi)
return vals[order[nearest]], naz
def polar_frame(radar, sweep, field_cfg):
"""Pack one sweep onto a uniform azimuth grid -> gate-native frame dict.
The uint8 grid (rows=azimuth bins, cols=gates) is PNG-compressed
losslessly; 0 = no data, 1..255 spans [vmin, vmax].
"""
fname = field_cfg["pyart"]
data = radar.get_field(sweep, fname)
mask = np.ma.getmaskarray(data)
if mask.all():
return None
s_idx = radar.sweep_start_ray_index["data"][sweep]
e_idx = radar.sweep_end_ray_index["data"][sweep]
az = radar.azimuth["data"][s_idx:e_idx + 1]
rng = radar.range["data"]
dr = float(rng[1] - rng[0])
r0 = float(rng[0]) - dr / 2.0 # edge of first gate
ngates = data.shape[1]
try: # censor gates with no usable return (same-sweep reflectivity);
# skip when this sweep has no Z at all (legacy Doppler cuts)
z = radar.get_field(sweep, "reflectivity")
if not np.all(np.ma.getmaskarray(z)):
mask = mask | _noreturn_mask(z)
except Exception:
pass
vals = _scale_u8(np.ma.filled(data, field_cfg["vmin"]), mask, field_cfg)
grid, naz = _regrid_az(az, vals)
buf = io.BytesIO()
Image.fromarray(grid, mode="L").save(buf, format="WEBP",
lossless=True, method=4)
t = sweep_datetime(radar, sweep)
el = float(radar.fixed_angle["data"][sweep])
vcp = getattr(polar_frame, "_vcp", "")
vcp_part = f"{vcp} • " if vcp else ""
return dict(
img=base64.b64encode(buf.getvalue()).decode(),
naz=naz, ngates=ngates, r0=r0, dr=dr, el=el,
maxr=r0 + ngates * dr, time=t,
label=(f"{t:%Y-%m-%d %H:%M:%S}Z • {el:.1f}° • {vcp_part}"
f"sweep {sweep}{polar_frame._vol}"),
)
def colorbar_cfg(field_cfg, n=256):
"""Colormap stops + range; drawn client-side and used as the WebGL LUT."""
cm = plt.get_cmap(field_cfg["cmap"])
stops = [matplotlib.colors.to_hex(cm(i / (n - 1))) for i in range(n)]
unit = f" ({field_cfg['units']})" if field_cfg["units"] else ""
return dict(stops=stops, vmin=field_cfg["vmin"], vmax=field_cfg["vmax"],
tick=field_cfg["tick"], units=field_cfg["units"],
label=f"{field_cfg['label']}{unit}")
XR_FIELD = {"reflectivity": "DBZH", "velocity": "VRADH",
"differential_reflectivity": "ZDR",
"cross_correlation_ratio": "RHOHV"}
def _gunzip(path):
if not path.endswith(".gz"):
return path
out = path[:-3]
if not os.path.exists(out):
with gzip.open(path, "rb") as fi, open(out, "wb") as fo:
shutil.copyfileobj(fi, fo)
return out
def _np_dt(t64):
return dt.datetime.utcfromtimestamp(
int(np.datetime64(t64, "ns").astype("int64")) / 1e9)
def polar_frame_xr(ds, fvar, field_cfg, el, sweep_name, vol, vcp=""):
data = ds[fvar].values
finite = np.isfinite(data)
if not finite.any():
return None
az = ds["azimuth"].values
rng = ds["range"].values
dr = float(rng[1] - rng[0])
r0 = float(rng[0]) - dr / 2.0
ngates = data.shape[1]
nodata = ~finite
rsv = _reserved_mask(ds, fvar, data)
if rsv is not None: # below-threshold / range-folded codes
nodata = nodata | rsv
if "DBZH" in ds: # censor gates with no usable return
zdat = ds["DBZH"].values
zbad = _noreturn_mask(zdat)
zrsv = _reserved_mask(ds, "DBZH", zdat)
if zrsv is not None:
zbad = zbad | zrsv
nodata = nodata | zbad
vals = _scale_u8(np.nan_to_num(data, nan=field_cfg["vmin"]),
nodata, field_cfg)
grid, naz = _regrid_az(az, vals)
buf = io.BytesIO()
Image.fromarray(grid, mode="L").save(buf, format="WEBP",
lossless=True, method=4)
t = _np_dt(ds["time"].values.min())
vcp_part = f"{vcp} • " if vcp else ""
return dict(
img=base64.b64encode(buf.getvalue()).decode(),
naz=naz, ngates=ngates, r0=r0, dr=dr, el=el,
maxr=r0 + ngates * dr, time=t,
label=(f"{t:%Y-%m-%d %H:%M:%S}Z • {el:.1f}° • {vcp_part}"
f"{sweep_name}{vol}"),
)
def _reserved_mask(ds, fvar, data):
"""NEXRAD moment codes 0 (below threshold) and 1 (range folded) are
reserved, but xradar decodes them as physical values (no _FillValue in
its attrs). Mask anything at/below code 1 using the CF encoding."""
enc = getattr(ds[fvar], "encoding", {}) or {}
sf, ao = enc.get("scale_factor"), enc.get("add_offset")
if sf is None or ao is None:
return None
return data <= (ao + 1.5 * abs(sf))
def _dedup_doppler(cands, nyq_of, window_s=90):
"""Collapse MPDA-style multi-PRF bursts: within a cluster of Doppler
cuts closer than window_s, keep only the highest-Nyquist cut — but only
when the Nyquists actually differ (>2 m/s spread). Equal-Nyquist cuts
are SAILS/MRLE revisits and are all kept."""
out = []
cluster = []
def flush():
if not cluster:
return
nv = [nyq_of(s) for s in cluster]
if max(nv) - min(nv) > 2.0:
out.append(cluster[int(np.argmax(nv))])
else:
out.extend(cluster)
for s in cands: # cands are in scan order
if cluster and (s["t"] - cluster[0]["t"]).total_seconds() > window_s:
flush()
cluster = []
cluster.append(s)
flush()
return out
def _process_xradar(path, key, cfgs):
"""Read once with xradar (swnesbitt fork); render every requested field.
Returns ({field_name: [frames]}, site_ll)."""
dtree = xd.io.open_nexradlevel2_datatree(_gunzip(path))
return _tree_to_frames(dtree, os.path.basename(key), cfgs)
def process_chunks(paths, vol_label, cfgs):
"""Decode a live chunk list (S/I/E pieces of one volume) — the fork
assembles partial volumes; incomplete trailing sweeps are dropped."""
dtree = xd.io.open_nexradlevel2_datatree(list(paths),
incomplete_sweep="drop")
return _tree_to_frames(dtree, vol_label, cfgs)
def _tree_to_frames(dtree, vol, cfgs):
root = dtree.ds
site_ll = (float(root["latitude"].values), float(root["longitude"].values))
vcp = str(root.attrs.get("scan_name", "") or "")
dyn = str(root.attrs.get("dynamic_scan_type", "") or "")
if dyn and dyn.lower() not in ("none", "false", "", "standard"):
vcp = f"{vcp} ({dyn})" if vcp else dyn
sweeps = []
for name in sorted((c for c in dtree.children if c.startswith("sweep_")),
key=lambda n: int(n.split("_")[1])):
ds = dtree[name].ds
el = float(ds["sweep_fixed_angle"].values)
if el >= ELEV_MAX:
continue
has_v = "VRADH" in ds and bool(np.isfinite(ds["VRADH"].values).any())
sweeps.append(dict(name=name, ds=ds, el=el, has_v=has_v,
t=_np_dt(ds["time"].values.min())))
# show only the lowest-elevation cut: a site that scans below 0.5° (e.g.
# KBUF's 0.3° supplemental cut) shows just that, not it AND the 0.5° cut
if sweeps:
_lo = min(s["el"] for s in sweeps)
sweeps = [s for s in sweeps if s["el"] <= _lo + LOW_SWEEP_TOL]
out = {}
for fname, cfg in cfgs.items():
fvar = XR_FIELD[cfg["pyart"]]
cands = [s for s in sweeps
if fvar in s["ds"]
and bool(np.isfinite(s["ds"][fvar].values).any())]
if cfg["pyart"] != "velocity":
# surveillance (long-range) cuts only — never the shorter-range
# Doppler split cuts; fall back for merged-cut VCPs
surv = [s for s in cands if not s["has_v"]]
cands = surv or cands
else:
# MPDA (VCP 121) runs several near-simultaneous Doppler cuts at
# different PRFs: keep only the highest-Nyquist member of each
# cluster. With reserved codes masked, max |V| == the Nyquist.
def _est_nyq(s):
v = s["ds"][fvar].values
rsv = _reserved_mask(s["ds"], fvar, v)
if rsv is not None:
v = np.where(rsv, np.nan, v)
return float(np.nanmax(np.abs(v)))
cands = _dedup_doppler(cands, _est_nyq)
frames = []
for s in cands:
try:
fr = polar_frame_xr(s["ds"], fvar, cfg, s["el"],
s["name"], vol, vcp)
except Exception:
continue
if fr is not None:
frames.append(fr)
out[fname] = frames
del dtree
return out, site_ll
def dealias_volume(bucket, key, dest_dir):
"""Download a volume and region-dealias its low Doppler cuts
(xradar fork + Rust region_dealias; no Py-ART)."""
try:
path = download_volume(bucket, key, dest_dir)
except Exception:
return []
return dealias_volume_file(path, os.path.basename(key))
def process_volume(bucket, key, cfgs, dest_dir):
"""Download + read one volume with the xradar fork (sole reader).
Decodes every requested field in a single read; raw files stay cached."""
empty = {fn: [] for fn in cfgs}
try:
path = download_volume(bucket, key, dest_dir)
except Exception:
return empty, None
try:
return _process_xradar(path, key, cfgs)
except Exception:
return empty, None
# (Py-ART fallback reader removed — the xradar fork is the sole ingest path)
# ----------------------------------------------------------------------------- leaflet + webgl page
LEAFLET_PAGE = """<!DOCTYPE html>
<html><head>
<meta charset="utf-8"/>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@600;700&family=Source+Sans+3:wght@400;600;700&display=swap');
:root{--illini-orange:#FF5F05;--illini-blue:#13294B;--illini-blue-2:#1D3866;
--illini-blue-3:#25457F;--storm-light:#C8C6C7}
html,body{margin:0;height:100%;background:var(--illini-blue)}
#map{position:absolute;inset:0 0 86px 0}
#glcv{position:absolute;inset:0;pointer-events:none;z-index:400}
#bar{position:absolute;left:0;right:0;bottom:0;height:86px;
background:var(--illini-blue);border-top:4px solid var(--illini-orange);
color:#fff;font:13px 'Source Sans 3','Source Sans Pro',system-ui,sans-serif;
display:flex;flex-direction:column;justify-content:center;gap:6px;
padding:6px 14px;box-sizing:border-box}
#row1{display:flex;align-items:center;gap:10px}
#slider{flex:1;accent-color:var(--illini-orange)}
#op{accent-color:var(--illini-orange);width:90px}
#label{font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;color:var(--storm-light)}
button{background:var(--illini-orange);color:#fff;border:0;border-radius:4px;
padding:4px 12px;cursor:pointer;font-size:13px;
font-family:'Montserrat',sans-serif;font-weight:700}
button:hover{background:#E25504}
a{color:#FF8136}
#share{position:absolute;top:80px;left:10px;z-index:1000;width:34px;height:34px;
display:flex;align-items:center;justify-content:center;cursor:pointer;
background:var(--illini-blue-2);color:#fff;border:1px solid var(--illini-blue-3);
border-radius:4px;box-shadow:0 1px 5px rgba(0,0,0,.4)}
#share:hover{background:var(--illini-orange)}
#toast{position:absolute;top:88px;left:52px;z-index:1000;display:none;
background:var(--illini-blue);border:1px solid var(--illini-blue-3);
color:#fff;font:12px 'Source Sans 3',sans-serif;padding:4px 10px;
border-radius:4px}
#ovmenu{position:absolute;top:10px;right:10px;z-index:1000;
background:rgba(19,41,75,.92);border:1px solid var(--illini-blue-3);
border-top:4px solid var(--illini-orange);border-radius:4px;
padding:10px;color:#fff;font:12px 'Source Sans 3',sans-serif;
display:flex;flex-direction:column;gap:8px;width:118px}
#ovmenu b{font-family:'Montserrat',sans-serif;font-size:10px;
letter-spacing:.06em;text-transform:uppercase;opacity:.85}
#ovmenu label{display:flex;gap:6px;align-items:flex-start;cursor:pointer;
line-height:1.25}
#ovmenu input{accent-color:var(--illini-orange);margin-top:1px}
#cbar{position:absolute;top:50%;right:10px;transform:translateY(-50%);z-index:1000;
background:rgba(19,41,75,.92);border:1px solid var(--illini-blue-3);
border-top:4px solid var(--illini-orange);border-radius:4px;
padding:10px 8px;color:#fff;
font:10px 'Source Sans 3',system-ui,sans-serif;
display:flex;gap:6px;align-items:stretch}
#cblabel{writing-mode:vertical-rl;transform:rotate(180deg);text-align:center;
font-size:11px;opacity:.9}
#cbticks{position:relative;width:34px;margin:0}
#cbticks div{position:absolute;left:0;transform:translateY(-50%);
font-variant-numeric:tabular-nums}
</style></head><body>
<div id="map"></div>
<div id="share" title="Copy share link">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/>
<circle cx="18" cy="19" r="3"/>
<line x1="8.6" y1="10.5" x2="15.4" y2="6.5"/>
<line x1="8.6" y1="13.5" x2="15.4" y2="17.5"/>
</svg>
</div>
<div id="toast">Link copied</div>
<div id="cbar"><div id="cblabel"></div>
<canvas id="cbcanvas" width="16" height="320"></canvas>
<div id="cbticks"></div></div>
<div id="ovmenu"><b>Overlays</b>
<label><input type="checkbox" id="ck-counties" checked/><span>County boundaries</span></label>
<label><input type="checkbox" id="ck-interstates" checked/><span>Highways</span></label>
</div>
<div id="bar">
<div id="row1">
<button id="play">&#9654;</button>
<input id="slider" type="range" min="0" max="__MAXIDX__" value="0" step="1"/>
<span style="opacity:.7">opacity</span>
<input id="op" type="range" min="10" max="100" value="100"/>
</div>
<div id="label"></div>
</div>
<script>
const frames = __FRAMES__;
const cb = __CBAR__;
const SITE = [__SLAT__, __SLON__]; // deg
const SHARE_URL = "__SHARE__";
// ---------------------------------------------------------------- colorbar
(function(){
const cv = document.getElementById('cbcanvas'), ctx = cv.getContext('2d');
const g = ctx.createLinearGradient(0, cv.height, 0, 0);
cb.stops.forEach((c, i) => g.addColorStop(i / (cb.stops.length - 1), c));
ctx.fillStyle = g; ctx.fillRect(0, 0, cv.width, cv.height);
document.getElementById('cblabel').textContent = cb.label;
const ticks = document.getElementById('cbticks');
const t0 = Math.ceil(cb.vmin / cb.tick) * cb.tick;
for (let v = t0; v <= cb.vmax + 1e-9; v += cb.tick) {
const val = +v.toFixed(6);
const frac = (val - cb.vmin) / (cb.vmax - cb.vmin);
const d = document.createElement('div');
d.style.top = (100 - frac * 100) + '%';
d.textContent = '' + (cb.tick < 1 ? val.toFixed(1) : Math.round(val));
ticks.appendChild(d);
}
})();
// ---------------------------------------------------------------- leaflet
const map = L.map('map', {zoomControl: true}).setView(SITE, 8);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
{attribution: '&copy; OpenStreetMap &copy; CARTO', subdomains: 'abcd',
maxZoom: 14}).addTo(map);
L.circleMarker(SITE, {radius: 5, color: '#fff', weight: 2, fillColor: '#e33',
fillOpacity: 1}).addTo(map).bindTooltip('__SITE__');
// white ring at maximum unambiguous range of the current sweep
const ring = L.circle(SITE, {radius: frames.length ? frames[0].maxr : 300000,
color: '#fff', weight: 1.5, opacity: 0.9, fill: false,
interactive: false}).addTo(map);
// fit to max range of frame 0
(function(){
const mr = frames.length ? frames[0].maxr : 300000;
const dLat = mr / 111320, dLon = mr / (111320 * Math.cos(SITE[0] * Math.PI / 180));
map.fitBounds([[SITE[0] - dLat, SITE[1] - dLon], [SITE[0] + dLat, SITE[1] + dLon]],
{padding: [8, 8]});
})();
// ---------------------------------------------------------------- webgl layer
const glcv = document.createElement('canvas');
glcv.id = 'glcv';
document.getElementById('map').appendChild(glcv);
const gl = glcv.getContext('webgl', {premultipliedAlpha: false, alpha: true});
const VS = `
attribute vec2 aPos;
varying vec2 vUV;
void main(){ vUV = aPos * 0.5 + 0.5; gl_Position = vec4(aPos, 0., 1.); }`;
const FS = `
precision highp float;
varying vec2 vUV;
uniform sampler2D uData, uCmap;
uniform vec2 uMercMin, uMercMax; // SW / NE of canvas in mercator meters
uniform vec2 uSite; // lon0, lat0 (radians)
uniform float uEl, uR0, uDr, uNgates, uNaz, uMaxR, uOpacity;
const float R = 6378137.0;
const float RE = 6371000.0;
const float K = 1.3333333;
const float PI = 3.141592653589793;
void main(){
vec2 merc = mix(uMercMin, uMercMax, vUV);
float lat = 2.0 * atan(exp(merc.y / R)) - PI / 2.0;
float lon = merc.x / R;
float dLon = lon - uSite.x;
float lat0 = uSite.y;
// great-circle ground distance (haversine) and azimuth from site
float sdl = sin(dLon * 0.5), sdp = sin((lat - lat0) * 0.5);
float a = sdp * sdp + cos(lat0) * cos(lat) * sdl * sdl;
float s = 2.0 * RE * asin(min(1.0, sqrt(a)));
if (s > uMaxR * 1.05) discard;
float az = atan(sin(dLon) * cos(lat),
cos(lat0) * sin(lat) - sin(lat0) * cos(lat) * cos(dLon));
az = mod(degrees(az) + 360.0, 360.0);
// invert 4/3-earth beam model: find slant range r whose ground arc = s
float Rk = K * RE;
float r = s;
for (int i = 0; i < 3; i++) {
float h = sqrt(r * r + Rk * Rk + 2.0 * r * Rk * sin(uEl)) - Rk;
float sg = Rk * asin(clamp(r * cos(uEl) / (Rk + h), -1.0, 1.0));
r = r * (1.0 + (s - sg) / max(sg, 1.0));
}
float gate = (r - uR0) / uDr;
if (gate < 0.0 || gate >= uNgates) discard;
vec2 tc = vec2((floor(gate) + 0.5) / uNgates, (az / 360.0));
float v = texture2D(uData, tc).r;
if (v < 0.002) discard; // 0 = no data
vec3 col = texture2D(uCmap, vec2((v * 255.0 - 1.0) / 254.0, 0.5)).rgb;
gl_FragColor = vec4(col, uOpacity);
}`;
function shader(type, src){
const sh = gl.createShader(type);
gl.shaderSource(sh, src); gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS))
throw new Error(gl.getShaderInfoLog(sh));
return sh;
}
const prog = gl.createProgram();
gl.attachShader(prog, shader(gl.VERTEX_SHADER, VS));
gl.attachShader(prog, shader(gl.FRAGMENT_SHADER, FS));
gl.linkProgram(prog); gl.useProgram(prog);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER,
new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(prog, 'aPos');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
const U = {};
['uData','uCmap','uMercMin','uMercMax','uSite','uEl','uR0','uDr','uNgates',
'uNaz','uMaxR','uOpacity'].forEach(n => U[n] = gl.getUniformLocation(prog, n));
// colormap LUT texture
(function(){
const px = new Uint8Array(cb.stops.length * 4);
cb.stops.forEach((hex, i) => {
px[i*4] = parseInt(hex.slice(1,3),16);
px[i*4+1] = parseInt(hex.slice(3,5),16);
px[i*4+2] = parseInt(hex.slice(5,7),16);
px[i*4+3] = 255;
});
const t = gl.createTexture();
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, t);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, cb.stops.length, 1, 0,
gl.RGBA, gl.UNSIGNED_BYTE, px);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
})();
// per-frame polar textures, decoded lazily from data-URL PNGs
const texCache = new Array(frames.length).fill(null);
function frameTexture(i, cbk){
if (texCache[i]) { cbk(texCache[i]); return; }
const img = new (window.Image)();
img.onload = () => {
const t = gl.createTexture();
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, t);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, gl.LUMINANCE,
gl.UNSIGNED_BYTE, img);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
texCache[i] = t; cbk(t);
};
img.src = 'data:image/webp;base64,' + frames[i].img;
}
const PROJ = L.Projection.SphericalMercator;
let idx = -1, opacity = 1.0, raf = 0;
function draw(){
raf = 0;
if (idx < 0) return;
const f = frames[idx];
frameTexture(idx, (tex) => {
const sz = map.getSize(), dpr = window.devicePixelRatio || 1;
if (glcv.width !== sz.x * dpr || glcv.height !== sz.y * dpr) {
glcv.width = sz.x * dpr; glcv.height = sz.y * dpr;
glcv.style.width = sz.x + 'px'; glcv.style.height = sz.y + 'px';
}
gl.viewport(0, 0, glcv.width, glcv.height);
gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);
const sw = PROJ.project(map.containerPointToLatLng([0, sz.y]));
const ne = PROJ.project(map.containerPointToLatLng([sz.x, 0]));
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, tex);
gl.uniform1i(U.uData, 0); gl.uniform1i(U.uCmap, 1);
gl.uniform2f(U.uMercMin, sw.x, sw.y);
gl.uniform2f(U.uMercMax, ne.x, ne.y);
gl.uniform2f(U.uSite, SITE[1] * Math.PI / 180, SITE[0] * Math.PI / 180);
gl.uniform1f(U.uEl, f.el * Math.PI / 180);
gl.uniform1f(U.uR0, f.r0); gl.uniform1f(U.uDr, f.dr);
gl.uniform1f(U.uNgates, f.ngates); gl.uniform1f(U.uNaz, f.naz);
gl.uniform1f(U.uMaxR, f.maxr); gl.uniform1f(U.uOpacity, opacity);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
});
}
function requestDraw(){ if (!raf) raf = requestAnimationFrame(draw); }
map.on('move zoom zoomend moveend resize viewreset', requestDraw);
// ---------------------------------------------------------------- controls
const slider = document.getElementById('slider'),
label = document.getElementById('label'),
playBtn = document.getElementById('play'),
op = document.getElementById('op');
let playing = false, timer = null;
function show(i){
if (i === idx) return;
idx = i;
label.textContent = (i + 1) + '/' + frames.length + ' — ' + frames[i].label;
slider.value = i;
ring.setRadius(frames[i].maxr);
requestDraw();
}
slider.addEventListener('input', e => show(+e.target.value));
op.addEventListener('input', e => { opacity = e.target.value / 100; requestDraw(); });
playBtn.addEventListener('click', () => {
playing = !playing;
playBtn.innerHTML = playing ? '&#10074;&#10074;' : '&#9654;';
if (playing) timer = setInterval(() => show((idx + 1) % frames.length), 450);
else clearInterval(timer);
});
document.addEventListener('keydown', e => {
if (e.key === 'ArrowRight') show(Math.min(idx + 1, frames.length - 1));
if (e.key === 'ArrowLeft') show(Math.max(idx - 1, 0));
});
document.getElementById('share').addEventListener('click', () => {
const done = () => { const t = document.getElementById('toast');
t.style.display = 'block'; setTimeout(() => t.style.display = 'none', 1600); };
if (navigator.clipboard && navigator.clipboard.writeText)
navigator.clipboard.writeText(SHARE_URL).then(done,
() => window.prompt('Copy share link:', SHARE_URL));
else window.prompt('Copy share link:', SHARE_URL);
});
// ---------------------------------------------------------------- overlays
map.createPane('refpane');
map.getPane('refpane').style.zIndex = 450; // above the radar canvas
map.getPane('refpane').style.pointerEvents = 'none';
const COUNTY_URL =
'https://raw.githubusercontent.com/plotly/datasets/master/geojson-counties-fips.json';
let countyLayer = null, countyLoading = false;
const roadLayer = L.tileLayer(
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Transportation/MapServer/tile/{z}/{y}/{x}',
{pane: 'refpane', attribution: 'Esri', opacity: 0.9});
document.getElementById('ck-counties').addEventListener('change', e => {
const lbl = e.target.nextElementSibling;
if (e.target.checked) {
if (countyLayer) { countyLayer.addTo(map); return; }
if (countyLoading) return;
countyLoading = true; lbl.textContent = 'County boundaries…';
fetch(COUNTY_URL).then(r => r.json()).then(gj => {
countyLayer = L.geoJSON(gj, {pane: 'refpane', style:
{color: '#C8C6C7', weight: 0.6, opacity: 0.7, fill: false}});
if (document.getElementById('ck-counties').checked)
countyLayer.addTo(map);
lbl.textContent = 'County boundaries';
}).catch(() => { lbl.textContent = 'Counties (load failed)'; })
.finally(() => { countyLoading = false; });
} else if (countyLayer) map.removeLayer(countyLayer);
});
document.getElementById('ck-interstates').addEventListener('change', e => {
if (e.target.checked) roadLayer.addTo(map);
else map.removeLayer(roadLayer);
});
// preload all frame textures in the background
frames.forEach((_, i) => setTimeout(() => frameTexture(i, () => {}), 50 * i));
show(0);
</script></body></html>"""
def build_page(frames, cbar, site, slat, slon, share_url=""):
payload = json.dumps(
[dict(img=f["img"], naz=f["naz"], ngates=f["ngates"], r0=f["r0"],
dr=f["dr"], el=f["el"], maxr=f["maxr"], label=f["label"])
for f in frames]
)
page = (LEAFLET_PAGE
.replace("__FRAMES__", payload)
.replace("__CBAR__", json.dumps(cbar))
.replace("__MAXIDX__", str(len(frames) - 1))
.replace("__SLAT__", f"{slat:.5f}")
.replace("__SLON__", f"{slon:.5f}")
.replace("__SITE__", site)
.replace("__SHARE__", share_url))
return (f'<iframe allow="clipboard-write" '
f'style="width:100%;height:calc(100vh - 245px);'
f'min-height:420px;border:0;border-radius:4px" '
f'srcdoc="{html_mod.escape(page)}"></iframe>')
QUAD = "All fields (4-panel)"
ZVF = "Z+V (2-panel)"
QUAD_PAGE = """<!DOCTYPE html>
<html><head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@600;700&family=Source+Sans+3:wght@400;600;700&display=swap');
:root{--io:#FF5F05;--ib:#13294B;--ib2:#1D3866;--ib3:#25457F;--sl:#C8C6C7}
html,body{margin:0;height:100%;background:var(--ib)}
:root{--barh:86px}
#grid{position:absolute;inset:0 0 var(--barh) 0;display:grid;gap:4px;
grid-template-columns:1fr;grid-template-rows:1fr;
background:#0c1d38}
#grid.quad{grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr}
#grid.zv{grid-template-columns:1fr;grid-template-rows:1fr 1fr}
#grid.zv.zvlr{grid-template-columns:1fr 1fr;grid-template-rows:1fr}
.panel{position:relative;overflow:hidden}
.pmap{position:absolute;inset:0}
/* radar canvas lives in a Leaflet pane (z 250): tiles(200) < radar(250)
< ring/vectors(400) < markers(600) < county/road overlays(650) */
canvas.gl{pointer-events:none}
.chip{position:absolute;top:6px;left:6px;z-index:1000;color:#fff;
background:rgba(19,41,75,.92);border-left:3px solid var(--io);
padding:3px 9px;font:700 11px 'Montserrat',sans-serif;
letter-spacing:.04em;text-transform:uppercase;border-radius:2px}
.pcbar{position:absolute;top:50%;right:6px;transform:translateY(-50%);
z-index:1000;background:rgba(19,41,75,.92);
border:1px solid var(--ib3);border-top:3px solid var(--io);
border-radius:4px;padding:6px 5px;color:#fff;
font:9px 'Source Sans 3',sans-serif;display:flex;gap:4px}
.pcl{writing-mode:vertical-rl;transform:rotate(180deg);text-align:center;
font-size:9px;opacity:.9}
.pct{position:relative;width:26px}
.pct div{position:absolute;left:0;transform:translateY(-50%);
font-variant-numeric:tabular-nums}
#bar{position:absolute;left:0;right:0;bottom:0;height:var(--barh);
background:var(--ib);border-top:4px solid var(--io);color:#fff;
font:13px 'Source Sans 3',system-ui,sans-serif;display:flex;
flex-direction:column;justify-content:center;gap:6px;
padding:6px 14px;box-sizing:border-box}
#row1{display:flex;align-items:center;gap:10px;flex-wrap:nowrap}
#slider{flex:1;accent-color:var(--io)}
#op{accent-color:var(--io);width:90px}
#label{font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;color:var(--sl)}
button{background:var(--io);color:#fff;border:0;border-radius:4px;
padding:4px 12px;cursor:pointer;font-size:13px;
font-family:'Montserrat',sans-serif;font-weight:700}
button:hover{background:#E25504}
#refresh{font-size:16px;line-height:1;padding:4px 9px}
#refresh.busy{animation:spin .8s linear infinite;pointer-events:none;opacity:.7}
@keyframes spin{to{transform:rotate(360deg)}}
#share{position:absolute;top:116px;left:10px;z-index:1100;width:34px;
height:34px;display:flex;align-items:center;justify-content:center;
cursor:pointer;background:var(--ib2);color:#fff;
border:1px solid var(--ib3);border-radius:4px;
box-shadow:0 1px 5px rgba(0,0,0,.4)}
#share:hover{background:var(--io)}
#toast{position:absolute;top:124px;left:52px;z-index:1100;display:none;
background:var(--ib);border:1px solid var(--ib3);color:#fff;
font:12px 'Source Sans 3',sans-serif;padding:4px 10px;
border-radius:4px}
.ck{display:flex;gap:5px;align-items:center;cursor:pointer;
white-space:nowrap;color:var(--sl);font-size:12px}
.ck input{accent-color:var(--io)}
.nodata{position:absolute;inset:0;z-index:900;display:flex;
align-items:center;justify-content:center;text-align:center;
pointer-events:none;color:var(--sl);background:rgba(12,29,56,.45);
font:600 13px 'Montserrat',sans-serif;letter-spacing:.05em;
text-transform:uppercase}
#export{position:absolute;top:158px;left:10px;z-index:1100;width:34px;
height:34px;display:flex;align-items:center;justify-content:center;
cursor:pointer;background:var(--ib2);color:#fff;
border:1px solid var(--ib3);border-radius:4px;
box-shadow:0 1px 5px rgba(0,0,0,.4)}
#export:hover{background:var(--io)}
#sites{position:absolute;top:200px;left:10px;z-index:1100;width:34px;
height:34px;display:flex;align-items:center;justify-content:center;
cursor:pointer;background:var(--ib2);color:#fff;
border:1px solid var(--ib3);border-radius:4px;
box-shadow:0 1px 5px rgba(0,0,0,.4)}
#sites:hover{background:var(--io)}
#sites.act{background:var(--io);border-color:var(--io)}
.sitebox{width:46px;height:18px;line-height:18px;text-align:center;
background:#1a9a3a;color:#fff;font:bold 12px Arial,Helvetica,sans-serif;
border:1px solid #08381a;border-radius:3px;cursor:pointer;
box-shadow:0 1px 3px rgba(0,0,0,.55)}
.sitebox:hover{background:#26c455}
#exmenu{position:absolute;top:158px;left:52px;z-index:1100;display:none;
flex-direction:column;gap:6px;background:rgba(19,41,75,.95);
border:1px solid var(--ib3);border-top:4px solid var(--io);
border-radius:4px;padding:10px;color:#fff;
font:12px 'Source Sans 3',sans-serif}
#exmenu b{font-family:'Montserrat',sans-serif;font-size:10px;
letter-spacing:.06em;text-transform:uppercase;opacity:.85}
#exmenu button{text-align:left;font-family:'Source Sans 3',sans-serif;
font-weight:600;font-size:12px;padding:6px 10px}
@media (max-width:700px){
:root{--barh:132px}
#row1{flex-wrap:wrap;gap:8px;row-gap:6px}
#slider{flex:1 1 100%;order:10}
#modes button{padding:6px 10px;font-size:12px}
#play{padding:6px 14px}
.chip{font-size:9px;padding:2px 6px}
.pcbar{padding:4px 3px;gap:3px}
.pcbar canvas{height:120px;width:8px}
.panel.solo .pcbar canvas{height:150px}
.pct{width:22px}.pcl{font-size:8px}
#share,#export{width:42px;height:42px}
#share{top:104px}
#export{top:154px}
#exmenu{top:154px;left:60px}
#label{font-size:11px}
#bar{gap:4px;padding:6px 10px}
}
.leaflet-top.leaflet-left{top:30px} /* keep zoom clear of the field chip */
.panel{display:none}
.panel .leaflet-control-zoom{display:none}
.panel.zc .leaflet-control-zoom{display:block}
.panel.solo .pcbar canvas{height:300px}
#modes{display:flex;gap:4px;margin-left:4px}
#modes button{padding:3px 9px;font-size:11px;background:var(--ib2);
border:1px solid var(--ib3)}
#modes button.act{background:var(--io);border-color:var(--io)}
</style></head><body>
<div id="grid"></div>
<div id="share" title="Copy share link">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/>
<circle cx="18" cy="19" r="3"/>
<line x1="8.6" y1="10.5" x2="15.4" y2="6.5"/>
<line x1="8.6" y1="13.5" x2="15.4" y2="17.5"/>
</svg>
</div>
<div id="toast">Link copied</div>
<div id="export" title="Export image / loop">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>
</div>
<div id="sites" title="Show radar sites — click one to switch">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round">
<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3.5"/>
<line x1="12" y1="12" x2="19.5" y2="6.5"/>
</svg>
</div>
<div id="exmenu"><b>Export view</b>
<button data-k="still4k">Still &middot; 4K (3840&times;2160)</button>
<button data-k="stillig">Still &middot; Instagram reel (1080&times;1920)</button>
<button data-k="loop4k">Loop &middot; 4K movie</button>
<button data-k="loopig">Loop &middot; Instagram reel movie</button>
</div>
<div id="bar">
<div id="row1">
<button id="play">&#9654;</button>
<button id="refresh" title="Refresh now: load new scans + warnings"
style="display:none">&#x21bb;</button>
<span id="modes"></span>
<input id="slider" type="range" min="0" max="0" value="0" step="1"/>
<label class="ck"><input type="checkbox" id="ck-counties" checked/><span>Counties</span></label>
<label class="ck"><input type="checkbox" id="ck-interstates" checked/><span>Highways</span></label>
<label class="ck"><input type="checkbox" id="ck-dealias"/><span>Dealias V</span></label>
<span style="opacity:.7">opacity</span>
<input id="op" type="range" min="10" max="100" value="100"/>
</div>
<div id="label"></div>
</div>
<script>
const DATA = __DATA__;
const SITE = [__SLAT__, __SLON__];
const SITE_ID = "__SITE__";
// reflect the loaded radar site in the browser tab title (the tab shows the
// top document's title; the bundle is same-origin with the app page)
try { parent.document.title =
SITE_ID + " \\u00b7 NEXRAD Level 2 low level sweep browser"; }
catch (e) { try { document.title = SITE_ID; } catch (e2) {} }
const SHARE_BASE = "__SHAREBASE__";
const QUADF = "All fields (4-panel)";
const ZVF = "Z+V (2-panel)";
const INIT_VIEW = __VIEW__; // [lat, lon, zoom] from a share link, or null
const INIT_DEAL = __DEAL__; // start with dealiased velocity shown
const RT = __RT__; // real-time (trailing hour) mode
const WARN_URL = "__WARNURL__";
const CITIES_URL = "__CITIESURL__";
const SITES_URL = "__SITESURL__";
let SITES = null, sitesOn = false; // in-map radar-site picker overlay
const LIVE_URL = "__LIVEURL__"; // full live frame set (fetched on change)
const LIVE_TAG_URL = "__LIVETAGURL__"; // tiny etag sidecar (polled cheaply)
const DEALF = "Radial velocity (dealiased)";
let DEAL = DATA.find(d => d.name === DEALF) || null;
const PANEL_DATA = DATA.filter(d => d.name !== DEALF);
let RAWV = PANEL_DATA.find(d => d.name === 'Radial velocity') || null;
let mode = __MODE__;
let zvOrient = 'tb'; // 2x1 orientation: 'tb' (top/bottom) or 'lr' (left/right)
if (mode === DEALF) mode = 'Radial velocity';
if (mode !== 'quad' && mode !== 'zv' &&
!PANEL_DATA.some(fd => fd.name === mode))
mode = PANEL_DATA[0].name;
// 2x1 mode requires both reflectivity and velocity to be present
if (mode === 'zv' && !(PANEL_DATA.some(d => d.name === 'Reflectivity') &&
PANEL_DATA.some(d => d.name === 'Radial velocity')))
mode = PANEL_DATA[0].name;
const VS = `attribute vec2 aPos; varying vec2 vUV;
void main(){ vUV = aPos*0.5+0.5; gl_Position = vec4(aPos,0.,1.); }`;
const FS = `
precision highp float;
varying vec2 vUV;
uniform sampler2D uData, uCmap;
uniform vec2 uMercMin, uMercMax, uSite;
uniform float uEl, uR0, uDr, uNgates, uNaz, uMaxR, uOpacity;
const float R = 6378137.0; const float RE = 6371000.0;
const float K = 1.3333333; const float PI = 3.141592653589793;
void main(){
vec2 merc = mix(uMercMin, uMercMax, vUV);
float lat = 2.0*atan(exp(merc.y/R)) - PI/2.0;
float lon = merc.x/R;
float dLon = lon - uSite.x; float lat0 = uSite.y;
float sdl = sin(dLon*0.5), sdp = sin((lat-lat0)*0.5);
float a = sdp*sdp + cos(lat0)*cos(lat)*sdl*sdl;
float s = 2.0*RE*asin(min(1.0,sqrt(a)));
if (s > uMaxR*1.05) discard;
float az = atan(sin(dLon)*cos(lat),
cos(lat0)*sin(lat)-sin(lat0)*cos(lat)*cos(dLon));
az = mod(degrees(az)+360.0, 360.0);
float Rk = K*RE; float r = s;
for (int i=0;i<3;i++){
float h = sqrt(r*r+Rk*Rk+2.0*r*Rk*sin(uEl))-Rk;
float sg = Rk*asin(clamp(r*cos(uEl)/(Rk+h),-1.0,1.0));
r = r*(1.0+(s-sg)/max(sg,1.0));
}
float gate = (r-uR0)/uDr;
if (gate < 0.0 || gate >= uNgates) discard;
float v = texture2D(uData, vec2((floor(gate)+0.5)/uNgates, az/360.0)).r;
if (v < 0.002) discard;
vec3 col = texture2D(uCmap, vec2((v*255.0-1.0)/254.0, 0.5)).rgb;
gl_FragColor = vec4(col, uOpacity);
}`;
const PROJ = L.Projection.SphericalMercator;
let opacity = 1.0, idx = -1, playing = false, timer = null, syncing = false;
const panels = [];
function makePanel(fd, first){
const wrap = document.createElement('div'); wrap.className = 'panel';
document.getElementById('grid').appendChild(wrap);
const mdiv = document.createElement('div'); mdiv.className = 'pmap';
wrap.appendChild(mdiv);
const cv = document.createElement('canvas'); cv.className = 'gl';
const st = {fd: fd}; // swappable dataset (raw <-> dealiased velocity)
const chip = document.createElement('div'); chip.className = 'chip';
chip.textContent = fd.name; wrap.appendChild(chip);
if (!fd.frames.length) {
const nd = document.createElement('div'); nd.className = 'nodata';
nd.textContent = (fd.name.indexOf('reflectivity') > 0 ||
fd.name.indexOf('Correlation') === 0)
? 'Pre-polarimetric upgrade data' : 'No data for this hour';
wrap.appendChild(nd);
}
// mini colorbar (repaintable)
const cwrap = document.createElement('div'); cwrap.className = 'pcbar';
const cl = document.createElement('div'); cl.className = 'pcl';
const ccv = document.createElement('canvas'); ccv.width = 10; ccv.height = 180;
const ct = document.createElement('div'); ct.className = 'pct';
cwrap.appendChild(cl); cwrap.appendChild(ccv); cwrap.appendChild(ct);
wrap.appendChild(cwrap);
function paintCbar(cb){
cl.textContent = cb.label;
const cctx = ccv.getContext('2d');
cctx.clearRect(0,0,ccv.width,ccv.height);
const g = cctx.createLinearGradient(0, ccv.height, 0, 0);
cb.stops.forEach((c,i)=>g.addColorStop(i/(cb.stops.length-1), c));
cctx.fillStyle = g; cctx.fillRect(0,0,ccv.width,ccv.height);
ct.innerHTML = '';
const t0 = Math.ceil(cb.vmin/cb.tick)*cb.tick;
for (let v=t0; v<=cb.vmax+1e-9; v+=cb.tick){
const val = +v.toFixed(6);
const d = document.createElement('div');
d.style.top = (100-(val-cb.vmin)/(cb.vmax-cb.vmin)*100)+'%';
d.textContent = ''+(cb.tick<1?val.toFixed(1):Math.round(val));
ct.appendChild(d);
}
}
const cb = fd.cbar;
paintCbar(cb);
// map (zoom control on all; CSS shows it only on the first visible panel)
const map = L.map(mdiv, {center:SITE, zoom:7, zoomControl:true,
attributionControl:first});
// city labels pane (canvas) above overlays, below warnings
map.createPane('citypane');
map.getPane('citypane').style.zIndex = 655;
map.getPane('citypane').style.pointerEvents = 'none';
const cityCv = document.createElement('canvas');
map.getPane('citypane').appendChild(cityCv);
function cityDraw(){
if (!window.CITIES) return;
const sz = map.getSize(), dpr = window.devicePixelRatio || 1;
if (cityCv.width !== sz.x*dpr || cityCv.height !== sz.y*dpr){
cityCv.width = sz.x*dpr; cityCv.height = sz.y*dpr;
cityCv.style.width = sz.x+'px'; cityCv.style.height = sz.y+'px';
}
L.DomUtil.setPosition(cityCv, map.containerPointToLayerPoint([0,0]));
const g = cityCv.getContext('2d');
g.setTransform(dpr,0,0,dpr,0,0);
g.clearRect(0,0,sz.x,sz.y);
const z = map.getZoom(), b = map.getBounds();
const minPop = cityPopMin(z);
const placed = [];
let n = 0;
g.textBaseline = 'middle'; g.textAlign = 'left';
g.font = '600 12px Arial, Helvetica, sans-serif';
for (let i = 0; i < window.CITIES.length && n < 110; i++){
const c = window.CITIES[i]; // [name, lat, lon, pop]
if (c[3] < minPop) { if (minPop > 0) continue; }
if (c[1] < b.getSouth() || c[1] > b.getNorth() ||
c[2] < b.getWest() || c[2] > b.getEast()) continue;
const pt = map.latLngToContainerPoint([c[1], c[2]]);
let ok = true;
for (let j = 0; j < placed.length; j++){
if (Math.abs(pt.x-placed[j][0]) < 92 &&
Math.abs(pt.y-placed[j][1]) < 18){ ok = false; break; }
}
if (!ok) continue;
placed.push([pt.x, pt.y]); n++;
g.fillStyle = '#FFFFFF';
g.beginPath(); g.arc(pt.x, pt.y, 2.4, 0, 2*Math.PI); g.fill();
g.lineWidth = 3; g.strokeStyle = 'rgba(7,16,32,.85)';
g.strokeText(c[0], pt.x+6, pt.y);
g.fillText(c[0], pt.x+6, pt.y);
}
}
map.on('move zoom zoomend moveend resize viewreset', cityDraw);
// warnings pane sits above everything else
map.createPane('warnpane');
map.getPane('warnpane').style.zIndex = 660;
map.getPane('warnpane').style.pointerEvents = 'none';
// radar-site picker pane: clickable (pointer events on), above warnings
map.createPane('sitepane');
map.getPane('sitepane').style.zIndex = 670;
map.on('moveend zoomend', ()=>{ if (sitesOn) drawSites(); });
// radar canvas pane sits between basemap tiles and all vector panes
map.createPane('radarpane');
map.getPane('radarpane').style.zIndex = 250;
map.getPane('radarpane').style.pointerEvents = 'none';
map.getPane('radarpane').appendChild(cv);
// reference overlays (counties / roads) draw above everything
map.createPane('refpane');
map.getPane('refpane').style.zIndex = 650;
map.getPane('refpane').style.pointerEvents = 'none';
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
{attribution:'&copy; OSM &copy; CARTO', subdomains:'abcd', maxZoom:14})
.addTo(map);
L.circleMarker(SITE,{radius:4,color:'#fff',weight:2,fillColor:'#e33',
fillOpacity:1}).addTo(map).bindTooltip('__SITE__');
const ring = L.circle(SITE,{radius:fd.frames.length?fd.frames[0].maxr:3e5,
color:'#fff',weight:1.2,opacity:.9,fill:false,interactive:false})
.addTo(map);
// webgl
const gl = cv.getContext('webgl',{premultipliedAlpha:false,alpha:true});
function sh(t,src){ const s=gl.createShader(t); gl.shaderSource(s,src);
gl.compileShader(s); return s; }
const prog = gl.createProgram();
gl.attachShader(prog, sh(gl.VERTEX_SHADER,VS));
gl.attachShader(prog, sh(gl.FRAGMENT_SHADER,FS));
gl.linkProgram(prog); gl.useProgram(prog);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER,
new Float32Array([-1,-1,1,-1,-1,1,1,1]), gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(prog,'aPos');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos,2,gl.FLOAT,false,0,0);
const U = {};
['uData','uCmap','uMercMin','uMercMax','uSite','uEl','uR0','uDr','uNgates',
'uNaz','uMaxR','uOpacity'].forEach(n=>U[n]=gl.getUniformLocation(prog,n));
const px = new Uint8Array(cb.stops.length*4);
cb.stops.forEach((hex,i)=>{px[i*4]=parseInt(hex.slice(1,3),16);
px[i*4+1]=parseInt(hex.slice(3,5),16);
px[i*4+2]=parseInt(hex.slice(5,7),16); px[i*4+3]=255;});
const ct2 = gl.createTexture();
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, ct2);
gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,cb.stops.length,1,0,gl.RGBA,
gl.UNSIGNED_BYTE,px);
['TEXTURE_MIN_FILTER','TEXTURE_MAG_FILTER'].forEach(p=>
gl.texParameteri(gl.TEXTURE_2D, gl[p], gl.LINEAR));
['TEXTURE_WRAP_S','TEXTURE_WRAP_T'].forEach(p=>
gl.texParameteri(gl.TEXTURE_2D, gl[p], gl.CLAMP_TO_EDGE));
let texCache = {};
function texFor(i, cbk){
if (texCache[i]) { cbk(texCache[i]); return; }
const img = new (window.Image)();
img.onload = ()=>{
const t = gl.createTexture();
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,t);
gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE,gl.LUMINANCE,
gl.UNSIGNED_BYTE,img);
['TEXTURE_MIN_FILTER','TEXTURE_MAG_FILTER'].forEach(p=>
gl.texParameteri(gl.TEXTURE_2D, gl[p], gl.NEAREST));
['TEXTURE_WRAP_S','TEXTURE_WRAP_T'].forEach(p=>
gl.texParameteri(gl.TEXTURE_2D, gl[p], gl.CLAMP_TO_EDGE));
texCache[i] = t; cbk(t);
};
img.src = 'data:image/webp;base64,' + st.fd.frames[i].img;
}
let raf = 0;
function draw(){
raf = 0;
const pi = Math.min(idx, st.fd.frames.length-1);
if (pi < 0) return;
const f = st.fd.frames[pi];
texFor(pi, (tex)=>{
const sz = map.getSize(), dpr = window.devicePixelRatio||1;
if (cv.width!==sz.x*dpr||cv.height!==sz.y*dpr){
cv.width=sz.x*dpr; cv.height=sz.y*dpr;
cv.style.width=sz.x+'px'; cv.style.height=sz.y+'px';
}
// counter the map-pane's pan transform so the canvas stays
// screen-aligned while living inside the pane stack
L.DomUtil.setPosition(cv, map.containerPointToLayerPoint([0,0]));
gl.viewport(0,0,cv.width,cv.height);
gl.clearColor(0,0,0,0); gl.clear(gl.COLOR_BUFFER_BIT);
const sw = PROJ.project(map.containerPointToLatLng([0,sz.y]));
const ne = PROJ.project(map.containerPointToLatLng([sz.x,0]));
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,tex);
gl.uniform1i(U.uData,0); gl.uniform1i(U.uCmap,1);
gl.uniform2f(U.uMercMin,sw.x,sw.y); gl.uniform2f(U.uMercMax,ne.x,ne.y);
gl.uniform2f(U.uSite,SITE[1]*Math.PI/180,SITE[0]*Math.PI/180);
gl.uniform1f(U.uEl,f.el*Math.PI/180);
gl.uniform1f(U.uR0,f.r0); gl.uniform1f(U.uDr,f.dr);
gl.uniform1f(U.uNgates,f.ngates); gl.uniform1f(U.uNaz,f.naz);
gl.uniform1f(U.uMaxR,f.maxr); gl.uniform1f(U.uOpacity,opacity);
gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA);
gl.drawArrays(gl.TRIANGLE_STRIP,0,4);
});
}
function requestDraw(){ if(!raf) raf=requestAnimationFrame(draw); }
map.on('move zoom zoomend moveend resize viewreset', requestDraw);
function setData(nf){
st.fd = nf; texCache = {};
chip.textContent = nf.name;
paintCbar(nf.cbar);
const f = nf.frames[Math.min(Math.max(idx,0), nf.frames.length-1)];
if (f) ring.setRadius(f.maxr);
if (idx >= 0) refreshLabel(idx);
requestDraw();
}
return {map, get fd(){ return st.fd; }, name: fd.name, ring,
requestDraw, texFor, wrap, setData, cityDraw};
}
PANEL_DATA.forEach((fd,i)=>panels.push(makePanel(fd, i===0)));
function cityPopMin(z){
if (z >= 12) return 0;
if (z >= 11) return 2000;
if (z >= 10) return 6000;
if (z >= 9) return 15000;
if (z >= 8) return 40000;
if (z >= 7) return 90000;
if (z >= 6) return 250000;
return 600000;
}
fetch(CITIES_URL).then(r=>r.json()).then(arr=>{
window.CITIES = arr;
panels.forEach(p=>p.cityDraw());
}).catch(()=>{});
// view sync
panels.forEach(p=>{
p.map.on('move', ()=>{
if (syncing) return;
syncing = true;
const c = p.map.getCenter(), z = p.map.getZoom();
panels.forEach(o=>{ if(o!==p) o.map.setView(c,z,{animate:false}); });
syncing = false;
});
});
// view-mode switcher: every field's textures live in this one page, so
// toggling Z/V/ZDR/CC/2x2 is instant — no reload, no refetch
const SHORT = {'Reflectivity':'Z','Radial velocity':'V',
'Radial velocity (dealiased)':'V\\u2020',
'Differential reflectivity':'ZDR','Correlation coefficient':'CC'};
const modesEl = document.getElementById('modes');
function mkBtn(txt, m){
const b = document.createElement('button');
b.textContent = txt; b.dataset.m = m;
b.onclick = ()=>setMode(m);
modesEl.appendChild(b);
}
PANEL_DATA.forEach(fd=>mkBtn(SHORT[fd.name]||fd.name, fd.name));
const HAS_ZV = PANEL_DATA.some(d=>d.name==='Reflectivity') &&
PANEL_DATA.some(d=>d.name==='Radial velocity');
if (HAS_ZV) mkBtn('Z+V', 'zv');
if (PANEL_DATA.length > 1) mkBtn('2\\u00d72', 'quad');
// the two fields shown in 2x1 mode (match on stable panel name, not fd.name,
// so a dealiased velocity panel still counts as the velocity panel)
const ZV_SET = {'Reflectivity':1, 'Radial velocity':1};
function setMode(m){
// clicking Z+V while already in 2x1 flips its orientation (tb <-> lr)
if (m === 'zv' && mode === 'zv') zvOrient = (zvOrient === 'tb' ? 'lr' : 'tb');
mode = m;
const grid = document.getElementById('grid');
let zcDone = false;
panels.forEach(p=>{
const vis = (m === 'quad') || (m === 'zv' && ZV_SET[p.name]) ||
(p.fd.name === m);
p.wrap.style.display = vis ? 'block' : 'none';
p.wrap.classList.toggle('solo', vis && m !== 'quad' && m !== 'zv');
const zc = vis && !zcDone;
p.wrap.classList.toggle('zc', zc);
if (zc) zcDone = true;
});
grid.classList.toggle('quad', m === 'quad');
grid.classList.toggle('zv', m === 'zv');
grid.classList.toggle('zvlr', m === 'zv' && zvOrient === 'lr');
document.querySelectorAll('#modes button').forEach(b=>
b.classList.toggle('act', b.dataset.m === m));
setTimeout(()=>{
panels.forEach(p=>{ p.map.invalidateSize(false); p.requestDraw(); });
if (idx >= 0) refreshLabel(idx);
if (sitesOn) drawSites();
}, 60);
}
// initial view
setMode(mode);
setTimeout(function(){
panels.forEach(p=>p.map.invalidateSize(false));
const vis = panels.find(p=>p.wrap.style.display !== 'none') || panels[0];
if (INIT_VIEW && INIT_VIEW.length === 3) {
vis.map.setView([INIT_VIEW[0], INIT_VIEW[1]], INIT_VIEW[2]);
return;
}
vis.map.setView(SITE, 8); // default zoom on load
}, 90);
// controls
let nmax = Math.max.apply(null, PANEL_DATA.map(fd=>fd.frames.length));
const slider = document.getElementById('slider');
slider.max = nmax-1;
const label = document.getElementById('label'),
playBtn = document.getElementById('play'),
op = document.getElementById('op');
function refreshLabel(i){
const ap = panels.find(p=>p.wrap.style.display !== 'none') || panels[0];
const f = ap.fd.frames[Math.min(i, ap.fd.frames.length-1)];
label.textContent = (i+1)+'/'+nmax+' — '+(f ? f.label : '');
}
function show(i){
if (i===idx) return;
idx = i;
slider.value = i;
refreshLabel(i);
panels.forEach(p=>{
const f = p.fd.frames[Math.min(i, p.fd.frames.length-1)];
if (f) p.ring.setRadius(f.maxr);
p.requestDraw();
});
}
slider.addEventListener('input', e=>show(+e.target.value));
op.addEventListener('input', e=>{opacity=e.target.value/100;
panels.forEach(p=>p.requestDraw());});
const FRAME_MS = 450, END_DWELL = 4; // hold the last frame 4x before looping
function playStep(){
const next = (idx+1) % nmax;
show(next);
timer = setTimeout(playStep,
FRAME_MS * (next === nmax-1 ? END_DWELL : 1));
}
playBtn.addEventListener('click', ()=>{
playing=!playing;
playBtn.innerHTML = playing?'&#10074;&#10074;':'&#9654;';
if(playing) timer = setTimeout(playStep,
FRAME_MS * (idx === nmax-1 ? END_DWELL : 1));
else clearTimeout(timer);
});
document.addEventListener('keydown', e=>{
if(e.key==='ArrowRight') show(Math.min(idx+1,nmax-1));
if(e.key==='ArrowLeft') show(Math.max(idx-1,0));
});
document.getElementById('share').addEventListener('click', ()=>{
const vis = panels.find(p=>p.wrap.style.display !== 'none') || panels[0];
const c = vis.map.getCenter(), z = vis.map.getZoom();
let url = SHARE_BASE + '&field=' +
encodeURIComponent(mode === 'quad' ? QUADF : (mode === 'zv' ? ZVF : mode)) +
'&lat=' + c.lat.toFixed(4) + '&lon=' + c.lng.toFixed(4) + '&zoom=' + z;
const ckd = document.getElementById('ck-dealias');
if (ckd && ckd.checked) url += '&dealias=1';
// SHARE_BASE is a bare query (?...) when the server didn't know the Space
// host (always so in live mode) — prepend the app's origin+path so the
// copied link is a full, openable URL
if (!/^https?:/i.test(url)){
let base = '';
try { base = parent.location.origin + parent.location.pathname; }
catch (e) { base = location.origin + location.pathname; }
url = base + url;
}
const done=()=>{const t=document.getElementById('toast');
t.style.display='block'; setTimeout(()=>t.style.display='none',1600);};
if(navigator.clipboard&&navigator.clipboard.writeText)
navigator.clipboard.writeText(url).then(done,
()=>window.prompt('Copy share link:',url));
else window.prompt('Copy share link:',url);
});
// ---- radar-site picker overlay ----------------------------------------
// green clickable boxes (Arial bold 4-letter ID) at each NEXRAD site on the
// current view; clicking one loads that radar, keeping the current field/
// layout and mode (live -> latest live; archive -> same hour as loaded).
function siteNavURL(id){
let u = SHARE_BASE.replace(/([?&]site=)[^&]*/i, '$1' + id);
if (!/[?&]site=/i.test(u)) u += (u.indexOf('?')>=0?'&':'?') + 'site=' + id;
u += '&field=' +
encodeURIComponent(mode === 'quad' ? QUADF : (mode === 'zv' ? ZVF : mode));
const ckd = document.getElementById('ck-dealias');
if (ckd && ckd.checked) u += '&dealias=1';
if (!/^https?:/i.test(u)){
let base = '';
try { base = parent.location.origin + parent.location.pathname; }
catch (e) { base = location.origin + location.pathname; }
u = base + u;
}
return u;
}
function clearSites(){
panels.forEach(p=>{ if (p._siteLayer){ p._siteLayer.remove(); p._siteLayer = null; } });
}
function drawSites(){
clearSites();
if (!sitesOn || !SITES) return;
panels.forEach(p=>{
if (p.wrap.style.display === 'none') return;
const b = p.map.getBounds(), placed = [], grp = L.layerGroup();
for (let i = 0; i < SITES.length; i++){
const id = SITES[i][0], la = SITES[i][1], lo = SITES[i][2];
if (la < b.getSouth() || la > b.getNorth() ||
lo < b.getWest() || lo > b.getEast()) continue;
const pt = p.map.latLngToContainerPoint([la, lo]);
let ok = true;
for (let j = 0; j < placed.length; j++)
if (Math.abs(pt.x-placed[j][0]) < 50 && Math.abs(pt.y-placed[j][1]) < 20){ ok = false; break; }
if (!ok) continue;
placed.push([pt.x, pt.y]);
const icon = L.divIcon({className:'', html:'<div class="sitebox">'+id+'</div>',
iconSize:[46,18], iconAnchor:[23,9]});
const m = L.marker([la, lo], {icon, pane:'sitepane', keyboard:false, title:id});
m.on('click', ()=>{
const payload = id + '\\t' +
(mode==='quad' ? QUADF : (mode==='zv' ? ZVF : mode));
try {
// switch radar in place so the load progress bar shows on the map
const host = parent.document.getElementById('site-jump');
const ta = host.querySelector('textarea, input');
const set = Object.getOwnPropertyDescriptor(ta.__proto__, 'value').set;
set.call(ta, payload);
ta.dispatchEvent(new Event('input', {bubbles:true}));
sitesOn = false;
document.getElementById('sites').classList.remove('act');
clearSites();
setTimeout(()=>{ const gb = parent.document.getElementById('site-go');
(gb.querySelector('button') || gb).click(); }, 60);
} catch (e) {
const u = siteNavURL(id);
try { parent.location.href = u; } catch (e2) { location.href = u; }
}
});
m.addTo(grp);
}
grp.addTo(p.map);
p._siteLayer = grp;
});
}
fetch(SITES_URL).then(r=>r.json()).then(a=>{ SITES = a;
if (sitesOn) drawSites(); }).catch(()=>{});
document.getElementById('sites').addEventListener('click', ()=>{
sitesOn = !sitesOn;
document.getElementById('sites').classList.toggle('act', sitesOn);
drawSites();
});
// overlays applied to all panels
const COUNTY_URL =
'https://raw.githubusercontent.com/plotly/datasets/master/geojson-counties-fips.json';
let countyLayers = null, countyLoading = false, countyGJ = null;
const roadLayers = panels.map(p=>L.tileLayer(
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Transportation/MapServer/tile/{z}/{y}/{x}',
{attribution:'Esri', opacity:1.0, pane:'refpane'}));
document.getElementById('ck-counties').addEventListener('change', e=>{
const lbl = e.target.nextElementSibling;
if (e.target.checked){
if (countyLayers){ countyLayers.forEach((l,i)=>l.addTo(panels[i].map));
return; }
if (countyLoading) return;
countyLoading = true; lbl.textContent='Counties…';
fetch(COUNTY_URL).then(r=>r.json()).then(gj=>{
countyGJ = gj;
countyLayers = panels.map(()=>L.geoJSON(gj,{pane:'refpane',style:
{color:'#C8C6C7',weight:0.7,opacity:1.0,fill:false}}));
if (document.getElementById('ck-counties').checked)
countyLayers.forEach((l,i)=>l.addTo(panels[i].map));
lbl.textContent='Counties';
}).catch(()=>{lbl.textContent='Counties (load failed)';})
.finally(()=>{countyLoading=false;});
} else if (countyLayers)
countyLayers.forEach((l,i)=>panels[i].map.removeLayer(l));
});
document.getElementById('ck-interstates').addEventListener('change', e=>{
if (e.target.checked) roadLayers.forEach((l,i)=>l.addTo(panels[i].map));
else roadLayers.forEach((l,i)=>panels[i].map.removeLayer(l));
});
// both overlays start enabled
document.getElementById('ck-counties').dispatchEvent(new Event('change'));
document.getElementById('ck-interstates').dispatchEvent(new Event('change'));
// dealias toggle: swap the velocity panel between raw and dealiased.
// If this hour hasn't been dealiased server-side yet, click the hidden
// Gradio trigger in the parent page to reprocess (page will update).
const ckDeal = document.getElementById('ck-dealias');
const vPanel = panels.find(p=>p.name === 'Radial velocity') || null;
if (!vPanel || !RAWV || !RAWV.frames.length)
ckDeal.parentElement.style.display = 'none';
function applyDeal(on){
if (!vPanel) return;
if (on){
if (DEAL && DEAL.frames.length){
vPanel.setData(DEAL);
} else {
exToast('Reprocessing hour with region-based dealiasing\\u2026', true);
try {
// adding the dealiased field rebuilds the iframe; hand the current
// frame to the new bundle (via the shared parent) so it doesn't reset
parent.__restoreIdx = idx;
const host = parent.document.getElementById('dax-trigger');
(host.querySelector('button') || host).click();
} catch (err) {
exToast('Could not start dealiasing'); ckDeal.checked = false;
}
}
} else if (vPanel.fd.name === DEALF){
vPanel.setData(RAWV);
}
}
ckDeal.addEventListener('change', e=>applyDeal(e.target.checked));
if (INIT_DEAL && DEAL && DEAL.frames.length){
ckDeal.checked = true; applyDeal(true);
}
// ------------------------------------------------------------- real-time
if (RT){
// Live updates merge new frames IN PLACE — no iframe reload. The old design
// reloaded the whole ~40MB bundle every tick (parent rt-refresh click /
// server Timer), blanking the map ("grey-out"). Now we poll a tiny etag
// sidecar; only when it changes do we fetch the full frame set and swap
// each panel's data via setData, preserving pan/zoom/playback/mode.
let liveTag = __LIVETAG0__;
async function fetchTag(){
try {
const r = await fetch(LIVE_TAG_URL + '?t=' + Date.now(), {cache:'no-store'});
if (r.ok) return await r.json(); // {etag, ts}
} catch (err) {}
return null;
}
function applyLive(j){
if (!j || !j.fields) return;
const byName = {};
j.fields.forEach(fd=>{ byName[fd.name] = fd; });
if (byName[DEALF]) DEAL = byName[DEALF];
if (byName['Radial velocity']) RAWV = byName['Radial velocity'];
const oldNmax = nmax;
panels.forEach(p=>{
// a velocity panel currently showing dealiased data keeps showing it
if (p.name === 'Radial velocity' && p.fd.name === DEALF){
if (DEAL && DEAL.frames.length) p.setData(DEAL);
return;
}
const nf = byName[p.name];
if (nf) p.setData(nf);
});
nmax = Math.max.apply(null, panels.map(p=>p.fd.frames.length));
slider.max = Math.max(0, nmax-1);
if (idx >= oldNmax-1 && nmax-1 !== idx) show(nmax-1); // follow newest
else { if (idx>=0) refreshLabel(idx); panels.forEach(p=>p.requestDraw()); }
}
async function pollLive(){
const t = await fetchTag();
if (!t || t.etag === liveTag) return false;
try {
const r2 = await fetch(LIVE_URL + '?t=' + Date.now(), {cache:'no-store'});
if (!r2.ok) return false;
const j = await r2.json();
if (j && j.etag){ liveTag = j.etag; applyLive(j); return true; }
} catch (err) {}
return false;
}
setInterval(pollLive, 30 * 1000);
// poll current TOR/SVR warnings every minute; redraw on change
let warnTag = null, warnLayers = [];
async function pollWarnings(){
try {
const r = await fetch(WARN_URL + '?t=' + Date.now(),
{cache: 'no-store'});
if (!r.ok) return;
const j = await r.json();
if (j.etag === warnTag) return;
warnTag = j.etag;
window.WARN_GJ = j; // stash for the export compositor
warnLayers.forEach(l=>l.remove());
// backup guard: hide any warning already past its UTC expiry
// (YYYYMMDDHHMM, fixed-width so string compare is safe)
const nd = new Date(), pad = n => String(n).padStart(2, '0');
const nowZ = '' + nd.getUTCFullYear() + pad(nd.getUTCMonth()+1) +
pad(nd.getUTCDate()) + pad(nd.getUTCHours()) + pad(nd.getUTCMinutes());
const WARN_W = 2.5, WARN_BUF = 0.25 * (96/72); // 0.25pt black buffer
const keep = f=> !f.properties.exp || f.properties.exp > nowZ;
warnLayers = [];
panels.forEach(p=>{
// black casing first (drawn under), colored warning line on top
const casing = L.geoJSON(j, {pane: 'warnpane', filter: keep,
style: ()=>({color:'#000', weight: WARN_W + 2*WARN_BUF,
opacity: 1, fill: false})});
const line = L.geoJSON(j, {pane: 'warnpane', filter: keep,
style: f=>({color: warnColor(f.properties.ph),
weight: WARN_W, opacity: 1, fill: false})});
casing.addTo(p.map); line.addTo(p.map);
warnLayers.push(casing, line);
});
} catch (err) {}
}
pollWarnings();
setInterval(pollWarnings, 60 * 1000);
// forced-refresh button: warnings update instantly; ask the server to
// re-decode the live hour now (picks up any new scans), then fast-poll the
// frame set until it lands — all in place, no iframe reload
const refreshBtn = document.getElementById('refresh');
if (refreshBtn){
refreshBtn.style.display = '';
let refreshing = false;
refreshBtn.addEventListener('click', async ()=>{
if (refreshing) return;
refreshing = true; refreshBtn.classList.add('busy');
pollWarnings();
const base = await fetchTag();
const baseTs = base && base.ts ? base.ts : 0;
try {
const h = parent.document.getElementById('rt-force');
(h.querySelector('button') || h).click();
} catch (err) {}
const t0 = Date.now();
while (Date.now() - t0 < 15000){
await new Promise(r=>setTimeout(r, 500));
const t = await fetchTag();
if (!t) continue;
if (t.etag !== liveTag){ await pollLive(); break; } // new scans merged
if (baseTs && t.ts && t.ts > baseTs) break; // server done, no change
}
pollWarnings();
refreshBtn.classList.remove('busy'); refreshing = false;
});
}
}
// ---------------------------------------------------------------- export
function activePanel(){
return panels.find(p=>p.wrap.style.display!=='none') || panels[0];
}
function exToast(t, sticky){
const el = document.getElementById('toast');
el.textContent = t; el.style.display = 'block';
clearTimeout(exToast._t);
if (!sticky) exToast._t = setTimeout(()=>{
el.style.display='none'; el.textContent='Link copied';}, 2600);
}
function loadImg(src, cors){
return new Promise((res, rej)=>{
const im = new (window.Image)();
if (cors) im.crossOrigin = 'anonymous';
im.onload = ()=>res(im);
im.onerror = ()=>rej(new Error('image load failed'));
im.src = src;
});
}
const MERC_R = 6378137, MERC_FULL = 2*Math.PI*MERC_R;
function mercX(lon){ return MERC_R*lon*Math.PI/180; }
function mercY(lat){ return MERC_R*Math.log(Math.tan(Math.PI/4+lat*Math.PI/360)); }
async function drawTiles(ctx, tpl, win, w, subs){
const mpp = (win.x1-win.x0)/w;
let z = Math.round(Math.log2(MERC_FULL/(256*mpp)));
z = Math.max(2, Math.min(18, z));
const n = Math.pow(2, z), ts = MERC_FULL/n;
const tx0 = Math.floor((win.x0+MERC_FULL/2)/ts),
tx1 = Math.floor((win.x1+MERC_FULL/2)/ts),
ty0 = Math.floor((MERC_FULL/2-win.y1)/ts),
ty1 = Math.floor((MERC_FULL/2-win.y0)/ts);
const jobs = [];
for (let tx=tx0; tx<=tx1; tx++) for (let ty=ty0; ty<=ty1; ty++){
if (ty<0 || ty>=n) continue;
const xm = ((tx%n)+n)%n;
const url = tpl.replace('{z}',z).replace('{x}',xm).replace('{y}',ty)
.replace('{s}', subs ? subs[(tx+ty)%subs.length] : '');
const px = (tx*ts-MERC_FULL/2-win.x0)/mpp;
const py = (win.y1-(MERC_FULL/2-ty*ts))/mpp;
jobs.push(loadImg(url, true)
.then(im=>ctx.drawImage(im, px, py, ts/mpp+0.6, ts/mpp+0.6))
.catch(()=>{}));
}
await Promise.all(jobs);
}
function drawCitiesExport(ctx, win, w, h){
if (!window.CITIES) return;
const mpp = (win.x1-win.x0)/w;
const zEff = Math.round(Math.log2(MERC_FULL/(256*mpp)));
const minPop = cityPopMin(zEff);
const s = Math.min(w, h);
const fs = Math.max(12, Math.round(s*0.016));
ctx.save();
ctx.font = '600 '+fs+'px Arial, Helvetica, sans-serif';
ctx.textBaseline = 'middle'; ctx.textAlign = 'left';
const placed = [];
let n = 0;
for (let i = 0; i < window.CITIES.length && n < 120; i++){
const c = window.CITIES[i];
if (c[3] < minPop && minPop > 0) continue;
const mx = mercX(c[2]), my = mercY(c[1]);
if (mx < win.x0 || mx > win.x1 || my < win.y0 || my > win.y1) continue;
const px = (mx-win.x0)/mpp, py = (win.y1-my)/mpp;
let ok = true;
for (let j = 0; j < placed.length; j++){
if (Math.abs(px-placed[j][0]) < fs*8 &&
Math.abs(py-placed[j][1]) < fs*1.6){ ok = false; break; }
}
if (!ok) continue;
placed.push([px, py]); n++;
ctx.fillStyle = '#FFFFFF';
ctx.beginPath(); ctx.arc(px, py, fs*0.2, 0, 2*Math.PI); ctx.fill();
ctx.lineWidth = fs*0.25; ctx.strokeStyle = 'rgba(7,16,32,.85)';
ctx.strokeText(c[0], px+fs*0.5, py);
ctx.fillText(c[0], px+fs*0.5, py);
}
ctx.restore();
}
function drawCounties(ctx, win, w){
if (!countyGJ) return;
const mpp = (win.x1-win.x0)/w;
ctx.save();
ctx.strokeStyle = '#C8C6C7'; ctx.globalAlpha = 0.9;
ctx.lineWidth = Math.max(1, w/2200);
const drawRing = ring=>{
ctx.beginPath();
ring.forEach((pt,i)=>{
const x = (mercX(pt[0])-win.x0)/mpp, y = (win.y1-mercY(pt[1]))/mpp;
if (i) ctx.lineTo(x,y); else ctx.moveTo(x,y);
});
ctx.stroke();
};
countyGJ.features.forEach(f=>{
const g = f.geometry; if (!g) return;
if (g.type === 'Polygon') g.coordinates.forEach(drawRing);
else if (g.type === 'MultiPolygon')
g.coordinates.forEach(p=>p.forEach(drawRing));
});
ctx.restore();
}
// warning outline colors: tornado red, severe-tstorm yellow, flash-flood green
function warnColor(ph){
return ph === 'TO' ? '#FF2A2A' : (ph === 'FF' ? '#2EE66B' : '#FFE12A');
}
function drawWarningsExport(ctx, win, w){
const gj = window.WARN_GJ;
if (!gj || !gj.features || !gj.features.length) return;
// drop any warning already past its UTC expiry (YYYYMMDDHHMM string compare)
const nd = new Date(), pad = n => String(n).padStart(2, '0');
const nowZ = '' + nd.getUTCFullYear() + pad(nd.getUTCMonth()+1) +
pad(nd.getUTCDate()) + pad(nd.getUTCHours()) + pad(nd.getUTCMinutes());
const mpp = (win.x1-win.x0)/w;
const colorW = Math.max(2, w/500); // bold so it reads on 4K stills/movies
const buf = 0.25 * (96/72) * (w/900); // 0.25pt black buffer, scaled to export
ctx.save();
ctx.lineJoin = 'round';
const ringPath = ring=>{
ctx.beginPath();
ring.forEach((pt,i)=>{
const x = (mercX(pt[0])-win.x0)/mpp, y = (win.y1-mercY(pt[1]))/mpp;
if (i) ctx.lineTo(x,y); else ctx.moveTo(x,y);
});
ctx.closePath();
};
const strokeGeom = g=>{
if (g.type === 'Polygon') g.coordinates.forEach(r=>{ ringPath(r); ctx.stroke(); });
else if (g.type === 'MultiPolygon')
g.coordinates.forEach(p=>p.forEach(r=>{ ringPath(r); ctx.stroke(); }));
};
const each = cb=>gj.features.forEach(f=>{
const pr = f.properties || {};
if (pr.exp && pr.exp <= nowZ) return;
const g = f.geometry; if (!g) return;
cb(pr, g);
});
// pass 1: black casing (0.25pt buffer outside the colored stroke)
ctx.strokeStyle = '#000'; ctx.lineWidth = colorW + 2*buf;
each((pr, g)=>strokeGeom(g));
// pass 2: colored warning line on top
ctx.lineWidth = colorW;
each((pr, g)=>{ ctx.strokeStyle = warnColor(pr.ph);
strokeGeom(g); });
ctx.restore();
}
function makeExportGL(w, h, cb){
const cnv = document.createElement('canvas');
cnv.width = w; cnv.height = h;
const gl = cnv.getContext('webgl', {premultipliedAlpha:false, alpha:true,
preserveDrawingBuffer:true});
function sh(t,src){ const s=gl.createShader(t); gl.shaderSource(s,src);
gl.compileShader(s); return s; }
const prog = gl.createProgram();
gl.attachShader(prog, sh(gl.VERTEX_SHADER,VS));
gl.attachShader(prog, sh(gl.FRAGMENT_SHADER,FS));
gl.linkProgram(prog); gl.useProgram(prog);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER,
new Float32Array([-1,-1,1,-1,-1,1,1,1]), gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(prog,'aPos');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos,2,gl.FLOAT,false,0,0);
const U = {};
['uData','uCmap','uMercMin','uMercMax','uSite','uEl','uR0','uDr','uNgates',
'uNaz','uMaxR','uOpacity'].forEach(n=>U[n]=gl.getUniformLocation(prog,n));
const px = new Uint8Array(cb.stops.length*4);
cb.stops.forEach((hex,i)=>{px[i*4]=parseInt(hex.slice(1,3),16);
px[i*4+1]=parseInt(hex.slice(3,5),16);
px[i*4+2]=parseInt(hex.slice(5,7),16); px[i*4+3]=255;});
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, gl.createTexture());
gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,cb.stops.length,1,0,gl.RGBA,
gl.UNSIGNED_BYTE,px);
['TEXTURE_MIN_FILTER','TEXTURE_MAG_FILTER'].forEach(p=>
gl.texParameteri(gl.TEXTURE_2D, gl[p], gl.LINEAR));
['TEXTURE_WRAP_S','TEXTURE_WRAP_T'].forEach(p=>
gl.texParameteri(gl.TEXTURE_2D, gl[p], gl.CLAMP_TO_EDGE));
const cache = {};
async function ensureTex(f, i){
if (cache[i]) return;
const im = await loadImg('data:image/webp;base64,'+f.img);
const t = gl.createTexture();
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,t);
gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE,gl.LUMINANCE,
gl.UNSIGNED_BYTE,im);
['TEXTURE_MIN_FILTER','TEXTURE_MAG_FILTER'].forEach(p=>
gl.texParameteri(gl.TEXTURE_2D, gl[p], gl.NEAREST));
['TEXTURE_WRAP_S','TEXTURE_WRAP_T'].forEach(p=>
gl.texParameteri(gl.TEXTURE_2D, gl[p], gl.CLAMP_TO_EDGE));
cache[i] = t;
}
async function preload(frames){
for (let i=0;i<frames.length;i++) await ensureTex(frames[i], i);
}
async function draw(f, i, win){
await ensureTex(f, i);
gl.viewport(0,0,w,h);
gl.clearColor(0,0,0,0); gl.clear(gl.COLOR_BUFFER_BIT);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,cache[i]);
gl.uniform1i(U.uData,0); gl.uniform1i(U.uCmap,1);
gl.uniform2f(U.uMercMin,win.x0,win.y0);
gl.uniform2f(U.uMercMax,win.x1,win.y1);
gl.uniform2f(U.uSite,SITE[1]*Math.PI/180,SITE[0]*Math.PI/180);
gl.uniform1f(U.uEl,f.el*Math.PI/180);
gl.uniform1f(U.uR0,f.r0); gl.uniform1f(U.uDr,f.dr);
gl.uniform1f(U.uNgates,f.ngates); gl.uniform1f(U.uNaz,f.naz);
gl.uniform1f(U.uMaxR,f.maxr); gl.uniform1f(U.uOpacity,1.0);
gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA);
gl.drawArrays(gl.TRIANGLE_STRIP,0,4);
}
return {cnv, draw, preload};
}
function ellipsize(ctx, text, maxw){
if (ctx.measureText(text).width <= maxw) return text;
while (text.length > 1 &&
ctx.measureText(text+'\\u2026').width > maxw)
text = text.slice(0, -1);
return text+'\\u2026';
}
function drawCbarTicks(ctx, cx, cy, cw, chh, cb, fs){
// interior tick marks + labels every cb.tick, thinned to the bar width
if (!cb.tick || cb.vmax <= cb.vmin) return;
const span = cb.vmax-cb.vmin;
const k0 = Math.ceil((cb.vmin+span*1e-6)/cb.tick),
k1 = Math.floor((cb.vmax-span*1e-6)/cb.tick);
const vals = [];
for (let k = k0; k <= k1; k++){
const v = k*cb.tick, x = (v-cb.vmin)/span;
if (x > 0.03 && x < 0.97) vals.push(v);
}
if (!vals.length) return;
const stride = Math.max(1, Math.ceil((vals.length*fs*2.4)/cw));
ctx.save();
ctx.font = "600 "+(fs*0.72)+"px 'Source Sans 3', sans-serif";
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
for (let i = 0; i < vals.length; i++){
const v = vals[i];
const px = cx + cw*(v-cb.vmin)/span;
ctx.strokeStyle = 'rgba(255,255,255,.9)';
ctx.lineWidth = Math.max(1, fs*0.07);
ctx.beginPath(); ctx.moveTo(px, cy); ctx.lineTo(px, cy+chh*0.5);
ctx.stroke();
if (i % stride) continue;
const x = (v-cb.vmin)/span;
if (x < 0.08 || x > 0.92) continue; // keep clear of vmin/vmax labels
ctx.fillStyle = '#fff';
ctx.fillText(String(+v.toFixed(2)), px, cy+chh+fs*0.62);
}
ctx.restore();
}
const LOGO_IMG = new (window.Image)();
LOGO_IMG.src = 'data:image/png;base64,__LOGOB64__';
function rrPath(ctx, x, y, w, h, r){
ctx.beginPath();
ctx.moveTo(x+r, y);
ctx.arcTo(x+w, y, x+w, y+h, r);
ctx.arcTo(x+w, y+h, x, y+h, r);
ctx.arcTo(x, y+h, x, y, r);
ctx.arcTo(x, y, x+w, y, r);
ctx.closePath();
}
function drawLogo(ctx, x, y, s){
// the actual CliMAS logo in a rounded-rectangle badge
if (!LOGO_IMG.complete || !LOGO_IMG.naturalWidth) return;
ctx.save();
rrPath(ctx, x, y, s, s, s*0.18);
ctx.clip();
ctx.drawImage(LOGO_IMG, x, y, s, s);
ctx.restore();
ctx.save();
rrPath(ctx, x, y, s, s, s*0.18);
ctx.lineWidth = Math.max(2, s*0.012);
ctx.strokeStyle = 'rgba(255,255,255,.85)';
ctx.stroke();
ctx.restore();
}
function drawChrome(ctx, w, h, text, cb){
// scale all chrome off the SHORT edge so portrait/landscape/4K all keep
// the same visual proportions (no oversized text on reels)
const s = Math.min(w, h);
const bh = Math.max(44, Math.round(s*0.066));
const pad = Math.round(s*0.018);
ctx.fillStyle = '#13294B'; ctx.fillRect(0, h-bh, w, bh);
ctx.fillStyle = '#FF5F05'; ctx.fillRect(0, h-bh, w, Math.max(2, bh*0.07));
const fs = Math.round(bh*0.34);
// colorbar zone reserved at right (skipped when cb is null - quad export
// draws one mini colorbar per quadrant instead)
const cw = Math.round(Math.min(s*0.27, w*0.35)),
chh = Math.max(8, Math.round(bh*0.26)),
cx = w-cw-pad, cy = h-bh*0.72;
const maxText = (cb ? cx : w) - 2*pad;
ctx.textBaseline = 'middle'; ctx.textAlign = 'left';
ctx.fillStyle = '#fff';
ctx.font = "600 "+fs+"px 'Source Sans 3', sans-serif";
ctx.fillText(ellipsize(ctx, text, maxText), pad, h-bh*0.62);
ctx.fillStyle = '#C8C6C7';
ctx.font = (fs*0.8)+"px 'Source Sans 3', sans-serif";
ctx.fillText(ellipsize(ctx,
'CliMAS \\u00b7 Illinois \\u00b7 NEXRAD Level 2 browser', maxText),
pad, h-bh*0.22);
if (cb){
const g = ctx.createLinearGradient(cx,0,cx+cw,0);
cb.stops.forEach((c,i)=>g.addColorStop(i/(cb.stops.length-1), c));
ctx.fillStyle = g; ctx.fillRect(cx, cy, cw, chh);
ctx.fillStyle = '#fff'; ctx.font = (fs*0.75)+"px 'Source Sans 3'";
ctx.textAlign = 'left'; ctx.fillText(cb.vmin, cx, cy+chh+fs*0.62);
ctx.textAlign = 'right'; ctx.fillText(cb.vmax, cx+cw, cy+chh+fs*0.62);
drawCbarTicks(ctx, cx, cy, cw, chh, cb, fs);
ctx.textAlign = 'center';
ctx.fillText(ellipsize(ctx, cb.label, cw+2*pad), cx+cw/2, cy-fs*0.55);
ctx.textAlign = 'left';
}
return bh;
}
function dlBlob(blob, name){
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = name;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(()=>URL.revokeObjectURL(a.href), 30000);
}
async function exportQuad(kind, w, h){
// 2x2 composite: same geographic window in all four quadrants, each
// field with its own colormap, chip and mini colorbar; shared chrome.
if (!panels.some(p=>p.fd.frames.length)){ exToast('No data'); return; }
const s = Math.min(w, h);
const bh = Math.max(44, Math.round(s*0.066)); // matches drawChrome
const gap = Math.max(2, Math.round(s*0.004));
const qw = Math.floor((w-gap)/2), qh = Math.floor((h-bh-gap)/2);
const map = panels[0].map, sz = map.getSize();
const cm = PROJ.project(map.getCenter());
const e1 = PROJ.project(map.containerPointToLatLng([0, sz.y/2]));
const e2 = PROJ.project(map.containerPointToLatLng([sz.x, sz.y/2]));
const _ws = Math.abs(e2.x-e1.x), _hs = _ws*(sz.y/sz.x), _ar = qw/qh;
let mercW, mercH;
if (_ar >= _ws/_hs) { mercH = _hs; mercW = _hs*_ar; }
else { mercW = _ws; mercH = _ws/_ar; }
const win = {x0: cm.x-mercW/2, x1: cm.x+mercW/2,
y0: cm.y-mercH/2, y1: cm.y+mercH/2};
exToast('Rendering basemap\\u2026', true);
const base = document.createElement('canvas');
base.width = qw; base.height = qh;
await drawTiles(base.getContext('2d'),
'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}@2x.png',
win, qw, ['a','b','c','d']);
const refs = document.createElement('canvas');
refs.width = qw; refs.height = qh;
if (document.getElementById('ck-interstates').checked)
await drawTiles(refs.getContext('2d'),
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Transportation/MapServer/tile/{z}/{y}/{x}',
win, qw, null);
if (document.getElementById('ck-counties').checked)
drawCounties(refs.getContext('2d'), win, qw);
drawCitiesExport(refs.getContext('2d'), win, qw, qh);
drawWarningsExport(refs.getContext('2d'), win, qw);
const xgls = panels.map(p=>p.fd.frames.length
? makeExportGL(qw, qh, p.fd.cbar) : null);
const work = document.createElement('canvas');
work.width = w; work.height = h;
const wctx = work.getContext('2d');
const out = document.createElement('canvas');
out.width = w; out.height = h;
const octx = out.getContext('2d');
const logoS = Math.round(s*0.13), pad = Math.round(s*0.018);
function quadChrome(ox, oy, p){
const qs = Math.min(qw, qh);
const fs = Math.max(11, Math.round(qs*0.034));
// field chip, top-left
wctx.save();
wctx.font = "600 "+fs+"px 'Source Sans 3', sans-serif";
wctx.textBaseline = 'middle';
const name = ellipsize(wctx, p.fd.name, qw*0.6);
const tw = wctx.measureText(name).width;
rrPath(wctx, ox+fs*0.7, oy+fs*0.7, tw+fs*1.4, fs*1.9, fs*0.5);
wctx.fillStyle = 'rgba(19,41,75,.88)'; wctx.fill();
wctx.fillStyle = '#fff'; wctx.textAlign = 'left';
wctx.fillText(name, ox+fs*1.4, oy+fs*0.7+fs*0.95);
// mini colorbar, bottom-right
const cb = p.fd.cbar;
const cw2 = Math.round(qw*0.30), chh = Math.max(6, Math.round(fs*0.55)),
cx = ox+qw-cw2-fs, cy = oy+qh-chh-fs*1.7;
const g = wctx.createLinearGradient(cx,0,cx+cw2,0);
cb.stops.forEach((c,i)=>g.addColorStop(i/(cb.stops.length-1), c));
rrPath(wctx, cx-fs*0.5, cy-fs*1.5, cw2+fs, chh+fs*2.65, fs*0.35);
wctx.fillStyle = 'rgba(7,16,32,.55)'; wctx.fill();
wctx.fillStyle = g; wctx.fillRect(cx, cy, cw2, chh);
wctx.fillStyle = '#fff';
wctx.font = (fs*0.8)+"px 'Source Sans 3', sans-serif";
wctx.textAlign = 'left'; wctx.fillText(cb.vmin, cx, cy+chh+fs*0.55);
wctx.textAlign = 'right'; wctx.fillText(cb.vmax, cx+cw2, cy+chh+fs*0.55);
if (cb.units){ wctx.textAlign = 'center';
wctx.fillText(cb.units, cx+cw2/2, cy-fs*0.55); }
drawCbarTicks(wctx, cx, cy, cw2, chh, cb, fs*0.9);
wctx.textAlign = 'left';
wctx.restore();
}
function shortLabel(f){
const parts = f.label.split('\\u2022').map(x=>x.trim());
let t = SITE_ID+' \\u00b7 '+parts[0]+(parts[1]?' \\u00b7 '+parts[1]:'');
if (parts[2] && parts[2].indexOf('VCP') === 0) t += ' \\u00b7 '+parts[2];
return t;
}
async function compose(i){
wctx.fillStyle = '#071020'; wctx.fillRect(0,0,w,h);
let lab = null;
for (let q=0; q<4; q++){
const p = panels[q];
const ox = (q%2)*(qw+gap), oy = Math.floor(q/2)*(qh+gap);
wctx.drawImage(base, ox, oy);
if (p.fd.frames.length){
const fi = Math.min(i, p.fd.frames.length-1);
const f = p.fd.frames[fi];
if (!lab) lab = f;
await xgls[q].draw(f, fi, win);
wctx.drawImage(xgls[q].cnv, ox, oy);
}
wctx.drawImage(refs, ox, oy);
if (p.fd.frames.length) quadChrome(ox, oy, p);
else {
const qs = Math.min(qw, qh), fs2 = Math.round(qs*0.04);
wctx.font = "600 "+fs2+"px 'Source Sans 3', sans-serif";
wctx.textAlign = 'center'; wctx.textBaseline = 'middle';
wctx.lineWidth = fs2*0.25; wctx.strokeStyle = 'rgba(7,16,32,.85)';
wctx.strokeText('Pre-polarimetric upgrade data', ox+qw/2, oy+qh/2);
wctx.fillStyle = '#C8C6C7';
wctx.fillText('Pre-polarimetric upgrade data', ox+qw/2, oy+qh/2);
wctx.textAlign = 'left';
}
}
drawChrome(wctx, w, h, lab ? shortLabel(lab) : SITE_ID, null);
drawLogo(wctx, pad, h-bh-logoS-pad, logoS);
octx.clearRect(0,0,w,h);
octx.drawImage(work,0,0);
}
const act = panels.find(p=>p.fd.frames.length);
const f0 = act.fd.frames[0];
const stamp = f0.label.slice(0,10).replace(/-/g,'') + '_' +
f0.label.slice(11,16).split(':').join('') + 'Z';
const base_name = SITE_ID+'_quad_'+stamp+'_'+w+'x'+h;
if (kind === 'still'){
await compose(Math.max(0, idx));
out.toBlob(b=>{ dlBlob(b, base_name+'.png');
exToast('Image saved'); }, 'image/png');
return;
}
// prefer High-profile H.264 at a level that actually supports the output
// size (4.0 tops out below 4K; 5.2 covers 3840x2160), then fall back
const mime = ['video/mp4;codecs=avc1.640034','video/mp4;codecs=avc1.640033',
'video/mp4;codecs=avc1.640028','video/mp4',
'video/webm;codecs=vp9','video/webm']
.find(m=>window.MediaRecorder && MediaRecorder.isTypeSupported(m));
if (!mime){ exToast('Video recording unsupported in this browser'); return; }
const ext = mime.indexOf('mp4')>=0 ? '.mp4' : '.webm';
exToast('Preparing radar textures\\u2026', true);
for (let q=0; q<4; q++)
if (xgls[q]) await xgls[q].preload(panels[q].fd.frames);
await compose(0);
const stream = out.captureStream(30);
// maximum quality: ~0.5 bits/pixel @30fps (4K ~120 Mbps, reel ~31 Mbps)
const rec = new MediaRecorder(stream,
{mimeType:mime, videoBitsPerSecond: Math.round(w*h*30*0.5)});
const chunks = [];
rec.ondataavailable = e=>{ if (e.data.size) chunks.push(e.data); };
const done = new Promise(res=>{ rec.onstop = res; });
rec.start(250);
for (let i=0; i<nmax; i++){
exToast('Recording frame '+(i+1)+'/'+nmax+'\\u2026', true);
await compose(i);
await new Promise(r=>setTimeout(r,
FRAME_MS * (i === nmax-1 ? END_DWELL : 1))); // dwell on last frame
}
rec.stop();
await done;
dlBlob(new Blob(chunks, {type:mime}), base_name+ext);
exToast('Movie saved ('+ext.slice(1)+')');
}
async function exportZV(kind, w, h){
// 2-panel composite. Landscape (4K) -> reflectivity LEFT, velocity RIGHT;
// portrait (reel) -> reflectivity TOP, velocity BOTTOM. The velocity panel
// exports whatever it currently shows (raw or dealiased), so the dealiased
// state is preserved.
const zp = panels.find(p=>p.name === 'Reflectivity');
const vp = panels.find(p=>p.name === 'Radial velocity');
if (!zp || !vp){ exToast('Z+V needs reflectivity and velocity'); return; }
const cells = [zp, vp];
if (!cells.some(p=>p.fd.frames.length)){ exToast('No data'); return; }
const s = Math.min(w, h);
const bh = Math.max(44, Math.round(s*0.066)); // matches drawChrome
const gap = Math.max(2, Math.round(s*0.004));
const landscape = w >= h;
let qw, qh, pos;
if (landscape){ // side by side
qw = Math.floor((w-gap)/2); qh = h - bh;
pos = [[0,0],[qw+gap,0]];
} else { // stacked
qw = w; qh = Math.floor((h-bh-gap)/2);
pos = [[0,0],[0,qh+gap]];
}
const map = zp.map, sz = map.getSize();
const cm = PROJ.project(map.getCenter());
const e1 = PROJ.project(map.containerPointToLatLng([0, sz.y/2]));
const e2 = PROJ.project(map.containerPointToLatLng([sz.x, sz.y/2]));
const _ws = Math.abs(e2.x-e1.x), _hs = _ws*(sz.y/sz.x), _ar = qw/qh;
let mercW, mercH;
if (_ar >= _ws/_hs) { mercH = _hs; mercW = _hs*_ar; }
else { mercW = _ws; mercH = _ws/_ar; }
const win = {x0: cm.x-mercW/2, x1: cm.x+mercW/2,
y0: cm.y-mercH/2, y1: cm.y+mercH/2};
exToast('Rendering basemap\\u2026', true);
const base = document.createElement('canvas');
base.width = qw; base.height = qh;
await drawTiles(base.getContext('2d'),
'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}@2x.png',
win, qw, ['a','b','c','d']);
const refs = document.createElement('canvas');
refs.width = qw; refs.height = qh;
if (document.getElementById('ck-interstates').checked)
await drawTiles(refs.getContext('2d'),
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Transportation/MapServer/tile/{z}/{y}/{x}',
win, qw, null);
if (document.getElementById('ck-counties').checked)
drawCounties(refs.getContext('2d'), win, qw);
drawCitiesExport(refs.getContext('2d'), win, qw, qh);
drawWarningsExport(refs.getContext('2d'), win, qw);
const xgls = cells.map(p=>p.fd.frames.length
? makeExportGL(qw, qh, p.fd.cbar) : null);
const work = document.createElement('canvas');
work.width = w; work.height = h;
const wctx = work.getContext('2d');
const out = document.createElement('canvas');
out.width = w; out.height = h;
const octx = out.getContext('2d');
const logoS = Math.round(s*0.13), pad = Math.round(s*0.018);
function panelChrome(ox, oy, p){
const qs = Math.min(qw, qh);
const fs = Math.max(11, Math.round(qs*0.034));
wctx.save();
wctx.font = "600 "+fs+"px 'Source Sans 3', sans-serif";
wctx.textBaseline = 'middle';
const name = ellipsize(wctx, p.fd.name, qw*0.6);
const tw = wctx.measureText(name).width;
rrPath(wctx, ox+fs*0.7, oy+fs*0.7, tw+fs*1.4, fs*1.9, fs*0.5);
wctx.fillStyle = 'rgba(19,41,75,.88)'; wctx.fill();
wctx.fillStyle = '#fff'; wctx.textAlign = 'left';
wctx.fillText(name, ox+fs*1.4, oy+fs*0.7+fs*0.95);
const cb = p.fd.cbar;
const cw2 = Math.round(qw*0.30), chh = Math.max(6, Math.round(fs*0.55)),
cx = ox+qw-cw2-fs, cy = oy+qh-chh-fs*1.7;
const g = wctx.createLinearGradient(cx,0,cx+cw2,0);
cb.stops.forEach((c,i)=>g.addColorStop(i/(cb.stops.length-1), c));
rrPath(wctx, cx-fs*0.5, cy-fs*1.5, cw2+fs, chh+fs*2.65, fs*0.35);
wctx.fillStyle = 'rgba(7,16,32,.55)'; wctx.fill();
wctx.fillStyle = g; wctx.fillRect(cx, cy, cw2, chh);
wctx.fillStyle = '#fff';
wctx.font = (fs*0.8)+"px 'Source Sans 3', sans-serif";
wctx.textAlign = 'left'; wctx.fillText(cb.vmin, cx, cy+chh+fs*0.55);
wctx.textAlign = 'right'; wctx.fillText(cb.vmax, cx+cw2, cy+chh+fs*0.55);
if (cb.units){ wctx.textAlign = 'center';
wctx.fillText(cb.units, cx+cw2/2, cy-fs*0.55); }
drawCbarTicks(wctx, cx, cy, cw2, chh, cb, fs*0.9);
wctx.textAlign = 'left';
wctx.restore();
}
function shortLabel(f){
const parts = f.label.split('\\u2022').map(x=>x.trim());
let t = SITE_ID+' \\u00b7 '+parts[0]+(parts[1]?' \\u00b7 '+parts[1]:'');
if (parts[2] && parts[2].indexOf('VCP') === 0) t += ' \\u00b7 '+parts[2];
return t;
}
async function compose(i){
wctx.fillStyle = '#071020'; wctx.fillRect(0,0,w,h);
let lab = null;
for (let q=0; q<cells.length; q++){
const p = cells[q];
const ox = pos[q][0], oy = pos[q][1];
wctx.drawImage(base, ox, oy);
if (p.fd.frames.length){
const fi = Math.min(i, p.fd.frames.length-1);
const f = p.fd.frames[fi];
if (!lab) lab = f;
await xgls[q].draw(f, fi, win);
wctx.drawImage(xgls[q].cnv, ox, oy);
}
wctx.drawImage(refs, ox, oy);
if (p.fd.frames.length) panelChrome(ox, oy, p);
else {
const qs = Math.min(qw, qh), fs2 = Math.round(qs*0.04);
wctx.font = "600 "+fs2+"px 'Source Sans 3', sans-serif";
wctx.textAlign = 'center'; wctx.textBaseline = 'middle';
wctx.lineWidth = fs2*0.25; wctx.strokeStyle = 'rgba(7,16,32,.85)';
wctx.strokeText('No data for this hour', ox+qw/2, oy+qh/2);
wctx.fillStyle = '#C8C6C7';
wctx.fillText('No data for this hour', ox+qw/2, oy+qh/2);
wctx.textAlign = 'left';
}
}
drawChrome(wctx, w, h, lab ? shortLabel(lab) : SITE_ID, null);
drawLogo(wctx, pad, h-bh-logoS-pad, logoS);
octx.clearRect(0,0,w,h);
octx.drawImage(work,0,0);
}
const act = cells.find(p=>p.fd.frames.length);
const f0 = act.fd.frames[0];
const stamp = f0.label.slice(0,10).replace(/-/g,'') + '_' +
f0.label.slice(11,16).split(':').join('') + 'Z';
const base_name = SITE_ID+'_zv_'+stamp+'_'+w+'x'+h;
if (kind === 'still'){
await compose(Math.max(0, idx));
out.toBlob(b=>{ dlBlob(b, base_name+'.png');
exToast('Image saved'); }, 'image/png');
return;
}
const mime = ['video/mp4;codecs=avc1.640034','video/mp4;codecs=avc1.640033',
'video/mp4;codecs=avc1.640028','video/mp4',
'video/webm;codecs=vp9','video/webm']
.find(m=>window.MediaRecorder && MediaRecorder.isTypeSupported(m));
if (!mime){ exToast('Video recording unsupported in this browser'); return; }
const ext = mime.indexOf('mp4')>=0 ? '.mp4' : '.webm';
exToast('Preparing radar textures\\u2026', true);
for (let q=0; q<cells.length; q++)
if (xgls[q]) await xgls[q].preload(cells[q].fd.frames);
await compose(0);
const stream = out.captureStream(30);
const rec = new MediaRecorder(stream,
{mimeType:mime, videoBitsPerSecond: Math.round(w*h*30*0.5)});
const chunks = [];
rec.ondataavailable = e=>{ if (e.data.size) chunks.push(e.data); };
const done = new Promise(res=>{ rec.onstop = res; });
rec.start(250);
for (let i=0; i<nmax; i++){
exToast('Recording frame '+(i+1)+'/'+nmax+'\\u2026', true);
await compose(i);
await new Promise(r=>setTimeout(r,
FRAME_MS * (i === nmax-1 ? END_DWELL : 1))); // dwell on last frame
}
rec.stop();
await done;
dlBlob(new Blob(chunks, {type:mime}), base_name+ext);
exToast('Movie saved ('+ext.slice(1)+')');
}
async function exportMedia(kind, w, h){
if (mode === 'quad') return exportQuad(kind, w, h);
if (mode === 'zv') return exportZV(kind, w, h);
const vis = activePanel(), fd = vis.fd;
if (!fd.frames.length) { exToast('No data in this panel'); return; }
const map = vis.map, sz = map.getSize();
const cm = PROJ.project(map.getCenter());
const e1 = PROJ.project(map.containerPointToLatLng([0, sz.y/2]));
const e2 = PROJ.project(map.containerPointToLatLng([sz.x, sz.y/2]));
// consistent zoom: match the on-screen scale and CONTAIN the visible view
// (anchor on width or height by aspect) so the export neither crops nor
// re-zooms relative to what's on screen.
const _ws = Math.abs(e2.x-e1.x), _hs = _ws*(sz.y/sz.x), _ar = w/h;
let mercW, mercH;
if (_ar >= _ws/_hs) { mercH = _hs; mercW = _hs*_ar; }
else { mercW = _ws; mercH = _ws/_ar; }
const win = {x0: cm.x-mercW/2, x1: cm.x+mercW/2,
y0: cm.y-mercH/2, y1: cm.y+mercH/2};
exToast('Rendering basemap\\u2026', true);
const base = document.createElement('canvas');
base.width = w; base.height = h;
await drawTiles(base.getContext('2d'),
'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}@2x.png',
win, w, ['a','b','c','d']);
const refs = document.createElement('canvas');
refs.width = w; refs.height = h;
if (document.getElementById('ck-interstates').checked)
await drawTiles(refs.getContext('2d'),
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Transportation/MapServer/tile/{z}/{y}/{x}',
win, w, null);
if (document.getElementById('ck-counties').checked)
drawCounties(refs.getContext('2d'), win, w);
drawCitiesExport(refs.getContext('2d'), win, w, h);
drawWarningsExport(refs.getContext('2d'), win, w);
const xgl = makeExportGL(w, h, fd.cbar);
// double buffer: compose on `work`, blit complete frames to `out` —
// the recorder only ever sees finished composites
const work = document.createElement('canvas');
work.width = w; work.height = h;
const wctx = work.getContext('2d');
const out = document.createElement('canvas');
out.width = w; out.height = h;
const octx = out.getContext('2d');
const logoS = Math.round(Math.min(w,h)*0.15),
pad = Math.round(Math.min(w,h)*0.018);
function shortLabel(f){
const parts = f.label.split('\\u2022').map(s=>s.trim());
let t = SITE_ID+' \\u00b7 '+parts[0]+(parts[1]?' \\u00b7 '+parts[1]:'');
if (parts[2] && parts[2].indexOf('VCP') === 0)
t += ' \\u00b7 '+parts[2];
return t;
}
async function compose(i){
const fi = Math.min(i, fd.frames.length-1);
const f = fd.frames[fi];
await xgl.draw(f, fi, win);
wctx.clearRect(0,0,w,h);
wctx.drawImage(base,0,0);
wctx.drawImage(xgl.cnv,0,0);
wctx.drawImage(refs,0,0);
const bh = drawChrome(wctx, w, h, shortLabel(f), fd.cbar);
drawLogo(wctx, pad, h-bh-logoS-pad, logoS);
octx.clearRect(0,0,w,h);
octx.drawImage(work,0,0);
}
const f0 = fd.frames[0];
const stamp = f0.label.slice(0,10).replace(/-/g,'') + '_' +
f0.label.slice(11,16).split(':').join('') + 'Z';
const fldShort = (SHORT[fd.name]||fd.name).toLowerCase();
const base_name = SITE_ID+'_'+fldShort+'_'+stamp+'_'+w+'x'+h;
if (kind === 'still'){
await compose(Math.max(0, idx));
out.toBlob(b=>{ dlBlob(b, base_name+'.png');
exToast('Image saved'); }, 'image/png');
return;
}
// loop -> movie via MediaRecorder; pre-decode every radar texture first
// prefer High-profile H.264 at a level that actually supports the output
// size (4.0 tops out below 4K; 5.2 covers 3840x2160), then fall back
const mime = ['video/mp4;codecs=avc1.640034','video/mp4;codecs=avc1.640033',
'video/mp4;codecs=avc1.640028','video/mp4',
'video/webm;codecs=vp9','video/webm']
.find(m=>window.MediaRecorder && MediaRecorder.isTypeSupported(m));
if (!mime){ exToast('Video recording unsupported in this browser'); return; }
const ext = mime.indexOf('mp4')>=0 ? '.mp4' : '.webm';
exToast('Preparing radar textures\\u2026', true);
await xgl.preload(fd.frames);
await compose(0); // first full frame before recording
const stream = out.captureStream(30);
// maximum quality: ~0.5 bits/pixel @30fps (4K ~120 Mbps, reel ~31 Mbps)
const rec = new MediaRecorder(stream,
{mimeType:mime, videoBitsPerSecond: Math.round(w*h*30*0.5)});
const chunks = [];
rec.ondataavailable = e=>{ if (e.data.size) chunks.push(e.data); };
const done = new Promise(res=>{ rec.onstop = res; });
rec.start(250);
const n = fd.frames.length;
for (let i=0; i<n; i++){
exToast('Recording frame '+(i+1)+'/'+n+'\\u2026', true);
await compose(i);
await new Promise(r=>setTimeout(r,
FRAME_MS * (i === n-1 ? END_DWELL : 1))); // dwell on last frame
}
rec.stop();
await done;
dlBlob(new Blob(chunks, {type:mime}), base_name+ext);
exToast('Movie saved ('+ext.slice(1)+')');
}
const EXMENU = document.getElementById('exmenu');
document.getElementById('export').addEventListener('click', ()=>{
EXMENU.style.display = EXMENU.style.display==='flex' ? 'none' : 'flex';
});
EXMENU.querySelectorAll('button').forEach(b=>b.addEventListener('click', ()=>{
EXMENU.style.display = 'none';
const cfg = {still4k:['still',3840,2160], stillig:['still',1080,1920],
loop4k:['loop',3840,2160], loopig:['loop',1080,1920]}[b.dataset.k];
exportMedia(cfg[0], cfg[1], cfg[2])
.catch(e=>exToast('Export failed: '+e.message));
}));
// stagger texture preloads (newest-first in live mode)
panels.forEach((p,pi)=>p.fd.frames.forEach((_,i)=>
setTimeout(()=>p.texFor(i,()=>{}),
80*(RT ? p.fd.frames.length-1-i : i)+20*pi)));
// live view opens on the newest frame; archive starts at the top of the hour —
// unless we just rebuilt for a dealias toggle, then restore the prior frame
let _startIdx = RT ? nmax-1 : 0;
try {
if (parent && typeof parent.__restoreIdx === 'number'){
_startIdx = Math.min(Math.max(parent.__restoreIdx, 0), nmax-1);
parent.__restoreIdx = undefined;
}
} catch (e) {}
show(_startIdx);
</script></body></html>"""
# small base64 of the CliMAS logo for export stamping (from logo.png)
def _export_logo_b64():
p = os.path.join(os.path.dirname(__file__), "logo.png")
if not os.path.exists(p):
return ""
try:
im = Image.open(p).convert("RGB").resize((512, 512), Image.LANCZOS)
buf = io.BytesIO()
im.save(buf, format="PNG", optimize=True)
return base64.b64encode(buf.getvalue()).decode()
except Exception:
return ""
EXPORT_LOGO_B64 = _export_logo_b64()
def _bundle_data(by_field):
"""The per-field DATA payload (textures + colorbar) the bundle renders."""
return [dict(name=fn, cbar=colorbar_cfg(_field_cfg(fn)),
frames=[{k: f[k] for k in ("img", "naz", "ngates", "r0",
"dr", "el", "maxr", "label")}
for f in frames])
for fn, frames in by_field.items()]
def _live_sig(data):
"""Cheap content signature: changes whenever any frame is added/updated."""
parts = []
for d in data:
parts.append(d["name"] + ":" + str(len(d["frames"])))
parts.append(",".join(str(fr["label"]) for fr in d["frames"]))
return hashlib.md5("|".join(parts).encode()).hexdigest()[:12]
def _live_paths(site):
s = site.upper()
return (os.path.join(VOL_CACHE_DIR, f"live_{s}.json"),
os.path.join(VOL_CACHE_DIR, f"live_{s}.tag.json"))
def _write_live(site, by_field):
"""Write the live frame set (+ a tiny etag-only sidecar) so the in-page
poller can merge new frames WITHOUT reloading the whole iframe."""
try:
data = _bundle_data(by_field)
etag = _live_sig(data)
ts = time_mod.time() # bumps every compute, even when etag is unchanged
big, tag = _live_paths(site)
for path, payload in ((big, dict(etag=etag, ts=ts, fields=data)),
(tag, dict(etag=etag, ts=ts))):
tmp = path + ".part"
with open(tmp, "w") as f:
json.dump(payload, f)
os.replace(tmp, path)
return etag
except Exception:
return ""
def build_bundle_page(by_field, site, slat, slon, share_base="",
rt_refresh_s=300):
"""One page carrying ALL fields' polar textures; the requested view
(single field or 2×2) is substituted into __MODE__ at serve time, so
every field/4-panel switch after the first load is instant."""
data = _bundle_data(by_field)
etag = _live_sig(data)
big, tag = _live_paths(site)
page = (QUAD_PAGE
.replace("__DATA__", json.dumps(data))
.replace("__SLAT__", f"{slat:.5f}")
.replace("__SLON__", f"{slon:.5f}")
.replace("__SITE__", site)
.replace("__WARNURL__", WARN_URL)
.replace("__CITIESURL__", CITIES_URL)
.replace("__SITESURL__", SITES_URL)
.replace("__LIVEURL__", "/gradio_api/file=" + big)
.replace("__LIVETAGURL__", "/gradio_api/file=" + tag)
.replace("__LIVETAG0__", json.dumps(etag))
.replace("__LOGOB64__", EXPORT_LOGO_B64)
.replace("__RTSEC__", str(int(rt_refresh_s)))
.replace("__SHAREBASE__", share_base))
return (f'<iframe allow="clipboard-write" '
f'style="width:100%;height:calc(100vh - 170px);'
f'min-height:480px;border:0;border-radius:4px" '
f'srcdoc="{html_mod.escape(page)}"></iframe>')
def _mode_page(tpl, field_name, view=None, deal=False, rt=False):
"""Substitute the initial view mode, optional [lat, lon, zoom] and the
dealias flag into a cached bundle template. The template is already
HTML-escaped (srcdoc), so escape the JSON too."""
if field_name == QUAD:
mode = "quad"
elif field_name == ZVF:
mode = "zv"
elif field_name == DEALIAS_NAME:
mode, deal = "Radial velocity", True
else:
mode = field_name
return (tpl
.replace("__MODE__", html_mod.escape(json.dumps(mode)))
.replace("__VIEW__", html_mod.escape(json.dumps(view)))
.replace("__DEAL__", "true" if deal else "false")
.replace("__RT__", "true" if rt else "false"))
# ----------------------------------------------------------------------------- gradio callback
# rendered-page cache: default case is pre-rendered at startup, and recent
# views are served instantly (share links, repeat visits)
DEFAULT_VIEW = ("KILX", "Reflectivity", "2023", "06", "29", "18:00")
_PAGE_CACHE = collections.OrderedDict()
_PAGE_CACHE_CAP = 6 # hours; each bundle holds all four fields (~30-50 MB)
_FRAME_CACHE = collections.OrderedDict() # hour -> raw frames (for dealias)
_FRAME_CACHE_CAP = 4
_INFLIGHT = {}
_CACHE_LOCK = threading.Lock()
def _rt_keys(site):
"""Volumes whose start time lies in the trailing 60 minutes."""
now = dt.datetime.utcnow()
bucket, keys = None, []
for hdt in (now - dt.timedelta(hours=1), now):
try:
b, k = list_hour_keys(site, hdt.date(), hdt.hour)
except Exception:
b, k = None, []
if k:
bucket = bucket or b
keys += k
cutoff = now - dt.timedelta(minutes=62)
sel = []
for k in keys:
m = re.search(r"(\d{8})_(\d{6})", k)
if m:
t = dt.datetime.strptime(m.group(1) + m.group(2),
"%Y%m%d%H%M%S")
if t >= cutoff:
sel.append(k)
return bucket, sorted(set(sel)), now
_RT_CACHE = {} # site -> dict(ts, tpl, by_field, site_ll, has_deal)
_RT_TTL = 150 # seconds — refresh cadence is 300 s
# sites with a recent live viewer -> (last_request_ts, want_deal). A background
# daemon keeps these refreshed so ingest never depends on a browser tab driving
# the client timer (backgrounded tabs freeze their timers).
_LIVE_ACTIVE = {}
_LIVE_ACTIVE_TTL = 20 * 60 # keep polling a site for 20 min after last view
def _realtime_compute(site, field_name, _p, view, want_deal=False):
"""Decode the trailing hour. Short-lived cache + in-flight dedup keep
reloads and concurrent viewers from re-decoding live data. Dealiasing
is supported: the cached raw decode is upgraded in place."""
ttl = 45 # short-lived live cache (every site is on the chunk feed)
c = _RT_CACHE.get(site)
fresh = c and time_mod.time() - c["ts"] < ttl
if fresh and (not want_deal or c["has_deal"]):
return "", _mode_page(c["tpl"], field_name, view,
deal=want_deal, rt=True)
key_rt = ("RT", site)
with _CACHE_LOCK:
evt = _INFLIGHT.get(key_rt)
owner = evt is None
if owner:
_INFLIGHT[key_rt] = threading.Event()
if not owner:
_p(0.5, "Live hour already rendering — attaching…")
evt.wait(600)
c = _RT_CACHE.get(site)
if c and (not want_deal or c["has_deal"]):
return "", _mode_page(c["tpl"], field_name, view,
deal=want_deal, rt=True)
try:
return _realtime_compute_inner(site, field_name, _p, view,
want_deal)
finally:
if owner:
with _CACHE_LOCK:
ev = _INFLIGHT.pop(key_rt, None)
if ev:
ev.set()
def _realtime_chunks_inner(site, field_name, _p, view, want_deal=False):
"""Live mode on the Level 2 chunk feed: per-volume incremental decode —
only the in-progress volume (and newly completed ones) cost CPU."""
_p(0.02, f"Polling the live chunk feed for {site}…")
vols = fetch_live_chunks(
site, progress=lambda f, d: _p(0.02 + 0.28 * f, d))
if not vols:
raise RuntimeError("no live chunks")
by_field = {fn: [] for fn in FIELDS}
site_ll = None
todo = []
for v in vols:
ck = (site, v["vol"])
cached = _CHUNK_FRAMES.get(ck)
if (cached and cached["n"] == len(v["paths"])
and cached["complete"] == v["complete"]):
continue
todo.append(v)
done = 0
for i in range(0, len(todo), 2):
grp = todo[i:i + 2]
with _DECODE_SEM:
with ProcessPoolExecutor(max_workers=N_PROC) as px:
futs = {px.submit(
process_chunks, v["paths"],
"live vol %d%s" % (v["vol"],
"" if v["complete"] else
" (updating)"),
FIELDS): v for v in grp}
for fut in as_completed(futs):
v = futs[fut]
done += 1
_p(0.32 + 0.40 * done / max(1, len(todo)),
f"Decoding live volume {v['vol']} "
f"({done}/{len(todo)})…")
try:
frames, ll = fut.result()
except Exception:
continue
_CHUNK_FRAMES[(site, v["vol"])] = dict(
n=len(v["paths"]), complete=v["complete"],
frames=frames, site_ll=ll, ts=time_mod.time())
for v in vols:
cached = _CHUNK_FRAMES.get((site, v["vol"]))
if not cached:
continue
cached["ts"] = time_mod.time() # touch -> LRU expiry by last view
site_ll = site_ll or cached.get("site_ll")
for fn, fr in cached["frames"].items():
by_field[fn].extend(fr)
if want_deal:
comp = [v for v in vols if v["complete"]]
todo_d = [v for v in comp if (site, v["vol"]) not in _CHUNK_DEAL]
dn = 0
for i in range(0, len(todo_d), 2):
grp = todo_d[i:i + 2]
with _DECODE_SEM:
with ProcessPoolExecutor(max_workers=N_PROC) as px:
futs = {px.submit(
dealias_volume_file, v["paths"],
"live vol %d" % v["vol"]): v for v in grp}
for fut in as_completed(futs):
v = futs[fut]
dn += 1
_p(0.74 + 0.18 * dn / max(1, len(todo_d)),
f"Dealiasing live volume {v['vol']} "
f"({dn}/{len(todo_d)}, {_DEALIAS_ENGINE})…")
try:
_CHUNK_DEAL[(site, v["vol"])] = dict(
ts=time_mod.time(), frames=fut.result())
except Exception:
_CHUNK_DEAL[(site, v["vol"])] = dict(
ts=time_mod.time(), frames=[])
dframes = []
for v in comp:
d = _CHUNK_DEAL.get((site, v["vol"]))
if d:
d["ts"] = time_mod.time()
dframes.extend(d["frames"])
dframes.sort(key=lambda f: f["time"])
by_field[DEALIAS_NAME] = dframes[:MAX_FRAMES]
for fn in by_field:
by_field[fn].sort(key=lambda f: f["time"])
by_field[fn] = by_field[fn][:MAX_FRAMES]
# short-lived caches: drop volumes for sites not viewed within _CHUNK_TTL,
# with a hard count backstop (every site uses the chunk feed now)
_now = time_mod.time()
for cache in (_CHUNK_FRAMES, _CHUNK_DEAL):
for k in [k for k, vv in cache.items()
if _now - vv.get("ts", 0) > _CHUNK_TTL]:
cache.pop(k, None)
while len(cache) > 80:
cache.pop(next(iter(cache)))
_prune_chunk_cache(4800) # files: cover the ~65-min loop window
return _rt_finish(site, field_name, _p, view, want_deal,
by_field, site_ll)
def _realtime_compute_inner(site, field_name, _p, view, want_deal=False):
# Every site uses the low-latency chunk feed (so in-progress volumes show);
# fall back to the assembled-archive bucket only if a site has no chunks.
try:
return _realtime_chunks_inner(site, field_name, _p, view, want_deal)
except Exception:
pass # fall back to the archive-bucket live path below
_p(0.02, f"Listing the last 60 minutes for {site}…")
bucket, keys, now = _rt_keys(site)
if not keys:
return _msg(f"No Level 2 volumes from {site} in the last hour — "
f"the radar may be down or data is still in transit.")
n = len(keys)
c = _RT_CACHE.get(site)
if (c and time_mod.time() - c["ts"] < _RT_TTL and want_deal
and not c["has_deal"]):
# fresh raw decode exists — only the dealias pass is needed
by_field = dict(c["by_field"])
site_ll = c["site_ll"]
else:
with ThreadPoolExecutor(max_workers=6) as ex:
futs = [ex.submit(_safe_download, bucket, k, VOL_CACHE_DIR)
for k in keys]
for i, _ in enumerate(as_completed(futs)):
_p(0.04 + 0.16 * (i + 1) / n,
f"Fetching live volumes… {i + 1}/{n}")
by_field, site_ll = _decode_keys(bucket, keys, _p, 0.22, 0.50,
"Decoding live volumes")
_prune_vol_cache()
if want_deal:
dframes, done = [], 0
for i in range(0, n, 2):
chunk = keys[i:i + 2]
with _DECODE_SEM:
with ProcessPoolExecutor(max_workers=N_PROC) as px:
pfuts = [px.submit(dealias_volume, bucket, k,
VOL_CACHE_DIR) for k in chunk]
for fut in as_completed(pfuts):
done += 1
_p(0.72 + 0.22 * done / n,
f"Dealiasing live volumes {done}/{n} "
f"({_DEALIAS_ENGINE})…")
dframes.extend(fut.result())
dframes.sort(key=lambda f: f["time"])
by_field[DEALIAS_NAME] = dframes[:MAX_FRAMES]
return _rt_finish(site, field_name, _p, view, want_deal,
by_field, site_ll)
def _rt_finish(site, field_name, _p, view, want_deal, by_field, site_ll):
if site_ll is not None and abs(site_ll[0]) < 0.1 and abs(site_ll[1]) < 0.1:
site_ll = None
if site_ll is None:
loc = NEXRAD_COORDS.get(site)
if loc:
site_ll = (loc[0], loc[1])
if site_ll is None or not any(by_field.values()):
return _msg(f"Could not decode any live data from {site}.")
_p(0.96, "Packing live bundle…")
rtsec = 120 # in-browser live self-refresh cadence (s)
tpl = build_bundle_page(by_field, site, site_ll[0], site_ll[1],
f"?site={site}&rt=1", rt_refresh_s=rtsec)
_write_live(site, by_field) # refresh the in-place poll file
_RT_CACHE[site] = dict(ts=time_mod.time(), tpl=tpl, by_field=by_field,
site_ll=site_ll,
has_deal=DEALIAS_NAME in by_field)
for k in [k for k in _RT_CACHE if k != site][4:]:
_RT_CACHE.pop(k, None)
return "", _mode_page(tpl, field_name, view, deal=want_deal, rt=True)
def browse(site, field_name, year, month, day, hour, progress=None,
view=None, want_deal=False, realtime=False):
def _p(frac, desc):
if progress is not None:
try:
progress(frac, desc=desc)
except Exception:
pass
try:
m = re.match(r"\s*([A-Za-z]{4})\b", site or "")
if not m:
return _msg("Pick a NEXRAD site (e.g. KTLX, KILX, PHWA).")
site = m.group(1).upper()
if realtime:
# remember this site so the background poller keeps it fresh even
# if the client timer stops (inactive/backgrounded tab)
_LIVE_ACTIVE[site] = (time_mod.time(), bool(want_deal))
return _realtime_compute(site, field_name, _p, view, want_deal)
try:
date = dt.date(int(year), int(month), int(day))
except ValueError:
return _msg("That calendar date doesn't exist — check day/month.")
hr = int(str(hour)[:2])
if field_name not in FIELDS and field_name not in (QUAD, ZVF,
DEALIAS_NAME):
field_name = "Reflectivity"
# ---- per-hour bundle cache / in-flight dedup ---------------------
key_h = _hour_key(site, date, hr)
with _CACHE_LOCK:
tpl = _PAGE_CACHE.get(key_h)
if tpl is not None:
_PAGE_CACHE.move_to_end(key_h)
if tpl is not None:
if ((field_name == DEALIAS_NAME or want_deal)
and _DEAL_MARKER not in tpl):
tpl = _dealias_compute(site, date, hr, _p)
return "", _mode_page(tpl, field_name, view, want_deal)
with _CACHE_LOCK:
evt = _INFLIGHT.get(key_h)
owner = evt is None
if owner:
_INFLIGHT[key_h] = threading.Event()
if not owner:
_p(0.5, "This hour is already rendering — attaching to it…")
evt.wait(900)
with _CACHE_LOCK:
tpl = _PAGE_CACHE.get(key_h)
if tpl is not None:
if field_name == DEALIAS_NAME and _DEAL_MARKER not in tpl:
tpl = _dealias_compute(site, date, hr, _p)
return "", _mode_page(tpl, field_name, view)
# fall through and compute ourselves if the other run failed
try:
err, tpl = _browse_compute(site, field_name, date, hr, _p)
if err is not None:
return err
if ((field_name == DEALIAS_NAME or want_deal)
and _DEAL_MARKER not in tpl):
tpl = _dealias_compute(site, date, hr, _p)
return "", _mode_page(tpl, field_name, view, want_deal)
finally:
if owner:
with _CACHE_LOCK:
ev = _INFLIGHT.pop(key_h, None)
if ev:
ev.set()
except Exception:
return _msg("Unexpected error:\n```\n"
+ traceback.format_exc()[-1500:] + "\n```")
# marker proving the dealiased field is inside a bundle template (must not
# collide with the JS SHORT-name literal, hence the json "name" form)
_DEAL_MARKER = html_mod.escape(json.dumps("name") + ": "
+ json.dumps(DEALIAS_NAME))
# Only one decode batch may use the CPUs at a time, and work is chunked so
# concurrent requests (boot pre-render vs. a live user) interleave fairly
# instead of thrashing two process pools on two cores.
_DECODE_SEM = threading.Semaphore(1)
def _decode_keys(bucket, keys, _p, base, span, label):
"""Decode volumes (all fields) in semaphore-guarded chunks of 2."""
by_field = {fn: [] for fn in FIELDS}
site_ll = None
n, done = len(keys), 0
for i in range(0, n, 2):
chunk = keys[i:i + 2]
with _DECODE_SEM:
with ProcessPoolExecutor(max_workers=N_PROC) as px:
futs = [px.submit(process_volume, bucket, k, FIELDS,
VOL_CACHE_DIR) for k in chunk]
for fut in as_completed(futs):
done += 1
_p(base + span * done / n, f"{label} {done}/{n}…")
volframes, ll = fut.result()
for fn, fr in volframes.items():
by_field[fn].extend(fr)
site_ll = site_ll or ll
for fn in by_field:
by_field[fn].sort(key=lambda f: f["time"])
by_field[fn] = by_field[fn][:MAX_FRAMES]
return by_field, site_ll
def _hour_key(site, date, hr):
return (site, f"{date.year}", f"{date.month:02d}",
f"{date.day:02d}", f"{hr:02d}")
def _share_base(site, date, hr):
qs = (f"?site={site}&year={date.year}&month={date.month:02d}"
f"&day={date.day:02d}&hour={hr:02d}")
host = os.environ.get("SPACE_HOST", "")
return f"https://{host}/{qs}" if host else qs
def _browse_compute(site, field_name, date, hr, _p):
"""Decode ALL fields in one pass over the hour's volumes; build and
cache the bundle template. Returns (error_tuple_or_None, tpl_or_None)."""
try:
_p(0.02, f"Listing Level 2 volumes for {site} {date} {hr:02d}Z…")
bucket, keys = list_hour_keys(site, date, hr)
if not keys:
return _msg(
f"No Level 2 volumes found for {site} on {date} "
f"{hr:02d}:00–{hr:02d}:59 UTC. Check the site ID and date "
f"(dual-pol fields require 2011+ for most sites)."
), None
n = len(keys)
with ThreadPoolExecutor(max_workers=6) as ex:
futs = [ex.submit(_safe_download, bucket, k, VOL_CACHE_DIR)
for k in keys]
for i, _ in enumerate(as_completed(futs)):
_p(0.04 + 0.20 * (i + 1) / n,
f"Downloading volumes from AWS… {i + 1}/{n} "
f"(cached volumes skip this)")
by_field, site_ll = _decode_keys(
bucket, keys, _p, 0.26, 0.66,
"Decoding volumes (all four fields)")
_prune_vol_cache()
# legacy (Message 1 era, pre-~2008) volumes carry no site coords and
# decode as 0°N 0°E — fall back to the WSR-88D station table
if site_ll is not None and abs(site_ll[0]) < 0.1 and abs(site_ll[1]) < 0.1:
site_ll = None
if site_ll is None:
loc = NEXRAD_COORDS.get(site)
if loc:
site_ll = (loc[0], loc[1])
if site_ll is None or not any(by_field.values()):
return _msg(
f"Volumes were found for {site} in that hour, but no 0.5° "
f"sweep data could be decoded. Pre-dual-pol data (before "
f"~2011-2013 depending on site) has no ZDR/CC."
), None
_p(0.94, "Packing the all-field WebGL bundle (textures for every "
"field load once)…")
slat, slon = site_ll
# keep empty fields in the bundle: their panels show an explanatory
# message (e.g. dual-pol fields on pre-upgrade data)
tpl = build_bundle_page(by_field, site, slat, slon,
_share_base(site, date, hr))
with _CACHE_LOCK:
_PAGE_CACHE[_hour_key(site, date, hr)] = tpl
while len(_PAGE_CACHE) > _PAGE_CACHE_CAP:
_PAGE_CACHE.popitem(last=False)
_FRAME_CACHE[_hour_key(site, date, hr)] = dict(
by_field=by_field, slat=slat, slon=slon)
while len(_FRAME_CACHE) > _FRAME_CACHE_CAP:
_FRAME_CACHE.popitem(last=False)
return None, tpl
except Exception:
return _msg("Unexpected error:\n```\n"
+ traceback.format_exc()[-1500:] + "\n```"), None
def _dealias_compute(site, date, hr, _p):
"""Region-based dealiasing for the hour; merges the new field into the
cached bundle and returns the updated template."""
key_h = _hour_key(site, date, hr)
with _CACHE_LOCK:
fc = _FRAME_CACHE.get(key_h)
if fc is None: # server restarted since the hour was decoded
err, _tpl = _browse_compute(site, "Reflectivity", date, hr, _p)
if err is not None:
raise RuntimeError(err[0])
with _CACHE_LOCK:
fc = _FRAME_CACHE.get(key_h)
bucket, keys = list_hour_keys(site, date, hr)
n = len(keys)
dframes = []
done = 0
for i in range(0, n, 2):
chunk = keys[i:i + 2]
with _DECODE_SEM:
with ProcessPoolExecutor(max_workers=N_PROC) as px:
pfuts = [px.submit(dealias_volume, bucket, k, VOL_CACHE_DIR)
for k in chunk]
for fut in as_completed(pfuts):
done += 1
_p(0.05 + 0.85 * done / n,
f"Region-based dealiasing {done}/{n} volumes "
f"({_DEALIAS_ENGINE})…")
dframes.extend(fut.result())
dframes.sort(key=lambda f: f["time"])
dframes = dframes[:MAX_FRAMES]
_p(0.95, "Rebuilding bundle with dealiased velocity…")
by_field = dict(fc["by_field"])
by_field[DEALIAS_NAME] = dframes
tpl = build_bundle_page(by_field, site, fc["slat"], fc["slon"],
_share_base(site, date, hr))
with _CACHE_LOCK:
_PAGE_CACHE[key_h] = tpl
_FRAME_CACHE[key_h] = dict(by_field=by_field,
slat=fc["slat"], slon=fc["slon"])
return tpl
def _msg(text):
return text, ""
def prepare_archive(site, year, month, day, hour):
"""Bundle the hour's raw Level 2 volumes into SITEyyyymmdd_HHUTC.zip
(files are pulled from the volume cache; anything missing is fetched).
The volumes are internally compressed already, so members are stored."""
m = re.match(r"\s*([A-Za-z]{4})\b", site or "")
if not m:
raise gr.Error("Pick a site first.")
site = m.group(1).upper()
date = dt.date(int(year), int(month), int(day))
hr = int(str(hour)[:2])
bucket, keys = list_hour_keys(site, date, hr)
if not keys:
raise gr.Error("No Level 2 volumes for this selection.")
with ThreadPoolExecutor(max_workers=6) as ex:
list(ex.map(lambda k: _safe_download(bucket, k, VOL_CACHE_DIR), keys))
out = os.path.join(VOL_CACHE_DIR, f"{site}{date:%Y%m%d}_{hr:02d}UTC.zip")
if not os.path.exists(out):
tmp = out + ".part"
with zipfile.ZipFile(tmp, "w", zipfile.ZIP_STORED) as zf:
for k in keys:
p = os.path.join(VOL_CACHE_DIR, os.path.basename(k))
if os.path.exists(p):
zf.write(p, arcname=os.path.basename(k))
os.replace(tmp, out)
return out
# ----------------------------------------------------------------------------- ui
ILLINI = dict(orange="#FF5F05", blue="#13294B", blue2="#1D3866", blue3="#25457F",
storm_light="#C8C6C7", severe="#C84113")
ILLINI_THEME = gr.themes.Default(
primary_hue=gr.themes.Color(
c50="#FFF1E8", c100="#FFE1CC", c200="#FFC39A", c300="#FFA268",
c400="#FF8136", c500="#FF5F05", c600="#E25504", c700="#C84113",
c800="#9E3610", c900="#7A2A0C", c950="#571E08"),
font=[gr.themes.GoogleFont("Source Sans 3"), "system-ui", "sans-serif"],
).set(
body_background_fill=ILLINI["blue"],
body_background_fill_dark=ILLINI["blue"],
body_text_color="#FFFFFF", body_text_color_dark="#FFFFFF",
block_background_fill=ILLINI["blue2"],
block_background_fill_dark=ILLINI["blue2"],
border_color_primary=ILLINI["blue3"],
border_color_primary_dark=ILLINI["blue3"],
input_background_fill=ILLINI["blue"],
input_background_fill_dark=ILLINI["blue"],
button_primary_background_fill=ILLINI["orange"],
button_primary_background_fill_hover="#E25504",
button_primary_text_color="#FFFFFF",
)
ILLINI_CSS = """
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@600;700;800&family=Source+Sans+3:wght@400;600;700&display=swap');
.gradio-container { background: #13294B !important; max-width: 100% !important;
padding: 0 !important;
font-family: 'Source Sans 3','Source Sans Pro',system-ui,Arial,sans-serif !important; }
/* the app wrapper (<main class="fillable app">) is centred at 1280px with 32px
padding by default — make the content span the full window with 2px edges */
.gradio-container > main, .gradio-container .fillable, .gradio-container main.app {
max-width: 100% !important; margin: 0 !important; padding: 2px !important; }
/* collapse all inter-element spacing so the map gets the window */
.gradio-container .gap, .gradio-container .wrap,
.gradio-container .contain { gap: 2px !important; }
.gradio-container .html-container { padding: 0 !important; margin: 0 !important; }
.gradio-container .block { margin: 0 !important; }
/* the map fills edge-to-edge */
#map-html, #map-html .html-container, #map-html .block { padding: 0 !important; }
.gradio-container h1 { font-family: 'Montserrat','Arial Black',sans-serif !important;
font-weight: 800 !important; text-transform: uppercase; letter-spacing: .06em;
font-size: 17px !important; margin: 0 !important;
color: #fff !important; border-bottom: 3px solid #FF5F05;
padding-bottom: 2px; display: inline-block; }
.gradio-container .prose a { color: #FF8136 !important; }
.gradio-container .prose, .gradio-container .prose p { color: #C8C6C7 !important;
font-size: 13px !important; }
.gradio-container .prose p { margin: 2px 0 !important; }
.gradio-container .block { padding: 2px 6px !important; }
.gradio-container .form { gap: 2px !important; }
.gradio-container .gap, .gradio-container .gradio-row { gap: 3px !important; }
/* compact one-row control strip */
#ctrl-row { flex-wrap: nowrap !important; }
.gradio-container .block > label,
.gradio-container span[data-testid="block-info"] {
font-size: 11px !important; margin-bottom: 1px !important; }
.gradio-container input, .gradio-container .gradio-dropdown input {
font-size: 13px !important; }
.gradio-container .gradio-dropdown { min-height: 0 !important; }
#mode-sw .wrap { flex-direction: row !important; gap: 4px !important;
flex-wrap: nowrap !important; }
#mode-sw label { padding: 0 11px !important; font-size: 13px !important;
height: 38px !important; display: flex !important;
align-items: center !important; box-sizing: border-box !important; }
#ctrl-row button { font-size: 13px !important; padding: 6px 8px !important; }
/* visually grey the time dropdowns when disabled (Live mode) — Gradio sets
the input disabled but doesn't dim it, so the lock wasn't obvious */
#ctrl-row .block:has(input:disabled) { opacity: .45 !important; }
#ctrl-row .block:has(input:disabled) input { cursor: not-allowed !important; }
@media (max-width: 900px) { #ctrl-row { flex-wrap: wrap !important; } }
footer { display: none !important; }
#dax-trigger, #rt-refresh, #rt-force, #site-go, #site-jump { display: none !important; }
/* Map panel: one consistent height across states; 100dvh so iOS Safari's
address-bar show/hide doesn't resize the radar mid-scroll. */
#map-html iframe {
height: calc(100vh - 170px) !important;
height: calc(100dvh - 170px) !important;
min-height: 460px !important; box-sizing: border-box !important; }
@media (max-width: 700px) {
.gradio-container { padding: 8px 10px 4px !important; }
.gradio-container h1 { font-size: 14px !important; letter-spacing: .03em !important; }
.gradio-container .prose, .gradio-container .prose p {
font-size: 10px !important; line-height: 1.3 !important; }
#ctrl-row .block > label,
#ctrl-row span[data-testid="block-info"] { font-size: 10px !important; }
#map-html iframe {
height: calc(100vh - 300px) !important;
height: calc(100dvh - 300px) !important;
min-height: 380px !important; }
}
"""
# CliMAS logo: use logo.png if present in the repo, else an inline SVG replica
if os.path.exists(os.path.join(os.path.dirname(__file__), "logo.png")):
_b64 = base64.b64encode(
open(os.path.join(os.path.dirname(__file__), "logo.png"), "rb").read()
).decode()
LOGO_HTML = (f'<img src="data:image/png;base64,{_b64}" alt="CliMAS" '
f'style="width:46px;height:46px;border-radius:10px;flex:none"/>')
else:
LOGO_HTML = """
<svg viewBox="0 0 400 400" width="46" height="46"
style="border-radius:10px;flex:none" aria-label="CliMAS">
<circle cx="200" cy="200" r="200" fill="#13294B"/>
<path d="M150 70 h100 v42 h-26 v76 h26 v42 h-100 v-42 h26 v-76 h-26 z"
fill="#FF5F05" stroke="#fff" stroke-width="10"/>
<g fill="#fff" text-anchor="middle"
font-family="'Source Sans 3','Source Sans Pro',sans-serif"
font-weight="600" font-size="28">
<text x="200" y="268">Climate,</text>
<text x="200" y="298">Meteorology &amp;</text>
<text x="200" y="328">Atmospheric</text>
<text x="200" y="358">Sciences</text>
</g>
</svg>"""
HEADER_HTML = f"""
<div style="display:flex;align-items:center;gap:10px">
{LOGO_HTML}
<div>
<h1>NEXRAD Level 2 low level sweep browser</h1>
<p style="color:#C8C6C7;font-size:12px;margin:1px 0">
Brought to you by the
<a href="https://climas.illinois.edu" target="_blank"
style="color:#FF8136">Department of Climate, Meteorology &amp;
Atmospheric Sciences</a>, University of Illinois Urbana–Champaign</p>
</div>
</div>"""
# block-I favicon (Illini Orange I on Illini Blue), as an SVG data URI
_FAVICON_SVG = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">'
'<rect width="400" height="400" rx="72" fill="#13294B"/>'
'<path d="M150 70 h100 v42 h-26 v76 h26 v42 h-100 v-42 h26 v-76 h-26 z" '
'fill="#FF5F05" stroke="#fff" stroke-width="10"/></svg>'
)
_FAVICON_B64 = base64.b64encode(_FAVICON_SVG.encode()).decode()
OG_HEAD = f"""
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,{_FAVICON_B64}"/>
<meta property="og:title" content="NEXRAD level 2 browser"/>
<meta property="og:description" content="Gate-native WebGL browsing of archived WSR-88D 0.5° scans (SAILS-aware) from the AWS NEXRAD Level 2 archive."/>
<meta property="og:image" content="{THUMB_URL}"/>
<meta property="og:type" content="website"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content="NEXRAD level 2 browser"/>
<meta name="twitter:image" content="{THUMB_URL}"/>
<script>
// show only elapsed time in progress timers (strip Gradio's "/eta" part)
addEventListener('DOMContentLoaded', function () {{
var rxFull = /^\\s*\\d+(?:\\.\\d+)?\\/\\d+(?:\\.\\d+)?s\\s*$/;
function fixEl(el) {{
if (!el || !el.childNodes || !rxFull.test(el.textContent)) return;
var seen = false;
el.childNodes.forEach(function (n) {{
if (n.nodeType !== 3) return;
var v = n.nodeValue;
if (!seen) {{
var m = v.match(/^(\\s*\\d+(?:\\.\\d+)?)\\/\\d+(?:\\.\\d+)?(s\\s*)?$/);
if (m) {{ n.nodeValue = m[1] + (m[2] || ''); seen = true; return; }}
var i = v.indexOf('/');
if (i >= 0) {{ n.nodeValue = v.slice(0, i); seen = true; }}
}} else if (/^\\d+(?:\\.\\d+)?$/.test(v.trim())) {{
n.nodeValue = '';
}}
}});
}}
new MutationObserver(function (ms) {{
ms.forEach(function (m) {{
if (m.type === 'characterData') fixEl(m.target.parentElement);
if (m.addedNodes) m.addedNodes.forEach(function (an) {{
if (an.nodeType === 1 && an.textContent.length < 24) fixEl(an);
}});
}});
}}).observe(document.body, {{subtree: true, characterData: true,
childList: true}});
}});
</script>
<script>
// Embedded on huggingface.co the Space iframe is auto-resized (via
// iframeResizer) from the app's reported content height. The map iframe
// is sized with calc(100vh - 205px), so content height is always
// ~viewport + footer: the host grows the iframe, 100vh grows, the map
// grows again - a one-way ratchet that any reflow re-triggers. Break the
// loop by pinning the map to a fixed pixel height whenever we are
// framed. NOTE: must run immediately - this script can be evaluated
// after DOMContentLoaded has already fired.
(function () {{
if (window.self === window.top) return; // direct visit: keep vh sizing
var pinH = null;
function pin() {{
var ifs = document.querySelectorAll('.html-container iframe');
for (var i = 0; i < ifs.length; i++) {{
var f = ifs[i];
if (f.dataset.hpin) continue;
f.dataset.hpin = '1';
if (pinH === null) {{
// leave room for the compact header + control row + footer so the
// map fills the granted viewport with minimal dead space
pinH = Math.max(480, window.innerHeight - 170);
}}
f.style.height = pinH + 'px';
}}
}}
function arm() {{
pin();
new MutationObserver(pin).observe(document.body,
{{subtree: true, childList: true}});
}}
if (document.readyState === 'loading') {{
addEventListener('DOMContentLoaded', arm);
}} else {{
arm();
}}
}})();
</script>
"""
gr.set_static_paths(paths=[VOL_CACHE_DIR]) # serves warnings.json
with gr.Blocks(title="NEXRAD Level 2 low level sweep browser", head=OG_HEAD,
theme=ILLINI_THEME, css=ILLINI_CSS) as demo:
gr.HTML(HEADER_HTML)
with gr.Row(elem_id="ctrl-row"):
mode_sw = gr.Radio(["Archive", "Live"], value="Archive",
label="Mode", scale=1, min_width=172,
elem_id="mode-sw")
site_tb = gr.Dropdown(SITE_CHOICES, value="KILX", label="Site",
allow_custom_value=True, scale=2, min_width=150)
# field is chosen via the in-map selector (Z/V/ZDR/CC/Z+V/2×2); this
# dropdown is kept hidden so handlers + share-link round-trips still work
field_dd = gr.Dropdown(list(FIELDS) + [QUAD, ZVF], value="Reflectivity",
label="Field", scale=2, min_width=140,
visible=False)
year_dd = gr.Dropdown(YEARS, value="2023", label="Year (UTC)",
scale=1, min_width=84)
month_dd = gr.Dropdown(MONTHS, value="06", label="Month (UTC)",
scale=1, min_width=72)
day_dd = gr.Dropdown(DAYS, value="29", label="Day (UTC)",
scale=1, min_width=72)
hour_dd = gr.Dropdown(HOURS, value="18:00", label="Hour (UTC)",
scale=1, min_width=84)
with gr.Column(scale=1, min_width=150):
go = gr.Button("Go", variant="primary")
dl = gr.DownloadButton("Download raw (.zip)",
interactive=False, size="sm",
visible=False)
# hidden trigger: the in-map "Dealias V" checkbox clicks this
# via parent.document when the hour hasn't been dealiased yet
dax = gr.Button("Dealias velocity", elem_id="dax-trigger",
size="sm")
# hidden: the live page clicks this every 5 minutes
rtr = gr.Button("Refresh live", elem_id="rt-refresh", size="sm")
# hidden: the in-map refresh button clicks this to force a live
# re-decode now (picks up new scans) without reloading the iframe
frr = gr.Button("Force live", elem_id="rt-force", size="sm")
# hidden: the in-map site picker writes "<ICAO>\t<field>" here and
# clicks site-go to switch radars in place (with a progress bar)
site_jump = gr.Textbox(elem_id="site-jump")
sgo = gr.Button("Go site", elem_id="site-go", size="sm")
status = gr.Markdown()
map_html = gr.HTML(elem_id="map-html")
shared = gr.State(False)
view_st = gr.State(None)
deal_st = gr.State(False) # live dealiasing on for this session
def browse_h(mode, site, field, year, month, day, hour,
progress=gr.Progress()):
rt = (mode == "Live")
info, page = browse(site, field, year, month, day, hour,
progress=progress, realtime=rt)
return info, page, gr.DownloadButton(
interactive=bool(page) and not rt)
go.click(browse_h,
[mode_sw, site_tb, field_dd, year_dd, month_dd, day_dd, hour_dd],
[status, map_html, dl], show_progress_on=map_html)
def set_mode(mode, site, field, year, month, day, hour,
progress=gr.Progress()):
rt = (mode == "Live")
info, page = browse(site, field, year, month, day, hour,
progress=progress, realtime=rt)
dd = [gr.Dropdown(interactive=not rt) for _ in range(4)]
return (*dd, info, page,
gr.DownloadButton(interactive=bool(page) and not rt),
False) # reset live-dealias preference on mode change
mode_sw.change(set_mode,
[mode_sw, site_tb, field_dd, year_dd, month_dd, day_dd,
hour_dd],
[year_dd, month_dd, day_dd, hour_dd, status, map_html, dl,
deal_st],
show_progress_on=map_html)
def rt_refresh(site, field, progress=gr.Progress()):
info, page = browse(site, field, "", "", "", "",
progress=progress, realtime=True)
return info, page
rtr.click(rt_refresh, [site_tb, field_dd], [status, map_html],
show_progress_on=map_html)
dl.click(prepare_archive,
[site_tb, year_dd, month_dd, day_dd, hour_dd], dl)
def dealias_h(mode, site, field, year, month, day, hour,
progress=gr.Progress()):
if mode == "Live":
info, page = browse(site, "Radial velocity", year, month, day,
hour, progress=progress, realtime=True,
want_deal=True)
else:
info, page = browse(site, DEALIAS_NAME, year, month, day, hour,
progress=progress)
return info, page, (mode == "Live")
dax.click(dealias_h,
[mode_sw, site_tb, field_dd, year_dd, month_dd, day_dd,
hour_dd],
[status, map_html, deal_st], show_progress_on=map_html)
def site_jump_h(jump, mode, year, month, day, hour, progress=gr.Progress()):
# jump = "<ICAO>\t<field/mode>" from the in-map site picker; load that
# radar in place (live -> latest; archive -> same hour), keeping field.
parts = (jump or "").split("\t")
site = parts[0].strip()
field = parts[1] if len(parts) > 1 and parts[1] else "Reflectivity"
rt = (mode == "Live")
info, page = browse(site, field, year, month, day, hour,
progress=progress, realtime=rt)
return (info, page,
gr.DownloadButton(interactive=bool(page) and not rt),
gr.Dropdown(value=site))
sgo.click(site_jump_h,
[site_jump, mode_sw, year_dd, month_dd, day_dd, hour_dd],
[status, map_html, dl, site_tb], show_progress_on=map_html)
# server-side live refresh: a Timer tick every 2 minutes replaces the
# in-page hidden-button click (programmatic DOM clicks proved unreliable
# across browsers). Archive sessions skip at zero cost.
rt_timer = gr.Timer(60) # re-check the live feed for new chunks every minute
def rt_tick(mode, site, field, deal, progress=gr.Progress()):
# Only refresh the live frame file; the in-page poller merges the new
# frames in place when ready. Crucially this has NO Gradio outputs, so
# the display is never marked "generating"/greyed during an autoupdate.
if mode == "Live":
try:
browse(site, "Radial velocity" if deal else field,
"", "", "", "", progress=progress,
realtime=True, want_deal=deal)
except Exception:
pass
rt_timer.tick(rt_tick, [mode_sw, site_tb, field_dd, deal_st],
None, show_progress="hidden")
def force_refresh(mode, site, field, deal, progress=gr.Progress()):
# user hit the in-map refresh: drop the short-lived RT cache so we
# re-check the feed for new scans now and rewrite live_<site>.json.
# NO Gradio outputs -> the display is never greyed; the in-page poller
# (with its own spinning icon) merges the result when ready.
if mode == "Live":
try:
_RT_CACHE.pop(site, None)
browse(site, "Radial velocity" if deal else field,
"", "", "", "", progress=progress,
realtime=True, want_deal=deal)
except Exception:
pass
frr.click(force_refresh, [mode_sw, site_tb, field_dd, deal_st], None,
show_progress="hidden")
gr.Markdown(
"Level 2 decoding by [xradar](https://github.com/swnesbitt/xradar) "
"(openradar; S. Nesbitt fork) "
"· velocity dealiasing by the Rust region-based dealiaser "
"[region-dealias](https://github.com/swnesbitt/region-dealias) (S. Nesbitt) "
"· colormaps from [cmweather](https://github.com/openradar/cmweather) "
"and [CMasher](https://cmasher.readthedocs.io) "
"· data from the [NOAA NEXRAD Level II archive on AWS](https://registry.opendata.aws/noaa-nexrad/) "
"· [Source code on GitHub](https://github.com/swnesbitt/nexrad-level2-browser)."
)
def init_values(request: gr.Request):
"""Fast: restore dropdown values (and map view) from URL params."""
q = dict(request.query_params) if request else {}
deal = q.get("dealias") == "1"
rt = q.get("rt") == "1"
view = None
try:
if "lat" in q and "lon" in q and "zoom" in q:
view = [float(q["lat"]), float(q["lon"]),
max(3, min(14, int(float(q["zoom"]))))]
except (TypeError, ValueError):
view = None
site = q.get("site", "KILX").upper()[:4]
field = q.get("field", "Reflectivity")
if field not in FIELDS and field not in (QUAD, ZVF, DEALIAS_NAME):
field = "Reflectivity"
year = q.get("year", "2023")
month = q.get("month", "06").zfill(2)
day = q.get("day", "29").zfill(2)
hour = q.get("hour", "18")[:2].zfill(2) + ":00"
year = year if year in YEARS else "2023"
month = month if month in MONTHS else "06"
day = day if day in DAYS else "29"
hour = hour if hour in HOURS else "18:00"
arch = not rt
return (site, field,
gr.Dropdown(value=year, interactive=arch),
gr.Dropdown(value=month, interactive=arch),
gr.Dropdown(value=day, interactive=arch),
gr.Dropdown(value=hour, interactive=arch),
("site" in q or "year" in q), view, deal,
"Live" if rt else "Archive")
def maybe_browse(mode, view, deal, site, field, year, month, day,
hour, progress=gr.Progress()):
"""Slow: auto-load on every visit — the default case on a plain
visit, the shared view (incl. map center/zoom) when params exist."""
rt = (mode == "Live")
info, page = browse(site, field, year, month, day, hour,
progress=progress, view=view, want_deal=deal,
realtime=rt)
return info, page, gr.DownloadButton(
interactive=bool(page) and not rt)
demo.load(
init_values, None,
[site_tb, field_dd, year_dd, month_dd, day_dd, hour_dd, shared,
view_st, deal_st, mode_sw],
).then(
maybe_browse,
[mode_sw, view_st, deal_st, site_tb, field_dd, year_dd, month_dd,
day_dd, hour_dd],
[status, map_html, dl], show_progress_on=map_html,
)
def _live_poller():
"""Keep live_<site>.json current for recently-viewed live sites regardless
of any browser tab (client timers freeze when a tab is backgrounded). Calls
_realtime_compute directly so it does NOT refresh the activity timestamp —
sites age out _LIVE_ACTIVE_TTL after the last real view."""
noop = lambda *a, **k: None
while True:
try:
now = time_mod.time()
active = [(s, d) for s, (ts, d) in list(_LIVE_ACTIVE.items())
if now - ts < _LIVE_ACTIVE_TTL]
for s, deal in active:
try:
_realtime_compute(s, "Reflectivity", noop, None, deal)
except Exception:
pass
except Exception:
pass
time_mod.sleep(60)
def start_background():
"""Start the startup pre-render + the warnings/live poller daemons. Kept
out of module import so that (a) ProcessPoolExecutor workers, which re-import
this module under the macOS 'spawn' start method, don't relaunch the daemons
(or recurse), and (b) the desktop launcher can start them explicitly."""
# pre-render the default case at startup so first visitors get it instantly
threading.Thread(target=lambda: browse(*DEFAULT_VIEW), daemon=True).start()
# keep live warnings fresh for real-time mode
threading.Thread(target=_warn_poller, daemon=True).start()
# keep live radar frames fresh for recently-viewed sites (server-side, so it
# doesn't depend on a client tab driving the refresh timer)
threading.Thread(target=_live_poller, daemon=True).start()
if __name__ == "__main__":
start_background()
# ssr_mode=False -> Python serves the OG-patched index.html, so social
# link previews show our title/thumbnail instead of Gradio defaults
demo.launch(ssr_mode=False)