· Software · 12 min read

Maps on Your Wrist Without a Map Engine: A watchOS App for CoMaps

Disclaimer: I am not an iOS developer, but a computational scientist that dabbles with random technology in my spare time.

I have wanted a watch companion app for CoMaps (a community fork of Organic Maps, built on OpenStreetMap data) for a long time. My initial research was discouraging since Apple does not expose Metal (the GPU programming interface) to third-party apps on watchOS, which means you cannot run a real map renderer on the watch. That is a high barrier for any map app, and it is why so few of them have watch companions.

Then I looked at how Garmin devices handle navigation and realized that for glancing at your wrist mid-ride, you don’t need a map engine. You need a line — the route — and just enough context around it to orient yourself. A GPX trace with street names’ worth of geometry would be “good enough”. Months went by, I kept poking at the CoMaps codebase in my spare time, and eventually this turned into a working app that I now use for my daily commute. The code itself was never the barrier — in my day job is resort often to Python (especially for plotting through ultraplot that I develop ;-)), but I have written split-keyboard firmware for the ESP32 in C++, recently dabbled a bit in rust, and have a soft spot for lower-level languages in general, Nim in particular. Needless to say, wandering through a C++ map engine felt like familiar territory. The Apple ecosystem around it did not - like at all. This post is about how it works.

But what if?

CoMaps renders maps with Drape, a custom C++ engine that reads the offline map files tile by tile, applies the full style rules, and draws everything on the GPU — text, icons, 3D buildings, the lot. None of that can come along to the watch — there is no Metal, no room for multi-gigabyte map files, and frankly not enough battery to want either.

My first thought as an outsider was that surely Apple ships a ready-made map view for the watch. It does — MapKit exists on watchOS — but it draws Apple’s map data, fetched over the network. The entire point of CoMaps is offline OpenStreetMap data, so an app that needs connectivity to draw the map under your offline route defeats itself the moment you leave coverage.

But the phone in your pocket already has everything — the routing engine, the offline maps, your bookmarks. Then it dawned on me,

The phone stays the brain. The watch is just a renderer(!)

The watch never computes a route and never reads a map file. It draws compact snapshots that the phone pushes to it, and sends small commands back (“take me home”, “end the route”). If the phone is dead or out of range, the watch keeps working from the last snapshot it cached on disk.

%%{init: {'theme': 'dark', 'themeVariables': { 'darkMode': true }}}%%
flowchart TB
    subgraph phone["iPhone — the brain"]
        direction LR
        core["C++ core<br>routing · maps · bookmarks"] --- bridge["MWMWatchMapExtractor<br>(the only C++ bridge)"] --- sync["WatchRouteSyncManager<br>(sync orchestration)"]
    end
    subgraph watch["Apple Watch — the renderer"]
        direction LR
        store["RouteStore<br>(state + disk cache)"] --> views["SwiftUI views<br>map · guidance · elevation"]
    end
    phone <-->|"WatchConnectivity — 4 channels"| watch

All access to the C++ core is funneled through a single Objective-C++ file, so the Swift code on both sides stays clean and the iOS app doesn’t grow new C++ tendrils.

The brain and the Viewer

Getting data between the phone and the watch was where my non-iOS-developer status showed most. The two devices share no files and no memory; everything crosses through Apple’s WatchConnectivity framework, which offers no fewer than five different ways to send something. My first naive plan was to simply message the route to the watch whenever it changed. That fails in an instructive way — live messages only arrive while the watch app is reachable, meaning awake and connected, so a route built while the watch sleeps against your wrist would never show up. Nothing errors loudly. The data just isn’t there.

That failure sent me back to the documentation with a better question. Not “how do I send data”, but “what delivery guarantee does each mechanism actually make”. (This became the working pattern for the whole project — ask Claude Code what my options are and what trade-offs they carry, then check the claims against Apple’s docs, then test on real hardware.) Each mechanism turns out to be designed for a different shape of data, and the right design uses all of them:

