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.
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.
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.
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.
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.
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
────────────────────────────────────────────────
| Flag | Default | Meaning |
|---|---|---|
--out <dir> | ./moto-out | Output directory |
--split <mode> | all | day / hour / stage / all |
--break <minutes> | 20 | Gap threshold to start a new stage |
--min-points <n> | 10 | Drop stages with fewer points |
--simplify <meters> | 0 | Douglas-Peucker tolerance, 0 = off |
--tz <offset> | local | Hours from UTC for day/hour bucketing |
--name <string> | folder basename | Trip name, stamped into every feature |
--no-<name> to skip)--stops | stops.geojson — rest-stop + overnight Points |
--speedbins | speedbins.geojson — line chunks tagged by speed bucket |
--markers | markers.geojson — stage + day start/end Points, pre-labeled |
--days-merged | ONE unbroken LineString per day |
--styles | Sibling .qml style files for auto-load in QGIS |
--superlatives | Post-run fun-stats banner (+ stats.json.superlatives) |
--media <dir> | Ingest JPG/HEIC/MP4/MOV as Point features |
--media-tz <offset> | Override for naive EXIF timestamps |
--dem | Fetch 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 |
--enrich weather,osm,routes,crossings,sun
--enrich all
| Token | API | What lands |
|---|---|---|
weather | Open-Meteo | per-stage temp/wind/conditions + hourly weather_timeline.json |
osm | Overpass | roads, places, POIs, per-stage road summary |
routes | OSRM public | optimal_routes.geojson with extra_distance_pct |
crossings | Natural Earth (bundled) | state/province transition Points |
sun | local math | altitude/azimuth/is_golden_hour on each photo |
fetch only. No npm install needed to run. exiftool and GDAL are the only external binaries, and only when the features that need them are enabled..qml siblings auto-load on drag. Zero symbology configuration to get a readable map.dem/tiles/. Re-running on the same bbox is free.