This commit is contained in:
vcoppe committed 2026-09-20 13:58:27 +02:00
1 parent 20222c450d
commit cc5f5653ef
9 files changed
+690 -11

No files matched your search

+327 -7
View File
@@ -4,6 +4,7 @@ use crate::gpx::{
Author, GPXFile, Link, LngLat, Track, TrackPoint, TrackPointChunk, TrackSegment, Waypoint,
WaypointChunk,
};
use chrono::DateTime;
use quick_xml::Error;
use quick_xml::events::Event;
use quick_xml::events::attributes::Attributes;
@@ -22,10 +23,19 @@ enum GPXElement {
SEGMENT(TrackSegment),
TRACKPOINT(TrackPoint),
WAYPOINT(Waypoint),
ELEVATION,
TIME,
TEMPERATURE,
HEARTRATE,
CADENCE,
POWER,
SYMBOL,
TYPE,
ELEVATION,
COLOR,
OPACITY,
WIDTH,
}
fn parse_coordinates(attributes: Attributes<'_>) -> LngLat {
let mut coordinates = LngLat::default();
for attr in attributes {
@@ -83,8 +93,17 @@ pub fn parse(data: &[u8]) -> Result<GPXFile, Error> {
stack.push(GPXElement::WAYPOINT(wpt));
}
"ele" => stack.push(GPXElement::ELEVATION),
"time" => stack.push(GPXElement::TIME),
e if e.ends_with("atemp") => stack.push(GPXElement::TEMPERATURE),
e if e.ends_with("hr") => stack.push(GPXElement::HEARTRATE),
e if e.ends_with("cad") => stack.push(GPXElement::CADENCE),
"power" => stack.push(GPXElement::POWER),
e if e.ends_with("PowerInWatts") => stack.push(GPXElement::POWER),
"sym" => stack.push(GPXElement::SYMBOL),
"type" => stack.push(GPXElement::TYPE),
e if e.ends_with("color") => stack.push(GPXElement::COLOR),
e if e.ends_with("opacity") => stack.push(GPXElement::OPACITY),
e if e.ends_with("width") => stack.push(GPXElement::WIDTH),
_ => println!("{:?}", e),
},
Ok(Event::End(e)) => match e.name().as_ref() {
@@ -229,6 +248,47 @@ pub fn parse(data: &[u8]) -> Result<GPXFile, Error> {
_ => (),
}
}
Some(GPXElement::TIME) => {
stack.pop();
if let Some(GPXElement::TRACKPOINT(trkpt)) = stack.last_mut() {
if let Ok(time) = DateTime::parse_from_rfc3339(e.as_ref()) {
trkpt.time = Some(time.timestamp_millis());
}
}
}
Some(GPXElement::TEMPERATURE) => {
stack.pop();
if let Some(GPXElement::TRACKPOINT(trkpt)) = stack.last_mut() {
trkpt.atemp = e.parse().ok();
}
}
Some(GPXElement::HEARTRATE) => {
stack.pop();
if let Some(GPXElement::TRACKPOINT(trkpt)) = stack.last_mut() {
trkpt.hr = e.parse().ok();
}
}
Some(GPXElement::CADENCE) => {
stack.pop();
if let Some(GPXElement::TRACKPOINT(trkpt)) = stack.last_mut() {
trkpt.cad = e.parse().ok();
}
}
Some(GPXElement::POWER) => {
stack.pop();
if let Some(GPXElement::TRACKPOINT(trkpt)) = stack.last_mut() {
trkpt.power = e.parse().ok();
}
}
Some(GPXElement::SYMBOL) => {
stack.pop();
match stack.last_mut() {
Some(GPXElement::WAYPOINT(wpt)) => {
wpt.sym = Some(e.to_string());
}
_ => (),
}
}
Some(GPXElement::TYPE) => {
stack.pop();
match stack.last_mut() {
@@ -241,11 +301,31 @@ pub fn parse(data: &[u8]) -> Result<GPXFile, Error> {
_ => (),
}
}
Some(GPXElement::SYMBOL) => {
Some(GPXElement::COLOR) => {
stack.pop();
match stack.last_mut() {
Some(GPXElement::WAYPOINT(wpt)) => {
wpt.sym = Some(e.to_string());
Some(GPXElement::TRACK(trk)) => {
let mut color = "#".to_string();
color.push_str(&e);
trk.info.color = Some(color);
}
_ => (),
}
}
Some(GPXElement::OPACITY) => {
stack.pop();
match stack.last_mut() {
Some(GPXElement::TRACK(trk)) => {
trk.info.opacity = e.parse().ok();
}
_ => (),
}
}
Some(GPXElement::WIDTH) => {
stack.pop();
match stack.last_mut() {
Some(GPXElement::TRACK(trk)) => {
trk.info.width = e.parse().ok();
}
_ => (),
}
@@ -301,8 +381,8 @@ mod tests {
assert!(link.text.as_ref().is_some_and(|t| t == "track link text"));
assert!(trk.info.type_.as_ref().is_some_and(|c| c == "Cycling"));
assert_eq!(gpx.trk[0].trkseg.len(), 1);
let trkseg = &gpx.trk[0].trkseg[0];
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert_eq!(trkseg.chunks.len(), 1);
let chunk = &trkseg.chunks[0];
assert_eq!(chunk.trkpt.len(), 80);
@@ -312,13 +392,130 @@ mod tests {
assert_eq!(trkpt.ele, 109.0);
}
#[test]
fn test_parse_tracks() {
let mut f = File::open("data/with_tracks.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap();
assert_eq!(gpx.trk.len(), 2);
let trk = &gpx.trk[0];
assert!(trk.info.name.as_ref().is_some_and(|n| n == "track 1"));
assert!(trk.info.type_.as_ref().is_some_and(|c| c == "Cycling"));
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert_eq!(trkseg.chunks.len(), 1);
let chunk = &trkseg.chunks[0];
assert_eq!(chunk.trkpt.len(), 49);
let trkpt = &chunk.trkpt[0];
assert_eq!(trkpt.coordinates.lat, 50.790867);
assert_eq!(trkpt.coordinates.lng, 4.404968);
assert_eq!(trkpt.ele, 109.0);
let trk = &gpx.trk[1];
assert!(trk.info.name.as_ref().is_some_and(|n| n == "track 2"));
assert!(trk.info.type_.as_ref().is_some_and(|c| c == "Cycling"));
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert_eq!(trkseg.chunks.len(), 1);
let chunk = &trkseg.chunks[0];
assert_eq!(chunk.trkpt.len(), 28);
let trkpt = &chunk.trkpt[0];
assert_eq!(trkpt.coordinates.lat, 50.782212);
assert_eq!(trkpt.coordinates.lng, 4.406377);
assert_eq!(trkpt.ele, 115.5);
}
#[test]
fn test_parse_segments() {
let mut f = File::open("data/with_segments.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap();
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 2);
let trkseg = &trk.trkseg[0];
assert_eq!(trkseg.chunks.len(), 1);
let chunk = &trkseg.chunks[0];
assert_eq!(chunk.trkpt.len(), 49);
let trkpt = &chunk.trkpt[0];
assert_eq!(trkpt.coordinates.lat, 50.790867);
assert_eq!(trkpt.coordinates.lng, 4.404968);
assert_eq!(trkpt.ele, 109.0);
let trkseg = &trk.trkseg[1];
assert_eq!(trkseg.chunks.len(), 1);
let chunk = &trkseg.chunks[0];
assert_eq!(chunk.trkpt.len(), 28);
let trkpt = &chunk.trkpt[0];
assert_eq!(trkpt.coordinates.lat, 50.782212);
assert_eq!(trkpt.coordinates.lng, 4.406377);
assert_eq!(trkpt.ele, 115.5);
}
#[test]
fn test_parse_tracks_and_segments() {
let mut f = File::open("data/with_tracks_and_segments.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap();
assert_eq!(gpx.trk.len(), 2);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 2);
let trkseg = &trk.trkseg[0];
assert_eq!(trkseg.chunks.len(), 1);
let chunk = &trkseg.chunks[0];
assert_eq!(chunk.trkpt.len(), 16);
let trkpt = &chunk.trkpt[0];
assert_eq!(trkpt.coordinates.lat, 50.790867);
assert_eq!(trkpt.coordinates.lng, 4.404968);
assert_eq!(trkpt.ele, 109.0);
let trkseg = &trk.trkseg[1];
assert_eq!(trkseg.chunks.len(), 1);
let chunk = &trkseg.chunks[0];
assert_eq!(chunk.trkpt.len(), 34);
let trkpt = &chunk.trkpt[0];
assert_eq!(trkpt.coordinates.lat, 50.78727108169855);
assert_eq!(trkpt.coordinates.lng, 4.406133681127736);
assert_eq!(trkpt.ele, 115.0);
let trk = &gpx.trk[1];
assert_eq!(trk.trkseg.len(), 2);
let trkseg = &trk.trkseg[0];
assert_eq!(trkseg.chunks.len(), 1);
let chunk = &trkseg.chunks[0];
assert_eq!(chunk.trkpt.len(), 19);
let trkpt = &chunk.trkpt[0];
assert_eq!(trkpt.coordinates.lat, 50.782212);
assert_eq!(trkpt.coordinates.lng, 4.406377);
assert_eq!(trkpt.ele, 115.5);
let trkseg = &trk.trkseg[1];
assert_eq!(trkseg.chunks.len(), 1);
let chunk = &trkseg.chunks[0];
assert_eq!(chunk.trkpt.len(), 10);
let trkpt = &chunk.trkpt[0];
assert_eq!(trkpt.coordinates.lat, 50.77906316558724);
assert_eq!(trkpt.coordinates.lng, 4.412547477922485);
assert_eq!(trkpt.ele, 133.3);
}
#[test]
fn test_parse_waypoint() {
let mut f = File::open("data/with_waypoint.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap();
println!("{:?}", gpx);
assert_eq!(gpx.wpt.len(), 1);
let chunk = &gpx.wpt[0];
@@ -344,4 +541,127 @@ mod tests {
assert!(wpt.sym.as_ref().is_some_and(|s| s == "Bike Trail"));
assert!(wpt.type_.as_ref().is_some_and(|t| t == "Bike Trail"));
}
#[test]
fn test_parse_trackpoint_time() {
let mut f = File::open("data/with_time.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap();
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert_eq!(trkseg.chunks.len(), 1);
let chunk = &trkseg.chunks[0];
assert!(!chunk.trkpt.is_empty());
let trkpt = &chunk.trkpt[0];
assert!(trkpt.time.is_some_and(|t| t == 1704063600000));
}
#[test]
fn test_parse_trackpoint_hr() {
let mut f = File::open("data/with_hr.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap();
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert_eq!(trkseg.chunks.len(), 1);
let chunk = &trkseg.chunks[0];
assert!(!chunk.trkpt.is_empty());
let trkpt = &chunk.trkpt[0];
assert!(trkpt.hr.is_some_and(|h| h == 150));
}
#[test]
fn test_parse_trackpoint_cad() {
let mut f = File::open("data/with_cad.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap();
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert_eq!(trkseg.chunks.len(), 1);
let chunk = &trkseg.chunks[0];
assert!(!chunk.trkpt.is_empty());
let trkpt = &chunk.trkpt[0];
assert!(trkpt.cad.is_some_and(|c| c == 80));
}
#[test]
fn test_parse_trackpoint_power_1() {
let mut f = File::open("data/with_power_1.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap();
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert_eq!(trkseg.chunks.len(), 1);
let chunk = &trkseg.chunks[0];
assert!(!chunk.trkpt.is_empty());
let trkpt = &chunk.trkpt[0];
assert!(trkpt.power.is_some_and(|p| p == 200));
}
#[test]
fn test_parse_trackpoint_power_2() {
let mut f = File::open("data/with_power_2.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap();
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert_eq!(trkseg.chunks.len(), 1);
let chunk = &trkseg.chunks[0];
assert!(!chunk.trkpt.is_empty());
let trkpt = &chunk.trkpt[0];
assert!(trkpt.power.is_some_and(|p| p == 200));
}
#[test]
fn test_parse_trackpoint_atemp() {
let mut f = File::open("data/with_temp.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap();
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert_eq!(trkseg.chunks.len(), 1);
let chunk = &trkseg.chunks[0];
assert!(!chunk.trkpt.is_empty());
let trkpt = &chunk.trkpt[0];
assert!(trkpt.atemp.is_some_and(|t| t == 21));
}
#[test]
fn test_parse_track_style() {
let mut f = File::open("data/with_style.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap();
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 1);
assert!(trk.info.color.as_ref().is_some_and(|c| c == "#2d3ee9"));
assert!(trk.info.opacity.is_some_and(|o| o == 0.5));
assert!(trk.info.width.is_some_and(|w| w == 6.0));
}
}
+6
View File
@@ -9,3 +9,9 @@ pub struct LngLat {
pub lng: f64,
pub lat: f64,
}
#[derive(Debug, Default)]
pub struct LngLatBounds {
pub sw: LngLat,
pub ne: LngLat,
}
+1 -1
View File
@@ -5,9 +5,9 @@ pub struct TrackPoint {
pub coordinates: LngLat,
pub ele: f64,
pub time: Option<i64>,
pub atemp: Option<i16>,
pub hr: Option<u16>,
pub cad: Option<u16>,
pub power: Option<u16>,
pub atemp: Option<i16>,
// TODO OSM data? or store intervals at a higher level?
}
+2
View File
@@ -2,3 +2,5 @@ mod actions;
mod controller;
mod gpx;
mod stack;
mod statistics;
mod utils;
+113
View File
@@ -0,0 +1,113 @@
use crate::{
gpx::{LngLat, LngLatBounds, TrackPoint, TrackPointChunk},
utils::{distance, speed},
};
#[derive(Default)]
pub struct GPXStatistics {
pub total_distance: f64,
pub moving_distance: f64,
pub moving_time: i64,
pub elevation_gain: f64,
pub elevation_loss: f64,
pub start_time: Option<i64>,
pub end_time: Option<i64>,
pub bounds: LngLatBounds,
pub local: Vec<TrackpointStatistics>,
}
impl GPXStatistics {
fn accumulate(&mut self, prev: &TrackPoint, cur: &TrackPoint) {
self.accumulate_distance_and_time(prev, cur);
self.update_time_bounds(cur.time);
self.update_bounds(&cur.coordinates);
self.local
.push(TrackpointStatistics::from_partial_stats(&self));
}
fn accumulate_distance_and_time(&mut self, prev: &TrackPoint, cur: &TrackPoint) {
let dist = distance(&prev.coordinates, &cur.coordinates);
let time = prev.time.zip(cur.time).map(|(t1, t2)| t2 - t1);
self.total_distance += dist;
if let Some(time) = time {
let speed = speed(dist, time);
if speed >= 0.5 && speed <= 1500.0 {
self.moving_distance += dist;
self.moving_time += time;
}
}
}
fn update_time_bounds(&mut self, time: Option<i64>) {
if let Some(time) = time {
if self.start_time.is_none() {
self.start_time = Some(time);
}
self.end_time = Some(time);
}
}
fn update_bounds(&mut self, coordinates: &LngLat) {
self.bounds.sw.lng = self.bounds.sw.lng.min(coordinates.lng);
self.bounds.sw.lat = self.bounds.sw.lat.min(coordinates.lat);
self.bounds.ne.lng = self.bounds.ne.lng.min(coordinates.lng);
self.bounds.ne.lat = self.bounds.ne.lat.min(coordinates.lat);
}
}
#[derive(Default)]
pub struct SlopeSegment {
pub slope: f64,
pub distance: f64,
}
#[derive(Default)]
pub struct TrackpointStatistics {
pub total_distance: f64,
pub moving_distance: f64,
pub total_time: i64,
pub moving_time: i64,
pub speed: f64,
pub elevation_gain: f64,
pub elevation_loss: f64,
pub slope: f64,
pub slope_segment: SlopeSegment,
}
impl TrackpointStatistics {
fn from_partial_stats(stats: &GPXStatistics) -> Self {
Self {
total_distance: stats.total_distance,
moving_distance: stats.moving_distance,
total_time: stats
.start_time
.zip(stats.end_time)
.map_or(0, |(t1, t2)| t2 - t1),
moving_time: stats.moving_time,
speed: todo!(),
elevation_gain: stats.elevation_gain,
elevation_loss: stats.elevation_loss,
slope: Default::default(),
slope_segment: Default::default(),
}
}
}
impl GPXStatistics {
pub fn compute(chunk: &TrackPointChunk) -> Self {
let mut stats = Self::default();
if chunk.trkpt.is_empty() {
return stats;
}
let mut prev = &chunk.trkpt[0];
for i in 0..chunk.trkpt.len() {
let cur = &chunk.trkpt[i];
stats.accumulate(prev, cur);
prev = cur;
}
stats
}
}
+24
View File
@@ -0,0 +1,24 @@
use std::f64::consts::PI;
use crate::gpx::LngLat;
static TO_RADIANS: f64 = PI / 180.0;
static EARTH_RADIUS: f64 = 6371.0088;
/// Computes the distance in kilometers between two coordinates using the Haversine formula
pub fn distance(coord1: &LngLat, coord2: &LngLat) -> f64 {
let lat1 = coord1.lat * TO_RADIANS;
let lat2 = coord2.lat * TO_RADIANS;
let delta_lat = lat2 - lat1;
let delta_lng = (coord2.lng - coord1.lng) * TO_RADIANS;
let a =
(delta_lat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (delta_lng / 2.0).sin().powi(2);
let c = 2.0 * a.min(1.0).sqrt().asin();
EARTH_RADIUS * c
}
/// Computes the speed for a given distance in kilometers and a time in milliseconds
pub fn speed(distance: f64, time: i64) -> f64 {
distance / (time as f64 / 3600_000.0)
}