npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

Readme

@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