Blog
Engineering

An interactive map on a microcontroller

Drag to pan, scroll or pinch to zoom — a Leaflet map of the same OpenStreetMap tiles, bundled and served from this site. The live Canvas reimplementation is in the figures below.

An ESP32-S3 is running an interactive OpenStreetMap — you can pan it, pinch to zoom, watch the tiles stream in over Wi-Fi, watch it recenter on a GPS fix. The code that draws it is the web's Canvas 2D API — getContext('2d'), fillRect, drawImage, fillText — the same calls you'd write in a browser tab. What's missing is the browser: that Canvas code was compiled to native C++ ahead of time.

The Canvas API is the web's 2D drawing surface. You tell it "fill this rectangle here," "draw this image there," "write this text" — the same thing browser games and charts are built out of. In a browser, those calls travel through a big graphics engine and usually the GPU. This chip has neither. A Waveshare ESP32-S3 has a few megabytes of RAM, a small real-time kernel (FreeRTOS) underneath, and a little round AMOLED screen. No GPU, no browser. A chip, a screen, and the code.

GEA's idea is simple: give you that same drawing API, but compile it to native C++ ahead of time, so it runs straight on the chip. Your fillRect doesn't call into a browser at runtime — there's no browser there. It's already C++ that sets pixels in the screen's buffer.

This is the third post in the series. The first was about CSS; the second about a single component that ran unchanged across two very different screens. This one is about graphics — about drawing pixels with the web's API on a chip that has never heard of the web.

It's mostly one call

OpenStreetMap serves the world as little square images — tiles — that line up into a grid. Drawing the map comes down to working out which tiles land on the screen right now and painting each one where it belongs. Painting a tile is a single Canvas call:

ctx.drawImage(tile, left, top, width, height)

That's the whole drawing primitive for the map. A background fill, one drawImage per visible tile, a little text for the labels. Working out where each tile goes — turning a latitude and longitude into a pixel — is just arithmetic, the standard map projection written out as plain multiplication. No graphics library decides anything; by the time the Canvas hears about it, all it gets handed is a rectangle and an image.

Those same calls, running live in your browser: a tile drawn onto a filled background with a Berlin label. On the device the identical code is compiled to native.

Because the compiler knows ahead of time exactly what ctx is, every ctx.* call becomes a direct native instruction that sets bytes in the screen's buffer. The one call the web doesn't have is a pair that brackets a frame — draw everything, then push it to the screen once. On a chip, sending the whole screen out is the expensive part, so you do it once a frame rather than after every line.

Making it feel like a map

A still picture of tiles isn't a map yet. What makes it one is the panning and the pinch-zoom, and that comes from the loop around the drawing, not the drawing itself. GEA gives you the browser's requestAnimationFrame, and the map lives inside a single loop built on it: drag the screen, the view shifts, the next frame redraws. It only redraws when something actually changed, so an idle map costs nothing.

function tick() {
  if (viewChanged) render()   // redraw only when the map moved or a tile arrived
  requestAnimationFrame(tick)
}

Fetching a tile over Wi-Fi and decoding it takes far longer than a frame, so it can't happen on the frame — it would hitch every time. Instead the tiles are fetched and decoded in the background, on the chip's second core, and dropped into a cache when they're ready. The drawing loop never waits on the network; it just paints whatever has arrived. And when a tile isn't there yet, the map stretches a coarser one it already has to cover the gap, so it never blanks to gray while you wait.

That render loop turning over, live here: the visible tiles composited and a marker drawn on top, drifting because the loop never stops. The same drawImage and fillRect the chip runs.

The board knows where it is

This board has something a browser tab doesn't: a GPS receiver. It's a Waveshare ESP32-S3 with built-in GPS and that round AMOLED, the map laid out across it in landscape. GEA reads the fix through the same Geolocation API the web gives you — and when a fix lands, the map recenters on it. It doesn't drop a "you are here" dot; it just moves the world under you to put you in the middle.

A saved-place pin drawn over the map: a red stem and head plus a name label, nothing more than a couple of filled rectangles and a line of text. The same calls on the board.

The pins on the map are saved places, not your location. Each one is a couple of filled rectangles for the marker and a line of text for the name, drawn with the same calls as everything else. Pins, GPS, tiles — all of it is the same small handful of Canvas calls pointed at different data.

The same code is the web

Notice how little of this was exotic. The drawing was fillRect, drawImage and fillText. The projection was arithmetic. The motion was requestAnimationFrame and a dirty flag. The location came from Geolocation. There's nothing in that list you couldn't have written for a <canvas> in a browser tab — and nothing you'd have written any differently.

That same source runs on the web, on macOS, on iOS, and on this ESP32. One codebase, no fork for the chip. What changes from target to target isn't your code; it's what the compiler decides each call should become underneath.

In a browser, fillRect travels through the context, a graphics engine, and the GPU. On the ESP32 it's a few lines of C++ setting bytes in a buffer. The call is identical; the layers under it are not.

And that's the whole trick. Nothing on that screen knows it's on a chip. The Canvas code thinks it's in a browser, the loop thinks it's calling requestAnimationFrame — and both are right, because the web's drawing model never actually needed a browser to run. It needed somewhere to put pixels. Hand it a screen instead of a browser, compile it ahead of time, and the map is none the wiser. It just draws — on a round little screen that happens to know where it is.