Real-time convective storm tracker built on NOAA GOES-19 open data. Cloud tops are rendered in 3D over a MapLibre map, storms are detected and tracked across scans, and each cell gets a motion vector, forecast path and ETA to the selected location (default: Umuarama, PR).
- TanStack Start (SPA mode, no SSR) + TanStack Router + TanStack Query
- shadcn/ui + Tailwind CSS v4
- MapLibre GL (3D
fill-extrusionfor cloud tops) - h5wasm to read GOES NetCDF4 files on the server
All data is read directly from the public noaa-goes19 bucket on AWS (no API key):
| Product | Use |
|---|---|
ABI-L2-CMIPF band 13 (10.3 µm) |
Cloud top brightness temperature, storm detection |
ABI-L2-ACHAF |
Cloud top height (3D extrusion height) |
GLM-L2-LCFA |
Lightning flashes |
ABI-L2-RRQPEF |
Satellite rainfall rate estimate (mm/h) |
Environment fields come from the GFS 0.25° model on the public noaa-gfs-bdp-pds bucket. Only the needed GRIB2 messages are fetched with HTTP range requests (using the .idx sidecar) and parsed with @mattnucc/gribberish: mean sea level pressure, CAPE, CIN, lifted index, 2 m temperature and dew point, and winds/heights at 10 m, 925, 850, 700 and 500 hPa.
Files are cached in the OS temp folder (storm-tracker-cache) for 4 hours.
- The last 12 full-disk scans (10 min apart) are cropped to a 400 km radius around the target and binned to a 0.05° grid.
- Storms are segmented with multiple brightness temperature thresholds (235 K down to 195 K) so large systems are split into their convective cores.
- Cores are matched using motion-compensated overlap, area and distance. IDs are retained across overlapping requests in a bounded in-memory cache. IDs reset on server restarts or cache eviction; deployments with independent workers do not share identities.
- Motion uses normalized cross-correlation of local temperature matrices 10 to 20 minutes apart, with coverage, texture, correlation and ambiguity checks. The last six valid-window measurements (up to 50 minutes) are combined with outlier rejection and modest recency weighting. Centroid changes no longer set the velocity. One missing estimate is tolerated; two missing scans or insufficient history hide the forecast and mark motion uncertain.
- Closest approach and ETA use the motion vector and an equivalent-area cloud radius. The dashed line is a constant-motion extrapolation out to 180 minutes, not a prediction of storm growth or decay. Skill at that range is unverified.
- Satellite severe-weather signals per cell: lightning jump (sudden rise in GLM flash rate) and overshooting top (minimum brightness temperature much colder than the surrounding anvil).
- Storm environment per cell from GFS: LCL height, 0–6 km bulk shear, storm-relative helicity computed with the cell's own tracked motion, and a significant-tornado-parameter style index (CAPE × LCL × SRH × shear). In the Southern Hemisphere SRH is negative for favourable environments; the index uses its magnitude.
The map can overlay 2D fields from GFS (sea level pressure with isobars, CAPE, 0–6 km shear, |SRH| 0–3 km) and the GOES rainfall rate.
/api/forecast merges two hourly point forecasts for Umuarama (fixed, it does not follow the selected target) by local hour, for the next 36 hours: the ECMWF IFS 9 km forecast from Open-Meteo (CC-BY 4.0, rain probability from the ECMWF ensemble) and the Simepar county forecast scraped from forecast_by_counties/4128104. Either source may fail without hiding the other. The card shows only rain, amount and chance per source; rows where both give 70% or more are highlighted.
The radar layer is the IPMet/Unesp PPI mosaic (Bauru and Presidente Prudente radars), requested from their MapServer WMS as a transparent PNG already in Web Mercator (EPSG:900913) over the layer's own bounds (lon -55.876 to -44.516, lat -26.4 to -18.08). It is proxied through /api/radar because the server only answers requests with an IPMet referer. IPMet keeps only the last five scans (about 30 minutes, last.map to last4.map with their times in lastPPI*.txt); /api/radar lists their times and serves each one by time, and the timeline shows the scan closest to the selected frame (within 8 minutes, the live frame always shows the newest scan). The WMS and WCS only expose the coloured palette, not raw dBZ values.
The Paraná state radar mosaic published by Simepar (radar_msc) is a plain JPEG with a baked-in basemap and no georeferencing. It was georeferenced once by detecting the 26 city markers in the image and fitting them to known coordinates: the image is an axis-aligned lat/lon box (lon −57.1419 to −45.8144, lat −28.5076 to −21.0058) with a 0.85 px RMS residual (about 1 km). At runtime the images are proxied through /api/simepar and rows are resampled to Web Mercator. The layer has two styles with their own opacity: Original (the image as published, basemap included) and HD (the default). HD redraws the radar from the image: each pixel is matched to the nearest colour of the legend gradient sampled from the image (saturation at least 0.7, distance at most 60), which gives an intensity from 0 to 1; a 3x3 presence average drops isolated speckles and fills thin labels, and the field is resampled at twice the resolution in Web Mercator with bilinear interpolation of the values (not the colours) before being painted with the legend scale. It is a cleaner rendering of the same data, not added detail. Simepar keeps eight scans in its public Riak bucket (lb01.simepar.br/riak/pgw-radar/product1.jpeg to product8.jpeg), product1 being the newest and each next one 10 minutes older; the timeline maps them by position (live frame to product1, each step back to the next product) without reading timestamps, so they may be a few minutes off the satellite frame. product0 is a thumbnail and is ignored. Simepar also runs THREDDS and GeoServer (www2.simepar.br, produtos.simepar.br/geosimepar) but they answer 403 to the public.
Requires Node 24 (nvm use 24).
npm install
npm run devOpen http://localhost:3000. The first request downloads ~150 MB from NOAA and can take up to a minute; later refreshes reuse the cache.
The app needs a Node runtime for the /api/frames route, so it cannot run on static hosts such as GitHub Pages. It is set up for Vercel through the Nitro Vite plugin: import the repository at https://vercel.com/new and deploy with the defaults (npm run build, Node 24). The first request after a cold start downloads ~150 MB from NOAA and can take up to a minute; later requests reuse the cache in /tmp.
GET /api/frames?lat=-23.7661&lon=-53.3206&frames=6&radius=400
Português and English are selectable in the layer panel; the choice is stored in the browser. The ruler adds map-anchored points with cumulative distance in km. Finish drawing to resume normal map clicks; Undo and Clear edit the line. Weather refreshes do not move or remove it. GFS fields and pressure contours are clipped to the same target radius as the clouds.
Run npm test, npx tsc --noEmit and npm run build. Synthetic regression checks cover translation despite cloud cooling, vector outliers, missing scans, identity continuity, glossary translations and circular clipping. These checks do not establish meteorological forecast accuracy; replaying observed cases is the next validation step.
The existing GFS input already includes winds at multiple heights for shear and helicity. Surface winds alone are not the motion of a deep cloud system. GOES also publishes Derived Motion Winds, tracking features across images and associating vectors with pressure levels. This update measures the existing infrared matrices directly; it does not ingest the DMW product or blend GFS winds into the trajectory. Pysteps motion estimation provides a reference for image-based tracking and outlier filtering; our implementation uses local correlation, not Lucas-Kanade.