@alosha/stride
v2.0.1
Published
Parse GPX, TCX and FIT files and compute running metrics — pace, HR zones, splits, and Chart.js dashboards. Zero config.
Downloads
512
Maintainers
Readme
@alosha/stride
Parse GPX, TCX and FIT files, compute running metrics, and build Chart.js dashboards — zero config.
- GPX, TCX & FIT in, insights out — parses GPX and TCX XML plus binary FIT files from Garmin, Strava, Coros, Wahoo and more. Format is auto-detected; the same
analyze()works for all three. - Every running metric you want — pace, splits, HR zones (Z1–Z5), elevation, cadence, moving vs. elapsed time — each computed the way Garmin and Strava compute it, and labelled with where it came from (
distanceSource,elevationSource). - Charts with zero boilerplate — 5 ready-made Chart.js configs (pace, elevation, heart rate, HR zones, splits) behind a separate
@alosha/stride/chartsentry point, so parse/analyze consumers never pull in Chart.js. - CLI and library —
npx stride analyze run.gpx, metric or imperial, no config required.
Upgrading from 1.x? Read MIGRATING.md — two silent changes, three loud ones, one deprecation.
Install
npm install @alosha/strideRequires Node >= 18 (any current LTS). Parsing and metrics have no peer
dependencies. chart.js is an optional peer dependency needed only if
you use the chart builders, which live behind a separate entry point:
npm install chart.js # only if you import @alosha/stride/chartsInstalling @alosha/stride alone pulls no charting code and emits no peer
warning. Note that @alosha/stride/charts returns plain Chart.js
configuration objects and never calls Chart.js itself — you need the package
to render a config and, if you use TypeScript, to resolve the
ChartConfiguration type in the builders' signatures.
Quick start
import { parseFile, analyze } from '@alosha/stride'
// Parse a GPX, TCX or FIT file — the format is detected automatically
const activity = await parseFile('./my-run.fit')
// Compute all running metrics
const stats = analyze(activity, { maxHR: 185 })
console.log(stats.distanceM, stats.avgPaceSecPerKm, stats.hrZones)
console.log(stats.distanceSource, stats.elevationSource) // 'device' | 'computed'To render charts, add the optional chart.js peer and import from the
charts entry point:
import { analyze } from '@alosha/stride'
import { paceChartConfig, splitsChartConfig } from '@alosha/stride/charts'
import { Chart } from 'chart.js/auto' // npm install chart.js
const stats = analyze(activity)
new Chart(document.querySelector<HTMLCanvasElement>('#pace')!, paceChartConfig(activity, stats))
new Chart(document.querySelector<HTMLCanvasElement>('#splits')!, splitsChartConfig(stats))Accuracy
Every metric is computed the way the systems you compare against — Garmin
Connect, Strava — compute it, with the derivation written down in
docs/metrics-spec.md. The four that most
libraries get wrong:
- Device-reported distance over summed haversine. Summing haversine
distances between raw GPS fixes integrates ±3–5 m of per-fix jitter as
extra path length, typically +1–3%. When a FIT or TCX file carries the
device's own filtered distance stream, stride uses it — and tells you so
via
distanceSource. (spec §2.3) - Time-weighted HR zones over sample counts. Smart-recording watches sample hard efforts more densely, so counting samples systematically inflates time in high zones. Stride attributes each segment's duration to a zone, matching what Garmin Connect and TrainingPeaks report. (spec §1)
- Hysteresis-filtered elevation, with deference to the device. Raw positive altitude deltas integrate GPS noise as climb; stride only credits a rise once it cumulatively clears a threshold (default 8 m, the GPS-altitude figure), and defers outright to the device's barometric total when a FIT file carries one. (spec §5)
- A true rolling best-km. The fastest kilometre almost never starts on a split boundary, so the fastest bucketed split systematically understates it. Stride slides a 1000 m window over the cumulative series with interpolated edges, like Strava's Best Efforts. (spec §2)
Why do these numbers disagree?
Three pairs of numbers differ by design. Each alone can read as a bug; together they are the same policy applied consistently: report the best available instrument for each question, and never fudge one number to make it agree with another.
distanceM: 1980 vs deviceDistanceM: 1983.3. The device counts
distance accumulated before its first position fix (and during position-less
segments); distanceM measures between the first and last recorded point.
The gap is real distance that has no GPS points to attach to. Both are
reported; nothing is derived from deviceDistanceM.
elevationGainM: 78 vs sum(splits[].elevationGainM): 58 (when
elevationSource === 'device'). The device's total ascent is an
activity-level scalar from its barometric altimeter — it cannot be
attributed to a distance range. Splits therefore keep using the GPS-altitude
hysteresis pass, the only elevation signal that can be sliced by distance.
The parts answer a different question than the whole, with a different
instrument. We do not fudge the splits to make them add up.
Elevation fell (threshold) and rose (device) in the same release.
Two independent 2.0.0 changes: the hysteresis threshold went 3 m → 8 m
because GPS altitude noise is larger than barometric (lowers gain —
sample-run.tcx: 12 → 0), and FIT files carrying session.totalAscent now
defer to the device (can raise it — climb-run.fit: 78 where GPS computes
58). Check elevationSource to see which regime a file is in.
A known limitation of the hysteresis filter: a climb's trailing rise
that never cumulatively clears the threshold is never credited — crediting
an in-progress, still-ambiguous rise would re-admit exactly the noise the
threshold exists to reject. gpx-climb.gpx climbs 30 m from its starting
elevation and reports 28. (spec §5.3)
What stride does not solve
- Attributing a device elevation total to splits. When
elevationSource === 'device', per-split gains stay GPS-derived and do not sum toelevationGainM(see above). No rescaling is offered. - The trailing unconfirmed climb. Hysteresis never credits a rise that
hasn't cleared the threshold when the track ends:
gpx-climb.gpxclimbs 30 m, reports 28. LoweringelevationThresholdMnarrows this at the cost of admitting noise. - Reconciling
deviceDistanceMwithdistanceM. The gap (distance before the first fix) is reported, not repaired — there are no points to attach it to. - GPX device data. GPX has no standard device distance or elevation
total, so GPX is always
distanceSource: 'computed'andelevationSource: 'computed'; its accuracy is bounded by GPS quality. - DEM elevation correction. Garmin Connect re-derives elevation from a terrain model for non-barometric watches; stride only works with what is in the file.
- Device pause events.
movingTimeSeccomes from a speed threshold (pauseThresholdMps), not from the watch's own timer-stop events, which most exports don't carry per-point. - Lactate-threshold (LTHR) zone model.
zoneModelsupports the two anchors a runner can actually supply —hrmaxandreserve(Karvonen). Anchoring zones to lactate-threshold HR is deliberately not implemented: LTHR must be measured by a 30-minute time-trial field test, and the established LTHR conventions use seven zones rather than the five inHeartRateZones. A futurezoneModel: { type: 'lthr' }mapping onto the existing five zones would be a non-breaking addition. - Multisport FIT files are unexercised. When a FIT file carries more than
one
sessionmessage (a triathlon, a brick workout), stride sumstotalDistance,totalAscentandtotalDescentacross them. That summation is implemented but has no test fixture — every fixture in the suite carries exactly one session — so the multi-session path has never executed. Single-session files, which is nearly every run, are covered. - Streaming parse.
parse()andparseFile()read the whole file into memory. Activity files are kilobytes to a few megabytes, so this is the right trade; it would be the wrong one for a multi-gigabyte archive.
Supported formats
| Format | Extension | Input types | Typical sources |
|---|---|---|---|
| GPX | .gpx | file path, raw XML string | Strava, Apple Health/Watch routes, Komoot, most apps |
| TCX | .tcx | file path, raw XML string | Garmin Connect, Strava, Wahoo, Zwift |
| FIT | .fit | file path, Uint8Array, ArrayBuffer | Garmin, Coros, Wahoo, Suunto, Polar (native device files) |
The format is auto-detected — you call parse()/parseFile() and never
branch on file type (pass { format } to skip sniffing when you already
know it). All three normalise into the same Activity shape: GPS track,
elevation, heart rate, cadence, timestamps, and — where the source provides
them — the device's own distance and elevation figures.
CLI
npx stride analyze my-run.gpx
npx stride analyze my-run.tcx # TCX and FIT work too — auto-detected
npx stride analyze my-run.fit
npx stride analyze my-run.fit --imperialFlags: --imperial for miles/feet/min-per-mile; --max-hr 185 and
--elevation-threshold 2 expose the corresponding analyze() options; and
--json prints the raw ActivityStats object — the same documented schema
the library returns — for scripting:
npx stride analyze my-run.fit --json | jq '.avgPaceSecPerKm'
npx stride analyze my-run.fit --max-hr 185 --json | jq '.hrZones'Output (real output for the repo's test/fixtures/sample-run.tcx):
🏃 @alosha/stride — test/fixtures/sample-run.tcx
Distance: 1.98 km
Moving time: 10:00
Elapsed time: 10:00
Avg pace: 5:03/km
Best km pace: 5:03/km
Elevation ↑: 0m
Elevation ↓: 0m
Avg HR: 143 bpm
Max HR: 163 bpm
Avg cadence: 174 spm
Splits:
km 1 5:03/km HR 131bpm
km 2 5:03/km (0.98 km) HR 154bpmAPI
parse(input, options?)
parse(input: string | Uint8Array | ArrayBuffer, options?: { format?: 'gpx' | 'tcx' | 'fit' }): ActivitySynchronous. Accepts a file path (Node), raw GPX/TCX XML, or FIT bytes, and auto-detects the format:
import { parse } from '@alosha/stride'
const a1 = parse('./run.gpx') // file path (GPX / TCX / FIT)
const a2 = parse(xmlString) // raw GPX or TCX XML
const a3 = parse(new Uint8Array(fitBytes)) // FIT bytes (browser / streamed)
const a4 = parse(xmlString, { format: 'gpx' }) // skip format sniffing| Input | Detected as |
|---|---|
| String containing <gpx | GPX |
| String containing <TrainingCenterDatabase | TCX |
| File path to a .FIT file, or Uint8Array / ArrayBuffer bytes | FIT |
A short string that is neither a readable path nor recognisable GPX/TCX/FIT
throws a clear error (with a truncated preview of the input), not a bare
ENOENT.
parseFile(path, options?) — Node only
parseFile(path: string, options?: { format?: 'gpx' | 'tcx' | 'fit' }): Promise<Activity>Async, backed by fs/promises — reads without blocking the event loop, and
skips path-vs-content sniffing entirely since the argument is unambiguously
a path. Prefer it whenever you're in Node and reading from disk; use
parse() when you already hold the content (an upload, a string, a fetch
response) or need a synchronous call.
import { parseFile } from '@alosha/stride'
const activity = await parseFile('./my-run.fit')
const gpx = await parseFile('./export.xml', { format: 'gpx' }) // odd extension, known formatparseFile is absent from the browser build (see Browser).
analyze(activity, options?)
analyze(activity: Activity, options?: AnalyzeOptions): ActivityStatsComputes every metric in one pass. The 1.x positional form
analyze(activity, maxHR, elevationThresholdM) still works but is
deprecated and will be removed in 3.0.0.
const stats = analyze(activity, {
maxHR: 185,
zoneModel: { type: 'reserve', restingHR: 52 },
elevationThresholdM: 2,
pauseThresholdMps: 0.5,
})AnalyzeOptions
| Option | Type | Default | What it changes |
|---|---|---|---|
| maxHR | number | 190 | The athlete's max heart rate — the reference all zone percentages are computed against. Affects hrZones only. Must be finite and within 60–220 bpm; outside that range throws instead of silently dividing by zero or inverting the pct formula into a single zone. |
| zoneModel | HrZoneModel | { type: 'hrmax' } | Which formula turns a heart rate into a zone percentage. { type: 'hrmax' } uses hr / maxHR (the historical behaviour). { type: 'reserve', restingHR } uses the Karvonen formula, (hr − restingHR) / (maxHR − restingHR) — the same effort reads a lower percentage, so samples shift toward lower zones relative to 'hrmax'. restingHR must be finite, non-negative, and less than maxHR; missing, negative, or out-of-order values throw. Both variants accept an optional boundaries: [number, number, number, number] (default [0.6, 0.7, 0.8, 0.9], the 60/70/80/90% bands). Boundaries must be strictly increasing and each strictly between 0 and 1 — anything else throws instead of silently mis-bucketing. |
| elevationThresholdM | number | 8 | The hysteresis threshold: a cumulative rise (or fall) must clear this many metres before it is credited as gain (or loss). The default suits GPS-derived altitude; pass 2 for barometric data. Affects elevationGainM/elevationLossM when elevationSource is 'computed', and splits[].elevationGainM always. |
| pauseThresholdMps | number | 0.3 | Speed (m/s) at or below which a segment counts as paused rather than moving. Raising it treats slow shuffling and GPS drift at rest as pauses. Affects movingTimeSec, and through it avgPaceSecPerKm (= moving time ÷ distance). |
Chart builders — @alosha/stride/charts
All builders return a plain Chart.js configuration object
— you instantiate Chart yourself, so the library never touches the DOM.
Requires the optional chart.js peer to render (and for the
ChartConfiguration type).
| Function | Chart type | Notes |
|---|---|---|
| paceChartConfig(activity, stats, opts?) | Line | Trailing partial split labelled distinctly (km 2 (0.98 km)) |
| elevationChartConfig(activity, stats, opts?) | Line | x-axis uses the same distance series as stats.distanceM |
| heartRateChartConfig(activity, stats, opts?) | Line | x-axis uses the same distance series as stats.distanceM |
| hrZonesChartConfig(stats) | Doughnut | Never throws — renders a labelled empty chart when there's no HR data |
| splitsChartConfig(stats, opts?) | Bar | Partial split's bar is faded as well as labelled |
import { Chart } from 'chart.js/auto'
import { paceChartConfig, elevationChartConfig, hrZonesChartConfig } from '@alosha/stride/charts'
new Chart(canvas1, paceChartConfig(activity, stats, { units: 'imperial' }))
new Chart(canvas2, elevationChartConfig(activity, stats))
new Chart(canvas3, hrZonesChartConfig(stats))Formatting helpers
import { formatPace, formatDistance, formatDuration } from '@alosha/stride'
formatPace(302, 'metric') // "5:02/km"
formatPace(302, 'imperial') // "8:06/mi"
formatDistance(10240) // "10.24 km"
formatDuration(3092) // "51:32"ActivityStats reference
| Field | Type | Description |
|---|---|---|
| distanceM | number | Total distance in metres, from the first to the last recorded point |
| distanceSource | 'device' \| 'computed' | Whether distanceM (and the series behind splits[] / bestKmPaceSecPerKm) came from the file's own device distance stream, or was summed from GPS points |
| deviceDistanceM | number \| undefined | The device's own total distance (TCX <Lap><DistanceMeters>, FIT session.totalDistance), passed through unrounded. Undefined for GPX. May exceed distanceM — see Why do these numbers disagree? |
| elapsedTimeSec | number | Total elapsed time in seconds |
| movingTimeSec | number | Moving time in seconds (segments at or below pauseThresholdMps excluded) |
| avgPaceSecPerKm | number | Average pace in sec/km (moving time ÷ distance) |
| bestKmPaceSecPerKm | number \| null | Fastest 1000 m anywhere in the activity, as a rolling window with interpolated edges — independent of splits[]. Null under 1 km total |
| elevationGainM | number | Total elevation gain in metres |
| elevationLossM | number | Total elevation loss in metres |
| elevationSource | 'device' \| 'computed' | 'device' when a FIT session.totalAscent/totalDescent was used; 'computed' for the hysteresis filter (GPX and TCX always). When 'device', split gains don't sum to the total — see Why do these numbers disagree? |
| avgHeartRate | number \| null | Average HR in bpm |
| maxHeartRate | number \| null | Max HR in bpm |
| hrZones | HeartRateZones \| null | Time in each HR zone, in seconds, time-weighted (not sample-counted) |
| avgCadence | number \| null | Average cadence in steps/min |
| splits | Split[] | Per-km splits at exact 1000 m marks, plus a trailing partial split (distanceM !== 1000). sum(splits[].distanceM) === distanceM |
Browser
The package ships a dedicated browser build behind the exports map's
browser condition — bundlers (and TypeScript with
customConditions: ["browser"]) resolve dist/index.browser.js with its own
type declarations, and that build never references fs.
The browser build is parse(), analyze() and the formatting helpers.
parseFile() is Node-only and absent from the browser build — in the
browser there are no file paths; read the file yourself and hand the content
to parse():
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
}(For GPX/TCX you can equally pass the file's text: parse(await file.text()).)
Examples
The examples/ directory holds small, runnable scripts — one
per feature, each with its real output pasted at the bottom: parseFile vs
parse, explicit format, both zone models side by side, pause and
elevation thresholds, distanceSource/elevationSource branching, and the
charts entry point. See examples/README.md.
Support & custom work
@alosha/stride is free and MIT-licensed, and always will be. When you need more than the open-source library, there's a paid path backed by the maintainer — not a ticket queue:
- Priority support — a direct line to the person who wrote it, with prioritised fixes.
- Custom work — bespoke chart types or running metrics, and help integrating Stride into your app or platform.
Get in touch at alosha.dev/support.
Docs & live demo: stride.alosha.dev · Built by Alosha
