Case study · Personal project

Fahsai

Fires, wind and PM2.5 on one map of Thailand's burning season, with an AI explanation for any station on any day. Designed and built solo, with Claude Code as my pair programmer.

Role
Solo: design, data pipeline, backend, map UI
Status
Live and open source
Stack
React, TypeScript, deck.gl, Mapbox GL, Fastify, Postgres (Supabase), Redis
AI
Gemini explanations, MCP server, built with Claude Code
Data
NASA FIRMS, Copernicus CAMS, OpenAQ, Open-Meteo

Every year, around January, pollution starts building in the north of Thailand. Chiang Mai, the largest city in the region, is on some days the most polluted city in the world! The World Bank estimates that fine particulate matter (PM2.5), particles small enough to get deep into your lungs, cost Thailand about 6% of its GDP in 2019.

And as predictable as the haze is the blaming that comes with it: neighbouring countries, farmers, industry, the government, hill tribes. Most of it is argued without data, so I built a map that puts the data in one place.

The map shows fires, wind, power plants and PM2.5 across mainland Southeast Asia, day by day, including readings from around 500 ground monitoring stations. Click a station to see whether that day's reading is unusual and what it means for your health. One more click, and AI explains what is most likely behind the reading.

The map

Idea

Fires cause pollution, wind carries it, and stations measure it. I wanted one map that shows how those three relate, day by day.

Data in

Every day, scheduled jobs on the backend pull in NASA's daytime fire detections, PM2.5 from Copernicus CAMS (the EU's atmosphere model), wind and rain from Open-Meteo, and ground station readings from OpenAQ.

Result

The map only talks to my own API, with Redis in front of Postgres, so it never waits on a third-party rate limit and stays pretty snappy.

Rendering

On a busy day there are over 50,000 fire detections in view, plus a modelled pollution grid and thousands of wind particles. I wanted the deck.gl map to stay fast and look good, without lying about the data.

Fires

For the fires I wanted a firefly effect, so they're a ScatterplotLayer with a simple halo shader. Lower-confidence fires are more transparent, and higher-intensity fires are larger.

Wind

The wind is about 2,400 particles in a TripsLayer, each following the interpolated wind grid with a fading trail. They're coloured by the PM2.5 underneath, so you can see whether the wind brings clean air or haze. Getting the density, speed and shape right across zoom levels took a lot of tweaking.

PM2.5 · Why not a HeatmapLayer

My first idea was a HeatmapLayer. It looks great, but it's made for point density: it blurs points across screen pixels, and by default its colours follow whatever is in view. Even with a fixed scale it spreads its colours evenly, while the EPA's health thresholds (12, 35, 55, 150 and 250 µg/m³) are uneven.

PM2.5 · What I built instead

So I paint the grid myself, in a Web Worker. For every pixel of a fixed-size canvas, it looks up the four nearest grid cells, colours each one on the EPA scale and blends between them. That gives smooth gradients without losing the true colour of each cell. The finished image goes to a BitmapLayer, with a land mask that stops the haze at the coast. The main thread never touches a pixel, and unhealthy air looks unhealthy wherever you pan.

Station panels

Problem

A number on its own doesn't tell you much. Is 120 µg/m³ bad for Chiang Rai in April?

What I built

The panel compares the day's reading with that station's own history. Most stations are fairly new, so it only does that when it has more than 30 past readings from within two weeks of that calendar date; otherwise it tells you there isn't enough data. Below that are the last five days and a weather table, because wind blows haze away and rain clears it.

Gotcha

Wind data gives the direction the wind comes from, but the particles and arrows should point where it's going to. Easy for a human, yet Claude Code tripped over it at least three times. Documenting the rule and adding a few well-named utility functions prevented a fourth.

Interpreting data through AI

Problem

“What's causing this?” is the question the whole map exists for. It's also exactly the kind of question a language model will happily answer with confidence, and get wrong.

What I built

