diff --git a/gpx-rs/engine/src/engine/command/command.rs b/gpx-rs/engine/src/engine/command/command.rs index a49400eeb..7220a2bb5 100644 --- a/gpx-rs/engine/src/engine/command/command.rs +++ b/gpx-rs/engine/src/engine/command/command.rs @@ -1,8 +1,8 @@ use crate::{Apply, CommandError, State}; use crate::{ - Clean, Crop, Delete, DeleteAll, Duplicate, Elevation, Extract, Load, LoadFiles, Merge, - Metadata, Move, MoveWaypoint, New, NewTrack, NewTrackSegment, NewWaypoint, Paste, Reverse, - SpliceTrackpoints, Split, Style, Time, + Clean, Crop, Delete, DeleteAll, DeleteWaypoint, Duplicate, EditWaypoint, Elevation, Extract, + Load, LoadFiles, Merge, Metadata, Move, MoveWaypoint, New, NewTrack, NewTrackSegment, + NewWaypoint, Paste, Reverse, SpliceTrackpoints, Split, Style, Time, }; /// A user action that edits the files. Each variant wraps the command's own struct, whose @@ -20,6 +20,7 @@ pub enum Command<'a> { Paste(Paste), Move(Move), DeleteAll(DeleteAll), + DeleteWaypoint(DeleteWaypoint), Metadata(Metadata<'a>), Style(Style<'a>), NewTrack(NewTrack), @@ -27,6 +28,7 @@ pub enum Command<'a> { Reverse(Reverse), SpliceTrackpoints(SpliceTrackpoints<'a>), NewWaypoint(NewWaypoint<'a>), + EditWaypoint(EditWaypoint<'a>), MoveWaypoint(MoveWaypoint), Crop(Crop), Split(Split), @@ -48,6 +50,7 @@ impl Apply for Command<'_> { Command::Paste(c) => c.apply(state), Command::Move(c) => c.apply(state), Command::DeleteAll(c) => c.apply(state), + Command::DeleteWaypoint(c) => c.apply(state), Command::Metadata(c) => c.apply(state), Command::Style(c) => c.apply(state), Command::NewTrack(c) => c.apply(state), @@ -55,6 +58,7 @@ impl Apply for Command<'_> { Command::Reverse(c) => c.apply(state), Command::SpliceTrackpoints(c) => c.apply(state), Command::NewWaypoint(c) => c.apply(state), + Command::EditWaypoint(c) => c.apply(state), Command::MoveWaypoint(c) => c.apply(state), Command::Crop(c) => c.apply(state), Command::Split(c) => c.apply(state), diff --git a/gpx-rs/engine/src/engine/command/file/delete.rs b/gpx-rs/engine/src/engine/command/file/delete.rs index b8edf3b83..f394a81ab 100644 --- a/gpx-rs/engine/src/engine/command/file/delete.rs +++ b/gpx-rs/engine/src/engine/command/file/delete.rs @@ -76,7 +76,7 @@ impl Apply for Delete { } } -fn delete_waypoints( +pub(crate) fn delete_waypoints( files: &mut StackEntry, file_id: FileId, filter: impl Fn(&Waypoint) -> bool, diff --git a/gpx-rs/engine/src/engine/command/file/delete_waypoint.rs b/gpx-rs/engine/src/engine/command/file/delete_waypoint.rs new file mode 100644 index 000000000..5976c2d67 --- /dev/null +++ b/gpx-rs/engine/src/engine/command/file/delete_waypoint.rs @@ -0,0 +1,121 @@ +use crate::{Apply, CommandError, FileId, Selection, State, WaypointId, delete_waypoints}; + +/// Deletes a waypoint of a file, whatever is selected (what the delete button of its popup does). +/// If it was the only selected element, its file gets selected. +#[derive(Debug)] +pub struct DeleteWaypoint { + pub file_id: FileId, + pub waypoint_id: WaypointId, +} + +impl Apply for DeleteWaypoint { + fn apply(self, state: &mut State) -> Result<(), CommandError> { + delete_waypoints(state.files, self.file_id, |wpt| wpt.id == self.waypoint_id)?; + if let Selection::Waypoint { file_id, wpt_ids } = &*state.selection + && *file_id == self.file_id + && wpt_ids.iter().all(|id| *id == self.waypoint_id) + { + *state.selection = Selection::File { + file_ids: [self.file_id].into(), + }; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use crate::{ + File, Selection, Waypoint, WaypointChunk, engine::command::fixture::Fixture, waypoint_ids, + }; + + use super::*; + + #[test] + fn test_deletes_one_waypoint_without_touching_the_selection() { + let mut fx = Fixture::default(); + let mut file = File::default(); + file.wpt.push(Rc::new(WaypointChunk { + wpt: (0..3) + .map(|i| Waypoint { + name: Some(format!("w{i}")), + ..Default::default() + }) + .collect(), + ..Default::default() + })); + let id = file.id; + let ids: Vec<_> = waypoint_ids(&file).collect(); + fx.files.insert(id, Rc::new(file)); + fx.order.0.push(id); + fx.selection = Selection::File { + file_ids: [id].into(), + }; + + DeleteWaypoint { + file_id: id, + waypoint_id: ids[1], + } + .apply(&mut fx.state()) + .unwrap(); + + let names: Vec<_> = fx.files[&id] + .wpt + .iter() + .flat_map(|chunk| &chunk.wpt) + .map(|wpt| wpt.name.clone().unwrap()) + .collect(); + assert_eq!(names, ["w0", "w2"]); + assert_eq!( + fx.selection, + Selection::File { + file_ids: [id].into() + } + ); + } + + #[test] + fn test_deleting_the_selected_waypoint_selects_its_file() { + let mut fx = Fixture::default(); + let mut file = File::default(); + file.wpt.push(Rc::new(WaypointChunk { + wpt: vec![Waypoint::default(), Waypoint::default()], + ..Default::default() + })); + let id = file.id; + let ids: Vec<_> = waypoint_ids(&file).collect(); + fx.files.insert(id, Rc::new(file)); + fx.selection = Selection::Waypoint { + file_id: id, + wpt_ids: [ids[0]].into(), + }; + DeleteWaypoint { + file_id: id, + waypoint_id: ids[0], + } + .apply(&mut fx.state()) + .unwrap(); + assert_eq!( + fx.selection, + Selection::File { + file_ids: [id].into() + } + ); + } + + #[test] + fn test_unknown_waypoint_is_nothing_to_do() { + let mut fx = Fixture::default(); + let file = File::default(); + let id = file.id; + fx.files.insert(id, Rc::new(file)); + let result = DeleteWaypoint { + file_id: id, + waypoint_id: WaypointId::default(), + } + .apply(&mut fx.state()); + assert!(matches!(result, Err(CommandError::NothingToDo))); + } +} diff --git a/gpx-rs/engine/src/engine/command/file/mod.rs b/gpx-rs/engine/src/engine/command/file/mod.rs index 1ba906f74..95fd803b9 100644 --- a/gpx-rs/engine/src/engine/command/file/mod.rs +++ b/gpx-rs/engine/src/engine/command/file/mod.rs @@ -1,5 +1,6 @@ mod delete; mod delete_all; +mod delete_waypoint; mod duplicate; mod load; mod move_elements; @@ -9,6 +10,7 @@ mod transfer; pub use delete::*; pub use delete_all::*; +pub use delete_waypoint::*; pub use duplicate::*; pub use load::*; pub use move_elements::*; diff --git a/gpx-rs/engine/src/engine/command/tools/edit_waypoint.rs b/gpx-rs/engine/src/engine/command/tools/edit_waypoint.rs new file mode 100644 index 000000000..3a4c501b7 --- /dev/null +++ b/gpx-rs/engine/src/engine/command/tools/edit_waypoint.rs @@ -0,0 +1,113 @@ +use crate::{ + Apply, CommandError, Edit, Editor, State, Waypoint, set_waypoint_fields, update_selected, +}; + +/// Changes the selected waypoints (what the form of the waypoint tool does): their name, +/// description, icon, link, position and elevation. The strings that are empty remove the field. +#[derive(Debug)] +pub struct EditWaypoint<'a> { + pub lng: f64, + pub lat: f64, + pub ele: f64, + pub name: &'a str, + pub desc: &'a str, + pub icon: &'a str, + pub link: &'a str, +} + +impl Apply for EditWaypoint<'_> { + fn apply(mut self, state: &mut State) -> Result<(), CommandError> { + update_selected(state, &mut self); + Ok(()) + } +} + +impl Editor for EditWaypoint<'_> { + fn waypoint(&mut self, waypoint: &mut Waypoint) -> Edit { + set_waypoint_fields( + waypoint, + (self.lng, self.lat, self.ele), + self.name, + self.desc, + self.icon, + self.link, + ); + Edit::Changed + } +} + +#[cfg(test)] +mod tests { + use std::{collections::HashSet, rc::Rc}; + + use crate::{ + File, Link, Selection, WaypointChunk, engine::command::fixture::Fixture, waypoint_ids, + }; + + use super::*; + + #[test] + fn test_edit_the_selected_waypoint() { + let mut fx = Fixture::default(); + let mut file = File::default(); + file.wpt.push(Rc::new(WaypointChunk { + wpt: vec![ + Waypoint { + name: Some("old".into()), + desc: Some("old description".into()), + cmt: Some("old comment".into()), + sym: Some("Flag".into()), + link: Some(Link { + href: "https://old".into(), + text: None, + }), + ..Default::default() + }, + Waypoint { + name: Some("other".into()), + ..Default::default() + }, + ], + ..Default::default() + })); + let id = file.id; + let ids: Vec<_> = waypoint_ids(&file).collect(); + fx.files.insert(id, Rc::new(file)); + fx.selection = Selection::Waypoint { + file_id: id, + wpt_ids: HashSet::from([ids[0]]), + }; + + EditWaypoint { + lng: 4.5, + lat: 50.5, + ele: 12.0, + name: "new", + desc: "new description", + icon: "Summit", + link: "", + } + .apply(&mut fx.state()) + .unwrap(); + + let wpts: Vec<_> = fx.files[&id] + .wpt + .iter() + .flat_map(|c| c.wpt.iter()) + .collect(); + let edited = wpts[0]; + assert_eq!(edited.id, ids[0]); + assert_eq!(edited.name.as_deref(), Some("new")); + assert_eq!(edited.desc.as_deref(), Some("new description")); + assert_eq!(edited.cmt.as_deref(), Some("new description")); + assert_eq!(edited.sym.as_deref(), Some("Summit")); + // an empty link removes it + assert!(edited.link.is_none()); + assert_eq!( + (edited.coordinates.lng, edited.coordinates.lat, edited.ele), + (4.5, 50.5, 12.0) + ); + // the other waypoint is as it was + assert_eq!(wpts[1].name.as_deref(), Some("other")); + } +} diff --git a/gpx-rs/engine/src/engine/command/tools/mod.rs b/gpx-rs/engine/src/engine/command/tools/mod.rs index ab19aea2b..b4f088c18 100644 --- a/gpx-rs/engine/src/engine/command/tools/mod.rs +++ b/gpx-rs/engine/src/engine/command/tools/mod.rs @@ -1,5 +1,6 @@ mod clean; mod crop; +mod edit_waypoint; mod elevation; mod extract; mod merge; @@ -12,6 +13,7 @@ mod time; pub use clean::*; pub use crop::*; +pub use edit_waypoint::*; pub use elevation::*; pub use extract::*; pub use merge::*; diff --git a/gpx-rs/engine/src/engine/command/tools/move_waypoint.rs b/gpx-rs/engine/src/engine/command/tools/move_waypoint.rs index 99541109f..07ca2c52a 100644 --- a/gpx-rs/engine/src/engine/command/tools/move_waypoint.rs +++ b/gpx-rs/engine/src/engine/command/tools/move_waypoint.rs @@ -1,15 +1,130 @@ -use crate::{Apply, CommandError, State}; +use std::rc::Rc; +use crate::{Apply, CommandError, FileId, LngLat, State, WaypointId, edit_waypoint_chunks}; + +/// Moves a waypoint of a file, whatever is selected (what a drag does): only its position and +/// elevation change. #[derive(Debug)] pub struct MoveWaypoint { + pub file_id: FileId, + pub waypoint_id: WaypointId, pub lng: f64, pub lat: f64, pub ele: f64, } impl Apply for MoveWaypoint { - fn apply(self, _state: &mut State) -> Result<(), CommandError> { - // TODO - Err(CommandError::NotImplemented("move waypoint")) + fn apply(self, state: &mut State) -> Result<(), CommandError> { + let file = state + .files + .get(&self.file_id) + .ok_or(CommandError::NothingToDo)?; + let mut file = (**file).clone(); + let changed = edit_waypoint_chunks( + &mut file, + |wpt| wpt.id == self.waypoint_id, + |wpts| { + for wpt in wpts.iter_mut().filter(|wpt| wpt.id == self.waypoint_id) { + wpt.coordinates = LngLat { + lng: self.lng, + lat: self.lat, + }; + wpt.ele = self.ele; + } + true + }, + ); + if !changed { + return Err(CommandError::NothingToDo); + } + state.files.insert(self.file_id, Rc::new(file)); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use crate::{ + File, Selection, Waypoint, WaypointChunk, engine::command::fixture::Fixture, waypoint_ids, + }; + + use super::*; + + fn fixture() -> (Fixture, crate::FileId, Vec) { + let mut fx = Fixture::default(); + let mut file = File::default(); + file.wpt.push(Rc::new(WaypointChunk { + wpt: (0..3) + .map(|i| Waypoint { + name: Some(format!("w{i}")), + ..Default::default() + }) + .collect(), + ..Default::default() + })); + let id = file.id; + let ids = waypoint_ids(&file).collect(); + fx.files.insert(id, Rc::new(file)); + fx.order.0.push(id); + (fx, id, ids) + } + + #[test] + fn test_only_the_given_waypoint_moves_whatever_is_selected() { + let (mut fx, id, ids) = fixture(); + let rev = fx.files[&id].wpt_rev_id; + + MoveWaypoint { + file_id: id, + waypoint_id: ids[1], + lng: 4.0, + lat: 50.0, + ele: 42.0, + } + .apply(&mut fx.state()) + .unwrap(); + + let wpts: Vec<_> = fx.files[&id] + .wpt + .iter() + .flat_map(|c| c.wpt.iter()) + .collect(); + assert_eq!(wpts.len(), 3); + assert_eq!( + ( + wpts[1].coordinates.lng, + wpts[1].coordinates.lat, + wpts[1].ele + ), + (4.0, 50.0, 42.0) + ); + // the other fields and the other waypoints are as they were + assert_eq!(wpts[1].name.as_deref(), Some("w1")); + assert_eq!(wpts[1].id, ids[1]); + assert_eq!((wpts[0].coordinates.lng, wpts[0].ele), (0.0, 0.0)); + assert_eq!((wpts[2].coordinates.lat, wpts[2].ele), (0.0, 0.0)); + // the coordinates changed, which is noticed by what is derived from them + assert_ne!(fx.files[&id].wpt_rev_id, rev); + assert_eq!(fx.selection, Selection::Empty); + } + + #[test] + fn test_unknown_waypoint_or_file_is_nothing_to_do() { + let (mut fx, id, ids) = fixture(); + let before = fx.files[&id].clone(); + for (file_id, waypoint_id) in [(id, WaypointId::default()), (FileId::default(), ids[0])] { + let result = MoveWaypoint { + file_id, + waypoint_id, + lng: 1.0, + lat: 1.0, + ele: 1.0, + } + .apply(&mut fx.state()); + assert!(matches!(result, Err(CommandError::NothingToDo))); + } + assert!(Rc::ptr_eq(&fx.files[&id], &before)); } } diff --git a/gpx-rs/engine/src/engine/command/tools/new_waypoint.rs b/gpx-rs/engine/src/engine/command/tools/new_waypoint.rs index c817076f4..6499ff0d4 100644 --- a/gpx-rs/engine/src/engine/command/tools/new_waypoint.rs +++ b/gpx-rs/engine/src/engine/command/tools/new_waypoint.rs @@ -1,5 +1,12 @@ -use crate::{Apply, CommandError, State}; +use std::rc::Rc; +use crate::{ + Apply, CommandError, File, FileId, Link, LngLat, Selection, State, Waypoint, + insert_waypoints_at, +}; + +/// Adds a waypoint at the end of the waypoints of each selected file (or of the file of the +/// selected elements). The strings are empty when the waypoint does not have the field. #[derive(Debug)] pub struct NewWaypoint<'a> { pub lng: f64, @@ -11,9 +18,222 @@ pub struct NewWaypoint<'a> { pub link: &'a str, } +fn non_empty(text: &str) -> Option { + (!text.is_empty()).then(|| text.to_owned()) +} + +/// Sets the fields of a waypoint: the strings that are empty remove the field. +pub fn set_waypoint_fields( + waypoint: &mut Waypoint, + position: (f64, f64, f64), + name: &str, + desc: &str, + icon: &str, + link: &str, +) { + let (lng, lat, ele) = position; + waypoint.coordinates = LngLat { lng, lat }; + waypoint.ele = ele; + waypoint.name = non_empty(name); + waypoint.desc = non_empty(desc); + // the description is also the comment, as it is what the other applications show + waypoint.cmt = non_empty(desc); + waypoint.sym = non_empty(icon); + waypoint.link = non_empty(link).map(|href| Link { href, text: None }); +} + impl Apply for NewWaypoint<'_> { - fn apply(self, _state: &mut State) -> Result<(), CommandError> { - // TODO - Err(CommandError::NotImplemented("new waypoint")) + fn apply(self, state: &mut State) -> Result<(), CommandError> { + let file_ids: Vec = match &*state.selection { + Selection::Empty => vec![], + Selection::File { file_ids } => state + .order + .0 + .iter() + .filter(|id| file_ids.contains(id)) + .copied() + .collect(), + Selection::Track { file_id, .. } + | Selection::TrackSegment { file_id, .. } + | Selection::Waypoints { file_id } + | Selection::Waypoint { file_id, .. } => vec![*file_id], + }; + let file_ids: Vec = file_ids + .into_iter() + .filter(|id| state.files.contains_key(id)) + .collect(); + if file_ids.is_empty() { + return Err(CommandError::NothingToDo); + } + + for id in file_ids { + let mut waypoint = Waypoint::default(); + set_waypoint_fields( + &mut waypoint, + (self.lng, self.lat, self.ele), + self.name, + self.desc, + self.icon, + self.link, + ); + let file: &mut File = Rc::make_mut(state.files.get_mut(&id).unwrap()); + insert_waypoints_at(file, usize::MAX, vec![waypoint]); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use crate::{New, TrackId, TrackSegmentId, engine::command::fixture::Fixture, waypoint_ids}; + + use super::*; + + fn new_waypoint(name: &str) -> NewWaypoint<'_> { + NewWaypoint { + lng: 4.5, + lat: 50.25, + ele: 123.0, + name, + desc: "a description", + icon: "Summit", + link: "https://example.com", + } + } + + fn waypoints(fx: &Fixture, id: FileId) -> Vec { + fx.files[&id] + .wpt + .iter() + .flat_map(|chunk| chunk.wpt.iter().cloned()) + .collect() + } + + #[test] + fn test_new_waypoint_in_the_selected_file() { + let mut fx = Fixture::default(); + New { name: "a" }.apply(&mut fx.state()).unwrap(); + New { name: "b" }.apply(&mut fx.state()).unwrap(); + let (a, b) = (fx.order.0[0], fx.order.0[1]); + let rev = fx.files[&b].wpt_rev_id; + + // b is selected + new_waypoint("peak").apply(&mut fx.state()).unwrap(); + assert!(waypoints(&fx, a).is_empty()); + let created = waypoints(&fx, b); + assert_eq!(created.len(), 1); + let wpt = &created[0]; + assert_eq!( + (wpt.coordinates.lng, wpt.coordinates.lat, wpt.ele), + (4.5, 50.25, 123.0) + ); + assert_eq!(wpt.name.as_deref(), Some("peak")); + assert_eq!(wpt.desc.as_deref(), Some("a description")); + assert_eq!(wpt.cmt.as_deref(), Some("a description")); + assert_eq!(wpt.sym.as_deref(), Some("Summit")); + assert_eq!( + wpt.link.as_ref().map(|l| l.href.as_str()), + Some("https://example.com") + ); + assert_ne!(fx.files[&b].wpt_rev_id, rev); + + // added after the others + let mut second = new_waypoint("second"); + second.lng = 5.0; + second.apply(&mut fx.state()).unwrap(); + let names: Vec<_> = waypoints(&fx, b) + .iter() + .map(|w| w.name.clone().unwrap()) + .collect(); + assert_eq!(names, ["peak", "second"]); + // and not selected + assert_eq!(fx.selected_files(), [b].into()); + } + + #[test] + fn test_empty_fields_are_not_set() { + let mut fx = Fixture::default(); + New { name: "a" }.apply(&mut fx.state()).unwrap(); + let a = fx.order.0[0]; + NewWaypoint { + lng: 1.0, + lat: 2.0, + ele: 0.0, + name: "", + desc: "", + icon: "", + link: "", + } + .apply(&mut fx.state()) + .unwrap(); + let wpt = &waypoints(&fx, a)[0]; + assert!(wpt.name.is_none() && wpt.desc.is_none() && wpt.cmt.is_none()); + assert!(wpt.sym.is_none() && wpt.link.is_none()); + } + + #[test] + fn test_each_selected_file_gets_its_own_waypoint() { + let mut fx = Fixture::default(); + for name in ["a", "b", "c"] { + New { name }.apply(&mut fx.state()).unwrap(); + } + let ids = fx.order.0.clone(); + fx.selection = Selection::File { + file_ids: HashSet::from([ids[0], ids[2]]), + }; + new_waypoint("w").apply(&mut fx.state()).unwrap(); + assert_eq!(waypoints(&fx, ids[0]).len(), 1); + assert!(waypoints(&fx, ids[1]).is_empty()); + assert_eq!(waypoints(&fx, ids[2]).len(), 1); + // not the same waypoint + assert_ne!(waypoints(&fx, ids[0])[0].id, waypoints(&fx, ids[2])[0].id); + } + + #[test] + fn test_the_file_of_the_selected_elements_is_used() { + for selection in [ + |id| Selection::Track { + file_id: id, + trk_ids: [TrackId::default()].into(), + }, + |id| Selection::TrackSegment { + file_id: id, + trk_id: TrackId::default(), + trkseg_ids: [TrackSegmentId::default()].into(), + }, + |id| Selection::Waypoints { file_id: id }, + |id| Selection::Waypoint { + file_id: id, + wpt_ids: HashSet::new(), + }, + ] { + let mut fx = Fixture::default(); + New { name: "a" }.apply(&mut fx.state()).unwrap(); + let a = fx.order.0[0]; + fx.selection = selection(a); + new_waypoint("w").apply(&mut fx.state()).unwrap(); + assert_eq!(waypoint_ids(&fx.files[&a]).count(), 1); + } + } + + #[test] + fn test_nothing_selected_or_unknown_file() { + let mut fx = Fixture::default(); + New { name: "a" }.apply(&mut fx.state()).unwrap(); + fx.selection = Selection::Empty; + assert_eq!( + new_waypoint("w").apply(&mut fx.state()), + Err(CommandError::NothingToDo) + ); + fx.selection = Selection::Waypoints { + file_id: FileId::default(), + }; + assert_eq!( + new_waypoint("w").apply(&mut fx.state()), + Err(CommandError::NothingToDo) + ); + assert_eq!(fx.files.len(), 1); } } diff --git a/gpx-rs/engine/src/engine/engine.rs b/gpx-rs/engine/src/engine/engine.rs index 6a1918c59..8284dd063 100644 --- a/gpx-rs/engine/src/engine/engine.rs +++ b/gpx-rs/engine/src/engine/engine.rs @@ -3,7 +3,7 @@ use crate::{ Action, Apply, Clipboard, Command, CoordinatesCache, Diff, FileId, FileOrder, FileStructure, FileStructureCache, GlobalStatistics, SelectMode, Selection, Stack, State, StatisticsBuffer, - StatisticsCache, TrackSegmentId, + StatisticsCache, TrackSegmentId, Trackpoint, Waypoint, WaypointId, }; #[derive(Debug, Default)] @@ -60,6 +60,35 @@ impl Engine { self.clipboard_changed } + /// A waypoint of a file, with all its data. `None` if it does not exist. + pub fn waypoint(&self, file_id: &FileId, id: &WaypointId) -> Option<&Waypoint> { + self.stack + .current()? + .get(file_id)? + .wpt + .iter() + .flat_map(|chunk| &chunk.wpt) + .find(|wpt| wpt.id == *id) + } + + /// A trackpoint of a segment of a file. `None` if it does not exist. + pub fn trackpoint( + &self, + file_id: &FileId, + segment_id: &TrackSegmentId, + index: usize, + ) -> Option<&Trackpoint> { + let segment = self + .stack + .current()? + .get(file_id)? + .trk + .iter() + .flat_map(|trk| &trk.trkseg) + .find(|seg| seg.id == *segment_id)?; + (index < segment.len()).then(|| &segment[index]) + } + /// Whether there is something to undo. pub fn can_undo(&self) -> bool { self.stack.can_undo() @@ -1110,4 +1139,120 @@ mod tests { assert!(engine.execute(Action::Redo)); assert_eq!(engine.order().len(), 3); } + + #[test] + fn test_create_edit_and_move_a_waypoint() { + use crate::{EditWaypoint, MoveWaypoint, NewWaypoint}; + + let mut engine = Engine::default(); + new(&mut engine, "file"); + let file = engine.order()[0]; + assert!(edit( + &mut engine, + Command::NewWaypoint(NewWaypoint { + lng: 4.0, + lat: 50.0, + ele: 10.0, + name: "created", + desc: "", + icon: "Flag", + link: "", + }) + )); + let id = engine.file_structure(&file).unwrap().waypoints[0].id; + let waypoint = engine.waypoint(&file, &id).unwrap(); + assert_eq!(waypoint.name.as_deref(), Some("created")); + assert_eq!(waypoint.sym.as_deref(), Some("Flag")); + assert_eq!(waypoint.ele, 10.0); + // unknown waypoint or file + assert!(engine.waypoint(&file, &Default::default()).is_none()); + assert!(engine.waypoint(&Default::default(), &id).is_none()); + + // no selection needed + assert!(edit( + &mut engine, + Command::MoveWaypoint(MoveWaypoint { + file_id: file, + waypoint_id: id, + lng: 5.0, + lat: 51.0, + ele: 20.0, + }) + )); + let moved = engine.waypoint(&file, &id).unwrap(); + assert_eq!( + (moved.coordinates.lng, moved.coordinates.lat, moved.ele), + (5.0, 51.0, 20.0) + ); + assert_eq!(moved.name.as_deref(), Some("created")); + // the coordinates buffer follows + assert_eq!(engine.waypoint_coordinates(&file), &[5.0, 51.0]); + + // editing applies to the selected waypoints + select_elements( + &mut engine, + Selection::Waypoint { + file_id: file, + wpt_ids: [id].into(), + }, + SelectMode::Replace, + ); + assert!(edit( + &mut engine, + Command::EditWaypoint(EditWaypoint { + lng: 6.0, + lat: 52.0, + ele: 30.0, + name: "edited", + desc: "text", + icon: "", + link: "https://example.com", + }) + )); + let edited = engine.waypoint(&file, &id).unwrap(); + assert_eq!(edited.name.as_deref(), Some("edited")); + assert_eq!(edited.cmt.as_deref(), Some("text")); + assert!(edited.sym.is_none()); + assert_eq!(engine.waypoint_coordinates(&file), &[6.0, 52.0]); + + // each of them can be undone + assert!(engine.execute(Action::Undo)); + assert_eq!( + engine.waypoint(&file, &id).unwrap().name.as_deref(), + Some("created") + ); + assert!(engine.execute(Action::Undo)); + assert_eq!(engine.waypoint_coordinates(&file), &[4.0, 50.0]); + + // deleting does not need a selection either + assert!(edit( + &mut engine, + Command::DeleteWaypoint(crate::DeleteWaypoint { + file_id: file, + waypoint_id: id, + }) + )); + assert!(engine.waypoint(&file, &id).is_none()); + assert!(engine.waypoint_coordinates(&file).is_empty()); + assert!(engine.execute(Action::Undo)); + assert!(engine.waypoint(&file, &id).is_some()); + } + + #[test] + fn test_trackpoint() { + let mut engine = Engine::default(); + load(&mut engine, "data/simple.gpx"); + let file = engine.order()[0]; + let structure = engine.file_structure(&file).unwrap(); + let seg = &structure.tracks[0].segments[0]; + let (seg_id, len) = (seg.id, seg.len); + let coordinates = engine.segment_coordinates(&seg_id).to_vec(); + let last = engine.trackpoint(&file, &seg_id, len - 1).unwrap(); + assert_eq!( + [last.coordinates.lng, last.coordinates.lat], + coordinates[(len - 1) * 2..] + ); + assert!(engine.trackpoint(&file, &seg_id, len).is_none()); + assert!(engine.trackpoint(&Default::default(), &seg_id, 0).is_none()); + } } diff --git a/gpx-rs/wasm/src/lib.rs b/gpx-rs/wasm/src/lib.rs index 162881f86..d3f55012a 100644 --- a/gpx-rs/wasm/src/lib.rs +++ b/gpx-rs/wasm/src/lib.rs @@ -284,12 +284,59 @@ pub fn new_waypoint( })) } +/// Moves a waypoint of a file, whatever is selected. #[wasm_bindgen] -pub fn move_waypoint(lng: f64, lat: f64, ele: f64) -> bool { - edit(Command::MoveWaypoint(engine::MoveWaypoint { +pub fn move_waypoint(file_id: &str, waypoint_id: &str, lng: f64, lat: f64, ele: f64) -> bool { + match (parse_file_id(file_id), parse_waypoint_id(waypoint_id)) { + (Some(file_id), Some(waypoint_id)) => edit(Command::MoveWaypoint(engine::MoveWaypoint { + file_id, + waypoint_id, + lng, + lat, + ele, + })), + _ => false, + } +} + +/// Deletes a waypoint of a file, whatever is selected. +#[wasm_bindgen] +pub fn delete_waypoint(file_id: &str, waypoint_id: &str) -> bool { + match (parse_file_id(file_id), parse_waypoint_id(waypoint_id)) { + (Some(file_id), Some(waypoint_id)) => { + edit(Command::DeleteWaypoint(engine::DeleteWaypoint { + file_id, + waypoint_id, + })) + } + _ => false, + } +} + +fn parse_waypoint_id(id: &str) -> Option { + uuid::Uuid::parse_str(id).ok().map(engine::WaypointId) +} + +/// Changes the selected waypoints: their name, description, icon, link, position and elevation. +/// The strings that are empty remove the field. +#[wasm_bindgen] +pub fn update_waypoint( + lng: f64, + lat: f64, + ele: f64, + name: &str, + desc: &str, + icon: &str, + link: &str, +) -> bool { + edit(Command::EditWaypoint(engine::EditWaypoint { lng, lat, ele, + name, + desc, + icon, + link, })) } @@ -629,6 +676,28 @@ export type MoveTarget = | { type: 'tracks'; fileId: string; index: number } | { type: 'segments'; fileId: string; trackId: string; index: number } | { type: 'waypoints'; fileId: string; index: number }; +/** All the data of a waypoint. The fields it does not have are absent. */ +export interface WaypointDetails { + id: string; + lng: number; + lat: number; + ele: number; + /** ms since epoch */ + time?: number; + name?: string; + desc?: string; + cmt?: string; + link?: { href: string; text?: string }; + sym?: string; +} +/** The position, elevation and time of a trackpoint. */ +export interface TrackpointDetails { + lng: number; + lat: number; + ele: number; + /** ms since epoch */ + time?: number; +} /** What was copied or cut, to be pasted: the kind of the elements and their ids. */ export interface Clipboard { type: 'files' | 'tracks' | 'segments' | 'waypoints'; @@ -710,6 +779,10 @@ export interface WaypointNode { extern "C" { #[wasm_bindgen(typescript_type = "Selection")] pub type Selection; + #[wasm_bindgen(typescript_type = "WaypointDetails | undefined")] + pub type WaypointDetails; + #[wasm_bindgen(typescript_type = "TrackpointDetails | undefined")] + pub type TrackpointDetails; #[wasm_bindgen(typescript_type = "MoveTarget")] pub type MoveTarget; #[wasm_bindgen(typescript_type = "Clipboard | undefined")] @@ -941,6 +1014,76 @@ pub fn selection() -> Selection { .unchecked_into() } +/// All the data of a waypoint of a file, `undefined` if it does not exist. +#[wasm_bindgen] +pub fn waypoint(file_id: &str, waypoint_id: &str) -> WaypointDetails { + uuid::Uuid::parse_str(file_id) + .ok() + .zip(uuid::Uuid::parse_str(waypoint_id).ok()) + .and_then(|(file_id, id)| { + with_engine(|e| { + e.waypoint(&FileId(file_id), &engine::WaypointId(id)) + .map(|wpt| { + let object = Object::new(); + set(&object, "id", wpt.id.0.to_string()); + set(&object, "lng", wpt.coordinates.lng); + set(&object, "lat", wpt.coordinates.lat); + set(&object, "ele", wpt.ele); + if let Some(time) = wpt.time { + set(&object, "time", time as f64); + } + for (key, value) in [ + ("name", &wpt.name), + ("desc", &wpt.desc), + ("cmt", &wpt.cmt), + ("sym", &wpt.sym), + ] { + if let Some(value) = value { + set(&object, key, value.as_str()); + } + } + if let Some(link) = &wpt.link { + let link_object = Object::new(); + set(&link_object, "href", link.href.as_str()); + if let Some(text) = &link.text { + set(&link_object, "text", text.as_str()); + } + set(&object, "link", link_object); + } + JsValue::from(object) + }) + }) + .flatten() + }) + .unwrap_or(JsValue::UNDEFINED) + .unchecked_into() +} + +/// A trackpoint of a segment of a file, `undefined` if it does not exist. +#[wasm_bindgen] +pub fn trackpoint(file_id: &str, segment_id: &str, index: usize) -> TrackpointDetails { + parse_file_id(file_id) + .zip(uuid::Uuid::parse_str(segment_id).ok()) + .and_then(|(file_id, segment_id)| { + with_engine(|e| { + e.trackpoint(&file_id, &engine::TrackSegmentId(segment_id), index) + .map(|pt| { + let object = Object::new(); + set(&object, "lng", pt.coordinates.lng); + set(&object, "lat", pt.coordinates.lat); + set(&object, "ele", pt.ele); + if let Some(time) = pt.time { + set(&object, "time", time as f64); + } + JsValue::from(object) + }) + }) + .flatten() + }) + .unwrap_or(JsValue::UNDEFINED) + .unchecked_into() +} + /// What was copied or cut and is waiting to be pasted, `undefined` if nothing: the kind of the /// elements and their ids (UUID strings). #[wasm_bindgen] diff --git a/website/src/lib/closest-point.test.ts b/website/src/lib/closest-point.test.ts new file mode 100644 index 000000000..2928d86bd --- /dev/null +++ b/website/src/lib/closest-point.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { closestPointIndex } from './closest-point'; + +describe('closestPointIndex', () => { + const line = [0, 0, 1, 0, 2, 0, 2, 1]; + + it('has no point on an empty polyline', () => { + assert.equal(closestPointIndex([], { lng: 0, lat: 0 }), undefined); + }); + + it('is the only point of a polyline of one point', () => { + assert.equal(closestPointIndex([3, 4], { lng: 0, lat: 0 }), 0); + }); + + it('is the nearest end of the nearest segment', () => { + assert.equal(closestPointIndex(line, { lng: 0.2, lat: 0.1 }), 0); + assert.equal(closestPointIndex(line, { lng: 0.8, lat: 0.1 }), 1); + assert.equal(closestPointIndex(line, { lng: 1.6, lat: -0.1 }), 2); + assert.equal(closestPointIndex(line, { lng: 2.1, lat: 0.9 }), 3); + }); + + it('uses the distance to the segment, not to its ends', () => { + // far from every point but right next to the long segment + const coordinates = [0, 0, 10, 0, 10, 5]; + assert.equal(closestPointIndex(coordinates, { lng: 7, lat: 0.01 }), 1); + assert.equal(closestPointIndex(coordinates, { lng: 3, lat: 0.01 }), 0); + }); +}); diff --git a/website/src/lib/closest-point.ts b/website/src/lib/closest-point.ts new file mode 100644 index 000000000..d73f48bc1 --- /dev/null +++ b/website/src/lib/closest-point.ts @@ -0,0 +1,38 @@ +const metersPerDegree = 111320; + +/** + * Index of the point of a polyline (flat `[lng, lat, ...]` coordinates) that is the closest to + * `point`, among the two ends of the segment of the polyline that passes the closest to it. + * `undefined` for an empty polyline. Planar approximation, like the one used to simplify tracks. + */ +export function closestPointIndex( + coordinates: ArrayLike, + point: { lng: number; lat: number } +): number | undefined { + const count = Math.floor(coordinates.length / 2); + if (count === 0) { + return undefined; + } + const kx = Math.cos((point.lat * Math.PI) / 180) * metersPerDegree; + const ky = metersPerDegree; + const px = point.lng * kx; + const py = point.lat * ky; + const x = (i: number) => coordinates[2 * i] * kx - px; + const y = (i: number) => coordinates[2 * i + 1] * ky - py; + + let best = 0; + let bestDistance = Number.MAX_VALUE; + for (let i = 0; i < count - 1; i++) { + const [ax, ay, bx, by] = [x(i), y(i), x(i + 1), y(i + 1)]; + const [dx, dy] = [bx - ax, by - ay]; + const length = dx * dx + dy * dy; + // the point is at the origin: the closest position on the segment, as a ratio + const t = length === 0 ? 0 : Math.max(0, Math.min(1, -(ax * dx + ay * dy) / length)); + const distance = Math.hypot(ax + t * dx, ay + t * dy); + if (distance < bestDistance) { + bestDistance = distance; + best = t < 0.5 ? i : i + 1; + } + } + return best; +} diff --git a/website/src/lib/components/file-list/FileListNodeLabel.svelte b/website/src/lib/components/file-list/FileListNodeLabel.svelte index 614ec5fe5..a747bc1fe 100644 --- a/website/src/lib/components/file-list/FileListNodeLabel.svelte +++ b/website/src/lib/components/file-list/FileListNodeLabel.svelte @@ -38,12 +38,8 @@ selectionSize, type FileTreeNode, } from '$lib/selection-helpers'; - // TODO centering on the selection and the waypoint popup still work on the previous - // implementation import { boundsManager } from '$lib/logic/bounds'; - // import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers'; - // import { fileStateCollection } from '$lib/logic/file-state'; - // import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup'; + import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup'; let { fileState, @@ -165,26 +161,21 @@ } }} onmouseenter={() => { - // TODO waypoint popup: it needs the waypoint of the previous implementation - // if (node.type === 'waypoint') { - // let layer = gpxLayers.getLayer(node.fileId); - // let file = fileStateCollection.getFile(node.fileId); - // if (layer && file) { - // let waypoint = file.wpt[waypointIndex]; - // if (waypoint && !waypoint._data.hidden) { - // waypointPopup?.setItem({ item: waypoint, fileId: node.fileId }); - // } - // } - // } + if (node.type === 'waypoint' && !isHidden(fileState, node.waypointId)) { + const waypoint = engine.waypoint(node.fileId, node.waypointId); + if (waypoint) { + waypointPopup?.setItem({ + item: waypoint, + kind: 'waypoint', + fileId: node.fileId, + }); + } + } }} onmouseleave={() => { - // TODO waypoint popup - // if (node.type === 'waypoint') { - // let layer = gpxLayers.getLayer(node.fileId); - // if (layer) { - // waypointPopup?.setItem(null); - // } - // } + if (node.type === 'waypoint') { + waypointPopup?.setItem(null); + } }} > {#if level === ListLevel.SEGMENT} diff --git a/website/src/lib/components/map/CoordinatesPopup.svelte b/website/src/lib/components/map/CoordinatesPopup.svelte index 53628d94c..fa6f113e2 100644 --- a/website/src/lib/components/map/CoordinatesPopup.svelte +++ b/website/src/lib/components/map/CoordinatesPopup.svelte @@ -1,7 +1,6 @@
- {trackpoint.item.getLatitude().toFixed(6)}° {trackpoint.item - .getLongitude() - .toFixed(6)}° + {trackpoint.item.lat.toFixed(6)}° {trackpoint.item.lng.toFixed(6)}°
{#if trackpoint.item.ele !== undefined}
@@ -26,14 +24,14 @@
{/if} - {#if trackpoint.item.time} + {#if trackpoint.item.time !== undefined}
- {i18n.df.format(trackpoint.item.time)} + {i18n.df.format(new Date(trackpoint.item.time))}
{/if} trackpoint.hide?.()} class="mt-0.5" /> @@ -41,7 +39,7 @@