gpx-stats

module
v0.4.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 3, 2026 License: MIT

README

gpx-stats

A single, self-contained Go binary that computes statistics about a GPX file — from the command line or through a small embedded web UI. No database, no server-side network calls, nothing stored.

The web UI now shows your track on a map. Map backgrounds are loaded by your browser directly from the tile provider, which can see the area you are viewing. Your GPX file and its statistics never leave the server. See Privacy.

Demo

gpx-stats demo

Features

  • CLI: pass a GPX path, get raw statistics as text (or --json), plus optional ASCII charts in the terminal.
  • Web UI: serve starts a local page where you upload a GPX file — or drop it straight onto the map — and see the same statistics rendered with inline SVG charts. The file is processed in memory and is never saved.
  • Interactive map: the recorded track is drawn over a choice of four free base layers (OpenStreetMap, OpenTopoMap, CyclOSM, Humanitarian), framed on the activity with start/end markers, and expandable to full screen. Every recorded point is drawn — the line is never simplified — and each recorded segment is drawn as its own line, so an interrupted recording never shows a line across ground you did not travel.
  • MVP statistics: total distance (km), ascending and descending elevation (m), total / moving / pause time, number of pauses, average and moving speed (km/h), and per-kilometer splits (time + speed).
  • Activity identity: the activity's name, type, recording device, and start and end times, read from the file and shown on every surface. See Activity identity.
  • Segment-aware: a GPX file whose recording was interrupted is not treated as one continuous line. See Segments.
  • Effort kilometers: the flat distance that would cost the same effort as the hilly route, reported under both common conventions. See Effort kilometers.
  • Safe by construction: GPX is parsed with the standard library encoding/xml, which ignores DTDs and external entities, so XXE and entity-expansion ("billion laughs") attacks are neutralised. Input is bounded by a maximum size and track-point count.

Build

go build -o gpx-stats ./cmd/gpx-stats
# or: task build

The binary embeds all web assets (Bulma CSS, HTML templates); copy it anywhere and run it offline.

CLI usage

gpx-stats path/to/activity.gpx            # human-readable statistics
gpx-stats --json path/to/activity.gpx     # machine-readable JSON
gpx-stats --charts path/to/activity.gpx   # add ASCII elevation & speed charts
gpx-stats --version                       # build identity: version, commit, date, Go

# Tune pause detection (defaults: 0.1 km/h sustained for 2m0s)
gpx-stats --pause-speed 1.5 --pause-duration 15s path/to/activity.gpx

# Tune how aggressively GPS elevation jitter is filtered (default: 1 m)
gpx-stats --elevation-noise 3 path/to/activity.gpx

Errors are written to stderr with a non-zero exit code (1 runtime, 2 usage).

Version

--version reports which build you are running, which is what a bug report needs from a self-contained binary with no package manager behind it:

$ gpx-stats --version
  gpx-stats  0.4.0
  commit     96c9f3edee5d1bb1c655910e2a42b8b0d871144c
  built      2026-07-07T18:58:31Z
  go         go1.26.1 darwin/arm64

Released binaries carry the values stamped at link time. A binary you built yourself falls back, per field, to the build information the Go toolchain embeds on its own — so go build of your own tree reports a pseudo-version with the real commit and commit time, go install ...@v0.4.0 reports the tag but no commit, and anything neither source supplies reads unknown. The flag needs no file argument and must come before the path.

Terminal styling

The text output is grouped into sections, and its labels and values are aligned in one column sized to whatever the file actually reports — a track with no elevation gets a narrower report, not a padded one.

Section headings are shown in bold and units in dim only when stdout is a terminal. Redirect or pipe the output and it is plain text, so nothing has to strip escape sequences. Styling is bold and dim only: a hue that reads well on a dark background is often invisible on a light one. To turn it off on a terminal too, any of these works:

gpx-stats --no-color path/to/activity.gpx
NO_COLOR=1 gpx-stats path/to/activity.gpx
TERM=dumb gpx-stats path/to/activity.gpx

Effort kilometers

A hilly route costs more than its flat distance suggests. Effort kilometers ("km-effort") restate it as the flat distance that would cost the same effort.

Two conventions are in common use, so both are reported and neither is presented as the correct one — pick whichever your club, race or training plan already uses:

Figure Formula Legend
Effort km (climb) distance + D+/100 100 m ascent = 1 km
Effort km (climb + descent) distance + D+/100 + D-/300 100 m ascent = 1 km, 300 m descent = 1 km

For a 10 km route with 500 m of ascent and 300 m of descent, they read 15.00 and 16.00.

Effort kilometers per hour

Each convention is also reported as a rate, so two hilly outings are comparable without dividing by hand. Plain Avg speed understates a climb-heavy activity for exactly the reason effort kilometers exist; the effort rate does not.