So before anything reaches Gemini, the backend does the real work. It traces the air back 72 hours through the stored wind grids, along five paths: one from the station and four from points around it. The further back you trace the wind, the less certain the path, so together they fan out into a cone of where the air could have come from. The backend scores every fire in that cone by power, recency and distance from the paths, checks for cities, industry and power plants upwind, and compares the station with its neighbours. Then a classifier decides what kind of day it was: fire smoke blown in, urban or industrial pollution, clean air, regional background haze, unclear, or a station that disagrees sharply with its neighbours. The model does the least interesting part, which is writing it up.

Result

When an answer came out wrong during development, I fixed the classifier first, then the data, and the prompt only as a last resort. Rules in code can't quietly contradict each other the way two sentences in a prompt can. Sixteen real station-days with hand-written ideal answers, plus an eval script, keep it honest.

MCP server

Idea

If the backend already turns raw data into something a model can reason about, other models might as well use it.

What I built

fahsai-mcp-server is a small server for the Model Context Protocol (MCP), the standard way to give AI assistants like Claude access to tools. It's on npm and wraps the public API as 14 tools. It follows the same rule as the button: the server classifies, and the model only talks. Every PM2.5 value comes back with its air quality index (AQI) category, like “unhealthy”, and the raw PM2.5 grid of 4,599 points gets summarised in code first. The only new thing I had to build was a geocoding tool on top of OpenStreetMap's Nominatim, so you can ask about places by name.

Deliberately left out

It doesn't wrap the explain endpoint. That one shares a daily Gemini quota with real people using the map, and it would put one model's answer inside another model's tool call, so any mistake in the first gets passed on as fact. Instead it exposes the same numbers the button sends to Gemini, without the prose.

Working with Claude Code

I built Fahsai alone, with Claude Code writing a large share of the code. Claude and other coding agents write inconsistent code without the proper guardrails: no shared abstractions, a different style per file, side effects where a pure function would do, and tests whenever it felt like it. After a while, I became annoyed and started to improve both my workflow and the context.

The context consists of a short CLAUDE.md that points to one doc per area of the code, decision records for choices and skills indicating my preferred coding patterns (pure functions, immutable where relevant, Clean Code, etc.). You can see the codebase becoming more consistent in later commits. It's not done yet: the ingest scripts still solve similar problems in different ways.

Lately, a typical issue starts in a Claude Opus session that grills me with questions until there’s no more ambiguity. Claude turns the result into Linear issues, each with an estimated change of under 600 lines, and I check them. Every issue gets a fresh session, which grills me again on the details. Claude builds, then we run the typecheck, linter, prettier and tests. Before Claude opens a PR, I run a generic multi-agent code-review skill, sometimes plus a specialised one for performance or security.

Claude can also inspect my database and reach Vercel, Railway and Rollbar through MCP servers.

Compared to my old “prompt and hope for the best” style, the code now has less duplication, more pure functions, better tests and fewer bugs.

Trade-offs

This is a personal project, and I don’t want to spend too much on infra until I know it’s useful, which I won’t know until the next haze season (January).

That meant some painful decisions: keeping only 140 days of data (120 of them browsable on the map), a lower spatial resolution for the modelled PM2.5 layer and weather (0.4°, roughly 44 km, instead of 0.1°, roughly 11 km), and – this one hurts me a lot – a very low temporal wind resolution: the source provides wind data every 6 hours, but I only store one value a day. The AI explanation traces the wind back 72 hours, so its accuracy is probably modest.

When I wrote this in October, it was rainy season in Thailand, with very little pollution. At the same time, pollution is spiking in Indonesia and Singapore, which this map doesn’t cover. Again, mainly an infra cost issue.

Next case study

Restor.eco

The Google Maps of nature, for the world's largest network of restoration sites. The first version was slow and buggy, so a small team rebuilt it from scratch. I was the sole architect of the geospatial and data visualisation frontend.

Working on something similar?

I'm looking for my next product engineering role on a data- or map-heavy product. Based in Utrecht, working remote.

dev@joostkiens.com