Weather scalar rendering

The colored "wash" overlays for temperature, wind speed, and (internally) precipitation are server-rendered RGBA PNGs. They look like full-resolution satellite imagery; they're actually 36 × 17 source values upsampled by the browser.

The pipeline

Open-Meteo grid (36 × 17 floats)
  ↓
LANCZOS resample to 720 × 360 (Pillow)
  ↓
Per-pixel colormap (Pillow + numpy)
  ↓
Save as RGBA PNG
  ↓
Cache in process memory (30 min TTL)
  ↓
Serve as Leaflet L.imageOverlay over the bounds

Why server-render and not client-render

Three options were on the table:

  1. Send the raw grid as JSON, render in WebGL on the client. Beautiful, but every browser becomes responsible for shaders and fragment programs. Mobile Safari struggles; IE/old Edge users see nothing.
  2. Send the grid as JSON, render on a <canvas>. Works everywhere, but the per-pixel colour math runs in JavaScript for ~260,000 pixels — ~50 ms cost on every frame.
  3. Render once on the server, ship a tiny PNG. ~30 KB per layer. Browser-native scaling. Works on every device. (We chose this.)

LANCZOS resampling

The 36 × 17 source grid is too sparse for direct rendering — at 720 × 360 output, each source cell would cover 20 × 21 output pixels as a hard rectangle, and the result would look like giant Minecraft blocks.

Pillow's Image.LANCZOS resampler does sinc-windowed reconstruction: each output pixel is a weighted sum of nearby source values, with the weights derived from the sinc function truncated to a 3-cell window. The math:

sinc(x) = sin(π * x) / (π * x)
lanczos3(x) = sinc(x) * sinc(x / 3),  -3 ≤ x ≤ 3

The output is smooth — no visible source-grid boundaries — even at zoom levels where one source cell maps to most of the viewport.

Per-field colormaps

Each scalar field has its own colormap function:

Temperature (continental wash, -30 °C → 40 °C):

def temp_color(t):
    norm = clamp((t + 30) / 70, 0, 1)
    if   norm < 0.20: return interp(BLUE_DEEP, BLUE,    norm / 0.20)
    elif norm < 0.45: return interp(BLUE,      GREEN,   (norm - 0.20) / 0.25)
    elif norm < 0.55: return interp(GREEN,     GOLD,    (norm - 0.45) / 0.10)
    elif norm < 0.80: return interp(GOLD,      ORANGE,  (norm - 0.55) / 0.25)
    else:             return interp(ORANGE,    RED,     (norm - 0.80) / 0.20)

The narrow gold band at 15-21 °C is intentional — that's the "comfortable" range, and a sharp visual distinction there makes the user instantly read whether a place is too hot, too cold, or fine.

Wind speed (fades from transparent at calm to amber at gale, no green to keep it distinct from temp):

def wind_color(speed_kmh):
    norm = clamp(speed_kmh / 80, 0, 1)
    return interp_alpha(GOLD_LOW, AMBER_HIGH, norm), alpha=norm

Per-pixel composition (numpy)

For the 720×360 output, a Python loop would be 260k iterations in pure Python — 5+ seconds. Numpy vectorises the colormap across the whole array:

img = np.zeros((H, W, 4), dtype=np.uint8)
norm = np.clip((values + 30) / 70, 0, 1)
# Vectorised piecewise interpolation:
band = np.searchsorted(STOPS, norm)
t = (norm - STOPS[band-1]) / (STOPS[band] - STOPS[band-1])
img[..., 0] = (1 - t) * R[band-1] + t * R[band]
img[..., 1] = (1 - t) * G[band-1] + t * G[band]
img[..., 2] = (1 - t) * B[band-1] + t * B[band]
img[..., 3] = 255

Cost: ~80 ms total per frame, dominated by the LANCZOS resample.

Caching

Each rendered PNG is stamped with a t={half-hour-bucket} query parameter. The browser caches it for the default Cache-Control: public, max-age=1800 window. The next half hour the same URL serves; after that, a new bucket triggers a re-render.

In-process the renderer also caches the last N=4 PNGs (one per field) keyed by half-hour bucket — so a wave of users in the same half-hour all hit the same cached bytes.

Why Open-Meteo and not GFS / ECMWF directly

  • Open-Meteo is free, commercial-OK, no key needed.
  • The data is already a 5°-grid forecast for the next ~7 days, which is plenty for live-tracking context.
  • They aggregate from the major models (GFS, ICON, ECMWF where permitted) and republish as a uniform API — we get the best of all worlds without writing code per provider.

Need help? Contact support · Where Is Tereza?