Inspiration

Time in nature is now prescribed clinically - roughly two hours a week, in sessions of twenty minutes or more. But nothing answers the question that prescription actually raises: given where I am, how long I have, and the body I have today, where should I walk?

For people with mobility impairments the failure is worse than unhelpful. Mainstream routers will direct wheelchair-users towards flights of steps simply because the missing tag in the map data suggests that it is probably fine to traverse - for a wheelchair user, that is not actually the case.

Thus the rule upon which the whole project is built: a missing tag on OpenStreetMap means UNKNOWN, never accessible. Everything else was negotiable. That never was.

What it does

Tell Meander where you are and how long you have (one dial, 20 to 360 minutes) and it gives you three walking routes back. Type a destination instead and you get three ways there. There are six objectives, and you pick up to three at a time:

objective what it optimises
Fastest Shortest time. The control.
Scenic Maximum greenery, capped at 1.6× the fastest duration
Accessible Hard accessibility constraints first. May honestly return no route at all
Quiet Away from motor traffic, off cobblestones
Shade The kinds of way that tend to be shaded
Clean air Away from traffic, out of tunnels

Every route card shows duration, distance, greenery, air quality and rest stops (benches, water, toilets, shelter, viewpoints). Open a route and you also get the best departure window, sunrise and sunset, turn-by-turn directions with any barrier shown inside the exact step where you'd meet it, photos of the way, and a short narration that Claude writes from the numbers on the card and nothing else. There's a live follow mode that walks the route with you: chevrons ahead, a heading cone, the next bench or water fountain coming up, your position on the elevation profile. Take a sustained wrong turn and it redraws the route. It also installs as a PWA and opens offline.

The part we care most about is that it tells the truth:

  • The accessible objective will return "blocked, and here's why" instead of inventing a path you can't use.
  • Every route says how much of its length was actually checked. Below 30%, the wording changes and tells you not to rely on it.
  • Quiet, shade and clean air are labelled "inferred, not measured" on every card, because OpenStreetMap has no noise or canopy data to steer on.
  • Every scenery score names its method: clip, geometry_only, or placeholder.
  • The privacy sentence on the follow screen changes with the basemap, because under satellite imagery the tile requests reveal your walk. The layer picker warns you before you pick it.

The server is stateless. No accounts, no cookies, no analytics, no location history. Route photos are proxied through our backend so no image host ever sees your IP next to the coordinates of your walk.

Try it in ten seconds (no account, no location permission, routes load on open):

One caveat before you type your own city: the self-hosted routing graph currently covers just three regions (Sri Lanka, Greater London and the Amsterdam area). Anywhere else, the app tells you it has no coverage instead of guessing.

How we built it

Frontend: React 19 and Vite, MapLibre GL with OpenFreeMap vector tiles, no component library. The design system is token-based (Hanken Grotesk and Space Mono) with three basemaps, including a green-cover repaint of the same tiles.

Backend: FastAPI on Python 3.13. POST /api/routes streams results over SSE so cards show up as they're computed. SQLite holds segment scores and a route cache. Rest stops and barriers come from Overpass, air quality from Open-Meteo, place search from Nominatim. Photos come from Wikimedia Commons and Mapillary through an HMAC-guarded proxy.

Routing: a self-hosted GraphHopper 11 with a custom model per objective. Self-hosting is what makes five of the six objectives real, since hosted free tiers can't run custom models. It also exposes the smoothness tag, our fifth hard accessibility constraint, which the hosted API can't supply at all.

Scenery scoring: CLIP (ViT-B-32) scores real Mapillary street imagery in an offline batch job that writes data/cache.db, and the deployed container just reads that file. CLIP needs 2 to 3 GB of RAM and our container is small, so the split isn't an optimisation, it's the only way it fits. Uncached areas fall back to numpy-only geometry scoring, and every response says which path produced its numbers.

