Click points on a map, have them snap to real bike trails, and get a rideable loop back. Purpose-built for planning a ride around places you want to visit, without hand-tracing every turn.
It is a single-page web app built with Leaflet and TypeScript. There is no backend and no API key: trail geometry comes from OpenStreetMap, routing from BRouter, and everything else runs in the browser. Open it, click a few places you'd like to ride past, and export the GPX.
- Trails load for the area you're looking at. Bike infrastructure comes straight from OpenStreetMap through the Overpass API, fetched as cached tiles: off-road trails and protected tracks (green), painted lanes (blue), shared lanes (dashed). Data loads at zoom 13 or closer. Tiles are kept in IndexedDB for two weeks, so a city you've already looked at loads instantly and still works when Overpass is slow or down.
- Every click snaps. A click is moved to the nearest trail junction if one is close, otherwise to the nearest point on a trail within the snap radius you choose. If nothing is nearby it stays as a "free" point and routing still works from there. Drag any marker to re-snap it.
- The engine stitches the points into a route. Routing uses BRouter, an open bike-specific router. With the loop button on, the route returns to your first point. The stats bar shows distance, ride time, climb, an elevation profile, and the share of the route on mapped bike infrastructure.
- Take it with you. Export a GPX for your bike computer, or copy a link that restores the route and settings. The last route is also kept locally.
Requires Deno 2.9 or newer. There is no npm install
step and no npm CLI involved — Deno resolves every dependency from
deno.json itself, including Vite, which runs in the Deno runtime.
deno task dev
deno task build runs the checks and produces a static site in dist/ for any
static host. deno task check runs them on their own: types, lint, the ESM
audit, and the tests.
Every module in the browser bundle must be ESM. CommonJS is not merely discouraged here, it is checked for, because the way it usually arrives is silent: a bundler that cannot find an ESM entry falls back to the CommonJS one, wraps it in interop, and ships it without a warning.
Three layers enforce it, each catching what the others cannot see:
| Layer | Runs as | Catches |
|---|---|---|
tools/esm-lint-plugin.ts |
deno lint |
CommonJS syntax in our own source: require, module.exports, __dirname, createRequire |
tools/check-esm-deps.ts |
deno task check:esm |
A CommonJS package entering the runtime dependency graph, read from deno info so transitive ones count too |
tools/esm-vite-plugin.ts |
every vite build |
CommonJS reaching the bundle — including the case above, where Vite resolves a specifier differently than Deno does |
That last one is not hypothetical. Vite resolves bare specifiers through
node_modules with its own resolver and never reads deno.json's import map,
so resolve.alias in vite.config.ts has to restate every ESM entry mapped
there. Remove the Leaflet alias and the build fails with the offending file
named.
The policy, and the one exception it currently grants, live in
tools/esm-policy.ts. deno task test covers the enforcement itself — a check
nobody has watched fail is not a check.
The map is the whole screen. Everything else floats on frosted glass:
- Top left: brand and place search (
/focuses it). - Dock (left on desktop, bottom on phones): waypoints list, tuning, map layers, loop toggle, my location, undo, clear, copy link, export GPX. Hover any icon for its label.
- Stats bar (bottom): appears once a route exists. The chevron opens the elevation profile.
| Action | How |
|---|---|
| Add a waypoint | Click the map or a trail |
| Move a waypoint | Drag its marker |
| Close the loop | Loop icon, L, or click the start marker |
| Undo | Undo icon or Cmd/Ctrl+Z |
| Reorder or remove | Waypoints panel |
| Close panels | Esc |
Tuning: snap radius, whether painted lanes count as snap targets, and the BRouter profile (safest, trekking, quiet roads, fast road, shortest).
Measured in Chrome on a ×ばつ900 viewport with 12,568 trail ways in the index (synthetic data, identical for both runs), zoom 14, about 4,950 polylines near the viewport:
| Metric | Canvas (shipped) | SVG |
|---|---|---|
| Pan redraw, sync | 10.2 ms avg | 12.1 ms avg |
| Zoom redraw, sync | 16.1 ms avg, 27.6 max | 23.2 ms avg, 39.1 max |
| Animated pan / zoom | 118 / 114 fps | 118 / 112 fps |
| Big pan (2 frames incl. culling) | 19 ms | 34 ms |
| Long tasks (>50 ms) | 0 | 1 |
| JS heap | 139 MB | 369 MB |
| Snap query | 0.09 ms | 0.10 ms |
| Hover hit-test | 0.1 ms | native |
What makes it fast:
- Canvas renderer for trails (one bitmap instead of thousands of DOM nodes). The route stays on SVG so its dash animation is CSS-only.
- Viewport culling: only ways within half a screen of the view exist as Leaflet objects; the rest are plain data. Adds happen in 8 ms slices per frame, so a big tile never blocks input.
- Spatial grids for both trail segments and junctions, so snapping is a few hundred distance checks rather than a scan of the city.
- Tiled fetching with two requests in flight, nearest tile first, and per-tile parse plus index in about 3 ms.
- Resilience: 45 s request timeout, a one-minute cooldown for any Overpass endpoint that errors, three mirrors, and automatic fallback to the Streets basemap if a tile source stops responding.
Append ?svg to the URL to run the trail layer on SVG for comparison.
In dev builds window.__trailBlazer exposes the map and index for profiling.
Google's bicycling layer is render-only: the API does not expose trail geometry, so there is nothing to snap to, and every map load and directions request needs a billed API key. OpenStreetMap has the actual trail lines and tags, which is what makes snapping to a junction possible.
- Tiles: OpenStreetMap (Streets), OpenTopoMap (Terrain), Stadia Maps (Muted; needs a free key when hosted on a public domain)
- Trail data: Overpass API with two mirrors
- Routing and elevation: BRouter
- Geocoding: Nominatim
These are community servers with fair-use limits. Overpass in particular will temporarily block an IP that hammers it. For heavy or public use, self-host BRouter and Overpass or switch to a paid provider.
Issues and pull requests are welcome. The code is small and dependency-light
on purpose: Leaflet is the only runtime dependency, and any new one must be
ESM. Run deno task check before opening a PR. To work offline or without hammering Overpass, the dev
hook window.__trailBlazer.importWays(ways) accepts an array of ways in the
same shape the index stores.
MIT. See LICENSE.
src/main.tsmap, waypoints, panels, culling, persistencesrc/trails.tstiled Overpass fetching, spatial index, snappingsrc/tilecache.tsIndexedDB tile cachesrc/routing.tsBRouter client, legs, elevation, infra sharesrc/geo.ts,src/gpx.ts,src/geocode.ts,src/icons.ts