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
distanceelevation (m)
0.00 km73
0.50 km64
1.00 km57
1.50 km67
2.00 km85
2.50 km83
3.00 km82
3.50 km85
4.00 km90
4.50 km80
5.00 km78
5.50 km69
6.00 km91
6.50 km104
7.00 km97
7.50 km85
8.00 km75
8.50 km69
9.00 km71
9.50 km68
10.00 km63
10.50 km59
11.00 km49
11.50 km57
12.00 km50
12.50 km45
13.00 km29
13.50 km22
14.00 km40
14.50 km78
15.00 km80
15.50 km49
16.00 km26
16.50 km3
17.00 km3
17.50 km3
18.00 km14
18.50 km39
19.00 km78
19.50 km96
20.00 km77
20.50 km84
21.00 km80
21.38 km73
one route, shown as a continuous line through terrain.

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
stretchalong the looplength
reuse 15.49–5.73 km235 m
reuse 29.26–9.49 km225 m
distance is a constraint. shape is part of the answer.

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
ix0y0x1y1tags
00030.62.20 → residential · asphalt
130.62.259.44.60 → residential · asphalt
259.44.660.735.21 → tertiary · asphalt
360.735.262.366.11 → tertiary · asphalt
462.366.131.867.60 → residential · asphalt
531.867.61.469.32 → cycleway · asphalt
indexed arrays keep a large road network within reach.

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
stepfromtocarries
1frontendnginxtarget km + start
2nginxfastapitarget km + start
3preflightbearingsper bearing, via-points on a circle; radius × target/actual until it converges (clamped to 0.5–2)
4fastapibroutervia-points
5brouterfastapiroad routes
6bearingsvariantsedge-reuse alternatives for the top few candidates
7variantsscorebikeability, corridor reuse, cornering flow
8scorerankmulti-objective greedy top-K, kept diverse
9fastapinginxranked loops
10nginxfrontendranked loops
a requested distance becomes a navigable loop.

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.