Both time bases are shown, mirroring the existing Avg speed / Avg moving speed pair:

Figure Divided by
Effort km/h (…) elapsed time — pauses included
Moving effort km/h (…) moving time — pauses excluded

Rates need timestamps as well as elevation. A track carrying elevation but no <time> still reports its effort kilometers; only the rates are unavailable.

Every figure appears in the CLI, in --json and on the web results page. The block is grouped by convention, so one legend covers a convention's total and both of its rates — here as an excerpt of gpx-stats testdata/sample.gpx:

Distance & elevation
  Total distance                         0.16  km
  Ascending elev.                          25  m
  Descending elev.                          0  m

Effort kilometers
  Effort km (climb)                      0.41
  Effort km/h (climb)                   12.22  km/h
  Moving effort km/h (climb)            12.22  km/h
    100 m ascent = 1 km
  Effort km (climb + descent)            0.41
  Effort km/h (climb + descent)         12.22  km/h
  Moving effort km/h (climb + descent)  12.22  km/h
    100 m ascent = 1 km, 300 m descent = 1 km

That sample has no pause, so its two time bases coincide. On a track with a real stop they separate: testdata/two_segments.gpx spends 10 of its 10m40s standing still, and reads 1.83 km/h elapsed against 29.32 km/h moving.

Segments

A GPX file records a track as one or more <trkseg> blocks. A new segment starts whenever recording was interrupted — you stopped the watch, drove to the next trailhead, and started it again; or the device lost signal for a while.

gpx-stats does not join those segments. The straight line between the end of one and the start of the next is not ground you covered, so nothing accrues across it: no distance, no elevation gain, no kilometer split, and no line on the map. The gap's elapsed time is kept and, having no distance, is detected as a pause — so moving time plus pause time still equals total time.

This is worth knowing because it makes gpx-stats report a shorter distance than tools that flatten the segments. For testdata/two_segments.gpx, a ten-minute interruption during which the device travelled ~7.8 km:

gpx-stats joining the segments
Total distance 0.13 km 7.93 km
Ascending elevation 20 m 210 m
Moving time 40s 10m40s
Effort km (climb) 0.33 10.03

Whenever a file holds more than one segment, the count is noted under the distance so the difference is never a mystery:

  Total distance                          0.13  km
    2 segments; gaps between them are not counted

A single-segment file — which is nearly all of them — is completely unaffected.

Activity identity

The file usually knows what it is. gpx-stats reads and reports it:

  Activity                              Sample Track
  Recorded by                           gpx-stats-test
  Start                                 2023-06-15 08:00:00 UTC
  End                                   2023-06-15 08:02:00 UTC
Line Read from Notes
Activity the first <trk><name> A file with several tracks is one activity, so one name is reported.
Type the first <trk><type> As recorded (running, cycling, …); not normalised.
Recorded by the <gpx creator="..."> attribute The device or app that wrote the file.
Start / End the first and last timestamped point When the activity actually happened.
File time <metadata><time> When the file was written. Many exporters set this to the moment you downloaded it, which is why it is not called a date.

Anything the file does not carry is reported as absent — unavailable in text, null in JSON, and simply omitted on the web page. A file with no identity at all prints one line rather than five.

Times are shown in the terminal as 2023-06-15 08:00:00 UTC. --json and the web page keep RFC3339 (2023-06-15T08:00:00Z): a machine surface should stay parseable, a terminal should stay readable. Only the presentation differs — no figure does.

Notes:

  • Descending elevation (D-) is reported in its own right, as a positive number. It mirrors the ascent calculation and filters jitter with the same --elevation-noise threshold, so an out-and-back route reports comparable gain and loss instead of an inflated descent.
  • Effort needs only distance and elevation, so it is reported for tracks that carry no timestamps — where every time-based statistic is unavailable.
  • A track with no elevation data shows unavailable (no elevation data) in text, null in JSON and n/a on the web page — never a misleading 0.00.
  • The figures describe the whole activity. Per-kilometer splits and the charts are unaffected.

Web UI usage

gpx-stats serve --addr :8080
# open http://localhost:8080

Upload a GPX file — or drag one onto the map — to see the statistics, charts and the route. Optional form fields override the pause and elevation-noise thresholds, and a dropped file uses whatever values the form currently shows. Nothing is persisted.

The results page leads with the activity's identity, then the route on the map, then every statistic as a tile — including both effort-kilometer conventions with their legends:

Charts

Below the tiles, the same three series the CLI draws in ASCII are rendered as inline SVG, followed by the kilometer splits table.

Elevation against distance

Elevation against elapsed time

