moto-gpx

A zero-dependency Node.js CLI that turns a folder of GPX tracks into a stack of map-ready GeoJSON layers — tracks, stops, speed bins, photos, weather, OSM roads and places, state crossings, SRTM hillshade, OSRM route comparisons, sun position. Built for hand-making dope QGIS maps of multi-day motorcycle trips.

github.com/ejfox/moto-gpx  ·  releases  ·  MIT

Below: the tool's own output on a 5,689-point Garmin file — 56-mile Hudson Valley loop, September 2021. Map + elevation + speed SVGs are written to the output folder on every run. They respond to your OS theme.

map of the Hudson Valley trip with town labels and superlative markers
elevation profile of the Hudson Valley trip
speed profile of the Hudson Valley trip

What it does

Given a folder of .gpx files — phone, Garmin, helmet cam, whatever — moto-gpx walks recursively, merges every track point into one sorted timeline, dedupes, splits on break-gaps to find stages, and emits a stack of layered GeoJSON ready to drag straight into QGIS. Every .geojson gets a sibling .qml style file so the symbology auto-loads.

Optional enrichments add weather (Open-Meteo), OSM roads and place names (Overpass), OSRM route comparisons (what the car would've done), state/province crossings, sun position on each photo, and a seamless SRTM DEM + hillshade (AWS Terrain Tiles + GDAL). A exiftool-backed --media flag places photos and videos directly from EXIF GPS or interpolates them along the track by timestamp.

Every run ends with a superlatives banner — the year-in-review reading for a single ride.

Install

git clone https://github.com/ejfox/moto-gpx.git
cd moto-gpx && npm link

Needs Node 18+. Optional: exiftool for --media, GDAL for --dem. Zero npm install — the tool is stdlib + fetch only.

Run

moto-gpx ~/trips/big-sur \
  --media ~/trips/big-sur \
  --enrich all \
  --dem --dem-contour 100 \
  --mastodon @ejfox@mastodon.social \
  --out ./big-sur-out

Then drop ./big-sur-out/ into QGIS. Every layer comes pre-styled.

Output

out/
├── all.geojson            every stage as a LineString
├── stages/                one LineString per break-split stage
├── days/                  one file per calendar day
├── days-merged/           ONE unbroken LineString per day
├── hours/                 per-hour slices
├── stops.geojson          rest / long-rest / overnight Points
├── speedbins.geojson      line chunks tagged slow|moderate|fast|highway
├── markers.geojson        stage + day start/end Points, pre-labeled
├── media.geojson          photos + videos placed on the map
├── crossings.geojson      state/province transition Points
├── places.geojson         towns along the route (osm)
├── roads.geojson          OSM ways you rode, by highway class (osm)
├── pois.geojson           viewpoints, peaks, fuel, historic (osm)
├── optimal_routes.geojson  OSRM "what you should've done" (routes)
├── weather_timeline.json  hourly weather per stage region (weather)
├── dem/                   SRTM tiles + VRT + hillshade + contours (dem)
├── styles/                full QGIS .qml templates
└── stats.json             totals, per-stage breakdown, superlatives

CRS is plain WGS 84 (EPSG:4326). See LAYERS.md for every property on every layer.


Superlatives banner

Printed to console at the end of every run, also stashed in stats.json.superlatives. From the same Hudson Valley trip as the hero graphics above:

  ────────────────────────────────────────────────
  superlatives
  ────────────────────────────────────────────────
  fastest mile      1:07 @ 53.7 mph  (13:20)
  longest streak    14.4 min / 8.44 mi without a break
  highest point     353m at 41.4475, -73.8507
  lowest point      -2m at 41.4384, -73.9727
  biggest climb     355m over 15.92 km (2.2% avg)
  biggest descent   355m over 54.79 km (0.6% avg)
  steepest grade    10.6% over 204m
  longest straight  688m (0.43 mi) heading N
  turns per mile    332° — some curves
  time at speed     slow 62% · moderate 36% · fast 1% · highway 0%
  time of day       afternoon 100%

  telemetry
    top speed       90 mph at 13:20  (41.4456, -73.8564)
    0-60 mph        n/a (never came to a stop before hitting 60)
    time > 60 mph   1.2 min (1% of ride)
    peak cornering  0.92G at 32.2 mph through a 23m-radius turn
    hardest brake   -0.54G  (dropped 23.6 mph in 2s)
    biggest launch  0.76G  (gained 33.3 mph in 2s)
    smoothness      7/10 — smooth

  furthest north    41.50832°   (14:59)
  furthest south    41.30119°   (14:20)
  furthest east     -73.83158°   (13:04)
  furthest west     -73.98244°   (12:43)

  town signs you passed:
    12:27  Beacon · pop. 13,769
    12:40  Dutchess Junction (hamlet)
    12:49  Cold Spring (village) · pop. 1,986
    12:51  Nelsonville (hamlet) · pop. 624
    12:59  Dennytown (hamlet)
    13:45  Highland Falls (village) · pop. 3,684
    …

  roughly equivalent to:
    · 999 football fields end-to-end
    · 2.2× a marathon in distance
    · 3.6× the Empire State Building climbed
    · 4.6× the Eiffel Tower climbed
    · 0.23× Mt. Kilimanjaro from base camp
  ────────────────────────────────────────────────

Options

Core

FlagDefaultMeaning
--out <dir>./moto-outOutput directory
--split <mode>allday / hour / stage / all
--break <minutes>20Gap threshold to start a new stage
--min-points <n>10Drop stages with fewer points
--simplify <meters>0Douglas-Peucker tolerance, 0 = off
--tz <offset>localHours from UTC for day/hour bucketing
--name <string>folder basenameTrip name, stamped into every feature

QGIS prep layers (on by default, --no-<name> to skip)

--stopsstops.geojson — rest-stop + overnight Points
--speedbinsspeedbins.geojson — line chunks tagged by speed bucket
--markersmarkers.geojson — stage + day start/end Points, pre-labeled
--days-mergedONE unbroken LineString per day
--stylesSibling .qml style files for auto-load in QGIS
--superlativesPost-run fun-stats banner (+ stats.json.superlatives)

Media (needs exiftool)

--media <dir>Ingest JPG/HEIC/MP4/MOV as Point features
--media-tz <offset>Override for naive EXIF timestamps

DEM (needs GDAL)

--demFetch AWS Terrain Tiles, stitch into seamless trip.vrt
--dem-buffer <pct>Bbox padding percent (default 20)
--dem-contour <m>Emit contour lines every N meters as GeoJSON

Enrichments (opt-in, network)

--enrich weather,osm,routes,crossings,sun
--enrich all
TokenAPIWhat lands
weatherOpen-Meteoper-stage temp/wind/conditions + hourly weather_timeline.json
osmOverpassroads, places, POIs, per-stage road summary
routesOSRM publicoptimal_routes.geojson with extra_distance_pct
crossingsNatural Earth (bundled)state/province transition Points
sunlocal mathaltitude/azimuth/is_golden_hour on each photo

Design


Documentation