Air Atlas

An interactive globe of the world’s air. Each pollutant prints as its own halftone ink, and the bigger the dot, the more of it there is.

Role
Design and code, solo
Type
Personal project, live
Data
Copernicus CAMS via Open-Meteo
Built
8 to 18 October 2026
Model
CAMS global forecast
Hour shown
9 Oct 2026, 03:00 UTC
Inks on
PM2.5 Dust Formaldehyde
Cells
405,900 per field, about 45 km
Fields
28: 15 global, 13 more over Europe
Server
None. It’s all static files

Drag to turn. Pinch or ⌘-scroll to zoom.

Open the live atlas

Problem

Copernicus models the whole atmosphere every day and gives it away for free. It comes as grids of numbers, and most maps of it show one pollutant at a time as a colour ramp.

I came across the data and wanted to see what it looked like. That’s all this was at the start: a side project, for fun.

global-air-native.bin

00105a70  66 66 aa 41 cd cc a8 41  33 33 b7 41 cd cc b4 41
00105a80  66 66 ba 41 66 66 12 42  66 66 26 42 33 33 65 42
00105a90  33 33 6d 42 cd cc 3e 42  cd cc 48 42 66 66 70 42
00105aa0  33 33 4b 42 9a 99 29 42  00 00 18 42 9a 99 11 42
00105ab0  9a 99 07 42 33 33 ff 41  00 00 0a 42 66 66 0a 42

Bytes 1,071,772 to 1,071,775 of 24,354,000float32, little-endian60.1 µg/m³PM2.5 over Delhi, 17 Oct 2026, 12:00 UTC

The same value as one dot

One number from one hour of the global file. There are 6,088,500 of them: 15 fields, 405,900 cells each.

Hidden

The data is free, but it comes as binary grid files and APIs. You can’t just open it and look.

One at a time

Colour ramps don’t stack. Put two on one map and you can read neither, so maps show one field.

Overclaimed

A smooth map looks like a measurement. These are model estimates, one value for every 45 km or so.

I wanted to see dust, smoke and traffic fumes on one globe, at the same time.

First version

It started as Who owns the air?, which split each city’s pollution into eight sectors. The catch: I’d made the shares up. So I threw them out and kept only the model data.

New Delhi PM2.5 by source

  • Energy
  • Industry
  • Agriculture
  • Construction
  • Transportation
  • Waste
  • Households
  • Regional transport
Invented

src/data.js, 8 Oct

concentrations = (city, year, month) =>
  city.pm
  * (1 + (2024 - year) * 0.027)
  * [1.22, 1.16, 1.04, .89, .83, .71,
     .68, .75, .92, 1.10, 1.31, 1.26][month]

Every number on that first screen came from a formula like this: a base value, a yearly trend and a seasonal curve I picked by hand. It looked convincing, which was the problem.

The sector bars are redrawn here without their made-up values. The code is from the first prototype, which is still in the repo.

One value, one dot

So I went back to a single value and asked how to draw just that. Here’s one number from the forecast as one dot. Drag a number or pick another pollutant, and the dot follows.

Directions

Then several values at once. Each layer needed its own colour, and the overlaps still had to be readable. Over three days I tried five ways of drawing it.

  1. 8 Oct

    A plume over one city. Only its opacity came from the data; I made up the shape.

  2. 9 Oct

    A solid gradient shell, raised wherever values were high.

  3. 9 Oct

    Several layers blended into one shell. Where they overlapped, it went muddy.

  4. 9 Oct

    Dots of nearly fixed size, with value as opacity.

  5. 10 Oct · kept

    Halftone. Dot size is the value, and each layer gets its own plate. This one stuck.

Each sketch redraws one method on the same made-up blobs, so the drawing is the only thing that changes.

Drawing rules

Size, not opacity

Dot area follows the value. Every dot is solid ink.

Fixed plates

Each layer sits at its own offset, so turning one on never shoves another’s dots around.

Quiet background

Ozone is everywhere. It only shows where there’s more of it than usual.

Missing stays missing

A gap in the data draws nothing. It never pretends to be zero.

A screen of dots

Concentration is dot size, like halftone in print. Zero prints nothing, so clean air stays dark.

Colour ramp

Halftone

Real PM2.5 over the Indo-Gangetic plain on 17 October 2026, 12:00 UTC, at the model’s native 0.4°. Both sides use the same data and range, 0 to 100 µg/m³. The ramp has to colour every cell. The halftone can leave clean air empty. Hover either one for the value.
Zooming into the live globe until the screen comes apart into single dots.