Speed per kilometer, above the kilometer splits table

The map

The track is drawn over one of four base layers, switchable from the control in the top-right corner:

Layer Attribution
OpenStreetMap (default) © OpenStreetMap contributors
OpenTopoMap © OpenStreetMap contributors, SRTM; style © OpenTopoMap (CC-BY-SA)
CyclOSM © OpenStreetMap contributors; tiles by CyclOSM, hosted by OpenStreetMap France
Humanitarian © OpenStreetMap contributors; tiles by HOT, hosted by OpenStreetMap France

All four are free and need no API key. Their attribution is displayed on the map as their licences require.

The map is built with Leaflet 1.9.4 (BSD-2-Clause), vendored into the repository and embedded in the binary — there is no CDN dependency. See internal/web/assets/static/vendor/leaflet/VERSION for the version, licence and checksums.

If the tile provider is unreachable, the route, the markers and every control stay usable over a blank backdrop, and the statistics are unaffected. With JavaScript disabled the map is replaced by a short note; everything else on the page still renders.

Privacy

  • Your GPX file is processed in memory and never written to disk.
  • The server makes no outbound network connections at all. It does not proxy, relay or cache map tiles.
  • Map background tiles are requested by your browser, directly from the tile provider. The provider therefore sees the geographic area you are looking at (as it would for any map on the web). It never receives your GPX file, your coordinates or your statistics.
  • Nothing is stored client-side either: the selected layer, zoom and position are forgotten when you leave the page.

Configuration

Flag Default Applies to Meaning
--json off stats JSON output
--charts off stats ASCII charts
--no-color off stats never style the text output (see Terminal styling)
--version off stats print version, commit, build date and Go toolchain, then exit (see Version)
--pause-speed 0.1 both stationary speed threshold (km/h); a segment at or below it counts as stopped
--pause-duration 2m0s both minimum pause duration
--elevation-noise 1.0 both elevation jitter threshold (m) filtered out of ascent and descent
--max-points 500000 both maximum track points accepted
--addr :8080 serve listen address
--max-upload 26214400 (25 MB) serve maximum upload size (bytes)

In the web UI the two limits interact: a GPX file averages ~100 bytes per track point, so --max-upload binds well before --max-points. At the defaults the practical ceiling is around 260,000 points (a ~26 MB file); larger uploads are rejected with 413 before the point limit applies. Raise --max-upload too if you need to analyse longer tracks in the browser. The CLI reads from disk and is bounded only by --max-points.

Development

go test -race ./...   # unit + handler tests with the race detector (or: task test)
task lint             # gofmt + go vet + golangci-lint
task demo             # re-record doc/demo.gif from doc/demo.tape (needs vhs, ttyd, ffmpeg)

Project layout

  • cmd/gpx-stats — thin entrypoint (CLI + serve dispatch)
  • internal/gpx — hardened GPX parser (stdlib encoding/xml)
  • internal/stats — pure statistics engine (distance, elevation, effort, pauses, splits)
  • internal/config — typed configuration
  • internal/cli — text/JSON output and ASCII charts (asciigraph)
  • internal/web — HTTP server, embedded templates/assets, SVG charts (go-analyze/charts)
  • testdata/ — sample and adversarial GPX fixtures

The domain packages (gpx, stats, config) never import the transport packages (cli, web), so both interfaces compute identical results.

Directories

Path Synopsis
cmd
gpx-stats command
Command gpx-stats computes statistics about a GPX file, either from the CLI (given a path) or via an embedded web UI (the `serve` subcommand).
Command gpx-stats computes statistics about a GPX file, either from the CLI (given a path) or via an embedded web UI (the `serve` subcommand).
internal
cli
Package cli implements the command-line transport for gpx-stats: flag parsing, dispatch, and rendering of statistics as text or JSON.
Package cli implements the command-line transport for gpx-stats: flag parsing, dispatch, and rendering of statistics as text or JSON.
config
Package config holds the typed, centralized configuration for gpx-stats.
Package config holds the typed, centralized configuration for gpx-stats.
geo
Package geo turns a parsed GPX track into display-ready map geometry.
Package geo turns a parsed GPX track into display-ready map geometry.
gpx
Package gpx parses GPX files into an in-memory Track.
Package gpx parses GPX files into an in-memory Track.
stats
Package stats computes activity statistics from a parsed GPX track.
Package stats computes activity statistics from a parsed GPX track.
web
Package web implements the HTTP transport for gpx-stats: a stateless, single-user web UI that accepts a GPX upload, computes statistics, and renders them with inline SVG charts.
Package web implements the HTTP transport for gpx-stats: a stateless, single-user web UI that accepts a GPX upload, computes statistics, and renders them with inline SVG charts.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL