A real-time map showing how many people across Europe are experiencing extreme heat. Built by Klimadashboard.
For every hour of the day the tracker answers: how many people are currently living in a grid cell where the temperature exceeds 30 °C (or 35 °C, or whatever threshold you pick)? It also shows how much hotter today is compared to the 1961–1990 average — both as a Europe-wide headline and as a per-cell anomaly map.
DWD ICON-EU forecast (GRIB2)
│ fetched hourly, ~92 steps (0 h … 120 h ahead)
▼
scripts/fetch-dwd.py
│ interpolates to ~175 k population-weighted grid cells
│ computes apparent temperature (Steadman 1994)
│ writes snapshots to SQLite
▼
data/heat-tracker.db ◄──── data/climatology-1961-1990.npz
│ (E-OBS 1961–1990 reference)
▼
SvelteKit API routes (/api/current, /api/grid, …)
│ pre-generated static JSON/GeoJSON served from disk
▼
SvelteKit frontend ← MapLibre GL map
Data source: DWD ICON-EU — Germany's national weather service, open data. The model runs every 3 hours and publishes hourly forecast steps through +78 h, then 3-hourly through +120 h (its maximum); we ingest the full range.
Population grid: ~175,000 cells built from GHS-POP 2020 at 30 arc-second resolution, clipped to Europe (Turkey excluded).
Climatology reference: Per-cell daily-mean and 90th-percentile temperatures computed from E-OBS v30 (1961–1990), smoothed with a ±15-day window. Built once by scripts/build-climatology.py.
- Node.js 22+ (
node --version) - Python 3.10+ with:
pip install cfgrib xarray scipy numpy requests - eccodes for GRIB2 parsing:
brew install eccodes(macOS) orapt-get install libeccodes-dev(Linux)
git clone https://github.com/klimadashboard/heat-tracker
cd heat-tracker
npm install
cp .env.example .envIf you want to run the data pipeline locally (optional — see the proxy tip below):
# Fetch the latest ICON-EU run and populate data/heat-tracker.db
python scripts/fetch-dwd.py
# Then start the dev server
npm run devTip: If you just want to work on the frontend without running the Python pipeline, set API_PROXY in .env to proxy requests to the live server:
API_PROXY=https://heat-tracker.eu
| Variable | Default | Description |
|---|---|---|
HEAT_THRESHOLD |
30 |
Default heat threshold in °C for the "affected" headline |
PORT |
3000 |
HTTP server port |
API_PROXY |
— | Dev only: forward /api requests to this URL instead of the local DB |
All scripts live in scripts/. They are separated into one-time setup scripts (run once to generate committed reference files) and the recurring cron script.
scripts/fetch-dwd.py — the entire pipeline, run hourly in production. This is the only scheduled job.
python scripts/fetch-dwd.py [--threshold 30] [--forecast-hours 120]
# First run of the day: also backfill analysis for earlier model runs
python scripts/fetch-dwd.py --backfill-todayDownloads the latest ICON-EU GRIB2 files for T_2M, RELHUM_2M, U_10M, and V_10M; interpolates them to the population grid; computes apparent temperature (Steadman 1994); writes hourly snapshots to data/heat-tracker.db; regenerates the pre-computed GeoJSON and JSON files that the API serves from disk. --forecast-hours defaults to 120 (DWD's actual maximum) so the DB always has several days of forecast on hand — see "Answering ad-hoc questions" below for why that matters.
There's no separate reporting tool — GET /api/current?from=<ISO>&to=<ISO>&threshold=<N>&indicator=temperature is a live, public JSON endpoint that answers arbitrary questions like "how many people in Europe/Germany are exposed to ≥35°C between yesterday and Sunday?" directly against heat-tracker.db, no script to run:
curl "https://heat-tracker.eu/api/current?from=2026-07-29T00:00:00.000Z&to=2026-08-02T23:59:59.000Z&threshold=35"Returns snapshot.totalAffected / totalPopulation / meanAnomalyC (Europe-wide) plus a countries[] array with the same fields per country. Because the DB keeps full history (no pruning) and the hourly cron now fetches 5 days ahead by default, this covers both the recent past and the near future without any extra fetch step.
Any Claude session — Claude Code, claude.ai, or Claude in Slack — can resolve the natural-language date range itself (given today's date) and just fetch that URL to answer a teammate's question. Nothing to install or deploy beyond what's already running.
These scripts were used to build the committed reference files. You don't need to run them unless you're changing the grid or regional boundaries.
scripts/build-climatology.py — builds data/climatology-1961-1990.npz from the E-OBS NetCDF source. Required for the anomaly headlines. The output file (~404 MB) is deployed out-of-band to the server volume — see data/README.md.
# Download E-OBS first (4.3 GB):
# https://www.ecad.eu/download/ensembles/download.php
# → TG (mean temperature) → 0.1° regular grid → Full period
python scripts/build-climatology.py --input data/eobs-tg-0.1deg-v30.ncscripts/build-nuts-grid.py — tags each cell in population-grid.json with its NUTS-1/2/3 region name, writing data/nuts-by-cell.json. Requires data/nuts.geojson (download from Eurostat GISCO).
scripts/extract-population.js — builds data/population-grid.json from real GHS-POP GeoTIFF tiles (30 arc-second raster, summed per 0.125° cell). Tiles are not in git; see data/ghspop-tiles/download.sh.
scripts/extract-turkey-border.js — extracts static/turkey-border.json from Natural Earth data. Turkey is excluded from the European totals for political reasons; the border is drawn as a visual boundary on the map.
scripts/seed-indoor-temps.js — populates the indoor_temps table with 100 synthetic readings. Useful for local development to see the IndoorTemps component with data.
The project ships as two Docker containers defined in docker-compose.yml:
app— the SvelteKit server (Node.js)fetcher— a Python container that stays alive so Coolify's scheduled task runner canexecinto it to runfetch-dwd.pyhourly
Both share a persistent volume (heat-data) mounted at /app/data — this is where the SQLite database, pre-generated GeoJSON/JSON, and the climatology file live.
docker compose up --build- Start the stack and let it run once so the volume and DB are created.
- Copy the climatology file to the server volume (without it, the anomaly headlines show no data):
# Build locally first if you haven't: python scripts/build-climatology.py --input data/eobs-tg-0.1deg-v30.nc # Copy to server (adjust path to your volume): rsync -az data/climatology-1961-1990.npz user@server:/path/to/volume/
- Trigger the cron or wait for the next scheduled run. Check logs for:
- ✅
Climatology loaded: climatology-1961-1990.npz - ✅
Complete. ~92 snapshots written.
- ✅
Set the scheduled task to run every hour — this is the only scheduled job:
docker exec <fetcher-container> python /app/scripts/fetch-dwd.py
The interactive methodology page at /methodology explains the indicator definitions, data sources, and known limitations in plain language.
Key choices documented there:
- "Affected" definition — a person is counted if the peak temperature in their grid cell reaches the threshold at any point during the day (daily maximum, not daily mean).
- Apparent temperature — Steadman (1994), the same formula used by Open-Meteo. Accounts for humidity and wind but not solar radiation.
- Anomaly — today's daily mean temperature minus the per-cell 1961–1990 daily mean for the same calendar day, smoothed over ±15 days.
scripts/ Python data pipeline + one-time setup scripts
src/
lib/
components/ Svelte UI components
server/ DB access layer (weather.ts, db.ts, fileCache.ts)
stores/ Svelte stores + shared types
utils/ Colour scales, formatters
routes/
api/ JSON API endpoints
[country]/ Country detail page
methodology/ Methodology explanation page
data/ Runtime data (see data/README.md)
static/ Fonts, favicon, turkey border GeoJSON
Issues and pull requests are welcome. For large changes, please open an issue first to discuss the approach.
The codebase uses:
- SvelteKit with TypeScript
- MapLibre GL for the map
- Tailwind CSS for styles
- better-sqlite3 for synchronous DB access on the server
- cfgrib / xarray for GRIB2 parsing in Python
MIT — data from DWD is open data under GeoNutzV. E-OBS climatology data is from ECA&D, for non-commercial use. Population data from GHS-POP (JRC), CC BY 4.0.