Ink that mixes

Each pollutant gets its own ink plate, set at a fixed offset. Overlaps mix the same way whatever order you turn the layers on.

Real fields from 17 October 2026, 12:00 UTC, from the Atlantic to Japan. The dots mix in OKLab, the way the app does it. Turn the plates on in a different order and you get the same picture. Hover values come from a compressed copy of the data, so they’re close, not exact.

Halftone globe

One hour of the whole atmosphere, printed on a sphere. It only moves when you drag, zoom or search.

28
Fields: 15 worldwide, 13 more over Europe
45 km
Between global cells, 0.4° apart. Over Europe it’s about 11 km
1,024,000
Dots on the globe at the closest zoom

Time

You can play 48 hours of forecast, or step back through monthly means to August 2022. Over Delhi, the same thing happens every winter.

020406080100120140WHO daily guideline, 152023202420252026116123124127µg/m³, monthly meanAugust 2022: 44 µg/m³September 2022: 63 µg/m³October 2022: 96 µg/m³November 2022: 105 µg/m³December 2022: 101 µg/m³January 2023: 116 µg/m³February 2023: 82 µg/m³March 2023: 69 µg/m³April 2023: 48 µg/m³May 2023: 56 µg/m³June 2023: 47 µg/m³July 2023: 38 µg/m³August 2023: 52 µg/m³September 2023: 43 µg/m³October 2023: 52 µg/m³November 2023: 95 µg/m³December 2023: 98 µg/m³January 2024: 123 µg/m³February 2024: 59 µg/m³March 2024: 49 µg/m³April 2024: 38 µg/m³May 2024: 53 µg/m³June 2024: 54 µg/m³July 2024: 48 µg/m³August 2024: 34 µg/m³September 2024: 47 µg/m³October 2024: 69 µg/m³November 2024: 116 µg/m³December 2024: 102 µg/m³January 2025: 124 µg/m³February 2025: 68 µg/m³March 2025: 78 µg/m³April 2025: 72 µg/m³May 2025: 83 µg/m³June 2025: 74 µg/m³July 2025: 58 µg/m³August 2025: 63 µg/m³September 2025: 42 µg/m³October 2025: 70 µg/m³November 2025: 101 µg/m³December 2025: 127 µg/m³January 2026: 121 µg/m³February 2026: 81 µg/m³March 2026: 76 µg/m³April 2026: 73 µg/m³May 2026: 103 µg/m³June 2026: 102 µg/m³July 2026: 72 µg/m³August 2026: 65 µg/m³September 2026: 52 µg/m³
Monthly mean PM2.5 over Delhi, August 2022 to September 2026, read from the atlas’s own history files. Shading marks November to January, and each winter’s peak is labelled. Hover a month for its value.
The globe in Monthly mode showing the November 2025 monthly mean: dust over North Africa and Arabia, fine particles and nitrogen dioxide over northern India and China, with the time bar at the bottom.
Monthly mode, November 2025. The two time bars stack like cards, with Monthly in front and Today behind.

Read any point

Hover anywhere to see the model values under the pointer, each one next to the WHO guideline. Click to pin it.

Values come from the model cell under the pointer, interpolated, and never rounded down to zero. The WHO chip only shows up when a value is over the guideline.

Fly to a city

Search 237 cities offline, or type in coordinates. The camera arcs around the Earth and lands on the latest PM2.5 estimate.

25 of 30

said no to the location prompt

On 10 October the site asked for your location the moment it opened. Most people said no. A day later I switched to turning the globe toward your rough region from your IP, and now it only asks if you press My location.

Event cards

There are 16 collectible cards, each one a real pollution event: wildfire smoke, peat haze, crop burning, savanna fires, dust storms. You find a card on the globe, then answer a question about what caused it to keep it.

Three of the 16 cards. Tap one to turn it over. The art is the real monthly field inside the card’s area, printed with the same halftone rule as the globe.
FindClick within 300 km AnswerWhat caused it? CollectCard turns over ShareOne short message right Wrong: a new question in 1 hour, or as soon as you find another card The celebration grows with how you earned it: found without clues, first try, no hints
Rarity

By how often, never by harm

Every year (9 cards), every few years (5), once in decades (2). Most air-pollution deaths happen where bad air comes back every year, so I didn’t want the yearly cards to feel like the lesser ones.

Questions

Each one corrects a belief

