bikeloop
x 0.90 · purpose 0.65 · t 1 min · scope 0.85
from 2025-12
Python · FastAPI · MapLibre · Docker · CI/CD
I wanted a way to ask for a ride of a certain distance and get a route that ends where it started. BikeLoop generates cycling loops from OpenStreetMap roads, then scores them for how they actually feel to ride, not just whether their distance is correct.
a loop in space
A 21.38 km loop drawn as a line over a ground grid, elevation exaggerated 3×: it climbs 329 m and descends 329 m between 3 m and 105 m, starting and ending at 73 m. Left and right arrows move a marker along it.
data
| distance | elevation (m) |
|---|---|
| 0.00 km | 73 |
| 0.50 km | 64 |
| 1.00 km | 57 |
| 1.50 km | 67 |
| 2.00 km | 85 |
| 2.50 km | 83 |
| 3.00 km | 82 |
| 3.50 km | 85 |
| 4.00 km | 90 |
| 4.50 km | 80 |
| 5.00 km | 78 |
| 5.50 km | 69 |
| 6.00 km | 91 |
| 6.50 km | 104 |
| 7.00 km | 97 |
| 7.50 km | 85 |
| 8.00 km | 75 |
| 8.50 km | 69 |
| 9.00 km | 71 |
| 9.50 km | 68 |
| 10.00 km | 63 |
| 10.50 km | 59 |
| 11.00 km | 49 |
| 11.50 km | 57 |
| 12.00 km | 50 |
| 12.50 km | 45 |
| 13.00 km | 29 |
| 13.50 km | 22 |
| 14.00 km | 40 |
| 14.50 km | 78 |
| 15.00 km | 80 |
| 15.50 km | 49 |
| 16.00 km | 26 |
| 16.50 km | 3 |
| 17.00 km | 3 |
| 17.50 km | 3 |
| 18.00 km | 14 |
| 18.50 km | 39 |
| 19.00 km | 78 |
| 19.50 km | 96 |
| 20.00 km | 77 |
| 20.50 km | 84 |
| 21.00 km | 80 |
| 21.38 km | 73 |
A route is a sequence of connected roads, but on the map it becomes a single shape. The route viewer preserves that shape and lifts it gently off the plane using actual terrain elevation, so the same line stays recognizable from overhead and oblique angles. A moving point follows the route, with the distance traveled along the line available on inspection. The terrain here is USGS 3DEP lidar; BikeLoop’s own ascent figure sums BRouter’s SRTM elevations, so the two climb numbers can differ.
beyond the shortest path
The 21.38 km loop from overhead, north up, ridden clockwise, with arrows for its direction. 0.46 km of it reuses its own corridor (5.49 to 5.73 km and 9.26 to 9.49 km), by BikeLoop's rule: on a 50 m grid, passes within 45° of parallel count, crossings don't. Below it, the elevation along the loop on the same distance scale. Left and right arrows move a marker along both.
data
| stretch | along the loop | length |
|---|---|---|
| reuse 1 | 5.49–5.73 km | 235 m |
| reuse 2 | 9.26–9.49 km | 225 m |
BikeLoop doesn’t simply find the shortest way between two points. It places via-points on a circle around the start and, across several bearings at once, grows or shrinks each circle until the routed loop lands near the requested length. Then it judges the shape: how much of the loop reuses its own corridor (riding a road back the other way counts, crossing it doesn’t), how smoothly it corners, and whether a via-point dragged it down a dead end and back, a spur it trims out. The shortlist is ranked to stay diverse, not near-copies of one loop. A loop can be numerically precise yet awkward to ride, so road overlap and route direction come before any wall of scoring terms.
the road graph as data
A schematic of the road data layout. Before: a dictionary entry per road segment, dict[id, (p0, p1, tags)]. After: 5 flat arrays (x0, y0, x1, y1, tags) indexed by segment id, whose tag column points into one deduplicated table of 5 way tag sets. The dataset holds about 18.1 million road segments. Left and right arrows select a segment and light its cells and the tags they name.
data
| i | x0 | y0 | x1 | y1 | tags |
|---|---|---|---|---|---|
| 0 | 0 | 0 | 30.6 | 2.2 | 0 → residential · asphalt |
| 1 | 30.6 | 2.2 | 59.4 | 4.6 | 0 → residential · asphalt |
| 2 | 59.4 | 4.6 | 60.7 | 35.2 | 1 → tertiary · asphalt |
| 3 | 60.7 | 35.2 | 62.3 | 66.1 | 1 → tertiary · asphalt |
| 4 | 62.3 | 66.1 | 31.8 | 67.6 | 0 → residential · asphalt |
| 5 | 31.8 | 67.6 | 1.4 | 69.3 | 2 → cycleway · asphalt |
The application expanded from Gainesville to major US cities by changing how road data was represented in memory. The way index used to be a pickled Python dictionary holding each segment’s endpoints and tags, which costs an order of magnitude more live heap per segment. Now coordinates live in flat float32 arrays, each way’s tags are stored once in a deduplicated table, and because segment ids are already dense, a segment’s id is simply its position in the arrays. The result is a road index covering about 18.1 million OpenStreetMap road segments, served from my own infrastructure.
request to route
A request path: frontend → nginx → fastapi carries the target km + start. Inside fastapi, generation runs preflight → bearings → variants → score → rank; brouter computes every road route it asks for (via-points out, road routes back). The ranked loops return the same way to the frontend, which offers gpx export. A route cache sits in front: identical concurrent requests share one computation (in-flight de-duplication). nginx, fastapi, brouter run under docker compose. Left and right arrows step a pulse through the hops.
data
| step | from | to | carries |
|---|---|---|---|
| 1 | frontend | nginx | target km + start |
| 2 | nginx | fastapi | target km + start |
| 3 | preflight | bearings | per bearing, via-points on a circle; radius × target/actual until it converges (clamped to 0.5–2) |
| 4 | fastapi | brouter | via-points |
| 5 | brouter | fastapi | road routes |
| 6 | bearings | variants | edge-reuse alternatives for the top few candidates |
| 7 | variants | score | bikeability, corridor reuse, cornering flow |
| 8 | score | rank | multi-objective greedy top-K, kept diverse |
| 9 | fastapi | nginx | ranked loops |
| 10 | nginx | frontend | ranked loops |
The MapLibre frontend asks for a target distance and a start. Behind nginx, a thin FastAPI shell hands the request to the routing core, a framework-free, dependency-injected library with an explicit error taxonomy and structured logging. The core plans the search: it fans out across bearings, refines each one’s radius toward the target, asks for road variants of the best few, then scores and ranks the candidates. The pathfinding itself is a stock BRouter server, which computes every road route the service asks for. Identical requests share one computation through a route cache, and the ranked loops come back for the frontend to draw, edit and export as GPX. The API, the frontend and the routing engine run as a containerized three-service stack. A self-hosted CI/CD pipeline gates every deploy on linting, strict typing with mypy, and at least 85% branch coverage across about 90 tests.