@glandais/virtual-cyclist
v1.2.2
Published
Virtual Cyclist
Readme
🚴♂️ Virtual Cyclist
A TypeScript library for realistic cycling simulations based on GPS data and physics. Analyzes GPX files, corrects elevation data, computes safe speeds, and simulates virtual cycling with accurate power models.
Features
- 📍 GPX Parsing - Read GPS tracks from Garmin, Strava, Amazfit, and other devices
- 🏔️ Elevation Correction - Fix GPS elevation data using external elevation services
- ⚡ Physics-Based Speed Computation - Calculate maximum safe speeds based on:
- Cornering physics (lean angle limits)
- Braking constraints (deceleration limits)
- Terrain and gradient
- 🎮 Virtual Cyclist Simulation - Realistic cycling simulation with:
- Aerodynamic drag (air density, CdA, drafting)
- Rolling resistance (tire, surface type)
- Gravity effects (climbing/descending)
- Power-based speed calculations
- 🛠️ Path Processing - Douglas-Peucker simplification, resampling, ECEF coordinate transformations
- 📊 Multi-Format Support - ES modules, UMD, IIFE, Node.js (CJS/ESM)
Installation
npm install @glandais/virtual-cyclistQuick Start
import { GPXParser, Enhancer, Path } from '@glandais/virtual-cyclist';
// Parse a GPX file
const gpxContent = '<?xml version="1.0"?>...'; // Your GPX XML
const result = GPXParser.parse(gpxContent);
const path: Path = result.tracks[0];
console.log(`Loaded ${path.getPointCount()} points`);
console.log(`Distance: ${(path.getTotalDistance() / 1000).toFixed(1)} km`);
// Enhance with physics-based simulation
const enhancedPath = await Enhancer.enhanceCourseDefault(path);
// Access computed data
for (let i = 0; i < enhancedPath.getPointCount(); i++) {
console.log({
lat: enhancedPath.getLatitudeDeg(i),
lon: enhancedPath.getLongitudeDeg(i),
elevation: enhancedPath.getElevation(i),
speed: enhancedPath.getSpeed(i) * 3.6, // m/s to km/h
distance: enhancedPath.getDistance(i),
});
}Core API
GPX Parsing
import { GPXParser } from '@glandais/virtual-cyclist';
const result = GPXParser.parse(gpxXmlString);
const path = result.tracks[0];
// Access path data
path.getPointCount(); // Number of points
path.getTotalDistance(); // Total distance in meters
path.getTotalElevationGain(); // Total climbing in meters
path.getLatitudeDeg(index); // Latitude in degrees
path.getLongitudeDeg(index); // Longitude in degrees
path.getElevation(index); // Elevation in metersCyclist & Bike Models
import { Cyclist, Bike } from '@glandais/virtual-cyclist';
// Use defaults (recreational cyclist, road bike)
const cyclist = Cyclist.getDefault();
// 80kg total mass, 280W power, 3.5 W/kg
const bike = Bike.getDefault();
// Road bike with 0.004 Crr, 700c wheels
// Or customize
const customCyclist = new Cyclist(
75, // mass (kg) - cyclist + bike
300, // power (watts)
false, // harmonics
0.6, // max brake (g)
0.7, // drag coefficient
0.5, // frontal area (m²)
35, // max lean angle (degrees)
100 // max speed (km/h)
);Course vs CoursePhysics
The library uses two related interfaces:
Course: Basic course with path, cyclist, and bikeCoursePhysics: Extends Course with physics providers (air density, aerodynamics, wind, power)
import { Course, CoursePhysics } from '@glandais/virtual-cyclist';
// Basic Course - just the essentials
const course: Course = {
path,
cyclist: Cyclist.getDefault(),
bike: Bike.getDefault(),
};
// CoursePhysics - includes all physics simulation providers
const coursePhysics: CoursePhysics = {
...course,
rhoProvider: rhoProviderEstimate, // Air density
aeroProvider: aeroProviderConstant, // Aerodynamics
windProvider: windProviderNone, // Wind
cyclistPowerProvider: new PowerProviderConstant(280, false), // Power
};
// Most physics methods require CoursePhysics
MaxSpeedComputer.computeMaxSpeeds(coursePhysics);
VirtualizeService.virtualizeTrack(coursePhysics);Maximum Speed Computation
import { MaxSpeedComputer, Cyclist, Bike } from '@glandais/virtual-cyclist';
const cyclist = Cyclist.getDefault();
const bike = Bike.getDefault();
MaxSpeedComputer.computeMaxSpeeds({
path,
cyclist,
bike,
});
// Access computed maximum speeds
for (let i = 0; i < path.getPointCount(); i++) {
const maxSpeed = path.getSpeedMax(i);
const radius = path.getRadius(i);
console.log(
`Point ${i}: max ${(maxSpeed * 3.6).toFixed(1)} km/h, radius ${radius.toFixed(1)}m`
);
}Virtual Cyclist Simulation
import { VirtualizeService, Cyclist, Bike } from '@glandais/virtual-cyclist';
import {
aeroProviderConstant,
rhoProviderEstimate,
windProviderNone,
PowerProviderConstant,
} from '@glandais/virtual-cyclist';
const simulatedPath = VirtualizeService.virtualizeTrack({
path,
cyclist: Cyclist.getDefault(),
bike: Bike.getDefault(),
rhoProvider: rhoProviderEstimate, // Air density
aeroProvider: aeroProviderConstant, // Aerodynamics
windProvider: windProviderNone, // Wind conditions
cyclistPowerProvider: new PowerProviderConstant(280, false), // Power output
});
// Get realistic speed and power at each point
for (let i = 0; i < simulatedPath.getPointCount(); i++) {
console.log({
speed: simulatedPath.getSpeed(i) * 3.6, // km/h
power: simulatedPath.getPCyclistProvidedMuscular(i), // watts
time: simulatedPath.getElapsed(i), // seconds
});
}Available Providers
Air Density Providers:
import {
rhoProviderEstimate, // Estimates air density from elevation (default)
rhoProviderDefault, // Fixed air density (1.225 kg/m³ at sea level)
} from '@glandais/virtual-cyclist';Aerodynamics Providers:
import {
aeroProviderConstant, // Constant aerodynamic coefficient (default)
} from '@glandais/virtual-cyclist';Wind Providers:
import {
windProviderNone, // No wind (default)
WindProviderConstant, // Constant wind speed and direction
} from '@glandais/virtual-cyclist';
// Example: 5 m/s headwind from north
const windProvider = new WindProviderConstant(5, 0); // speed, bearing in radiansCyclist Power Providers:
import {
PowerProviderConstant, // Constant power output
PowerProviderConstantWithTiring, // Power with fatigue simulation
powerProviderFromData, // Use power data from GPX file
muscularPowerProvider, // Advanced muscular power model
} from '@glandais/virtual-cyclist';
// Constant power: 280W, no harmonics
const constantPower = new PowerProviderConstant(280, false);
// Power with tiring: 280W base, 10% fatigue after 1 hour
const tiringPower = new PowerProviderConstantWithTiring(280, 0.1, 3600, false);
// Use power from GPX data
const gpxPower = powerProviderFromData;
// Advanced muscular model
const muscularPower = muscularPowerProvider;Complete Enhancement Pipeline
import { Enhancer } from '@glandais/virtual-cyclist';
// All-in-one: elevation correction + max speeds + simulation + simplification
const enhancedPath = await Enhancer.enhanceCourseDefault(path);
// The enhanced path includes:
// - Corrected elevation data
// - Maximum safe speeds computed
// - Physics-based virtual cyclist simulation
// - Resampled to 1 point per second
// - Douglas-Peucker simplified (10m tolerance)
// Or use the full API with custom CoursePhysics
import { Cyclist, Bike } from '@glandais/virtual-cyclist';
import {
rhoProviderEstimate,
aeroProviderConstant,
windProviderNone,
PowerProviderConstant,
} from '@glandais/virtual-cyclist';
const coursePhysics = {
path,
cyclist: Cyclist.getDefault(),
bike: Bike.getDefault(),
rhoProvider: rhoProviderEstimate,
aeroProvider: aeroProviderConstant,
windProvider: windProviderNone,
cyclistPowerProvider: new PowerProviderConstant(280, false),
};
const enhancedPath = await Enhancer.enhanceCourse(coursePhysics);Customizing Enhancement Options
Control exactly which enhancement steps to apply:
import { Enhancer, EnhanceOptions } from '@glandais/virtual-cyclist';
const options: EnhanceOptions = {
fixElevation: true, // Fix GPS elevation data (default: true)
computeMaxSpeeds: true, // Calculate maximum safe speeds (default: true)
virtualizeTrack: true, // Simulate realistic cycling (default: true)
computeOnePointPerSecond: true, // Resample to 1Hz (default: true)
simplifyPath: {
enable: true, // Use Douglas-Peucker simplification (default: true)
tolerance: 10, // Maximum deviation in meters (default: 10)
zExaggeration: 3, // Elevation exaggeration factor (default: 3)
},
};
const coursePhysics = {
path,
cyclist: Cyclist.getDefault(),
bike: Bike.getDefault(),
rhoProvider: rhoProviderEstimate,
aeroProvider: aeroProviderConstant,
windProvider: windProviderNone,
cyclistPowerProvider: new PowerProviderConstant(280, false),
};
const enhancedPath = await Enhancer.enhanceCourse(coursePhysics, options);
// Or skip certain steps:
const quickEnhance: EnhanceOptions = {
fixElevation: false, // Skip elevation correction
computeMaxSpeeds: true,
virtualizeTrack: false, // Skip simulation
computeOnePointPerSecond: false,
simplifyPath: { enable: false },
};
const quickPath = await Enhancer.enhanceCourse(coursePhysics, quickEnhance);Elevation Correction
import { Elevation } from '@glandais/virtual-cyclist';
const correctedPath = await Elevation.fixElevation(path);
console.log(`Min elevation: ${correctedPath.getMinElevation()}m`);
console.log(`Max elevation: ${correctedPath.getMaxElevation()}m`);
console.log(`Elevation gain: ${correctedPath.getTotalElevationGain()}m`);Path Processing
import { DouglasPeucker, PointPerSecond } from '@glandais/virtual-cyclist';
// Simplify path using Douglas-Peucker algorithm (3D with ECEF)
const simplified = DouglasPeucker.simplify(
path,
10, // tolerance in meters
3 // elevation exaggeration factor
);
// Resample to 1 point per second
const resampled = PointPerSecond.computeOnePointPerSecond(path);Physics Model
Virtual Cyclist uses scientifically validated physics models:
Aerodynamic Drag
F_aero = 0.5 × ρ × CdA × v²- Air density (ρ) varies with temperature and altitude
- CdA = drag coefficient × frontal area
- Supports drafting effects
Rolling Resistance
F_rolling = Crr × N × cos(grade)- Tire coefficient (Crr) ~0.004 for road bikes
- Normal force depends on mass and gradient
Gravity
F_gravity = m × g × sin(grade)- Positive when climbing, negative when descending
Cornering Physics
v_max = √(g × radius × tan(max_lean_angle))- Maximum lean angle: 35° (default)
- Turning radius computed from GPS geometry
Braking Constraints
v_initial² = v_final² + 2 × a × distance- Maximum deceleration: 0.6g (default)
- Ensures cyclist can brake safely
Demo Application
An interactive Vue 3 demo is included at demo/:
Features
- 📁 Load sample GPX files or upload your own
- 📈 Visualize elevation and speed profiles with Chart.js
- 🔧 Apply elevation correction
- ⚡ Compute maximum safe speeds based on physics
- 🎮 Full virtual cyclist enhancement
- ⚙️ Configure chart fields (elevation, speed, power, heart rate, cadence, etc.)
- 🔍 Interactive chart with zoom/pan
Running the Demo
# Install dependencies
npm install
cd demo && npm install
# Run development server
npm run dev:demo
# Or build and serve
npm run build:demo
cd demo && npm run previewThe demo will be available at http://localhost:5173 (or the port Vite assigns).
Demo Screenshots
The demo provides:
- File Selection - Choose from sample GPX files or upload your own
- Control Panel - Apply enhancement operations:
- Fix Elevation
- Compute Max Speeds
- Enhance Path (complete pipeline)
- Interactive Charts - Visualize:
- Elevation profile
- Speed (actual, max, optimal)
- Power output
- Heart rate
- Cadence
- Temperature
- And 30+ other data fields
Sample GPX Files
The demo includes sample tracks from various devices:
sample.gpx- General routestelvio.gpx- Famous alpine descentamazfit.gpx- Amazfit watch trackinggarmin.gpx- Garmin device with power/cadencemovescount.gpx- Suunto Movescountsports-tracker.gpx- Sports Tracker appstrava.gpx- Strava export
Architecture
Data Structure
Virtual Cyclist uses chunked array storage for memory efficiency:
- Each point stores 37 numeric fields in Float64Array across 12 categories
- Access via generated getter/setter methods
- Code-generated from field definitions for type safety
Field Categories
The library tracks comprehensive cycling data organized into these categories:
- Coordinates - Latitude, longitude, distance
- Temporal - Timestamps and elapsed time
- Angles - Bearing and directional data
- 🏔️ Elevation - Altitude information
- 📐 Grade - Road slope percentage
- Radius - Turning radius for cornering
- Aero coef - Aerodynamic coefficients
- Cyclist wind - Wind bearing and angle
- ⚡ Power Physics - Aerodynamic, gravitational, rolling resistance, bearing power
- ⚡ Power Cyclist - Input power, optimal power, muscular power, wheel power
- ⚡ Power Post processed - Computed power from kinetic energy
- Speed & Motion - Current speed, max speed, virtual speed
- Environmental - Temperature, wind speed, wind direction
- Physiological - Heart rate, cadence
Example field access:
const point = path.getPoint(index);
console.log({
// Coordinates
lat: path.getLatitudeDeg(index),
lon: path.getLongitudeDeg(index),
distance: path.getDistance(index),
// Elevation & Grade
elevation: path.getElevation(index),
grade: path.getGrade(index),
// Speed & Motion
speed: path.getSpeed(index),
speedMax: path.getSpeedMax(index),
// Power
pAero: path.getPAero(index),
pGravity: path.getPGravity(index),
pRollingResistance: path.getPRollingResistance(index),
// Physiological
heartRate: path.getHeartRate(index),
cadence: path.getCadence(index),
// Environmental
temperature: path.getTemperature(index),
windSpeed: path.getWindSpeed(index),
});Coordinate Systems
- GPS Coordinates (lat/lon/elevation) for input/output
- ECEF Coordinates (Earth-Centered Earth-Fixed) for 3D geometry
- Accurate distance calculations using WGS-84 ellipsoid
Code Generation
Point.ts and GeneratedPath.ts are auto-generated from field definitions:
# Modify field definitions
vim src/types/path/fieldDefinitions.ts
# Regenerate files
npm run generateThe field definitions define all 37 fields with their names, types, units, and categories. The code generator creates type-safe getters and setters for efficient array-based storage.
Build Formats
The library is distributed in multiple formats:
- ES Module (
index.esm.js) - For modern bundlers - UMD (
index.umd.js) - Universal module definition - IIFE (
index.min.js) - Minified browser build - Node.js CJS (
index.node.js) - CommonJS for Node - Node.js ESM (
index.node.mjs) - ES modules for Node
TypeScript declarations included (index.d.ts).
Development
# Install dependencies
npm install
# Run tests
npm test
npm run test:coverage # With coverage report
# Linting
npm run lint
npm run lint:fix
# Type checking
npm run typecheck
# Build
npm run build # Production (no logging)
npm run build:dev # Development (with logging)
# Complete quality check
npm run check # Format + lint + typecheck + test + buildBrowser Support
- Modern browsers with ES2020 support
- Node.js ≥18
Credits
Based on the gpx2web Java project.
Physics models validated against academic cycling research and real-world data.
License
MIT License - see LICENSE file for details.
Contributing
Contributions welcome! Please open an issue or pull request on GitHub.
Links
- GitHub: https://github.com/glandais/virtual-cyclist
- npm: https://www.npmjs.com/package/@glandais/virtual-cyclist
- Issues: https://github.com/glandais/virtual-cyclist/issues