North India’s November smog usually gets blamed on Diwali fireworks. They add a night or two. The haze that lasts all month is crop smoke and local pollution, held down by cold air.

After launch

Place is enough

At first you had to find a card in the right place and the right month. Friends told me they couldn’t find a single one. So on 17 October I dropped the month: the place is enough, give or take 300 km.

Two models

Over Europe the atlas switches to the 11 km regional model, and it doesn’t always agree with the 45 km global one. On 9 October its dust was about a quarter as high. The readout tells you which model you’re looking at.

SameA quarter as highTwice as high0.25×0.5×1×2×Dust0.25SO₂0.40Sea salt0.50NO₂0.59Formaldehyde0.82CO0.92Ozone0.92Glyoxal1.00PM2.51.12PM101.22PAN1.33
Median of the cell-by-cell ratio, Europe model ÷ global model, for every field both models share, over the land cells of the Europe box at the same hour (9 October 2026, 03:00 UTC). From the data study.
Northern Algeria at close zoom with the dust layer on: small dots inside the European model’s area, large dots from the global model beyond its southern edge at 30° N.
The southern edge of the Europe model at 30° N, dust on. Small dots inside, large global dots beyond.

Data study

I also ran a short study of correlations across all 405,900 cells. Fine particles follow carbon monoxide where fires burn, and follow dust over the Sahara. That’s a hint of smoke versus sand. It isn’t source attribution.

00.250.50.751follows CO (smoke)follows dust (sand)Southern Africa (fire season)0.790.27Amazon & Cerrado (fire season)0.750.05Sahara & Sahel (dust)0.010.88India & Bangladesh0.800.45Eastern China0.440.59Europe0.650.20
Spearman correlation of PM2.5 with CO and with dust, cell by cell, in six regions. From the data study.
World map of 5° tiles from the data study. Orange dots, where fine particles and carbon monoxide rise together, cluster over South and East Asia, Central Africa and South America.
Where PM2.5 and CO rise together, in 5° tiles.
Left: smoke plumes from savanna fires in the southern Congo basin, seen from the ISS. Right: how closely PM2.5 follows carbon monoxide and dust in six regions.
Savanna fire smoke over the Congo basin, from the ISS.

System design

There’s no backend. Most of the work went into getting a daily model run into the browser intact, then drawing up to a million dots from it.

Architecture

Once a day, a GitHub Action pulls the newest CAMS and weather runs from Open-Meteo’s open-data bucket, turns them into checked binary files, and publishes them with the site on GitHub Pages. Everything after that happens in the browser.

Open-MeteoOpen data on S3cams_global0.4°cams_europe0.1°ecmwf_ifs0250.25°archive2022 on.om files, one per hourGitHub ActionsDaily, 12:30 UTCdata:pullrefresh-forecasthistory monthnpm testvite buildPython, numpy, omfilesGitHub PagesStatic filesindex.html, JS*.bin.gz*.json manifestsc/<card>/ pagesAbout 180 MB of dataBrowserReact, Three.jsverifyWeb WorkerWebGL shaderslocalStorageCards and settings.omdeployfetchAir-quality APICity PM2.5, every 15 minGeoJSRough region from IPGoatCounterVisit counts, no cookiesCalled from the browser
The data only changes once a day, so a static site is enough. Hosting is free.

Data pipeline

Every day after the 00 UTC runs, one job swaps in the new snapshot, the 48-hour forecast and the weather, and adds last month if it has just ended. If a download or a test fails, nothing deploys and yesterday’s data stays up.

PullData already live FetchNewest runs Add monthIf one finished Test175 tests BuildVite DeployGitHub Pages Any failure: stopNothing deploys. Yesterday’s data stays live. Every day, 12:30 UTC
Global snapshot15 fields, 1 hour2.8 MB
Europe snapshot25 fields, 1 hour2.1 MB
Weather4 fields, 1 hour3.9 MB
Forecast, global17 frames, 3 h apart39.4 MB
Forecast, Europe17 frames8.1 MB
History, global50 monthly means113.4 MB
History, Europe48 monthly means8.5 MB

178 MB compressed in all. The first picture only needs the 2.8 MB global snapshot. The frames load once it’s on screen.

Published data sets, gzipped. Global grids are 900 × 451 cells at 0.4°, and Europe grids are 700 × 420 at 0.1°. The snapshot is float32. Forecast and history frames are float16, and they’re decoded a few at a time once the first view is on screen.
History

A month from every hour

