Skip to content

Repository files navigation

@alosha/stride

Parse GPX, TCX and FIT files, compute running metrics, and build Chart.js dashboards — zero config.

npm version npm downloads Types included License: MIT

  • 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/charts entry point, so parse/analyze consumers never pull in Chart.js.
  • CLI and librarynpx 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/stride

Requires 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/charts

Installing @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 to elevationGainM (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.gpx climbs 30 m, reports 28. Lowering elevationThresholdM narrows this at the cost of admitting noise.
  • Reconciling deviceDistanceM with distanceM. 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' and elevationSource: '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. movingTimeSec comes 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. zoneModel supports the two anchors a runner can actually supply — hrmax and reserve (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 in HeartRateZones. A future zoneModel: { 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 session message (a triathlon, a brick workout), stride sums totalDistance, totalAscent and totalDescent across 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() and parseFile() 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 --imperial

Flags: --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 154bpm

API

parse(input, options?)

parse(input: string | Uint8Array | ArrayBuffer, options?: { format?: 'gpx' | 'tcx' | 'fit' }): Activity

Synchronous. 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 format

parseFile is absent from the browser build (see Browser).

analyze(activity, options?)

analyze(activity: Activity, options?: AnalyzeOptions): ActivityStats

Computes 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

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages