4 Commits
Author SHA1 Message Date
vcoppe 45f877ec78 route export, filter out anchors at high zoom level 2026-10-26 19:27:12 +01:00
vcoppe 0f957fb1f2 create route file action 2026-10-26 19:22:14 +01:00
vcoppe 764d0b233a route - track conversion 2026-10-26 19:06:19 +01:00
vcoppe b6c0f5190a route full support 2026-10-26 18:51:56 +01:00
136 changed files with 2942 additions and 845 deletions

No files matched your search

+37 -6
View File
@@ -49,7 +49,7 @@ pub fn select_elements<H: History>(
}
/// Selects all the elements of the same kind as the selected ones, in the same place: all the
/// files, the tracks of the file, the segments of the track, the waypoints of the file. All the
/// files, the routes or the tracks of the file, the segments of the track, the waypoints of the file. All the
/// files when nothing is selected.
pub fn select_all<H: History>(engine: &mut Engine<H>) -> Outcome {
execute(engine, Action::SelectAll)
@@ -62,6 +62,19 @@ pub fn arrow_select<H: History>(engine: &mut Engine<H>, down: bool, add: bool) -
execute(engine, Action::ArrowSelect { down, add })
}
pub fn select_routes<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
route_ids: &[Uuid],
mode: engine::SelectMode,
) -> Outcome {
let selection = engine::Selection::Route {
file_id: engine::FileId(file_id),
rte_ids: route_ids.iter().copied().map(engine::RouteId).collect(),
};
select_elements(engine, selection, mode)
}
pub fn select_tracks<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
@@ -135,21 +148,30 @@ pub fn redo<H: History>(engine: &mut Engine<H>) -> Outcome {
// Files
/// Creates a file and selects it. With `lng` and `lat`, it starts with a track and a segment that
/// hold that trackpoint (at `ele`, 0 by default).
/// Creates a file and selects it. With `route`, it starts with a route. With `lng` and `lat`, it
/// starts with a segment that holds that trackpoint (at `ele`, 0 by default): the one of its route
/// with `route`, of a track otherwise.
pub fn new_file<H: History>(
engine: &mut Engine<H>,
name: &str,
lng: Option<f64>,
lat: Option<f64>,
ele: Option<f64>,
route: bool,
) -> Outcome {
let trackpoint = lng.zip(lat).map(|(lng, lat)| engine::NewTrackpoint {
lng,
lat,
ele: ele.unwrap_or_default(),
});
edit(engine, Command::New(engine::New { name, trackpoint }))
edit(
engine,
Command::New(engine::New {
name,
trackpoint,
route,
}),
)
}
/// Loads files as a single command (one undo step). The files are in `data`, one after the other,
@@ -304,6 +326,15 @@ pub fn style<H: History>(
)
}
/// Converts the selected routes to tracks, or the selected tracks or segments to routes.
pub fn convert<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::Convert(engine::Convert))
}
pub fn new_route<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::NewRoute(engine::NewRoute))
}
pub fn new_track<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::NewTrack(engine::NewTrack))
}
@@ -575,7 +606,7 @@ pub fn route<H: History>(
at_revision(engine, revision, |engine| {
edit(
engine,
Command::Route(engine::Route {
Command::Reroute(engine::Reroute {
start,
end,
lng,
@@ -618,7 +649,7 @@ pub fn change_loop_start<H: History>(engine: &mut Engine<H>, revision: u32, inde
})
}
/// Cuts the file, the track or the segment (see `SplitType`) of the trackpoint `at` of the
/// Cuts the file, the route, the track or the segment (see `SplitType`) of the trackpoint `at` of the
/// selection in two, there: the trackpoint ends the first part and starts the second one. A file that
/// is cut is kept (and its two parts are new files) if the history needs it, see
/// [`History::KEEPS_ORIGINALS`].
+74 -16
View File
@@ -131,6 +131,10 @@ pub enum Selection {
File {
file_ids: Vec<Uuid>,
},
Route {
file_id: Uuid,
route_ids: Vec<Uuid>,
},
Track {
file_id: Uuid,
track_ids: Vec<Uuid>,
@@ -157,6 +161,10 @@ impl From<&engine::Selection> for Selection {
S::File { file_ids } => Self::File {
file_ids: file_ids.iter().map(|id| id.0).collect(),
},
S::Route { file_id, rte_ids } => Self::Route {
file_id: file_id.0,
route_ids: rte_ids.iter().map(|id| id.0).collect(),
},
S::Track { file_id, trk_ids } => Self::Track {
file_id: file_id.0,
track_ids: trk_ids.iter().map(|id| id.0).collect(),
@@ -187,6 +195,10 @@ impl From<Selection> for engine::Selection {
Selection::File { file_ids } => S::File {
file_ids: file_ids.into_iter().map(FileId).collect(),
},
Selection::Route { file_id, route_ids } => S::Route {
file_id: FileId(file_id),
rte_ids: route_ids.into_iter().map(engine::RouteId).collect(),
},
Selection::Track { file_id, track_ids } => S::Track {
file_id: FileId(file_id),
trk_ids: track_ids.into_iter().map(engine::TrackId).collect(),
@@ -229,6 +241,10 @@ pub enum MoveTarget {
Files {
index: f64,
},
Routes {
file_id: Uuid,
index: f64,
},
Tracks {
file_id: Uuid,
index: f64,
@@ -250,6 +266,10 @@ impl From<MoveTarget> for engine::MoveTarget {
let index = |index: f64| index.max(0.0) as usize;
match target {
MoveTarget::Files { index: i } => T::Files { index: index(i) },
MoveTarget::Routes { file_id, index: i } => T::Routes {
file_id: FileId(file_id),
index: index(i),
},
MoveTarget::Tracks { file_id, index: i } => T::Tracks {
file_id: FileId(file_id),
index: index(i),
@@ -286,6 +306,9 @@ impl Clipboard {
engine::ClipboardIds::Files(ids) => {
("files", strings(ids.into_iter().map(|id| id.0).collect()))
}
engine::ClipboardIds::Routes(ids) => {
("routes", strings(ids.into_iter().map(|id| id.0).collect()))
}
engine::ClipboardIds::Tracks(ids) => {
("tracks", strings(ids.into_iter().map(|id| id.0).collect()))
}
@@ -315,14 +338,15 @@ pub struct FileStructure {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
desc: Option<String>,
routes: Vec<RouteNode>,
tracks: Vec<TrackNode>,
waypoints: Vec<WaypointNode>,
waypoints_rev: String,
}
/// The name, description and style of a route or a track.
#[derive(Serialize)]
struct TrackNode {
id: String,
struct PathInfo {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -333,6 +357,33 @@ struct TrackNode {
opacity: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
width: Option<f64>,
}
impl From<&engine::PathInfo> for PathInfo {
fn from(info: &engine::PathInfo) -> Self {
Self {
name: info.name.clone(),
desc: info.desc.clone(),
color: info.color.clone(),
opacity: info.opacity,
width: info.width,
}
}
}
#[derive(Serialize)]
struct RouteNode {
id: String,
#[serde(flatten)]
info: PathInfo,
segment: SegmentNode,
}
#[derive(Serialize)]
struct TrackNode {
id: String,
#[serde(flatten)]
info: PathInfo,
segments: Vec<SegmentNode>,
}
@@ -343,6 +394,16 @@ struct SegmentNode {
length: f64,
}
impl From<&engine::SegmentNode> for SegmentNode {
fn from(seg: &engine::SegmentNode) -> Self {
Self {
id: seg.id.0.to_string(),
rev: seg.rev_id.0.to_string(),
length: seg.len as f64,
}
}
}
#[derive(Serialize)]
struct WaypointNode {
id: String,
@@ -358,25 +419,22 @@ impl FileStructure {
id: file.id.0.to_string(),
name: file.name.clone(),
desc: file.desc.clone(),
routes: file
.routes
.iter()
.map(|rte| RouteNode {
id: rte.id.0.to_string(),
info: (&rte.info).into(),
segment: (&rte.segment).into(),
})
.collect(),
tracks: file
.tracks
.iter()
.map(|trk| TrackNode {
id: trk.id.0.to_string(),
name: trk.name.clone(),
desc: trk.desc.clone(),
color: trk.color.clone(),
opacity: trk.opacity,
width: trk.width,
segments: trk
.segments
.iter()
.map(|seg| SegmentNode {
id: seg.id.0.to_string(),
rev: seg.rev_id.0.to_string(),
length: seg.len as f64,
})
.collect(),
info: (&trk.info).into(),
segments: trk.segments.iter().map(Into::into).collect(),
})
.collect(),
waypoints: file
+1 -3
View File
@@ -76,9 +76,7 @@ pub fn trackpoint<H: History>(
}
/// A file as GPX (UTF-8 bytes), `None` if it does not exist. The options say which data of the
/// trackpoints 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.
/// trackpoints to keep.
pub fn export_file<H: History>(
engine: &Engine<H>,
file_id: Uuid,
+14 -1
View File
@@ -6,7 +6,7 @@
//! between them are kept: every part comes after the previous one in the new order, as late as the
//! original one came after the part that followed it.
use crate::{Track, TrackSegment, Trackpoint};
use crate::{Route, Track, TrackSegment, Trackpoint};
/// The time of the first trackpoint of a segment.
pub fn start_time(segment: &TrackSegment) -> Option<i64> {
@@ -94,6 +94,19 @@ pub fn reverse_segments(
}
}
/// Reverses the routes of a file, and their trackpoints, like the segments of a track (see
/// [`reverse_segments`]).
pub fn reverse_routes(routes: &mut [Route], original_next: Option<i64>, new_previous: Option<i64>) {
routes.reverse();
let (mut next, mut previous) = (original_next, new_previous);
for route in routes.iter_mut() {
let original_start = start_time(&route.seg);
reverse_segment(&mut route.seg, next, previous);
next = original_start;
previous = end_time(&route.seg);
}
}
/// Reverses the tracks of a file, and their segments and trackpoints.
pub fn reverse_tracks(tracks: &mut [Track], original_next: Option<i64>, new_previous: Option<i64>) {
tracks.reverse();
+51 -1
View File
@@ -1,4 +1,4 @@
use crate::{Link, Track, Waypoints};
use crate::{Link, Route, Track, TrackSegment, Waypoints};
use super::common::uuid_id;
@@ -8,6 +8,9 @@ uuid_id!(FileId);
pub struct File {
pub id: FileId,
pub info: FileInfo,
/// The routes come before the tracks, as in GPX: everything that goes through the segments
/// of a file (statistics, the elevation profile...) takes the ones of the routes first.
pub rte: Vec<Route>,
pub trk: Vec<Track>,
pub wpt: Waypoints,
}
@@ -29,3 +32,50 @@ pub struct Author {
/// The author has a single link, unlike the other elements.
pub link: Option<Link>,
}
/// Where a segment is in its file: the one of a route, or one of a track.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SegmentIndex {
Route(usize),
Track { trk: usize, seg: usize },
}
impl File {
/// The segments of the routes, then the ones of the tracks.
pub fn segments(&self) -> impl Iterator<Item = &TrackSegment> {
self.rte
.iter()
.map(|rte| &rte.seg)
.chain(self.trk.iter().flat_map(|trk| &trk.trkseg))
}
/// Where the segments are, in the order of [`File::segments`].
pub fn segment_indices(&self) -> impl Iterator<Item = SegmentIndex> + '_ {
(0..self.rte.len())
.map(SegmentIndex::Route)
.chain(self.trk.iter().enumerate().flat_map(|(trk, track)| {
(0..track.trkseg.len()).map(move |seg| SegmentIndex::Track { trk, seg })
}))
}
/// The segment at `index`. Panics if there is none.
pub fn segment(&self, index: SegmentIndex) -> &TrackSegment {
match index {
SegmentIndex::Route(rte) => &self.rte[rte].seg,
SegmentIndex::Track { trk, seg } => &self.trk[trk].trkseg[seg],
}
}
/// The segment at `index`. Panics if there is none.
pub fn segment_mut(&mut self, index: SegmentIndex) -> &mut TrackSegment {
match index {
SegmentIndex::Route(rte) => &mut self.rte[rte].seg,
SegmentIndex::Track { trk, seg } => &mut self.trk[trk].trkseg[seg],
}
}
/// Whether the file has no route nor track.
pub fn has_no_path(&self) -> bool {
self.rte.is_empty() && self.trk.is_empty()
}
}
+2
View File
@@ -3,6 +3,7 @@ mod chunk;
mod chunked;
mod common;
mod file;
mod route;
mod segment;
mod track;
mod trackpoint;
@@ -14,6 +15,7 @@ pub use chunk::*;
pub use chunked::*;
pub use common::*;
pub use file::*;
pub use route::*;
pub use segment::*;
pub use track::*;
pub use trackpoint::*;
+15
View File
@@ -0,0 +1,15 @@
use crate::{TrackInfo, TrackSegment};
use super::common::uuid_id;
uuid_id!(RouteId);
/// A route of the file (`rte`): like a track, but with a single segment, its points.
///
/// It has the information of a track: GPX gives the same to both.
#[derive(Debug, Default, Clone)]
pub struct Route {
pub id: RouteId,
pub info: TrackInfo,
pub seg: TrackSegment,
}
+43 -44
View File
@@ -1,6 +1,6 @@
use crate::{
Author, Chunk, File, Link, LngLat, Track, TrackSegment, Trackpoint, TrackpointCategories,
TrackpointChunk, Waypoint, WaypointChunk, distance,
Author, Chunk, File, Link, LngLat, Route, Track, TrackSegment, Trackpoint,
TrackpointCategories, TrackpointChunk, Waypoint, WaypointChunk, distance,
};
use chrono::DateTime;
use quick_xml::Error;
@@ -18,6 +18,7 @@ enum GPXElement {
Author(Author),
Link(Link),
Text,
/// A track, or a route, whose information is read the same way.
Track(Track),
Segment(TrackSegment),
Trackpoint(Trackpoint),
@@ -396,7 +397,7 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
let mut stack: Vec<GPXElement> = vec![];
let mut trkpt_chunk = TrackpointChunk::default();
let mut wpt_chunk = WaypointChunk::default();
// Routes are read as tracks of a single segment, see `RouteReader`. The points of the detailed
// The segment of a route is read by `RouteReader`. The points of the detailed
// path that a route point comes with (`gpxx:rpt`) are collected until the route point is read.
let mut route = RouteReader::default();
let mut rpt_points: Vec<Trackpoint> = vec![];
@@ -530,12 +531,12 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
}
}
"rte" => {
if let Some(GPXElement::Track(mut trk)) = stack.pop() {
let trkseg = std::mem::take(&mut route).finish();
if !trkseg.is_empty() {
trk.trkseg.push(trkseg);
}
gpx.trk.push(trk);
if let Some(GPXElement::Track(trk)) = stack.pop() {
gpx.rte.push(Route {
info: trk.info,
seg: std::mem::take(&mut route).finish(),
..Default::default()
});
}
}
"rtept" => {
@@ -740,38 +741,37 @@ mod tests {
}
#[test]
fn test_parse_routes_as_tracks() {
fn test_parse_routes() {
let gpx = parse_data("with_routes");
assert_eq!(gpx.trk.len(), 2);
let trk = &gpx.trk[0];
assert_eq!(trk.info.name.as_deref(), Some("route 1"));
assert_eq!(trk.info.type_.as_deref(), Some("Cycling"));
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert_eq!(trkseg.len(), 49);
let trkpt = &trkseg[0];
assert!(gpx.trk.is_empty());
assert_eq!(gpx.rte.len(), 2);
let rte = &gpx.rte[0];
assert_eq!(rte.info.name.as_deref(), Some("route 1"));
assert_eq!(rte.info.type_.as_deref(), Some("Cycling"));
assert_eq!(rte.seg.len(), 49);
let trkpt = &rte.seg[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_eq!(trk.info.name.as_deref(), Some("route 2"));
assert_eq!(trk.trkseg[0].len(), 28);
let rte = &gpx.rte[1];
assert_eq!(rte.info.name.as_deref(), Some("route 2"));
assert_eq!(rte.seg.len(), 28);
assert_ne!(gpx.rte[0].id, rte.id);
}
#[test]
fn test_parse_route_point_extensions() {
let gpx = parse_data("with_route_extensions");
// a route without points has no segment
assert_eq!(gpx.trk.len(), 2);
assert!(gpx.trk[1].trkseg.is_empty());
// a route without points has an empty segment
assert_eq!(gpx.rte.len(), 2);
assert!(gpx.rte[1].seg.is_empty());
let trk = &gpx.trk[0];
assert_eq!(trk.info.name.as_deref(), Some("with extensions"));
assert_eq!(trk.trkseg.len(), 1);
let points: Vec<_> = trk.trkseg[0].iter().collect();
let rte = &gpx.rte[0];
assert_eq!(rte.info.name.as_deref(), Some("with extensions"));
let points: Vec<_> = rte.seg.iter().collect();
// the route points are kept, the points of the detailed path follow the one they come with
let coordinates: Vec<_> = points
.iter()
@@ -788,14 +788,13 @@ mod tests {
#[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();
let anchors: Vec<_> = gpx.rte[0].seg.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)));
for rte in &gpx.rte {
assert!(rte.seg.iter().all(|p| p.anchor == Some(0)));
}
// while a track gets the anchors that its shape needs
let track = parse_data("with_tracks");
@@ -807,14 +806,14 @@ mod tests {
#[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"));
assert_eq!(gpx.rte.len(), 1);
let rte = &gpx.rte[0];
assert_eq!(rte.info.name.as_deref(), Some("Lausanne"));
assert_eq!(rte.info.cmt.as_deref(), Some("comment"));
assert_eq!(rte.info.desc.as_deref(), Some("description"));
assert_eq!(rte.info.type_.as_deref(), Some("Cycling"));
let points: Vec<_> = trk.trkseg[0].iter().cloned().collect();
let points: Vec<_> = rte.seg.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();
@@ -846,7 +845,7 @@ mod tests {
</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 points: Vec<_> = gpx.rte[0].seg.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
@@ -861,7 +860,7 @@ mod tests {
<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();
let anchors: Vec<_> = gpx.rte[0].seg.iter().map(|p| p.anchor).collect();
assert_eq!(anchors, [Some(0), Some(0)]);
}
@@ -930,14 +929,14 @@ mod tests {
assert_eq!(waypoints[0].links[2].text.as_deref(), Some("three"));
assert!(waypoints[1].links.is_empty());
// tracks, and routes which are read as tracks
assert_eq!(gpx.trk.len(), 2);
// tracks, and routes
assert_eq!((gpx.trk.len(), gpx.rte.len()), (1, 1));
assert_eq!(
hrefs(&gpx.trk[0].info.links),
["https://example.com/trk-1", "https://example.com/trk-2"]
);
assert_eq!(
hrefs(&gpx.trk[1].info.links),
hrefs(&gpx.rte[0].info.links),
["https://example.com/rte-1", "https://example.com/rte-2"]
);
}
+140 -115
View File
@@ -8,9 +8,6 @@ use crate::{File, Link, Track, Trackpoint, TrackpointCategories, Waypoint};
/// 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`].
///
/// 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)]
pub struct ExportOptions {
pub time: bool,
@@ -20,8 +17,6 @@ pub struct ExportOptions {
pub power: bool,
/// The OpenStreetMap data: surface, highway, SAC scale and MTB scale.
pub osm: bool,
/// Writes every segment as a route (`rte`) instead of a track.
pub as_route: bool,
}
impl ExportOptions {
@@ -36,7 +31,6 @@ impl ExportOptions {
atemp: value,
power: value,
osm: value,
as_route: false,
}
}
@@ -49,7 +43,6 @@ impl ExportOptions {
atemp: self.atemp || other.atemp,
power: self.power || other.power,
osm: self.osm || other.osm,
as_route: self.as_route || other.as_route,
}
}
}
@@ -59,12 +52,7 @@ impl File {
/// options of [`write`] can leave out.
pub fn exportable_data(&self) -> ExportOptions {
let mut data = ExportOptions::NONE;
for point in self
.trk
.iter()
.flat_map(|trk| &trk.trkseg)
.flat_map(|seg| seg.iter())
{
for point in self.segments().flat_map(|seg| seg.iter()) {
data.time |= point.time.is_some();
data.hr |= point.hr.is_some();
data.cad |= point.cad.is_some();
@@ -82,6 +70,10 @@ impl File {
}
}
/// The highest zoom level from which an anchor is shown that makes it a route point, see [`write`]:
/// the anchors that only appear when zooming in further are details of the path.
pub const ROUTE_POINT_MAX_ZOOM: u8 = 12;
const SCHEMA_LOCATION: &str = "http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd";
type XmlWriter = Writer<Vec<u8>>;
@@ -89,10 +81,10 @@ 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
/// 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`.
/// The routes are written as routes (`rte`): the anchors of their segment that are shown from the
/// zoom level [`ROUTE_POINT_MAX_ZOOM`] or a lower one are the `rtept`, and the trackpoints between
/// two of them 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> {
let mut writer = Writer::new_with_indent(Vec::new(), b' ', 4);
// writing to memory does not fail
@@ -100,9 +92,9 @@ pub fn write(file: &File, categories: &TrackpointCategories, options: ExportOpti
writer.into_inner()
}
fn write_file(
fn write_file<'a>(
w: &mut XmlWriter,
file: &File,
file: &'a File,
categories: &TrackpointCategories,
options: ExportOptions,
) -> io::Result<()> {
@@ -136,20 +128,18 @@ fn write_file(
for wpt in file.wpt.iter() {
write_waypoint(w, wpt, options)?;
}
for trk in &file.trk {
// a single unnamed track is named like the file
let name = match (&trk.info.name, file.trk.len()) {
(Some(name), _) if !name.is_empty() => Some(name.as_str()),
(_, 1) => Some(file.info.name.as_str()),
// a single unnamed route or track is named like the file
let alone = file.rte.len() + file.trk.len() == 1;
let name = |info: &'a crate::TrackInfo| match &info.name {
Some(name) if !name.is_empty() => Some(name.as_str()),
_ if alone => Some(file.info.name.as_str()),
_ => None,
};
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)?;
for rte in &file.rte {
write_route(w, &rte.info, &rte.seg, name(&rte.info), categories, options)?;
}
for trk in &file.trk {
write_track(w, trk, name(&trk.info), categories, options)?;
}
Ok(())
})?;
@@ -311,22 +301,28 @@ fn write_track(
fn write_route(
w: &mut XmlWriter,
trk: &Track,
info: &crate::TrackInfo,
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)?;
write_track_info(w, name, 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 last = points.len().saturating_sub(1);
let is_route_point = |i: usize| {
i == 0
|| i == last
|| points[i]
.anchor
.is_some_and(|zoom| zoom <= ROUTE_POINT_MAX_ZOOM)
};
let mut i = 0;
while i < points.len() {
// the trackpoints up to the next anchor
// the trackpoints up to the next route point
let mut next = i + 1;
while next < points.len() && !is_anchor(next) {
while next < points.len() && !is_route_point(next) {
next += 1;
}
write_point(
@@ -455,9 +451,7 @@ mod tests {
}
fn points(file: &File) -> Vec<Trackpoint> {
file.trk
.iter()
.flat_map(|trk| &trk.trkseg)
file.segments()
.flat_map(|seg| seg.iter().cloned())
.collect()
}
@@ -595,11 +589,20 @@ mod tests {
assert!(file.exportable_data().power);
}
fn as_route() -> ExportOptions {
ExportOptions {
as_route: true,
..ExportOptions::ALL
}
/// The file with each segment of its tracks as a route, which has the information of its
/// track.
fn as_routes(mut file: File) -> File {
file.rte = std::mem::take(&mut file.trk)
.into_iter()
.flat_map(|trk| {
trk.trkseg.into_iter().map(move |seg| crate::Route {
info: trk.info.clone(),
seg,
..Default::default()
})
})
.collect();
file
}
fn count(text: &str, pattern: &str) -> usize {
@@ -607,19 +610,17 @@ mod tests {
}
#[test]
fn test_export_as_route_writes_routes_instead_of_tracks() {
fn test_routes_are_written_with_their_route_points_and_their_path() {
let (file, categories) = read("with_route_points");
let text = written_text(&file, &categories, as_route());
let text = written_text(&file, &categories, ExportOptions::ALL);
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>"));
@@ -634,14 +635,14 @@ mod tests {
}
#[test]
fn test_a_route_survives_being_exported_as_a_route() {
fn test_a_route_survives_being_written() {
let (file, mut categories) = read("with_route_points");
let written = write(&file, &categories, as_route());
let written = write(&file, &categories, ExportOptions::ALL);
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!(again.rte.len(), 1);
assert_eq!(again.rte[0].info, file.rte[0].info);
let (a, b) = (&file.rte[0].seg, &again.rte[0].seg);
assert_eq!(a.len(), b.len());
for (a, b) in a.iter().zip(b.iter()) {
assert_eq!(a.coordinates.lat, b.coordinates.lat);
@@ -659,85 +660,121 @@ mod tests {
}
#[test]
fn test_tracks_become_one_route_per_segment_with_the_information_of_the_track() {
fn test_the_anchors_shown_up_to_zoom_12_are_the_route_points() {
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 file = as_routes(file);
assert!(file.rte.len() > 2);
let written = write(&file, &categories, ExportOptions::ALL);
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| {
assert_eq!(again.rte.len(), file.rte.len());
let anchors = |s: &crate::TrackSegment, max_zoom: u8| {
s.iter()
.enumerate()
.filter(|(_, p)| p.anchor.is_some())
.filter(|(_, p)| p.anchor.is_some_and(|zoom| zoom <= max_zoom))
.map(|(i, _)| i)
.collect::<Vec<_>>()
};
assert_eq!(anchors(a), anchors(b));
}
for (a, b) in file.rte.iter().zip(&again.rte) {
assert_eq!(a.info.name, b.info.name);
assert_eq!(a.seg.len(), b.seg.len());
// the route points come back as anchors, the other anchors as points of the path
assert_eq!(
anchors(&a.seg, ROUTE_POINT_MAX_ZOOM),
anchors(&b.seg, u8::MAX)
);
}
}
#[test]
fn test_anchors_shown_from_a_higher_zoom_are_points_of_the_path() {
let (file, categories) = read("simple");
let mut file = as_routes(file);
let segment = &mut file.rte[0].seg;
segment.update_all(|_, point| point.anchor = None);
let len = segment.len();
segment.set_anchor(5, ROUTE_POINT_MAX_ZOOM);
segment.set_anchor(10, ROUTE_POINT_MAX_ZOOM + 1);
let text = written_text(&file, &categories, ExportOptions::ALL);
// the ends, and the anchor shown from zoom 12
assert_eq!(count(&text, "<rtept"), 3);
assert_eq!(count(&text, "<gpxx:rpt"), len - 3);
}
#[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());
let (file, categories) = read("simple");
let mut file = as_routes(file);
file.rte[0].seg.update_all(|_, point| point.anchor = None);
let len = file.rte[0].seg.len();
let text = written_text(&file, &categories, ExportOptions::ALL);
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());
file.rte[0].seg.splice(1, len, vec![]);
let text = written_text(&file, &categories, ExportOptions::ALL);
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() {
fn test_routes_keep_their_style() {
let (file, categories) = read("with_style");
let text = written_text(&file, &categories, as_route());
let file = as_routes(file);
let text = written_text(&file, &categories, ExportOptions::ALL);
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);
assert_eq!(again.rte[0].info.color, file.rte[0].info.color);
assert_eq!(again.rte[0].info.width, file.rte[0].info.width);
}
#[test]
fn test_routes_are_written_as_routes_before_the_tracks() {
let (mut file, categories) = read("with_tracks");
let (routes, _) = read("with_routes");
file.rte = routes.rte.clone();
let text = written_text(&file, &categories, ExportOptions::ALL);
assert_eq!(count(&text, "<rte>"), routes.rte.len());
assert_eq!(count(&text, "<trk>"), file.trk.len());
assert!(text.rfind("</rte>").unwrap() < text.find("<trk>").unwrap());
let again = parse(text.as_bytes(), &mut categories.clone()).unwrap();
assert_eq!(again.rte.len(), file.rte.len());
assert_eq!(again.trk.len(), file.trk.len());
for (a, b) in file.rte.iter().zip(&again.rte) {
assert_eq!(a.info, b.info);
same_points(
&a.seg.iter().cloned().collect::<Vec<_>>(),
&b.seg.iter().cloned().collect::<Vec<_>>(),
);
}
same_points(&points(&file), &points(&again));
}
#[test]
fn test_an_empty_route_is_written_and_a_single_route_is_named_like_the_file() {
let mut file = File::default();
file.info.name = "file".into();
file.rte.push(crate::Route::default());
let text = written_text(&file, &Default::default(), ExportOptions::ALL);
assert_eq!(count(&text, "<rte>"), 1);
assert_eq!(count(&text, "<rtept"), 0);
let again = parse(text.as_bytes(), &mut Default::default()).unwrap();
assert_eq!(again.rte.len(), 1);
assert_eq!(again.rte[0].info.name.as_deref(), Some("file"));
// not when there are others
file.trk.push(Track::default());
let text = written_text(&file, &Default::default(), ExportOptions::ALL);
// the metadata only
assert_eq!(count(&text, "<name>file</name>"), 1);
}
#[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());
let file = as_routes(file);
let with_all = written_text(&file, &categories, ExportOptions::ALL);
assert!(with_all.contains("<gpxtpx:hr>"));
let without = written_text(
&file,
@@ -745,22 +782,10 @@ mod tests {
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);
}
}
+128
View File
@@ -0,0 +1,128 @@
use std::{collections::HashSet, rc::Rc};
use crate::{Apply, CommandError, FileId, Route, Selection, State, Track};
/// Converts the selected routes to tracks, or the selected tracks or segments to routes, in their
/// file:
///
/// - a route becomes a track with its segment. The new tracks go before the other tracks, right
/// after the routes;
/// - a track becomes one route per segment, as a route has a single segment, and goes away. A
/// selected segment becomes a route and leaves its track, which stays even if it has no segment
/// left. The routes have the information of their track, and go after the other routes.
///
/// The segments keep their ids, the new routes and tracks are selected. There is nothing to do
/// for the other selections, or for tracks without segments.
#[derive(Debug)]
pub struct Convert;
impl Apply for Convert {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let selection = state.selection.clone();
let next = match &selection {
Selection::Route { file_id, rte_ids } => {
let file = Rc::make_mut(
state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?,
);
let (converted, kept) = std::mem::take(&mut file.rte)
.into_iter()
.partition::<Vec<_>, _>(|rte| rte_ids.contains(&rte.id));
file.rte = kept;
let tracks: Vec<Track> = converted
.into_iter()
.map(|rte| Track {
id: Default::default(),
info: rte.info,
trkseg: vec![rte.seg],
})
.collect();
let trk_ids: HashSet<_> = tracks.iter().map(|trk| trk.id).collect();
file.trk.splice(0..0, tracks);
Selection::Track {
file_id: *file_id,
trk_ids,
}
}
Selection::Track { file_id, trk_ids } => {
let file = Rc::make_mut(
state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?,
);
let (converted, kept) = std::mem::take(&mut file.trk)
.into_iter()
.partition::<Vec<_>, _>(|trk| trk_ids.contains(&trk.id));
file.trk = kept;
let routes = converted.into_iter().flat_map(|trk| {
trk.trkseg.into_iter().map(move |seg| Route {
id: Default::default(),
info: trk.info.clone(),
seg,
})
});
add_routes(&mut file.rte, *file_id, routes)
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let file = Rc::make_mut(
state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?,
);
let trk = file
.trk
.iter_mut()
.find(|trk| trk.id == *trk_id)
.ok_or(CommandError::NothingToDo)?;
let (converted, kept) = std::mem::take(&mut trk.trkseg)
.into_iter()
.partition::<Vec<_>, _>(|seg| trkseg_ids.contains(&seg.id));
trk.trkseg = kept;
let info = trk.info.clone();
let routes = converted.into_iter().map(|seg| Route {
id: Default::default(),
info: info.clone(),
seg,
});
add_routes(&mut file.rte, *file_id, routes)
}
Selection::Empty
| Selection::File { .. }
| Selection::Waypoints { .. }
| Selection::Waypoint { .. } => return Err(CommandError::NothingToDo),
};
let empty = match &next {
Selection::Route { rte_ids, .. } => rte_ids.is_empty(),
Selection::Track { trk_ids, .. } => trk_ids.is_empty(),
_ => true,
};
if empty {
// nothing was converted (the changed copy of the file is dropped)
return Err(CommandError::NothingToDo);
}
*state.selection = next;
Ok(())
}
}
/// Adds the routes after the other routes, and gives the selection of them.
fn add_routes(
rte: &mut Vec<Route>,
file_id: FileId,
routes: impl Iterator<Item = Route>,
) -> Selection {
let start = rte.len();
rte.extend(routes);
Selection::Route {
file_id,
rte_ids: rte[start..].iter().map(|rte| rte.id).collect(),
}
}
+24 -7
View File
@@ -1,4 +1,6 @@
use crate::{Apply, CommandError, Edit, Editor, File, State, Track, update_selected};
use crate::{
Apply, CommandError, Edit, Editor, File, Route, State, Track, TrackInfo, update_selected,
};
#[derive(Debug)]
pub struct Metadata<'a> {
@@ -13,21 +15,33 @@ impl Apply for Metadata<'_> {
}
}
impl Metadata<'_> {
fn set(&self, info: &mut TrackInfo) -> Edit {
info.name = Some(self.name.to_owned());
info.desc = Some(self.desc.to_owned());
Edit::Changed
}
}
impl Editor for Metadata<'_> {
fn file(&mut self, file: &mut File) -> Edit {
file.info.name = self.name.to_owned();
file.info.desc = Some(self.desc.to_owned());
// a single track is named like the file
if let [track] = file.trk.as_mut_slice() {
track.info.name = Some(self.name.to_owned());
// a single route or track is named like the file
match (file.rte.as_mut_slice(), file.trk.as_mut_slice()) {
([route], []) => route.info.name = Some(self.name.to_owned()),
([], [track]) => track.info.name = Some(self.name.to_owned()),
_ => {}
}
Edit::Changed
}
fn route(&mut self, route: &mut Route) -> Edit {
self.set(&mut route.info)
}
fn track(&mut self, track: &mut Track) -> Edit {
track.info.name = Some(self.name.to_owned());
track.info.desc = Some(self.desc.to_owned());
Edit::Changed
self.set(&mut track.info)
}
}
@@ -45,12 +59,14 @@ mod tests {
New {
name: "first",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
New {
name: "second",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap(); // now selected
@@ -108,6 +124,7 @@ mod tests {
New {
name: "file",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
+4
View File
@@ -1,9 +1,13 @@
mod convert;
mod metadata;
mod new_route;
mod new_track;
mod new_track_segment;
mod style;
pub use convert::*;
pub use metadata::*;
pub use new_route::*;
pub use new_track::*;
pub use new_track_segment::*;
pub use style::*;
@@ -0,0 +1,55 @@
use crate::{Apply, CommandError, Edit, Editor, File, Route, State, update_selected};
use super::new_track::common_style;
/// Adds an empty route to the selected files, with the style that their routes and tracks agree
/// on (see [`crate::NewTrack`]).
#[derive(Debug)]
pub struct NewRoute;
impl Apply for NewRoute {
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
update_selected(state, &mut self);
Ok(())
}
}
impl Editor for NewRoute {
fn file(&mut self, file: &mut File) -> Edit {
file.rte.push(Route {
info: common_style(file),
..Default::default()
});
Edit::Changed
}
}
#[cfg(test)]
mod tests {
use crate::{New, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_new_route_has_the_style_of_the_tracks_and_an_empty_segment() {
let mut fx = Fixture::default();
New {
name: "file",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
crate::NewTrack.apply(&mut fx.state()).unwrap();
let file = std::rc::Rc::make_mut(fx.files.values_mut().next().unwrap());
file.trk[0].info.color = Some("ff0000".into());
NewRoute.apply(&mut fx.state()).unwrap();
NewRoute.apply(&mut fx.state()).unwrap();
let file = fx.files.values().next().unwrap();
assert_eq!(file.rte.len(), 2);
assert_ne!(file.rte[0].id, file.rte[1].id);
assert!(file.rte[0].seg.is_empty());
assert_eq!(file.rte[1].info.color.as_deref(), Some("ff0000"));
}
}
+34 -11
View File
@@ -1,4 +1,4 @@
use crate::{Apply, CommandError, Edit, Editor, File, State, Track, update_selected};
use crate::{Apply, CommandError, Edit, Editor, File, State, Track, TrackInfo, update_selected};
#[derive(Debug)]
pub struct NewTrack;
@@ -12,20 +12,41 @@ impl Apply for NewTrack {
impl Editor for NewTrack {
fn file(&mut self, file: &mut File) -> Edit {
let mut track = Track::default();
track.info.color = common(file, |t| &t.info.color);
track.info.opacity = common(file, |t| &t.info.opacity);
track.info.width = common(file, |t| &t.info.width);
file.trk.push(track);
file.trk.push(Track {
info: common_style(file),
..Default::default()
});
Edit::Changed
}
}
/// Returns the value of a style attribute if all existing tracks agree on it.
fn common<T: Clone + PartialEq>(file: &File, get: impl Fn(&Track) -> &Option<T>) -> Option<T> {
let mut tracks = file.trk.iter();
let first = get(tracks.next()?);
tracks.all(|t| get(t) == first).then(|| first.clone())?
/// The style that all the routes and tracks of the file agree on: each attribute is set if they
/// all have the same value for it.
pub(super) fn common_style(file: &File) -> TrackInfo {
let infos: Vec<&TrackInfo> = file
.rte
.iter()
.map(|rte| &rte.info)
.chain(file.trk.iter().map(|trk| &trk.info))
.collect();
TrackInfo {
color: common(&infos, |info| &info.color),
opacity: common(&infos, |info| &info.opacity),
width: common(&infos, |info| &info.width),
..Default::default()
}
}
/// Returns the value of a style attribute if all the infos agree on it.
fn common<T: Clone + PartialEq>(
infos: &[&TrackInfo],
get: impl Fn(&TrackInfo) -> &Option<T>,
) -> Option<T> {
let mut infos = infos.iter();
let first = get(infos.next()?);
infos
.all(|info| get(info) == first)
.then(|| first.clone())?
}
#[cfg(test)]
@@ -40,6 +61,7 @@ mod tests {
New {
name: "file",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
@@ -56,6 +78,7 @@ mod tests {
New {
name: "file",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
@@ -29,6 +29,7 @@ mod tests {
New {
name: "file",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
+16 -6
View File
@@ -1,4 +1,4 @@
use crate::{Apply, CommandError, Edit, Editor, State, Track, update_selected};
use crate::{Apply, CommandError, Edit, Editor, Route, State, Track, TrackInfo, update_selected};
#[derive(Debug)]
pub struct Style<'a> {
@@ -14,21 +14,31 @@ impl Apply for Style<'_> {
}
}
impl Editor for Style<'_> {
fn track(&mut self, track: &mut Track) -> Edit {
impl Style<'_> {
fn set(&self, info: &mut TrackInfo) -> Edit {
if let Some(color) = self.color {
track.info.color = Some(color.to_owned());
info.color = Some(color.to_owned());
}
if self.opacity.is_some() {
track.info.opacity = self.opacity;
info.opacity = self.opacity;
}
if self.width.is_some() {
track.info.width = self.width;
info.width = self.width;
}
Edit::Changed
}
}
impl Editor for Style<'_> {
fn route(&mut self, route: &mut Route) -> Edit {
self.set(&mut route.info)
}
fn track(&mut self, track: &mut Track) -> Edit {
self.set(&mut track.info)
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
+18 -1
View File
@@ -3,7 +3,7 @@ use std::rc::Rc;
use crate::{Apply, CommandError, FileId, Files, Selection, State, Waypoint};
/// Deletes the selected elements. With `whole_files`, the files holding the selected elements
/// are deleted instead, even if only a track or a waypoint is selected.
/// are deleted instead, even if only a route, a track or a waypoint is selected.
#[derive(Debug)]
pub struct Delete {
pub whole_files: bool,
@@ -23,6 +23,21 @@ impl Apply for Delete {
state.order.0.retain(|id| !file_ids.contains(id));
Selection::Empty
}
Selection::Route { file_id, rte_ids } => {
let file = state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?;
let file = Rc::make_mut(file);
let len = file.rte.len();
file.rte.retain(|rte| !rte_ids.contains(&rte.id));
if file.rte.len() == len {
return Err(CommandError::NothingToDo);
}
Selection::File {
file_ids: [*file_id].into(),
}
}
Selection::Track { file_id, trk_ids } => {
let file = state
.files
@@ -131,6 +146,7 @@ mod tests {
crate::New {
name: "keep",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
@@ -265,6 +281,7 @@ mod tests {
crate::New {
name: "keep",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
@@ -31,12 +31,14 @@ mod tests {
crate::New {
name: "a",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
crate::New {
name: "b",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
+27 -2
View File
@@ -1,8 +1,8 @@
use std::{collections::HashSet, rc::Rc};
use crate::{
Apply, CommandError, FileId, Files, Selection, State, Waypoint, copy_file, copy_segment,
copy_track, copy_waypoint,
Apply, CommandError, FileId, Files, Selection, State, Waypoint, copy_file, copy_route,
copy_segment, copy_track, copy_waypoint,
};
#[derive(Debug)]
@@ -35,6 +35,30 @@ impl Apply for Duplicate {
state.order.0.splice(at..at, order);
Selection::File { file_ids: copies }
}
Selection::Route { file_id, rte_ids } => {
let file = state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?;
let file = Rc::make_mut(file);
let mut copies = HashSet::new();
append_copies(
&mut file.rte,
|rte| rte_ids.contains(&rte.id),
|rte| {
let copy = copy_route(rte);
copies.insert(copy.id);
copy
},
);
if copies.is_empty() {
return Err(CommandError::NothingToDo);
}
Selection::Route {
file_id: *file_id,
rte_ids: copies,
}
}
Selection::Track { file_id, trk_ids } => {
let file = state
.files
@@ -193,6 +217,7 @@ mod tests {
crate::New {
name,
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
+3
View File
@@ -85,6 +85,7 @@ mod tests {
crate::New {
name: "keep",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
@@ -140,6 +141,7 @@ mod tests {
crate::New {
name: "before",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
@@ -189,6 +191,7 @@ mod tests {
crate::New {
name: "keep",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
@@ -9,6 +9,8 @@ use crate::{
pub enum MoveTarget {
/// The list of files.
Files { index: usize },
/// The routes of a file.
Routes { file_id: FileId, index: usize },
/// The tracks of a file.
Tracks { file_id: FileId, index: usize },
/// The segments of a track.
@@ -26,9 +28,10 @@ pub enum MoveTarget {
///
/// What can go where:
/// - files: among the files;
/// - tracks: among the files (each track becomes a file), or among the tracks of a file;
/// - segments: among the files (each segment becomes a file), among the tracks of a file (each
/// segment becomes a track), or among the segments of a track;
/// - routes and tracks: among the files (each one becomes a file), or among the routes or the
/// tracks of a file, where they keep their kind (see [`Destination`]);
/// - segments: among the files (each segment becomes a file), among the routes or the tracks of a
/// file (each segment becomes a route or a track), or among the segments of a track;
/// - waypoints, or all the waypoints of a file (the waypoints node): among the waypoints of a
/// file.
#[derive(Debug)]
@@ -41,6 +44,9 @@ impl From<MoveTarget> for Destination {
fn from(target: MoveTarget) -> Self {
match target {
MoveTarget::Files { index } => Destination::Files(Place::Index(index)),
MoveTarget::Routes { file_id, index } => {
Destination::Routes(file_id, Place::Index(index))
}
MoveTarget::Tracks { file_id, index } => {
Destination::Tracks(file_id, Place::Index(index))
}
+53 -6
View File
@@ -1,12 +1,16 @@
use crate::{Apply, CommandError, File, LngLat, State, Track, TrackSegment, Trackpoint, produce};
use crate::{
Apply, CommandError, File, LngLat, Route, State, Track, TrackSegment, Trackpoint, produce,
};
/// Creates an empty file and selects it. With a `trackpoint`, the file starts with a track and a
/// segment that hold it: it is how the routing tool starts a route on an empty map, in a single
/// undo step.
/// Creates an empty file and selects it. With `route`, the file starts with a route, which is
/// empty. With a `trackpoint`, the file starts with a segment that holds it, the one of its route
/// with `route` or of a track otherwise: it is how the routing tool starts a route on an empty map,
/// in a single undo step.
#[derive(Debug)]
pub struct New<'a> {
pub name: &'a str,
pub trackpoint: Option<NewTrackpoint>,
pub route: bool,
}
#[derive(Debug, Clone, Copy)]
@@ -21,8 +25,8 @@ impl Apply for New<'_> {
produce(state, |_| {
let mut file = File::default();
file.info.name = self.name.to_owned();
if let Some(point) = self.trackpoint {
let mut segment = TrackSegment::default();
if let Some(point) = self.trackpoint {
// the ends of a segment are anchors of the routing tool
segment.splice(
0,
@@ -36,6 +40,13 @@ impl Apply for New<'_> {
..Default::default()
}],
);
}
if self.route {
file.rte.push(Route {
seg: segment,
..Default::default()
});
} else if self.trackpoint.is_some() {
file.trk.push(Track {
trkseg: vec![segment],
..Default::default()
@@ -59,7 +70,8 @@ mod tests {
assert!(
New {
name: "new",
trackpoint: None
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.is_ok()
@@ -83,6 +95,7 @@ mod tests {
New {
name: "new",
trackpoint: Some(point),
route: false,
}
.apply(&mut fx.state())
.unwrap();
@@ -98,4 +111,38 @@ mod tests {
assert_eq!(segment[0].anchor, Some(0));
assert_eq!(fx.selected_files(), [file.id].into());
}
#[test]
fn test_new_with_a_route() {
let mut fx = Fixture::default();
New {
name: "new",
trackpoint: None,
route: true,
}
.apply(&mut fx.state())
.unwrap();
let file = fx.files.values().next().unwrap();
assert_eq!((file.rte.len(), file.trk.len()), (1, 0));
assert!(file.rte[0].seg.is_empty());
assert_eq!(fx.selected_files(), [file.id].into());
// the trackpoint goes to the route
let mut fx = Fixture::default();
New {
name: "new",
trackpoint: Some(NewTrackpoint {
lng: 1.0,
lat: 2.0,
ele: 3.0,
}),
route: true,
}
.apply(&mut fx.state())
.unwrap();
let file = fx.files.values().next().unwrap();
assert_eq!((file.rte.len(), file.trk.len()), (1, 0));
assert_eq!(file.rte[0].seg.len(), 1);
assert_eq!(file.rte[0].seg[0].anchor, Some(0));
}
}
+41 -10
View File
@@ -1,18 +1,21 @@
use std::{collections::HashSet, hash::Hash};
use crate::{
Apply, ClipboardContent, CommandError, Destination, FileId, Place, Selection, State, TrackId,
TrackSegmentId, WaypointId, transfer, waypoint_ids,
Apply, ClipboardContent, CommandError, Destination, FileId, Place, RouteId, Selection, State,
TrackId, TrackSegmentId, WaypointId, transfer, waypoint_ids,
};
/// Pastes what was copied or cut (see [`crate::Clipboard`]).
///
/// Where it goes depends on the selection, see [`crate::Clipboard::can_paste`]:
/// - nothing selected: the files are copied, and tracks and segments become new files (named
/// like their track, or like the file they come from);
/// - a file: the tracks are added to it, segments become new tracks in it, and the waypoints are
/// added to its own;
/// - a track: the segments are added to it, tracks go after it in its file;
/// - nothing selected: the files are copied, and routes, tracks and segments become new files
/// (named like their route or track, or like the file they come from);
/// - a file: the routes and tracks are added to it, segments become new tracks in it, and the
/// waypoints are added to its own;
/// - a route: routes, and segments as new routes, go after it in its file, tracks go before the
/// other tracks (right after the routes);
/// - a track: the segments are added to it, tracks go after it in its file, and routes after the
/// other routes;
/// - a segment: the segments go after it in its track;
/// - the waypoints or a waypoint: the waypoints are added to their file, after the waypoint.
///
@@ -46,6 +49,14 @@ impl Apply for Paste {
}
}
fn route_ids_of(state: &State, file_id: FileId) -> Vec<RouteId> {
state
.files
.get(&file_id)
.map(|file| file.rte.iter().map(|rte| rte.id).collect())
.unwrap_or_default()
}
fn track_ids_of(state: &State, file_id: FileId) -> Vec<TrackId> {
state
.files
@@ -79,14 +90,34 @@ fn destination(state: &State, content: &ClipboardContent) -> Result<Destination,
};
Ok(match (content, &*state.selection) {
(ClipboardContent::Files(_), _) => Destination::Files(Place::End),
(ClipboardContent::Tracks(_) | ClipboardContent::Segments(_), Selection::Empty) => {
Destination::Files(Place::End)
(
ClipboardContent::Routes(_)
| ClipboardContent::Tracks(_)
| ClipboardContent::Segments(_),
Selection::Empty,
) => Destination::Files(Place::End),
(ClipboardContent::Routes(_), Selection::File { file_ids }) => {
Destination::Routes(last_file(file_ids)?, Place::End)
}
// routes and tracks keep their kind (see `Destination`)
(
ClipboardContent::Routes(_)
| ClipboardContent::Tracks(_)
| ClipboardContent::Segments(_),
Selection::Route { file_id, rte_ids },
) => {
let last =
last_selected(route_ids_of(state, *file_id).into_iter(), rte_ids).ok_or(nothing)?;
Destination::Routes(*file_id, Place::After(last))
}
(
ClipboardContent::Tracks(_) | ClipboardContent::Segments(_),
Selection::File { file_ids },
) => Destination::Tracks(last_file(file_ids)?, Place::End),
(ClipboardContent::Tracks(_), Selection::Track { file_id, trk_ids }) => {
(
ClipboardContent::Routes(_) | ClipboardContent::Tracks(_),
Selection::Track { file_id, trk_ids },
) => {
let last =
last_selected(track_ids_of(state, *file_id).into_iter(), trk_ids).ok_or(nothing)?;
Destination::Tracks(*file_id, Place::After(last))
+1
View File
@@ -80,6 +80,7 @@ mod tests {
New {
name,
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
+131 -30
View File
@@ -5,9 +5,9 @@
use std::{collections::HashSet, rc::Rc};
use crate::{
ClipboardContent, ClipboardSegment, ClipboardTrack, CommandError, File, FileId, Selection,
State, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint, WaypointId, copy_file,
copy_segment, copy_track, copy_waypoint,
ClipboardContent, ClipboardRoute, ClipboardSegment, ClipboardTrack, CommandError, File, FileId,
Route, RouteId, Selection, State, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint,
WaypointId, copy_file, copy_route, copy_segment, copy_track, copy_waypoint,
};
/// Where elements are put, among the others of the same list.
@@ -37,15 +37,20 @@ impl<T: Copy + PartialEq> Place<T> {
/// Where elements go. What can go where:
/// - files: among the files;
/// - routes: among the files (each route becomes a file, named like the route, or like the file
/// it comes from), or among the routes of a file. Among the tracks of a file, they stay routes,
/// which come before the tracks: they go after the other routes;
/// - tracks: among the files (each track becomes a file, named like the track, or like the file
/// it comes from), or among the tracks of a file;
/// it comes from), or among the tracks of a file. Among the routes of a file, they stay tracks,
/// which come after the routes: they go before the other tracks;
/// - segments: among the files (each segment becomes a file with a track, named like its track,
/// or like its file), among the tracks of a file (each segment becomes a track), or among the
/// segments of a track;
/// or like its file), among the routes or the tracks of a file (each segment becomes a route or
/// a track), or among the segments of a track;
/// - waypoints: among the waypoints of a file.
#[derive(Debug, Clone, Copy)]
pub enum Destination {
Files(Place<FileId>),
Routes(FileId, Place<RouteId>),
Tracks(FileId, Place<TrackId>),
Segments(FileId, TrackId, Place<TrackSegmentId>),
Waypoints(FileId, Place<WaypointId>),
@@ -93,6 +98,14 @@ pub fn remove_elements(state: &mut State, content: &ClipboardContent) {
state.files.retain(|id, _| !ids.contains(id));
state.order.0.retain(|id| !ids.contains(id));
}
ClipboardContent::Routes(routes) => {
let ids: HashSet<RouteId> = routes.iter().map(|rte| rte.route.id).collect();
for file in state.files.values_mut() {
if file.rte.iter().any(|rte| ids.contains(&rte.id)) {
Rc::make_mut(file).rte.retain(|rte| !ids.contains(&rte.id));
}
}
}
ClipboardContent::Tracks(tracks) => {
let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.track.id).collect();
for file in state.files.values_mut() {
@@ -141,7 +154,9 @@ pub fn transfer(
// the destination has to be there, before anything is changed
let exists = match to {
Destination::Files(_) => true,
Destination::Tracks(file_id, _) => state.files.contains_key(&file_id),
Destination::Routes(file_id, _) | Destination::Tracks(file_id, _) => {
state.files.contains_key(&file_id)
}
Destination::Segments(file_id, trk_id, _) => state
.files
.get(&file_id)
@@ -152,17 +167,21 @@ pub fn transfer(
(content, to),
(ClipboardContent::Files(_), Destination::Files(_))
| (
ClipboardContent::Tracks(_),
Destination::Files(_) | Destination::Tracks(..)
ClipboardContent::Routes(_) | ClipboardContent::Tracks(_),
Destination::Files(_) | Destination::Routes(..) | Destination::Tracks(..)
)
| (
ClipboardContent::Segments(_),
Destination::Files(_) | Destination::Tracks(..) | Destination::Segments(..),
Destination::Files(_)
| Destination::Routes(..)
| Destination::Tracks(..)
| Destination::Segments(..),
)
| (ClipboardContent::Waypoints(_), Destination::Waypoints(..))
);
let empty = match content {
ClipboardContent::Files(files) => files.is_empty(),
ClipboardContent::Routes(routes) => routes.is_empty(),
ClipboardContent::Tracks(tracks) => tracks.is_empty(),
ClipboardContent::Segments(segments) => segments.is_empty(),
ClipboardContent::Waypoints(waypoints) => waypoints.is_empty(),
@@ -189,6 +208,7 @@ pub fn transfer(
.collect();
Ok(add_files(state, files, place))
}
(ClipboardContent::Routes(routes), to) => transfer_routes(state, routes, copy, to),
(ClipboardContent::Tracks(tracks), to) => transfer_tracks(state, tracks, copy, to),
(ClipboardContent::Segments(segments), to) => transfer_segments(state, segments, copy, to),
(ClipboardContent::Waypoints(waypoints), Destination::Waypoints(file_id, place)) => {
@@ -198,6 +218,89 @@ pub fn transfer(
}
}
/// Puts the routes among the routes of a file, at `place`, and gives the selection of them.
fn insert_routes(
state: &mut State,
file_id: FileId,
place: Place<RouteId>,
routes: Vec<Route>,
) -> Result<Selection, CommandError> {
let ids: HashSet<RouteId> = routes.iter().map(|rte| rte.id).collect();
let file = file_mut(state, file_id)?;
let existing: Vec<RouteId> = file.rte.iter().map(|rte| rte.id).collect();
let at = place.position(&existing);
file.rte.splice(at..at, routes);
Ok(Selection::Route {
file_id,
rte_ids: ids,
})
}
fn transfer_routes(
state: &mut State,
routes: &[ClipboardRoute],
copy: bool,
to: Destination,
) -> Result<Selection, CommandError> {
let routes: Vec<(Route, &str)> = routes
.iter()
.map(|clip| {
let route = if copy {
copy_route(&clip.route)
} else {
clip.route.clone()
};
(route, clip.file_name.as_str())
})
.collect();
match to {
Destination::Files(place) => {
let files = routes
.into_iter()
.map(|(route, file_name)| {
let name = route
.info
.name
.clone()
.unwrap_or_else(|| file_name.to_owned());
Rc::new(File {
rte: vec![route],
..new_file(&name, vec![])
})
})
.collect();
Ok(add_files(state, files, place))
}
Destination::Routes(file_id, place) => {
let routes = routes.into_iter().map(|(route, _)| route).collect();
insert_routes(state, file_id, place, routes)
}
Destination::Tracks(file_id, _) => {
let routes = routes.into_iter().map(|(route, _)| route).collect();
insert_routes(state, file_id, Place::End, routes)
}
_ => Err(CommandError::NothingToDo),
}
}
/// Puts the tracks among the tracks of a file, at `place`, and gives the selection of them.
fn insert_tracks(
state: &mut State,
file_id: FileId,
place: Place<TrackId>,
tracks: Vec<Track>,
) -> Result<Selection, CommandError> {
let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.id).collect();
let file = file_mut(state, file_id)?;
let existing: Vec<TrackId> = file.trk.iter().map(|trk| trk.id).collect();
let at = place.position(&existing);
file.trk.splice(at..at, tracks);
Ok(Selection::Track {
file_id,
trk_ids: ids,
})
}
fn transfer_tracks(
state: &mut State,
tracks: &[ClipboardTrack],
@@ -231,16 +334,12 @@ fn transfer_tracks(
Ok(add_files(state, files, place))
}
Destination::Tracks(file_id, place) => {
let tracks: Vec<Track> = tracks.into_iter().map(|(track, _)| track).collect();
let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.id).collect();
let file = file_mut(state, file_id)?;
let existing: Vec<TrackId> = file.trk.iter().map(|trk| trk.id).collect();
let at = place.position(&existing);
file.trk.splice(at..at, tracks);
Ok(Selection::Track {
file_id,
trk_ids: ids,
})
let tracks = tracks.into_iter().map(|(track, _)| track).collect();
insert_tracks(state, file_id, place, tracks)
}
Destination::Routes(file_id, _) => {
let tracks = tracks.into_iter().map(|(track, _)| track).collect();
insert_tracks(state, file_id, Place::Index(0), tracks)
}
_ => Err(CommandError::NothingToDo),
}
@@ -276,20 +375,22 @@ fn transfer_segments(
.collect();
Ok(add_files(state, files, place))
}
Destination::Routes(file_id, place) => {
let routes = segments
.into_iter()
.map(|(seg, _)| Route {
seg,
..Default::default()
})
.collect();
insert_routes(state, file_id, place, routes)
}
Destination::Tracks(file_id, place) => {
let tracks: Vec<Track> = segments
let tracks = segments
.into_iter()
.map(|(segment, _)| own_track(segment))
.collect();
let ids = tracks.iter().map(|trk| trk.id).collect();
let file = file_mut(state, file_id)?;
let existing: Vec<TrackId> = file.trk.iter().map(|trk| trk.id).collect();
let at = place.position(&existing);
file.trk.splice(at..at, tracks);
Ok(Selection::Track {
file_id,
trk_ids: ids,
})
insert_tracks(state, file_id, place, tracks)
}
Destination::Segments(file_id, trk_id, place) => {
let segments: Vec<TrackSegment> =
+8 -2
View File
@@ -4,6 +4,8 @@ mod file;
#[cfg(test)]
pub(crate) mod fixture;
mod pattern;
#[cfg(test)]
mod routes_tests;
mod tools;
pub use apply::*;
@@ -31,13 +33,15 @@ pub enum Command<'a> {
Move(Move),
DeleteAll(DeleteAll),
DeleteWaypoint(DeleteWaypoint),
Convert(Convert),
Metadata(Metadata<'a>),
Style(Style<'a>),
NewRoute(NewRoute),
NewTrack(NewTrack),
NewTrackSegment(NewTrackSegment),
Reverse(Reverse),
RoundTrip(RoundTrip),
Route(Route<'a>),
Reroute(Reroute<'a>),
NewWaypoint(NewWaypoint<'a>),
EditWaypoint(EditWaypoint<'a>),
MoveWaypoint(MoveWaypoint),
@@ -66,13 +70,15 @@ impl Apply for Command<'_> {
Command::Move(c) => c.apply(state),
Command::DeleteAll(c) => c.apply(state),
Command::DeleteWaypoint(c) => c.apply(state),
Command::Convert(c) => c.apply(state),
Command::Metadata(c) => c.apply(state),
Command::Style(c) => c.apply(state),
Command::NewRoute(c) => c.apply(state),
Command::NewTrack(c) => c.apply(state),
Command::NewTrackSegment(c) => c.apply(state),
Command::Reverse(c) => c.apply(state),
Command::RoundTrip(c) => c.apply(state),
Command::Route(c) => c.apply(state),
Command::Reroute(c) => c.apply(state),
Command::NewWaypoint(c) => c.apply(state),
Command::EditWaypoint(c) => c.apply(state),
Command::MoveWaypoint(c) => c.apply(state),
+12 -2
View File
@@ -1,4 +1,4 @@
use crate::{File, Track, TrackSegment, Waypoint};
use crate::{File, Route, Track, TrackSegment, Waypoint};
// The trackpoints of a segment are shared chunks, so copying elements is cheap.
@@ -18,10 +18,20 @@ pub fn copy_track(track: &Track) -> Track {
}
}
/// A copy of the file and of its tracks and segments, with new ids.
/// A copy of the route and of its segment, with new ids.
pub fn copy_route(route: &Route) -> Route {
Route {
id: Default::default(),
seg: copy_segment(&route.seg),
..route.clone()
}
}
/// A copy of the file and of its routes, tracks and segments, with new ids.
pub fn copy_file(file: &File) -> File {
File {
id: Default::default(),
rte: file.rte.iter().map(copy_route).collect(),
trk: file.trk.iter().map(copy_track).collect(),
..file.clone()
}
@@ -18,7 +18,7 @@ pub fn update_segments(
let file = files
.entry(location.file_id)
.or_insert_with(|| (*state.files[&location.file_id]).clone());
f(i, &mut file.trk[location.trk].trkseg[location.seg]);
f(i, file.segment_mut(location.index));
}
for (file_id, file) in files {
state.files.insert(file_id, Rc::new(file));
@@ -31,6 +31,8 @@ mod tests {
use crate::engine::command::fixture::Fixture;
use crate::SegmentIndex;
use super::*;
fn fixture() -> (Fixture, FileId) {
@@ -49,13 +51,11 @@ mod tests {
let locations = [
SegmentLocation {
file_id: id,
trk: 1,
seg: 0,
index: SegmentIndex::Track { trk: 1, seg: 0 },
},
SegmentLocation {
file_id: id,
trk: 0,
seg: 0,
index: SegmentIndex::Track { trk: 0, seg: 0 },
},
];
let mut calls = vec![];
@@ -1,6 +1,6 @@
use std::rc::Rc;
use crate::{File, FileId, Files, Selection, State, Track, TrackSegment, Waypoint};
use crate::{File, FileId, Files, Route, Selection, State, Track, TrackSegment, Waypoint};
/// What an [`Editor`] hook did to the element it was given.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -21,13 +21,19 @@ impl Edit {
/// Independent edit of each selected element, without needing the rest of the state.
///
/// A selected file, track or segment is passed to the hook of its own level. By default a
/// file is forwarded to its tracks and a track to its segments, so an editor only overrides
/// the level(s) it cares about (metadata: file and track, style: track, reverse: segment).
/// Waypoints are only edited when selected, never through their file.
/// A selected file, route, track or segment is passed to the hook of its own level. By default
/// a file is forwarded to its routes and tracks, and a route or a track to its segments, so an
/// editor only overrides the level(s) it cares about (metadata: file, route and track, style:
/// route and track, reverse: segment). Waypoints are only edited when selected, never through
/// their file.
pub trait Editor {
fn file(&mut self, file: &mut File) -> Edit {
edit_each(&mut file.trk, |trk| self.track(trk))
let routes = edit_each(&mut file.rte, |rte| self.route(rte));
routes.merge(edit_each(&mut file.trk, |trk| self.track(trk)))
}
fn route(&mut self, route: &mut Route) -> Edit {
edit_segment(self, &mut route.seg)
}
fn track(&mut self, track: &mut Track) -> Edit {
@@ -95,7 +101,7 @@ fn edit_waypoints<E: Editor + ?Sized>(
}
}
/// Applies the editor to every selected file, track or segment.
/// Applies the editor to every selected file, route, track or segment.
pub fn update_selected<E: Editor>(state: &mut State, editor: &mut E) {
match &*state.selection {
Selection::File { file_ids } => {
@@ -103,6 +109,15 @@ pub fn update_selected<E: Editor>(state: &mut State, editor: &mut E) {
update_file(state.files, *id, |file| editor.file(file));
}
}
Selection::Route { file_id, rte_ids } => {
update_file(state.files, *file_id, |file| {
edit_where(
&mut file.rte,
|rte| rte_ids.contains(&rte.id),
|rte| editor.route(rte),
)
});
}
Selection::Track { file_id, trk_ids } => {
update_file(state.files, *file_id, |file| {
edit_where(
+684
View File
@@ -0,0 +1,684 @@
//! The commands on routes: they work as on tracks, a route being a track of a single segment.
use std::rc::Rc;
use crate::{
Apply, Clean, CleanType, Clipboard, ClipboardContent, CommandError, Convert, Crop, Delete,
Duplicate, Extract, File, FileId, LngLat, LngLatBounds, Merge, MergeType, Metadata, Move,
MoveTarget, Paste, Reverse, RouteId, SegmentIndex, Selection, Split, SplitType, Style,
engine::command::fixture::Fixture, parse,
};
/// A file with the 2 routes of `with_routes` and the 2 tracks (of 2 segments) of
/// `with_tracks_and_segments`, which is the only file and is in the order.
fn fixture() -> (Fixture, FileId) {
let read = |name: &str| {
let data = std::fs::read(format!("data/{name}.gpx")).unwrap();
parse(&data, &mut Default::default()).unwrap()
};
let mut file = read("with_tracks_and_segments");
file.rte = read("with_routes").rte;
assert_eq!((file.rte.len(), file.trk.len()), (2, 2));
let mut fx = Fixture::default();
let id = file.id;
fx.files.insert(id, Rc::new(file));
fx.order.0.push(id);
(fx, id)
}
fn file(fx: &Fixture, id: FileId) -> &File {
&fx.files[&id]
}
fn routes(fx: &Fixture, id: FileId, indices: &[usize]) -> Selection {
Selection::Route {
file_id: id,
rte_ids: indices.iter().map(|i| file(fx, id).rte[*i].id).collect(),
}
}
fn whole(id: FileId) -> Selection {
Selection::File {
file_ids: [id].into(),
}
}
fn lengths(file: &File) -> Vec<usize> {
file.segments().map(|segment| segment.len()).collect()
}
#[test]
fn test_the_segments_of_a_file_are_the_ones_of_its_routes_then_its_tracks() {
let (fx, id) = fixture();
let file = file(&fx, id);
let locations = whole(id).segment_locations(&fx.files, &fx.order.0);
let indices: Vec<SegmentIndex> = locations.iter().map(|l| l.index).collect();
assert_eq!(
indices,
[
SegmentIndex::Route(0),
SegmentIndex::Route(1),
SegmentIndex::Track { trk: 0, seg: 0 },
SegmentIndex::Track { trk: 0, seg: 1 },
SegmentIndex::Track { trk: 1, seg: 0 },
SegmentIndex::Track { trk: 1, seg: 1 },
]
);
// a selection of routes covers their segments only
let selected = routes(&fx, id, &[1]).segment_locations(&fx.files, &fx.order.0);
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].segment(&fx.files).id, file.rte[1].seg.id);
}
#[test]
fn test_routes_are_selected_like_tracks() {
let (fx, id) = fixture();
let ids: Vec<RouteId> = file(&fx, id).rte.iter().map(|r| r.id).collect();
let mut selection = routes(&fx, id, &[0]);
selection.extend(routes(&fx, id, &[1]));
assert_eq!(selection, routes(&fx, id, &[0, 1]));
selection.toggle(routes(&fx, id, &[0]));
assert_eq!(selection, routes(&fx, id, &[1]));
// routes and tracks are not selected together
let track = Selection::Track {
file_id: id,
trk_ids: [file(&fx, id).trk[0].id].into(),
};
selection.extend(track.clone());
assert_eq!(selection, track);
assert_eq!(
routes(&fx, id, &[0]).all_at_level(&fx.files, &fx.order.0),
Some(routes(&fx, id, &[0, 1]))
);
assert_eq!(
routes(&fx, id, &[0]).neighbour(&fx.files, &fx.order.0, true),
Some(routes(&fx, id, &[1]))
);
assert_eq!(
routes(&fx, id, &[1]).neighbour(&fx.files, &fx.order.0, true),
None
);
assert_eq!(routes(&fx, id, &[1]).to_files(), whole(id));
let mut gone = Selection::Route {
file_id: id,
rte_ids: [ids[0], RouteId::default()].into(),
};
gone.retain_existing(&fx.files);
assert_eq!(gone, routes(&fx, id, &[0]));
}
#[test]
fn test_delete_and_duplicate_routes() {
let (mut fx, id) = fixture();
let kept = file(&fx, id).rte[1].id;
fx.selection = routes(&fx, id, &[0]);
Delete { whole_files: false }
.apply(&mut fx.state())
.unwrap();
assert_eq!(file(&fx, id).rte.len(), 1);
assert_eq!(file(&fx, id).rte[0].id, kept);
assert_eq!(file(&fx, id).trk.len(), 2);
assert_eq!(fx.selection, whole(id));
fx.selection = routes(&fx, id, &[0]);
Duplicate.apply(&mut fx.state()).unwrap();
let file = file(&fx, id);
assert_eq!(file.rte.len(), 2);
assert_ne!(file.rte[1].id, kept);
assert_ne!(file.rte[1].seg.id, file.rte[0].seg.id);
assert_eq!(file.rte[1].info, file.rte[0].info);
assert_eq!(
fx.selection,
Selection::Route {
file_id: id,
rte_ids: [file.rte[1].id].into()
}
);
}
#[test]
fn test_metadata_and_style_of_routes() {
let (mut fx, id) = fixture();
fx.selection = routes(&fx, id, &[1]);
Metadata {
name: "renamed",
desc: "about",
}
.apply(&mut fx.state())
.unwrap();
Style {
color: Some("00ff00"),
opacity: None,
width: Some(3.0),
}
.apply(&mut fx.state())
.unwrap();
let file = file(&fx, id);
let info = &file.rte[1].info;
assert_eq!(info.name.as_deref(), Some("renamed"));
assert_eq!(info.desc.as_deref(), Some("about"));
assert_eq!(info.color.as_deref(), Some("00ff00"));
assert_eq!(info.width, Some(3.0));
assert_ne!(file.rte[0].info.name.as_deref(), Some("renamed"));
// the style of a file is the one of its routes and tracks
fx.selection = whole(id);
Style {
color: Some("ff0000"),
opacity: None,
width: None,
}
.apply(&mut fx.state())
.unwrap();
let file = &fx.files[&id];
assert!(
file.rte
.iter()
.map(|r| &r.info)
.chain(file.trk.iter().map(|t| &t.info))
.all(|info| info.color.as_deref() == Some("ff0000"))
);
}
#[test]
fn test_copy_and_paste_routes() {
let (mut fx, id) = fixture();
let copied = file(&fx, id).rte[0].clone();
fx.clipboard = Clipboard::new(&routes(&fx, id, &[0]), &fx.files, &fx.order.0, false);
assert!(matches!(
fx.clipboard.as_ref().unwrap().content,
ClipboardContent::Routes(_)
));
// after the selected route
fx.selection = routes(&fx, id, &[1]);
Paste.apply(&mut fx.state()).unwrap();
let file = file(&fx, id);
assert_eq!(file.rte.len(), 3);
assert_ne!(file.rte[2].id, copied.id);
assert_eq!(file.rte[2].seg.len(), copied.seg.len());
assert!(
matches!(&fx.selection, Selection::Route { rte_ids, .. } if rte_ids.contains(&file.rte[2].id))
);
// nothing selected: a new file, named like the route
fx.selection = Selection::Empty;
Paste.apply(&mut fx.state()).unwrap();
assert_eq!(fx.order.0.len(), 2);
let new = &fx.files[&fx.order.0[1]];
assert_eq!(new.rte.len(), 1);
assert!(new.trk.is_empty());
assert_eq!(Some(&new.info.name), copied.info.name.as_ref());
// a track selected: among the routes of its file
fx.selection = Selection::Track {
file_id: id,
trk_ids: [fx.files[&id].trk[0].id].into(),
};
Paste.apply(&mut fx.state()).unwrap();
assert_eq!(fx.files[&id].rte.len(), 4);
// segments do not go in a route, but become routes next to it
let file = &fx.files[&id];
let track = &file.trk[0];
fx.clipboard = Clipboard::new(
&Selection::TrackSegment {
file_id: id,
trk_id: track.id,
trkseg_ids: [track.trkseg[1].id].into(),
},
&fx.files,
&fx.order.0,
false,
);
fx.selection = routes(&fx, id, &[0]);
Paste.apply(&mut fx.state()).unwrap();
let file = &fx.files[&id];
assert_eq!(file.rte.len(), 5);
assert_eq!(file.rte[1].seg.len(), file.trk[0].trkseg[1].len());
}
#[test]
fn test_cut_tracks_pasted_on_a_route_go_first_among_the_tracks() {
let (mut fx, id) = fixture();
let moved = file(&fx, id).trk[1].id;
fx.clipboard = Clipboard::new(
&Selection::Track {
file_id: id,
trk_ids: [moved].into(),
},
&fx.files,
&fx.order.0,
true,
);
fx.selection = routes(&fx, id, &[0]);
Paste.apply(&mut fx.state()).unwrap();
let file = file(&fx, id);
assert_eq!(file.trk.len(), 2);
assert_eq!(file.trk[0].id, moved);
assert_eq!(file.rte.len(), 2);
}
#[test]
fn test_move_routes() {
let (mut fx, id) = fixture();
let ids: Vec<RouteId> = file(&fx, id).rte.iter().map(|r| r.id).collect();
Move {
what: routes(&fx, id, &[1]),
to: MoveTarget::Routes {
file_id: id,
index: 0,
},
}
.apply(&mut fx.state())
.unwrap();
let order: Vec<RouteId> = file(&fx, id).rte.iter().map(|r| r.id).collect();
assert_eq!(order, [ids[1], ids[0]]);
// to the files: a file of its own
Move {
what: routes(&fx, id, &[0]),
to: MoveTarget::Files { index: 1 },
}
.apply(&mut fx.state())
.unwrap();
assert_eq!(file(&fx, id).rte.len(), 1);
let new = &fx.files[&fx.order.0[1]];
assert_eq!(new.rte[0].id, ids[1]);
// a segment moved among the routes becomes one
let track = &file(&fx, id).trk[0];
let segment = track.trkseg[0].id;
Move {
what: Selection::TrackSegment {
file_id: id,
trk_id: track.id,
trkseg_ids: [segment].into(),
},
to: MoveTarget::Routes {
file_id: id,
index: 1,
},
}
.apply(&mut fx.state())
.unwrap();
let file = file(&fx, id);
assert_eq!(file.rte[1].seg.id, segment);
assert_eq!(file.trk[0].trkseg.len(), 1);
}
#[test]
fn test_routes_and_tracks_moved_among_each_other_keep_their_kind() {
let (mut fx, id) = fixture();
let route = file(&fx, id).rte[0].id;
let track = file(&fx, id).trk[1].id;
// a route among the tracks: after the other routes, which come first
Move {
what: routes(&fx, id, &[0]),
to: MoveTarget::Tracks {
file_id: id,
index: 1,
},
}
.apply(&mut fx.state())
.unwrap();
let moved = file(&fx, id);
assert_eq!((moved.rte.len(), moved.trk.len()), (2, 2));
assert_eq!(moved.rte[1].id, route);
assert_eq!(
fx.selection,
Selection::Route {
file_id: id,
rte_ids: [route].into()
}
);
// a track among the routes: before the other tracks, which come after the routes
Move {
what: Selection::Track {
file_id: id,
trk_ids: [track].into(),
},
to: MoveTarget::Routes {
file_id: id,
index: 0,
},
}
.apply(&mut fx.state())
.unwrap();
let moved = file(&fx, id);
assert_eq!((moved.rte.len(), moved.trk.len()), (2, 2));
assert_eq!(moved.trk[0].id, track);
// and copied routes pasted on a track stay routes
fx.clipboard = Clipboard::new(&routes(&fx, id, &[0]), &fx.files, &fx.order.0, false);
fx.selection = Selection::Track {
file_id: id,
trk_ids: [track].into(),
};
Paste.apply(&mut fx.state()).unwrap();
let pasted = file(&fx, id);
assert_eq!((pasted.rte.len(), pasted.trk.len()), (3, 2));
}
#[test]
fn test_merge_routes() {
let (mut fx, id) = fixture();
let before = lengths(file(&fx, id));
let first = file(&fx, id).rte[0].id;
fx.selection = routes(&fx, id, &[0, 1]);
let merge = |type_| Merge {
type_,
remove_gaps: false,
keep_originals: false,
};
assert_eq!(
merge(MergeType::Group).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
let file = file(&fx, id);
assert_eq!(file.rte.len(), 1);
assert_eq!(file.rte[0].id, first);
assert_eq!(file.rte[0].seg.len(), before[0] + before[1]);
assert_eq!(file.trk.len(), 2);
}
#[test]
fn test_merge_files_with_routes() {
let (mut fx, id) = fixture();
let other = File {
rte: file(&fx, id).rte.iter().map(crate::copy_route).collect(),
..File::default()
};
let other_id = other.id;
fx.files.insert(other_id, Rc::new(other));
fx.order.0.push(other_id);
let total: usize = lengths(file(&fx, id)).iter().sum::<usize>()
+ lengths(&fx.files[&other_id]).iter().sum::<usize>();
let mut grouped = Fixture {
files: fx.files.clone(),
order: fx.order.clone(),
selection: Selection::File {
file_ids: [id, other_id].into(),
},
..Default::default()
};
Merge {
type_: MergeType::Group,
remove_gaps: false,
keep_originals: false,
}
.apply(&mut grouped.state())
.unwrap();
let merged = &grouped.files[&id];
assert_eq!((merged.rte.len(), merged.trk.len()), (4, 2));
assert!(!grouped.files.contains_key(&other_id));
// connected in the first route, which comes first
fx.selection = Selection::File {
file_ids: [id, other_id].into(),
};
Merge {
type_: MergeType::Connect,
remove_gaps: false,
keep_originals: false,
}
.apply(&mut fx.state())
.unwrap();
let merged = &fx.files[&id];
assert_eq!((merged.rte.len(), merged.trk.len()), (1, 0));
assert_eq!(merged.rte[0].seg.len(), total);
}
#[test]
fn test_extract_a_file_with_routes() {
let (mut fx, id) = fixture();
let names: Vec<_> = file(&fx, id)
.rte
.iter()
.map(|r| r.info.name.clone().unwrap())
.collect();
fx.selection = whole(id);
Extract {
keep_originals: false,
}
.apply(&mut fx.state())
.unwrap();
// one file per route and one per track
assert_eq!(fx.order.0.len(), 4);
let files: Vec<&File> = fx.order.0.iter().map(|id| &*fx.files[id]).collect();
for (file, name) in files.iter().zip(&names) {
assert_eq!((file.rte.len(), file.trk.len()), (1, 0));
assert_eq!(&file.info.name, name);
}
for file in &files[2..] {
assert_eq!((file.rte.len(), file.trk.len()), (0, 2));
}
// a route has nothing to extract
let (mut fx, id) = fixture();
fx.selection = routes(&fx, id, &[0]);
assert_eq!(
Extract {
keep_originals: false
}
.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_split_a_route() {
let (mut fx, id) = fixture();
let before = file(&fx, id).rte[1].clone();
fx.selection = routes(&fx, id, &[1]);
Split {
at: 10,
split_type: SplitType::Routes,
keep_originals: false,
}
.apply(&mut fx.state())
.unwrap();
// two routes, which share the trackpoint
let split = file(&fx, id);
assert_eq!(split.rte.len(), 3);
assert_eq!(split.rte[1].id, before.id);
assert_ne!(split.rte[2].id, before.id);
assert_eq!(split.rte[1].seg.len(), 11);
assert_eq!(split.rte[2].seg.len(), before.seg.len() - 10);
assert_eq!(split.rte[2].info, before.info);
// a route is not a track nor a segment, and the other way around
let (mut fx, id) = fixture();
fx.selection = whole(id);
let segment_start = lengths(file(&fx, id))[..2].iter().sum::<usize>() as u32;
for (at, split_type) in [
(10, SplitType::Tracks),
(10, SplitType::Segments),
(segment_start + 10, SplitType::Routes),
] {
assert_eq!(
Split {
at,
split_type,
keep_originals: false,
}
.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
assert_eq!(file(&fx, id).rte.len(), 2);
// into two files: the following routes and the tracks go to the second one
let (mut fx, id) = fixture();
let first_len = file(&fx, id).rte[0].seg.len();
fx.selection = whole(id);
Split {
at: 5,
split_type: SplitType::Files,
keep_originals: false,
}
.apply(&mut fx.state())
.unwrap();
assert_eq!(fx.order.0.len(), 2);
let (a, b) = (&fx.files[&fx.order.0[0]], &fx.files[&fx.order.0[1]]);
assert_eq!(a.id, id);
assert_eq!((a.rte.len(), a.trk.len()), (1, 0));
assert_eq!(a.rte[0].seg.len(), 6);
assert_eq!((b.rte.len(), b.trk.len()), (2, 2));
assert_eq!(b.rte[0].seg.len(), first_len - 5);
}
#[test]
fn test_crop_removes_the_routes_left_without_trackpoints() {
let (mut fx, id) = fixture();
let len = lengths(file(&fx, id));
fx.selection = whole(id);
// the second route only
let start = len[0] as u32;
Crop {
start,
end: start + len[1] as u32 - 1,
}
.apply(&mut fx.state())
.unwrap();
let file = file(&fx, id);
assert_eq!((file.rte.len(), file.trk.len()), (1, 0));
assert_eq!(file.rte[0].seg.len(), len[1]);
}
#[test]
fn test_clean_removes_the_routes_left_without_trackpoints() {
let (mut fx, id) = fixture();
fx.selection = routes(&fx, id, &[0]);
let everywhere = LngLatBounds {
sw: LngLat {
lng: -180.0,
lat: -90.0,
},
ne: LngLat {
lng: 180.0,
lat: 90.0,
},
};
Clean {
bounds: everywhere,
type_: CleanType::Inside,
trkpt: true,
wpt: false,
}
.apply(&mut fx.state())
.unwrap();
assert_eq!(file(&fx, id).rte.len(), 1);
assert_eq!(fx.selection, whole(id));
}
#[test]
fn test_reverse_a_route_and_the_routes_of_a_file() {
let (mut fx, id) = fixture();
let before = file(&fx, id).clone();
fx.selection = routes(&fx, id, &[0]);
Reverse.apply(&mut fx.state()).unwrap();
let route = &file(&fx, id).rte[0].seg;
let original = &before.rte[0].seg;
assert_eq!(
route[0].coordinates.lat,
original[original.len() - 1].coordinates.lat
);
assert_eq!(file(&fx, id).rte[1].seg.rev_id, before.rte[1].seg.rev_id);
let (mut fx, id) = fixture();
let before = file(&fx, id).clone();
fx.selection = whole(id);
Reverse.apply(&mut fx.state()).unwrap();
let file = file(&fx, id);
// the routes are in the opposite order, and still before the tracks
assert_eq!(file.rte[0].id, before.rte[1].id);
assert_eq!(file.trk[0].id, before.trk[1].id);
}
#[test]
fn test_convert_routes_to_tracks_and_back() {
let (mut fx, id) = fixture();
let before = file(&fx, id).clone();
// a route becomes a track, first among the tracks, with its segment and its information
fx.selection = routes(&fx, id, &[1]);
Convert.apply(&mut fx.state()).unwrap();
let converted = file(&fx, id);
assert_eq!((converted.rte.len(), converted.trk.len()), (1, 3));
assert_eq!(converted.trk[0].info, before.rte[1].info);
assert_eq!(converted.trk[0].trkseg.len(), 1);
assert_eq!(converted.trk[0].trkseg[0].id, before.rte[1].seg.id);
assert_eq!(
fx.selection,
Selection::Track {
file_id: id,
trk_ids: [converted.trk[0].id].into()
}
);
// and back: a route again, after the other routes
Convert.apply(&mut fx.state()).unwrap();
let back = file(&fx, id);
assert_eq!((back.rte.len(), back.trk.len()), (2, 2));
assert_eq!(back.rte[1].seg.id, before.rte[1].seg.id);
assert_eq!(back.rte[1].info, before.rte[1].info);
assert!(
matches!(&fx.selection, Selection::Route { rte_ids, .. } if rte_ids.contains(&back.rte[1].id))
);
}
#[test]
fn test_convert_a_track_to_one_route_per_segment() {
let (mut fx, id) = fixture();
let track = file(&fx, id).trk[0].clone();
assert_eq!(track.trkseg.len(), 2);
fx.selection = Selection::Track {
file_id: id,
trk_ids: [track.id].into(),
};
Convert.apply(&mut fx.state()).unwrap();
let converted = file(&fx, id);
assert_eq!((converted.rte.len(), converted.trk.len()), (4, 1));
for (route, segment) in converted.rte[2..].iter().zip(&track.trkseg) {
assert_eq!(route.seg.id, segment.id);
assert_eq!(route.info, track.info);
}
assert!(matches!(&fx.selection, Selection::Route { rte_ids, .. } if rte_ids.len() == 2));
}
#[test]
fn test_convert_segments_to_routes_keeps_their_track() {
let (mut fx, id) = fixture();
let track = file(&fx, id).trk[1].clone();
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: track.id,
trkseg_ids: [track.trkseg[0].id, track.trkseg[1].id].into(),
};
Convert.apply(&mut fx.state()).unwrap();
let converted = file(&fx, id);
assert_eq!((converted.rte.len(), converted.trk.len()), (4, 2));
assert!(converted.trk[1].trkseg.is_empty());
assert_eq!(converted.rte[2].seg.id, track.trkseg[0].id);
// nothing to convert otherwise
for selection in [
whole(id),
Selection::Empty,
Selection::Waypoints { file_id: id },
] {
fx.selection = selection;
assert_eq!(
Convert.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
}
@@ -24,7 +24,7 @@ impl Apply for ChangeLoopStart {
let index = self.index as usize;
let target = find_target(state, index, index)?.ok_or(CommandError::NothingToDo)?;
let mut file = (*state.files[&target.location.file_id]).clone();
let segment = &mut file.trk[target.location.trk].trkseg[target.location.seg];
let segment = file.segment_mut(target.location.index);
let (start, len) = (target.start, segment.len());
if start == 0 || start >= len {
// it already is the start, or it is not a trackpoint
+32 -9
View File
@@ -13,8 +13,8 @@ pub enum CleanType {
}
/// Removes the trackpoints and/or waypoints of the selection lying inside (or outside) a
/// rectangle. A selected file cleans its tracks and its waypoints, a selected track or segment
/// only trackpoints, selected waypoints only waypoints.
/// rectangle. A selected file cleans its routes, its tracks and its waypoints, a selected route,
/// track or segment only trackpoints, selected waypoints only waypoints.
#[derive(Debug)]
pub struct Clean {
pub bounds: LngLatBounds,
@@ -85,6 +85,11 @@ struct Cleaner<'a> {
impl Editor for Cleaner<'_> {
fn file(&mut self, file: &mut File) -> Edit {
let mut edit = Edit::Unchanged;
for rte in &mut file.rte {
if self.route(rte) == Edit::Changed {
edit = Edit::Changed;
}
}
for trk in &mut file.trk {
if self.track(trk) == Edit::Changed {
edit = Edit::Changed;
@@ -124,24 +129,21 @@ impl Editor for Cleaner<'_> {
}
}
/// Removes what the clean left empty: the emptied segments and the tracks that lost all their
/// segments. A file is never removed, even if nothing is left in it: it stays, empty. The
/// Removes what the clean left empty: the emptied segments, the routes of these segments and the
/// tracks that lost all their segments. A file is never removed, even if nothing is left in it: it stays, empty. The
/// selection falls back to the closest element still existing.
fn prune(state: &mut State, emptied: &HashSet<TrackSegmentId>, wpt_files: &HashSet<FileId>) {
let ids: Vec<FileId> = state
.files
.iter()
.filter(|(id, file)| {
wpt_files.contains(id)
|| file
.trk
.iter()
.any(|trk| trk.trkseg.iter().any(|seg| emptied.contains(&seg.id)))
wpt_files.contains(id) || file.segments().any(|seg| emptied.contains(&seg.id))
})
.map(|(id, _)| *id)
.collect();
for id in ids {
let mut file = (*state.files[&id]).clone();
file.rte.retain(|rte| !emptied.contains(&rte.seg.id));
file.trk.retain_mut(|trk| {
let len = trk.trkseg.len();
trk.trkseg.retain(|seg| !emptied.contains(&seg.id));
@@ -167,6 +169,27 @@ fn fixed_selection(state: &State) -> Selection {
Selection::File { file_ids }
}
}
Selection::Route { file_id, rte_ids } => {
let Some(file) = files.get(file_id) else {
return Selection::Empty;
};
let rte_ids: HashSet<_> = file
.rte
.iter()
.map(|rte| rte.id)
.filter(|id| rte_ids.contains(id))
.collect();
if rte_ids.is_empty() {
Selection::File {
file_ids: [*file_id].into(),
}
} else {
Selection::Route {
file_id: *file_id,
rte_ids,
}
}
}
Selection::Track { file_id, trk_ids } => {
let Some(file) = files.get(file_id) else {
return Selection::Empty;
+9 -8
View File
@@ -11,9 +11,9 @@ use crate::{
/// Keeps the trackpoints `start` to `end` (both included) of the selection, which are counted
/// over its segments one after the other (as in the `RoutingBuffer`), and removes the others.
///
/// The segments that have no trackpoint left are removed. So are the tracks that have no segment
/// left, if tracks or files are selected (not if only segments are), and the files that have no
/// trackpoint left, if files are selected. A range that goes past the last trackpoint stops at it.
/// The segments that have no trackpoint left are removed, and so are the routes of these segments.
/// So are the tracks that have no segment left, if tracks or files are selected (not if only
/// segments are), and the files that have no trackpoint left, if files are selected. A range that goes past the last trackpoint stops at it.
#[derive(Debug)]
pub struct Crop {
pub start: u32,
@@ -43,8 +43,7 @@ impl Apply for Crop {
.segment_locations(state.files, &state.order.0)
.into_iter()
.map(|location| {
let len =
state.files[&location.file_id].trk[location.trk].trkseg[location.seg].len();
let len = location.segment(state.files).len();
let keep = (len > 0 && start < offset + len && end >= offset)
.then(|| (start.saturating_sub(offset), (end - offset).min(len - 1)));
offset += len;
@@ -73,14 +72,14 @@ impl Apply for Crop {
let mut removed: HashSet<TrackSegmentId> = HashSet::new();
let mut had_points: HashSet<FileId> = HashSet::new();
for plan in &plans {
let SegmentLocation { file_id, trk, seg } = plan.location;
let SegmentLocation { file_id, index } = plan.location;
let file = files
.entry(file_id)
.or_insert_with(|| (*state.files[&file_id]).clone());
if plan.len > 0 {
had_points.insert(file_id);
}
let segment = &mut file.trk[trk].trkseg[seg];
let segment = file.segment_mut(index);
match plan.keep {
None => {
removed.insert(segment.id);
@@ -91,6 +90,8 @@ impl Apply for Crop {
let mut gone: Vec<FileId> = vec![];
for (file_id, file) in files.iter_mut() {
// a route has its segment, or is not there
file.rte.retain(|route| !removed.contains(&route.seg.id));
// the tracks that lost segments
let touched: Vec<bool> = file
.trk
@@ -108,7 +109,7 @@ impl Apply for Crop {
file.trk
.retain(|track| !(touched.next().unwrap() && track.trkseg.is_empty()));
}
if prune_files && file.trk.is_empty() && had_points.contains(file_id) {
if prune_files && file.has_no_path() && had_points.contains(file_id) {
gone.push(*file_id);
}
}
+1 -3
View File
@@ -14,9 +14,7 @@ impl Apply for Elevation<'_> {
.segment_locations(state.files, &state.order.0);
let count: usize = locations
.iter()
.map(|location| {
state.files[&location.file_id].trk[location.trk].trkseg[location.seg].len()
})
.map(|location| location.segment(state.files).len())
.sum();
if count == 0 {
return Err(CommandError::NothingToDo);
+36 -30
View File
@@ -1,22 +1,23 @@
use std::{collections::HashSet, rc::Rc};
use crate::{
Apply, CommandError, File, FileId, LngLat, LngLatBounds, Selection, State, Track, TrackId,
TrackSegment, Waypoint, Waypoints, copy_file, copy_waypoint, crossarc_lnglat, distance,
Apply, CommandError, File, FileId, LngLat, LngLatBounds, Route, Selection, State, Track,
TrackId, TrackSegment, Waypoint, Waypoints, copy_file, copy_waypoint, crossarc_lnglat,
distance,
};
/// Splits the selected elements that are made of several segments into elements that have one
/// each:
///
/// - files: a file with several tracks becomes one file per track, in which the track is split
/// into one track per segment. A file with a single track becomes one file per segment. The new
/// files are in the place of the file, which goes, and are selected. They are named after the
/// track, or the file, and numbered. The waypoints go to the files that have the segments
/// - files: a file with several routes and tracks becomes one file per route and per track, in
/// which the track is split into one track per segment. A file with a single track becomes one
/// file per segment. The new files are in the place of the file, which goes, and are selected.
/// They are named after the route or track, or the file, and numbered. The waypoints go to the files that have the segments
/// closest to them (to the first file if the file has no trackpoint).
/// - tracks: each selected track is replaced by one track per segment, which are selected. They
/// are named after the track and numbered.
///
/// What has a single segment is left as it is. There is nothing to do if nothing was changed.
/// What has a single segment is left as it is, which routes always are. There is nothing to do if nothing was changed.
///
/// With `keep_originals`, the files that are extracted stay where they are, and the new files come
/// right after each of them, and only the new files are selected. Extracting
@@ -46,8 +47,7 @@ fn extract_files(
let mut extracted: Vec<(FileId, Vec<File>)> = vec![];
for id in state.order.0.iter().filter(|id| file_ids.contains(id)) {
let file = &state.files[id];
let segments: usize = file.trk.iter().map(|track| track.trkseg.len()).sum();
if segments > 1 {
if file.segments().count() > 1 {
let mut files = extract_file(file);
if keep_originals {
// the tracks and segments are the ones of the file, which stays: they need new ids
@@ -95,31 +95,35 @@ fn extract_files(
/// The files that a file with several segments becomes.
fn extract_file(file: &File) -> Vec<File> {
// for each new file: its tracks and its name, and the segments that go with it
// for each new file: its routes, its tracks and its name, and the segments that go with it
struct Part {
routes: Vec<Route>,
tracks: Vec<Track>,
name: String,
}
let (parts, owner): (Vec<Part>, Vec<usize>) = if file.trk.len() > 1 {
// one file per track, the segments of a track follow it
let parts = file
.trk
.iter()
.enumerate()
.map(|(i, track)| Part {
let name = |name: &Option<String>, i: usize| {
name.clone()
.unwrap_or_else(|| format!("{} ({})", file.info.name, i + 1))
};
let (parts, owner): (Vec<Part>, Vec<usize>) =
if file.rte.len() + file.trk.len() > 1 {
// one file per route and per track, the segments of a track follow it
let routes = file.rte.iter().enumerate().map(|(i, route)| Part {
routes: vec![route.clone()],
tracks: vec![],
name: name(&route.info.name, i),
});
let tracks = file.trk.iter().enumerate().map(|(i, track)| Part {
routes: vec![],
tracks: split_track(track),
name: track
.info
.name
.clone()
.unwrap_or_else(|| format!("{} ({})", file.info.name, i + 1)),
})
.collect();
let owner = file
.trk
.iter()
.enumerate()
.flat_map(|(i, track)| track.trkseg.iter().map(move |_| i))
name: name(&track.info.name, file.rte.len() + i),
});
let parts = routes.chain(tracks).collect();
let owner =
(0..file.rte.len())
.chain(file.trk.iter().enumerate().flat_map(|(i, track)| {
track.trkseg.iter().map(move |_| file.rte.len() + i)
}))
.collect();
(parts, owner)
} else {
@@ -135,6 +139,7 @@ fn extract_file(file: &File) -> Vec<File> {
.iter()
.enumerate()
.map(|(i, segment)| Part {
routes: vec![],
tracks: vec![Track {
id: if i == 0 { track.id } else { Default::default() },
trkseg: vec![segment.clone()],
@@ -148,7 +153,7 @@ fn extract_file(file: &File) -> Vec<File> {
};
// the waypoints go with the segments that are the closest to them
let segments: Vec<&TrackSegment> = file.trk.iter().flat_map(|track| &track.trkseg).collect();
let segments: Vec<&TrackSegment> = file.segments().collect();
let mut waypoints: Vec<Vec<Waypoint>> = vec![vec![]; parts.len()];
for waypoint in file.wpt.iter() {
let mut owners: Vec<usize> = closest_segments(&segments, waypoint.coordinates)
@@ -176,6 +181,7 @@ fn extract_file(file: &File) -> Vec<File> {
.map(|(part, waypoints)| {
let mut extracted = File {
id: Default::default(),
rte: part.routes,
trk: part.tracks,
wpt: Waypoints::default(),
..file.clone()
@@ -39,7 +39,7 @@ impl Apply for InsertAnchor {
.selection
.segment_locations(state.files, &state.order.0)
{
let segment = &state.files[&location.file_id].trk[location.trk].trkseg[location.seg];
let segment = location.segment(state.files);
let mut points = segment.iter();
let Some(mut previous) = points.next() else {
continue;
@@ -81,7 +81,7 @@ impl Apply for InsertAnchor {
let closest = closest.ok_or(CommandError::NothingToDo)?;
let mut file = (*state.files[&closest.location.file_id]).clone();
let segment = &mut file.trk[closest.location.trk].trkseg[closest.location.seg];
let segment = file.segment_mut(closest.location.index);
let before = closest.before;
// on a trackpoint already, if it is closer than `SNAP_DISTANCE`
let snapped = if segment.len() == 1 {
+59 -11
View File
@@ -1,8 +1,9 @@
use std::{collections::HashSet, rc::Rc};
use crate::{
Apply, CommandError, FileId, GlobalStatistics, Selection, State, Statistics, Track, TrackId,
TrackSegment, TrackSegmentId, Waypoint, Waypoints, copy_track, replace_trackpoints,
Apply, CommandError, FileId, GlobalStatistics, Route, RouteId, Selection, State, Statistics,
Track, TrackId, TrackSegment, TrackSegmentId, Waypoint, Waypoints, copy_route, copy_track,
replace_trackpoints,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
@@ -16,13 +17,16 @@ pub enum MergeType {
/// Merges the selected elements into the first of them:
///
/// - files: the other files are deleted. The tracks of all of them are in the first file, as
/// they are with `Group`, or all connected in the first track with `Connect`. The waypoints of
/// - files: the other files are deleted. The routes and tracks of all of them are in the first
/// file, as they are with `Group`, or all connected in the first of them (a route or a track,
/// the routes of a file coming before its tracks) with `Connect`. The waypoints of
/// all of them are in the first file too, except the ones that are the same. With
/// `keep_originals`, no file is deleted nor changed: the result is a new file (with the info of
/// the first one and new ids), which comes right after the last of the files and is selected.
/// - tracks of a file: the first track gets their segments, as they are with `Group`, or all
/// connected in one segment with `Connect`. The other tracks are removed.
/// - routes of a file: the first route gets their trackpoints, all connected with `Connect`, and
/// the other routes are removed. A route has a single segment, so there is nothing to `Group`.
/// - segments of a track: they are all connected in a segment at the position of the first one
/// with `Connect`. With `Group` they are put one after the other, at that position.
///
@@ -44,6 +48,7 @@ impl Apply for Merge {
let selection = state.selection.clone();
match &selection {
Selection::File { file_ids } => self.merge_files(state, file_ids),
Selection::Route { file_id, rte_ids } => self.merge_routes(state, *file_id, rte_ids),
Selection::Track { file_id, trk_ids } => self.merge_tracks(state, *file_id, trk_ids),
Selection::TrackSegment {
file_id,
@@ -77,21 +82,26 @@ impl Merge {
return Err(CommandError::NothingToDo);
};
let mut routes: Vec<Route> = vec![];
let mut tracks: Vec<Track> = vec![];
let mut segments: Vec<TrackSegment> = vec![];
let mut waypoints: Vec<Waypoint> = vec![];
for id in &ordered {
let file = &state.files[id];
routes.extend(file.rte.iter().cloned());
tracks.extend(file.trk.iter().cloned());
segments.extend(file.segments().cloned());
for waypoint in file.wpt.iter() {
if !waypoints.iter().any(|kept| kept.same_content(waypoint)) {
waypoints.push(waypoint.clone());
}
}
}
let segments: Vec<TrackSegment> = tracks
.iter()
.flat_map(|track| track.trkseg.iter().cloned())
.collect();
// the first route or track, which holds the connected segment
let first_is_route = ordered.iter().find_map(|id| {
let file = &state.files[id];
(!file.has_no_path()).then(|| !file.rte.is_empty())
});
match self.type_ {
MergeType::Group if ordered.len() == 1 => return Err(CommandError::NothingToDo),
@@ -99,24 +109,35 @@ impl Merge {
return Err(CommandError::NothingToDo);
}
MergeType::Connect if !segments.is_empty() => {
// a single track, which is the first one, with the connected segment
// a single route or track, which is the first one, with the connected segment
let merged = connect(&segments, segments[0].id, self.remove_gaps);
if first_is_route == Some(true) {
let first = Route {
seg: merged,
..routes[0].clone()
};
(routes, tracks) = (vec![first], vec![]);
} else {
let first = Track {
trkseg: vec![merged],
..tracks[0].clone()
};
tracks = vec![first];
(routes, tracks) = (vec![], vec![first]);
}
}
_ => {}
}
let mut target = (*state.files[&target_id]).clone();
target.rte = routes;
target.trk = tracks;
target.wpt = Waypoints::default();
target.wpt.insert_at(0, waypoints);
if self.keep_originals {
// a new file, whose tracks and segments do not share their ids with the originals
// a new file, whose routes, tracks and segments do not share their ids with the
// originals
target.id = Default::default();
target.rte = target.rte.iter().map(copy_route).collect();
target.trk = target.trk.iter().map(copy_track).collect();
let at = state
.order
@@ -139,6 +160,33 @@ impl Merge {
Ok(())
}
fn merge_routes(
&self,
state: &mut State,
file_id: FileId,
rte_ids: &HashSet<RouteId>,
) -> Result<(), CommandError> {
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let selected: Vec<usize> = (0..file.rte.len())
.filter(|i| rte_ids.contains(&file.rte[*i].id))
.collect();
if selected.len() < 2 || self.type_ == MergeType::Group {
return Err(CommandError::NothingToDo);
}
let first = selected[0];
let segments: Vec<TrackSegment> =
selected.iter().map(|i| file.rte[*i].seg.clone()).collect();
let mut file = (**file).clone();
file.rte[first].seg = connect(&segments, segments[0].id, self.remove_gaps);
let mut index = 0;
file.rte.retain(|_| {
index += 1;
!selected[1..].contains(&(index - 1))
});
state.files.insert(file_id, Rc::new(file));
Ok(())
}
fn merge_tracks(
&self,
state: &mut State,
@@ -58,7 +58,8 @@ impl Apply for NewWaypoint<'_> {
.filter(|id| file_ids.contains(id))
.copied()
.collect(),
Selection::Track { file_id, .. }
Selection::Route { file_id, .. }
| Selection::Track { file_id, .. }
| Selection::TrackSegment { file_id, .. }
| Selection::Waypoints { file_id }
| Selection::Waypoint { file_id, .. } => vec![*file_id],
@@ -118,12 +119,14 @@ mod tests {
New {
name: "a",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
New {
name: "b",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
@@ -169,6 +172,7 @@ mod tests {
New {
name: "a",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
@@ -196,6 +200,7 @@ mod tests {
New {
name,
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
@@ -234,6 +239,7 @@ mod tests {
New {
name: "a",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
@@ -250,6 +256,7 @@ mod tests {
New {
name: "a",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
+1 -1
View File
@@ -23,7 +23,7 @@ impl Apply for Reduce {
let mut changed = vec![];
let mut kept_points = vec![];
for location in &locations {
let segment = &state.files[&location.file_id].trk[location.trk].trkseg[location.seg];
let segment = location.segment(state.files);
let kept = reduce_indices(segment, self.tolerance);
if kept.len() == segment.len() {
continue;
+21 -9
View File
@@ -1,14 +1,15 @@
use std::rc::Rc;
use crate::{
Apply, CommandError, File, FileId, Selection, State, TrackId, TrackSegmentId, end_time,
reverse_segment, reverse_segments, reverse_tracks, start_time, track_end_time,
track_start_time,
Apply, CommandError, File, FileId, RouteId, Selection, State, TrackId, TrackSegmentId,
end_time, reverse_routes, reverse_segment, reverse_segments, reverse_tracks, start_time,
track_end_time, track_start_time,
};
/// Reverses the direction of the selected elements: the whole of each selected file (its tracks,
/// the segments of its tracks and their trackpoints are in the opposite order), of each selected
/// track (its segments and their trackpoints), or each selected segment on its own (its
/// Reverses the direction of the selected elements: the whole of each selected file (its routes,
/// its tracks, the segments of its tracks and their trackpoints are in the opposite order; the
/// routes and the tracks are two wholes, as the routes stay before the tracks), of each selected
/// track (its segments and their trackpoints), or each selected route or segment on its own (its
/// trackpoints). The timestamps are mirrored, see [`reverse_segment`].
#[derive(Debug)]
pub struct Reverse;
@@ -20,9 +21,7 @@ impl Apply for Reverse {
.segment_locations(state.files, &state.order.0);
let points: usize = locations
.iter()
.map(|location| {
state.files[&location.file_id].trk[location.trk].trkseg[location.seg].len()
})
.map(|location| location.segment(state.files).len())
.sum();
if points <= 1 {
return Err(CommandError::NothingToDo);
@@ -40,12 +39,18 @@ impl Apply for Reverse {
Selection::File { file_ids } => {
for file_id in file_ids {
edit(file_id, &|file| {
let end = file.rte.last().and_then(|rte| end_time(&rte.seg));
let start = file.rte.first().and_then(|rte| start_time(&rte.seg));
reverse_routes(&mut file.rte, end, start);
let end = file.trk.last().and_then(track_end_time);
let start = file.trk.first().and_then(track_start_time);
reverse_tracks(&mut file.trk, end, start);
});
}
}
Selection::Route { file_id, rte_ids } => edit(file_id, &|file| {
reverse_selected_routes(file, rte_ids);
}),
Selection::Track { file_id, trk_ids } => edit(file_id, &|file| {
reverse_selected_tracks(file, trk_ids);
}),
@@ -66,6 +71,13 @@ impl Apply for Reverse {
}
}
fn reverse_selected_routes(file: &mut File, ids: &std::collections::HashSet<RouteId>) {
for route in file.rte.iter_mut().filter(|route| ids.contains(&route.id)) {
let (end, start) = (end_time(&route.seg), start_time(&route.seg));
reverse_segment(&mut route.seg, end, start);
}
}
fn reverse_selected_tracks(file: &mut File, ids: &std::collections::HashSet<TrackId>) {
for track in file.trk.iter_mut().filter(|track| ids.contains(&track.id)) {
let end = track.trkseg.last().and_then(end_time);
@@ -14,7 +14,7 @@ impl Apply for RoundTrip {
.selection
.segment_locations(state.files, &state.order.0)
.into_iter()
.filter(|l| state.files[&l.file_id].trk[l.trk].trkseg[l.seg].len() >= 2)
.filter(|l| l.segment(state.files).len() >= 2)
.collect();
if locations.is_empty() {
return Err(CommandError::NothingToDo);
+17 -14
View File
@@ -1,11 +1,11 @@
use std::rc::Rc;
use crate::{
Apply, Categories, CommandError, FileId, SegmentLocation, Selection, State, Statistics, Track,
TrackSegment, Trackpoint, distance, replace_trackpoints,
Apply, Categories, CommandError, FileId, SegmentIndex, SegmentLocation, Selection, State,
Statistics, Track, TrackSegment, Trackpoint, distance, replace_trackpoints,
};
/// The values of an OSM attribute (surface, highway...) of the new trackpoints of a [`Route`], as
/// The values of an OSM attribute (surface, highway...) of the new trackpoints of a [`Reroute`], as
/// the intervals of consecutive trackpoints that share one: `starts[i]` is the index of the first
/// trackpoint of the interval `i`, and `values[i]` its value, 0 when unknown, else 1 + the index
/// of its name in `names`. No intervals means that the attribute is unknown everywhere.
@@ -64,7 +64,7 @@ impl RouteCategory<'_> {
/// anchors anyway. The ones right before and after the range, between which the route goes, become
/// shown at every zoom level as well.
#[derive(Debug)]
pub struct Route<'a> {
pub struct Reroute<'a> {
pub start: u32,
pub end: u32,
pub lng: &'a [f64],
@@ -89,7 +89,7 @@ pub(super) struct Target {
pub end: usize,
}
impl Apply for Route<'_> {
impl Apply for Reroute<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let (start, end) = (self.start as usize, self.end as usize);
let len = self.lng.len();
@@ -152,14 +152,17 @@ impl Apply for Route<'_> {
}
let seg = file.trk[trk].trkseg.len() - 1;
Target {
location: SegmentLocation { file_id, trk, seg },
location: SegmentLocation {
file_id,
index: SegmentIndex::Track { trk, seg },
},
start: 0,
end: 0,
}
}
};
let segment = &mut file.trk[target.location.trk].trkseg[target.location.seg];
let segment = file.segment_mut(target.location.index);
let new_len = points.len();
let (speed, start_time) = speed_and_start_time(segment, &target, &points);
let has_times = !segment.is_empty() && segment[0].time.is_some();
@@ -208,7 +211,7 @@ pub(super) fn find_target(
let segments: Vec<(SegmentLocation, usize)> = locations
.into_iter()
.map(|location| {
let segment = &state.files[&location.file_id].trk[location.trk].trkseg[location.seg];
let segment = location.segment(state.files);
(location, segment.len())
})
.collect();
@@ -382,8 +385,8 @@ mod tests {
lng: &'a [f64],
lat: &'a [f64],
ele: &'a [f64],
) -> Route<'a> {
Route {
) -> Reroute<'a> {
Reroute {
start,
end,
lng,
@@ -459,7 +462,7 @@ mod tests {
route(0, 0, &[1.0], &[], &[]).apply(&mut fx.state()),
route(0, len + 1, &[], &[], &[]).apply(&mut fx.state()),
route(len + 1, len + 1, &[1.0], &[1.0], &[1.0]).apply(&mut fx.state()),
Route {
Reroute {
anchors: &[1],
..route(0, 0, &[1.0], &[1.0], &[1.0])
}
@@ -589,7 +592,7 @@ mod tests {
// 5 new points replacing the 2 in the middle, two of them are anchors
let (lng, lat, ele) = ([1.0; 5], [2.0; 5], [3.0; 5]);
Route {
Reroute {
anchors: &[1, 3],
..route(1, 3, &lng, &lat, &ele)
}
@@ -639,7 +642,7 @@ mod tests {
let names = ["asphalt".to_string(), "gravel".to_string()];
let (lng, lat, ele) = ([1.0; 4], [2.0; 4], [3.0; 4]);
// asphalt, asphalt, unknown, gravel
Route {
Reroute {
surface: RouteCategory {
starts: &[0, 2, 3],
values: &[1, 0, 2],
@@ -683,7 +686,7 @@ mod tests {
},
];
for surface in broken {
let result = Route {
let result = Reroute {
surface,
..route(0, 0, &lng, &lat, &ele)
}
+89 -75
View File
@@ -1,8 +1,8 @@
use std::rc::Rc;
use crate::{
Apply, CommandError, File, Selection, State, Track, TrackSegment, Waypoints, copy_file,
copy_segment, copy_track, copy_waypoint,
Apply, CommandError, File, Route, SegmentIndex, Selection, State, Track, TrackSegment,
Waypoints, copy_file, copy_route, copy_segment, copy_track, copy_waypoint,
};
use super::route::find_target;
@@ -13,24 +13,27 @@ use super::route::find_target;
pub enum SplitType {
/// The file becomes two files, which both have the waypoints (copies, in the second one).
Files,
/// The route becomes two routes.
Routes,
/// The track becomes two tracks.
Tracks,
/// The segment becomes two segments.
/// The segment of a track becomes two segments.
Segments,
}
/// Cuts the file, the track or the segment of the trackpoint `at` of the selection (see
/// Cuts the file, the route, the track or the segment of the trackpoint `at` of the selection (see
/// `RoutingBuffer`) in two, there: the trackpoint is the last one of the first part and the first
/// one of the second part.
/// one of the second part. Cutting a route needs the trackpoint to be in a route, and cutting a
/// track or a segment needs it to be in a track: there is nothing to do otherwise.
///
/// The first part keeps the ids of what is cut, the second one has new ids, as the elements that
/// come after the trackpoint (the following segments of the track, the following tracks of the
/// file), which are moved to it. When the file is cut, the new file comes right after it. The
/// selection does not change.
/// come after the trackpoint (the following segments of the track, the following routes and
/// tracks of the file, the routes coming before the tracks), which are moved to it. When the file
/// is cut, the new file comes right after it. The selection does not change.
///
/// With `keep_originals`, a file that is cut (`SplitType::Files`) is left as it is: its two parts
/// are two new files, with new ids, which come right after it and are selected. Cutting a track
/// or a segment changes the file in place anyway, so the flag has no effect on them.
/// are two new files, with new ids, which come right after it and are selected. Cutting a route, a
/// track or a segment changes the file in place anyway, so the flag has no effect on them.
#[derive(Debug)]
pub struct Split {
pub at: u32,
@@ -43,104 +46,115 @@ impl Apply for Split {
let at = self.at as usize;
let target = find_target(state, at, at)?.ok_or(CommandError::NothingToDo)?;
let location = target.location;
let (file_id, trk, seg, index) =
(location.file_id, location.trk, location.seg, target.start);
let (file_id, index) = (location.file_id, target.start);
let in_route = matches!(location.index, SegmentIndex::Route(_));
let fits = match self.split_type {
SplitType::Files => true,
SplitType::Routes => in_route,
SplitType::Tracks | SplitType::Segments => !in_route,
};
if !fits {
return Err(CommandError::NothingToDo);
}
let mut file = (*state.files[&file_id]).clone();
if index >= file.trk[trk].trkseg[seg].len() {
if index >= file.segment(location.index).len() {
return Err(CommandError::InvalidData(
"trackpoint out of the segment".into(),
));
}
// with `SplitType::Files`, what goes to the second file: what comes after the trackpoint
let mut second_file = File {
id: Default::default(),
rte: vec![],
trk: vec![],
wpt: Waypoints::default(),
..file.clone()
};
match location.index {
SegmentIndex::Route(rte) => {
let route = file.rte[rte].clone();
let first = Route {
seg: keep_up_to(&route.seg, index),
..route.clone()
};
let second = Route {
id: Default::default(),
seg: keep_from(&route.seg, index),
..route.clone()
};
if self.split_type == SplitType::Routes {
file.rte.splice(rte..=rte, [first, second]);
state.files.insert(file_id, Rc::new(file));
return Ok(());
}
// the following routes, and the tracks, which come after the routes
second_file.rte = [second]
.into_iter()
.chain(file.rte[rte + 1..].iter().map(copy_route))
.collect();
second_file.trk = file.trk.iter().map(copy_track).collect();
file.rte.truncate(rte);
file.rte.push(first);
file.trk.clear();
}
SegmentIndex::Track { trk, seg } => {
let track = file.trk[trk].clone();
let segment = &track.trkseg[seg];
let (first, second) = (keep_up_to(segment, index), keep_from(segment, index));
// the track is cut: what is before the segment, and its first part, then the second part
// and what is after it
let split_track = || {
if self.split_type == SplitType::Segments {
file.trk[trk].trkseg.splice(seg..=seg, [first, second]);
state.files.insert(file_id, Rc::new(file));
return Ok(());
}
// the track is cut: what is before the segment, and its first part, then the
// second part and what is after it
let mut first_track = track.clone();
first_track.trkseg.truncate(seg);
first_track.trkseg.push(first.clone());
first_track.trkseg.push(first);
let mut second_track = Track {
id: Default::default(),
trkseg: vec![second.clone()],
trkseg: vec![second],
..track.clone()
};
second_track
.trkseg
.extend(track.trkseg[seg + 1..].iter().map(copy_segment));
(first_track, second_track)
};
match self.split_type {
SplitType::Segments => {
file.trk[trk].trkseg.splice(seg..=seg, [first, second]);
}
SplitType::Tracks => {
let (first_track, second_track) = split_track();
if self.split_type == SplitType::Tracks {
file.trk.splice(trk..=trk, [first_track, second_track]);
state.files.insert(file_id, Rc::new(file));
return Ok(());
}
SplitType::Files if self.keep_originals => {
let (first_track, second_track) = split_track();
// the parts are new files: nothing is shared by id with the file, nor between them
let first_file = copy_file(&File {
trk: file.trk[..trk]
.iter()
.cloned()
.chain([first_track])
.collect(),
..file.clone()
});
let mut second_file = File {
id: Default::default(),
trk: vec![second_track],
wpt: Waypoints::default(),
..file.clone()
};
second_file
.trk
.extend(file.trk[trk + 1..].iter().map(copy_track));
second_file.trk = [second_track]
.into_iter()
.chain(file.trk[trk + 1..].iter().map(copy_track))
.collect();
file.trk.truncate(trk);
file.trk.push(first_track);
}
}
// the file is cut: both parts have the waypoints
second_file
.wpt
.insert_at(0, file.wpt.iter().map(copy_waypoint).collect());
let ids = [first_file.id, second_file.id];
let position = state.order.0.iter().position(|id| *id == file_id);
let at = position.map_or(state.order.0.len(), |p| p + 1);
if self.keep_originals {
// the parts are new files: nothing is shared by id with the file, nor between them
let first_file = copy_file(&file);
let ids = [first_file.id, second_file.id];
state.order.0.splice(at..at, ids);
state.files.insert(first_file.id, Rc::new(first_file));
state.files.insert(second_file.id, Rc::new(second_file));
*state.selection = Selection::File {
file_ids: ids.into(),
};
return Ok(());
}
SplitType::Files => {
let (first_track, second_track) = split_track();
let mut second_file = File {
id: Default::default(),
trk: vec![second_track],
wpt: Waypoints::default(),
..file.clone()
};
second_file
.trk
.extend(file.trk[trk + 1..].iter().map(copy_track));
second_file
.wpt
.insert_at(0, file.wpt.iter().map(copy_waypoint).collect());
file.trk.truncate(trk);
file.trk.push(first_track);
let position = state.order.0.iter().position(|id| *id == file_id);
state.order.0.insert(
position.map_or(state.order.0.len(), |p| p + 1),
second_file.id,
);
} else {
state.order.0.insert(at, second_file.id);
state.files.insert(second_file.id, Rc::new(second_file));
}
}
state.files.insert(file_id, Rc::new(file));
}
Ok(())
}
}
+2 -4
View File
@@ -45,10 +45,8 @@ impl Apply for Time {
let locations = state
.selection
.segment_locations(state.files, &state.order.0);
let segments: Vec<&TrackSegment> = locations
.iter()
.map(|l| &state.files[&l.file_id].trk[l.trk].trkseg[l.seg])
.collect();
let segments: Vec<&TrackSegment> =
locations.iter().map(|l| l.segment(state.files)).collect();
if segments.iter().all(|segment| segment.is_empty()) {
return Err(CommandError::NothingToDo);
}
@@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet};
use crate::{FileId, FileWaypointsRevisionId, Files, TrackSegmentId, TrackSegmentRevisionId};
/// Coordinates buffers, as flat `[lng, lat, lng, lat, ...]` arrays so that they can be handed
/// to the UI without per-point calls: one per track segment (its trackpoints) and one per file
/// to the UI without per-point calls: one per segment of a route or track (its trackpoints) and one per file
/// (its waypoints). A buffer is only rebuilt when the revision of its segment (or the waypoints
/// of its file) changes, so editing a segment does not touch the buffers of the others.
#[derive(Debug, Default)]
@@ -30,7 +30,7 @@ impl CoordinatesCache {
self.waypoints
.insert(file.id, (file.wpt.rev_id, coordinates));
}
for seg in file.trk.iter().flat_map(|trk| &trk.trkseg) {
for seg in file.segments() {
segments.insert(seg.id);
if self
.segments
+56 -25
View File
@@ -1,9 +1,10 @@
use crate::{
File, FileId, FileWaypointsRevisionId, TrackId, TrackSegmentId, TrackSegmentRevisionId,
WaypointId,
File, FileId, FileWaypointsRevisionId, RouteId, TrackId, TrackInfo, TrackSegment,
TrackSegmentId, TrackSegmentRevisionId, WaypointId,
};
/// What the UI needs to display a file: its name and the structure of its tracks and waypoints,
/// What the UI needs to display a file: its name and the structure of its routes, tracks and
/// waypoints,
/// with the ids that reference every element. Coordinates are not part of it, they are read
/// from the buffers of the [`CoordinatesCache`](crate::CoordinatesCache) (the `i`-th waypoint
/// of a file is the `i`-th coordinates pair of its waypoint buffer, same for the trackpoints of
@@ -13,21 +14,47 @@ pub struct FileStructure {
pub id: FileId,
pub name: String,
pub desc: Option<String>,
pub routes: Vec<RouteNode>,
pub tracks: Vec<TrackNode>,
pub waypoints: Vec<WaypointNode>,
/// Changes whenever the waypoints of the file (hence their buffer) change.
pub wpt_rev_id: FileWaypointsRevisionId,
}
/// The name, description and style of a route or a track (the style only when the file defines
/// it).
#[derive(Debug, Clone, PartialEq)]
pub struct TrackNode {
pub id: TrackId,
pub struct PathInfo {
pub name: Option<String>,
pub desc: Option<String>,
/// Style of the track, only when the file defines it.
pub color: Option<String>,
pub opacity: Option<f64>,
pub width: Option<f64>,
}
impl From<&TrackInfo> for PathInfo {
fn from(info: &TrackInfo) -> Self {
Self {
name: info.name.clone(),
desc: info.desc.clone(),
color: info.color.clone(),
opacity: info.opacity,
width: info.width,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct RouteNode {
pub id: RouteId,
pub info: PathInfo,
pub segment: SegmentNode,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TrackNode {
pub id: TrackId,
pub info: PathInfo,
pub segments: Vec<SegmentNode>,
}
@@ -46,31 +73,38 @@ pub struct WaypointNode {
pub sym: Option<String>,
}
impl From<&TrackSegment> for SegmentNode {
fn from(seg: &TrackSegment) -> Self {
Self {
id: seg.id,
rev_id: seg.rev_id,
len: seg.len(),
}
}
}
impl FileStructure {
pub fn new(file: &File) -> Self {
Self {
id: file.id,
name: file.info.name.clone(),
desc: file.info.desc.clone(),
routes: file
.rte
.iter()
.map(|rte| RouteNode {
id: rte.id,
info: (&rte.info).into(),
segment: (&rte.seg).into(),
})
.collect(),
tracks: file
.trk
.iter()
.map(|trk| TrackNode {
id: trk.id,
name: trk.info.name.clone(),
desc: trk.info.desc.clone(),
color: trk.info.color.clone(),
opacity: trk.info.opacity,
width: trk.info.width,
segments: trk
.trkseg
.iter()
.map(|seg| SegmentNode {
id: seg.id,
rev_id: seg.rev_id,
len: seg.len(),
})
.collect(),
info: (&trk.info).into(),
segments: trk.trkseg.iter().map(Into::into).collect(),
})
.collect(),
waypoints: file
@@ -112,11 +146,8 @@ mod tests {
);
assert_eq!(node.tracks.len(), file.trk.len());
for (n, t) in node.tracks.iter().zip(&file.trk) {
assert_eq!((n.id, &n.name, &n.desc), (t.id, &t.info.name, &t.info.desc));
assert_eq!(
(&n.color, n.opacity, n.width),
(&t.info.color, t.info.opacity, t.info.width)
);
assert_eq!(n.id, t.id);
assert_eq!(n.info, PathInfo::from(&t.info));
assert_eq!(n.segments.len(), t.trkseg.len());
for (n, s) in n.segments.iter().zip(&t.trkseg) {
assert_eq!((n.id, n.rev_id, n.len), (s.id, s.rev_id, s.len()));
+1 -1
View File
@@ -39,7 +39,7 @@ impl RoutingBuffer {
.map(|files| selection.segment_locations(files, order))
.unwrap_or_default()
{
let segment = &files.unwrap()[&location.file_id].trk[location.trk].trkseg[location.seg];
let segment = location.segment(files.unwrap());
signature.push((segment.id, segment.rev_id));
if segment.is_empty() {
continue;
+12 -5
View File
@@ -18,8 +18,7 @@ impl StatisticsCache {
let mut stale: HashSet<TrackSegmentRevisionId> = self.map.keys().copied().collect();
if let Some(state) = state {
for file in state.values() {
for trk in file.trk.iter() {
for trkseg in trk.trkseg.iter() {
for trkseg in file.segments() {
match self.map.entry(trkseg.rev_id) {
Entry::Occupied(_) => {
stale.remove(&trkseg.rev_id);
@@ -31,7 +30,6 @@ impl StatisticsCache {
}
}
}
}
for id in stale.iter() {
self.map.remove(id);
}
@@ -72,6 +70,15 @@ impl StatisticsCache {
}
}
}
Selection::Route { file_id, rte_ids } => {
if let Some(file) = state.get(file_id) {
for rte in file.rte.iter() {
if rte_ids.contains(&rte.id) {
self.add_segment_stats(&rte.seg, &mut selected);
}
}
}
}
Selection::Track { file_id, trk_ids } => {
if let Some(file) = state.get(file_id) {
for trk in file.trk.iter() {
@@ -129,8 +136,8 @@ impl StatisticsCache {
}
fn add_file_stats<'a>(&'a self, file: &'a File, selected: &mut Vec<SelectedSegment<'a>>) {
for trk in file.trk.iter() {
self.add_track_stats(trk, selected);
for trkseg in file.segments() {
self.add_segment_stats(trkseg, selected);
}
}
+3 -9
View File
@@ -82,7 +82,7 @@ impl<H: History> Engine<H> {
.find(|wpt| wpt.id == *id)
}
/// A trackpoint of a segment of a file. `None` if it does not exist.
/// A trackpoint of a segment (of a route or a track) of a file. `None` if it does not exist.
pub fn trackpoint(
&self,
file_id: &FileId,
@@ -93,9 +93,7 @@ impl<H: History> Engine<H> {
.history
.current()?
.get(file_id)?
.trk
.iter()
.flat_map(|trk| &trk.trkseg)
.segments()
.find(|seg| seg.id == *segment_id)?;
(index < segment.len()).then(|| &segment[index])
}
@@ -109,11 +107,7 @@ impl<H: History> Engine<H> {
self.selection
.segment_locations(files, &self.order.0)
.iter()
.flat_map(|location| {
reduction_distances(
&files[&location.file_id].trk[location.trk].trkseg[location.seg],
)
})
.flat_map(|location| reduction_distances(location.segment(files)))
.collect()
}
+41 -10
View File
@@ -3,10 +3,18 @@ use std::rc::Rc;
use uuid::Uuid;
use crate::{
File, FileId, Files, Selection, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint,
WaypointId,
File, FileId, Files, Route, RouteId, Selection, Track, TrackId, TrackSegment, TrackSegmentId,
Waypoint, WaypointId,
};
/// A route in the clipboard, with the name of the file it comes from (the name of the new file
/// when it is pasted on its own).
#[derive(Debug, Clone)]
pub struct ClipboardRoute {
pub route: Route,
pub file_name: String,
}
/// A track in the clipboard, with the name of the file it comes from (the name of the new file
/// when it is pasted on its own).
#[derive(Debug, Clone)]
@@ -31,6 +39,7 @@ pub struct ClipboardSegment {
#[derive(Debug, Clone)]
pub enum ClipboardContent {
Files(Vec<Rc<File>>),
Routes(Vec<ClipboardRoute>),
Tracks(Vec<ClipboardTrack>),
Segments(Vec<ClipboardSegment>),
Waypoints(Vec<Waypoint>),
@@ -84,6 +93,19 @@ impl Clipboard {
.filter_map(|id| files.get(id).cloned())
.collect(),
),
Selection::Route { file_id, rte_ids } => {
let file = files.get(file_id)?;
ClipboardContent::Routes(
file.rte
.iter()
.filter(|rte| rte_ids.contains(&rte.id))
.map(|rte| ClipboardRoute {
route: rte.clone(),
file_name: file.info.name.clone(),
})
.collect(),
)
}
Selection::Track { file_id, trk_ids } => {
let file = files.get(file_id)?;
ClipboardContent::Tracks(
@@ -144,21 +166,23 @@ impl Clipboard {
pub fn is_empty(&self) -> bool {
match &self.content {
ClipboardContent::Files(files) => files.is_empty(),
ClipboardContent::Routes(routes) => routes.is_empty(),
ClipboardContent::Tracks(tracks) => tracks.is_empty(),
ClipboardContent::Segments(segments) => segments.is_empty(),
ClipboardContent::Waypoints(waypoints) => waypoints.is_empty(),
}
}
/// Whether the content can be pasted when `target` is selected: files and tracks can be
/// pasted anywhere above them in the file tree, and nothing selected stands for the list of
/// files.
/// Whether the content can be pasted when `target` is selected: files, routes and tracks can
/// be pasted anywhere above them in the file tree (routes and tracks are at the same level),
/// and nothing selected stands for the list of files.
///
/// | copied | pasted when selected |
/// |-----------|------------------------------------------|
/// |-----------|-----------------------------------------------|
/// | files | nothing, a file |
/// | tracks | nothing, a file, a track |
/// | segments | nothing, a file, a track, a segment |
/// | routes | nothing, a file, a route, a track |
/// | tracks | nothing, a file, a route, a track |
/// | segments | nothing, a file, a route, a track, a segment |
/// | waypoints | a file, the waypoints, a waypoint |
pub fn can_paste(&self, target: &Selection) -> bool {
use ClipboardContent as C;
@@ -166,10 +190,13 @@ impl Clipboard {
matches!(
(&self.content, target),
(C::Files(_), Empty | File { .. })
| (C::Tracks(_), Empty | File { .. } | Track { .. })
| (
C::Routes(_) | C::Tracks(_),
Empty | File { .. } | Route { .. } | Track { .. }
)
| (
C::Segments(_),
Empty | File { .. } | Track { .. } | TrackSegment { .. }
Empty | File { .. } | Route { .. } | Track { .. } | TrackSegment { .. }
)
| (
C::Waypoints(_),
@@ -183,6 +210,7 @@ impl Clipboard {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClipboardIds {
Files(Vec<FileId>),
Routes(Vec<RouteId>),
Tracks(Vec<TrackId>),
Segments(Vec<TrackSegmentId>),
Waypoints(Vec<WaypointId>),
@@ -194,6 +222,9 @@ impl ClipboardContent {
ClipboardContent::Files(files) => {
ClipboardIds::Files(files.iter().map(|file| file.id).collect())
}
ClipboardContent::Routes(routes) => {
ClipboardIds::Routes(routes.iter().map(|rte| rte.route.id).collect())
}
ClipboardContent::Tracks(tracks) => {
ClipboardIds::Tracks(tracks.iter().map(|trk| trk.track.id).collect())
}
+81 -27
View File
@@ -1,6 +1,8 @@
use std::collections::HashSet;
use crate::{FileId, Files, TrackId, TrackSegmentId, WaypointId};
use crate::{
File, FileId, Files, RouteId, SegmentIndex, TrackId, TrackSegment, TrackSegmentId, WaypointId,
};
/// How a new selection combines with the current one.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
@@ -23,6 +25,10 @@ pub enum Selection {
File {
file_ids: HashSet<FileId>,
},
Route {
file_id: FileId,
rte_ids: HashSet<RouteId>,
},
Track {
file_id: FileId,
trk_ids: HashSet<TrackId>,
@@ -41,12 +47,19 @@ pub enum Selection {
},
}
/// Where a track segment is: its file, then its track and its own position in it.
/// Where a segment is: its file, then its place in it (its route, or its track and its own
/// position in it).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SegmentLocation {
pub file_id: FileId,
pub trk: usize,
pub seg: usize,
pub index: SegmentIndex,
}
impl SegmentLocation {
/// The segment, in `files`. Panics if it is not there.
pub fn segment<'a>(&self, files: &'a Files) -> &'a TrackSegment {
files[&self.file_id].segment(self.index)
}
}
impl Selection {
@@ -89,23 +102,18 @@ impl Selection {
}
impl Selection {
/// The track segments covered by the selection, in order: the segments of several selected
/// files follow `order` (the files missing from it come last), then the tracks and segments
/// follow the file. Waypoints are not segments: selecting only waypoints covers none.
/// The segments covered by the selection, in order: the segments of several selected files
/// follow `order` (the files missing from it come last), then the routes, tracks and segments
/// follow the file (see [`File::segments`]). Waypoints are not segments: selecting only
/// waypoints covers none.
pub fn segment_locations(&self, files: &Files, order: &[FileId]) -> Vec<SegmentLocation> {
let mut locations = vec![];
let mut add = |file_id: FileId,
trk_filter: &dyn Fn(&crate::Track) -> bool,
seg_filter: &dyn Fn(&crate::TrackSegment) -> bool| {
let mut add = |file_id: FileId, filter: &dyn Fn(&File, SegmentIndex) -> bool| {
let Some(file) = files.get(&file_id) else {
return;
};
for (trk, track) in file.trk.iter().enumerate().filter(|(_, t)| trk_filter(t)) {
for (seg, segment) in track.trkseg.iter().enumerate() {
if seg_filter(segment) {
locations.push(SegmentLocation { file_id, trk, seg });
}
}
for index in file.segment_indices().filter(|index| filter(file, *index)) {
locations.push(SegmentLocation { file_id, index });
}
};
match self {
@@ -116,25 +124,35 @@ impl Selection {
.filter(|id| file_ids.contains(id))
.chain(file_ids.iter().filter(|id| !order.contains(id)));
for id in ordered {
add(*id, &|_| true, &|_| true);
add(*id, &|_, _| true);
}
}
Selection::Track { file_id, trk_ids } => {
add(*file_id, &|trk| trk_ids.contains(&trk.id), &|_| true)
}
Selection::Route { file_id, rte_ids } => add(*file_id, &|file, index| match index {
SegmentIndex::Route(rte) => rte_ids.contains(&file.rte[rte].id),
SegmentIndex::Track { .. } => false,
}),
Selection::Track { file_id, trk_ids } => add(*file_id, &|file, index| match index {
SegmentIndex::Track { trk, .. } => trk_ids.contains(&file.trk[trk].id),
SegmentIndex::Route(_) => false,
}),
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => add(*file_id, &|trk| trk.id == *trk_id, &|seg| {
trkseg_ids.contains(&seg.id)
} => add(*file_id, &|file, index| match index {
SegmentIndex::Track { trk, seg } => {
let track = &file.trk[trk];
track.id == *trk_id && trkseg_ids.contains(&track.trkseg[seg].id)
}
SegmentIndex::Route(_) => false,
}),
}
locations
}
/// Drops what does not exist anymore (e.g. after an undo): files, and the tracks, segments
/// and waypoints of the files that remain. A selection left with nothing becomes empty.
/// Drops what does not exist anymore (e.g. after an undo): files, and the routes, tracks,
/// segments and waypoints of the files that remain. A selection left with nothing becomes
/// empty.
pub fn retain_existing(&mut self, files: &Files) {
let empty = match self {
Selection::Empty => false,
@@ -142,6 +160,13 @@ impl Selection {
file_ids.retain(|id| files.contains_key(id));
file_ids.is_empty()
}
Selection::Route { file_id, rte_ids } => match files.get(file_id) {
Some(file) => {
rte_ids.retain(|id| file.rte.iter().any(|rte| rte.id == *id));
rte_ids.is_empty()
}
None => true,
},
Selection::Track { file_id, trk_ids } => match files.get(file_id) {
Some(file) => {
trk_ids.retain(|id| file.trk.iter().any(|trk| trk.id == *id));
@@ -185,6 +210,13 @@ impl Selection {
(Selection::File { file_ids }, Selection::File { file_ids: other }) => {
file_ids.extend(other)
}
(
Selection::Route { file_id, rte_ids },
Selection::Route {
file_id: other_file,
rte_ids: other,
},
) if *file_id == other_file => rte_ids.extend(other),
(
Selection::Track { file_id, trk_ids },
Selection::Track {
@@ -232,6 +264,16 @@ impl Selection {
toggle_ids(file_ids, other);
file_ids.is_empty()
}
(
Selection::Route { file_id, rte_ids },
Selection::Route {
file_id: other_file,
rte_ids: other,
},
) if *file_id == other_file => {
toggle_ids(rte_ids, other);
rte_ids.is_empty()
}
(
Selection::Track { file_id, trk_ids },
Selection::Track {
@@ -325,7 +367,8 @@ impl Selection {
match self {
Selection::Empty => Selection::Empty,
Selection::File { .. } => self.clone(),
Selection::Track { file_id, .. }
Selection::Route { file_id, .. }
| Selection::Track { file_id, .. }
| Selection::TrackSegment { file_id, .. }
| Selection::Waypoints { file_id }
| Selection::Waypoint { file_id, .. } => Selection::File {
@@ -335,7 +378,7 @@ impl Selection {
}
/// Selects all the elements of the same kind as the selected ones, in the same place: all
/// the files, all the tracks of the file, all the segments of the track, all the waypoints
/// the files, all the routes (or tracks) of the file, all the segments of the track, all the waypoints
/// of the file. All the files when nothing is selected. `None` when there is nothing to
/// select (the waypoints node, or a file that does not exist).
pub fn all_at_level(&self, files: &Files, order: &[FileId]) -> Option<Selection> {
@@ -343,6 +386,10 @@ impl Selection {
Selection::Empty | Selection::File { .. } => Some(Selection::File {
file_ids: order.iter().copied().collect(),
}),
Selection::Route { file_id, .. } => Some(Selection::Route {
file_id: *file_id,
rte_ids: files.get(file_id)?.rte.iter().map(|rte| rte.id).collect(),
}),
Selection::Track { file_id, .. } => Some(Selection::Track {
file_id: *file_id,
trk_ids: files.get(file_id)?.trk.iter().map(|trk| trk.id).collect(),
@@ -371,7 +418,7 @@ impl Selection {
/// The element next to the selected ones, as a selection of that one element: the next one
/// (`down`) or the previous one among the elements of the same kind, in the same place.
/// The tracks, segments and waypoints stop at their ends, the files wrap around and skip
/// The routes, tracks, segments and waypoints stop at their ends, the files wrap around and skip
/// the ones that are already selected. `None` when there is no such element.
pub fn neighbour(&self, files: &Files, order: &[FileId], down: bool) -> Option<Selection> {
match self {
@@ -393,6 +440,13 @@ impl Selection {
}
}
}
Selection::Route { file_id, rte_ids } => {
let ids: Vec<RouteId> = files.get(file_id)?.rte.iter().map(|r| r.id).collect();
Some(Selection::Route {
file_id: *file_id,
rte_ids: [neighbour(&ids, rte_ids, down)?].into(),
})
}
Selection::Track { file_id, trk_ids } => {
let ids: Vec<TrackId> = files.get(file_id)?.trk.iter().map(|t| t.id).collect();
Some(Selection::Track {
+2
View File
@@ -87,6 +87,7 @@ fn new(engine: &mut TestEngine, name: &str) -> bool {
Command::New(New {
name,
trackpoint: None,
route: false,
}),
)
}
@@ -1489,6 +1490,7 @@ mod per_file_history {
.execute(Action::Edit(Command::New(New {
name,
trackpoint: None,
route: false,
})))
.unwrap();
*engine.order().last().unwrap()
+88 -36
View File
@@ -18,9 +18,9 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
File, FileId, FileInfo, Track, TrackId, TrackInfo, TrackSegment, TrackSegmentId,
TrackpointCategories, TrackpointChunk, TrackpointChunkId, Waypoint, WaypointChunk,
WaypointChunkId, Waypoints,
File, FileId, FileInfo, Route, RouteId, Track, TrackId, TrackInfo, TrackSegment,
TrackSegmentId, TrackpointCategories, TrackpointChunk, TrackpointChunkId, Waypoint,
WaypointChunk, WaypointChunkId, Waypoints,
};
/// The version that is written.
@@ -54,10 +54,17 @@ fn parse<'a, T: Deserialize<'a>>(data: &'a [u8]) -> Result<T, DecodeError> {
/// A file without its trackpoints and waypoints, which are in chunks.
pub struct FileRecord {
info: FileInfo,
routes: Vec<RouteRecord>,
tracks: Vec<TrackRecord>,
waypoints: Vec<WaypointChunkId>,
}
struct RouteRecord {
id: RouteId,
info: TrackInfo,
segment: SegmentRecord,
}
struct TrackRecord {
id: TrackId,
info: TrackInfo,
@@ -69,24 +76,35 @@ struct SegmentRecord {
chunks: Vec<TrackpointChunkId>,
}
impl From<v1::SegmentRecord1> for SegmentRecord {
fn from(segment: v1::SegmentRecord1) -> Self {
Self {
id: TrackSegmentId(segment.id),
chunks: segment.chunks.into_iter().map(TrackpointChunkId).collect(),
}
}
}
impl From<v1::FileRecord1> for FileRecord {
fn from(record: v1::FileRecord1) -> Self {
Self {
info: record.info.into(),
routes: record
.routes
.into_iter()
.map(|route| RouteRecord {
id: RouteId(route.id),
info: route.info.into(),
segment: route.segment.into(),
})
.collect(),
tracks: record
.tracks
.into_iter()
.map(|track| TrackRecord {
id: TrackId(track.id),
info: track.info.into(),
segments: track
.segments
.into_iter()
.map(|segment| SegmentRecord {
id: TrackSegmentId(segment.id),
chunks: segment.chunks.into_iter().map(TrackpointChunkId).collect(),
})
.collect(),
segments: track.segments.into_iter().map(Into::into).collect(),
})
.collect(),
waypoints: record.waypoints.into_iter().map(WaypointChunkId).collect(),
@@ -108,16 +126,27 @@ impl FileRecord {
info: self.info,
..Default::default()
};
for track in self.tracks {
let mut trkseg = Vec::with_capacity(track.segments.len());
for segment in track.segments {
let segment = |record: SegmentRecord| {
let mut built = TrackSegment::default();
built.id = segment.id;
for chunk in &segment.chunks {
built.id = record.id;
for chunk in &record.chunks {
built.push_shared(trackpoint_chunks(chunk)?);
}
trkseg.push(built);
Some(built)
};
for route in self.routes {
file.rte.push(Route {
id: route.id,
info: route.info,
seg: segment(route.segment)?,
});
}
for track in self.tracks {
let trkseg = track
.segments
.into_iter()
.map(segment)
.collect::<Option<Vec<_>>>()?;
file.trk.push(Track {
id: track.id,
info: track.info,
@@ -292,6 +321,20 @@ mod tests {
});
let mut empty = TrackSegment::default();
empty.id = TrackSegmentId(id(6));
// a route, whose segment shares the chunk of the track
let mut route_segment = TrackSegment::default();
route_segment.id = TrackSegmentId(id(10));
route_segment.push_shared(segment.chunks()[0].clone());
file.rte.push(Route {
id: RouteId(id(9)),
info: TrackInfo {
name: Some("route".into()),
type_: Some("Hiking".into()),
color: Some("00ff00".into()),
..Default::default()
},
seg: route_segment,
});
file.trk.push(Track {
id: TrackId(id(2)),
info: TrackInfo {
@@ -352,9 +395,7 @@ mod tests {
HashMap<WaypointChunkId, Rc<WaypointChunk>>,
) {
let trackpoints = file
.trk
.iter()
.flat_map(|track| &track.trkseg)
.segments()
.flat_map(|segment| segment.chunks().iter().map(|c| (c.id, c.clone())))
.collect();
let waypoints = file
@@ -373,20 +414,29 @@ mod tests {
})
}
fn assert_same_file(a: &File, b: &File) {
assert_eq!(a.id, b.id);
assert_eq!(a.info, b.info);
assert_eq!(a.trk.len(), b.trk.len());
for (a, b) in a.trk.iter().zip(&b.trk) {
assert_eq!((a.id, &a.info), (b.id, &b.info));
assert_eq!(a.trkseg.len(), b.trkseg.len());
for (a, b) in a.trkseg.iter().zip(&b.trkseg) {
fn assert_same_segment(a: &TrackSegment, b: &TrackSegment) {
assert_eq!(a.id, b.id);
assert_eq!(a.len(), b.len());
for (a, b) in a.chunks().iter().zip(b.chunks()) {
assert!(Rc::ptr_eq(a, b), "the chunks are the ones that were given");
}
}
fn assert_same_file(a: &File, b: &File) {
assert_eq!(a.id, b.id);
assert_eq!(a.info, b.info);
assert_eq!(a.rte.len(), b.rte.len());
for (a, b) in a.rte.iter().zip(&b.rte) {
assert_eq!((a.id, &a.info), (b.id, &b.info));
assert_same_segment(&a.seg, &b.seg);
}
assert_eq!(a.trk.len(), b.trk.len());
for (a, b) in a.trk.iter().zip(&b.trk) {
assert_eq!((a.id, &a.info), (b.id, &b.info));
assert_eq!(a.trkseg.len(), b.trkseg.len());
for (a, b) in a.trkseg.iter().zip(&b.trkseg) {
assert_same_segment(a, b);
}
}
assert_eq!(a.wpt.len(), b.wpt.len());
for (a, b) in a.wpt.chunks().iter().zip(b.wpt.chunks()) {
@@ -425,7 +475,7 @@ mod tests {
.unwrap()
.build(file.id, &|_| None, &|_| None)
.unwrap();
assert!(built.trk.is_empty() && built.wpt.is_empty());
assert!(built.rte.is_empty() && built.trk.is_empty() && built.wpt.is_empty());
}
/// The names of the categories of a trackpoint.
@@ -611,13 +661,15 @@ mod tests {
"70733a2f2f6578616d706c652e636f6d2f736f6d656f6e65021868747470733a2f2f6578616d706c652e636f",
"6d2f66696c65012074657874206f662068747470733a2f2f6578616d706c652e636f6d2f66696c651a687474",
"70733a2f2f6578616d706c652e636f6d2f66696c652d32012274657874206f662068747470733a2f2f657861",
"6d706c652e636f6d2f66696c652d320180a0abfef96201100000000000000000000000000000000201057472",
"61636b0103636d740104646573630103737263021968747470733a2f2f6578616d706c652e636f6d2f747261",
"636b012174657874206f662068747470733a2f2f6578616d706c652e636f6d2f747261636b1b68747470733a",
"2f2f6578616d706c652e636f6d2f747261636b2d32012374657874206f662068747470733a2f2f6578616d70",
"6c652e636f6d2f747261636b2d3201074379636c696e67010666663030303001000000000000e03f01000000",
"0000001840021000000000000000000000000000000003011000000000000000000000000000000004100000",
"000000000000000000000000000600011000000000000000000000000000000005",
"6d706c652e636f6d2f66696c652d320180a0abfef9620110000000000000000000000000000000090105726f",
"75746500000000010648696b696e6701063030666630300000100000000000000000000000000000000a0110",
"000000000000000000000000000000040110000000000000000000000000000000020105747261636b010363",
"6d740104646573630103737263021968747470733a2f2f6578616d706c652e636f6d2f747261636b01217465",
"7874206f662068747470733a2f2f6578616d706c652e636f6d2f747261636b1b68747470733a2f2f6578616d",
"706c652e636f6d2f747261636b2d32012374657874206f662068747470733a2f2f6578616d706c652e636f6d",
"2f747261636b2d3201074379636c696e67010666663030303001000000000000e03f01000000000000184002",
"1000000000000000000000000000000003011000000000000000000000000000000004100000000000000000",
"000000000000000600011000000000000000000000000000000005",
);
const TRACKPOINTS_V1: &str = concat!(
"01020667726176656c0761737068616c74010470617468010668696b696e6701046d74623003000000000000",
+29 -10
View File
@@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
Author, Categories, File, FileInfo, Link, LngLat, TrackInfo, Trackpoint, TrackpointCategories,
Waypoint, WaypointId,
Author, Categories, File, FileInfo, Link, LngLat, TrackInfo, TrackSegment, Trackpoint,
TrackpointCategories, Waypoint, WaypointId,
};
#[derive(Serialize, Deserialize)]
@@ -133,10 +133,18 @@ impl From<TrackInfo1> for TrackInfo {
#[derive(Serialize, Deserialize)]
pub struct FileRecord1 {
pub info: FileInfo1,
pub routes: Vec<RouteRecord1>,
pub tracks: Vec<TrackRecord1>,
pub waypoints: Vec<Uuid>,
}
#[derive(Serialize, Deserialize)]
pub struct RouteRecord1 {
pub id: Uuid,
pub info: TrackInfo1,
pub segment: SegmentRecord1,
}
#[derive(Serialize, Deserialize)]
pub struct TrackRecord1 {
pub id: Uuid,
@@ -150,24 +158,35 @@ pub struct SegmentRecord1 {
pub chunks: Vec<Uuid>,
}
impl SegmentRecord1 {
fn new(segment: &TrackSegment) -> Self {
Self {
id: segment.id.0,
chunks: segment.chunks().iter().map(|chunk| chunk.id.0).collect(),
}
}
}
impl FileRecord1 {
pub fn new(file: &File) -> Self {
Self {
info: (&file.info).into(),
routes: file
.rte
.iter()
.map(|route| RouteRecord1 {
id: route.id.0,
info: (&route.info).into(),
segment: SegmentRecord1::new(&route.seg),
})
.collect(),
tracks: file
.trk
.iter()
.map(|track| TrackRecord1 {
id: track.id.0,
info: (&track.info).into(),
segments: track
.trkseg
.iter()
.map(|segment| SegmentRecord1 {
id: segment.id.0,
chunks: segment.chunks().iter().map(|chunk| chunk.id.0).collect(),
})
.collect(),
segments: track.trkseg.iter().map(SegmentRecord1::new).collect(),
})
.collect(),
waypoints: file.wpt.chunks().iter().map(|chunk| chunk.id.0).collect(),
+1 -2
View File
@@ -217,8 +217,7 @@ impl Persister {
.files
.get(id)
.is_none_or(|saved| !Rc::ptr_eq(saved, file));
let segments = file.trk.iter().flat_map(|track| &track.trkseg);
for chunk in segments.flat_map(|segment| segment.chunks()) {
for chunk in file.segments().flat_map(|segment| segment.chunks()) {
let key = ChunkKey::trackpoints(chunk.id.0);
if chunks.insert(key) && changed && !self.chunks.contains(&key) {
batch
-2
View File
@@ -313,7 +313,6 @@ pub struct ExportArguments {
atemp: bool,
power: bool,
osm: bool,
as_route: bool,
}
impl From<ExportArguments> for ExportOptions {
@@ -325,7 +324,6 @@ impl From<ExportArguments> for ExportOptions {
atemp: arguments.atemp,
power: arguments.power,
osm: arguments.osm,
as_route: arguments.as_route,
}
}
}
+10
View File
@@ -38,6 +38,16 @@ pub fn style(
state.run(move |e| api::style(e, color.as_deref(), opacity, width))
}
#[tauri::command(async)]
pub fn convert(state: State<AppState>) -> api::Outcome {
state.run(api::convert)
}
#[tauri::command(async)]
pub fn new_route(state: State<AppState>) -> api::Outcome {
state.run(api::new_route)
}
#[tauri::command(async)]
pub fn new_track(state: State<AppState>) -> api::Outcome {
state.run(api::new_track)
+7 -1
View File
@@ -16,10 +16,16 @@ pub fn new_file(
lng: Option<f64>,
lat: Option<f64>,
ele: Option<f64>,
route: Option<bool>,
folder: Option<PathBuf>,
) -> api::Outcome {
let route = route.unwrap_or_default();
// in `folder` if it is given, otherwise like the selected file, otherwise in the open folder
state.core(move |core| core.creating(folder, true, |e| api::new_file(e, &name, lng, lat, ele)))
state.core(move |core| {
core.creating(folder, true, |e| {
api::new_file(e, &name, lng, lat, ele, route)
})
})
}
/// The files are in the body, one after the other, the headers `lengths` and `names` (JSON,
-2
View File
@@ -79,7 +79,6 @@ pub fn export_file(
atemp: bool,
power: bool,
osm: bool,
as_route: bool,
) -> Bytes {
let options = ExportOptions {
time,
@@ -88,7 +87,6 @@ pub fn export_file(
atemp,
power,
osm,
as_route,
};
optional(state.read(move |e| api::export_file(e, file_id, options)))
}
+10
View File
@@ -21,6 +21,16 @@ pub fn arrow_select(state: State<AppState>, down: bool, add: bool) -> api::Outco
state.run(move |e| api::arrow_select(e, down, add))
}
#[tauri::command(async)]
pub fn select_routes(
state: State<AppState>,
file_id: Uuid,
route_ids: Vec<Uuid>,
mode: SelectMode,
) -> api::Outcome {
state.run(move |e| api::select_routes(e, file_id, &route_ids, mode))
}
#[tauri::command(async)]
pub fn select_tracks(
state: State<AppState>,
+3
View File
@@ -114,6 +114,7 @@ fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Bu
selection::select,
selection::select_all,
selection::arrow_select,
selection::select_routes,
selection::select_tracks,
selection::select_segments,
selection::select_waypoint_group,
@@ -134,6 +135,8 @@ fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Bu
// edits and tools
edit::metadata,
edit::style,
edit::convert,
edit::new_route,
edit::new_track,
edit::new_track_segment,
edit::new_waypoint,
+2 -2
View File
@@ -257,7 +257,7 @@ fn waypoints_exports_and_routing_revisions() {
let exported = app.bytes(
"export_file",
json!({ "fileId": file, "time": true, "hr": true, "cad": true, "atemp": true, "power": true, "osm": true, "asRoute": false }),
json!({ "fileId": file, "time": true, "hr": true, "cad": true, "atemp": true, "power": true, "osm": true }),
);
assert_eq!(exported[0], 1);
assert!(
@@ -483,7 +483,7 @@ fn files_made_from_a_file_of_the_open_folder_are_written_next_to_it_at_once() {
assert_eq!(opened.added().len(), 1);
let copy = core.creating(None, false, gpx_engine::api::duplicate);
let fresh = core.creating(None, true, |e| {
gpx_engine::api::new_file(e, "fresh", None, None, None)
gpx_engine::api::new_file(e, "fresh", None, None, None, false)
});
// a copy of the file goes next to it, a new file with nothing selected in the selection
// of the file that was just made
+7 -6
View File
@@ -1191,6 +1191,7 @@ mod tests {
/// What the file holds: the name of its track (its own name is the one of the file).
fn content(&self, id: Uuid) -> String {
self.engine.file_structure(&FileId(id)).unwrap().tracks[0]
.info
.name
.clone()
.unwrap_or_default()
@@ -1246,7 +1247,7 @@ mod tests {
#[test]
fn a_file_that_is_not_on_the_disk_is_unsaved_until_it_is_given_a_place() {
let mut fx = Fixture::new();
api::new_file(&mut fx.engine, "fresh", None, None, None);
api::new_file(&mut fx.engine, "fresh", None, None, None, false);
let id = fx.ids()[0];
assert!(fx.workspace.is_dirty(&fx.engine, id));
assert_eq!(
@@ -1271,9 +1272,9 @@ mod tests {
fn several_files_without_a_place_go_in_a_folder_with_names_that_are_free() {
let mut fx = Fixture::new();
fx.write("fresh.gpx", "already there");
api::new_file(&mut fx.engine, "fresh", None, None, None);
api::new_file(&mut fx.engine, "a/b: c", None, None, None);
api::new_file(&mut fx.engine, "fresh", None, None, None);
api::new_file(&mut fx.engine, "fresh", None, None, None, false);
api::new_file(&mut fx.engine, "a/b: c", None, None, None, false);
api::new_file(&mut fx.engine, "fresh", None, None, None, false);
let ids = fx.ids();
let folder = fx.folder.clone();
fx.workspace
@@ -1696,7 +1697,7 @@ mod tests {
fx.open_folder();
let a = fx.ids()[0];
fx.rename(a, "edited");
api::new_file(&mut fx.engine, "fresh", None, None, None);
api::new_file(&mut fx.engine, "fresh", None, None, None, false);
let fresh = fx.id_named("fresh");
fx.workspace.close_files(&mut fx.engine, &[a, fresh], true);
assert!(fx.ids().is_empty());
@@ -1712,7 +1713,7 @@ mod tests {
let mut fx = Fixture::new();
fx.write("a.gpx", "a");
fx.open_folder();
api::new_file(&mut fx.engine, "fresh", None, None, None);
api::new_file(&mut fx.engine, "fresh", None, None, None, false);
assert_eq!(fx.ids().len(), 2);
// the new file has nowhere to go: it has to be discarded
+11
View File
@@ -18,6 +18,17 @@ pub fn style(color: Option<String>, opacity: Option<f64>, width: Option<f64>) ->
run(|e| api::style(e, color.as_deref(), opacity, width))
}
/// Converts the selected routes to tracks, or the selected tracks or segments to routes.
#[wasm_bindgen]
pub fn convert() -> Outcome {
run(api::convert)
}
#[wasm_bindgen]
pub fn new_route() -> Outcome {
run(api::new_route)
}
#[wasm_bindgen]
pub fn new_track() -> Outcome {
run(api::new_track)
+11 -4
View File
@@ -10,11 +10,18 @@ use crate::ts::*;
// File commands
/// Creates a file and selects it. With `lng` and `lat`, it starts with a track and a segment that
/// hold that trackpoint (at `ele`, 0 by default).
/// Creates a file and selects it. With `route`, it starts with a route. With `lng` and `lat`, it
/// starts with a segment that holds that trackpoint (at `ele`, 0 by default): the one of its route
/// with `route`, of a track otherwise.
#[wasm_bindgen]
pub fn new_file(name: &str, lng: Option<f64>, lat: Option<f64>, ele: Option<f64>) -> Outcome {
run(|e| api::new_file(e, name, lng, lat, ele))
pub fn new_file(
name: &str,
lng: Option<f64>,
lat: Option<f64>,
ele: Option<f64>,
route: bool,
) -> Outcome {
run(|e| api::new_file(e, name, lng, lat, ele, route))
}
/// Loads files as a single command (one undo step). The files are in `data`, one after the
+1 -5
View File
@@ -45,10 +45,8 @@ 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
/// 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.
/// to keep.
#[wasm_bindgen]
#[allow(clippy::too_many_arguments)]
pub fn export_file(
file_id: &str,
time: bool,
@@ -57,7 +55,6 @@ pub fn export_file(
atemp: bool,
power: bool,
osm: bool,
as_route: bool,
) -> Option<Vec<u8>> {
let id = parse_uuid(file_id)?;
let options = ExportOptions {
@@ -67,7 +64,6 @@ pub fn export_file(
atemp,
power,
osm,
as_route,
};
with_engine(|e| api::export_file(e, id, options)).flatten()
}
+8
View File
@@ -41,6 +41,14 @@ pub fn arrow_select(down: bool, add: bool) -> Outcome {
// with the selection when it holds elements of the same kind in the same place (same file, same
// track for segments), otherwise they replace it.
#[wasm_bindgen]
pub fn select_routes(file_id: &str, route_ids_bytes: &[u8], mode: SelectMode) -> Outcome {
match parse_uuid(file_id).zip(uuid_list(route_ids_bytes)) {
Some((file_id, ids)) => run(|e| api::select_routes(e, file_id, &ids, mode.into())),
None => fail("the ids are not UUIDs"),
}
}
#[wasm_bindgen]
pub fn select_tracks(file_id: &str, track_ids_bytes: &[u8], mode: SelectMode) -> Outcome {
match parse_uuid(file_id).zip(uuid_list(track_ids_bytes)) {
+27 -21
View File
@@ -16,6 +16,7 @@ use crate::types::*;
const SIMPLE: &[u8] = include_bytes!("../../gpx-rs/data/simple.gpx");
const WITH_TIME: &[u8] = include_bytes!("../../gpx-rs/data/with_time.gpx");
const WITH_SURFACE: &[u8] = include_bytes!("../../gpx-rs/data/with_surface.gpx");
const WITH_ROUTES: &[u8] = include_bytes!("../../gpx-rs/data/with_routes.gpx");
thread_local! {
static LAST: std::cell::RefCell<JsValue> = const { std::cell::RefCell::new(JsValue::UNDEFINED) };
@@ -83,7 +84,7 @@ fn uuid_bytes(ids: &[String]) -> Vec<u8> {
fn test_new_file_is_listed_and_selected() {
start();
assert!(run(crate::command::files::new_file(
"route", None, None, None
"route", None, None, None, false
)));
assert_eq!(last_error(), None);
@@ -112,10 +113,7 @@ fn test_unknown_file_has_no_structure() {
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_statistics(&id).is_undefined());
assert!(
crate::queries::files::export_file(&id, true, true, true, true, true, true, false)
.is_none()
);
assert!(crate::queries::files::export_file(&id, true, true, true, true, true, true).is_none());
assert_eq!(
crate::queries::coordinates::segment_coordinates(&id).length(),
0
@@ -200,7 +198,9 @@ fn test_the_reason_of_a_failure_can_be_read() {
assert!(!run(crate::command::tools::reverse()));
assert_eq!(last_error().as_deref(), Some("nothing to do"));
// the next call starts afresh
assert!(run(crate::command::files::new_file("a", None, None, None)));
assert!(run(crate::command::files::new_file(
"a", None, None, None, false
)));
assert_eq!(last_error(), None);
// invalid data
@@ -253,7 +253,9 @@ fn test_the_reason_of_a_failure_can_be_read() {
assert!(last_error().is_some());
// answers are not failures
assert!(run(crate::command::files::new_file("b", None, None, None)));
assert!(run(crate::command::files::new_file(
"b", None, None, None, false
)));
assert!(!crate::command::files::can_paste());
assert_eq!(last_error(), None);
}
@@ -262,7 +264,9 @@ fn test_the_reason_of_a_failure_can_be_read() {
fn test_undo_and_redo() {
started_with(&[]);
assert!(!can_undo() && !can_redo());
assert!(run(crate::command::files::new_file("a", None, None, None)));
assert!(run(crate::command::files::new_file(
"a", None, None, None, false
)));
assert!(can_undo() && !can_redo());
assert!(run(undo()));
assert!(order().is_empty());
@@ -279,8 +283,7 @@ fn test_export_and_exportable_data() {
let ids = order();
let bytes =
crate::queries::files::export_file(&ids[0], true, true, true, true, true, true, false)
.unwrap();
crate::queries::files::export_file(&ids[0], true, true, true, true, true, true).unwrap();
assert!(String::from_utf8(bytes).unwrap().contains("<gpx"));
// the time is the first bit
@@ -300,18 +303,17 @@ fn test_export_and_exportable_data() {
}
#[wasm_bindgen_test]
fn test_export_as_route() {
started_with(&[(SIMPLE, "plain")]);
let id = order().remove(0);
let text = |as_route| {
fn test_export_tracks_and_routes() {
started_with(&[(SIMPLE, "track"), (WITH_ROUTES, "routes")]);
let text = |id: &str| {
let bytes =
crate::queries::files::export_file(&id, true, true, true, true, true, true, as_route)
.unwrap();
crate::queries::files::export_file(id, true, true, true, true, true, true).unwrap();
String::from_utf8(bytes).unwrap()
};
let tracks = text(false);
let ids = order();
let tracks = text(&ids[0]);
assert!(tracks.contains("<trk>") && !tracks.contains("<rte>"));
let routes = text(true);
let routes = text(&ids[1]);
assert!(routes.contains("<rte>") && !routes.contains("<trk>"));
assert!(routes.contains("<rtept"));
}
@@ -390,7 +392,7 @@ fn test_move_elements_takes_js_objects() {
fn test_waypoints() {
started_with(&[]);
assert!(run(crate::command::files::new_file(
"file", None, None, None
"file", None, None, None, false
)));
let file = order().remove(0);
assert!(run(crate::command::waypoints::new_waypoint(
@@ -494,7 +496,9 @@ fn test_editing_tools_change_the_statistics() {
#[wasm_bindgen_test]
fn test_last_update_describes_the_last_call_only() {
started_with(&[]);
assert!(run(crate::command::files::new_file("a", None, None, None)));
assert!(run(crate::command::files::new_file(
"a", None, None, None, false
)));
let update = last_outcome();
assert_eq!(array(get(&update, "added")).length(), 1);
assert_eq!(get(&update, "orderChanged").as_bool(), Some(true));
@@ -506,7 +510,9 @@ fn test_last_update_describes_the_last_call_only() {
assert_eq!(get(&update, "orderChanged").as_bool(), Some(false));
assert_eq!(get(&update, "selectionChanged").as_bool(), Some(false));
assert!(run(crate::command::files::new_file("b", None, None, None)));
assert!(run(crate::command::files::new_file(
"b", None, None, None, false
)));
assert!(!run(crate::command::tools::crop(3, 1)));
let update = last_outcome();
assert_eq!(array(get(&update, "added")).length(), 0);
+18 -5
View File
@@ -28,16 +28,19 @@ const FILE_STRUCTURE_TS: &str = r#"
export type Selection =
| { type: 'empty' }
| { type: 'file'; fileIds: string[] }
| { type: 'route'; fileId: string; routeIds: string[] }
| { type: 'track'; fileId: string; trackIds: string[] }
| { type: 'segment'; fileId: string; trackId: string; segmentIds: string[] }
| { type: 'waypoints'; fileId: string }
| { type: 'waypoint'; fileId: string; waypointIds: string[] };
/**
* Where moved elements go: a list of the file tree and the position in it, counted among the
* elements of the list that are not moved.
* elements of the list that are not moved. Routes and tracks keep their kind: moved among the
* other kind, routes go after the routes, tracks before the tracks.
*/
export type MoveTarget =
| { type: 'files'; index: number }
| { type: 'routes'; fileId: string; index: number }
| { type: 'tracks'; fileId: string; index: number }
| { type: 'segments'; fileId: string; trackId: string; index: number }
| { type: 'waypoints'; fileId: string; index: number };
@@ -83,7 +86,7 @@ export interface SelectionPoint {
}
/** What was copied or cut, to be pasted: the kind of the elements and their ids. */
export interface Clipboard {
type: 'files' | 'tracks' | 'segments' | 'waypoints';
type: 'files' | 'routes' | 'tracks' | 'segments' | 'waypoints';
ids: string[];
cut: boolean;
}
@@ -119,6 +122,8 @@ export interface FileStructure {
id: string;
name: string;
desc?: string;
/** The routes come before the tracks, in the file and in the statistics. */
routes: RouteNode[];
tracks: TrackNode[];
waypoints: WaypointNode[];
/** Changes when the waypoints of the file change: refetch their coordinates. */
@@ -146,14 +151,22 @@ export interface GlobalStatistics {
/** Absent when there are no trackpoints. */
bounds?: { west: number; south: number; east: number; north: number };
}
export interface TrackNode {
id: string;
/** The name, description and style of a route or a track. */
export interface PathInfo {
name?: string;
desc?: string;
/** Style of the track, only present when the file defines it. */
/** Style, only present when the file defines it. */
color?: string;
opacity?: number;
width?: number;
}
/** A route: like a track, with a single segment. */
export interface RouteNode extends PathInfo {
id: string;
segment: SegmentNode;
}
export interface TrackNode extends PathInfo {
id: string;
segments: SegmentNode[];
}
export interface SegmentNode {
+2
View File
@@ -15,6 +15,7 @@ pub enum MergeType {
#[derive(Clone, Copy)]
pub enum SplitType {
Files,
Routes,
Tracks,
Segments,
}
@@ -23,6 +24,7 @@ impl From<SplitType> for engine::SplitType {
fn from(t: SplitType) -> Self {
match t {
SplitType::Files => Self::Files,
SplitType::Routes => Self::Routes,
SplitType::Tracks => Self::Tracks,
SplitType::Segments => Self::Segments,
}
+6
View File
@@ -20,6 +20,12 @@ export function createFile() {
currentTool.set(Tool.ROUTING);
}
/** Creates a file that starts with an empty route, which the routing tool then draws. */
export function createRouteFile() {
engine.newFile(newFileName(), undefined, true);
currentTool.set(Tool.ROUTING);
}
/** Asks for the files to open: with the file picker of the system in the desktop app. */
export async function openFiles() {
if (get(engine.workspace)) {
+31 -3
View File
@@ -48,6 +48,7 @@
Maximize2,
Minimize2,
FolderX,
ArrowRightLeft,
} from '@lucide/svelte';
import { map } from '$lib/components/map/map';
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
@@ -67,6 +68,7 @@
closeFiles,
closeFolder,
createFile,
createRouteFile,
loadFiles,
openFiles,
openFolder,
@@ -105,8 +107,10 @@
let desktop = $derived($workspace !== undefined);
let selectedIds = $derived(selectedFileIds($selection));
let anyUnsaved = $derived(Object.values($workspace?.files ?? {}).some((file) => file.dirty));
// metadata and style can be edited for files and tracks only
let filesOrTracksSelected = $derived($selection.type === 'file' || $selection.type === 'track');
// metadata and style can be edited for files, routes and tracks only
let filesOrTracksSelected = $derived(
$selection.type === 'file' || $selection.type === 'route' || $selection.type === 'track'
);
function switchBasemaps() {
[$currentBasemap, $previousBasemap] = [$previousBasemap, $currentBasemap];
@@ -157,9 +161,13 @@
<Menubar.Content class="border-none">
<Menubar.Item onclick={createFile}>
<Plus size="16" />
{i18n._('menu.new')}
{i18n._('menu.new_file')}
<Shortcut key="+" ctrl={true} />
</Menubar.Item>
<Menubar.Item onclick={createRouteFile}>
<Plus size="16" />
{i18n._('menu.new_route_file')}
</Menubar.Item>
<Menubar.Separator />
<Menubar.Item onclick={openFiles}>
<FolderOpen size="16" />
@@ -308,6 +316,13 @@
<Plus size="16" />
{i18n._('menu.new_track')}
</Menubar.Item>
<Menubar.Item
onclick={() => engine.newRoute()}
disabled={selectionCount !== 1}
>
<Plus size="16" />
{i18n._('menu.new_route')}
</Menubar.Item>
{:else if $selection.type === 'track'}
<Menubar.Separator />
<Menubar.Item
@@ -318,6 +333,19 @@
{i18n._('menu.new_segment')}
</Menubar.Item>
{/if}
{#if $selection.type === 'route' || $selection.type === 'track' || $selection.type === 'segment'}
{#if $selection.type !== 'track'}
<Menubar.Separator />
{/if}
<Menubar.Item onclick={() => engine.convert()}>
<ArrowRightLeft size="16" />
{i18n._(
$selection.type === 'route'
? 'menu.convert_to_track'
: 'menu.convert_to_route'
)}
</Menubar.Item>
{/if}
{/if}
<Menubar.Separator />
<Menubar.Item onclick={() => engine.selectAll()} disabled={noFiles}>
@@ -116,12 +116,16 @@
/** The folder where what is made from the menu goes: the folder of the row. */
let folder = $derived(kind === 'file' ? undefined : path);
/** Asks for the name of a file or a folder to make in the folder of the row (see `creating`). */
function create(what: 'file' | 'folder') {
/**
* Asks for the name of a file or a folder to make in the folder of the row (see `creating`).
* With `route`, the file starts with an empty route.
*/
function create(what: 'file' | 'folder', route = false) {
if (folder === undefined) return;
// where what is being named is shown
folders.open(folder);
creating.kind = what;
creating.route = route;
creating.parent = folder;
}
@@ -286,6 +290,10 @@
<Plus size="16" />
{i18n._('menu.explorer_menu.new_file')}
</ContextMenu.Item>
<ContextMenu.Item onclick={() => create('file', true)}>
<Plus size="16" />
{i18n._('menu.explorer_menu.new_route_file')}
</ContextMenu.Item>
<ContextMenu.Item onclick={() => create('folder')}>
<Plus size="16" />
{i18n._('menu.explorer_menu.new_folder')}
@@ -1,6 +1,6 @@
<script lang="ts">
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
import { FileText, Folder } from '@lucide/svelte';
import { FileText, Folder, Route } from '@lucide/svelte';
import { getContext } from 'svelte';
import { get } from 'svelte/store';
import { toast } from 'svelte-sonner';
@@ -45,6 +45,7 @@
async function create(value: string, retry: boolean) {
const kind = pending;
const parent = folder;
const route = creating.route;
if (kind === undefined || parent === undefined) return;
const name = value.trim();
const taken = entries.some(
@@ -60,7 +61,7 @@
if (name === '' || taken) return;
let created: string | undefined;
if (kind === 'file') {
const id = await engine.newFileIn(name, parent);
const id = await engine.newFileIn(name, parent, route);
created = id === undefined ? undefined : get(workspace)?.files[id]?.path;
if (created !== undefined) {
currentTool.set(Tool.ROUTING);
@@ -94,14 +95,20 @@
<!-- the name of what is being made, which it is made with -->
{#snippet named(kind: 'file' | 'folder')}
<div class="flex flex-row items-center h-7 pl-1 pr-1 {kind === 'file' ? 'gap-1.5' : 'gap-1'}">
{#if kind === 'file'}
{#if kind === 'file' && creating.route}
<Route size="16" class="shrink-0" />
{:else if kind === 'file'}
<FileText size="16" class="shrink-0" />
{:else}
<Folder size="16" class="shrink-0" />
{/if}
<NameInput
placeholder={i18n._(
kind === 'file' ? 'menu.explorer_menu.new_file' : 'menu.explorer_menu.new_folder'
kind === 'folder'
? 'menu.explorer_menu.new_folder'
: creating.route
? 'menu.explorer_menu.new_route_file'
: 'menu.explorer_menu.new_file'
)}
oncommit={create}
oncancel={() => (creating.parent = undefined)}
@@ -15,11 +15,13 @@ export const renaming: { path: string | undefined } = $state({ path: undefined }
/**
* The file or folder that is being made in a folder (`parent`) of the explorer: it is only made once
* the user gave it a name.
* the user gave it a name. With `route`, the file starts with an empty route.
*/
export const creating: { parent: string | undefined; kind: 'file' | 'folder' } = $state({
export const creating: { parent: string | undefined; kind: 'file' | 'folder'; route: boolean } =
$state({
parent: undefined,
kind: 'file',
route: false,
});
/** What is dragged in the explorer, and the folder it would be dropped in. */
@@ -21,7 +21,6 @@
Orbit,
Thermometer,
SquareActivity,
Route,
} from '@lucide/svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import { engine, type ExportOptions } from '$lib/engine';
@@ -38,7 +37,6 @@
atemp: true,
power: true,
osm: false,
asRoute: false,
});
// the files that are exported
let fileIds: string[] = $derived.by(() => {
@@ -61,7 +59,6 @@
atemp: false,
power: false,
osm: false,
asRoute: false,
});
$effect(() => {
const ids = fileIds;
@@ -75,8 +72,6 @@
atemp: !available.atemp,
power: !available.power,
osm: !available.osm,
// always possible
asRoute: false,
};
}
});
@@ -131,7 +126,6 @@
atemp: exportOptions.atemp && !hide.atemp,
power: exportOptions.power && !hide.power,
osm: exportOptions.osm && !hide.osm,
asRoute: exportOptions.asRoute,
};
if (exportState.current === ExportState.SELECTION) {
exportSelectedFiles(options);
@@ -211,13 +205,6 @@
{i18n._('quantities.osm_extensions')}
</Label>
</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>
</Dialog.Content>
@@ -30,6 +30,12 @@
switch (node.type) {
case 'file':
return structure.name;
case 'route': {
const routeIndex = structure.routes.findIndex((r) => r.id === node.routeId);
return (
structure.routes[routeIndex]?.name ?? `${i18n._('gpx.route')} ${routeIndex + 1}`
);
}
case 'track': {
const trackIndex = structure.tracks.findIndex((t) => t.id === node.trackId);
return (
@@ -75,7 +81,7 @@
});
</script>
{#if node.type === 'segment' || node.type === 'waypoint'}
{#if node.type === 'route' || node.type === 'segment' || node.type === 'waypoint'}
<FileListNodeLabel {fileState} {node} {label} />
{:else if recursive}
<CollapsibleTreeNode {id} bind:this={collapsible}>
@@ -84,7 +90,11 @@
{/snippet}
{#snippet content()}
<div class="ml-4">
{#if node.type === 'file' && fileState.structure.routes.length > 0}
<FileListNodeContent {fileState} {node} part="routes" />
{:else}
<FileListNodeContent {fileState} {node} />
{/if}
</div>
{/snippet}
</CollapsibleTreeNode>
@@ -10,16 +10,21 @@
let {
fileState,
node,
waypointRoot = false,
part = 'tracks',
}: {
/** State of the file of the node (not needed for the root). */
fileState?: FileState;
/** The node whose children are listed, `null` for the list of the files. */
node: FileTreeNode | null;
/** List the node standing for the waypoints of the file, instead of its tracks. */
waypointRoot?: boolean;
/**
* For a file, which of its lists this is: its routes, its tracks, or the node standing for
* its waypoints. Each one is followed by the next ones.
*/
part?: 'routes' | 'tracks' | 'waypoints';
} = $props();
let waypointRoot = part === 'waypoints';
const { files, order } = engine;
let container: HTMLElement;
@@ -27,8 +32,10 @@
node === null
? 'file'
: node.type === 'file'
? waypointRoot
? part === 'waypoints'
? 'waypoints'
: part === 'routes'
? 'route'
: 'track'
: node.type === 'waypoints'
? 'waypoint'
@@ -74,7 +81,7 @@
{/each}
{:else if fileState}
{#if node.type === 'file'}
{#if waypointRoot}
{#if part === 'waypoints'}
{#if fileState.structure.waypoints.length > 0}
<div data-id="waypoints">
<FileListNode
@@ -83,6 +90,15 @@
/>
</div>
{/if}
{:else if part === 'routes'}
{#each fileState.structure.routes as route (route.id)}
<div data-id={route.id}>
<FileListNode
{fileState}
node={{ type: 'route', fileId: node.fileId, routeId: route.id }}
/>
</div>
{/each}
{:else}
{#each fileState.structure.tracks as track (track.id)}
<div data-id={track.id}>
@@ -120,8 +136,10 @@
{/if}
</div>
{#if fileState && node?.type === 'file' && !waypointRoot}
<FileListNodeContent {fileState} {node} waypointRoot={true} />
{#if fileState && node?.type === 'file' && part === 'routes'}
<FileListNodeContent {fileState} {node} part="tracks" />
{:else if fileState && node?.type === 'file' && part === 'tracks'}
<FileListNodeContent {fileState} {node} part="waypoints" />
{/if}
<style lang="postcss">
@@ -18,6 +18,8 @@
Scissors,
FileStack,
FileX,
Route,
ArrowRightLeft,
} from '@lucide/svelte';
import { getContext, untrack } from 'svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
@@ -34,6 +36,7 @@
elementId,
isInClipboard,
isSelected,
pathInfo,
selectedElementIds,
selectedFileIds,
selectionSize,
@@ -62,20 +65,23 @@
let singleSelection = $derived(selectionSize($selection) === 1);
// a route or a track: it has a name, a description and a style, like a file
let isPath = $derived(node.type === 'route' || node.type === 'track');
let nodeColors: string[] = $derived.by(() => {
const { tracks } = fileState.structure;
const { routes, tracks } = fileState.structure;
if (node.type === 'file') {
// the colors defined by the tracks, or else the one of the file
// the colors defined by the routes and tracks, or else the one of the file
const colors = [
...new Set(
tracks.flatMap((track) =>
track.color !== undefined ? [normalizeColor(track.color)] : []
[...routes, ...tracks].flatMap((path) =>
path.color !== undefined ? [normalizeColor(path.color)] : []
)
),
];
return colors.length > 0 ? colors : [fileState.color];
} else if (node.type === 'track') {
const color = tracks.find((track) => track.id === node.trackId)?.color;
} else if (isPath) {
const color = pathInfo(fileState.structure, node)?.color;
return [color !== undefined ? normalizeColor(color) : fileState.color];
}
return [];
@@ -141,11 +147,11 @@
? 'w-full h-7'
: 'w-max h-9 px-1.5'} pointer-events-auto"
>
{#if node.type === 'file' || node.type === 'track'}
{#if node.type === 'file' || isPath}
<MetadataDialog bind:open={openEditMetadata} {fileState} {node} />
<StyleDialog bind:open={openEditStyle} {fileState} {node} />
{/if}
{#if level === 'file' || level === 'track'}
{#if level === 'file' || isPath}
<div
class="absolute {orientation === 'vertical'
? 'top-0 bottom-0 right-0 w-1'
@@ -197,7 +203,9 @@
}
}}
>
{#if level === 'segment'}
{#if level === 'route'}
<Route size="16" class="mx-1 shrink-0" />
{:else if level === 'segment'}
<Waypoints size="16" class="mx-1 shrink-0" />
{:else if level === 'waypoint'}
{#if symbolKey && symbols[symbolKey].icon}
@@ -238,7 +246,7 @@
</Button>
</ContextMenu.Trigger>
<ContextMenu.Content>
{#if node.type === 'file' || node.type === 'track'}
{#if node.type === 'file' || isPath}
<ContextMenu.Item
disabled={!singleSelection}
onclick={() => (editMetadata.current = true)}
@@ -269,6 +277,10 @@
<Plus size="16" />
{i18n._('menu.new_track')}
</ContextMenu.Item>
<ContextMenu.Item disabled={!singleSelection} onclick={() => engine.newRoute()}>
<Plus size="16" />
{i18n._('menu.new_route')}
</ContextMenu.Item>
<ContextMenu.Separator />
{:else if node.type === 'track'}
<ContextMenu.Item
@@ -278,6 +290,14 @@
<Plus size="16" />
{i18n._('menu.new_segment')}
</ContextMenu.Item>
{/if}
{#if isPath || node.type === 'segment'}
<ContextMenu.Item onclick={() => engine.convert()}>
<ArrowRightLeft size="16" />
{i18n._(
node.type === 'route' ? 'menu.convert_to_track' : 'menu.convert_to_route'
)}
</ContextMenu.Item>
<ContextMenu.Separator />
{/if}
{/if}
@@ -8,7 +8,7 @@
import { i18n } from '$lib/i18n/i18n.svelte';
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
import { engine, type FileState } from '$lib/engine';
import type { FileTreeNode } from '$lib/engine/selection-helpers';
import { pathInfo, type FileTreeNode } from '$lib/engine/selection-helpers';
import { showEngineError } from '$lib/actions/file-actions';
let {
@@ -21,16 +21,13 @@
open: boolean;
} = $props();
let track = $derived(
node.type === 'track'
? fileState.structure.tracks.find((track) => track.id === node.trackId)
: undefined
);
// the route or the track
let path = $derived(pathInfo(fileState.structure, node));
let name: string = $derived(
node.type === 'file' ? fileState.structure.name : (track?.name ?? '')
node.type === 'file' ? fileState.structure.name : (path?.name ?? '')
);
let description: string = $derived(
node.type === 'file' ? (fileState.structure.desc ?? '') : (track?.desc ?? '')
node.type === 'file' ? (fileState.structure.desc ?? '') : (path?.desc ?? '')
);
$effect(() => {
@@ -5,13 +5,22 @@ import { tick } from 'svelte';
import { engine, type MoveTarget, type Selection } from '$lib/engine';
import { isSelected, type FileTreeNode } from '$lib/engine/selection-helpers';
/** What a list holds: the files, the tracks of a file, the segments of a track, or the waypoints. */
export type ListLevel = 'file' | 'track' | 'segment' | 'waypoints' | 'waypoint';
/**
* What a list holds: the files, the routes or the tracks of a file, the segments of a track, or the
* waypoints.
*/
export type ListLevel = 'file' | 'route' | 'track' | 'segment' | 'waypoints' | 'waypoint';
/**
* Where the elements of a list can be moved to (a segment on its own can become a route). Routes
* and tracks can be dropped among each other, but keep their kind: the engine puts them with the
* others of their kind, the routes coming first.
*/
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
file: ['file'],
track: ['file', 'track'],
segment: ['file', 'track', 'segment'],
route: ['file', 'route', 'track'],
track: ['file', 'route', 'track'],
segment: ['file', 'route', 'track', 'segment'],
waypoints: ['waypoints'],
waypoint: ['waypoints', 'waypoint'],
};
@@ -103,6 +112,8 @@ export class SortableFileList {
switch (this._sortableLevel) {
case 'file':
return { type: 'file', fileId: id };
case 'route':
return parent ? { type: 'route', fileId: parent.fileId, routeId: id } : undefined;
case 'track':
return parent ? { type: 'track', fileId: parent.fileId, trackId: id } : undefined;
case 'segment':
@@ -129,6 +140,8 @@ export class SortableFileList {
switch (this._sortableLevel) {
case 'file':
return { type: 'file', fileIds: ids };
case 'route':
return parent ? { type: 'route', fileId: parent.fileId, routeIds: ids } : undefined;
case 'track':
return parent ? { type: 'track', fileId: parent.fileId, trackIds: ids } : undefined;
case 'segment':
@@ -155,6 +168,8 @@ export class SortableFileList {
switch (this._sortableLevel) {
case 'file':
return { type: 'files', index };
case 'route':
return parent ? { type: 'routes', fileId: parent.fileId, index } : undefined;
case 'track':
return parent ? { type: 'tracks', fileId: parent.fileId, index } : undefined;
case 'segment':
@@ -275,6 +290,9 @@ export class SortableFileList {
case 'file':
request = engine.select(selected);
break;
case 'route':
request = engine.selectRoutes(parent!.fileId, selected);
break;
case 'track':
request = engine.selectTracks(parent!.fileId, selected);
break;
@@ -10,7 +10,7 @@
import { settings } from '$lib/state/settings';
import { engine, type FileState } from '$lib/engine';
import { normalizeColor } from '$lib/engine/file-colors';
import { selectedFileIds, type FileTreeNode } from '$lib/engine/selection-helpers';
import { pathInfo, selectedFileIds, type FileTreeNode } from '$lib/engine/selection-helpers';
import { untrack } from 'svelte';
let {
@@ -38,17 +38,15 @@
width = $defaultWidth;
color = fileState.color;
const { tracks } = fileState.structure;
const paths = [...fileState.structure.routes, ...fileState.structure.tracks];
const path = pathInfo(fileState.structure, node);
if (node.type === 'file') {
opacity = tracks.find((track) => track.opacity !== undefined)?.opacity ?? opacity;
width = tracks.find((track) => track.width !== undefined)?.width ?? width;
} else if (node.type === 'track') {
const track = tracks.find((track) => track.id === node.trackId);
if (track) {
color = track.color !== undefined ? normalizeColor(track.color) : color;
opacity = track.opacity ?? opacity;
width = track.width ?? width;
}
opacity = paths.find((path) => path.opacity !== undefined)?.opacity ?? opacity;
width = paths.find((path) => path.width !== undefined)?.width ?? width;
} else if (path) {
color = path.color !== undefined ? normalizeColor(path.color) : color;
opacity = path.opacity ?? opacity;
width = path.width ?? width;
}
colorChanged = false;
@@ -118,7 +118,11 @@ export class GPXLayer {
return { type: 'file', fileId: this.fileId };
}
/** The node of a segment of the map: its route, which has no other, or the segment itself. */
segmentNode(properties: GeoJSON.GeoJsonProperties): FileTreeNode {
if (properties!.routeId !== undefined) {
return { type: 'route', fileId: this.fileId, routeId: properties!.routeId };
}
return {
type: 'segment',
fileId: this.fileId,
@@ -181,7 +185,11 @@ export class GPXLayer {
const visibleSegmentIds = state.segments.features
.filter(
({ properties }) =>
!isSegmentHidden(state, properties.trackId, properties.segmentId)
!isSegmentHidden(
state,
properties.routeId ?? properties.trackId!,
properties.segmentId
)
)
.map(({ properties }) => properties.segmentId);
const segmentFilter: FilterSpecification = [
@@ -415,7 +423,7 @@ export class GPXLayer {
layerOnClick(e: MapLayerMouseEvent) {
const properties = e.features![0].properties!;
const { trackId, segmentId } = properties;
const { routeId, trackId, segmentId } = properties;
const selectionType = this.selectionState.type;
if (
@@ -441,10 +449,15 @@ export class GPXLayer {
}
const mode = e.originalEvent.ctrlKey || e.originalEvent.metaKey ? 'add' : 'replace';
const { tracks } = get(this.file).structure;
const segmentCount = tracks.reduce((count, track) => count + track.segments.length, 0);
const { routes, tracks } = get(this.file).structure;
const segmentCount =
routes.length + tracks.reduce((count, track) => count + track.segments.length, 0);
if (get(treeFileView) && segmentCount > 1) {
// Select inner item
if (routeId !== undefined) {
engine.selectRoutes(this.fileId, [routeId], mode);
return;
}
const track = tracks.find((track) => track.id === trackId);
if (track && track.segments.length > 1) {
engine.selectSegments(this.fileId, trackId, [segmentId], mode);
@@ -15,13 +15,14 @@
const files = engine.files;
const statistics = engine.statistics;
/** The number of segments of a file or of one of its tracks. */
/** The number of segments of a file (its routes have one each) or of one of its tracks. */
function segmentCount(fileId: string, trackId?: string) {
const file = $files.get(fileId);
const tracks = file ? get(file).structure.tracks : [];
return tracks
const structure = file ? get(file).structure : undefined;
const routes = trackId === undefined ? (structure?.routes.length ?? 0) : 0;
return (structure?.tracks ?? [])
.filter((track) => trackId === undefined || track.id === trackId)
.reduce((count, track) => count + track.segments.length, 0);
.reduce((count, track) => count + track.segments.length, routes);
}
// files or tracks, which all have several segments
@@ -31,11 +31,13 @@
onDestroy(() => request.release());
let segmentCount = $derived($statistics.anchors?.segmentStarts.length ?? 0);
// several files, tracks or segments, or a single file or track with several segments
// several files, routes, tracks or segments, or a single file or track with several segments
let canMergeTraces = $derived.by(() => {
switch ($selection.type) {
case 'file':
return $selection.fileIds.length > 1 || segmentCount > 1;
case 'route':
return $selection.routeIds.length > 1;
case 'track':
return $selection.trackIds.length > 1 || segmentCount > 1;
case 'segment':
@@ -45,7 +47,8 @@
}
});
// several files or tracks, that can be put together
// several files or tracks, that can be put together (a route has a single segment: routes
// cannot)
let canMergeContents = $derived(
($selection.type === 'file' && $selection.fileIds.length > 1) ||
($selection.type === 'track' && $selection.trackIds.length > 1)
@@ -172,9 +172,10 @@
updateEnd();
}
// a single file, track or segment
// a single file, route, track or segment
let canUpdate = $derived(
($selection.type === 'file' && $selection.fileIds.length === 1) ||
($selection.type === 'route' && $selection.routeIds.length === 1) ||
($selection.type === 'track' && $selection.trackIds.length === 1) ||
($selection.type === 'segment' && $selection.segmentIds.length === 1)
);
@@ -9,6 +9,7 @@
import { onDestroy } from 'svelte';
import { getURLForLanguage } from '$lib/utils';
import { engine } from '$lib/engine';
import { coversSegments } from '$lib/engine/selection-helpers';
import { minTolerance, ReducedLayer, tolerance } from './utils.svelte';
let props: { class?: string } = $props();
@@ -23,9 +24,7 @@
const request = engine.requestStatistics();
request.set(['anchors']);
let validSelection = $derived(
$selection.type === 'file' || $selection.type === 'track' || $selection.type === 'segment'
);
let validSelection = $derived(coversSegments($selection));
let reducedLayer = new ReducedLayer();
@@ -31,6 +31,7 @@
import { settings } from '$lib/state/settings';
import { map } from '$lib/components/map/map';
import { engine } from '$lib/engine';
import { coversSegments } from '$lib/engine/selection-helpers';
import { newFileName } from '$lib/actions/file-actions';
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
import { RoutingControls } from './routing-controls';
@@ -54,10 +55,8 @@
const selection = engine.selection;
let routingControls: RoutingControls | undefined = undefined;
// the tool works on files, tracks and segments, not on waypoints
let validSelection = $derived(
$selection.type === 'file' || $selection.type === 'track' || $selection.type === 'segment'
);
// the tool works on files, routes, tracks and segments, not on waypoints
let validSelection = $derived(coversSegments($selection));
function createFileWithPoint(e: any) {
if ($selection.type === 'empty') {
@@ -18,6 +18,7 @@ import { map } from '$lib/components/map/map';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import { settings } from '$lib/state/settings';
import { closestPointIndexIn } from '$lib/geo/closest-point';
import { coversSegments } from '$lib/engine/selection-helpers';
import {
engine,
type Selection,
@@ -53,7 +54,7 @@ type AnchorFeature = GeoJSON.Feature<GeoJSON.Point, AnchorProperties>;
/** Whether the tool has to show controls for a selection: it covers segments, or can get some. */
function isRoutable(selection: Selection) {
return selection.type === 'file' || selection.type === 'track' || selection.type === 'segment';
return coversSegments(selection);
}
/**
@@ -1,5 +1,5 @@
<script lang="ts">
import { splitAs, SplitType } from '$lib/components/toolbar/tools/scissors/scissors';
import { availableSplitTypes, splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
import Help from '$lib/components/Help.svelte';
import { Label } from '$lib/components/ui/label/index.js';
import { Button } from '$lib/components/ui/button';
@@ -14,6 +14,7 @@
import { getURLForLanguage } from '$lib/utils';
import { slicedStatistics } from '$lib/state/selection-statistics';
import { engine } from '$lib/engine';
import { coversSegments } from '$lib/engine/selection-helpers';
let props: {
class?: string;
@@ -24,13 +25,20 @@
const selection = engine.selection;
const statistics = engine.statistics;
// the tool works on files, tracks and segments, not on waypoints
let validSelection = $derived(
($selection.type === 'file' ||
$selection.type === 'track' ||
$selection.type === 'segment') &&
$statistics.length > 0
);
// the ways the selection can be split, which follow its content
let splitTypes = $derived.by(() => {
// what is selected can change without the selection: the files are edited
void $statistics;
return availableSplitTypes($selection);
});
$effect(() => {
if (!splitTypes.includes($splitAs)) {
$splitAs = splitTypes[0];
}
});
// the tool works on files, routes, tracks and segments, not on waypoints
let validSelection = $derived(coversSegments($selection) && $statistics.length > 0);
// The slider does work for each of its steps every time it changes, which is too slow with
// the thousands of trackpoints of a long track: it has a limited number of steps, which are
// spread over the trackpoints.
@@ -123,7 +131,7 @@
{i18n._('gpx.' + $splitAs)}
</Select.Trigger>
<Select.Content>
{#each Object.values(SplitType) as splitType}
{#each splitTypes as splitType}
<Select.Item value={splitType}>{i18n._('gpx.' + splitType)}</Select.Item>
{/each}
</Select.Content>
@@ -1,10 +1,12 @@
import { get, writable, type Writable } from 'svelte/store';
import { engine, type SplitType as EngineSplitType } from '$lib/engine';
import { engine, type Selection, type SplitType as EngineSplitType } from '$lib/engine';
import { selectedFileIds } from '$lib/engine/selection-helpers';
import { closestPointIndexIn } from '$lib/geo/closest-point';
/** What a split cuts in two, the values being the ones of the engine. */
export const SplitType = {
FILES: 'files',
ROUTES: 'routes',
TRACKS: 'tracks',
SEGMENTS: 'segments',
} as const satisfies Record<string, EngineSplitType>;
@@ -12,11 +14,62 @@ export type SplitType = (typeof SplitType)[keyof typeof SplitType];
export let splitAs: Writable<SplitType> = writable(SplitType.FILES);
/** The ids of the segments of the routes of the files of the selection. */
export function routeSegmentIds(selection: Selection): Set<string> {
const files = get(engine.files);
return new Set(
selectedFileIds(selection).flatMap((fileId) => {
const file = files.get(fileId);
return file ? get(file).structure.routes.map((route) => route.segment.id) : [];
})
);
}
/**
* Splits at the trackpoint of a selected segment that is the closest to `point`, as `splitAs` says.
* The ways the selection can be split: as files always, as routes if it has routes, as tracks or
* segments if it has tracks with segments (the engine does nothing for a trackpoint that is not in
* what it is asked to cut).
*/
export function availableSplitTypes(selection: Selection): SplitType[] {
let routes = false;
let tracks = false;
if (selection.type === 'route') {
routes = true;
} else if (selection.type === 'track' || selection.type === 'segment') {
tracks = true;
} else {
const files = get(engine.files);
for (const fileId of selectedFileIds(selection)) {
const file = files.get(fileId);
if (file) {
const { structure } = get(file);
routes ||= structure.routes.length > 0;
tracks ||= structure.tracks.some((track) => track.segments.length > 0);
}
}
}
return [
SplitType.FILES,
...(routes ? [SplitType.ROUTES] : []),
...(tracks ? [SplitType.TRACKS, SplitType.SEGMENTS] : []),
];
}
/** Whether splitting as `type` cuts something at a trackpoint of a segment, which is a route's or not. */
export function cuts(type: SplitType, inRoute: boolean): boolean {
return type === SplitType.FILES || (type === SplitType.ROUTES) === inRoute;
}
/**
* Splits at the trackpoint of a selected segment that is the closest to `point`, as `splitAs` says
* (nothing happens if the segment cannot be split that way).
* The statistics of the engine need to have the anchors (the scissors tool asks for them).
*/
export function splitAtPoint(segmentId: string, point: { lng: number; lat: number }) {
const inRoute = routeSegmentIds(get(engine.selection)).has(segmentId);
if (!cuts(get(splitAs), inRoute)) {
return;
}
const statistics = get(engine.statistics);
const anchors = statistics.anchors;
const segment = anchors?.segmentIds.indexOf(segmentId) ?? -1;
@@ -1,5 +1,5 @@
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
import { cuts, routeSegmentIds, splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
import { Scissors } from 'lucide-static';
import { get } from 'svelte/store';
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
@@ -7,6 +7,7 @@ import type { GeoJSONSource, Map, MapLayerMouseEvent } from 'maplibre-gl';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
import { loadSVGIcon } from '$lib/utils';
import { coversSegments } from '$lib/engine/selection-helpers';
import {
engine,
type Selection,
@@ -15,9 +16,9 @@ import {
} from '$lib/engine';
/**
* The anchors inside the segments of the selection, where a click splits the file, the track or
* the segment: a command of the engine, which finds them by the index of their trackpoint in the
* selection.
* The anchors inside the segments of the selection, where a click splits the file, the route, the
* track or the segment: a command of the engine, which finds them by the index of their trackpoint
* in the selection. Only the segments that can be split as `splitAs` says have them.
*/
export class SplitControls {
map: Map;
@@ -53,6 +54,7 @@ export class SplitControls {
})
);
this.unsubscribes.push(currentTool.subscribe(this.addIfNeeded.bind(this)));
this.unsubscribes.push(splitAs.subscribe(this.addIfNeeded.bind(this)));
this.unsubscribes.push(
engine.selection.subscribe((selection) => {
this.selection = selection;
@@ -62,12 +64,8 @@ export class SplitControls {
}
addIfNeeded() {
// the tool works on files, tracks and segments, not on waypoints
const scissors =
get(currentTool) === Tool.SCISSORS &&
(this.selection.type === 'file' ||
this.selection.type === 'track' ||
this.selection.type === 'segment');
// the tool works on files, routes, tracks and segments, not on waypoints
const scissors = get(currentTool) === Tool.SCISSORS && coversSegments(this.selection);
this.request.set(scissors ? ['anchors'] : []);
if (!scissors) {
this.remove();
@@ -85,6 +83,8 @@ export class SplitControls {
return [];
}
const starts = anchors.segmentStarts;
const routeSegments = routeSegmentIds(this.selection);
const type = get(splitAs);
const features: GeoJSON.Feature[] = [];
let segment = 0;
for (let i = 0; i < anchors.indices.length; i++) {
@@ -94,7 +94,8 @@ export class SplitControls {
segment++;
}
const end = segment + 1 < starts.length ? starts[segment + 1] : statistics.length;
if (index > starts[segment] && index < end - 1) {
const inRoute = routeSegments.has(anchors.segmentIds[segment]);
if (index > starts[segment] && index < end - 1 && cuts(type, inRoute)) {
features.push({
type: 'Feature',
geometry: {
+8 -4
View File
@@ -38,6 +38,8 @@ export async function createTauriBackend(): Promise<Backend> {
select: (fileIds, mode) => invoke('select', { fileIds, mode }),
select_all: () => invoke('select_all'),
arrow_select: (down, add) => invoke('arrow_select', { down, add }),
select_routes: (fileId, routeIds, mode) =>
invoke('select_routes', { fileId, routeIds, mode }),
select_tracks: (fileId, trackIds, mode) =>
invoke('select_tracks', { fileId, trackIds, mode }),
select_segments: (fileId, trackId, segmentIds, mode) =>
@@ -52,7 +54,8 @@ export async function createTauriBackend(): Promise<Backend> {
cut: () => invoke('cut'),
paste: () => invoke('paste'),
new_file: (name, lng, lat, ele) => invoke('new_file', { name, lng, lat, ele }),
new_file: (name, lng, lat, ele, route) =>
invoke('new_file', { name, lng, lat, ele, route }),
load_files: (files) => {
const data = new Uint8Array(files.reduce((sum, file) => sum + file.data.length, 0));
let offset = 0;
@@ -81,6 +84,8 @@ export async function createTauriBackend(): Promise<Backend> {
}),
metadata: (name, desc) => invoke('metadata', { name, desc }),
style: (color, opacity, width) => invoke('style', { color, opacity, width }),
convert: () => invoke('convert'),
new_route: () => invoke('new_route'),
new_track: () => invoke('new_track'),
new_track_segment: () => invoke('new_track_segment'),
@@ -156,7 +161,7 @@ export async function createTauriBackend(): Promise<Backend> {
(await invoke('waypoint', { fileId, waypointId })) ?? undefined,
trackpoint: async (fileId, segmentId, index) =>
(await invoke('trackpoint', { fileId, segmentId, index })) ?? undefined,
export_file: async (fileId, time, hr, cad, atemp, power, osm, asRoute) => {
export_file: async (fileId, time, hr, cad, atemp, power, osm) => {
const file = await optionalArray('export_file', Uint8Array, {
fileId,
time,
@@ -165,7 +170,6 @@ export async function createTauriBackend(): Promise<Backend> {
atemp,
power,
osm,
asRoute,
});
return file as Uint8Array<ArrayBuffer> | undefined;
},
@@ -222,7 +226,7 @@ export async function createTauriBackend(): Promise<Backend> {
invoke<boolean>('export_files', { fileIds, options }),
listFolder: (path) => invoke<FolderEntry[]>('list_folder', { path }),
openUrl: (url) => invoke('open_url', { url }),
newFile: (name, folder) => invoke<Outcome>('new_file', { name, folder }),
newFile: (name, folder, route) => invoke<Outcome>('new_file', { name, folder, route }),
createFolder: (parent, name) => invoke<string>('create_folder', { parent, name }),
renamePath: (path, name) => invoke<Outcome>('rename_path', { path, name }),
movePaths: (paths, folder) => invoke('move_paths', { paths, folder }),
+16 -5
View File
@@ -100,8 +100,11 @@ export interface DesktopFiles {
listFolder(path?: string): Promise<FolderEntry[]>;
/** Opens a web page (or an email to write) in the app of the system for it. */
openUrl(url: string): Promise<void>;
/** Makes a new file, written at once in `folder` (a folder of the open folder), and selects it. */
newFile(name: string, folder: string): Promise<Outcome>;
/**
* Makes a new file, written at once in `folder` (a folder of the open folder), and selects it.
* With `route`, it starts with an empty route.
*/
newFile(name: string, folder: string, route: boolean): Promise<Outcome>;
/** Makes a folder in a folder of the open folder (numbered if the name is taken): its path. */
createFolder(parent: string, name: string): Promise<string>;
/**
@@ -146,6 +149,7 @@ export interface Backend {
select(fileIds: string[], mode: SelectMode): Promise<Outcome>;
select_all(): Promise<Outcome>;
arrow_select(down: boolean, add: boolean): Promise<Outcome>;
select_routes(fileId: string, routeIds: string[], mode: SelectMode): Promise<Outcome>;
select_tracks(fileId: string, trackIds: string[], mode: SelectMode): Promise<Outcome>;
select_segments(
fileId: string,
@@ -164,7 +168,13 @@ export interface Backend {
paste(): Promise<Outcome>;
// Files
new_file(name: string, lng?: number, lat?: number, ele?: number): Promise<Outcome>;
new_file(
name: string,
lng: number | undefined,
lat: number | undefined,
ele: number | undefined,
route: boolean
): Promise<Outcome>;
load_files(files: { data: Uint8Array; name: string }[]): Promise<Outcome>;
duplicate(): Promise<Outcome>;
delete(wholeFiles: boolean): Promise<Outcome>;
@@ -173,6 +183,8 @@ export interface Backend {
move_elements(what: Selection, to: MoveTarget): Promise<Outcome>;
metadata(name: string, desc: string): Promise<Outcome>;
style(color?: string, opacity?: number, width?: number): Promise<Outcome>;
convert(): Promise<Outcome>;
new_route(): Promise<Outcome>;
new_track(): Promise<Outcome>;
new_track_segment(): Promise<Outcome>;
@@ -262,8 +274,7 @@ export interface Backend {
cad: boolean,
atemp: boolean,
power: boolean,
osm: boolean,
asRoute: boolean
osm: boolean
): Promise<Uint8Array<ArrayBuffer> | undefined>;
exportable_data(fileIds: string[]): Promise<number>;
reduction_distances(): Promise<Float64Array>;
+9 -3
View File
@@ -29,15 +29,19 @@ export async function createWasmBackend(): Promise<Backend> {
const mode = (m: SelectMode) =>
({ replace: w.SelectMode.Replace, add: w.SelectMode.Add, toggle: w.SelectMode.Toggle })[m];
const splitType = (t: SplitType) =>
({ files: w.SplitType.Files, tracks: w.SplitType.Tracks, segments: w.SplitType.Segments })[
t
];
({
files: w.SplitType.Files,
routes: w.SplitType.Routes,
tracks: w.SplitType.Tracks,
segments: w.SplitType.Segments,
})[t];
const mergeType = (t: MergeType) => (t === 'connect' ? w.MergeType.Connect : w.MergeType.Group);
return {
select: lift((ids, m) => w.select(idsToBytes(ids), mode(m))),
select_all: lift(w.select_all),
arrow_select: lift(w.arrow_select),
select_routes: lift((file, ids, m) => w.select_routes(file, idsToBytes(ids), mode(m))),
select_tracks: lift((file, ids, m) => w.select_tracks(file, idsToBytes(ids), mode(m))),
select_segments: lift((file, track, ids, m) =>
w.select_segments(file, track, idsToBytes(ids), mode(m))
@@ -75,6 +79,8 @@ export async function createWasmBackend(): Promise<Backend> {
move_elements: lift(w.move_elements),
metadata: lift(w.metadata),
style: lift(w.style),
convert: lift(w.convert),
new_route: lift(w.new_route),
new_track: lift(w.new_track),
new_track_segment: lift(w.new_track_segment),
+50 -22
View File
@@ -175,6 +175,44 @@ describe('selection', () => {
assert.ok(get(engine.statistics).length > trackLength);
});
it('selects routes, whose segments are on the map', async () => {
await load('with_routes');
const file = fileStates()[0];
const { id, routes, tracks } = file.structure;
assert.equal(routes.length, 2);
assert.equal(tracks.length, 0);
// the segments of the map are the ones of the routes
assert.deepEqual(
file.segments.features.map((f) => f.properties.routeId),
routes.map((route) => route.id)
);
assert.deepEqual(
file.segments.features.map((f) => f.properties.segmentId),
routes.map((route) => route.segment.id)
);
await engine.selectRoutes(id, [routes[1].id]);
assert.deepEqual(get(engine.selection), {
type: 'route',
fileId: id,
routeIds: [routes[1].id],
});
assert.equal(get(engine.statistics).length, routes[1].segment.length);
await engine.select([id]);
assert.equal(await engine.newRoute(), true);
assert.equal(fileStates()[0].structure.routes.length, 3);
});
it('creates a file that starts with an empty route', async () => {
assert.equal(await engine.newFile('routes', undefined, true), true);
const [state] = fileStates();
assert.equal(state.structure.routes.length, 1);
assert.equal(state.structure.routes[0].segment.length, 0);
assert.equal(state.structure.tracks.length, 0);
assert.deepEqual(get(engine.selection), { type: 'file', fileIds: [state.structure.id] });
});
it('ignores what does not exist', async () => {
await load('simple');
const before = get(engine.selection);
@@ -415,42 +453,32 @@ describe('export', () => {
await engine.exportFile(id, { ...ALL_EXPORT_OPTIONS, ...options })
);
it('writes tracks, or routes when asked to', async () => {
it('writes tracks as tracks', 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 = await text(id);
const tracks = await text(file.structure.id);
assert.ok(tracks.includes('<trk>') && !tracks.includes('<rte>'));
assert.equal(tracks.match(/<trkseg>/g)?.length, segments);
const routes = await 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 = await 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 = (await engine.exportFile(id, { ...ALL_EXPORT_OPTIONS, asRoute: true }))!;
await load('with_routes');
const [id] = get(engine.order);
const before = fileStates()[0].structure.routes.length;
const routes = (await engine.exportFile(id))!;
const written = new TextDecoder().decode(routes);
assert.equal(written.match(/<rte>/g)?.length, before);
assert.ok(!written.includes('<trk>') && written.includes('<rtept'));
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);
assert.equal(state.structure.routes.length, before);
assert.equal(state.structure.tracks.length, 0);
});
});
+34 -11
View File
@@ -182,6 +182,10 @@ class Engine {
// Selecting elements inside a file. Ids that do not exist are ignored.
selectRoutes(fileId: string, routeIds: string[], mode: SelectMode = 'replace') {
return this.run((b) => b.select_routes(fileId, routeIds, mode));
}
selectTracks(fileId: string, trackIds: string[], mode: SelectMode = 'replace') {
return this.run((b) => b.select_tracks(fileId, trackIds, mode));
}
@@ -209,6 +213,8 @@ class Engine {
switch (node.type) {
case 'file':
return this.select([node.fileId], mode);
case 'route':
return this.selectRoutes(node.fileId, [node.routeId], mode);
case 'track':
return this.selectTracks(node.fileId, [node.trackId], mode);
case 'segment':
@@ -245,11 +251,18 @@ class Engine {
// Edits of the files
/**
* Creates a file, which gets selected. With a `trackpoint`, the file starts with a track and a
* segment that hold it, in the same undo step.
* Creates a file, which gets selected. With `route`, the file starts with a route. With a
* `trackpoint`, the file starts with a segment that holds it, the one of its route with `route`
* or of a track otherwise, in the same undo step.
*/
newFile(name: string, trackpoint?: { lng: number; lat: number; ele?: number }) {
return this.run((b) => b.new_file(name, trackpoint?.lng, trackpoint?.lat, trackpoint?.ele));
newFile(
name: string,
trackpoint?: { lng: number; lat: number; ele?: number },
route: boolean = false
) {
return this.run((b) =>
b.new_file(name, trackpoint?.lng, trackpoint?.lat, trackpoint?.ele, route)
);
}
/**
@@ -359,6 +372,19 @@ class Engine {
// Edits of the selection
/**
* Converts the selected routes to tracks, or the selected tracks or segments to routes (one
* per segment), which are selected. Resolves to `false` when there is nothing to convert.
*/
convert() {
return this.run((b) => b.convert());
}
/** Adds an empty route to the selected files. */
newRoute() {
return this.run((b) => b.new_route());
}
newTrack() {
return this.run((b) => b.new_track());
}
@@ -587,10 +613,10 @@ class Engine {
/**
* Makes a new file in a folder of the open folder, which is written there at once and selected.
* Resolves to its id.
* With `route`, it starts with an empty route. Resolves to its id.
*/
async newFileIn(name: string, folder: string): Promise<string | undefined> {
const [id] = await this.addedBy((b) => b.desktop!.newFile(name, folder), true);
async newFileIn(name: string, folder: string, route = false): Promise<string | undefined> {
const [id] = await this.addedBy((b) => b.desktop!.newFile(name, folder, route), true);
return id;
}
@@ -868,8 +894,7 @@ class Engine {
options.cad,
options.atemp,
options.power,
options.osm,
options.asRoute
options.osm
),
undefined
);
@@ -885,8 +910,6 @@ class Engine {
atemp: (bits & 8) !== 0,
power: (bits & 16) !== 0,
osm: (bits & 32) !== 0,
// not a data
asRoute: false,
};
}
+22 -11
View File
@@ -3,7 +3,13 @@ import { normalizeColor, type FileColorAllocator } from '$lib/engine/file-colors
import type { Visibility } from '$lib/engine/file-visibility';
import type { Backend } from './backend';
import { toPositions } from './convert';
import { EMPTY_STATISTICS, type FileState, type SegmentProperties } from './types';
import {
EMPTY_STATISTICS,
type FileState,
type PathInfo,
type SegmentNode,
type SegmentProperties,
} from './types';
/** Reads the state of a file, reusing from `previous` what did not change. */
export async function readFileState(
@@ -18,9 +24,10 @@ export async function readFileState(
return null;
}
const paths: PathInfo[] = [...structure.routes, ...structure.tracks];
const color = colors.resolve(
id,
structure.tracks.map((track) => track.color)
paths.map((path) => path.color)
);
const previousSegments = new Map(
@@ -31,18 +38,19 @@ export async function readFileState(
| Feature<LineString, SegmentProperties>
| Promise<Feature<LineString, SegmentProperties>>
)[] = [];
structure.tracks.forEach((track, trackIndex) => {
track.segments.forEach((segment, segmentIndex) => {
const addSegment = (
segment: SegmentNode,
path: PathInfo,
parent: Pick<SegmentProperties, 'routeId' | 'trackId'>
) => {
const properties: SegmentProperties = {
fileId: id,
trackId: track.id,
...parent,
segmentId: segment.id,
trackIndex,
segmentIndex,
rev: segment.rev,
color: track.color !== undefined ? normalizeColor(track.color) : color,
opacity: track.opacity,
width: track.width,
color: path.color !== undefined ? normalizeColor(path.color) : color,
opacity: path.opacity,
width: path.width,
};
const old = previousSegments.get(segment.id);
const unchanged =
@@ -62,7 +70,10 @@ export async function readFileState(
properties,
}))
);
});
};
structure.routes.forEach((route) => addSegment(route.segment, route, { routeId: route.id }));
structure.tracks.forEach((track) => {
track.segments.forEach((segment) => addSegment(segment, track, { trackId: track.id }));
});
const segmentFeatures = await Promise.all(segments);
@@ -24,6 +24,7 @@ const structure: FileStructure = {
name: 'file',
waypointsRev: 'wpt-rev',
waypoints: [{ id: 'W1' }, { id: 'W2' }],
routes: [],
tracks: [
{ id: 'T1', segments: [segment('S1'), segment('S2')] },
{ id: 'T2', segments: [segment('S3')] },
@@ -134,6 +135,29 @@ describe('file visibility', () => {
}
});
it('hides a route, which is a leaf like a waypoint', () => {
const withRoute: FileStructure = {
...structure,
routes: [{ id: 'R', segment: segment('RS') }],
};
const state = (...actions: [ids: string[], hidden: boolean][]): VisibilityState => {
let visibility: Visibility = new Map();
for (const [ids, hidden] of actions) {
visibility = setHidden(withRoute, visibility, ids, hidden);
}
return { structure: withRoute, visibility };
};
assert.equal(isHidden(state([['R'], true]), 'R'), true);
assert.equal(isHidden(state([['R'], true]), 'F'), false);
assert.equal(isSegmentHidden(state([['R'], true]), 'R', 'RS'), true);
// with its file, and shown on its own below it
assert.equal(isHidden(state([['F'], true]), 'R'), true);
const shown = state([['F'], true], [['R'], false]);
assert.equal(isHidden(shown, 'R'), false);
assert.equal(isHidden(shown, 'F'), false);
assert.equal(isHidden(shown, 'T1'), true);
});
it('does not consider unknown ids hidden', () => {
assert.equal(isHidden(apply([['F'], true]), 'unknown'), false);
});
+16 -5
View File
@@ -1,7 +1,7 @@
import type { FileStructure } from 'gpx-wasm';
/**
* Visibility the user set explicitly, by element id (file, track, segment, waypoint, or the
* Visibility the user set explicitly, by element id (file, route, track, segment, waypoint, or the
* waypoints of the file as a whole, see `waypointsKey`): `false` hides, `true` shows. An element
* without entry follows its parent, and everything is shown by default.
*/
@@ -15,7 +15,10 @@ export function waypointsKey(fileId: string): string {
return `${fileId}:waypoints`;
}
/** Ids from the file down to `id` in the file tree (file, tracks, segments / waypoints group). */
/**
* Ids from the file down to `id` in the file tree (file, routes, tracks, segments / waypoints
* group).
*/
function pathTo(structure: FileStructure, id: string): string[] {
const fileId = structure.id;
if (id === fileId) {
@@ -27,6 +30,9 @@ function pathTo(structure: FileStructure, id: string): string[] {
if (structure.waypoints.some((waypoint) => waypoint.id === id)) {
return [fileId, waypointsKey(fileId), id];
}
if (structure.routes.some((route) => route.id === id)) {
return [fileId, id];
}
for (const track of structure.tracks) {
if (track.id === id) {
return [fileId, id];
@@ -40,7 +46,11 @@ function pathTo(structure: FileStructure, id: string): string[] {
function children(structure: FileStructure, id: string): string[] {
if (id === structure.id) {
return [waypointsKey(id), ...structure.tracks.map((track) => track.id)];
return [
waypointsKey(id),
...structure.routes.map((route) => route.id),
...structure.tracks.map((track) => track.id),
];
}
if (id === waypointsKey(structure.id)) {
return structure.waypoints.map((waypoint) => waypoint.id);
@@ -77,12 +87,13 @@ export function isHidden({ structure, visibility }: VisibilityState, id: string)
// Leaf elements have no descendants: no need to look at the structure.
/** Whether a segment is hidden: `parentId` is its route (which is a leaf), or its track. */
export function isSegmentHidden(
{ structure, visibility }: VisibilityState,
trackId: string,
parentId: string,
segmentId: string
): boolean {
return followsHidden(visibility, [structure.id, trackId, segmentId]);
return followsHidden(visibility, [structure.id, parentId, segmentId]);
}
export function isWaypointHidden(
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff. Show more