ExplorInk
GitHub

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.

Real X4 framebuffer at 3 metres per pixel: individual buildings with outlines and dithered interiors, forest as diagonal hatch, roads of different widths through a town Real X4 framebuffer of the same town at 6 metres per pixel: the village drawn as one stippled built-up area, forest hatched around it, no individual buildings
device Both frames are the panel’s own framebuffer, pulled over USB serial. Left: 3 m/px, 7,056 ways, 484 KB read, 2,506 ms. Right: the same place at 6 m/px, 3,230 ways, 198 KB read, 1,088 ms. Individual buildings become one built-up area — and that switch happens when the tiles are built, so the device never reads what it would not draw.

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:

ViewRead off the cardFrame ready
town, 3 m/px484 KB, 7,056 ways2,506 ms
same place, 6 m/px198 KB, 3,230 ways1,088 ms
whole route, 12 m/pxroute picked at map open2,537 ms
widest rung, 45 m/px787 KB, 12 tiles3,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.

Laptop preview render of the same map style: forest and built-up areas as dithered fills, water hatched, buildings toned down
preview The same renderer on a laptop, not the device. We keep these two labelled apart everywhere, because a laptop render presented as device output is the easiest lie to tell in this project. The preview cannot show the status text or the route line; the device can. So the route is judged on the panel only.

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.