ExplorInk
GitHub

development · map tile generation

Turning OpenStreetMap into tiles a microcontroller can read

No vector tiles, no SQLite, no floating point per point. Map data for ExplorInk is a binary file per square, written so an ESP32-C3 can stream it off an SD card, draw it and forget it. This is the format, the three levels of detail, the index that replaced a 76 MB manifest, and the server that builds the squares riders ask for.

Why tiles work here at all

The viewport stands still and the marker moves across it. That one decision removes the hard part of tiling: tiles are read only on a viewport reset, never on a position update, and resets are rare. So there is no cache, no prefetch, no eviction policy and no tile pyramid in RAM. Read what the screen covers, draw it, forget it — measured cost of a tile load on the device: 60–68 bytes of heap.

One coordinate frame, integers only

Everything is Web Mercator metres. Tile identity is the standard z/x/y, so “rebuild the square around this village” is a real command. Points inside a tile are int16 offsets in Mercator metres from the square’s north-west corner, which at the closest zoom is 0.22 pixels of error — enough.

Mercator metres are not ground metres: at 48.5°N one Mercator metre is 0.66 ground metres. The device computes that ratio once per viewport reset, in a single cosine, and everything after it is integer maths. The alternative — per-tile local ground metres — needs a fixup per tile and accumulates error across tiles. One global frame needs neither, and scale varies by under 0.1 % across the widest viewport.

Three levels of detail, and what each one drops

Detail (z13), regional (z12) and overview (z11). The zoomed-out levels are not the same data drawn smaller: they are built with less in them. Field tracks, ditches and rail sidings are gone. Individual buildings are replaced by one built-up area. Watercourses are broken so a stream cannot read as a road.

Those rules were not guessed. At 20 m/px Bratislava read as noise on the panel, four separate causes were measured over one viewport, and each rule exists because of one of them. The decision lives in the builder, so the device never reads bytes it would have thrown away — which is the same reason the renderer stays cheap.

Real X4 framebuffer at 45 metres per pixel: a road network winding through a large hatched forest area, dots marking villages, a small position marker near the centre and a scale bar showing 5 km
device The overview level doing its job: 24 by 40 km on one panel, 12 tiles, 787 KB read, 3,646 ms. Forest is most of the picture and the roads are what you navigate by — which is exactly what this level of detail keeps.

The index: two questions, without opening a tile

A device needs to know two things about a square it does not have:

  1. Should there be a tile here at all? Nobody-built-it is a hatched hole in the map. Nothing-is-there is white countryside. Getting those two the same way is how a rider mistakes surveyed emptiness for missing data.
  2. Is the tile I hold out of date? Either OpenStreetMap moved on, or the builder now builds differently.

The obvious answer — one manifest listing every tile — does not survive scale. Measured: a card’s manifest was 85,920 bytes for 998 tiles, about 86 bytes an entry. Computed for Europe at all three levels of detail, roughly 884,000 tiles, that is a 76 MB JSON file. Against a quarter of a megabyte of heap that is not a parsing problem to be streamed around; it is 76 MB of card reads to answer one question about one square.

So per-tile data does not live in one file. The manifest keeps only what grows with the number of builds — which areas were built and when, plus the hash of the rules they were built with — and the per-tile answers come from small index blocks projected out of the tile tree. Freshness is decided by comparing a content id, not a timestamp, so a rebuild that changes nothing does not make every device re-download the world.

The 404 is the feature

A device asks the server for a square, the server does not have it either, and answers 404. That 404 is the only place in the whole system where real demand for map data is written down.

So a resident job on the tile host watches its own missing-tile log. Within two seconds of a new miss it counts the last 24 hours, ranks the squares by hit count, takes the top ten, builds the areas they fall in straight into the live tile root and regenerates the index. One pass at a time, held by a lock. Measured on the server: about twenty seconds for a 13 by 13 km area — the missing square plus the ground around it, because you are riding into that too.

Two guard rails, both deliberate: nothing already published is ever rewritten, and the builder’s day is capped, so an unattended job cannot run away with the disk.

The reader had to learn patience for that to be worth anything. It used to cross a square off for good once the phone reported it could not be fetched — sensible when a miss meant nobody had it, wrong once the miss is what builds it. It waits and asks again instead, backing off from a minute and a half to an hour, so a square that genuinely does not exist stops costing anyone mobile data.

Tested end to end on the reader, on a drive across ground the map did not cover: four squares missing, two arrived from the server, the other two were built because the reader asked and drew a minute later, while it kept moving.

Why not a queue service

There is a message broker on that box and it was considered. The log already is the queue, ranked by a count nothing else has, and the tile tree is the record of what has been served. A broker would add a daemon to keep alive and a second copy of state that can disagree with the first. It earns its place the moment there is a second producer or a second worker box. Neither exists yet.