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, filling the forest and filling 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, 4,584 ways, 484 KB read, 2,506 ms. Right: the same place at 6 m/px, 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, 4,584 ways2,506 ms
same place, 6 m/px198 KB1,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 twice: at build time and at draw time

At 20 m/px Bratislava read as noise, and the first half of 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.

The second half is the width a road is drawn at once it has arrived. Every length in the style is a screen pixel, and a screen pixel is not a fixed amount of ground: it is one metre at the closest zoom step and forty five at the widest. So an eleven pixel motorway covers eleven metres of ground up close and nearly half a kilometre at the far end, which is why roads were two thirds of all the ink on the two widest steps. A road class now carries a width per zoom step, and the same mechanism can hand a walker a different map from a rider.

Eight preview renders in two rows, each one a full 480 by 800 pixel frame. The top row draws the same road classes at one fixed width across four zoom levels and the widest views turn into thick black webs. The bottom row thins each class as the view widens, down to a one pixel line for the minor network, and the road hierarchy stays readable
preview Same place, same tiles, four zoom steps. Top row: one width per road class, whatever the zoom. Bottom row: a width per class per step, down to a single pixel for the minor network at the widest views. Ink covering the panel drops from 9.0 to 8.3 per cent at 6 m/px and from 11.5 to 8.9 at 45 m/px. Over a capital the same change is worth six points. Every frame here is a full 480 by 800 panel at one to one: a one pixel line scaled down is a grey smudge, so scaling one of these would hide the thing it is here to show. These are the device renderer running on a laptop, not photographs of a screen.

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.