GPX, TCX & FIT analytics
for runners
Parse GPX, TCX and FIT files, compute running metrics, and render Chart.js dashboards. Zero config — one API for every format.
Parse, analyse, chart
import { parse, analyze } from '@alosha/stride'
// Charts are optional — install chart.js only if you render:
import { paceChartConfig } from '@alosha/stride/charts'
import { Chart } from 'chart.js/auto'
// Parse a GPX, TCX or FIT file from Garmin / Strava / Coros — format auto-detected
const activity = parse('./morning-run.fit')
// Compute every metric in one call
const stats = analyze(activity)
console.log(stats.distanceM, stats.avgPaceSecPerKm, stats.hrZones)
// Render a pace chart — Chart.js config returned, you own the canvas
new Chart(canvas, paceChartConfig(activity, stats))Everything you need to visualise a run
- GPX, TCX & FIT parserParse GPX, TCX or FIT files — Garmin, Strava, Coros, Wahoo and more — auto-detected, with full HR, cadence, and elevation support.
- Running metricsDistance, moving time, avg/best pace, elevation gain/loss, HR zones, cadence, and per-km splits — all in one call.
- Device-accurate numbersUses the watch’s own distance and barometric elevation when the file carries them, and denoises GPS altitude when it doesn’t — instead of integrating GPS jitter into inflated totals. A true rolling best-km, and time-weighted HR zones, not sample counts.
- Chart.js configsFive ready-made chart configs (pace, elevation, HR, HR zones, splits) behind an optional subpath — chart.js stays out of your install unless you render. Browser or Node canvas.
- Heart rate zonesAutomatic Z1–Z5 breakdown by %HRmax or heart-rate reserve (Karvonen). Time-weighted seconds in each zone, ready for a doughnut chart.
- Metric & imperialAll formatters and chart labels switch between km/min·km and mi/min·mi via a single units option.
- CLI includedRun `stride analyze run.gpx` to get a full activity summary in your terminal. No config needed.
CLI — analyse a run in seconds
$ npx stride analyze morning-run.gpx
🏃 @alosha/stride — Morning Run
Distance: 10.24 km
Moving time: 51:32
Elapsed time: 52:14
Avg pace: 5:02/km
Best km pace: 4:44/km
Elevation ↑: 142m
Elevation ↓: 138m
Avg HR: 158 bpm
Max HR: 178 bpm
Splits:
km 1 4:55/km ↑12m HR 152bpm
km 2 5:03/km ↑8m HR 156bpm
km 3 4:58/km ↑5m HR 159bpm
...Should you parse it yourself?
Binary FIT decoding is a project, not a parse()
FIT is Garmin’s binary format — the native export from most watches. There is no XML to read: you decode a typed message stream and convert every field by hand.
What you’d own building it yourself
- Binary message stream
FIT is a compact binary protocol of definition and data messages — you need the Garmin SDK or a full decoder, not a string parser.
- Semicircle coordinates
Latitude and longitude are stored as int32 semicircles; miss the 180 / 2³¹ conversion and the GPS track lands in the ocean.
- Cadence half-counts
Running cadence is recorded per foot (RPM); double it to steps per minute or every cadence figure reads half what athletes expect.
With Stride: parse() auto-detects FIT, decodes the message stream with the official Garmin SDK, applies the semicircle conversion and cadence doubling, and returns the same normalised Activity you get from GPX or TCX — no per-format branching in your code.
The running metrics hide the real work
Distance and pace look trivial until pauses, GPS noise and zone boundaries turn each metric into a pile of edge cases.
What you’d own building it yourself
- Moving vs elapsed
Stop at a crossing and naive elapsed time wrecks your average pace — you need a speed threshold to separate moving time from pauses.
- HR zone boundaries
Z1–Z5 time-in-zone means bucketing every sample against %-of-max-HR thresholds and summing seconds — fiddly and easy to off-by-one.
- Per-km splits
Splits don’t fall on sample boundaries; you accumulate distance and time across points and emit a split exactly at each kilometre.
With Stride: analyze() returns distance, moving vs elapsed time, avg and best pace, per-km splits, elevation and Z1–Z5 HR zones in one call — the pause threshold, zone bucketing and split accounting are already handled and tested.
Build vs adopt: the engineering you’d take on
A rough estimate of the work to build and own equivalent parsing and metrics in-house — before the first bug report from a watch you never tested.
GPX/TCX/FIT decoders + metrics engine + 5 chart configs + ongoing format upkeep — rough estimate
npm install — parse(), analyze() and charts included
Production recipes
Turn a Garmin .FIT upload into a pace chart in the browser
The problem: Users export runs from Garmin, Strava, Coros and Wahoo in different formats — and FIT is binary, not text.
import { parse, analyze } from '@alosha/stride'
import { paceChartConfig } from '@alosha/stride/charts'
import { Chart } from 'chart.js/auto'
// A user drops a .fit / .gpx / .tcx export onto your page.
async function renderUpload(file: File, canvas: HTMLCanvasElement) {
const bytes = new Uint8Array(await file.arrayBuffer())
const activity = parse(bytes) // format auto-detected: GPX / TCX / FIT
const stats = analyze(activity) // distance, pace, HR zones, splits
new Chart(canvas, paceChartConfig(activity, stats))
return stats
}Why it works: parse() auto-detects the format and returns one normalised Activity, so the same analyze() and chart configs work no matter which watch produced the file — no per-vendor branching in your upload handler.
Build a heart-rate zone breakdown without writing the maths
The problem: Time-in-zone is a core training metric, but computing Z1–Z5 from a raw HR stream by hand is fiddly and error-prone.
import { parse, analyze } from '@alosha/stride'
import { hrZonesChartConfig } from '@alosha/stride/charts'
import { Chart } from 'chart.js/auto'
const activity = parse('./tempo-run.tcx')
const stats = analyze(activity, { maxHR: 188 }) // %HRmax, or use a reserve model
// Time-weighted seconds in each zone, ready for a doughnut chart.
console.log(stats.hrZones) // { z1, z2, z3, z4, z5 } | null
new Chart(canvas, hrZonesChartConfig(stats))Why it works: analyze() computes Z1–Z5 time-in-zone from the HR stream — weighted by each sample’s duration, not counted — against the max HR you pass, and returns a ready Chart.js config. You get a training-quality breakdown without ever touching the zone formula.
Flag a negative split (or a late-race fade) from any run
The problem: Coaching and race-recap features want to know whether the second half was faster than the first — but pacing lives in the raw GPS stream, not in a tidy field.
import { parse, analyze, formatPace } from '@alosha/stride'
// Did the runner finish faster than they started? (a "negative split")
function splitAnalysis(activity) {
const { splits } = analyze(activity) // clean per-km splits
if (splits.length < 2) return null
const mid = Math.floor(splits.length / 2)
const avgPace = (arr) =>
arr.reduce((sum, s) => sum + s.paceSecPerKm, 0) / arr.length
const firstHalf = avgPace(splits.slice(0, mid))
const secondHalf = avgPace(splits.slice(mid))
const deltaSec = Math.round(firstHalf - secondHalf) // > 0 => back half quicker
return {
negativeSplit: deltaSec > 0,
firstHalfPace: formatPace(firstHalf), // "6:00/km"
secondHalfPace: formatPace(secondHalf), // "5:00/km"
swingSecPerKm: Math.abs(deltaSec),
}
}Why it works: analyze() already emits clean per-km splits, so classifying the run is a couple of array reductions over stats.splits and a formatPace() call — you never re-derive pace from raw GPS points or reinvent the split accounting.
Built to pass a dependency review
| Metric / concern | What ships |
|---|---|
| Formats | GPX · TCX · FIT |
| Data isolation | Parsed locally, never uploaded |
| Runtime | Browser + Node |
| Charts | Side-effect-free configs |
| Dependencies | chart.js + 2 parsers |
| Type safety | Ships .d.ts (ESM + CJS) |
| Licensing | MIT |
Add Stride to your project
- Custom chart types or running metrics for your product
- Help integrating Stride into your app or platform
- Priority fixes and feature requests from the maintainer