Skip to main content

The week we drew our own map

· 5 min read
Massimo Di Stefano
FastGIS author

Losing the OSM volunteer tile servers stung. It also settled an argument we had been having with ourselves: a routing platform that cannot render its own map is renting its most visible surface from someone else, on someone else's schedule.

This week we stopped renting.

Our own map

Phase 0 was Norway, behind a flag: planetiler over the Europe PBF we already host, our own style, our own self-hosted glyphs — because self-hosting the fonts is the difference between owning the basemap and owning only the tiles. Surface is a first-class visual channel in it: unpaved roads render ochre with a dashed casing, so "can I take the GS down there" is answerable at a glance. An absent surface tag stays neutral — overclaiming gravel is worse than saying nothing.

Phase 1 was all of Europe: 28.96 GB, 22.7 million tiles, 53 minutes on 24 cores. Verified by content, not exit code — the Champs-Élysées, Unter den Linden and the Colosseo each have to decode out of their own tile, because a continental build can lose a whole region while every other check stays green.

Then the rest arrived in days rather than months: hillshade and contours from our own GLO-30 DEM (Galdhøpiggen decodes to within ~10 m of its true summit — wrong georeferencing would miss by hundreds), POI icons with a palette that survives mid-zoom, a layer manager, tap-a-POI-to-identify, 3D buildings, a device-side tile server so the basemap works offline, and an unpaved-density heatmap pilot — because we measured that overview-zoom tiles simply do not contain the dirt, so no styling could ever answer "where is the riding".

Honesty corner: terrain tiles briefly stalled the whole API at low zoom, a style-spec subtlety silently dropped an entire contour layer, and the new map stays behind its flag until the cartography earns the default slot. It is ours now; it is not yet better. That gap is the roadmap.

Terrain became a first-class layer

The same DEM work pulled GRASS into everything. Per-country slope derivatives served as COG mosaics, an elevation/slope/aspect profile along any track, walking-cost services, wild-camp spot scoring refined on 1 m national DTM patches, and ice-risk that now casts actual shadows with r.sun at the hour you will ride past — a shaded hairpin at 8 a.m. and the same hairpin at noon are different roads in October.

Sweden's 1 m elevation went in without mirroring the 1.2 TB national product: we fetch only the tiles a route corridor touches. And the week supplied its own lesson in humility: in a lat-lon location, r.walk was quietly counting degrees as if they were metres. Everything walk-related now runs in a metric projection, with a sanity guard so that class of bug cannot ship silently again.

On the phone this surfaces as a climb strip, grade badges and an ice-risk panel; on the desktop as slope shading with a legend.

Wrong twice, then right

Offline routing on region packs was crashing on device with std::out_of_range while the identical pack routed fine on the server. We root-caused it three times in three days, and the first two answers were wrong.

First: "the packs are an incomplete graph subset." Second: "the August EU tile builds are corrupt — suspect the faulty CPU core." Both fit the evidence; both were disproven by the next experiment. The true cause was in our own mobile wrapper: it installed a tile-download getter even for local packs, so the first out-of-pack tile reference — normal and harmless when there is no getter — walked into a substr on an empty URL and threw. The server never reproduced it because server tools never install a getter.

No tileset was ever corrupt. No rebuild was needed. The fixed engine is pinned, reproduced and verified off-device in seconds instead of on a physical iPhone, and confirmed on production Norway and Spain packs. The lesson we keep re-learning: a plausible root cause that fits the evidence is a hypothesis, not an answer.

Voice under field conditions

Full-duplex calling met two real phones, and the phones won the first round. The fixes that came back: answering must go through CallKit, never around it — iOS refuses the app an audio session while a call sits unanswered, which is a connected-but-silent call; and a media library was deactivating the shared audio session a tenth of a second after any of its players stopped, tearing the floor out from under the live call. Hang-up races, ring cancellation and TURN over port 443 all landed the same day.

Locks and alarm bells

A security audit got paid down: fail-fast on weak secrets, login lockout, session epochs, SSRF-hardened URL imports, non-root containers, authenticated Redis. And the confession that started the visibility push — this repo had no CI at all, and the desktop client once sat broken for a week while Swarm served a stale container that looked perfectly healthy. Now there is CI, a daily deploy-drift check that messages Telegram only when something is wrong (a monitor that says "all good" every morning trains you to ignore it), live probes that exercise routing and geocoding end-to-end, every install reports exactly which build it is running, and the SOS bot has a watchdog — a dead emergency channel must make a noise.

Also

Four more countries joined the EU set (Luxembourg, Ireland with NI, Malta, Cyprus). SOS alerts now speak the contact's language. A portable cold-archive drive got a scriptable toolkit — after we measured the real link speed and the economics of restoring flipped. And enabling Redis AOF on an existing dataset can boot you into an empty database; we hit it, recovered, and wrote it down.


Dig deeper: offline data on the rider app, and the data products behind the new basemap.