navigo
GPS/geospatial analysis in Rust — trace processing, GPX parsing, Minetti pace model, race route analysis, and live recalibration.
api / usage
location
A location is a GPS coordinate defined by a longitude, a latitude, and an altitude.
let location = Location ;
- distance to another location (km):
let distance: f64 = paris.calculate_distance_to;
- bearing to another location (degrees):
let bearing: f64 = paris.calculate_bearing_to;
- elevation change to another location:
let elevation: Elevation = paris.calculate_elevation_to;
// elevation.positive — gain in meters
// elevation.negative — loss in meters
- check if inside a bounding box:
let area = Area ;
let is_in: bool = location.is_in_area;
- check if inside a radius (km):
let is_in: bool = location.is_in_radius;
trace
Trace::new ingests raw GPS locations and precomputes everything in one shot:
- Douglas-Peucker simplification (for traces > 1 000 points, ε = 15 m)
- Cumulative distances
- Denoised cumulative elevation gain / loss (median smoothing + hysteresis)
- Smoothed slope at each point
- Peaks and valleys (AMPD algorithm with prominence filter)
- Qualifying climb segments (Garmin-style thresholds)
A Trace is never empty — construction fails with TraceError::EmptyTrace
if locations is empty, so every other method can assume at least one point.
let trace: = new;
// or via the convenience wrapper:
let trace: = build_trace;
precomputed fields (accessed via methods)
trace.locations // &[Location] — simplified working set
trace.cumulative_distances // &[f64] — km from start, [0] == 0.0
trace.cumulative_elevation_gains // &[f64] — denoised gain in meters
trace.cumulative_elevation_losses // &[f64] — denoised loss in meters
trace.slopes // &[f64] — % grade at each point
trace.peaks // &[usize] — indices of detected peaks
trace.valleys // &[usize] — indices of detected valleys
trace.climbs // &[ClimbStats] — qualifying climb segments
trace.total_distance // f64 — total distance in km
trace.total_elevation_gain // f64 — total denoised gain in meters
trace.total_elevation_loss // f64 — total denoised loss in meters
trace.area // &Area — bounding box
trace.elevation // &Elevation — raw positive/negative totals
methods
- total length (km):
let length: f64 = trace.length; // alias for total_distance
- location at a cumulative distance (km):
let loc: = trace.point_at_distance;
- index at a cumulative distance (binary search):
let idx: usize = trace.index_at_distance;
- slice between two distance marks (km, both ends inclusive):
let section: = trace.slice_between_distances;
- closest location to a point (early-stop heuristic for loop courses):
let = trace.find_closest_point.unwrap;
// start search from a known index (e.g. to handle loop courses):
let result = trace.find_closest_point_from;
- bounding box (never fails — a trace always has at least one point):
let area: &Area = trace.area;
- sub-section by index range (inclusive):
let section: = trace.get_section;
climb stats
Climbs are qualified using Garmin Climb Pro thresholds:
distance ≥ 500 m, average gradient ≥ 3 %, distance × gradient > 3 500 m·%
GPX parsing
Parse a .gpx file from raw bytes — no XML dependency, byte-scanning only.
use ;
let bytes = read.unwrap;
// Extract track points as Vec<Location>
let locations = parse_trace_points;
// Extract <wpt> elements as Vec<Waypoint>
let waypoints = parse_waypoints;
// Extract <metadata> fields
let meta = parse_metadata;
// meta.name → Option<String>
// meta.description → Option<String>
Waypoints
Boundary classification:
waypoint.is_section_boundary; // true for any waypoint with a non-null type
waypoint.is_stage_boundary; // true only for Start / LifeBase / Arrival
Pace model
Minetti 2002
use ;
let cost = cmet; // metabolic cost at +10% grade (J/(kg·m))
let factor = pace_factor; // relative speed factor vs flat (= cmet/CMET_FLAT)
Domain: slope in [-0.45, 0.45] (clamped beyond).
Fatigue, circadian & weather
use ;
let fatigue = fatigue_factor; // exponential decay (≥ 1.0)
let circadian = circadian_factor; // cosine, −15% at 03:30 UTC
// WeatherConditions fields:
// temperature_c: °C
// humidity_pct: 0–100
// wind_kmh: km/h
// precip_prob_pct: 0–100
let weather = empty;
// or with per-checkpoint data:
let weather = new;
let factor = weather.factor_for; // combined thermal + wind + precip factor
Useful constants:
| Constant | Value | Meaning |
|---|---|---|
K_FATIGUE |
0.002 |
Default fatigue coefficient |
DEFAULT_BASE_PACE_S_PER_KM |
500.0 |
8:20/km flat pace |
DEFAULT_LIFE_BASE_STOP_S |
3600 |
Default LifeBase stop (1 h) |
RECOVERY_LIFE_BASE |
0.20 |
20 % effort reset at LifeBase |
Route analysis
All three levels take a built Trace and a slice of Waypoints derived from the same GPX file.
use ;
use ;
let bytes = read.unwrap;
let trace = build_trace.unwrap;
let waypoints = parse_waypoints;
let weather = empty;
const BASE_PACE: f64 = 500.0; // s/km on flat terrain
const K_FATIGUE: f64 = 0.002;
const LIFE_BASE_STOP: u32 = 3600; // 1 h planned stop at LifeBase checkpoints
Legs
One LegStats per consecutive pair of section-boundary waypoints.
let legs: = compute_from_waypoints;
// legs[i].leg_id
// legs[i].section_idx
// legs[i].start_location / end_location (waypoint names)
// legs[i].total_distance_km
// legs[i].total_elevation_gain_m / total_elevation_loss_m
// legs[i].avg_slope / max_slope (% grade)
// legs[i].min_elevation / max_elevation (meters)
// legs[i].bearing (degrees from north)
// legs[i].difficulty (1–5, Naismith effort)
// legs[i].estimated_duration_s (Naismith rule, no fatigue)
Sections
Sections are legs enriched with pace-model data (Minetti + fatigue + circadian + weather).
use ;
let options = default
.base_pace
.fatigue
.life_base_stop;
let sections: =
compute_from_waypoints;
// sections[i].section_id / stage_idx
// sections[i].start_location / end_location
// sections[i].total_distance_km
// sections[i].total_elevation_gain_m / total_elevation_loss_m
// sections[i].avg_slope / max_slope / min_elevation / max_elevation
// sections[i].start_time / end_time (Unix timestamps from waypoint <time>, or None)
// sections[i].bearing / difficulty
// sections[i].pace_factor — combined speed factor vs flat
// sections[i].estimated_duration_s — moving time + planned stop
// sections[i].max_completion_time — cutoff as Unix timestamp, or None
// sections[i].cutoff_ratio — estimated_duration / time_budget (< 1.0 = ok)
// sections[i].stop_duration — planned stop at end checkpoint (s), or None
Stages
Stages group sections between Start / LifeBase / Arrival boundaries (TimeBarrier waypoints are skipped).
let stages: =
compute_from_waypoints;
// stages[i].stage_id
// stages[i].start_location / end_location
// stages[i].total_distance_km
// stages[i].total_elevation_gain_m / total_elevation_loss_m
// stages[i].avg_slope / max_slope / min_elevation / max_elevation
// stages[i].start_time / end_time
// stages[i].bearing / difficulty
// stages[i].pace_factor / estimated_duration_s
// stages[i].max_completion_time / cutoff_ratio / stop_duration
Time utilities
use parse_iso8601_to_epoch;
// Supported formats: "2025-11-20T12:00:00Z", "2025-11-20T12:00:00+01:00"
let epoch: = parse_iso8601_to_epoch;
Live calibration
Recalibrate remaining ETAs mid-race given the actual elapsed time at a known position.
use ;
use AnalysisOptions;
let options = default
.base_pace
.fatigue
.life_base_stop;
let result = recalibrate_from_current;
if let Some = result
The factor is only applied when predicted_so_far ≥ 300 s to avoid noise from very short segments.
WebAssembly
The library can be compiled to WASM for use in web applications via the wasm feature.
Prebuilt bindings are published to npm as @totorototo/navigo — npm install @totorototo/navigo.
how it works
All data lives in WASM linear memory. The JS side holds a thin pointer (Trace).
Only the boundaries cross the WASM↔JS membrane — scalars are free (registers), bulk arrays are copied once on demand.
buildTrace(Float64Array) ← one O(n) copy JS→WASM, null if no points
│
▼
Trace stays in WASM memory
│
├── trace.totalDistance → free (register)
├── trace.findClosestPoint() → free (scalars in/out)
├── trace.locationsFlat → one O(n) copy, cache it
└── trace.free() ← you must call this (no GC bridge)
build
usage
From raw coordinates (buildTrace)
import init from "./navigo.js";
await ;
// build — one copy in, all computation in WASM
const pts = ;
const trace = ;
// → Trace, or null if pts carries no points
// scalar getters — free
trace.; // number (km)
trace.; // number (m)
trace.; // number (m)
trace.; // number
// array getters — copy once, then cache on the JS side
const locs = trace.; // Float64Array [lon,lat,alt,…]
const dists = trace.; // Float64Array (km)
const gains = trace.; // Float64Array (m)
const losses = trace.; // Float64Array (m)
const slopes = trace.; // Float64Array (%)
const peaks = trace.; // Uint32Array (indices)
const valleys = trace.; // Uint32Array (indices)
// query methods — scalars in, one small object out
trace.;
// → { longitude, latitude, altitude } | undefined
trace.;
// → number
trace.;
// → { location: { longitude, latitude, altitude }, index, distance } | undefined
trace.;
// → same shape | undefined (use on live-tracking loops)
trace.;
// → Float64Array [lon,lat,alt,…] | undefined
trace.;
// → Float64Array [lon,lat,alt,…] (throws on out-of-bounds / invalid range)
trace.;
// → { minLongitude, maxLongitude, minLatitude, maxLatitude }
trace.;
// → { positive, negative } (raw, non-denoised)
trace.;
// → [{ startIndex, endIndex, startDistKm, climbDistKm,
// elevationGain, summitElev, avgGradient }, …]
// always release when done — Rust allocator has no GC bridge
trace.;
From a GPX file (parseGpx / trace.analyze() / analyzeGpx)
import init from "./navigo.js";
await ;
const bytes = ;
// Parse once — track-points, waypoints and metadata are all stored on the
// returned Trace, so nothing needs to be re-sent for the steps below.
const trace = ;
// → Trace | null (same getters/methods as buildTrace, plus .analyze())
const options = ;
// Race analysis from the trace you already have — no bytes cross the
// boundary again, and the expensive trace computation isn't repeated.
const analysis = trace.;
// → {
// waypoints: [{ latitude, longitude, elevation, name, wptType, time, … }],
// legs: [{ totalDistanceKm, totalElevationGainM, bearing, difficulty, … }],
// sections: [{ …leg fields, paceFactor, maxCompletionTime, cutoffRatio, … }],
// stages: [{ …same, grouped by Start/LifeBase/Arrival }],
// metadata: { name, description },
// }
// or null on malformed options
// Or, if you just want the JSON in one call and don't need the Trace handle:
const full = ;
// → { trace: { totalDistanceKm, totalElevationGainM, … }, ...analysis }
// or null on parse failure
Live recalibration (trace.recalibrate())
Once the race clock has started and the runner has a GPS fix, correct the
static .analyze() prediction against actual progress — see
Live calibration above for the underlying model.
const currentIndex = trace.?.;
const recalibration = trace.;
// → {
// sections: { calibrationFactor, calibratedBasePaceSPerKm,
// predictedSoFarS, actualElapsedS,
// etas: [{ id, endIndex, remainingDurationS, cumulativeRemainingS }, …] } | null,
// stages: { …same shape, at Start/LifeBase/Arrival granularity } | null,
// }
// or null on malformed options
//
// `sections` and `stages` solve independent calibration factors — each
// re-predicts at its own boundary granularity, with its own per-range
// weather lookup. Either is null when that boundary kind has fewer than
// 2 typed waypoints.
trace.;
memory management
Trace lives in WASM linear memory. The JS object is just a pointer — Rust cannot reclaim it when the JS variable is GC'd. Always call .free(), or register a FinalizationRegistry:
const registry = ;
const trace = ;
registry.;