development · e-ink map rendering
Drawing a map on e-ink
A 480×800 e-ink panel with four grey levels, a 500 ms fast refresh and a 1,684 ms clean one, driven by an ESP32-C3 with 58 KB of free heap and map data on an SD card. Every normal map-rendering habit is wrong here. This is what we changed, and what it measured.
The problem: a solid fill destroys the map
The first version drew every road one width and no areas at all. Adding areas the obvious way — fill the forest, fill the buildings — made the map worse, not better: on a one-bit panel a solid black building swallows the roads around it, and a filled forest eats the road that crosses it. There is no colour to separate a layer from the layer under it, and no grey to lean on either (below).
So every area class is a pattern, not a fill: buildings get an outline and a dithered interior, forest is a diagonal hatch, built-up areas are stippled, water is filled with wave marks. Roads carry a white casing so they read as roads where they cross something. The pattern is what makes layers survive on top of each other.
Four grey levels, deliberately unused on the map
The panel does black, dark grey, light grey and white, and we verified it. The map still does not use grey, for two reasons that both came out of the hardware.
First, grey is not a value written to a pixel. It is a black-and-white base frame plus a weak differential waveform through two extra bit planes — both greys are black in the base frame and get nudged lighter. Lose the nudge and the pixel reads full black, so a grey design degrades into a black smear rather than a pale one. On a handlebar in the sun, contrast is the whole product.
Second, the cost. The grayscale path calls its draw callback 13 times for one frame. Our callback streams tiles off an SD card, so a grey map frame would cost 13 tile loads, not one extra waveform pass. The panel numbers have the mechanism.
What a frame actually costs
Measured on the device, viewport reset to whole-frame ready:
| View | Read off the card | Frame ready |
|---|---|---|
| town, 3 m/px | 484 KB, 7,056 ways | 2,506 ms |
| same place, 6 m/px | 198 KB, 3,230 ways | 1,088 ms |
| whole route, 12 m/px | route picked at map open | 2,537 ms |
| widest rung, 45 m/px | 787 KB, 12 tiles | 3,646 ms |
On top of that goes the waveform: 500 ms for a fast differential refresh, 1,684 ms for a clean one. Which is why the renderer is never asked to draw a frame per fix — the marker moves inside the frame instead.
The other half of the cost is reading the card, and the current pass structure reads more than the format needs: each render pass re-opens every tile in range, and each drawn layer is read twice per pass (once to check its checksum, once for its records). Roads and landuse are walked twice, so those layers are read four times per frame. That is written down as a known cost with a planned fix, not as a mystery.
Style edits without flashing the device
Judging a map style through a flash cycle is unusable: build, upload, walk to the device,
squint. So the same MapRenderer sources build as a host binary with CMake and
g++ through a canvas seam — the device hands it the real framebuffer renderer, the
laptop hands it a PPM writer. Same code, same tiles, about two seconds per edit.
Legibility is decided at build time, not draw time
At 20 m/px Bratislava read as noise, and the fix was not in the renderer. Each level of detail drops what you cannot use at that scale — field tracks, ditches, rail sidings go; individual buildings become one built-up area — and that happens in the tile builder. The device never reads the bytes it would only have thrown away. How the tiles are built.
Where it is now
On the panel today: per-class road widths and casings, buildings, forest, built-up areas, water with waves, railways as a blocked line, the route, place dots and place names. Not yet: junction dots, and edge markers for a village just off the screen. The render contract lives in the repo and the firmware is judged against it, not against a screenshot.