Deployment and verification: the frontend ships from main via Cloudflare Pages. The API and router run on one VM as three containers behind Caddy. 845 backend and 651 frontend tests run fully offline at 87% statement coverage, and CI runs the suite under unshare -n to prove nothing opens a socket. A 102-check gate drives a real headless Chrome: axe-core accessibility checks, a 44×44px touch target sweep, and no horizontal scroll at 320px. A separate 28-check live gate grades production itself in a real browser: CORS, CSP, the service worker, the offline open.

Challenges we ran into

  • Eleven build phases without a single real route. The hosted routing API's free tier can't run custom models, so every steered objective came back blocked. Self-hosting GraphHopper fixed it. Until that day, the README literally said "it has never produced a real route".
  • The 30-minute loop that took 108 minutes. Our first version of quiet, shade and clean air sent one round-trip request and kept whatever came back, and our synthetic test fixtures made that look fine. Then we ran it against the real graph at Colombo Fort, and a 30-minute quiet loop came back at 108 minutes. The presets now try three loop lengths and keep the best fit (18 minutes there), and a verification script checks this against the real graph so it can't quietly regress.
  • The CLIP prompt that scored a fort greener than a park. We tested seven prompt pairs on three real location pairs, and our previous default got Colombo backwards. The winning prompt really measures aesthetic appeal rather than greenery (they correlate on our sample), and we wrote that caveat into the repo right next to the constant so it doesn't get forgotten.
  • The accessibility engine that never saw a barrier. The request path never passed barrier data into the engine, so a route through a kissing gate came back fine with confidence 1.0. We fixed it, and the coverage sentence now names what was examined. When gates and stiles couldn't be checked, it says exactly that.
  • Privacy claims that were true on the default map and false under satellite. We measured tile traffic during a simulated walk. The default map fetches nothing as you move; satellite fetches a tile for every stretch you cover, so the request sequence reveals the walk. A privacy claim that's only true for the default is worse than no claim, so the wording became conditional.

Accomplishments that we're proud of

  • Six objectives returning real routes from a self-hosted router, live in production, with the deployment gate passing 28 of 28.
  • WCAG 2.1 AA measured, not just claimed. 102 automated checks, including follow mode, which is how we found out it had been overflowing every portrait phone since the day it was written.
  • 1,496 tests that pass fully offline, with CI actually proving the offline part.
  • A privacy design we're genuinely proud of: the opt-in saved route is keyed by a SHA-256 hash with the origin snapped to an 11 m grid, so no coordinate ever sits in a cache key.
  • The honesty rules held even when it hurt: blocked answers, "inferred" labels, demo data marked "Do not follow it", and a README that reports its one pre-existing test failure as 101 of 102 instead of rounding up to green.

What we learned

  • Missing data is not good news. Treating an untagged path as UNKNOWN instead of accessible is the difference between a routing preference and a promise you're making to someone in a wheelchair.
  • Synthetic fixtures pass and reality fails. The 108-minute loop sailed through every test and fell over on the first real graph. Verifying against reality is now a script, not something we remember to do.
  • A proxy has to say it's a proxy. Shade inferred from the kind of path, scenery from an aesthetic prompt. Both useful, both shipped, both labelled on the card, because nobody can un-trust a number that showed up dressed as a measurement.
  • Test the deployment, not just the code. curl doesn't enforce CORS, doesn't run a service worker and doesn't apply a CSP, so production can break while every curl in a runbook stays green. That's why our gate is a real browser pointed at the real deployment.
  • Write the failure down. "All green except one" is the sentence that hides next week's regression. The honest number, written down, is worth more than a clean dashboard.

What's next for Meander

A native iOS build (the offline store has to move off CacheStorage, and the frontend needs the safe-area insets it currently doesn't have). Settling the satellite imagery licensing properly, or switching to the fallback we've already documented. More regions in the router. And testing on real phones in real hands, because no amount of headless Chrome replaces that. The repo's BLOCKED.md keeps every open limitation written down along with what it would take to close it.

The one we care most about: POST /api/report-barrier already exists in the API. We want to grow it into community barrier reporting that flows back into OpenStreetMap, so every wrong answer a walker corrects becomes data that every router gets to keep. For a routing app, that's about as social as it gets.

Built With

Share this project:

Updates

Submission history