Skip to content

Navigation Menu

Sign in
Sign up

Repository files navigation

Trail Blazer

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.

Trail Blazer: a snapped four-point loop through downtown Toronto with distance, ride time, climb, bike-infrastructure share and an elevation profile

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.

How it works

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Running it

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.

ESM only

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 interface

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).

Performance

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.

Why not Google Maps?

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.

Services used (all free, no keys)

  • 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.

Contributing

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.

License

MIT. See LICENSE.

Project layout

  • src/main.ts map, waypoints, panels, culling, persistence
  • src/trails.ts tiled Overpass fetching, spatial index, snapping
  • src/tilecache.ts IndexedDB tile cache
  • src/routing.ts BRouter client, legs, elevation, infra share
  • src/geo.ts, src/gpx.ts, src/geocode.ts, src/icons.ts

About

Click points on a map, snap them to bike trails, and get a rideable loop. Leaflet + OpenStreetMap + BRouter, no API keys.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

AltStyle によって変換されたページ (->オリジナル) /