Each monthly mean averages every hourly value in the archive. That’s about 8 GB per field, streamed in and deleted chunk by chunk.

Storage

Data is not in git

Each deploy starts by downloading the data that’s already live and checking it against its manifests. A code push never has to refetch anything.

Recovery

Rebuild by hand

If the published data is ever lost, a manual run rebuilds both histories from the archives. It takes one to two hours.

Checks

Every data file comes with a manifest, and the browser checks the file against it before drawing anything. If a field fails, you get an error, not a wrong picture.

global-air-native.json

domain
cams_global
source
s3://openmeteo/data_spatial/cams_global/2026/10/17/0000Z/2026-10-17T1200.om
validAt
2026-10-17T12:00Z
grid
900 × 451, 0.4°
fieldOrder
15 fields, pm2_5 … nitrogen_monoxide
encoding
float32-le, layer-major
byteLength
24,354,000
sha256
8984b45b3ebeb145…
missing
carbon_monoxide 4, glyoxal 1 cells
  • LengthDecoded bytes match byteLength: 24,354,000.
  • LayoutGrid, encoding and field order match the catalogue.
  • UnitsEach field has the unit the app expects, µg/m³ or none.
  • ChecksumSHA-256 of the decoded floats matches the manifest.
  • ValuesEvery value is finite and not negative, or listed as missing.

Missing cells stay missing and draw nothing. About 0.01% of archive values are negative, as low as −295 µg/m³ for dust. I skip them when averaging instead of clamping them to zero.

Dot lattice

The dots sit on a Fibonacci sphere: the same area per dot everywhere, no bunching at the poles, and fixed to the Earth. Zooming moves between seven lattices, each with twice the dots of the last, to keep dots at least 7 pixels apart.

Latitude and longitude gridcrowds at the polesFibonacci sphereequal area per dotSeven levelsspacing16,000179 km32,000126 km64,00089 km128,00063 km256,00045 km512,00032 km1,024,00022 km
Each dot samples the 45 km grid at its own latitude and longitude, by bilinear interpolation. Zooming in adds display samples, not new observations.
Hysteresis

No flicker at the edge

Levels switch up at 7.7 px and back down at 6.65 px, so a zoom that stops near the line doesn’t flicker between two.

Worker

Built off the main thread

The next level’s geometry is built in a Web Worker and handed over as typed arrays. The old level stays on screen until it’s ready.

Memory

Two bytes a weight

Per-dot weights are stored as normalised 16-bit integers, four fields to an attribute.

Halftone shader

Radius is 0.56 × √(value ÷ range), in lattice units. That makes dot area grow in step with concentration, and a full-range dot just touches its neighbours. Discs smaller than half a pixel keep their ink area and fade out, so low values don’t just vanish.

00.250.50.75100.20.40.56radius ∝ valuearea ∝ valuevalue ÷ rangeradius2102550100µg/m³ of PM2.5 (range 100)One lattice cell each
The square root keeps area, not radius, in step with the value. Without it, twice the value would put down four times the ink.
PM2.5NO₂OzoneDustPM10COSO₂AODUV indexUV clear-skyFormaldehydeGlyoxalPANSea saltNOOne lattice cell. Ring: 0.26 from the centrePlateanglePlateangle0 PM2.590°1 NO₂228°2 Ozone5°3 Dust143°4 PM10280°5 CO58°6 SO₂195°7 AOD333°8 UV index110°9 UV clear-sky248°10 Formaldehyde25°11 Glyoxal163°12 PAN300°13 Sea salt78°14 NO215°
Each layer’s offset comes from its place in the catalogue: 0.26 lattice units out from the centre, turning 137.5° (the golden angle) at each step. It doesn’t depend on which other layers are on.

Ink mixing

Mixing happens in two passes. First, every disc adds its ink’s OKLab colour and its coverage to a half-float buffer. Then a second pass averages the hue and puts the chroma back, so overlaps stay as bright as the inks. Adding things up doesn’t care about order, and neither does the result.

Overlap: the OKLab meanof both inks, #d8944d Ink passAdditive blend, half-float target rgba += (a·c, b·c, C·c, c) a, b: the ink’s OKLab hue C: its chroma · c: disc coverage Commutative: any draw order Composite passOne full-screen triangle pair hue = normalize(ab ÷ c) ab = hue × (C ÷ c) L = shared ink lightness OKLab → linear → sRGB Every ink has the same lightness, so overlaps only mix hue and chroma, and don’t go muddy.

Display scales