ChannelMechanismCarries
Route snapshotApplication contextSimplified polyline + elevation profile, once per rebuild
Map corridorFile transferVector geometry blob, delivered in background
Commands (watch → phone)Request/reply messageBuild route, add bookmark, end route, refresh
Turn stream (phone → watch)Fire-and-forget messageNext maneuver + distance, throttled to every 3 s
%%{init: {'theme': 'dark', 'themeVariables': { 'darkMode': true }}}%%
sequenceDiagram
    participant P as iPhone
    participant W as Watch
    Note over P,W: route built on the phone
    P->>W: application context — polyline + elevation
    P->>W: file transfer — corridor blob (background)
    Note over P,W: user taps a bookmark on the watch
    W->>P: message — "build route to X"
    P->>P: routing engine builds route
    P->>W: application context + file transfer (as above)
    Note over P,W: while navigating
    P->>W: message — next turn (every 3 s)
    W->>P: message — "end route"

The mechanism that made the whole design click is the application context. It is a single dictionary that the system keeps synchronized between the two devices whenever it gets around to it, even while the watch app isn’t running — and each new push simply replaces the previous one. I first read “replaces the previous one” as a limitation, until I noticed it describes a route perfectly. Only the latest route matters; every intermediate state is worthless. So a route is transferred once per (re)build. No continuous streaming, no continuous battery cost. Stopping the route pushes an explicit “inactive” context and everything clears.

The corridor could not travel the same way. The application context is meant for small dictionaries of a few tens of kilobytes, while the corridor runs to a few hundred. Bulk data gets its own mechanism in transferFile, which queues the file and delivers it in the background at a pace the system chooses — exactly the right indifference for street context that will only become useful a minute from now. The interactive commands (build route, end route) genuinely need the live message path with its reachability requirement, but there the failure mode is acceptable, because you are looking at the watch when it happens and the screen can just say the phone isn’t reachable.

The polyline itself is simplified with Douglas–Peucker (an algorithm that removes points that don’t meaningfully change the shape of a line, more on that in a separate post perhaps) down to at most 2000 points — far more than a screen a few hundred pixels wide can show anyway.

Recreating a Tile

The part I am most pleased with is the miniature vector map under the route. Since the watch can’t read map files, the phone pre-chews them. It walks along the route, samples a point roughly every 400 m, and queries a ~1 km square around each sample using DataSource::ForEachInRect — the exact same API that Drape’s own tile readers use. Every feature that comes back is collapsed from the hundreds of style classes down to six kinds.

%%{init: {'theme': 'dark', 'themeVariables': { 'darkMode': true }}}%%
flowchart TD
    A["Route polyline"] --> B["Sample every 400 m"]
    B --> C["~1 km square per sample"]
    C --> D["DataSource::ForEachInRect<br>(same API the phone renderer uses)"]
    D --> E["Classify into 6 kinds:<br>major road · minor road · path<br>rail · water line · water area"]
    E --> F["Dedupe by feature ID,<br>cap point budgets"]
    F --> G["Pack binary blob (CMWC)"]
    G --> H["Background file transfer<br>to the watch"]

Two details fell out of the map format for free:

  • Water polygons arrive pre-triangulated. The map files store area geometry as triangles precisely so the renderer never has to tessellate — the watch inherits that and just fills triangles.
  • The classificator (the type system the map styles are keyed on) answers “is this a primary road or a footpath?” with the identical mechanism the phone’s styling uses.

For shipping the result, the day-job reflex says JSON. Bad idea — coordinates written as text take roughly four times the bytes, and the watch would burn CPU parsing strings. Apple’s property lists are denser but still pay per-key overhead on every single feature. This is where the firmware hobby paid off more than the science — on an ESP32 every protocol is a hand-packed struct, so designing a little binary format felt like meeting and old friend. Geometry is just numbers, and it became a small custom binary format — magic bytes CMWC, a version, then per feature one byte of kind, a point count, and latitude/longitude as Float32 pairs (about 1 m precision, half the size of doubles, and plenty for a corridor only a kilometer wide). A typical route corridor is a few hundred kilobytes. Unknown feature kinds are skipped rather than rejected when parsing, so a future phone app can add kinds without breaking older watches.

And there shall be a Draw!

On the watch, the whole map is drawn by SwiftUI Canvas — no tiles, no textures, just vector paths. The performance trick is to separate what happens once from what happens every frame:

%%{init: {'theme': 'dark', 'themeVariables': { 'darkMode': true }}}%%
flowchart TD
    subgraph once["Once per corridor"]
        A["Parse CMWC blob"] --> B["Project to local meter space<br>(flat east/north plane around route midpoint)"]
        B --> C["Merge into one cached Path per layer<br>(at most 6 paths, regardless of feature count)"]
    end
    subgraph frame["Every frame"]
        D["One affine transform:<br>translate → rotate → scale (flip y)"]
        E["Canvas draws layers in order:<br>water → rail → roads → route → you"]
        D --> E
    end
    C --> D
    F["Crown zoom"] --> D
    G["Compass heading"] --> D

