mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-09 23:26:31 +00:00
import and export of routes, ref #192
This commit is contained in:
1 parent
da9abbae5e
commit
dc36de2a48
10 files changed
+620
-62
No files matched your search
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<gpx xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3" version="1.1" creator="test">
|
||||||
|
<rte>
|
||||||
|
<name>Lausanne</name>
|
||||||
|
<cmt>comment</cmt>
|
||||||
|
<desc>description</desc>
|
||||||
|
<type>Cycling</type>
|
||||||
|
<rtept lat="46.544628096744418" lon="6.658079791814089">
|
||||||
|
<ele>500</ele>
|
||||||
|
<extensions>
|
||||||
|
<gpxx:RoutePointExtension>
|
||||||
|
<gpxx:rpt lat="46.544483928009868" lon="6.658018603920937"/>
|
||||||
|
<gpxx:rpt lat="46.544415950775146" lon="6.658358573913574"/>
|
||||||
|
<gpxx:rpt lat="46.544415950775146" lon="6.658358573913574"/>
|
||||||
|
</gpxx:RoutePointExtension>
|
||||||
|
</extensions>
|
||||||
|
</rtept>
|
||||||
|
<rtept lat="46.544100000000000" lon="6.659000000000000">
|
||||||
|
<ele>510</ele>
|
||||||
|
<extensions>
|
||||||
|
<gpxx:RoutePointExtension>
|
||||||
|
<gpxx:rpt lat="46.543900000000000" lon="6.659500000000000"/>
|
||||||
|
</gpxx:RoutePointExtension>
|
||||||
|
</extensions>
|
||||||
|
</rtept>
|
||||||
|
<rtept lat="46.543000000000000" lon="6.660000000000000">
|
||||||
|
<ele>505</ele>
|
||||||
|
</rtept>
|
||||||
|
</rte>
|
||||||
|
</gpx>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
Author, Chunk, File, Link, LngLat, Track, TrackSegment, Trackpoint, TrackpointCategories,
|
Author, Chunk, File, Link, LngLat, Track, TrackSegment, Trackpoint, TrackpointCategories,
|
||||||
TrackpointChunk, Waypoint, WaypointChunk,
|
TrackpointChunk, Waypoint, WaypointChunk, distance,
|
||||||
};
|
};
|
||||||
use chrono::DateTime;
|
use chrono::DateTime;
|
||||||
use quick_xml::Error;
|
use quick_xml::Error;
|
||||||
@@ -300,6 +300,93 @@ fn attach_link(stack: &mut [GPXElement], gpx: &mut File, link: Link) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds the segment of a route.
|
||||||
|
///
|
||||||
|
/// The route points are the anchors of the segment, and are the only ones that the file gives
|
||||||
|
/// (no anchor is computed). The points of the detailed path between a route point and the next one
|
||||||
|
/// (`gpxx:rpt`, which only have a position) are regular trackpoints, with the elevation worked out
|
||||||
|
/// from the one of the route points around them.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RouteReader {
|
||||||
|
segment: TrackSegment,
|
||||||
|
chunk: TrackpointChunk,
|
||||||
|
/// The intermediate points that follow the last route point, up to the next one.
|
||||||
|
pending: Vec<Trackpoint>,
|
||||||
|
/// Position and elevation of the last route point.
|
||||||
|
previous: Option<(LngLat, f64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RouteReader {
|
||||||
|
fn push(&mut self, point: Trackpoint) {
|
||||||
|
self.chunk.trkpt.push(point);
|
||||||
|
if self.chunk.is_full() {
|
||||||
|
self.segment.push(std::mem::take(&mut self.chunk));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a route point, after the intermediate points of the previous one. `following` are the
|
||||||
|
/// intermediate points that lead from it to the next one.
|
||||||
|
fn push_route_point(&mut self, mut rtept: Trackpoint, following: Vec<Trackpoint>) {
|
||||||
|
rtept.anchor = Some(0);
|
||||||
|
let here = (rtept.coordinates, rtept.ele);
|
||||||
|
self.flush(Some(here));
|
||||||
|
self.previous = Some(here);
|
||||||
|
self.push(rtept);
|
||||||
|
self.pending = following;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds the pending intermediate points, which lead to the route point `next` if there is one.
|
||||||
|
fn flush(&mut self, next: Option<(LngLat, f64)>) {
|
||||||
|
let mut pending = std::mem::take(&mut self.pending);
|
||||||
|
if let Some(previous) = self.previous {
|
||||||
|
interpolate_elevations(&mut pending, previous, next);
|
||||||
|
}
|
||||||
|
for point in pending {
|
||||||
|
self.push(point);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(mut self) -> TrackSegment {
|
||||||
|
self.flush(None);
|
||||||
|
self.segment.push(std::mem::take(&mut self.chunk));
|
||||||
|
self.segment.ensure_end_anchors();
|
||||||
|
self.segment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gives the points of a path from `from` to `to` an elevation that goes from the one of the first
|
||||||
|
/// to the one of the second, in proportion to the distance along the path. Without `to`, they have
|
||||||
|
/// the elevation of `from`.
|
||||||
|
fn interpolate_elevations(
|
||||||
|
points: &mut [Trackpoint],
|
||||||
|
from: (LngLat, f64),
|
||||||
|
to: Option<(LngLat, f64)>,
|
||||||
|
) {
|
||||||
|
let Some((to_position, to_ele)) = to else {
|
||||||
|
points.iter_mut().for_each(|point| point.ele = from.1);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut distances = Vec::with_capacity(points.len() + 1);
|
||||||
|
let mut total = 0.0;
|
||||||
|
let mut last = from.0;
|
||||||
|
for position in points
|
||||||
|
.iter()
|
||||||
|
.map(|point| point.coordinates)
|
||||||
|
.chain([to_position])
|
||||||
|
{
|
||||||
|
total += distance(last, position);
|
||||||
|
distances.push(total);
|
||||||
|
last = position;
|
||||||
|
}
|
||||||
|
for (point, along) in points.iter_mut().zip(distances) {
|
||||||
|
point.ele = if total > 0.0 {
|
||||||
|
from.1 + (to_ele - from.1) * along / total
|
||||||
|
} else {
|
||||||
|
from.1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Parses a GPX file. The surface, highway, SAC scale and MTB scale of the trackpoints are stored as codes of
|
/// Parses a GPX file. The surface, highway, SAC scale and MTB scale of the trackpoints are stored as codes of
|
||||||
/// `categories`, which learns the values it does not know yet.
|
/// `categories`, which learns the values it does not know yet.
|
||||||
pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File, Error> {
|
pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File, Error> {
|
||||||
@@ -309,9 +396,9 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
|
|||||||
let mut stack: Vec<GPXElement> = vec![];
|
let mut stack: Vec<GPXElement> = vec![];
|
||||||
let mut trkpt_chunk = TrackpointChunk::default();
|
let mut trkpt_chunk = TrackpointChunk::default();
|
||||||
let mut wpt_chunk = WaypointChunk::default();
|
let mut wpt_chunk = WaypointChunk::default();
|
||||||
// Routes are read as tracks of a single segment, which is what `rte_segment` collects the points of. A route
|
// Routes are read as tracks of a single segment, see `RouteReader`. The points of the detailed
|
||||||
// point that comes with the detailed path of the route to the next one (`gpxx:rpt`) is replaced by it.
|
// path that a route point comes with (`gpxx:rpt`) are collected until the route point is read.
|
||||||
let mut rte_segment = TrackSegment::default();
|
let mut route = RouteReader::default();
|
||||||
let mut rpt_points: Vec<Trackpoint> = vec![];
|
let mut rpt_points: Vec<Trackpoint> = vec![];
|
||||||
// the text of the element at the top of the stack, up to now
|
// the text of the element at the top of the stack, up to now
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
@@ -381,13 +468,13 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
|
|||||||
}
|
}
|
||||||
"rtept" => {
|
"rtept" => {
|
||||||
if let Some(GPXElement::Track(_)) = stack.last() {
|
if let Some(GPXElement::Track(_)) = stack.last() {
|
||||||
trkpt_chunk.trkpt.push(Trackpoint {
|
route.push_route_point(
|
||||||
coordinates: parse_coordinates(e.attributes()),
|
Trackpoint {
|
||||||
..Default::default()
|
coordinates: parse_coordinates(e.attributes()),
|
||||||
});
|
..Default::default()
|
||||||
if trkpt_chunk.is_full() {
|
},
|
||||||
rte_segment.push(std::mem::take(&mut trkpt_chunk));
|
vec![],
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
name if is_route_point_extension(name) => {
|
name if is_route_point_extension(name) => {
|
||||||
@@ -444,10 +531,8 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
|
|||||||
}
|
}
|
||||||
"rte" => {
|
"rte" => {
|
||||||
if let Some(GPXElement::Track(mut trk)) = stack.pop() {
|
if let Some(GPXElement::Track(mut trk)) = stack.pop() {
|
||||||
rte_segment.push(std::mem::take(&mut trkpt_chunk));
|
let trkseg = std::mem::take(&mut route).finish();
|
||||||
if !rte_segment.is_empty() {
|
if !trkseg.is_empty() {
|
||||||
let mut trkseg = std::mem::take(&mut rte_segment);
|
|
||||||
trkseg.compute_anchors();
|
|
||||||
trk.trkseg.push(trkseg);
|
trk.trkseg.push(trkseg);
|
||||||
}
|
}
|
||||||
gpx.trk.push(trk);
|
gpx.trk.push(trk);
|
||||||
@@ -457,17 +542,7 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
|
|||||||
if let Some(GPXElement::Trackpoint(rtept)) = stack.pop()
|
if let Some(GPXElement::Trackpoint(rtept)) = stack.pop()
|
||||||
&& let Some(GPXElement::Track(_)) = stack.last()
|
&& let Some(GPXElement::Track(_)) = stack.last()
|
||||||
{
|
{
|
||||||
let points = if rpt_points.is_empty() {
|
route.push_route_point(rtept, std::mem::take(&mut rpt_points));
|
||||||
vec![rtept]
|
|
||||||
} else {
|
|
||||||
std::mem::take(&mut rpt_points)
|
|
||||||
};
|
|
||||||
for point in points {
|
|
||||||
trkpt_chunk.trkpt.push(point);
|
|
||||||
if trkpt_chunk.is_full() {
|
|
||||||
rte_segment.push(std::mem::take(&mut trkpt_chunk));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
name if is_route_point_extension(name) => {
|
name if is_route_point_extension(name) => {
|
||||||
@@ -697,17 +772,128 @@ mod tests {
|
|||||||
assert_eq!(trk.info.name.as_deref(), Some("with extensions"));
|
assert_eq!(trk.info.name.as_deref(), Some("with extensions"));
|
||||||
assert_eq!(trk.trkseg.len(), 1);
|
assert_eq!(trk.trkseg.len(), 1);
|
||||||
let points: Vec<_> = trk.trkseg[0].iter().collect();
|
let points: Vec<_> = trk.trkseg[0].iter().collect();
|
||||||
// the detailed path replaces the route point it comes with
|
// the route points are kept, the points of the detailed path follow the one they come with
|
||||||
let coordinates: Vec<_> = points
|
let coordinates: Vec<_> = points
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| (p.coordinates.lat, p.coordinates.lng))
|
.map(|p| (p.coordinates.lat, p.coordinates.lng))
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
coordinates,
|
coordinates,
|
||||||
[(1.1, 1.1), (1.2, 1.2), (2.0, 2.0), (3.0, 3.0)]
|
[(1.0, 1.0), (1.1, 1.1), (1.2, 1.2), (2.0, 2.0), (3.0, 3.0)]
|
||||||
);
|
);
|
||||||
|
assert_eq!(points[3].ele, 20.0);
|
||||||
|
assert!(points[3].time.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_route_points_are_the_anchors_and_nothing_else_is() {
|
||||||
|
let gpx = parse_data("with_route_extensions");
|
||||||
|
let anchors: Vec<_> = gpx.trk[0].trkseg[0].iter().map(|p| p.anchor).collect();
|
||||||
|
assert_eq!(anchors, [Some(0), None, None, Some(0), Some(0)]);
|
||||||
|
|
||||||
|
// no anchor is computed for the points of a route that has none in between
|
||||||
|
let gpx = parse_data("with_routes");
|
||||||
|
for trk in &gpx.trk {
|
||||||
|
let segment = &trk.trkseg[0];
|
||||||
|
assert!(segment.iter().all(|p| p.anchor == Some(0)));
|
||||||
|
}
|
||||||
|
// while a track gets the anchors that its shape needs
|
||||||
|
let track = parse_data("with_tracks");
|
||||||
|
let segment = &track.trk[0].trkseg[0];
|
||||||
|
let anchors = segment.iter().filter(|p| p.anchor.is_some()).count();
|
||||||
|
assert!(anchors >= 2 && anchors < segment.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_the_points_of_the_detailed_path_of_a_route() {
|
||||||
|
let gpx = parse_data("with_route_points");
|
||||||
|
assert_eq!(gpx.trk.len(), 1);
|
||||||
|
let trk = &gpx.trk[0];
|
||||||
|
assert_eq!(trk.info.name.as_deref(), Some("Lausanne"));
|
||||||
|
assert_eq!(trk.info.cmt.as_deref(), Some("comment"));
|
||||||
|
assert_eq!(trk.info.desc.as_deref(), Some("description"));
|
||||||
|
assert_eq!(trk.info.type_.as_deref(), Some("Cycling"));
|
||||||
|
|
||||||
|
let points: Vec<_> = trk.trkseg[0].iter().cloned().collect();
|
||||||
|
// 3 route points and 4 points between them, the repeated one being kept
|
||||||
|
assert_eq!(points.len(), 7);
|
||||||
|
let kinds: Vec<_> = points.iter().map(|p| p.anchor.is_some()).collect();
|
||||||
|
assert_eq!(kinds, [true, false, false, false, true, false, true]);
|
||||||
|
assert_eq!(points[0].coordinates.lat, 46.54462809674442);
|
||||||
|
assert_eq!(points[0].coordinates.lng, 6.658079791814089);
|
||||||
|
assert_eq!(points[1].coordinates.lat, 46.54448392800987);
|
||||||
|
assert_eq!(points[3].coordinates.lng, points[2].coordinates.lng);
|
||||||
|
assert_eq!(points[4].coordinates.lat, 46.5441);
|
||||||
|
assert_eq!(points[5].coordinates.lat, 46.5439);
|
||||||
|
|
||||||
|
// the points in between have no elevation of their own: it goes from one route point to
|
||||||
|
// the next
|
||||||
|
let ele: Vec<_> = points.iter().map(|p| p.ele).collect();
|
||||||
|
assert_eq!((ele[0], ele[4], ele[6]), (500.0, 510.0, 505.0));
|
||||||
|
assert!(ele[0] < ele[1] && ele[1] < ele[2] && ele[2] <= ele[3] && ele[3] < ele[4]);
|
||||||
|
assert!(ele[5] < 510.0 && ele[5] > 505.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_the_last_point_of_a_route_is_an_anchor_even_after_a_path() {
|
||||||
|
// the last route point comes with a path that leads nowhere
|
||||||
|
let data = r#"<gpx xmlns:gpxx="x"><rte>
|
||||||
|
<rtept lat="1" lon="1"><ele>10</ele></rtept>
|
||||||
|
<rtept lat="2" lon="2"><ele>20</ele>
|
||||||
|
<extensions><gpxx:RoutePointExtension>
|
||||||
|
<gpxx:rpt lat="2.1" lon="2.1"/><gpxx:rpt lat="2.2" lon="2.2"/>
|
||||||
|
</gpxx:RoutePointExtension></extensions>
|
||||||
|
</rtept>
|
||||||
|
</rte></gpx>"#;
|
||||||
|
let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap();
|
||||||
|
let points: Vec<_> = gpx.trk[0].trkseg[0].iter().cloned().collect();
|
||||||
|
let coordinates: Vec<_> = points.iter().map(|p| p.coordinates.lat).collect();
|
||||||
|
assert_eq!(coordinates, [1.0, 2.0, 2.1, 2.2]);
|
||||||
|
// the route point is an anchor, and so is the end of the path
|
||||||
|
let anchors: Vec<_> = points.iter().map(|p| p.anchor).collect();
|
||||||
|
assert_eq!(anchors, [Some(0), Some(0), None, Some(0)]);
|
||||||
|
// with the elevation of the last route point, as there is no next one
|
||||||
assert_eq!(points[2].ele, 20.0);
|
assert_eq!(points[2].ele, 20.0);
|
||||||
assert!(points[2].time.is_some());
|
assert_eq!(points[3].ele, 20.0);
|
||||||
|
|
||||||
|
// a path after a single route point
|
||||||
|
let data = r#"<gpx xmlns:gpxx="x"><rte><rtept lat="1" lon="1"><extensions>
|
||||||
|
<gpxx:RoutePointExtension><gpxx:rpt lat="1.5" lon="1.5"/></gpxx:RoutePointExtension>
|
||||||
|
</extensions></rtept></rte></gpx>"#;
|
||||||
|
let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap();
|
||||||
|
let anchors: Vec<_> = gpx.trk[0].trkseg[0].iter().map(|p| p.anchor).collect();
|
||||||
|
assert_eq!(anchors, [Some(0), Some(0)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_elevations_between_route_points() {
|
||||||
|
let here = |lng: f64| LngLat { lng, lat: 0.0 };
|
||||||
|
let point = |lng: f64| Trackpoint {
|
||||||
|
coordinates: here(lng),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// evenly spaced points go up evenly
|
||||||
|
let mut points = vec![point(1.0), point(2.0), point(3.0)];
|
||||||
|
interpolate_elevations(&mut points, (here(0.0), 100.0), Some((here(4.0), 200.0)));
|
||||||
|
let ele: Vec<_> = points.iter().map(|p| p.ele.round()).collect();
|
||||||
|
assert_eq!(ele, [125.0, 150.0, 175.0]);
|
||||||
|
|
||||||
|
// the distance counts, not the number of points
|
||||||
|
let mut points = vec![point(1.0)];
|
||||||
|
interpolate_elevations(&mut points, (here(0.0), 0.0), Some((here(4.0), 400.0)));
|
||||||
|
assert!((points[0].ele - 100.0).abs() < 1e-6);
|
||||||
|
|
||||||
|
// after the last route point, the elevation does not change
|
||||||
|
let mut points = vec![point(1.0), point(2.0)];
|
||||||
|
interpolate_elevations(&mut points, (here(0.0), 321.0), None);
|
||||||
|
assert!(points.iter().all(|p| p.ele == 321.0));
|
||||||
|
|
||||||
|
// route points at the same place
|
||||||
|
let mut points = vec![point(0.0)];
|
||||||
|
interpolate_elevations(&mut points, (here(0.0), 10.0), Some((here(0.0), 20.0)));
|
||||||
|
assert_eq!(points[0].ele, 10.0);
|
||||||
|
// nothing between them is fine
|
||||||
|
interpolate_elevations(&mut [], (here(0.0), 1.0), Some((here(1.0), 2.0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ use crate::{File, Link, Track, Trackpoint, TrackpointCategories, Waypoint};
|
|||||||
|
|
||||||
/// What the written file contains: the data of the trackpoints that are `false` are left out.
|
/// What the written file contains: the data of the trackpoints that are `false` are left out.
|
||||||
/// The same type tells which data a file has, see [`File::exportable_data`].
|
/// The same type tells which data a file has, see [`File::exportable_data`].
|
||||||
|
///
|
||||||
|
/// With `as_route`, the file is written with routes instead of tracks, see [`write`]. It is not
|
||||||
|
/// data: it is off in `ALL` and `NONE`, and in what [`File::exportable_data`] gives.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct ExportOptions {
|
pub struct ExportOptions {
|
||||||
pub time: bool,
|
pub time: bool,
|
||||||
@@ -17,6 +20,8 @@ pub struct ExportOptions {
|
|||||||
pub power: bool,
|
pub power: bool,
|
||||||
/// The OpenStreetMap data: surface, highway, SAC scale and MTB scale.
|
/// The OpenStreetMap data: surface, highway, SAC scale and MTB scale.
|
||||||
pub osm: bool,
|
pub osm: bool,
|
||||||
|
/// Writes every segment as a route (`rte`) instead of a track.
|
||||||
|
pub as_route: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExportOptions {
|
impl ExportOptions {
|
||||||
@@ -31,6 +36,7 @@ impl ExportOptions {
|
|||||||
atemp: value,
|
atemp: value,
|
||||||
power: value,
|
power: value,
|
||||||
osm: value,
|
osm: value,
|
||||||
|
as_route: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +49,7 @@ impl ExportOptions {
|
|||||||
atemp: self.atemp || other.atemp,
|
atemp: self.atemp || other.atemp,
|
||||||
power: self.power || other.power,
|
power: self.power || other.power,
|
||||||
osm: self.osm || other.osm,
|
osm: self.osm || other.osm,
|
||||||
|
as_route: self.as_route || other.as_route,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,6 +88,11 @@ type XmlWriter = Writer<Vec<u8>>;
|
|||||||
|
|
||||||
/// Writes a file as GPX 1.1, in UTF-8. The surface, highway, SAC scale and MTB scale of the trackpoints are
|
/// Writes a file as GPX 1.1, in UTF-8. The surface, highway, SAC scale and MTB scale of the trackpoints are
|
||||||
/// the names of their codes in `categories`.
|
/// the names of their codes in `categories`.
|
||||||
|
///
|
||||||
|
/// The segments are written as tracks, or with `options.as_route` as routes: a segment becomes a
|
||||||
|
/// `rte` that has the information of its track, its anchors are the `rtept` and the trackpoints
|
||||||
|
/// between two anchors are the `gpxx:rpt` of the `rtept` before them (which only have a
|
||||||
|
/// position, unlike the `rtept`). The first and last trackpoints always are `rtept`.
|
||||||
pub fn write(file: &File, categories: &TrackpointCategories, options: ExportOptions) -> Vec<u8> {
|
pub fn write(file: &File, categories: &TrackpointCategories, options: ExportOptions) -> Vec<u8> {
|
||||||
let mut writer = Writer::new_with_indent(Vec::new(), b' ', 4);
|
let mut writer = Writer::new_with_indent(Vec::new(), b' ', 4);
|
||||||
// writing to memory does not fail
|
// writing to memory does not fail
|
||||||
@@ -131,7 +143,13 @@ fn write_file(
|
|||||||
(_, 1) => Some(file.info.name.as_str()),
|
(_, 1) => Some(file.info.name.as_str()),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
write_track(w, trk, name, categories, options)?;
|
if options.as_route {
|
||||||
|
for segment in trk.trkseg.iter().filter(|segment| !segment.is_empty()) {
|
||||||
|
write_route(w, trk, segment, name, categories, options)?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
write_track(w, trk, name, categories, options)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
@@ -237,6 +255,38 @@ fn write_waypoint(w: &mut XmlWriter, wpt: &Waypoint, options: ExportOptions) ->
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The information of a track that its routes have too: name, comment, description, source,
|
||||||
|
/// links, type and style.
|
||||||
|
fn write_track_info(
|
||||||
|
w: &mut XmlWriter,
|
||||||
|
name: Option<&str>,
|
||||||
|
info: &crate::TrackInfo,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
text(w, "name", name.unwrap_or_default())?;
|
||||||
|
optional_text(w, "cmt", &info.cmt)?;
|
||||||
|
optional_text(w, "desc", &info.desc)?;
|
||||||
|
optional_text(w, "src", &info.src)?;
|
||||||
|
links(w, &info.links)?;
|
||||||
|
optional_text(w, "type", &info.type_)?;
|
||||||
|
if info.color.is_some() || info.opacity.is_some() || info.width.is_some() {
|
||||||
|
w.create_element("extensions").write_inner_content(|w| {
|
||||||
|
w.create_element("gpx_style:line")
|
||||||
|
.write_inner_content(|w| {
|
||||||
|
optional_text(w, "gpx_style:color", &info.color)?;
|
||||||
|
if let Some(opacity) = info.opacity {
|
||||||
|
number(w, "gpx_style:opacity", opacity)?;
|
||||||
|
}
|
||||||
|
if let Some(width) = info.width {
|
||||||
|
number(w, "gpx_style:width", width)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn write_track(
|
fn write_track(
|
||||||
w: &mut XmlWriter,
|
w: &mut XmlWriter,
|
||||||
trk: &Track,
|
trk: &Track,
|
||||||
@@ -244,34 +294,12 @@ fn write_track(
|
|||||||
categories: &TrackpointCategories,
|
categories: &TrackpointCategories,
|
||||||
options: ExportOptions,
|
options: ExportOptions,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
let info = &trk.info;
|
|
||||||
w.create_element("trk").write_inner_content(|w| {
|
w.create_element("trk").write_inner_content(|w| {
|
||||||
text(w, "name", name.unwrap_or_default())?;
|
write_track_info(w, name, &trk.info)?;
|
||||||
optional_text(w, "cmt", &info.cmt)?;
|
|
||||||
optional_text(w, "desc", &info.desc)?;
|
|
||||||
optional_text(w, "src", &info.src)?;
|
|
||||||
links(w, &info.links)?;
|
|
||||||
optional_text(w, "type", &info.type_)?;
|
|
||||||
if info.color.is_some() || info.opacity.is_some() || info.width.is_some() {
|
|
||||||
w.create_element("extensions").write_inner_content(|w| {
|
|
||||||
w.create_element("gpx_style:line")
|
|
||||||
.write_inner_content(|w| {
|
|
||||||
optional_text(w, "gpx_style:color", &info.color)?;
|
|
||||||
if let Some(opacity) = info.opacity {
|
|
||||||
number(w, "gpx_style:opacity", opacity)?;
|
|
||||||
}
|
|
||||||
if let Some(width) = info.width {
|
|
||||||
number(w, "gpx_style:width", width)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
})?;
|
|
||||||
Ok(())
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
for segment in &trk.trkseg {
|
for segment in &trk.trkseg {
|
||||||
w.create_element("trkseg").write_inner_content(|w| {
|
w.create_element("trkseg").write_inner_content(|w| {
|
||||||
for point in segment.iter() {
|
for point in segment.iter() {
|
||||||
write_trackpoint(w, point, categories, options)?;
|
write_point(w, "trkpt", point, &[], categories, options)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
@@ -281,9 +309,48 @@ fn write_track(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_trackpoint(
|
fn write_route(
|
||||||
w: &mut XmlWriter,
|
w: &mut XmlWriter,
|
||||||
|
trk: &Track,
|
||||||
|
segment: &crate::TrackSegment,
|
||||||
|
name: Option<&str>,
|
||||||
|
categories: &TrackpointCategories,
|
||||||
|
options: ExportOptions,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
w.create_element("rte").write_inner_content(|w| {
|
||||||
|
write_track_info(w, name, &trk.info)?;
|
||||||
|
let points: Vec<&Trackpoint> = segment.iter().collect();
|
||||||
|
let last = points.len() - 1;
|
||||||
|
let is_anchor = |i: usize| i == 0 || i == last || points[i].anchor.is_some();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < points.len() {
|
||||||
|
// the trackpoints up to the next anchor
|
||||||
|
let mut next = i + 1;
|
||||||
|
while next < points.len() && !is_anchor(next) {
|
||||||
|
next += 1;
|
||||||
|
}
|
||||||
|
write_point(
|
||||||
|
w,
|
||||||
|
"rtept",
|
||||||
|
points[i],
|
||||||
|
&points[i + 1..next],
|
||||||
|
categories,
|
||||||
|
options,
|
||||||
|
)?;
|
||||||
|
i = next;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A trackpoint or a route point (`tag`). A route point has the `path` that leads to the next one,
|
||||||
|
/// of which only the positions are written.
|
||||||
|
fn write_point(
|
||||||
|
w: &mut XmlWriter,
|
||||||
|
tag: &str,
|
||||||
point: &Trackpoint,
|
point: &Trackpoint,
|
||||||
|
path: &[&Trackpoint],
|
||||||
categories: &TrackpointCategories,
|
categories: &TrackpointCategories,
|
||||||
options: ExportOptions,
|
options: ExportOptions,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
@@ -306,12 +373,12 @@ fn write_trackpoint(
|
|||||||
let has_osm = osm.iter().any(|(_, value)| value.is_some());
|
let has_osm = osm.iter().any(|(_, value)| value.is_some());
|
||||||
let has_track_point_extension = atemp.is_some() || hr.is_some() || cad.is_some() || has_osm;
|
let has_track_point_extension = atemp.is_some() || hr.is_some() || cad.is_some() || has_osm;
|
||||||
|
|
||||||
write_coordinates(w, "trkpt", point.coordinates).write_inner_content(|w| {
|
write_coordinates(w, tag, point.coordinates).write_inner_content(|w| {
|
||||||
number(w, "ele", point.ele)?;
|
number(w, "ele", point.ele)?;
|
||||||
if options.time {
|
if options.time {
|
||||||
time(w, point.time)?;
|
time(w, point.time)?;
|
||||||
}
|
}
|
||||||
if !has_track_point_extension && power.is_none() {
|
if !has_track_point_extension && power.is_none() && path.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
w.create_element("extensions").write_inner_content(|w| {
|
w.create_element("extensions").write_inner_content(|w| {
|
||||||
@@ -343,6 +410,15 @@ fn write_trackpoint(
|
|||||||
w.create_element("gpxpx:PowerExtension")
|
w.create_element("gpxpx:PowerExtension")
|
||||||
.write_inner_content(|w| number(w, "gpxpx:PowerInWatts", power))?;
|
.write_inner_content(|w| number(w, "gpxpx:PowerInWatts", power))?;
|
||||||
}
|
}
|
||||||
|
if !path.is_empty() {
|
||||||
|
w.create_element("gpxx:RoutePointExtension")
|
||||||
|
.write_inner_content(|w| {
|
||||||
|
for point in path {
|
||||||
|
write_coordinates(w, "gpxx:rpt", point.coordinates).write_empty()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -518,4 +594,173 @@ mod tests {
|
|||||||
let (file, _) = read("with_power_2");
|
let (file, _) = read("with_power_2");
|
||||||
assert!(file.exportable_data().power);
|
assert!(file.exportable_data().power);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn as_route() -> ExportOptions {
|
||||||
|
ExportOptions {
|
||||||
|
as_route: true,
|
||||||
|
..ExportOptions::ALL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count(text: &str, pattern: &str) -> usize {
|
||||||
|
text.matches(pattern).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_export_as_route_writes_routes_instead_of_tracks() {
|
||||||
|
let (file, categories) = read("with_route_points");
|
||||||
|
let text = written_text(&file, &categories, as_route());
|
||||||
|
|
||||||
|
assert_eq!(count(&text, "<rte>"), 1);
|
||||||
|
assert_eq!(count(&text, "<trk>"), 0);
|
||||||
|
assert_eq!(count(&text, "<trkseg>"), 0);
|
||||||
|
assert_eq!(count(&text, "<trkpt"), 0);
|
||||||
|
// the anchors are the route points, the trackpoints between them the points of their path
|
||||||
|
assert_eq!(count(&text, "<rtept"), 3);
|
||||||
|
assert_eq!(count(&text, "<gpxx:rpt"), 4);
|
||||||
|
assert_eq!(count(&text, "<gpxx:RoutePointExtension>"), 2);
|
||||||
|
// a route has the information of its track
|
||||||
|
assert!(text.contains("<name>Lausanne</name>"));
|
||||||
|
assert!(text.contains("<cmt>comment</cmt>"));
|
||||||
|
assert!(text.contains("<desc>description</desc>"));
|
||||||
|
assert!(text.contains("<type>Cycling</type>"));
|
||||||
|
// and its points only have a position in the path, and an elevation as route points
|
||||||
|
let first_rtept = &text[text.find("<rtept").unwrap()..];
|
||||||
|
let (lat, lng) = (46.54462809674442_f64, 6.658079791814089_f64);
|
||||||
|
assert!(first_rtept.starts_with(&format!("<rtept lat=\"{lat}\" lon=\"{lng}\">")));
|
||||||
|
assert!(first_rtept.contains("<ele>500</ele>"));
|
||||||
|
let (lat, lng) = (46.54441595077515_f64, 6.658358573913574_f64);
|
||||||
|
assert!(text.contains(&format!("<gpxx:rpt lat=\"{lat}\" lon=\"{lng}\"/>")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_a_route_survives_being_exported_as_a_route() {
|
||||||
|
let (file, mut categories) = read("with_route_points");
|
||||||
|
let written = write(&file, &categories, as_route());
|
||||||
|
let again = parse(&written, &mut categories).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(again.trk.len(), 1);
|
||||||
|
assert_eq!(again.trk[0].info, file.trk[0].info);
|
||||||
|
let (a, b) = (&file.trk[0].trkseg[0], &again.trk[0].trkseg[0]);
|
||||||
|
assert_eq!(a.len(), b.len());
|
||||||
|
for (a, b) in a.iter().zip(b.iter()) {
|
||||||
|
assert_eq!(a.coordinates.lat, b.coordinates.lat);
|
||||||
|
assert_eq!(a.coordinates.lng, b.coordinates.lng);
|
||||||
|
assert_eq!(a.anchor, b.anchor);
|
||||||
|
}
|
||||||
|
// the elevation of the route points is kept, the other ones are worked out again
|
||||||
|
let anchors = |s: &crate::TrackSegment| {
|
||||||
|
s.iter()
|
||||||
|
.filter(|p| p.anchor.is_some())
|
||||||
|
.map(|p| p.ele)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
assert_eq!(anchors(a), anchors(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tracks_become_one_route_per_segment_with_the_information_of_the_track() {
|
||||||
|
let (file, mut categories) = read("with_tracks_and_segments");
|
||||||
|
let segments: usize = file.trk.iter().map(|t| t.trkseg.len()).sum();
|
||||||
|
assert!(segments > 2);
|
||||||
|
let written = write(&file, &categories, as_route());
|
||||||
|
let text = String::from_utf8(written.clone()).unwrap();
|
||||||
|
assert_eq!(count(&text, "<rte>"), segments);
|
||||||
|
assert_eq!(count(&text, "<trk>"), 0);
|
||||||
|
// every route starts and ends with a route point
|
||||||
|
assert_eq!(count(&text, "</rte>"), segments);
|
||||||
|
|
||||||
|
let again = parse(&written, &mut categories).unwrap();
|
||||||
|
assert_eq!(again.trk.len(), segments);
|
||||||
|
let mut routes = again.trk.iter();
|
||||||
|
for track in &file.trk {
|
||||||
|
for segment in &track.trkseg {
|
||||||
|
let route = routes.next().unwrap();
|
||||||
|
// the information of the track, repeated for each of its segments
|
||||||
|
assert_eq!(route.info.name, track.info.name);
|
||||||
|
assert_eq!(route.info.type_, track.info.type_);
|
||||||
|
let (a, b) = (segment, &route.trkseg[0]);
|
||||||
|
assert_eq!(a.len(), b.len());
|
||||||
|
for (a, b) in a.iter().zip(b.iter()) {
|
||||||
|
assert_eq!(
|
||||||
|
(a.coordinates.lat, a.coordinates.lng),
|
||||||
|
(b.coordinates.lat, b.coordinates.lng)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// the anchors of the segment are the ones of its route
|
||||||
|
let anchors = |s: &crate::TrackSegment| {
|
||||||
|
s.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, p)| p.anchor.is_some())
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
assert_eq!(anchors(a), anchors(b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_the_ends_of_a_route_are_route_points_even_without_anchors() {
|
||||||
|
let (mut file, categories) = read("simple");
|
||||||
|
file.trk[0].trkseg[0].update_all(|_, point| point.anchor = None);
|
||||||
|
let len = file.trk[0].trkseg[0].len();
|
||||||
|
let text = written_text(&file, &categories, as_route());
|
||||||
|
assert_eq!(count(&text, "<rtept"), 2);
|
||||||
|
assert_eq!(count(&text, "<gpxx:rpt"), len - 2);
|
||||||
|
// and a single trackpoint is a route of one point
|
||||||
|
file.trk[0].trkseg[0].splice(1, len, vec![]);
|
||||||
|
let text = written_text(&file, &categories, as_route());
|
||||||
|
assert_eq!(count(&text, "<rtept"), 1);
|
||||||
|
assert_eq!(count(&text, "<gpxx:rpt"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_segments_are_not_routes() {
|
||||||
|
let (mut file, categories) = read("simple");
|
||||||
|
file.trk[0].trkseg.push(crate::TrackSegment::default());
|
||||||
|
let text = written_text(&file, &categories, as_route());
|
||||||
|
assert_eq!(count(&text, "<rte>"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_routes_have_the_style_of_their_track() {
|
||||||
|
let (file, categories) = read("with_style");
|
||||||
|
let text = written_text(&file, &categories, as_route());
|
||||||
|
assert!(count(&text, "<gpx_style:line>") >= 1);
|
||||||
|
assert_eq!(count(&text, "<gpx_style:line>"), count(&text, "<rte>"));
|
||||||
|
let again = parse(text.as_bytes(), &mut Default::default()).unwrap();
|
||||||
|
assert_eq!(again.trk[0].info.color, file.trk[0].info.color);
|
||||||
|
assert_eq!(again.trk[0].info.width, file.trk[0].info.width);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_route_points_keep_their_data_and_the_options_apply_to_them() {
|
||||||
|
let (file, categories) = read("with_hr");
|
||||||
|
let with_all = written_text(&file, &categories, as_route());
|
||||||
|
assert!(with_all.contains("<gpxtpx:hr>"));
|
||||||
|
let without = written_text(
|
||||||
|
&file,
|
||||||
|
&categories,
|
||||||
|
ExportOptions {
|
||||||
|
hr: false,
|
||||||
|
time: false,
|
||||||
|
as_route: true,
|
||||||
|
..ExportOptions::ALL
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(!without.contains("<gpxtpx:hr>"));
|
||||||
|
assert!(!without.contains("<time>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_the_route_option_is_not_data() {
|
||||||
|
let (file, _) = read("with_route_points");
|
||||||
|
assert!(!file.exportable_data().as_route);
|
||||||
|
// it is a way to write the data, never part of them
|
||||||
|
let all = std::hint::black_box(ExportOptions::ALL);
|
||||||
|
assert!(!all.as_route && !ExportOptions::NONE.as_route);
|
||||||
|
assert!(as_route().union(ExportOptions::NONE).as_route);
|
||||||
|
assert!(!ExportOptions::ALL.union(ExportOptions::NONE).as_route);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -56,8 +56,10 @@ pub fn selection_statistics() -> GlobalStatistics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A file as GPX (UTF-8 bytes), `undefined` if it does not exist. The options say which data of the trackpoints
|
/// A file as GPX (UTF-8 bytes), `undefined` if it does not exist. The options say which data of the trackpoints
|
||||||
/// to keep.
|
/// to keep, and with `as_route` that the segments are written as routes (`rte`) instead of tracks:
|
||||||
|
/// the anchors are the route points, and the trackpoints between them the points of their path.
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn export_file(
|
pub fn export_file(
|
||||||
file_id: &str,
|
file_id: &str,
|
||||||
time: bool,
|
time: bool,
|
||||||
@@ -66,6 +68,7 @@ pub fn export_file(
|
|||||||
atemp: bool,
|
atemp: bool,
|
||||||
power: bool,
|
power: bool,
|
||||||
osm: bool,
|
osm: bool,
|
||||||
|
as_route: bool,
|
||||||
) -> Option<Vec<u8>> {
|
) -> Option<Vec<u8>> {
|
||||||
let id = parse_file_id(file_id)?;
|
let id = parse_file_id(file_id)?;
|
||||||
let options = engine::ExportOptions {
|
let options = engine::ExportOptions {
|
||||||
@@ -75,6 +78,7 @@ pub fn export_file(
|
|||||||
atemp,
|
atemp,
|
||||||
power,
|
power,
|
||||||
osm,
|
osm,
|
||||||
|
as_route,
|
||||||
};
|
};
|
||||||
with_engine(|e| e.export(&id, options)).flatten()
|
with_engine(|e| e.export(&id, options)).flatten()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,7 +112,10 @@ fn test_unknown_file_has_no_structure() {
|
|||||||
assert!(crate::queries::files::file_structure(&id).is_undefined());
|
assert!(crate::queries::files::file_structure(&id).is_undefined());
|
||||||
assert!(crate::queries::files::file_structure("not an id").is_undefined());
|
assert!(crate::queries::files::file_structure("not an id").is_undefined());
|
||||||
assert!(crate::queries::files::file_statistics(&id).is_undefined());
|
assert!(crate::queries::files::file_statistics(&id).is_undefined());
|
||||||
assert!(crate::queries::files::export_file(&id, true, true, true, true, true, true).is_none());
|
assert!(
|
||||||
|
crate::queries::files::export_file(&id, true, true, true, true, true, true, false)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
crate::queries::coordinates::segment_coordinates(&id).length(),
|
crate::queries::coordinates::segment_coordinates(&id).length(),
|
||||||
0
|
0
|
||||||
@@ -265,7 +268,8 @@ fn test_export_and_exportable_data() {
|
|||||||
let ids = order();
|
let ids = order();
|
||||||
|
|
||||||
let bytes =
|
let bytes =
|
||||||
crate::queries::files::export_file(&ids[0], true, true, true, true, true, true).unwrap();
|
crate::queries::files::export_file(&ids[0], true, true, true, true, true, true, false)
|
||||||
|
.unwrap();
|
||||||
assert!(String::from_utf8(bytes).unwrap().contains("<gpx"));
|
assert!(String::from_utf8(bytes).unwrap().contains("<gpx"));
|
||||||
|
|
||||||
// the time is the first bit
|
// the time is the first bit
|
||||||
@@ -284,6 +288,23 @@ fn test_export_and_exportable_data() {
|
|||||||
assert_eq!(crate::queries::files::exportable_data(&[1, 2, 3]), 0);
|
assert_eq!(crate::queries::files::exportable_data(&[1, 2, 3]), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen_test]
|
||||||
|
fn test_export_as_route() {
|
||||||
|
started_with(&[(SIMPLE, "plain")]);
|
||||||
|
let id = order().remove(0);
|
||||||
|
let text = |as_route| {
|
||||||
|
let bytes =
|
||||||
|
crate::queries::files::export_file(&id, true, true, true, true, true, true, as_route)
|
||||||
|
.unwrap();
|
||||||
|
String::from_utf8(bytes).unwrap()
|
||||||
|
};
|
||||||
|
let tracks = text(false);
|
||||||
|
assert!(tracks.contains("<trk>") && !tracks.contains("<rte>"));
|
||||||
|
let routes = text(true);
|
||||||
|
assert!(routes.contains("<rte>") && !routes.contains("<trk>"));
|
||||||
|
assert!(routes.contains("<rtept"));
|
||||||
|
}
|
||||||
|
|
||||||
#[wasm_bindgen_test]
|
#[wasm_bindgen_test]
|
||||||
fn test_select_and_reorder_files() {
|
fn test_select_and_reorder_files() {
|
||||||
started_with(&[(SIMPLE, "a"), (WITH_TIME, "b")]);
|
started_with(&[(SIMPLE, "a"), (WITH_TIME, "b")]);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
Orbit,
|
Orbit,
|
||||||
Thermometer,
|
Thermometer,
|
||||||
SquareActivity,
|
SquareActivity,
|
||||||
|
Route,
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { engine, type ExportOptions } from '$lib/engine';
|
import { engine, type ExportOptions } from '$lib/engine';
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
atemp: true,
|
atemp: true,
|
||||||
power: true,
|
power: true,
|
||||||
osm: false,
|
osm: false,
|
||||||
|
asRoute: false,
|
||||||
});
|
});
|
||||||
// the files that are exported
|
// the files that are exported
|
||||||
let fileIds: string[] = $derived.by(() => {
|
let fileIds: string[] = $derived.by(() => {
|
||||||
@@ -61,6 +63,8 @@
|
|||||||
atemp: !available.atemp,
|
atemp: !available.atemp,
|
||||||
power: !available.power,
|
power: !available.power,
|
||||||
osm: !available.osm,
|
osm: !available.osm,
|
||||||
|
// always possible
|
||||||
|
asRoute: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -113,6 +117,7 @@
|
|||||||
atemp: exportOptions.atemp && !hide.atemp,
|
atemp: exportOptions.atemp && !hide.atemp,
|
||||||
power: exportOptions.power && !hide.power,
|
power: exportOptions.power && !hide.power,
|
||||||
osm: exportOptions.osm && !hide.osm,
|
osm: exportOptions.osm && !hide.osm,
|
||||||
|
asRoute: exportOptions.asRoute,
|
||||||
};
|
};
|
||||||
if (exportState.current === ExportState.SELECTION) {
|
if (exportState.current === ExportState.SELECTION) {
|
||||||
exportSelectedFiles(options);
|
exportSelectedFiles(options);
|
||||||
@@ -192,6 +197,13 @@
|
|||||||
{i18n._('quantities.osm_extensions')}
|
{i18n._('quantities.osm_extensions')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-row items-center gap-1.5">
|
||||||
|
<Checkbox id="export-as-route" bind:checked={exportOptions.asRoute} />
|
||||||
|
<Label for="export-as-route" class="flex flex-row items-center gap-1">
|
||||||
|
<Route size="16" />
|
||||||
|
{i18n._('menu.export_as_route', 'Export as route')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ import { readFileSync } from 'node:fs';
|
|||||||
import { beforeEach, describe, it } from 'node:test';
|
import { beforeEach, describe, it } from 'node:test';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { isHidden } from '$lib/file-visibility';
|
import { isHidden } from '$lib/file-visibility';
|
||||||
import { engine, FileStateCollectionObserver, NO_TIME, type FileState } from '$lib/engine';
|
import {
|
||||||
|
ALL_EXPORT_OPTIONS,
|
||||||
|
engine,
|
||||||
|
FileStateCollectionObserver,
|
||||||
|
NO_TIME,
|
||||||
|
type ExportOptions,
|
||||||
|
type FileState,
|
||||||
|
} from '$lib/engine';
|
||||||
|
|
||||||
function fixture(name: string): Uint8Array {
|
function fixture(name: string): Uint8Array {
|
||||||
return new Uint8Array(
|
return new Uint8Array(
|
||||||
@@ -387,6 +394,49 @@ describe('observer of the files', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('export', () => {
|
||||||
|
const text = (id: string, options: Partial<ExportOptions> = {}) =>
|
||||||
|
new TextDecoder().decode(engine.exportFile(id, { ...ALL_EXPORT_OPTIONS, ...options }));
|
||||||
|
|
||||||
|
it('writes tracks, or routes when asked to', async () => {
|
||||||
|
await load('with_tracks_and_segments');
|
||||||
|
const file = fileStates()[0];
|
||||||
|
const segments = file.structure.tracks.reduce(
|
||||||
|
(sum, track) => sum + track.segments.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const id = file.structure.id;
|
||||||
|
|
||||||
|
const tracks = text(id);
|
||||||
|
assert.ok(tracks.includes('<trk>') && !tracks.includes('<rte>'));
|
||||||
|
assert.equal(tracks.match(/<trkseg>/g)?.length, segments);
|
||||||
|
|
||||||
|
const routes = text(id, { asRoute: true });
|
||||||
|
assert.ok(routes.includes('<rte>') && !routes.includes('<trk>'));
|
||||||
|
assert.equal(routes.match(/<rte>/g)?.length, segments);
|
||||||
|
assert.ok(routes.includes('<rtept'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not count the way a file is written among its data', async () => {
|
||||||
|
await load('simple');
|
||||||
|
const data = engine.exportableData(get(engine.order));
|
||||||
|
assert.equal(data.asRoute, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can read back the routes it writes', async () => {
|
||||||
|
await load('with_tracks_and_segments');
|
||||||
|
const id = get(engine.order)[0];
|
||||||
|
const before = fileStates()[0].segments.features.length;
|
||||||
|
const routes = engine.exportFile(id, { ...ALL_EXPORT_OPTIONS, asRoute: true })!;
|
||||||
|
await engine.loadFiles([{ data: routes, name: 'routes' }]);
|
||||||
|
const [, loaded] = get(engine.order);
|
||||||
|
const state = get(get(engine.files).get(loaded)!);
|
||||||
|
// one route per segment, each read as a track of one segment
|
||||||
|
assert.equal(state.structure.tracks.length, before);
|
||||||
|
assert.equal(state.segments.features.length, before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('visibility', () => {
|
describe('visibility', () => {
|
||||||
it('hides the selection and shows it again', async () => {
|
it('hides the selection and shows it again', async () => {
|
||||||
await load('simple');
|
await load('simple');
|
||||||
|
|||||||
@@ -509,7 +509,8 @@ class Engine {
|
|||||||
options.cad,
|
options.cad,
|
||||||
options.atemp,
|
options.atemp,
|
||||||
options.power,
|
options.power,
|
||||||
options.osm
|
options.osm,
|
||||||
|
options.asRoute
|
||||||
) as Uint8Array<ArrayBuffer> | undefined;
|
) as Uint8Array<ArrayBuffer> | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,6 +524,8 @@ class Engine {
|
|||||||
atemp: (bits & 8) !== 0,
|
atemp: (bits & 8) !== 0,
|
||||||
power: (bits & 16) !== 0,
|
power: (bits & 16) !== 0,
|
||||||
osm: (bits & 32) !== 0,
|
osm: (bits & 32) !== 0,
|
||||||
|
// not a data
|
||||||
|
asRoute: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,11 @@ export type ExportOptions = {
|
|||||||
power: boolean;
|
power: boolean;
|
||||||
/** The OpenStreetMap data: surface, highway, SAC scale and MTB scale. */
|
/** The OpenStreetMap data: surface, highway, SAC scale and MTB scale. */
|
||||||
osm: boolean;
|
osm: boolean;
|
||||||
|
/**
|
||||||
|
* Writes the segments as routes instead of tracks: the anchors are the route points, and the
|
||||||
|
* trackpoints between them the points of their path (which only keep their position).
|
||||||
|
*/
|
||||||
|
asRoute: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ALL_EXPORT_OPTIONS: ExportOptions = {
|
export const ALL_EXPORT_OPTIONS: ExportOptions = {
|
||||||
@@ -126,6 +131,7 @@ export const ALL_EXPORT_OPTIONS: ExportOptions = {
|
|||||||
atemp: true,
|
atemp: true,
|
||||||
power: true,
|
power: true,
|
||||||
osm: true,
|
osm: true,
|
||||||
|
asRoute: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"export": "Export...",
|
"export": "Export...",
|
||||||
"export_all": "Export all...",
|
"export_all": "Export all...",
|
||||||
"export_options": "Export options",
|
"export_options": "Export options",
|
||||||
|
"export_as_route": "Export as route",
|
||||||
"support_message": "The tool is free to use, but not free to run. Please consider supporting the website if you use it frequently. Thank you!",
|
"support_message": "The tool is free to use, but not free to run. Please consider supporting the website if you use it frequently. Thank you!",
|
||||||
"support_button": "Help keep the website free",
|
"support_button": "Help keep the website free",
|
||||||
"download_file": "Download file",
|
"download_file": "Download file",
|
||||||
|
|||||||
Reference in new issue
Block a user