Most fields map value ÷ range straight to dot size. A field that’s about the same everywhere would print as a solid sheet, though. So when its coefficient of variation is under 0.5, the scale starts at the field’s own 5th percentile. Right now that’s only ozone.

0.20.5125102050Ozone0.39Sea salt0.84PM2.51.17AOD1.19CO1.60PM102.25SO₂3.55PAN4.18NO₂4.36Formaldehyde4.41Dust6.92NO17.16Glyoxal45.53Below 0.5: baseline scale
Coefficient of variation per field in the 17 October 2026 snapshot, weighted by cell area (cos latitude), log scale.

Playback

Forecast frames load as half-float texture arrays. Each dot samples two frames on the GPU and blends between them, so playing 48 hours never rebuilds any geometry. The readout shows the nearest real frame, never the blend.

NN+1 17 frames, 3 h apart, float16 −3 h+45 h Vertex shaderv = mix(fN, fN1, t) Radius0.56 × √(v ÷ range) In-between moments are display blends, not model output.
17

Frames from 3 hours before the snapshot to 45 hours after

2.3 MB

Per frame, all 15 global fields, gzipped

0

Geometry rebuilds while playing

Card system

The deck is a JSON file I write by hand, checked by tests. Card stats and art come from a small tool I run myself, which measures the real monthly field inside each card’s area. A save link fits a whole collection into a few characters.

deck.json16 cards, by hand card-tools.pystats · art Card SVGspublic/cards/ Share pagesHeadless Chrome previews Monthly data, footprint polygon

?deck=1.BQ

Two bits per card, in publishing order: one for the card, one for its CO print. Base64url, with trailing zeros dropped.

Order

The list only grows

order.json lists every card ever published. Save links rely on those positions, so cards never get reordered or removed.

Storage

One module

All storage goes through one module. If I add sign-in later, that’s the only file that has to change.

Stack and tests

175 tests in 26 files. They run on every push and on every nightly refresh.

React 19Three.jsWebGL shadersWeb WorkersViteTailwindPythonnumpyomfilesnode --testGitHub ActionsGitHub PagesGoatCounter
DataairFrames.test.js: 10airFramesfieldStats.test.js: 8airProbe.test.js: 6nativeAir.test.js: 5nativeAsset.test.js: 4weather.test.js: 4globalAir.test.js: 340DrawingpaintLayers.test.js: 8sphereSampling.test.js: 7sunPosition.test.js: 3cameraTransition.test.js: 119InterfaceembedParams.test.js: 8userLocation.test.js: 7stats.test.js: 7statsplaceName.test.js: 4readoutPref.test.js: 3settingsPref.test.js: 3places.test.js: 3release-notes.test.js: 338Cardscards-deck.test.js: 26cards-deckcards-collection.test.js: 23cards-collectioncards-recovery.test.js: 7cards-format.test.js: 7cards-share.test.js: 6cards-params.test.js: 5cards-sets.test.js: 478
Test count by file, grouped by area.

How I worked

Sketch first, then code. Each direction started as a rough drawing. I kept whatever read well and had AI coding agents build it. Bigger features, like the cards, got a written spec and plan first, and every change had to pass the tests.

