Architecture¶
Windfenster is one Rust binary that serves an API and a React single-page app built into it, keeps an SQLite database and runs background loops that fetch forecasts, measurements and the map's model fields. The browser does the drawing, including the wind map in WebGL2.
flowchart LR
subgraph Sources
OM[Open-Meteo<br>point forecasts]
AWC[aviationweather.gov<br>METAR, TAF]
DWD[DWD Open Data<br>10-min wind, ICON-D2, ICON global]
ECMWF[ECMWF open data<br>IFS 0.25°]
PEG[PEGELONLINE<br>water levels]
PM[Protomaps / Mapterhorn<br>basemap, relief]
end
subgraph Server["windfenster (one binary)"]
ING[wf-ingest<br>scheduler]
MAP[wf-map<br>GRIB2 → textures,<br>basemap cutting]
ALM[wf-alarm]
CORE[wf-core<br>verification, mix,<br>models, database]
API[wf-api<br>axum + embedded SPA]
DB[(SQLite)]
FILES[(map files<br>on disk)]
end
Browser[Browser<br>React + MapLibre + WebGL2]
OM & AWC & DWD & PEG --> ING
DWD & ECMWF & PM --> MAP
ING --> CORE --> DB
MAP --> FILES
ALM --> DB
FILES --> ALM
DB & FILES --> API --> Browser
The crates¶
| Crate | What it does |
|---|---|
wf-core |
The domain without input or output of its own: models, units, stations and places on the 0.01° grid, forecast series, measurements, the TAF's hourly series, gauges, verification and the mix, accounts, favourites, gear, alarm rules, the database queries. |
wf-ingest |
Fetching: Open-Meteo forecasts and place search, station lists (OurAirports, aviationweather.gov, DWD), measurements (METAR, DWD 10-minute values), TAFs, PEGELONLINE, the archives for the wind statistics; and the scheduler that runs it all. |
wf-map |
The wind map's data: GRIB2 from the DWD (ICON-D2, ICON global) and ECMWF decoded into PNG textures and raw texels, runs kept on disk; cutting the basemap and relief from Protomaps and Mapterhorn. |
wf-alarm |
Alarms: forecast windows from the map's fields, live checks from the measurements, Web Push and e-mail. |
wf-api |
The HTTP side (axum): JSON API, response caches, the map's files and tiles, station pages with meta tags for search engines, the embedded SPA. |
windfenster |
The binary: serve, migrate, import-stations, probe; reads the configuration and starts the loops. |
The web app¶
web/ is a React app built with Vite: TanStack Router (file-based
routes in src/routes) and TanStack Query for the API, Tailwind CSS
v4 with shadcn/ui components, Paraglide JS
for German and English, MapLibre GL JS for the map and own WebGL2 code
for the wind.
| Folder | |
|---|---|
src/routes/ |
pages: start, station, point, map, gear, account, sign-in, sources, admin |
src/components/forecast/ |
the ten days: quick view, day cards, model chips |
src/components/map/ |
the map: colour field, particles, pins, timeline, spot card |
src/components/live/, verification/ |
live tile, history and statistics; comparison chart, ranking, agreement |
src/lib/ |
logic without UI, each with Vitest tests: forecast days, model choice, agreement, METAR decoder, TAF texts, units, wind map maths |
src/bindings/ |
the API's types, generated from Rust by ts-rs |
messages/ |
every text, de.json and en.json |
The API never sends prose: errors and notices are codes (ErrorCode, Notice), and the app maps
each to a text in the viewer's language. A missing mapping fails the type check.
Shared types¶
API types are Rust structs that derive ts_rs::TS. cargo test writes them to
web/src/bindings, and the app imports them from there, so a field renamed on the server breaks
the app's type check, not its users. CI fails when the committed bindings are out of date.
The database¶
SQLite through sqlx, migrations in migrations/ (applied on
start; a pushed migration is never changed, sqlx checks their checksums).
| Tables | Hold |
|---|---|
station, place, model, model_run |
stations (METAR, DWD), places on the 0.01° grid, the models and their runs |
forecast_series |
one row per place, model and run: hourly wind, gusts, direction, weather |
observation, taf |
measurements and airport forecasts, 13 months at watched stations |
gauge |
water levels, the newest per gauge |
verification_daily |
each model's errors per station, UTC day and lead time |
wind_stats |
the ten-year summary per station |
account, passkey, session |
accounts, WebAuthn credentials, sessions (only the token's hash) |
favorite, gear, alarm, push_subscription, alarm_sent |
spots, gear, alarms and what was sent |
setting, admin_log, invite, alarm_delivery, usage_daily |
the admin page: settings changed there, what admins did, invitations, every alarm message handed to a channel, counters per day (Open-Meteo calls, e-mails) |
The wind map's fields are files, not rows: {WF_DATA_DIR}/map/{model}/{run}/{step}.png, .rgba
and .json, plus the basemap and relief as PMTiles. See The wind map.
Places and units¶
- Places: every forecast is stored per point on the 0.01° grid (
wf_core::geo::GridPoint); a station uses the grid point of its coordinates, so a station and a click next to it share one forecast. - Units: the server stores and sends wind in knots; the app converts to m/s or km/h.
The background loops¶
windfenster serve starts them next to the HTTP server (WF_SCHEDULER=false turns them off):
| Every | What |
|---|---|
| 5 min | new model runs on Open-Meteo; refresh the places opened within 7 days |
| 10 min | all METARs (one file), all PEGELONLINE gauges; the map's models (ICON-D2, ECMWF; ICON global in its own loop); the alarms |
| 30 min | TAFs; DWD 10-minute values for watched stations |
| hourly | verification of every model at every station with measurements |
| daily | the basemap and relief: rebuilt when old or when their settings changed |
| a minute | detail basemap spots around places people look at, two at a time |
| weekly | the station lists |
Settings the admin page changes live in wf_core::admin::settings: the loops and the API read the
current ones each time they need a value (an environment variable that is set wins over a stored
one, which wins over the default), so a change takes effect without a restart.
Every loop reports each run (when, how long, what came of it) and each outside source's answers to
a status board in memory (wf_core::status), which the admin page reads; the log's warnings and
errors go there too, the last 500. The admin page can ask a task to run now: its loop wakes.
Each step of the scheduler has a time limit (2 minutes for one file like the METARs, 5 for the per-station fetches, 10 for the forecast refresh): a source that hangs is given up and tried again next turn, so it cannot hold up the others. Every loop runs under a supervisor that starts it again a minute after a panic.
Details per source: Forecasts and models, Measurements, The wind map, The basemap, Accounts and alarms.
Pages for search engines¶
The app renders in the browser, but /station/{slug} is served by the server with the station's
title, description, Open Graph tags and JSON-LD filled into index.html (wf-api/src/meta.rs),
so a link preview and a search result show the station's name and region.
Caches¶
The server keeps hot answers in memory (moka): assembled forecasts per place, place search results, rankings, the map's pins and the pieces of ICON global. The map's textures are sent with an immutable cache header: a run's hour never changes.
The web app is compressed once, when it is built (web/scripts/precompress.mjs): every text
file gets a Brotli and a gzip copy at the highest level (9.4 MB → 1.5 MB with Brotli), and the
server sends the copy the browser accepts as it is. Compressing it per new visitor took about
half a second of CPU on a desktop, several on a Raspberry Pi. Browsers ask for Brotli over
HTTPS only; over plain http:// they get gzip.