Coordinates are projected once into a flat plane of east/north meters around the route’s midpoint (a local equirectangular projection — accurate to well under a meter at corridor scale, and much cheaper than Mercator per point). After that, zooming with the digital crown and rotating with the compass never touch the geometry again — they only change a single transform matrix. That is why panning and rotating stay smooth on watch hardware. Thousands of points are projected exactly once.

The layers declutter themselves as you zoom out, painter’s-algorithm style:

LayerDrawn when the visible span is
Water areas & linesalways
Major roadsalways
Rail< 25 km
Minor roads< 8 km
Footpaths< 4 km

On top of that come bookmark dots, the route (traveled part gray, remaining part blue), your position with a course wedge, and a little north needle whenever the map is rotated heading-up.

The watch map page showing the route over extracted streams and lakes
The corridor under the route — streams, Crater Lake next to the position dot, Dove Lake to the east, and saved places as yellow dots. The stills throughout are from the watch simulator, rendering day one of the Overland Track (Cradle Mountain, Tasmania) from a real GPX.

The UI

Since I suck at UI stuff, I threw this problem at claude code. The initial issue on codeberg already had more talented people than me make mock-ups that I really like. So I just pointed claude to the issue and it got to work.

The app has one idle screen and three route pages (in a user-configurable order):

  • Map — everything from the previous section. Crown zooms, double-tap fits the whole route, and when zoomed in the map rotates so the direction you face points up.
  • Guidance — a big compass-style arrow pointing along the route, with distance, arrival time, and the next maneuver streamed from the phone folded in underneath. It aims 100 m ahead along the route rather than at your snapped position, so it follows curves without flipping on GPS jitter. Drift more than 100 m off the route and the page turns red, the arrow points back to the route, and (if a workout is running) the watch taps your wrist. From here you can also end the route on the phone.
  • Elevation — the height profile with total ascent/descent and a crown-scrubbable cursor. The scrubbing has velocity-dependent gain — slow crown turns move meter by meter, sustained spinning sweeps the whole route (the gain ramps with the cube of the smoothed crown speed, which feels much more controllable than a linear ramp).
  • Idle — a “Home” chip, a ”+” that bookmarks your current location (“where did I park?”), and your phone’s bookmarks as tappable destinations. Tap one and the phone builds the route.
The guidance page in its normal green state next to the red off-route state
The guidance page on the trail, and 220 m off it — the page turns red and the arrow points back to the route.

Battery was another place where watchOS re-educated me. On an early test I noticed the position dot only moved while I was actually looking at it — watchOS freezes third-party apps the moment the wrist goes down, full stop, and no amount of background-location plumbing changes that. The sanctioned exception is a workout session. An app running a HealthKit workout may keep working through wrist-down, so that became the design. Location runs only while the app is on screen, which makes glancing nearly free — watchOS even borrows the phone’s GPS transparently when it is in Bluetooth range. If you want continuous guidance, you explicitly start a workout from the guidance page, which also records honestly to Apple Health, typed by route mode (cycling, hiking, …). The battery cost is opt-in by construction, and the freeze-on-wrist-down model turned from an obstacle into the reason a glance costs nothing.

What it took beyond the watch

Two things surprised me in scope. First, embedding a watch app meant migrating the iOS app from the deprecated application-delegate lifecycle to UIScene, which the current SDK requires — and that silently breaks CarPlay unless CarPlay gets its own scene role, and cold-start deep links need to be re-deferred until the map actually exists. Second, doing all of this on a free Apple developer account is possible but bumpy — personal-team signing, seven-day certificates, and a watch that refuses to install the app until it is registered as a development device.

So what’s next?

The PR to CoMaps is open (see issue #790). There are limitations. The watch-face complication is a static launcher (live data would need an App Group, which I left out to keep the entitlements free-account friendly), routing starts from the phone’s last known location rather than the watch’s GPS, and there is no map beyond the extracted corridor — zoom out far enough and the world is just your route and some water.

For my actual use case — glancing mid-commute to see where to turn — it already does more than I originally hoped for when “a GPX trace on the wrist” seemed like the ceiling.

Feedback very welcome, especially from actual iOS developers.

Back to Blog

Related Posts

View All Posts »