CardsDataDrawingProductUpdate Site sourceUpdate Site sourceUpdate Site sourceUpdate Site sourceUpdate Site source8 Oct5Update Site sourceUpdate Site sourceUpdate Site sourceUpdate Site sourceUpdate Site sourceUpdate Site sourceUpdate Site sourceUpdate Site sourceUpdate Site sourceUpdate Site sourceUpdate Site sourceSave air pollution map projectUpdate Site sourceUpdate Site source9 Oct14Add data study page and open it from AboutTime bar with 48-hour forecast and monthly history, first-visit layer picker, phone dockRework atlas: halftone dot field, readouts, search, loaderOKLab dot blending, mobile study page, embed modeDeploy to GitHub Pages with relative asset pathsStudy page: drop section eyebrows, multi-hue correlation ramp, field pigmentsPlain headings, spacing and responsive charts on the study pageStudy page: sans-serif tabular numbers instead of monospaceStudy page: causes, conclusions, photos; About opens it as a full pageMy location button and search option, phone dock spacing, short and narrow desktop layoutsAsk for location on first open; always use a fresh position10 Oct11Readout shows the layers you have on; searched places use the same cardShow Europe-only layers in the monthly historyCount new and returning visitors; add the stats snapshot scriptRefresh the snapshot, forecast frames and weather from the newest runs in one scriptAdd one month at a time to the global and Europe historiesnpm run data:pull: download the published data, checked against its manifestsRefresh data daily in the deploy workflow; data tests no longer pinned to one dayStop tracking public/data; it is published with the site and pulled by each deployVisitor and event counts with GoatCounter, sent from our own count.jsGlobe turns slowly after 12 seconds untouchedTurn the globe to the viewer's region from their IP instead of asking for location on openShow Europe-only layers during forecast playback, when zoomed out, and on loadSun layer: day and night, the sun and the moon for the hour shownKeep layer settings across reloads; phone fixes for touch and in-app browsersRename the site back to Who Owns the Air?Overview button returns to the viewer's region, not Africa11 Oct1612 Oct13 OctTapping the globe on a phone no longer bounces it or pins the wrong spot14 Oct1Add refresh-stats script: GoatCounter snapshot with retries and a summaryNightly refresh tolerates the few NaN cells the provider leaves in a CAMS gridNative air tests allow the provider gaps the snapshot now declaresRename time modes to hourly and monthly15 Oct4Event cards: design spec, implementation plan and deck candidatesAdd the event card deck format and its checksFind cards by footprint and monthCard collection rules: find, answer, one-hour waitRecovery link code for card collectionsParse ?card= and ?deck= optionsHand-run tools for card stats and artDraft three event cards: Canada 2023, Indonesia 2026, North India NovemberCard view with flipFind a card from the readout and answer to collect itCards panel, shared cards and restore linksStory share image and found countsREADME: how the event cards workFix review findings: blocked storage, tab merge, find precedence, share on iOSAdd PM10 and CO to the monthly history lists16 Oct15Cards: tiers named by how often, household fuel, what helps, and a collect momentCollect moment sized by how the card was earned; full-screen card, recap, setsFull 16-card deck, India and Pakistan as separate cards, stacked time barsReview fixes: tabs settle, embeds open on Today, Space and focus in the card dialogMerge pull request #1 from PKrunal26/event-cardsEvent cards: smaller art and review leftoversMerge pull request #2 from PKrunal26/cards-polishWhat's new dialog, CO prints, every-year cards keep workingMerge pull request #3 from PKrunal26/release-notesClicking a place always shows its readout and cards; Cards panel steps asideCards are found by place alone, with 300 km of leewayMerge pull request #4 from PKrunal26/cards-readout17 Oct12Merge pull request #5 from PKrunal26/hover-contrastShare sends one short message; card pages give links a card previewMerge pull request #6 from PKrunal26/share-messageFix crash after a right answer; success screen opens the card full sizeMerge pull request #7 from PKrunal26/fix-collect-crashHovering a place with a card shows that a card is thereMerge pull request #8 from PKrunal26/hover-card-hintHover and contrast fixes for map controls18 Oct8
All 86 commits from 8 to 18 October 2026, one square each. Hover a square to read its message.

Outcome

It’s live, and it refreshes itself every day. On 17 October I sent it to 31 people and groups, including Open-Meteo, earth.nullschool.net, Datawrapper and Our World in Data.

173

Visitors from 11 to 19 October, since I added analytics

75%

On phones

42

Tapped the globe for a reading

14

Cards found

6411 Oct2012 Oct813 Oct414 Oct2715 Oct1016 Oct1117 Oct2318 Oct619 Oct
Visitors per day, 11 to 19 October 2026, from GoatCounter (no cookies).

Where visitors came from

Reddit49
Direct or unknown48
Instagram39
Own pages and links22
LinkedIn9
Google3

What they did

Tapped the globe for a reading42
Played the 48-hour forecast35
Opened the monthly history26
Found a card14
Searched a place13

What I learned

Sketch many, keep one

I drew five versions in three days before landing on halftone. Throwing directions away quickly turned out to be most of the work.

Ship, then listen

Within a week of launch, people using it had changed two things: the location prompt and the card rule.

Phones first

Three in four visitors were on a phone, and about half of everyone came from a Reddit or Instagram link.

See it live

The live site is called Who Owns the Air? Spin it, look up your city, stack a few inks.

Data: Copernicus Atmosphere Monitoring Service / ECMWF, via Open-Meteo, CC BY 4.0. Weather: ECMWF IFS via Open-Meteo. Earth: NASA Blue Marble. Smoke photo: NASA, ISS crew. Modelled estimates, not sensor readings.

Next projectEscherAn AI interface for running a cloud estate.Read the case study →