28 Commits
Author SHA1 Message Date
vcoppe da8a9e3d84 handle self closing elements 2026-10-04 22:01:23 +02:00
vcoppe c6bd5b06e8 clean tool 2026-10-04 20:38:46 +02:00
vcoppe c04367dd86 progress 2026-10-04 19:19:53 +02:00
vcoppe 5e2f506120 progress 2026-10-04 19:14:41 +02:00
vcoppe c536e6247b progress 2026-10-04 17:29:57 +02:00
vcoppe 594d714b7f small fix 2026-10-04 17:19:35 +02:00
vcoppe 543fecadb8 fix popups 2026-10-04 17:09:41 +02:00
vcoppe 017d8521d6 use engine info 2026-10-04 16:27:03 +02:00
vcoppe 78e9d45072 small fix 2026-10-04 16:21:48 +02:00
vcoppe 7651d859c4 progress 2026-10-04 16:11:44 +02:00
vcoppe ec448b72e2 progress 2026-10-04 15:47:09 +02:00
vcoppe bf946f179a progress 2026-10-04 15:26:58 +02:00
vcoppe 9ff9b5475a progress 2026-10-04 15:03:37 +02:00
vcoppe 55f1d275b7 progress 2026-10-04 14:49:53 +02:00
vcoppe 1d534881f8 progress 2026-10-04 13:18:18 +02:00
vcoppe 03cbacfbb4 fix 2026-10-04 12:33:15 +02:00
vcoppe 9d0905959b fix markers 2026-10-04 12:23:42 +02:00
vcoppe b7ca462f58 progress 2026-10-04 11:52:46 +02:00
vcoppe 9015da4876 fixes 2026-10-04 11:07:12 +02:00
vcoppe f458a1104f progress 2026-10-04 11:00:46 +02:00
vcoppe caba9388af progress 2026-10-04 10:29:39 +02:00
vcoppe 763a7406ce progress 2026-10-04 10:15:51 +02:00
vcoppe 68c8933c35 progress 2026-10-04 09:55:08 +02:00
vcoppe 84b1558b2e progress 2026-10-04 09:50:34 +02:00
vcoppe cd870f0186 visibility 2026-10-04 09:40:17 +02:00
vcoppe 9ac2a2b69c progress 2026-10-04 08:53:00 +02:00
vcoppe 86aba8f4b1 progress 2026-10-04 08:26:15 +02:00
vcoppe 74b674e8e8 progress 2026-10-04 08:21:39 +02:00
90 changed files with 9720 additions and 1830 deletions

No files matched your search

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="gpx.studio" xmlns="http://www.topografix.com/GPX/1/1"
xmlns:ext="https://example.com/ext/0.1">
<metadata>
<name>self closing points</name>
<desc>Points without children are self-closing elements.</desc>
<extensions>
<ext:version>2.0</ext:version>
<ext:paths/>
</extensions>
</metadata>
<wpt lat="50.0" lon="4.0"/>
<wpt lat="50.1" lon="4.1"><name>with children</name></wpt>
<wpt lat="50.2" lon="4.2"/>
<trk>
<name>track</name>
<trkseg>
<trkpt lat="50.00" lon="4.00"/>
<trkpt lat="50.01" lon="4.01"><ele>12.5</ele></trkpt>
<trkpt lat="50.02" lon="4.02"/>
</trkseg>
<trkseg>
<trkpt lat="51.00" lon="5.00"/>
</trkseg>
</trk>
</gpx>
+62
View File
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns="http://www.topografix.com/GPX/1/1"
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1" version="1.1" creator="https://gpx.studio">
<metadata>
<name>with_highway</name>
</metadata>
<trk>
<name>with_highway</name>
<trkseg>
<trkpt lat="50.790867" lon="4.404968">
<ele>109.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
<highway>residential</highway>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.791000" lon="4.405100">
<ele>110.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>asphalt</surface>
<highway>residential</highway>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.791200" lon="4.405300">
<ele>111.0</ele>
</trkpt>
<trkpt lat="50.791400" lon="4.405500">
<ele>112.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>gravel</surface>
<highway>track</highway>
<sac_scale>mountain_hiking</sac_scale>
<mtb_scale>1</mtb_scale>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
<trkpt lat="50.791600" lon="4.405700">
<ele>113.0</ele>
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:Extensions>
<surface>gravel</surface>
<sac_scale>hiking</sac_scale>
<mtb_scale>1</mtb_scale>
</gpxtpx:Extensions>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
</trkseg>
</trk>
</gpx>
+96
View File
@@ -0,0 +1,96 @@
/// Maps the values of a category of trackpoint data (the surface, the highway...) to small codes,
/// so that the trackpoints only hold a `u8` instead of a string.
///
/// The codes are the positions of the names in the order of first appearance. The table only
/// grows: a code never changes meaning, so it stays valid in every state of the history.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct Categories(Vec<String>);
impl Categories {
/// Maximum number of distinct values.
pub const CAPACITY: usize = u8::MAX as usize;
/// The code of `name`, which is added to the table if it is new. `None` if the table is full.
pub fn code(&mut self, name: &str) -> Option<u8> {
let index = match self.0.iter().position(|known| known == name) {
Some(index) => index,
None if self.0.len() < Self::CAPACITY => {
self.0.push(name.to_owned());
self.0.len() - 1
}
None => return None,
};
u8::try_from(index).ok()
}
/// The name of a code, `None` if it is unknown.
pub fn name(&self, code: u8) -> Option<&str> {
self.0.get(usize::from(code)).map(String::as_str)
}
/// All the names, in the order of their codes.
pub fn names(&self) -> &[String] {
&self.0
}
}
/// The categories of the data of the trackpoints. There is one table per engine, shared by all
/// its files.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct TrackpointCategories {
pub surface: Categories,
pub highway: Categories,
pub sac_scale: Categories,
pub mtb_scale: Categories,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_codes_follow_the_order_of_appearance() {
let mut categories = Categories::default();
assert_eq!(categories.code("asphalt"), Some(0));
assert_eq!(categories.code("gravel"), Some(1));
// a known name keeps its code
assert_eq!(categories.code("asphalt"), Some(0));
assert_eq!(categories.names(), ["asphalt", "gravel"]);
}
#[test]
fn test_names_of_codes() {
let mut categories = Categories::default();
let code = categories.code("asphalt").unwrap();
assert_eq!(categories.name(code), Some("asphalt"));
assert_eq!(categories.name(code + 1), None);
assert_eq!(Categories::default().name(0), None);
}
#[test]
fn test_a_full_table_gives_no_code_to_new_names() {
let mut categories = Categories::default();
for i in 0..Categories::CAPACITY {
assert_eq!(categories.code(&i.to_string()), Some(i as u8));
}
assert_eq!(categories.code("one too many"), None);
assert_eq!(categories.names().len(), Categories::CAPACITY);
// the known ones still have their code, the last one included
assert_eq!(categories.code("0"), Some(0));
assert_eq!(categories.code("254"), Some(254));
assert_eq!(categories.name(254), Some("254"));
}
#[test]
fn test_the_categories_are_independent() {
let mut categories = TrackpointCategories::default();
categories.surface.code("asphalt");
assert_eq!(categories.highway.code("path"), Some(0));
assert_eq!(categories.sac_scale.code("hiking"), Some(0));
assert_eq!(categories.mtb_scale.code("1"), Some(0));
assert_eq!(categories.surface.names(), ["asphalt"]);
assert_eq!(categories.highway.names(), ["path"]);
assert_eq!(categories.sac_scale.names(), ["hiking"]);
assert_eq!(categories.mtb_scale.names(), ["1"]);
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ pub struct LngLat {
pub lat: f64, pub lat: f64,
} }
#[derive(Debug)] #[derive(Debug, Clone, Copy)]
pub struct LngLatBounds { pub struct LngLatBounds {
pub sw: LngLat, pub sw: LngLat,
pub ne: LngLat, pub ne: LngLat,
+2
View File
@@ -1,3 +1,4 @@
mod categories;
mod chunk; mod chunk;
mod common; mod common;
mod file; mod file;
@@ -6,6 +7,7 @@ mod track;
mod trackpoint; mod trackpoint;
mod waypoint; mod waypoint;
pub use categories::*;
pub use chunk::*; pub use chunk::*;
pub use common::*; pub use common::*;
pub use file::*; pub use file::*;
+8 -1
View File
@@ -9,5 +9,12 @@ pub struct Trackpoint {
pub hr: Option<u16>, pub hr: Option<u16>,
pub cad: Option<u16>, pub cad: Option<u16>,
pub power: Option<u16>, pub power: Option<u16>,
// TODO OSM data? or store intervals at a higher level? /// Code of the surface, see [`crate::TrackpointCategories`].
pub surface: Option<u8>,
/// Code of the highway, see [`crate::TrackpointCategories`].
pub highway: Option<u8>,
/// Code of the SAC hiking scale, see [`crate::TrackpointCategories`].
pub sac_scale: Option<u8>,
/// Code of the mountain biking scale, see [`crate::TrackpointCategories`].
pub mtb_scale: Option<u8>,
} }
+185 -7
View File
@@ -1,8 +1,8 @@
use std::rc::Rc; use std::rc::Rc;
use crate::{ use crate::{
Author, File, Link, LngLat, Track, TrackSegment, Trackpoint, TrackpointChunk, Waypoint, Author, File, Link, LngLat, Track, TrackSegment, Trackpoint, TrackpointCategories,
WaypointChunk, TrackpointChunk, Waypoint, WaypointChunk,
}; };
use chrono::DateTime; use chrono::DateTime;
use quick_xml::Error; use quick_xml::Error;
@@ -29,6 +29,10 @@ enum GPXElement {
Heartrate, Heartrate,
Cadence, Cadence,
Power, Power,
Surface,
Highway,
SacScale,
MtbScale,
Symbol, Symbol,
Type, Type,
Color, Color,
@@ -48,7 +52,9 @@ fn parse_coordinates(attributes: Attributes<'_>) -> LngLat {
coordinates coordinates
} }
pub fn parse(data: &[u8]) -> Result<File, Error> { /// Parses a GPX file. The surface, highway, SAC scale and MTB scale of the trackpoints are stored as codes of
/// `categories`, which learns the values it does not know yet.
pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File, Error> {
let mut reader = Reader::from_reader(data); let mut reader = Reader::from_reader(data);
let mut buf = vec![]; let mut buf = vec![];
let mut gpx = File::default(); let mut gpx = File::default();
@@ -95,8 +101,12 @@ pub fn parse(data: &[u8]) -> Result<File, Error> {
e if e.ends_with("atemp") => stack.push(GPXElement::Temperature), e if e.ends_with("atemp") => stack.push(GPXElement::Temperature),
e if e.ends_with("hr") => stack.push(GPXElement::Heartrate), e if e.ends_with("hr") => stack.push(GPXElement::Heartrate),
e if e.ends_with("cad") => stack.push(GPXElement::Cadence), e if e.ends_with("cad") => stack.push(GPXElement::Cadence),
"power" => stack.push(GPXElement::Power), e if e.ends_with("power") => stack.push(GPXElement::Power),
e if e.ends_with("PowerInWatts") => stack.push(GPXElement::Power), e if e.ends_with("PowerInWatts") => stack.push(GPXElement::Power),
"surface" => stack.push(GPXElement::Surface),
"highway" => stack.push(GPXElement::Highway),
"sac_scale" => stack.push(GPXElement::SacScale),
"mtb_scale" => stack.push(GPXElement::MtbScale),
"sym" => stack.push(GPXElement::Symbol), "sym" => stack.push(GPXElement::Symbol),
"type" => stack.push(GPXElement::Type), "type" => stack.push(GPXElement::Type),
e if e.ends_with("color") => stack.push(GPXElement::Color), e if e.ends_with("color") => stack.push(GPXElement::Color),
@@ -104,6 +114,30 @@ pub fn parse(data: &[u8]) -> Result<File, Error> {
e if e.ends_with("width") => stack.push(GPXElement::Width), e if e.ends_with("width") => stack.push(GPXElement::Width),
_ => (), _ => (),
}, },
// Self-closing points (`<trkpt lat=".." lon=".."/>`), which have no children
Ok(Event::Empty(e)) => match e.name().as_ref() {
"trkpt" => {
if let Some(GPXElement::Segment(trkseg)) = stack.last_mut() {
trkpt_chunk.trkpt.push(Trackpoint {
coordinates: parse_coordinates(e.attributes()),
..Default::default()
});
if trkpt_chunk.is_full() {
trkseg.push(std::mem::take(&mut trkpt_chunk));
}
}
}
"wpt" => {
wpt_chunk.wpt.push(Waypoint {
coordinates: parse_coordinates(e.attributes()),
..Default::default()
});
if wpt_chunk.is_full() {
gpx.wpt.push(Rc::new(std::mem::take(&mut wpt_chunk)));
}
}
_ => (),
},
Ok(Event::End(e)) => match e.name().as_ref() { Ok(Event::End(e)) => match e.name().as_ref() {
"gpx" => { "gpx" => {
if !wpt_chunk.wpt.is_empty() { if !wpt_chunk.wpt.is_empty() {
@@ -270,6 +304,30 @@ pub fn parse(data: &[u8]) -> Result<File, Error> {
trkpt.power = e.parse().ok(); trkpt.power = e.parse().ok();
} }
} }
Some(GPXElement::Surface) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.surface = categories.surface.code(&e);
}
}
Some(GPXElement::Highway) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.highway = categories.highway.code(&e);
}
}
Some(GPXElement::SacScale) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.sac_scale = categories.sac_scale.code(&e);
}
}
Some(GPXElement::MtbScale) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.mtb_scale = categories.mtb_scale.code(&e);
}
}
Some(GPXElement::Symbol) => { Some(GPXElement::Symbol) => {
stack.pop(); stack.pop();
if let Some(GPXElement::Waypoint(wpt)) = stack.last_mut() { if let Some(GPXElement::Waypoint(wpt)) = stack.last_mut() {
@@ -291,7 +349,7 @@ pub fn parse(data: &[u8]) -> Result<File, Error> {
Some(GPXElement::Color) => { Some(GPXElement::Color) => {
stack.pop(); stack.pop();
if let Some(GPXElement::Track(trk)) = stack.last_mut() { if let Some(GPXElement::Track(trk)) = stack.last_mut() {
trk.info.color = Some(format!("#{}", &*e)); trk.info.color = Some(e.to_string());
} }
} }
Some(GPXElement::Opacity) => { Some(GPXElement::Opacity) => {
@@ -322,7 +380,35 @@ mod tests {
use super::*; use super::*;
fn parse_data(name: &str) -> File { fn parse_data(name: &str) -> File {
parse(&std::fs::read(format!("data/{name}.gpx")).unwrap()).unwrap() parse(
&std::fs::read(format!("data/{name}.gpx")).unwrap(),
&mut Default::default(),
)
.unwrap()
}
#[test]
fn test_parse_self_closing_points() {
let gpx = parse_data("self_closing_points");
assert_eq!(gpx.info.name, "self closing points");
let wpt: Vec<_> = gpx.wpt.iter().flat_map(|chunk| &chunk.wpt).collect();
assert_eq!(wpt.len(), 3);
assert_eq!(wpt[0].coordinates.lat, 50.0);
assert_eq!(wpt[0].name, None);
assert_eq!(wpt[1].name.as_deref(), Some("with children"));
assert_eq!(wpt[2].coordinates.lng, 4.2);
assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 2);
let seg = &trk.trkseg[0];
assert_eq!(seg.len(), 3);
assert_eq!(seg[0].coordinates.lat, 50.0);
assert_eq!(seg[0].ele, 0.0);
assert_eq!(seg[1].ele, 12.5);
assert_eq!(seg[2].coordinates.lng, 4.02);
assert_eq!(trk.trkseg[1].len(), 1);
} }
#[test] #[test]
@@ -513,6 +599,98 @@ mod tests {
assert!(trkpt.hr.is_some_and(|h| h == 150)); assert!(trkpt.hr.is_some_and(|h| h == 150));
} }
fn parse_data_with_categories(name: &str) -> (File, TrackpointCategories) {
let mut categories = TrackpointCategories::default();
let file = parse(
&std::fs::read(format!("data/{name}.gpx")).unwrap(),
&mut categories,
)
.unwrap();
(file, categories)
}
#[test]
fn test_parse_trackpoint_surface() {
let (gpx, categories) = parse_data_with_categories("with_surface");
let trkseg = &gpx.trk[0].trkseg[0];
assert_eq!(trkseg.len(), 80);
// the codes follow the order of appearance in the file
assert_eq!(categories.surface.names(), ["asphalt", "cobblestone"]);
let asphalt = Some(0);
let cobblestone = Some(1);
assert_eq!(trkseg.iter().filter(|p| p.surface == asphalt).count(), 79);
assert_eq!(
trkseg.iter().filter(|p| p.surface == cobblestone).count(),
1
);
// no highway in this file
assert!(trkseg.iter().all(|trkpt| trkpt.highway.is_none()));
assert!(categories.highway.names().is_empty());
}
#[test]
fn test_parse_trackpoint_surface_highway_and_scales() {
let (gpx, categories) = parse_data_with_categories("with_highway");
let trkseg = &gpx.trk[0].trkseg[0];
let names = |code: Option<u8>, names: &crate::Categories| {
code.and_then(|code| names.name(code)).map(str::to_owned)
};
let values: Vec<_> = trkseg
.iter()
.map(|trkpt| {
[
names(trkpt.surface, &categories.surface),
names(trkpt.highway, &categories.highway),
names(trkpt.sac_scale, &categories.sac_scale),
names(trkpt.mtb_scale, &categories.mtb_scale),
]
})
.collect();
let some = |s: &str| Some(s.to_owned());
assert_eq!(
values,
[
[some("asphalt"), some("residential"), None, None],
[some("asphalt"), some("residential"), None, None],
[None, None, None, None],
[
some("gravel"),
some("track"),
some("mountain_hiking"),
some("1")
],
[some("gravel"), None, some("hiking"), some("1")],
]
);
// the tables are separate
assert_eq!(categories.sac_scale.names(), ["mountain_hiking", "hiking"]);
assert_eq!(categories.mtb_scale.names(), ["1"]);
}
#[test]
fn test_parse_shares_the_categories_between_files() {
let mut categories = TrackpointCategories::default();
let mut parsed = |name: &str| {
parse(
&std::fs::read(format!("data/{name}.gpx")).unwrap(),
&mut categories,
)
.unwrap()
};
let first = parsed("with_highway");
let second = parsed("with_surface");
let code = |file: &File, i: usize| file.trk[0].trkseg[0][i].surface;
// "asphalt" is the same in both, "cobblestone" comes after "gravel"
assert_eq!(code(&first, 0), Some(0));
assert_eq!(code(&second, 0), Some(0));
assert_eq!(
categories.surface.names(),
["asphalt", "gravel", "cobblestone"]
);
}
#[test] #[test]
fn test_parse_trackpoint_cad() { fn test_parse_trackpoint_cad() {
let gpx = parse_data("with_cad"); let gpx = parse_data("with_cad");
@@ -572,7 +750,7 @@ mod tests {
assert_eq!(gpx.trk.len(), 1); assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0]; let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 1); assert_eq!(trk.trkseg.len(), 1);
assert!(trk.info.color.as_ref().is_some_and(|c| c == "#2d3ee9")); assert!(trk.info.color.as_ref().is_some_and(|c| c == "2d3ee9"));
assert!(trk.info.opacity.is_some_and(|o| o == 0.5)); assert!(trk.info.opacity.is_some_and(|o| o == 0.5));
assert!(trk.info.width.is_some_and(|w| w == 6.0)); assert!(trk.info.width.is_some_and(|w| w == 6.0));
} }
@@ -41,6 +41,10 @@ impl Statistics {
self.accumulate_distance_and_time(prev, cur); self.accumulate_distance_and_time(prev, cur);
self.update_time_bounds(cur.time); self.update_time_bounds(cur.time);
self.update_bounds(cur.coordinates); self.update_bounds(cur.coordinates);
self.global.hr.add(cur.hr.map(f64::from));
self.global.cad.add(cur.cad.map(f64::from));
self.global.atemp.add(cur.atemp.map(f64::from));
self.global.power.add(cur.power.map(f64::from));
self.local self.local
.push(TrackpointStatistics::from_partial_stats(self)); .push(TrackpointStatistics::from_partial_stats(self));
} }
@@ -85,8 +89,8 @@ impl Statistics {
trkseg, trkseg,
trkseg.first_index(), trkseg.first_index(),
trkseg.last_index(), trkseg.last_index(),
Some(10000), Some(10_000),
|i, j| time_diff(trkseg[i].time, trkseg[j].time), |i, j| time_diff(trkseg[j].time, trkseg[i].time),
|i, left, right| { |i, left, right| {
self.local[i.flat].speed = self.local[i.flat].speed =
time_diff(trkseg[right].time, trkseg[left].time).map(|t| { time_diff(trkseg[right].time, trkseg[left].time).map(|t| {
@@ -207,17 +211,47 @@ impl Statistics {
} }
} }
/// Average of an optional measure (heart rate, cadence...) over the trackpoints that have it.
/// Keeps the sum so that averages can be merged.
#[derive(Default, Debug, Clone, Copy, PartialEq)]
pub struct Average {
pub sum: f64,
pub count: u32,
}
impl Average {
pub fn add(&mut self, value: Option<f64>) {
if let Some(value) = value {
self.sum += value;
self.count += 1;
}
}
pub fn merge(&mut self, other: &Average) {
self.sum += other.sum;
self.count += other.count;
}
pub fn avg(&self) -> Option<f64> {
(self.count > 0).then(|| self.sum / self.count as f64)
}
}
#[derive(Default, Debug)] #[derive(Default, Debug)]
pub struct GlobalStatistics { pub struct GlobalStatistics {
pub total_distance: f64, pub total_distance: f64,
pub moving_distance: Option<f64>, pub moving_distance: Option<f64>,
pub total_time: Option<i32>, pub total_time: Option<i64>,
pub moving_time: Option<i32>, pub moving_time: Option<i64>,
pub elevation_gain: f64, pub elevation_gain: f64,
pub elevation_loss: f64, pub elevation_loss: f64,
pub start_time: Option<i64>, pub start_time: Option<i64>,
pub end_time: Option<i64>, pub end_time: Option<i64>,
pub bounds: LngLatBounds, pub bounds: LngLatBounds,
pub hr: Average,
pub cad: Average,
pub atemp: Average,
pub power: Average,
} }
impl GlobalStatistics { impl GlobalStatistics {
@@ -241,6 +275,10 @@ impl GlobalStatistics {
self.start_time = min_options(self.start_time, other.start_time); self.start_time = min_options(self.start_time, other.start_time);
self.end_time = max_options(self.end_time, other.end_time); self.end_time = max_options(self.end_time, other.end_time);
self.bounds.merge(&other.bounds); self.bounds.merge(&other.bounds);
self.hr.merge(&other.hr);
self.cad.merge(&other.cad);
self.atemp.merge(&other.atemp);
self.power.merge(&other.power);
} }
} }
@@ -248,8 +286,8 @@ impl GlobalStatistics {
pub struct TrackpointStatistics { pub struct TrackpointStatistics {
pub total_distance: f64, pub total_distance: f64,
pub moving_distance: Option<f64>, pub moving_distance: Option<f64>,
pub total_time: Option<i32>, pub total_time: Option<i64>,
pub moving_time: Option<i32>, pub moving_time: Option<i64>,
pub speed: Option<f64>, pub speed: Option<f64>,
pub elevation_gain: f64, pub elevation_gain: f64,
pub elevation_loss: f64, pub elevation_loss: f64,
@@ -293,7 +331,7 @@ mod tests {
let mut f = File::open("data/with_time.gpx").unwrap(); let mut f = File::open("data/with_time.gpx").unwrap();
let mut data = String::new(); let mut data = String::new();
let _ = f.read_to_string(&mut data); let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap(); let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap();
let trkseg = &gpx.trk[0].trkseg[0]; let trkseg = &gpx.trk[0].trkseg[0];
let stats = Statistics::compute(trkseg); let stats = Statistics::compute(trkseg);
@@ -310,7 +348,7 @@ mod tests {
let mut f = File::open(path).unwrap(); let mut f = File::open(path).unwrap();
let mut data = String::new(); let mut data = String::new();
let _ = f.read_to_string(&mut data); let _ = f.read_to_string(&mut data);
parse(data.as_bytes()).unwrap() parse(data.as_bytes(), &mut Default::default()).unwrap()
} }
#[test] #[test]
@@ -417,4 +455,50 @@ mod tests {
assert_eq!(cumul.total_time, Some(7_000)); assert_eq!(cumul.total_time, Some(7_000));
assert_eq!((a.bounds.ne.lng, a.bounds.ne.lat), (2.0, 3.0)); assert_eq!((a.bounds.ne.lng, a.bounds.ne.lat), (2.0, 3.0));
} }
#[test]
fn test_average() {
let mut average = Average::default();
assert_eq!(average.avg(), None);
average.add(None);
assert_eq!(average.avg(), None);
average.add(Some(100.0));
average.add(Some(110.0));
assert_eq!(average.avg(), Some(105.0));
let mut other = Average::default();
other.add(Some(130.0));
average.merge(&other);
assert_eq!(average.count, 3);
assert_eq!(average.avg(), Some(113.33333333333333));
}
#[test]
fn test_measures_of_the_trackpoints_are_averaged() {
for (path, measure) in [
("data/with_hr.gpx", 0),
("data/with_cad.gpx", 1),
("data/with_temp.gpx", 2),
("data/with_power_1.gpx", 3),
] {
let data = std::fs::read(path).unwrap();
let gpx = parse(&data, &mut Default::default()).unwrap();
let trkseg = &gpx.trk[0].trkseg[0];
let global = Statistics::compute(trkseg).global;
let values: Vec<f64> = trkseg
.iter()
.filter_map(|trkpt| match measure {
0 => trkpt.hr.map(f64::from),
1 => trkpt.cad.map(f64::from),
2 => trkpt.atemp.map(f64::from),
_ => trkpt.power.map(f64::from),
})
.collect();
let average = [global.hr, global.cad, global.atemp, global.power][measure];
assert!(!values.is_empty(), "{path}");
assert_eq!(average.count as usize, values.len(), "{path}");
let expected = values.iter().sum::<f64>() / values.len() as f64;
assert!((average.avg().unwrap() - expected).abs() < 1e-9, "{path}");
}
}
} }
+16 -3
View File
@@ -18,12 +18,12 @@ pub fn distance(p1: LngLat, p2: LngLat) -> f64 {
EARTH_RADIUS * c EARTH_RADIUS * c
} }
pub fn time_diff(a: Option<i64>, b: Option<i64>) -> Option<i32> { pub fn time_diff(a: Option<i64>, b: Option<i64>) -> Option<i64> {
Some((a? - b?) as i32) Some(a? - b?)
} }
/// Computes the speed for a given distance in kilometers and a time in milliseconds /// Computes the speed for a given distance in kilometers and a time in milliseconds
pub fn speed(distance: f64, time: i32) -> f64 { pub fn speed(distance: f64, time: i64) -> f64 {
distance / (time as f64 / 3_600_000.0) distance / (time as f64 / 3_600_000.0)
} }
@@ -115,6 +115,19 @@ mod tests {
LngLat { lng, lat } LngLat { lng, lat }
} }
#[test]
fn test_time_diff_and_speed_with_long_durations() {
// 40 days in milliseconds, more than an i32 can hold
let forty_days = 40 * 24 * 3_600_000_i64;
assert!(forty_days > i64::from(i32::MAX));
assert_eq!(time_diff(Some(forty_days), Some(0)), Some(forty_days));
assert_eq!(time_diff(Some(0), Some(forty_days)), Some(-forty_days));
assert_eq!(time_diff(None, Some(1)), None);
assert_eq!(time_diff(Some(1), None), None);
// 1 km in one hour
assert!((speed(1.0, 3_600_000) - 1.0).abs() < 1e-12);
}
#[test] #[test]
fn test_distance() { fn test_distance() {
assert_eq!(distance(p(4.0, 50.0), p(4.0, 50.0)), 0.0); assert_eq!(distance(p(4.0, 50.0), p(4.0, 50.0)), 0.0);
+20 -5
View File
@@ -1,4 +1,4 @@
use crate::{Command, FileId}; use crate::{Command, FileId, SelectMode, Selection};
/// Everything the frontend can ask the engine to do. /// Everything the frontend can ask the engine to do.
/// ///
@@ -9,13 +9,28 @@ pub enum Action<'a> {
Edit(Command<'a>), Edit(Command<'a>),
Undo, Undo,
Redo, Redo,
/// Selects files, tracks, segments or waypoints (or the waypoints node of one file),
/// ignoring what does not exist: a selection left with nothing is empty, which deselects
/// everything. See [`SelectMode`] for how it combines with the current selection.
Select { Select {
file_ids: Vec<FileId>, selection: Selection,
}, mode: SelectMode,
AddSelect {
file_ids: Vec<FileId>,
}, },
/// Selects all the elements of the same kind as the selected ones, in the same place (see
/// [`Selection::all_at_level`]). Does nothing when there is nothing more to select.
SelectAll, SelectAll,
/// Moves the selection to the next (`down`) or previous element of the same kind, like the
/// arrow keys do in the file list (see [`Selection::neighbour`]). With `add`, the element is
/// added to the selection (shift + arrow), otherwise it replaces it.
ArrowSelect {
down: bool,
add: bool,
},
/// Puts the selected elements in the clipboard, to be pasted by [`crate::Paste`]. Does
/// nothing when nothing is selected.
Copy,
/// Like `Copy`, but the elements are moved instead of copied when they are pasted.
Cut,
/// Moves the files to `index` among the other files, in the given order. Not undoable. /// Moves the files to `index` among the other files, in the given order. Not undoable.
Reorder { Reorder {
file_ids: Vec<FileId>, file_ids: Vec<FileId>,
+13 -3
View File
@@ -1,8 +1,8 @@
use crate::{Apply, CommandError, State}; use crate::{Apply, CommandError, State};
use crate::{ use crate::{
Clean, Crop, Delete, DeleteAll, Duplicate, Elevation, Extract, Load, Merge, Metadata, Clean, Crop, Delete, DeleteAll, DeleteWaypoint, Duplicate, EditWaypoint, Elevation, Extract,
MoveWaypoint, New, NewTrack, NewTrackSegment, NewWaypoint, Reverse, SpliceTrackpoints, Split, Load, LoadFiles, Merge, Metadata, Move, MoveWaypoint, New, NewTrack, NewTrackSegment,
Style, Time, NewWaypoint, Paste, Reverse, SpliceTrackpoints, Split, Style, Time,
}; };
/// A user action that edits the files. Each variant wraps the command's own struct, whose /// A user action that edits the files. Each variant wraps the command's own struct, whose
@@ -14,9 +14,13 @@ use crate::{
pub enum Command<'a> { pub enum Command<'a> {
New(New<'a>), New(New<'a>),
Load(Load<'a>), Load(Load<'a>),
LoadFiles(LoadFiles<'a>),
Duplicate(Duplicate), Duplicate(Duplicate),
Delete(Delete), Delete(Delete),
Paste(Paste),
Move(Move),
DeleteAll(DeleteAll), DeleteAll(DeleteAll),
DeleteWaypoint(DeleteWaypoint),
Metadata(Metadata<'a>), Metadata(Metadata<'a>),
Style(Style<'a>), Style(Style<'a>),
NewTrack(NewTrack), NewTrack(NewTrack),
@@ -24,6 +28,7 @@ pub enum Command<'a> {
Reverse(Reverse), Reverse(Reverse),
SpliceTrackpoints(SpliceTrackpoints<'a>), SpliceTrackpoints(SpliceTrackpoints<'a>),
NewWaypoint(NewWaypoint<'a>), NewWaypoint(NewWaypoint<'a>),
EditWaypoint(EditWaypoint<'a>),
MoveWaypoint(MoveWaypoint), MoveWaypoint(MoveWaypoint),
Crop(Crop), Crop(Crop),
Split(Split), Split(Split),
@@ -39,9 +44,13 @@ impl Apply for Command<'_> {
match self { match self {
Command::New(c) => c.apply(state), Command::New(c) => c.apply(state),
Command::Load(c) => c.apply(state), Command::Load(c) => c.apply(state),
Command::LoadFiles(c) => c.apply(state),
Command::Duplicate(c) => c.apply(state), Command::Duplicate(c) => c.apply(state),
Command::Delete(c) => c.apply(state), Command::Delete(c) => c.apply(state),
Command::Paste(c) => c.apply(state),
Command::Move(c) => c.apply(state),
Command::DeleteAll(c) => c.apply(state), Command::DeleteAll(c) => c.apply(state),
Command::DeleteWaypoint(c) => c.apply(state),
Command::Metadata(c) => c.apply(state), Command::Metadata(c) => c.apply(state),
Command::Style(c) => c.apply(state), Command::Style(c) => c.apply(state),
Command::NewTrack(c) => c.apply(state), Command::NewTrack(c) => c.apply(state),
@@ -49,6 +58,7 @@ impl Apply for Command<'_> {
Command::Reverse(c) => c.apply(state), Command::Reverse(c) => c.apply(state),
Command::SpliceTrackpoints(c) => c.apply(state), Command::SpliceTrackpoints(c) => c.apply(state),
Command::NewWaypoint(c) => c.apply(state), Command::NewWaypoint(c) => c.apply(state),
Command::EditWaypoint(c) => c.apply(state),
Command::MoveWaypoint(c) => c.apply(state), Command::MoveWaypoint(c) => c.apply(state),
Command::Crop(c) => c.apply(state), Command::Crop(c) => c.apply(state),
Command::Split(c) => c.apply(state), Command::Split(c) => c.apply(state),
@@ -17,6 +17,10 @@ impl Editor for Metadata<'_> {
fn file(&mut self, file: &mut File) -> Edit { fn file(&mut self, file: &mut File) -> Edit {
file.info.name = self.name.to_owned(); file.info.name = self.name.to_owned();
file.info.desc = Some(self.desc.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());
}
Edit::Changed Edit::Changed
} }
@@ -31,7 +35,7 @@ impl Editor for Metadata<'_> {
mod tests { mod tests {
use std::collections::HashSet; use std::collections::HashSet;
use crate::{New, Selection, engine::command::fixture::Fixture}; use crate::{New, NewTrack, Selection, engine::command::fixture::Fixture};
use super::*; use super::*;
@@ -64,7 +68,12 @@ mod tests {
fn test_metadata_applies_to_selected_track() { fn test_metadata_applies_to_selected_track() {
let mut fx = Fixture::default(); let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks.gpx").unwrap(); let data = std::fs::read("data/with_tracks.gpx").unwrap();
crate::Load { data: &data }.apply(&mut fx.state()).unwrap(); crate::Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let file = fx.files.values().next().unwrap().clone(); let file = fx.files.values().next().unwrap().clone();
fx.selection = Selection::Track { fx.selection = Selection::Track {
file_id: file.id, file_id: file.id,
@@ -83,4 +92,55 @@ mod tests {
assert_eq!(after.trk[0].info.name.as_deref(), Some("renamed")); assert_eq!(after.trk[0].info.name.as_deref(), Some("renamed"));
assert_eq!(after.trk[1].info, file.trk[1].info); assert_eq!(after.trk[1].info, file.trk[1].info);
} }
/// A new file with `nb_tracks` tracks (without name), selected.
fn file_with_tracks(fx: &mut Fixture, nb_tracks: usize) {
New { name: "file" }.apply(&mut fx.state()).unwrap();
for _ in 0..nb_tracks {
NewTrack.apply(&mut fx.state()).unwrap();
}
}
fn rename(fx: &mut Fixture) {
Metadata {
name: "renamed",
desc: "",
}
.apply(&mut fx.state())
.unwrap();
}
#[test]
fn test_single_track_is_renamed_like_the_file() {
let mut fx = Fixture::default();
file_with_tracks(&mut fx, 1);
rename(&mut fx);
let file = fx.files.values().next().unwrap();
assert_eq!(file.info.name, "renamed");
assert_eq!(file.trk[0].info.name.as_deref(), Some("renamed"));
}
#[test]
fn test_single_track_with_a_name_is_renamed_too() {
let mut fx = Fixture::default();
file_with_tracks(&mut fx, 1);
let id = *fx.files.keys().next().unwrap();
std::rc::Rc::make_mut(fx.files.get_mut(&id).unwrap()).trk[0]
.info
.name = Some("track".to_owned());
rename(&mut fx);
let file = &fx.files[&id];
assert_eq!(file.info.name, "renamed");
assert_eq!(file.trk[0].info.name.as_deref(), Some("renamed"));
}
#[test]
fn test_several_tracks_are_not_renamed() {
let mut fx = Fixture::default();
file_with_tracks(&mut fx, 2);
rename(&mut fx);
let file = fx.files.values().next().unwrap();
assert_eq!(file.info.name, "renamed");
assert!(file.trk.iter().all(|trk| trk.info.name.is_none()));
}
} }
@@ -41,7 +41,12 @@ mod tests {
fn test_style_applies_to_all_tracks_of_selected_file_and_keeps_unset_fields() { fn test_style_applies_to_all_tracks_of_selected_file_and_keeps_unset_fields() {
let mut fx = Fixture::default(); let mut fx = Fixture::default();
let data = std::fs::read("data/with_style.gpx").unwrap(); let data = std::fs::read("data/with_style.gpx").unwrap();
crate::Load { data: &data }.apply(&mut fx.state()).unwrap(); crate::Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = *fx.files.keys().next().unwrap(); let id = *fx.files.keys().next().unwrap();
let before = fx.files[&id].clone(); let before = fx.files[&id].clone();
assert!(!before.trk.is_empty()); assert!(!before.trk.is_empty());
+74 -12
View File
@@ -4,12 +4,22 @@ use crate::{
Apply, CommandError, FileId, Selection, StackEntry, State, Waypoint, edit_waypoint_chunks, Apply, CommandError, FileId, Selection, StackEntry, State, Waypoint, edit_waypoint_chunks,
}; };
/// 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.
#[derive(Debug)] #[derive(Debug)]
pub struct Delete; pub struct Delete {
pub whole_files: bool,
}
impl Apply for Delete { impl Apply for Delete {
fn apply(self, state: &mut State) -> Result<(), CommandError> { fn apply(self, state: &mut State) -> Result<(), CommandError> {
let next = match &*state.selection { let files = state.selection.to_files();
let selection = if self.whole_files {
&files
} else {
&*state.selection
};
let next = match selection {
Selection::File { file_ids } => { Selection::File { file_ids } => {
state.files.retain(|id, _| !file_ids.contains(id)); state.files.retain(|id, _| !file_ids.contains(id));
state.order.0.retain(|id| !file_ids.contains(id)); state.order.0.retain(|id| !file_ids.contains(id));
@@ -66,7 +76,7 @@ impl Apply for Delete {
} }
} }
fn delete_waypoints( pub(crate) fn delete_waypoints(
files: &mut StackEntry, files: &mut StackEntry,
file_id: FileId, file_id: FileId,
filter: impl Fn(&Waypoint) -> bool, filter: impl Fn(&Waypoint) -> bool,
@@ -98,7 +108,12 @@ mod tests {
fn loaded() -> (Fixture, FileId) { fn loaded() -> (Fixture, FileId) {
let mut fx = Fixture::default(); let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap(); let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load { data: &data }.apply(&mut fx.state()).unwrap(); Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0]; let id = fx.order.0[0];
(fx, id) (fx, id)
} }
@@ -107,7 +122,7 @@ mod tests {
fn test_delete_nothing_selected() { fn test_delete_nothing_selected() {
let mut fx = Fixture::default(); let mut fx = Fixture::default();
assert_eq!( assert_eq!(
Delete.apply(&mut fx.state()), Delete { whole_files: false }.apply(&mut fx.state()),
Err(CommandError::NothingToDo) Err(CommandError::NothingToDo)
); );
} }
@@ -120,7 +135,9 @@ mod tests {
fx.selection = Selection::File { fx.selection = Selection::File {
file_ids: HashSet::from([id]), file_ids: HashSet::from([id]),
}; };
Delete.apply(&mut fx.state()).unwrap(); Delete { whole_files: false }
.apply(&mut fx.state())
.unwrap();
assert_eq!(fx.order.0, vec![keep]); assert_eq!(fx.order.0, vec![keep]);
assert!(fx.files.contains_key(&keep) && !fx.files.contains_key(&id)); assert!(fx.files.contains_key(&keep) && !fx.files.contains_key(&id));
assert!(matches!(fx.selection, Selection::Empty)); assert!(matches!(fx.selection, Selection::Empty));
@@ -135,7 +152,9 @@ mod tests {
file_id: id, file_id: id,
trk_ids: HashSet::from([trk_id]), trk_ids: HashSet::from([trk_id]),
}; };
Delete.apply(&mut fx.state()).unwrap(); Delete { whole_files: false }
.apply(&mut fx.state())
.unwrap();
let file = &fx.files[&id]; let file = &fx.files[&id];
assert_eq!(file.trk.len(), before - 1); assert_eq!(file.trk.len(), before - 1);
assert!(file.trk.iter().all(|t| t.id != trk_id)); assert!(file.trk.iter().all(|t| t.id != trk_id));
@@ -152,7 +171,9 @@ mod tests {
trk_id, trk_id,
trkseg_ids: HashSet::from([seg_id]), trkseg_ids: HashSet::from([seg_id]),
}; };
Delete.apply(&mut fx.state()).unwrap(); Delete { whole_files: false }
.apply(&mut fx.state())
.unwrap();
let trk = &fx.files[&id].trk[0]; let trk = &fx.files[&id].trk[0];
assert_eq!(trk.trkseg.len(), before - 1); assert_eq!(trk.trkseg.len(), before - 1);
assert!(trk.trkseg.iter().all(|s| s.id != seg_id)); assert!(trk.trkseg.iter().all(|s| s.id != seg_id));
@@ -182,7 +203,9 @@ mod tests {
wpt_ids: HashSet::from([ids[1]]), wpt_ids: HashSet::from([ids[1]]),
}; };
let rev = fx.files[&id].wpt_rev_id; let rev = fx.files[&id].wpt_rev_id;
Delete.apply(&mut fx.state()).unwrap(); Delete { whole_files: false }
.apply(&mut fx.state())
.unwrap();
assert_ne!(fx.files[&id].wpt_rev_id, rev); assert_ne!(fx.files[&id].wpt_rev_id, rev);
let left: Vec<_> = fx.files[&id] let left: Vec<_> = fx.files[&id]
.wpt .wpt
@@ -198,11 +221,13 @@ mod tests {
let (mut fx, id) = loaded(); let (mut fx, id) = loaded();
with_waypoints(&mut fx, id, 3); with_waypoints(&mut fx, id, 3);
fx.selection = Selection::Waypoints { file_id: id }; fx.selection = Selection::Waypoints { file_id: id };
Delete.apply(&mut fx.state()).unwrap(); Delete { whole_files: false }
.apply(&mut fx.state())
.unwrap();
assert!(fx.files[&id].wpt.is_empty()); assert!(fx.files[&id].wpt.is_empty());
fx.selection = Selection::Waypoints { file_id: id }; fx.selection = Selection::Waypoints { file_id: id };
assert_eq!( assert_eq!(
Delete.apply(&mut fx.state()), Delete { whole_files: false }.apply(&mut fx.state()),
Err(CommandError::NothingToDo) Err(CommandError::NothingToDo)
); );
} }
@@ -215,7 +240,44 @@ mod tests {
trk_ids: HashSet::from([Default::default()]), trk_ids: HashSet::from([Default::default()]),
}; };
assert_eq!( assert_eq!(
Delete.apply(&mut fx.state()), Delete { whole_files: false }.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_delete_whole_files_of_the_selected_elements() {
for selection in [
|id: FileId, fx: &Fixture| Selection::Track {
file_id: id,
trk_ids: HashSet::from([fx.files[&id].trk[0].id]),
},
|id: FileId, fx: &Fixture| Selection::TrackSegment {
file_id: id,
trk_id: fx.files[&id].trk[0].id,
trkseg_ids: HashSet::from([fx.files[&id].trk[0].trkseg[0].id]),
},
|id: FileId, _: &Fixture| Selection::Waypoints { file_id: id },
|id: FileId, _: &Fixture| Selection::File {
file_ids: HashSet::from([id]),
},
] {
let (mut fx, id) = loaded();
crate::New { name: "keep" }.apply(&mut fx.state()).unwrap();
let keep = fx.order.0[1];
fx.selection = selection(id, &fx);
Delete { whole_files: true }.apply(&mut fx.state()).unwrap();
assert_eq!(fx.order.0, vec![keep]);
assert!(!fx.files.contains_key(&id));
assert!(matches!(fx.selection, Selection::Empty));
}
}
#[test]
fn test_delete_whole_files_nothing_selected() {
let mut fx = Fixture::default();
assert_eq!(
Delete { whole_files: true }.apply(&mut fx.state()),
Err(CommandError::NothingToDo) Err(CommandError::NothingToDo)
); );
} }
@@ -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)));
}
}
@@ -1,8 +1,8 @@
use std::{collections::HashSet, rc::Rc}; use std::{collections::HashSet, rc::Rc};
use crate::{ use crate::{
Apply, CommandError, File, FileId, Selection, StackEntry, State, Track, TrackSegment, Waypoint, Apply, CommandError, FileId, Selection, StackEntry, State, Waypoint, copy_file, copy_segment,
WaypointChunk, copy_track, copy_waypoint, insert_waypoints,
}; };
#[derive(Debug)] #[derive(Debug)]
@@ -112,60 +112,23 @@ fn duplicate_waypoints(
filter: impl Fn(&Waypoint) -> bool, filter: impl Fn(&Waypoint) -> bool,
) -> Result<Selection, CommandError> { ) -> Result<Selection, CommandError> {
let file = files.get(&file_id).ok_or(CommandError::NothingToDo)?; let file = files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let mut file = (**file).clone(); let selected: Vec<&Waypoint> = file
let last_chunk = file
.wpt
.iter()
.rposition(|chunk| chunk.wpt.iter().any(&filter))
.ok_or(CommandError::NothingToDo)?;
let mut copies = HashSet::new();
let mut inserted = Vec::new();
let mut chunk = WaypointChunk::default();
for wpt in file
.wpt .wpt
.iter() .iter()
.flat_map(|chunk| &chunk.wpt) .flat_map(|chunk| &chunk.wpt)
.filter(|wpt| filter(wpt)) .filter(|wpt| filter(wpt))
{ .collect();
let mut copy = wpt.clone(); let last = selected.last().ok_or(CommandError::NothingToDo)?.id;
copy.id = Default::default(); let copies: Vec<Waypoint> = selected.into_iter().map(copy_waypoint).collect();
copies.insert(copy.id); let copy_ids = copies.iter().map(|wpt| wpt.id).collect();
chunk.wpt.push(copy);
if chunk.is_full() {
inserted.push(Rc::new(std::mem::take(&mut chunk)));
}
}
if !chunk.wpt.is_empty() {
inserted.push(Rc::new(chunk));
}
// The copies go right after the last selected waypoint: only the chunk holding it is cut // The copies go right after the last selected waypoint.
// (when waypoints follow it), all the other chunks are kept as they are. let mut file = (**file).clone();
let cut = &file.wpt[last_chunk]; insert_waypoints(&mut file, Some(last), copies);
let split = cut.wpt.iter().rposition(&filter).unwrap() + 1;
let replacement = if split == cut.wpt.len() {
let mut chunks = vec![cut.clone()];
chunks.extend(inserted);
chunks
} else {
let part = |wpt: &[Waypoint]| {
Rc::new(WaypointChunk {
wpt: wpt.to_vec(),
..Default::default()
})
};
let mut chunks = vec![part(&cut.wpt[..split])];
chunks.extend(inserted);
chunks.push(part(&cut.wpt[split..]));
chunks
};
file.wpt.splice(last_chunk..=last_chunk, replacement);
file.wpt_rev_id = Default::default();
files.insert(file_id, Rc::new(file)); files.insert(file_id, Rc::new(file));
Ok(Selection::Waypoint { Ok(Selection::Waypoint {
file_id, file_id,
wpt_ids: copies, wpt_ids: copy_ids,
}) })
} }
@@ -178,29 +141,6 @@ fn append_copies<T>(items: &mut Vec<T>, filter: impl Fn(&T) -> bool, copy: impl
items.splice(last + 1..last + 1, copies); items.splice(last + 1..last + 1, copies);
} }
// Track points are shared chunks, so copies are cheap.
fn copy_segment(segment: &TrackSegment) -> TrackSegment {
let mut copy = segment.clone();
copy.id = Default::default();
copy
}
fn copy_track(track: &Track) -> Track {
Track {
id: Default::default(),
trkseg: track.trkseg.iter().map(copy_segment).collect(),
..track.clone()
}
}
fn copy_file(file: &File) -> File {
File {
id: Default::default(),
trk: file.trk.iter().map(copy_track).collect(),
..file.clone()
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::engine::command::fixture::Fixture; use crate::engine::command::fixture::Fixture;
@@ -211,7 +151,12 @@ mod tests {
fn loaded() -> (Fixture, FileId) { fn loaded() -> (Fixture, FileId) {
let mut fx = Fixture::default(); let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap(); let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load { data: &data }.apply(&mut fx.state()).unwrap(); Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0]; let id = fx.order.0[0];
(fx, id) (fx, id)
} }
+176 -4
View File
@@ -1,14 +1,56 @@
use crate::{Apply, CommandError, State, parse, produce}; use crate::{Apply, CommandError, File, State, TrackpointCategories, parse, produce};
#[derive(Debug)] #[derive(Debug)]
pub struct Load<'a> { pub struct Load<'a> {
pub data: &'a [u8], pub data: &'a [u8],
/// Name of the file when the data has none (typically the name of the file on disk, without
/// its extension).
pub name: &'a str,
}
impl Load<'_> {
fn parse(self, categories: &mut TrackpointCategories) -> Result<File, CommandError> {
let mut file = parse(self.data, categories)
.map_err(|err| CommandError::InvalidData(err.to_string()))?;
if file.info.name.trim().is_empty() {
file.info.name = self.name.to_owned();
}
Ok(file)
}
} }
impl Apply for Load<'_> { impl Apply for Load<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> { fn apply(self, state: &mut State) -> Result<(), CommandError> {
let file = parse(self.data).map_err(|err| CommandError::InvalidData(err.to_string()))?; LoadFiles { files: vec![self] }.apply(state)
produce(state, |_| vec![file]); }
}
/// Loads several files at once, as a single command (one undo step): the files are added at the
/// end of the list, in the given order, and the first one is selected.
///
/// The files that cannot be read are skipped, unless none of them can: then nothing is loaded
/// and the error is the one of the first file.
#[derive(Debug)]
pub struct LoadFiles<'a> {
pub files: Vec<Load<'a>>,
}
impl Apply for LoadFiles<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let mut files = Vec::with_capacity(self.files.len());
let mut first_error = None;
for load in self.files {
match load.parse(state.categories) {
Ok(file) => files.push(file),
Err(error) => {
first_error.get_or_insert(error);
}
}
}
if files.is_empty() {
return Err(first_error.unwrap_or(CommandError::NothingToDo));
}
produce(state, |_| files);
Ok(()) Ok(())
} }
} }
@@ -23,7 +65,14 @@ mod tests {
fn test_load() { fn test_load() {
let mut fx = Fixture::default(); let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks.gpx").unwrap(); let data = std::fs::read("data/with_tracks.gpx").unwrap();
assert!(Load { data: &data }.apply(&mut fx.state()).is_ok()); assert!(
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.is_ok()
);
assert_eq!(fx.files.len(), 1); assert_eq!(fx.files.len(), 1);
let file = fx.files.values().next().unwrap(); let file = fx.files.values().next().unwrap();
assert!(!file.trk.is_empty()); assert!(!file.trk.is_empty());
@@ -38,6 +87,7 @@ mod tests {
let result = Load { let result = Load {
data: b"<gpx><trk></gpx>", data: b"<gpx><trk></gpx>",
name: "file",
} }
.apply(&mut fx.state()); .apply(&mut fx.state());
assert!(matches!(result, Err(CommandError::InvalidData(_)))); assert!(matches!(result, Err(CommandError::InvalidData(_))));
@@ -45,4 +95,126 @@ mod tests {
assert_eq!(fx.order.0.len(), 1); assert_eq!(fx.order.0.len(), 1);
assert_eq!(fx.selected_files(), selected); assert_eq!(fx.selected_files(), selected);
} }
fn load_with_name(data: &[u8]) -> String {
let mut fx = Fixture::default();
Load {
data,
name: "from disk",
}
.apply(&mut fx.state())
.unwrap();
fx.files.values().next().unwrap().info.name.clone()
}
#[test]
fn test_load_keeps_the_name_of_the_file() {
let data = br#"<gpx version="1.1"><metadata><name>in the file</name></metadata></gpx>"#;
assert_eq!(load_with_name(data), "in the file");
}
#[test]
fn test_load_without_name_uses_the_given_name() {
let without = br#"<gpx version="1.1"><trk><trkseg></trkseg></trk></gpx>"#;
assert_eq!(load_with_name(without), "from disk");
let empty = br#"<gpx version="1.1"><metadata><name> </name></metadata></gpx>"#;
assert_eq!(load_with_name(empty), "from disk");
}
fn gpx(name: &str) -> Vec<u8> {
format!(r#"<gpx version="1.1"><metadata><name>{name}</name></metadata></gpx>"#).into_bytes()
}
fn load<'a>(data: &'a [u8], name: &'a str) -> Load<'a> {
Load { data, name }
}
#[test]
fn test_load_several_files() {
let mut fx = Fixture::default();
crate::New { name: "before" }
.apply(&mut fx.state())
.unwrap();
let before = fx.order.0[0];
let (a, b, c) = (gpx("a"), gpx("b"), gpx("c"));
LoadFiles {
files: vec![load(&a, "x"), load(&b, "x"), load(&c, "x")],
}
.apply(&mut fx.state())
.unwrap();
// added after the others, in the order they were given
let names: Vec<_> = fx
.order
.0
.iter()
.map(|id| fx.files[id].info.name.clone())
.collect();
assert_eq!(names, ["before", "a", "b", "c"]);
assert_eq!(fx.order.0[0], before);
// the first one is selected
assert_eq!(fx.selected_files(), [fx.order.0[1]].into());
}
#[test]
fn test_load_several_files_skips_the_invalid_ones() {
let mut fx = Fixture::default();
let (a, c) = (gpx("a"), gpx("c"));
LoadFiles {
files: vec![load(&a, "x"), load(b"<gpx><trk></gpx>", "x"), load(&c, "x")],
}
.apply(&mut fx.state())
.unwrap();
let names: Vec<_> = fx
.order
.0
.iter()
.map(|id| fx.files[id].info.name.clone())
.collect();
assert_eq!(names, ["a", "c"]);
}
#[test]
fn test_load_several_invalid_files_changes_nothing() {
let mut fx = Fixture::default();
crate::New { name: "keep" }.apply(&mut fx.state()).unwrap();
let selected = fx.selected_files();
let result = LoadFiles {
files: vec![
load(b"<gpx><trk></gpx>", "x"),
load(b"<gpx><wpt></gpx>", "y"),
],
}
.apply(&mut fx.state());
assert!(matches!(result, Err(CommandError::InvalidData(_))));
assert_eq!(fx.files.len(), 1);
assert_eq!(fx.order.0.len(), 1);
assert_eq!(fx.selected_files(), selected);
// nothing to load
assert_eq!(
LoadFiles { files: vec![] }.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_each_file_without_name_gets_its_own_name() {
let mut fx = Fixture::default();
let unnamed = br#"<gpx version="1.1"><trk><trkseg></trkseg></trk></gpx>"#;
LoadFiles {
files: vec![load(unnamed, "first"), load(unnamed, "second")],
}
.apply(&mut fx.state())
.unwrap();
let names: Vec<_> = fx
.order
.0
.iter()
.map(|id| fx.files[id].info.name.clone())
.collect();
assert_eq!(names, ["first", "second"]);
}
} }
@@ -1,11 +1,19 @@
mod delete; mod delete;
mod delete_all; mod delete_all;
mod delete_waypoint;
mod duplicate; mod duplicate;
mod load; mod load;
mod move_elements;
mod new; mod new;
mod paste;
mod transfer;
pub use delete::*; pub use delete::*;
pub use delete_all::*; pub use delete_all::*;
pub use delete_waypoint::*;
pub use duplicate::*; pub use duplicate::*;
pub use load::*; pub use load::*;
pub use move_elements::*;
pub use new::*; pub use new::*;
pub use paste::*;
pub use transfer::*;
@@ -0,0 +1,447 @@
use crate::{
Apply, Clipboard, CommandError, Destination, FileId, Place, Selection, State, TrackId, transfer,
};
/// Where moved elements go: a list of the file tree, and the position in it. The position is
/// counted among the elements of the list that are not moved, like the position of a dragged
/// element in the list it is dropped in.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MoveTarget {
/// The list of files.
Files { index: usize },
/// The tracks of a file.
Tracks { file_id: FileId, index: usize },
/// The segments of a track.
Segments {
file_id: FileId,
trk_id: TrackId,
index: usize,
},
/// The waypoints of a file.
Waypoints { file_id: FileId, index: usize },
}
/// Moves elements to a place of the file tree (what drag and drop does), with their ids. The
/// moved elements are selected.
///
/// 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;
/// - waypoints, or all the waypoints of a file (the waypoints node): among the waypoints of a
/// file.
#[derive(Debug)]
pub struct Move {
pub what: Selection,
pub to: MoveTarget,
}
impl From<MoveTarget> for Destination {
fn from(target: MoveTarget) -> Self {
match target {
MoveTarget::Files { index } => Destination::Files(Place::Index(index)),
MoveTarget::Tracks { file_id, index } => {
Destination::Tracks(file_id, Place::Index(index))
}
MoveTarget::Segments {
file_id,
trk_id,
index,
} => Destination::Segments(file_id, trk_id, Place::Index(index)),
MoveTarget::Waypoints { file_id, index } => {
Destination::Waypoints(file_id, Place::Index(index))
}
}
}
}
impl Apply for Move {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let clipboard = Clipboard::new(&self.what, state.files, &state.order.0, true)
.ok_or(CommandError::NothingToDo)?;
*state.selection = transfer(state, &clipboard.content, true, self.to.into())?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{
File, Track, TrackInfo, TrackSegment, TrackSegmentId, Waypoint, WaypointChunk, WaypointId,
engine::command::fixture::Fixture, new_file, waypoint_ids,
};
use super::*;
/// A file with the given tracks (name, number of segments) and number of waypoints.
fn file(name: &str, tracks: &[(&str, usize)], waypoints: usize) -> File {
let mut file = new_file(
name,
tracks
.iter()
.map(|(name, segments)| Track {
info: TrackInfo {
name: Some((*name).to_owned()),
..Default::default()
},
trkseg: (0..*segments).map(|_| TrackSegment::default()).collect(),
..Default::default()
})
.collect(),
);
if waypoints > 0 {
file.wpt = vec![Rc::new(WaypointChunk {
wpt: (0..waypoints).map(|_| Waypoint::default()).collect(),
..Default::default()
})];
}
file
}
fn add(fx: &mut Fixture, file: File) -> FileId {
let id = file.id;
fx.files.insert(id, Rc::new(file));
fx.order.0.push(id);
id
}
fn track_ids(fx: &Fixture, id: FileId) -> Vec<TrackId> {
fx.files[&id].trk.iter().map(|trk| trk.id).collect()
}
fn segment_ids(fx: &Fixture, id: FileId, trk: usize) -> Vec<TrackSegmentId> {
fx.files[&id].trk[trk].trkseg.iter().map(|s| s.id).collect()
}
fn wpt_ids(fx: &Fixture, id: FileId) -> Vec<WaypointId> {
waypoint_ids(&fx.files[&id]).collect()
}
fn do_move(fx: &mut Fixture, what: Selection, to: MoveTarget) -> Result<(), CommandError> {
Move { what, to }.apply(&mut fx.state())
}
fn tracks(file_id: FileId, ids: &[TrackId]) -> Selection {
Selection::Track {
file_id,
trk_ids: ids.iter().copied().collect(),
}
}
fn segments(file_id: FileId, trk_id: TrackId, ids: &[TrackSegmentId]) -> Selection {
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids: ids.iter().copied().collect(),
}
}
fn waypoints(file_id: FileId, ids: &[WaypointId]) -> Selection {
Selection::Waypoint {
file_id,
wpt_ids: ids.iter().copied().collect(),
}
}
fn files(ids: &[FileId]) -> Selection {
Selection::File {
file_ids: ids.iter().copied().collect(),
}
}
#[test]
fn test_reorder_tracks_in_a_file() {
let build = || {
let mut fx = Fixture::default();
let a = add(
&mut fx,
file("a", &[("0", 1), ("1", 1), ("2", 1), ("3", 1)], 0),
);
let t = track_ids(&fx, a);
(fx, a, t)
};
let at = |a, index| MoveTarget::Tracks { file_id: a, index };
// the position counts the tracks that stay
let (mut fx, a, t) = build();
do_move(&mut fx, tracks(a, &[t[0]]), at(a, 1)).unwrap();
assert_eq!(track_ids(&fx, a), vec![t[1], t[0], t[2], t[3]]);
assert_eq!(fx.selection, tracks(a, &[t[0]]));
let (mut fx, a, t) = build();
do_move(&mut fx, tracks(a, &[t[3]]), at(a, 0)).unwrap();
assert_eq!(track_ids(&fx, a), vec![t[3], t[0], t[1], t[2]]);
// several at once, as a block, in their order; past the end is the end
let (mut fx, a, t) = build();
do_move(&mut fx, tracks(a, &[t[1], t[0]]), at(a, 99)).unwrap();
assert_eq!(track_ids(&fx, a), vec![t[2], t[3], t[0], t[1]]);
assert_eq!(fx.selection, tracks(a, &[t[0], t[1]]));
}
#[test]
fn test_move_tracks_to_another_file_or_to_new_files() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[("t0", 1), ("t1", 1)], 0));
let b = add(&mut fx, file("b", &[("b0", 1), ("b1", 1)], 0));
let c = add(&mut fx, file("c", &[], 0));
let t = track_ids(&fx, a);
let b_tracks = track_ids(&fx, b);
do_move(
&mut fx,
tracks(a, &[t[0]]),
MoveTarget::Tracks {
file_id: b,
index: 1,
},
)
.unwrap();
assert_eq!(track_ids(&fx, a), vec![t[1]]);
assert_eq!(track_ids(&fx, b), vec![b_tracks[0], t[0], b_tracks[1]]);
assert_eq!(fx.selection, tracks(b, &[t[0]]));
// as a new file, at a position of the list of files
do_move(&mut fx, tracks(a, &[t[1]]), MoveTarget::Files { index: 1 }).unwrap();
assert!(track_ids(&fx, a).is_empty());
assert_eq!(fx.order.0.len(), 4);
let new = fx.order.0[1];
assert_eq!(fx.order.0[0], a);
assert_eq!(fx.order.0[2..], [b, c]);
assert_eq!(track_ids(&fx, new), vec![t[1]]);
assert_eq!(fx.files[&new].info.name, "t1");
assert_eq!(fx.selection, files(&[new]));
}
#[test]
fn test_move_segments() {
let build = || {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[("t0", 3), ("t1", 1)], 0));
let t = track_ids(&fx, a);
(fx, a, t)
};
// within a track
let (mut fx, a, t) = build();
let s = segment_ids(&fx, a, 0);
do_move(
&mut fx,
segments(a, t[0], &[s[2]]),
MoveTarget::Segments {
file_id: a,
trk_id: t[0],
index: 0,
},
)
.unwrap();
assert_eq!(segment_ids(&fx, a, 0), vec![s[2], s[0], s[1]]);
assert_eq!(fx.selection, segments(a, t[0], &[s[2]]));
// to another track
let (mut fx, a, t) = build();
let s = segment_ids(&fx, a, 0);
let other = segment_ids(&fx, a, 1);
do_move(
&mut fx,
segments(a, t[0], &[s[0], s[1]]),
MoveTarget::Segments {
file_id: a,
trk_id: t[1],
index: 0,
},
)
.unwrap();
assert_eq!(segment_ids(&fx, a, 0), vec![s[2]]);
assert_eq!(segment_ids(&fx, a, 1), vec![s[0], s[1], other[0]]);
// as new tracks of a file
let (mut fx, a, t) = build();
let s = segment_ids(&fx, a, 0);
do_move(
&mut fx,
segments(a, t[0], &[s[1]]),
MoveTarget::Tracks {
file_id: a,
index: 1,
},
)
.unwrap();
assert_eq!(fx.files[&a].trk.len(), 3);
assert_eq!(segment_ids(&fx, a, 1), vec![s[1]]);
assert_eq!(segment_ids(&fx, a, 0), vec![s[0], s[2]]);
assert_eq!(fx.files[&a].trk[0].id, t[0]);
assert_eq!(fx.files[&a].trk[2].id, t[1]);
// as new files
let (mut fx, a, t) = build();
let s = segment_ids(&fx, a, 0);
do_move(
&mut fx,
segments(a, t[0], &[s[0], s[1]]),
MoveTarget::Files { index: 0 },
)
.unwrap();
assert_eq!(fx.order.0.len(), 3);
assert_eq!(fx.order.0[2], a);
assert_eq!(fx.files[&fx.order.0[0]].info.name, "t0");
assert_eq!(segment_ids(&fx, a, 0), vec![s[2]]);
}
#[test]
fn test_move_waypoints() {
let build = || {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[], 4));
let b = add(&mut fx, file("b", &[], 2));
(fx, a, b)
};
let at = |file_id, index| MoveTarget::Waypoints { file_id, index };
// within a file
let (mut fx, a, _) = build();
let w = wpt_ids(&fx, a);
do_move(&mut fx, waypoints(a, &[w[3]]), at(a, 1)).unwrap();
assert_eq!(wpt_ids(&fx, a), vec![w[0], w[3], w[1], w[2]]);
assert_eq!(fx.selection, waypoints(a, &[w[3]]));
// to the start of another file
let (mut fx, a, b) = build();
let w = wpt_ids(&fx, a);
let before = wpt_ids(&fx, b);
do_move(&mut fx, waypoints(a, &[w[0], w[1]]), at(b, 0)).unwrap();
assert_eq!(wpt_ids(&fx, a), w[2..].to_vec());
assert_eq!(wpt_ids(&fx, b), [&w[..2], &before[..]].concat());
// all the waypoints of a file
let (mut fx, a, b) = build();
let w = wpt_ids(&fx, a);
let before = wpt_ids(&fx, b);
do_move(&mut fx, Selection::Waypoints { file_id: a }, at(b, 1)).unwrap();
assert!(wpt_ids(&fx, a).is_empty());
assert_eq!(
wpt_ids(&fx, b),
[&before[..1], &w[..], &before[1..]].concat()
);
}
#[test]
fn test_move_files() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[], 0));
let b = add(&mut fx, file("b", &[], 0));
let c = add(&mut fx, file("c", &[], 0));
do_move(&mut fx, files(&[c]), MoveTarget::Files { index: 0 }).unwrap();
assert_eq!(fx.order.0, vec![c, a, b]);
do_move(&mut fx, files(&[c, a]), MoveTarget::Files { index: 1 }).unwrap();
assert_eq!(fx.order.0, vec![b, c, a]);
assert_eq!(fx.selection, files(&[c, a]));
}
#[test]
fn test_what_cannot_be_moved() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[("t", 1)], 1));
let t = track_ids(&fx, a);
let s = segment_ids(&fx, a, 0);
let w = wpt_ids(&fx, a);
let before = fx.files[&a].clone();
let selection = fx.selection.clone();
let invalid = [
// the wrong kind of list
(
files(&[a]),
MoveTarget::Tracks {
file_id: a,
index: 0,
},
),
(
tracks(a, &t),
MoveTarget::Waypoints {
file_id: a,
index: 0,
},
),
(
tracks(a, &t),
MoveTarget::Segments {
file_id: a,
trk_id: t[0],
index: 0,
},
),
(
segments(a, t[0], &s),
MoveTarget::Waypoints {
file_id: a,
index: 0,
},
),
(
waypoints(a, &w),
MoveTarget::Tracks {
file_id: a,
index: 0,
},
),
(waypoints(a, &w), MoveTarget::Files { index: 0 }),
(
Selection::Waypoints { file_id: a },
MoveTarget::Files { index: 0 },
),
(Selection::Empty, MoveTarget::Files { index: 0 }),
// elements that do not exist
(
tracks(a, &[TrackId::default()]),
MoveTarget::Files { index: 0 },
),
(
waypoints(a, &[WaypointId::default()]),
MoveTarget::Waypoints {
file_id: a,
index: 0,
},
),
(files(&[FileId::default()]), MoveTarget::Files { index: 0 }),
// a place that does not exist
(
tracks(a, &t),
MoveTarget::Tracks {
file_id: FileId::default(),
index: 0,
},
),
(
segments(a, t[0], &s),
MoveTarget::Segments {
file_id: a,
trk_id: TrackId::default(),
index: 0,
},
),
(
waypoints(a, &w),
MoveTarget::Waypoints {
file_id: FileId::default(),
index: 0,
},
),
];
for (what, to) in invalid {
assert_eq!(
do_move(&mut fx, what.clone(), to),
Err(CommandError::NothingToDo),
"{what:?} to {to:?}"
);
// nothing changed
assert!(Rc::ptr_eq(&fx.files[&a], &before));
assert_eq!(fx.order.0, vec![a]);
assert_eq!(fx.selection, selection);
}
}
}
@@ -0,0 +1,790 @@
use std::{collections::HashSet, hash::Hash};
use crate::{
Apply, ClipboardContent, CommandError, Destination, FileId, Place, 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;
/// - 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.
///
/// Copies are made, with new ids, and the pasted elements are selected. What was cut is moved
/// instead, with its ids, the first time: what is still where it was is removed.
///
/// What is pasted is always the elements as they were when they were copied or cut, even if they
/// were changed or deleted since. The clipboard is kept so that it can be pasted again: after
/// what was cut was moved it is not cut anymore, and the next pastes make copies.
#[derive(Debug)]
pub struct Paste;
impl Apply for Paste {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let clipboard = state
.clipboard
.clone()
.filter(|clipboard| clipboard.can_paste(state.selection))
.ok_or(CommandError::NothingToDo)?;
let cut = clipboard.cut;
// always the elements as they were when they were copied or cut
let to = destination(state, &clipboard.content)?;
let next = transfer(state, &clipboard.content, cut, to)?;
if cut && let Some(clipboard) = state.clipboard.as_mut() {
// moved: the next pastes make copies
clipboard.cut = false;
}
*state.selection = next;
Ok(())
}
}
fn track_ids_of(state: &State, file_id: FileId) -> Vec<TrackId> {
state
.files
.get(&file_id)
.map(|file| file.trk.iter().map(|trk| trk.id).collect())
.unwrap_or_default()
}
fn segment_ids_of(state: &State, file_id: FileId, trk_id: TrackId) -> Vec<TrackSegmentId> {
state
.files
.get(&file_id)
.and_then(|file| file.trk.iter().find(|trk| trk.id == trk_id))
.map(|trk| trk.trkseg.iter().map(|seg| seg.id).collect())
.unwrap_or_default()
}
fn waypoint_ids_of(state: &State, file_id: FileId) -> Vec<WaypointId> {
state
.files
.get(&file_id)
.map(|file| waypoint_ids(file).collect())
.unwrap_or_default()
}
/// Where the content goes, depending on what is selected.
fn destination(state: &State, content: &ClipboardContent) -> Result<Destination, CommandError> {
let nothing = CommandError::NothingToDo;
let last_file = |file_ids: &HashSet<FileId>| {
last_selected(state.order.0.iter().copied(), file_ids).ok_or(CommandError::NothingToDo)
};
Ok(match (content, &*state.selection) {
(ClipboardContent::Files(_), _) => Destination::Files(Place::End),
(ClipboardContent::Tracks(_) | ClipboardContent::Segments(_), Selection::Empty) => {
Destination::Files(Place::End)
}
(
ClipboardContent::Tracks(_) | ClipboardContent::Segments(_),
Selection::File { file_ids },
) => Destination::Tracks(last_file(file_ids)?, Place::End),
(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))
}
(ClipboardContent::Segments(_), Selection::Track { file_id, trk_ids }) => {
let last =
last_selected(track_ids_of(state, *file_id).into_iter(), trk_ids).ok_or(nothing)?;
Destination::Segments(*file_id, last, Place::End)
}
(
ClipboardContent::Segments(_),
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
},
) => {
let last = last_selected(
segment_ids_of(state, *file_id, *trk_id).into_iter(),
trkseg_ids,
)
.ok_or(nothing)?;
Destination::Segments(*file_id, *trk_id, Place::After(last))
}
(ClipboardContent::Waypoints(_), Selection::File { file_ids }) => {
Destination::Waypoints(last_file(file_ids)?, Place::End)
}
(ClipboardContent::Waypoints(_), Selection::Waypoints { file_id }) => {
Destination::Waypoints(*file_id, Place::End)
}
(ClipboardContent::Waypoints(_), Selection::Waypoint { file_id, wpt_ids }) => {
let last = last_selected(waypoint_ids_of(state, *file_id).into_iter(), wpt_ids)
.ok_or(nothing)?;
Destination::Waypoints(*file_id, Place::After(last))
}
_ => return Err(nothing),
})
}
/// The last of the `selected` ids in the order of `all`: the element a paste goes after.
fn last_selected<T: Copy + Eq + Hash>(
all: impl Iterator<Item = T>,
selected: &HashSet<T>,
) -> Option<T> {
all.filter(|id| selected.contains(id)).last()
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{
Clipboard, ClipboardIds, File, FileId, Track, TrackId, TrackInfo, TrackSegment,
TrackSegmentId, Waypoint, WaypointId, engine::command::fixture::Fixture, new_file,
};
use super::*;
/// A file with the given tracks (name, number of segments) and number of waypoints.
fn file(name: &str, tracks: &[(Option<&str>, usize)], waypoints: usize) -> File {
let mut file = new_file(
name,
tracks
.iter()
.map(|(name, segments)| Track {
info: TrackInfo {
name: name.map(str::to_owned),
..Default::default()
},
trkseg: (0..*segments).map(|_| TrackSegment::default()).collect(),
..Default::default()
})
.collect(),
);
if waypoints > 0 {
file.wpt = vec![Rc::new(crate::WaypointChunk {
wpt: (0..waypoints).map(|_| Waypoint::default()).collect(),
..Default::default()
})];
}
file
}
fn add(fx: &mut Fixture, file: File) -> FileId {
let id = file.id;
fx.files.insert(id, Rc::new(file));
fx.order.0.push(id);
id
}
fn track_ids(fx: &Fixture, id: FileId) -> Vec<TrackId> {
fx.files[&id].trk.iter().map(|trk| trk.id).collect()
}
fn segment_ids(fx: &Fixture, id: FileId, trk: usize) -> Vec<TrackSegmentId> {
fx.files[&id].trk[trk].trkseg.iter().map(|s| s.id).collect()
}
fn wpt_ids(fx: &Fixture, id: FileId) -> Vec<WaypointId> {
waypoint_ids(&fx.files[&id]).collect()
}
fn names(fx: &Fixture) -> Vec<String> {
fx.order
.0
.iter()
.map(|id| fx.files[id].info.name.clone())
.collect()
}
fn copy(fx: &mut Fixture, selection: Selection, cut: bool) {
fx.clipboard = Clipboard::new(&selection, &fx.files, &fx.order.0, cut);
}
/// The ids of what is in the clipboard, and whether it is cut.
fn clipboard_state(fx: &Fixture) -> Option<(ClipboardIds, bool)> {
fx.clipboard.as_ref().map(|c| (c.content.ids(), c.cut))
}
fn paste(fx: &mut Fixture) -> Result<(), CommandError> {
Paste.apply(&mut fx.state())
}
fn tracks(file_id: FileId, ids: &[TrackId]) -> Selection {
Selection::Track {
file_id,
trk_ids: ids.iter().copied().collect(),
}
}
fn segments(file_id: FileId, trk_id: TrackId, ids: &[TrackSegmentId]) -> Selection {
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids: ids.iter().copied().collect(),
}
}
fn waypoints(file_id: FileId, ids: &[WaypointId]) -> Selection {
Selection::Waypoint {
file_id,
wpt_ids: ids.iter().copied().collect(),
}
}
fn files(ids: &[FileId]) -> Selection {
Selection::File {
file_ids: ids.iter().copied().collect(),
}
}
#[test]
fn test_paste_copied_tracks_into_a_file() {
let mut fx = Fixture::default();
let a = add(
&mut fx,
file("a", &[(Some("t0"), 1), (Some("t1"), 2), (None, 1)], 0),
);
let b = add(&mut fx, file("b", &[(Some("b0"), 1)], 0));
let source = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[source[0], source[1]]), false);
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
// the source is untouched, copies with new ids are added at the end
assert_eq!(track_ids(&fx, a), source);
let in_b = track_ids(&fx, b);
assert_eq!(in_b.len(), 3);
assert!(!source.contains(&in_b[1]) && !source.contains(&in_b[2]));
let names: Vec<_> = fx.files[&b]
.trk
.iter()
.map(|t| t.info.name.clone())
.collect();
assert_eq!(
names,
[Some("b0".into()), Some("t0".into()), Some("t1".into())]
);
// the segments are copies too
assert_eq!(fx.files[&b].trk[2].trkseg.len(), 2);
assert_ne!(segment_ids(&fx, b, 2), segment_ids(&fx, a, 1));
// the pasted tracks are selected, and the clipboard is still there
assert_eq!(fx.selection, tracks(b, &in_b[1..]));
assert_eq!(
clipboard_state(&fx),
Some((ClipboardIds::Tracks(vec![source[0], source[1]]), false))
);
}
#[test]
fn test_paste_cut_tracks_moves_them() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t0"), 1), (Some("t1"), 1)], 0));
let b = add(&mut fx, file("b", &[], 0));
let source = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[source[0]]), true);
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
assert_eq!(track_ids(&fx, a), vec![source[1]]);
assert_eq!(track_ids(&fx, b), vec![source[0]]);
assert_eq!(fx.selection, tracks(b, &[source[0]]));
}
#[test]
fn test_paste_tracks_after_the_selected_track() {
let mut fx = Fixture::default();
let a = add(
&mut fx,
file(
"a",
&[
(Some("0"), 1),
(Some("1"), 1),
(Some("2"), 1),
(Some("3"), 1),
],
0,
),
);
let t = track_ids(&fx, a);
// copy: the block goes after the selected track
copy(&mut fx, tracks(a, &[t[0], t[1]]), false);
fx.selection = tracks(a, &[t[2]]);
paste(&mut fx).unwrap();
let names: Vec<_> = fx.files[&a]
.trk
.iter()
.map(|t| t.info.name.clone().unwrap())
.collect();
assert_eq!(names, ["0", "1", "2", "0", "1", "3"]);
// cut: moved after the selected track
let mut fx = Fixture::default();
let a = add(
&mut fx,
file("a", &[(Some("0"), 1), (Some("1"), 1), (Some("2"), 1)], 0),
);
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0]]), true);
fx.selection = tracks(a, &[t[2]]);
paste(&mut fx).unwrap();
assert_eq!(track_ids(&fx, a), vec![t[1], t[2], t[0]]);
// the selected track is cut itself: the tracks go at the end
let mut fx = Fixture::default();
let a = add(
&mut fx,
file("a", &[(Some("0"), 1), (Some("1"), 1), (Some("2"), 1)], 0),
);
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0]]), true);
fx.selection = tracks(a, &[t[0]]);
paste(&mut fx).unwrap();
assert_eq!(track_ids(&fx, a), vec![t[1], t[2], t[0]]);
}
#[test]
fn test_paste_tracks_as_new_files() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("source", &[(Some("named"), 1), (None, 1)], 0));
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0], t[1]]), false);
fx.selection = Selection::Empty;
paste(&mut fx).unwrap();
// named like the track, or like the file it comes from
assert_eq!(names(&fx), ["source", "named", "source"]);
assert_eq!(fx.files.len(), 3);
let new = &fx.order.0[1..];
assert!(
matches!(&fx.selection, Selection::File { file_ids } if file_ids.len() == 2 && new.iter().all(|id| file_ids.contains(id)))
);
assert_eq!(fx.files[&new[0]].trk.len(), 1);
// the source keeps its tracks
assert_eq!(track_ids(&fx, a), t);
}
#[test]
fn test_paste_cut_tracks_as_new_files() {
let mut fx = Fixture::default();
let a = add(
&mut fx,
file("source", &[(Some("one"), 1), (Some("two"), 1)], 0),
);
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0]]), true);
fx.selection = Selection::Empty;
paste(&mut fx).unwrap();
assert_eq!(track_ids(&fx, a), vec![t[1]]);
let new = fx.order.0[1];
assert_eq!(track_ids(&fx, new), vec![t[0]]);
}
#[test]
fn test_paste_segments() {
let build = || {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t0"), 3), (Some("t1"), 1)], 0));
(fx, a)
};
// at the end of the selected track
let (mut fx, a) = build();
let s = segment_ids(&fx, a, 0);
let t = track_ids(&fx, a);
copy(&mut fx, segments(a, t[0], &[s[0]]), false);
fx.selection = tracks(a, &[t[1]]);
paste(&mut fx).unwrap();
assert_eq!(segment_ids(&fx, a, 0), s);
let in_t1 = segment_ids(&fx, a, 1);
assert_eq!(in_t1.len(), 2);
assert_ne!(in_t1[1], s[0]);
assert_eq!(fx.selection, segments(a, t[1], &in_t1[1..]));
// after the selected segment, moved
let (mut fx, a) = build();
let s = segment_ids(&fx, a, 0);
let t = track_ids(&fx, a);
copy(&mut fx, segments(a, t[0], &[s[0]]), true);
fx.selection = segments(a, t[0], &[s[1]]);
paste(&mut fx).unwrap();
assert_eq!(segment_ids(&fx, a, 0), vec![s[1], s[0], s[2]]);
// each segment becomes a track of the selected file
let (mut fx, a) = build();
let s = segment_ids(&fx, a, 0);
let t = track_ids(&fx, a);
copy(&mut fx, segments(a, t[0], &[s[0], s[1]]), false);
fx.selection = files(&[a]);
paste(&mut fx).unwrap();
let tracks_now = track_ids(&fx, a);
assert_eq!(tracks_now.len(), 4);
assert!(
fx.files[&a].trk[2..]
.iter()
.all(|trk| trk.trkseg.len() == 1)
);
assert_eq!(fx.selection, tracks(a, &tracks_now[2..]));
// each segment becomes a file, named like its track
let (mut fx, a) = build();
let s = segment_ids(&fx, a, 0);
let t = track_ids(&fx, a);
copy(&mut fx, segments(a, t[0], &[s[0], s[1]]), false);
fx.selection = Selection::Empty;
paste(&mut fx).unwrap();
assert_eq!(names(&fx), ["a", "t0", "t0"]);
assert!(
fx.order.0[1..]
.iter()
.all(|id| fx.files[id].trk[0].trkseg.len() == 1)
);
}
#[test]
fn test_paste_waypoints() {
let build = || {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[], 4));
let b = add(&mut fx, file("b", &[], 2));
(fx, a, b)
};
// to the end of another file
let (mut fx, a, b) = build();
let w = wpt_ids(&fx, a);
let before = wpt_ids(&fx, b);
copy(&mut fx, waypoints(a, &[w[1], w[2]]), false);
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
assert_eq!(wpt_ids(&fx, a), w);
let now = wpt_ids(&fx, b);
assert_eq!(now.len(), 4);
assert_eq!(&now[..2], &before[..]);
assert!(!w.contains(&now[2]));
assert_eq!(fx.selection, waypoints(b, &now[2..]));
// after the selected waypoint
let (mut fx, a, b) = build();
let w = wpt_ids(&fx, a);
let before = wpt_ids(&fx, b);
copy(&mut fx, waypoints(a, &[w[0]]), true);
fx.selection = waypoints(b, &[before[0]]);
paste(&mut fx).unwrap();
assert_eq!(wpt_ids(&fx, a), w[1..].to_vec());
assert_eq!(wpt_ids(&fx, b), vec![before[0], w[0], before[1]]);
// within the file, after a waypoint
let (mut fx, a, _) = build();
let w = wpt_ids(&fx, a);
copy(&mut fx, waypoints(a, &[w[0], w[1]]), true);
fx.selection = waypoints(a, &[w[2]]);
paste(&mut fx).unwrap();
assert_eq!(wpt_ids(&fx, a), vec![w[2], w[0], w[1], w[3]]);
}
#[test]
fn test_paste_all_the_waypoints_of_a_file() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[], 3));
let b = add(&mut fx, file("b", &[], 1));
let w = wpt_ids(&fx, a);
copy(&mut fx, Selection::Waypoints { file_id: a }, false);
fx.selection = Selection::Waypoints { file_id: b };
paste(&mut fx).unwrap();
assert_eq!(wpt_ids(&fx, a), w);
assert_eq!(wpt_ids(&fx, b).len(), 4);
// moved: the source has none left
copy(&mut fx, Selection::Waypoints { file_id: a }, true);
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
assert!(wpt_ids(&fx, a).is_empty());
assert_eq!(&wpt_ids(&fx, b)[4..], &w[..]);
}
#[test]
fn test_paste_files() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t"), 1)], 1));
let b = add(&mut fx, file("b", &[], 0));
let c = add(&mut fx, file("c", &[], 0));
copy(&mut fx, files(&[b, a]), false);
fx.selection = files(&[c]);
paste(&mut fx).unwrap();
// copies, in the order of the files, at the end
assert_eq!(names(&fx), ["a", "b", "c", "a", "b"]);
assert_eq!(fx.files.len(), 5);
assert_eq!(fx.files[&fx.order.0[3]].trk.len(), 1);
assert_ne!(track_ids(&fx, fx.order.0[3]), track_ids(&fx, a));
assert!(matches!(&fx.selection, Selection::File { file_ids } if file_ids.len() == 2));
// cut: moved to the end, they keep their ids
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[], 0));
let b = add(&mut fx, file("b", &[], 0));
let c = add(&mut fx, file("c", &[], 0));
copy(&mut fx, files(&[a]), true);
fx.selection = Selection::Empty;
paste(&mut fx).unwrap();
assert_eq!(fx.order.0, vec![b, c, a]);
assert_eq!(fx.selection, files(&[a]));
}
#[test]
fn test_nothing_to_paste() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t"), 1)], 1));
let t = track_ids(&fx, a);
let w = wpt_ids(&fx, a);
fx.selection = files(&[a]);
// an empty clipboard
assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo));
// a target that does not accept it (tracks cannot go to waypoints), the clipboard stays
copy(&mut fx, tracks(a, &t), false);
fx.selection = waypoints(a, &w);
assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo));
assert!(fx.clipboard.is_some());
assert_eq!(fx.selection, waypoints(a, &w));
// nothing to copy: what does not exist is not put in the clipboard
copy(&mut fx, tracks(a, &[TrackId::default()]), false);
fx.selection = files(&[a]);
assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo));
copy(&mut fx, waypoints(a, &[WaypointId::default()]), false);
assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo));
copy(&mut fx, files(&[FileId::default()]), false);
fx.selection = Selection::Empty;
assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo));
assert_eq!(fx.files.len(), 1);
assert_eq!(fx.order.0, vec![a]);
}
#[test]
fn test_paste_a_copy_several_times() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t0"), 1), (Some("t1"), 1)], 1));
let b = add(&mut fx, file("b", &[], 0));
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0]]), false);
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
paste(&mut fx).unwrap();
paste(&mut fx).unwrap();
// each paste made a copy with new ids, and the source is untouched
let in_b = track_ids(&fx, b);
assert_eq!(in_b.len(), 3);
assert_eq!(in_b.iter().collect::<HashSet<_>>().len(), 3);
assert!(in_b.iter().all(|id| !t.contains(id)));
assert_eq!(track_ids(&fx, a), t);
assert!(fx.clipboard.is_some());
// the selection is the last copy: the next paste goes after it
assert_eq!(fx.selection, tracks(b, &[in_b[2]]));
paste(&mut fx).unwrap();
assert_eq!(track_ids(&fx, b).len(), 4);
// the same with other elements
let w = wpt_ids(&fx, a);
copy(&mut fx, waypoints(a, &w), false);
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
paste(&mut fx).unwrap();
assert_eq!(wpt_ids(&fx, b).len(), 2);
assert_eq!(wpt_ids(&fx, a), w);
copy(&mut fx, files(&[b]), false);
fx.selection = Selection::Empty;
paste(&mut fx).unwrap();
paste(&mut fx).unwrap();
assert_eq!(fx.files.len(), 4);
}
#[test]
fn test_paste_a_cut_moves_the_first_time_then_copies() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t0"), 1), (Some("t1"), 1)], 0));
let b = add(&mut fx, file("b", &[], 0));
let c = add(&mut fx, file("c", &[], 0));
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0]]), true);
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
// moved with its id, and what is in the clipboard now is a copy of it
assert_eq!(track_ids(&fx, a), vec![t[1]]);
assert_eq!(track_ids(&fx, b), vec![t[0]]);
assert_eq!(
clipboard_state(&fx),
Some((ClipboardIds::Tracks(vec![t[0]]), false))
);
fx.selection = files(&[c]);
paste(&mut fx).unwrap();
paste(&mut fx).unwrap();
// copies: the moved track stays where it was moved to
assert_eq!(track_ids(&fx, b), vec![t[0]]);
let in_c = track_ids(&fx, c);
assert_eq!(in_c.len(), 2);
assert!(!in_c.contains(&t[0]));
assert_eq!(track_ids(&fx, a), vec![t[1]]);
}
#[test]
fn test_paste_cut_files_several_times() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[], 0));
let b = add(&mut fx, file("b", &[], 0));
copy(&mut fx, files(&[a]), true);
fx.selection = Selection::Empty;
paste(&mut fx).unwrap();
assert_eq!(fx.order.0, vec![b, a]);
// the next pastes copy it
paste(&mut fx).unwrap();
assert_eq!(fx.files.len(), 3);
assert_eq!(fx.order.0[..2], [b, a]);
}
/// Renames the first track of the file, as an edit would.
fn rename_track(fx: &mut Fixture, id: FileId, name: &str) {
let file = std::rc::Rc::make_mut(fx.files.get_mut(&id).unwrap());
file.trk[0].info.name = Some(name.to_owned());
}
#[test]
fn test_paste_the_elements_as_they_were_when_copied() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("original"), 1)], 0));
let b = add(&mut fx, file("b", &[], 0));
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0]]), false);
rename_track(&mut fx, a, "edited");
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
let pasted = &fx.files[&b].trk[0];
assert_eq!(pasted.info.name.as_deref(), Some("original"));
// the source keeps what was done to it
assert_eq!(fx.files[&a].trk[0].info.name.as_deref(), Some("edited"));
// and each time
paste(&mut fx).unwrap();
assert!(
fx.files[&b]
.trk
.iter()
.all(|trk| trk.info.name.as_deref() == Some("original"))
);
assert_eq!(fx.files[&b].trk.len(), 2);
}
#[test]
fn test_paste_a_cut_as_it_was_when_cut() {
let mut fx = Fixture::default();
let a = add(
&mut fx,
file("a", &[(Some("original"), 1), (Some("other"), 1)], 0),
);
let b = add(&mut fx, file("b", &[], 0));
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0]]), true);
rename_track(&mut fx, a, "edited");
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
// what is in the clipboard is moved, the edited version is not what is pasted
assert_eq!(track_ids(&fx, a), vec![t[1]]);
assert_eq!(track_ids(&fx, b), vec![t[0]]);
assert_eq!(fx.files[&b].trk[0].info.name.as_deref(), Some("original"));
}
#[test]
fn test_paste_after_the_elements_were_deleted() {
let delete_file = |fx: &mut Fixture, id: FileId| {
fx.files.remove(&id);
fx.order.0.retain(|other| *other != id);
};
// copied tracks, waypoints, segments and files are still there to be pasted
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t"), 2)], 2));
let b = add(&mut fx, file("b", &[], 0));
let t = track_ids(&fx, a);
let s = segment_ids(&fx, a, 0);
let w = wpt_ids(&fx, a);
copy(&mut fx, tracks(a, &t), false);
fx.selection = files(&[b]);
delete_file(&mut fx, a);
paste(&mut fx).unwrap();
assert_eq!(fx.files[&b].trk.len(), 1);
assert_eq!(fx.files[&b].trk[0].trkseg.len(), 2);
// the copy of a track comes from a file that is gone: named like the track
fx.selection = Selection::Empty;
paste(&mut fx).unwrap();
assert_eq!(names(&fx), ["b", "t"]);
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t"), 2)], 2));
let b = add(&mut fx, file("b", &[], 0));
let (t, s, w) = (track_ids(&fx, a), segment_ids(&fx, a, 0), wpt_ids(&fx, a));
copy(&mut fx, segments(a, t[0], &s), false);
fx.selection = files(&[b]);
delete_file(&mut fx, a);
paste(&mut fx).unwrap();
assert_eq!(fx.files[&b].trk.len(), 2);
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t"), 2)], 2));
let b = add(&mut fx, file("b", &[], 0));
let w = wpt_ids(&fx, a);
copy(&mut fx, waypoints(a, &w), false);
fx.selection = files(&[b]);
delete_file(&mut fx, a);
paste(&mut fx).unwrap();
assert_eq!(wpt_ids(&fx, b).len(), 2);
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t"), 1)], 0));
copy(&mut fx, files(&[a]), false);
fx.selection = Selection::Empty;
delete_file(&mut fx, a);
assert!(fx.files.is_empty());
paste(&mut fx).unwrap();
paste(&mut fx).unwrap();
assert_eq!(fx.files.len(), 2);
assert_eq!(names(&fx), ["a", "a"]);
let _ = (s, w);
}
#[test]
fn test_paste_a_cut_after_the_elements_were_deleted() {
let mut fx = Fixture::default();
let a = add(&mut fx, file("a", &[(Some("t0"), 1), (Some("t1"), 1)], 0));
let b = add(&mut fx, file("b", &[], 0));
let t = track_ids(&fx, a);
copy(&mut fx, tracks(a, &[t[0]]), true);
// deleted before it is pasted
std::rc::Rc::make_mut(fx.files.get_mut(&a).unwrap())
.trk
.retain(|trk| trk.id != t[0]);
fx.selection = files(&[b]);
paste(&mut fx).unwrap();
// nothing to remove, the track is where it was pasted, with its id
assert_eq!(track_ids(&fx, a), vec![t[1]]);
assert_eq!(track_ids(&fx, b), vec![t[0]]);
assert_eq!(
clipboard_state(&fx),
Some((ClipboardIds::Tracks(vec![t[0]]), false))
);
}
}
@@ -0,0 +1,362 @@
//! Copying or moving elements to a place of the file tree: what paste and move are made of.
//! The elements come as the content of a clipboard, and a destination says where they go among
//! the elements already there.
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, edit_waypoint_chunks, insert_waypoints_at,
};
/// Where elements are put, among the others of the same list.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Place<T> {
/// After all of them.
End,
/// Right after the one with this id, or at the end if it is not there (anymore).
After(T),
/// At this position (at the end if it is past it).
Index(usize),
}
impl<T: Copy + PartialEq> Place<T> {
/// The position in the list of `ids`.
pub fn position(&self, ids: &[T]) -> usize {
match self {
Place::End => ids.len(),
Place::After(id) => ids
.iter()
.position(|other| other == id)
.map_or(ids.len(), |i| i + 1),
Place::Index(index) => (*index).min(ids.len()),
}
}
}
/// Where elements go. What can go where:
/// - files: among the files;
/// - 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;
/// - 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;
/// - waypoints: among the waypoints of a file.
#[derive(Debug, Clone, Copy)]
pub enum Destination {
Files(Place<FileId>),
Tracks(FileId, Place<TrackId>),
Segments(FileId, TrackId, Place<TrackSegmentId>),
Waypoints(FileId, Place<WaypointId>),
}
pub fn waypoint_ids(file: &File) -> impl Iterator<Item = WaypointId> + '_ {
file.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.map(|wpt| wpt.id)
}
fn file_mut<'a>(state: &'a mut State, id: FileId) -> Result<&'a mut File, CommandError> {
state
.files
.get_mut(&id)
.map(Rc::make_mut)
.ok_or(CommandError::NothingToDo)
}
pub fn new_file(name: &str, trk: Vec<Track>) -> File {
let mut file = File {
trk,
..Default::default()
};
file.info.name = name.to_owned();
file
}
/// Adds the files to the state, at `place` in the order of the files.
fn add_files(state: &mut State, files: Vec<Rc<File>>, place: Place<FileId>) -> Selection {
let at = place.position(&state.order.0);
let ids: Vec<FileId> = files.iter().map(|file| file.id).collect();
for file in files {
state.files.insert(file.id, file);
}
state.order.0.splice(at..at, ids.iter().copied());
Selection::File {
file_ids: ids.into_iter().collect(),
}
}
/// Removes the elements of the content from the files, wherever they are.
pub fn remove_elements(state: &mut State, content: &ClipboardContent) {
match content {
ClipboardContent::Files(files) => {
let ids: HashSet<FileId> = files.iter().map(|file| file.id).collect();
state.files.retain(|id, _| !ids.contains(id));
state.order.0.retain(|id| !ids.contains(id));
}
ClipboardContent::Tracks(tracks) => {
let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.track.id).collect();
for file in state.files.values_mut() {
if file.trk.iter().any(|trk| ids.contains(&trk.id)) {
Rc::make_mut(file).trk.retain(|trk| !ids.contains(&trk.id));
}
}
}
ClipboardContent::Segments(segments) => {
let ids: HashSet<TrackSegmentId> = segments.iter().map(|seg| seg.segment.id).collect();
for file in state.files.values_mut() {
let contains = |trk: &Track| trk.trkseg.iter().any(|seg| ids.contains(&seg.id));
if file.trk.iter().any(contains) {
for trk in Rc::make_mut(file).trk.iter_mut() {
trk.trkseg.retain(|seg| !ids.contains(&seg.id));
}
}
}
}
ClipboardContent::Waypoints(waypoints) => {
let ids: HashSet<WaypointId> = waypoints.iter().map(|wpt| wpt.id).collect();
for file in state.files.values_mut() {
if waypoint_ids(file).any(|id| ids.contains(&id)) {
edit_waypoint_chunks(
Rc::make_mut(file),
|wpt| ids.contains(&wpt.id),
|wpts| {
wpts.retain(|wpt| !ids.contains(&wpt.id));
true
},
);
}
}
}
}
}
/// Puts the elements of the content at the destination: copies of them, or the elements
/// themselves, with their ids, when `cut` (they are removed from where they are first). Returns
/// the selection of what was put.
pub fn transfer(
state: &mut State,
content: &ClipboardContent,
cut: bool,
to: Destination,
) -> Result<Selection, CommandError> {
// 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::Segments(file_id, trk_id, _) => state
.files
.get(&file_id)
.is_some_and(|file| file.trk.iter().any(|trk| trk.id == trk_id)),
Destination::Waypoints(file_id, _) => state.files.contains_key(&file_id),
};
let valid = match (content, to) {
(ClipboardContent::Files(_), Destination::Files(_))
| (ClipboardContent::Tracks(_), Destination::Files(_) | Destination::Tracks(..))
| (
ClipboardContent::Segments(_),
Destination::Files(_) | Destination::Tracks(..) | Destination::Segments(..),
)
| (ClipboardContent::Waypoints(_), Destination::Waypoints(..)) => true,
_ => false,
};
let empty = match content {
ClipboardContent::Files(files) => files.is_empty(),
ClipboardContent::Tracks(tracks) => tracks.is_empty(),
ClipboardContent::Segments(segments) => segments.is_empty(),
ClipboardContent::Waypoints(waypoints) => waypoints.is_empty(),
};
if !exists || !valid || empty {
return Err(CommandError::NothingToDo);
}
if cut {
remove_elements(state, content);
}
let copy = !cut;
match (content, to) {
(ClipboardContent::Files(files), Destination::Files(place)) => {
let files = files
.iter()
.map(|file| {
if copy {
Rc::new(copy_file(file))
} else {
file.clone()
}
})
.collect();
Ok(add_files(state, files, place))
}
(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)) => {
transfer_waypoints(state, waypoints, copy, file_id, place)
}
_ => Err(CommandError::NothingToDo),
}
}
fn transfer_tracks(
state: &mut State,
tracks: &[ClipboardTrack],
copy: bool,
to: Destination,
) -> Result<Selection, CommandError> {
let tracks: Vec<(Track, &str)> = tracks
.iter()
.map(|clip| {
let track = if copy {
copy_track(&clip.track)
} else {
clip.track.clone()
};
(track, clip.file_name.as_str())
})
.collect();
match to {
Destination::Files(place) => {
let files = tracks
.into_iter()
.map(|(track, file_name)| {
let name = track
.info
.name
.clone()
.unwrap_or_else(|| file_name.to_owned());
Rc::new(new_file(&name, vec![track]))
})
.collect();
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,
})
}
_ => Err(CommandError::NothingToDo),
}
}
fn transfer_segments(
state: &mut State,
segments: &[ClipboardSegment],
copy: bool,
to: Destination,
) -> Result<Selection, CommandError> {
let segments: Vec<(TrackSegment, &str)> = segments
.iter()
.map(|clip| {
let segment = if copy {
copy_segment(&clip.segment)
} else {
clip.segment.clone()
};
(segment, clip.name.as_str())
})
.collect();
// a segment on its own needs a track
let own_track = |segment: TrackSegment| Track {
trkseg: vec![segment],
..Default::default()
};
match to {
Destination::Files(place) => {
let files = segments
.into_iter()
.map(|(segment, name)| Rc::new(new_file(name, vec![own_track(segment)])))
.collect();
Ok(add_files(state, files, place))
}
Destination::Tracks(file_id, place) => {
let tracks: Vec<Track> = 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,
})
}
Destination::Segments(file_id, trk_id, place) => {
let segments: Vec<TrackSegment> =
segments.into_iter().map(|(segment, _)| segment).collect();
let ids: HashSet<TrackSegmentId> = segments.iter().map(|seg| seg.id).collect();
let trk = file_mut(state, file_id)?
.trk
.iter_mut()
.find(|trk| trk.id == trk_id)
.ok_or(CommandError::NothingToDo)?;
let existing: Vec<TrackSegmentId> = trk.trkseg.iter().map(|seg| seg.id).collect();
let at = place.position(&existing);
trk.trkseg.splice(at..at, segments);
Ok(Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids: ids,
})
}
Destination::Waypoints(..) => Err(CommandError::NothingToDo),
}
}
fn transfer_waypoints(
state: &mut State,
waypoints: &[Waypoint],
copy: bool,
file_id: FileId,
place: Place<WaypointId>,
) -> Result<Selection, CommandError> {
let waypoints: Vec<Waypoint> = waypoints
.iter()
.map(|wpt| {
if copy {
copy_waypoint(wpt)
} else {
wpt.clone()
}
})
.collect();
let ids = waypoints.iter().map(|wpt| wpt.id).collect();
let file = file_mut(state, file_id)?;
let existing: Vec<WaypointId> = waypoint_ids(file).collect();
insert_waypoints_at(file, place.position(&existing), waypoints);
Ok(Selection::Waypoint {
file_id,
wpt_ids: ids,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_place_position() {
let ids = [1, 2, 3];
assert_eq!(Place::End.position(&ids), 3);
assert_eq!(Place::After(1).position(&ids), 1);
assert_eq!(Place::After(3).position(&ids), 3);
// not there: at the end
assert_eq!(Place::After(9).position(&ids), 3);
assert_eq!(Place::Index(0).position(&ids), 0);
assert_eq!(Place::Index(2).position(&ids), 2);
assert_eq!(Place::Index(10).position(&ids), 3);
assert_eq!(Place::<i32>::End.position(&[]), 0);
assert_eq!(Place::<i32>::Index(4).position(&[]), 0);
}
}
+5 -1
View File
@@ -2,21 +2,25 @@
use std::collections::HashSet; use std::collections::HashSet;
use crate::{FileId, FileOrder, Selection, StackEntry, State}; use crate::{Clipboard, FileId, FileOrder, Selection, StackEntry, State, TrackpointCategories};
#[derive(Default)] #[derive(Default)]
pub struct Fixture { pub struct Fixture {
pub files: StackEntry, pub files: StackEntry,
pub selection: Selection, pub selection: Selection,
pub order: FileOrder, pub order: FileOrder,
pub clipboard: Option<Clipboard>,
pub categories: TrackpointCategories,
} }
impl Fixture { impl Fixture {
pub fn state(&mut self) -> State<'_> { pub fn state(&mut self) -> State<'_> {
State { State {
categories: &mut self.categories,
files: &mut self.files, files: &mut self.files,
selection: &mut self.selection, selection: &mut self.selection,
order: &mut self.order, order: &mut self.order,
clipboard: &mut self.clipboard,
} }
} }
@@ -0,0 +1,35 @@
use crate::{File, Track, TrackSegment, Waypoint};
// The trackpoints of a segment are shared chunks, so copying elements is cheap.
/// A copy of the segment, with a new id.
pub fn copy_segment(segment: &TrackSegment) -> TrackSegment {
let mut copy = segment.clone();
copy.id = Default::default();
copy
}
/// A copy of the track and of its segments, with new ids.
pub fn copy_track(track: &Track) -> Track {
Track {
id: Default::default(),
trkseg: track.trkseg.iter().map(copy_segment).collect(),
..track.clone()
}
}
/// A copy of the file and of its tracks and segments, with new ids.
pub fn copy_file(file: &File) -> File {
File {
id: Default::default(),
trk: file.trk.iter().map(copy_track).collect(),
..file.clone()
}
}
/// A copy of the waypoint, with a new id.
pub fn copy_waypoint(waypoint: &Waypoint) -> Waypoint {
let mut copy = waypoint.clone();
copy.id = Default::default();
copy
}
@@ -0,0 +1,218 @@
use std::rc::Rc;
use crate::{File, Waypoint, WaypointChunk, WaypointId};
/// Inserts waypoints in the file, right after the waypoint `after`, or at the end if there is
/// none (or if it is not in the file).
///
/// See [`insert_waypoints_at`].
pub fn insert_waypoints(file: &mut File, after: Option<WaypointId>, waypoints: Vec<Waypoint>) {
let index = after
.and_then(|after| {
file.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.position(|wpt| wpt.id == after)
})
.map_or(usize::MAX, |i| i + 1);
insert_waypoints_at(file, index, waypoints);
}
/// Inserts waypoints in the file, so that the first one is at `index` among the waypoints of
/// the file (at the end if the index is past it).
///
/// Only the chunk holding the waypoint that was at `index` is cut (when the waypoints are not
/// inserted between two chunks), all the other chunks are kept as they are.
pub fn insert_waypoints_at(file: &mut File, index: usize, waypoints: Vec<Waypoint>) {
if waypoints.is_empty() {
return;
}
let mut inserted = Vec::new();
let mut chunk = WaypointChunk::default();
for wpt in waypoints {
chunk.wpt.push(wpt);
if chunk.is_full() {
inserted.push(Rc::new(std::mem::take(&mut chunk)));
}
}
if !chunk.wpt.is_empty() {
inserted.push(Rc::new(chunk));
}
// the chunk holding the waypoint at `index`, and the position of that waypoint in it
let mut start = 0;
let position = file.wpt.iter().enumerate().find_map(|(i, chunk)| {
let offset = index
.checked_sub(start)
.filter(|offset| *offset < chunk.wpt.len());
start += chunk.wpt.len();
offset.map(|offset| (i, offset))
});
match position {
None => file.wpt.extend(inserted),
Some((i, 0)) => {
file.wpt.splice(i..i, inserted);
}
Some((i, offset)) => {
let cut = &file.wpt[i];
let part = |wpt: &[Waypoint]| {
Rc::new(WaypointChunk {
wpt: wpt.to_vec(),
..Default::default()
})
};
let mut chunks = vec![part(&cut.wpt[..offset])];
chunks.extend(inserted);
chunks.push(part(&cut.wpt[offset..]));
file.wpt.splice(i..=i, chunks);
}
}
file.wpt_rev_id = Default::default();
}
#[cfg(test)]
mod tests {
use super::*;
fn waypoint() -> Waypoint {
Waypoint::default()
}
fn file_with(chunks: &[usize]) -> (File, Vec<WaypointId>) {
let mut file = File::default();
let mut ids = vec![];
for n in chunks {
let wpt: Vec<_> = (0..*n).map(|_| waypoint()).collect();
ids.extend(wpt.iter().map(|w| w.id));
file.wpt.push(Rc::new(WaypointChunk {
wpt,
..Default::default()
}));
}
(file, ids)
}
fn ids(file: &File) -> Vec<WaypointId> {
file.wpt
.iter()
.flat_map(|chunk| chunk.wpt.iter().map(|w| w.id))
.collect()
}
#[test]
fn test_insert_at_the_end() {
let (mut file, before) = file_with(&[2, 1]);
let rev = file.wpt_rev_id;
let new = vec![waypoint(), waypoint()];
let new_ids: Vec<_> = new.iter().map(|w| w.id).collect();
insert_waypoints(&mut file, None, new);
assert_eq!(ids(&file), [before.clone(), new_ids].concat());
// the chunks that were there are kept
assert_eq!(file.wpt.len(), 3);
assert_ne!(file.wpt_rev_id, rev);
// an unknown waypoint is an insertion at the end too
let (mut file, before) = file_with(&[2]);
let one = waypoint();
let one_id = one.id;
insert_waypoints(&mut file, Some(WaypointId::default()), vec![one]);
assert_eq!(ids(&file), [before, vec![one_id]].concat());
}
#[test]
fn test_insert_after_the_last_waypoint_of_a_chunk_keeps_the_chunks() {
let (mut file, before) = file_with(&[2, 2]);
let first = file.wpt[0].clone();
let one = waypoint();
let one_id = one.id;
insert_waypoints(&mut file, Some(before[1]), vec![one]);
assert_eq!(ids(&file), [&before[..2], &[one_id], &before[2..]].concat());
// 2 chunks + the inserted one, nothing was cut
assert_eq!(file.wpt.len(), 3);
assert!(Rc::ptr_eq(&file.wpt[0], &first));
}
#[test]
fn test_insert_in_the_middle_of_a_chunk_cuts_it() {
let (mut file, before) = file_with(&[3, 1]);
let last = file.wpt[1].clone();
let new = vec![waypoint(), waypoint()];
let new_ids: Vec<_> = new.iter().map(|w| w.id).collect();
insert_waypoints(&mut file, Some(before[0]), new);
assert_eq!(
ids(&file),
[&before[..1], &new_ids[..], &before[1..]].concat()
);
// the cut chunk, the inserted one and the end of the cut one, then the untouched chunk
assert_eq!(file.wpt.len(), 4);
assert!(Rc::ptr_eq(&file.wpt[3], &last));
}
#[test]
fn test_insert_nothing() {
let (mut file, before) = file_with(&[2]);
let rev = file.wpt_rev_id;
insert_waypoints(&mut file, Some(before[0]), vec![]);
assert_eq!(ids(&file), before);
assert_eq!(file.wpt_rev_id, rev);
}
#[test]
fn test_insert_many_fills_chunks() {
let mut file = File::default();
let new: Vec<_> = (0..300).map(|_| waypoint()).collect();
let new_ids: Vec<_> = new.iter().map(|w| w.id).collect();
insert_waypoints(&mut file, None, new);
assert_eq!(ids(&file), new_ids);
assert_eq!(file.wpt.len(), 3);
assert!(file.wpt[..2].iter().all(|chunk| chunk.is_full()));
}
#[test]
fn test_insert_at_an_index() {
// at the start
let (mut file, before) = file_with(&[2, 1]);
let first = file.wpt[0].clone();
let one = waypoint();
let one_id = one.id;
insert_waypoints_at(&mut file, 0, vec![one]);
assert_eq!(ids(&file), [vec![one_id], before.clone()].concat());
// the existing chunks are kept
assert_eq!(file.wpt.len(), 3);
assert!(Rc::ptr_eq(&file.wpt[1], &first));
// between two chunks
let (mut file, before) = file_with(&[2, 1]);
let (first, last) = (file.wpt[0].clone(), file.wpt[1].clone());
let one = waypoint();
let one_id = one.id;
insert_waypoints_at(&mut file, 2, vec![one]);
assert_eq!(ids(&file), [&before[..2], &[one_id], &before[2..]].concat());
assert_eq!(file.wpt.len(), 3);
assert!(Rc::ptr_eq(&file.wpt[0], &first) && Rc::ptr_eq(&file.wpt[2], &last));
// inside a chunk
let (mut file, before) = file_with(&[3]);
let one = waypoint();
let one_id = one.id;
insert_waypoints_at(&mut file, 1, vec![one]);
assert_eq!(ids(&file), [&before[..1], &[one_id], &before[1..]].concat());
assert_eq!(file.wpt.len(), 3);
// at the end, or past it
for index in [3, 100] {
let (mut file, before) = file_with(&[3]);
let one = waypoint();
let one_id = one.id;
insert_waypoints_at(&mut file, index, vec![one]);
assert_eq!(ids(&file), [before, vec![one_id]].concat());
}
// in a file without waypoints
let mut file = File::default();
let one = waypoint();
let one_id = one.id;
insert_waypoints_at(&mut file, 0, vec![one]);
assert_eq!(ids(&file), vec![one_id]);
}
}
@@ -1,7 +1,13 @@
mod copy;
mod edit_waypoint_chunks; mod edit_waypoint_chunks;
mod insert_waypoints;
mod produce; mod produce;
mod update_selected; mod update_selected;
mod update_waypoint;
pub use copy::*;
pub use edit_waypoint_chunks::*; pub use edit_waypoint_chunks::*;
pub use insert_waypoints::*;
pub use produce::*; pub use produce::*;
pub use update_selected::*; pub use update_selected::*;
pub use update_waypoint::*;
@@ -179,7 +179,7 @@ mod tests {
fn fixture_with_tracks() -> (Fixture, FileId) { fn fixture_with_tracks() -> (Fixture, FileId) {
let mut fx = Fixture::default(); let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap(); let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
let file = Rc::new(crate::parse(&data).unwrap()); let file = Rc::new(crate::parse(&data, &mut Default::default()).unwrap());
let id = file.id; let id = file.id;
fx.files.insert(id, file); fx.files.insert(id, file);
(fx, id) (fx, id)
@@ -0,0 +1,30 @@
use std::rc::Rc;
use crate::{CommandError, FileId, State, Waypoint, WaypointId, edit_waypoint_chunks};
/// Changes one waypoint of a file with `f`, whatever is selected. Nothing to do if the file or
/// the waypoint does not exist.
pub fn update_waypoint(
state: &mut State,
file_id: FileId,
waypoint_id: WaypointId,
f: impl Fn(&mut Waypoint),
) -> Result<(), CommandError> {
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let mut file = (**file).clone();
let changed = edit_waypoint_chunks(
&mut file,
|wpt| wpt.id == waypoint_id,
|wpts| {
wpts.iter_mut()
.filter(|wpt| wpt.id == waypoint_id)
.for_each(&f);
true
},
);
if !changed {
return Err(CommandError::NothingToDo);
}
state.files.insert(file_id, Rc::new(file));
Ok(())
}
+389 -4
View File
@@ -1,4 +1,10 @@
use crate::{Apply, CommandError, LngLatBounds, State}; use std::{collections::HashSet, rc::Rc};
use crate::{
Apply, CommandError, Edit, Editor, File, FileId, LngLat, LngLatBounds, Selection, State,
TrackSegment, TrackSegmentId, Trackpoint, Waypoint, delete_waypoints, edit_waypoint_chunks,
update_selected,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CleanType { pub enum CleanType {
@@ -6,6 +12,9 @@ pub enum CleanType {
Outside, Outside,
} }
/// 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.
#[derive(Debug)] #[derive(Debug)]
pub struct Clean { pub struct Clean {
pub bounds: LngLatBounds, pub bounds: LngLatBounds,
@@ -14,9 +23,385 @@ pub struct Clean {
pub wpt: bool, pub wpt: bool,
} }
impl Clean {
fn contains(&self, c: LngLat) -> bool {
(self.bounds.sw.lng..=self.bounds.ne.lng).contains(&c.lng)
&& (self.bounds.sw.lat..=self.bounds.ne.lat).contains(&c.lat)
}
fn removes(&self, c: LngLat) -> bool {
self.contains(c) == (self.type_ == CleanType::Inside)
}
}
impl Apply for Clean { impl Apply for Clean {
fn apply(self, _state: &mut State) -> Result<(), CommandError> { fn apply(self, state: &mut State) -> Result<(), CommandError> {
// TODO match &*state.selection {
Err(CommandError::NotImplemented("clean")) Selection::Waypoint { file_id, .. } | Selection::Waypoints { file_id } => {
let file_id = *file_id;
if !self.wpt {
return Err(CommandError::NothingToDo);
}
let wpt_ids = match &*state.selection {
Selection::Waypoint { wpt_ids, .. } => Some(wpt_ids.clone()),
_ => None,
};
let selected =
|wpt: &Waypoint| wpt_ids.as_ref().is_none_or(|ids| ids.contains(&wpt.id));
*state.selection = delete_waypoints(state.files, file_id, |wpt| {
selected(wpt) && self.removes(wpt.coordinates)
})?;
Ok(())
}
Selection::Empty => Err(CommandError::NothingToDo),
_ => {
let mut cleaner = Cleaner {
clean: &self,
changed: false,
emptied: HashSet::new(),
wpt_files: HashSet::new(),
};
update_selected(state, &mut cleaner);
if cleaner.changed {
prune(state, &cleaner.emptied, &cleaner.wpt_files);
Ok(())
} else {
Err(CommandError::NothingToDo)
}
}
}
}
}
struct Cleaner<'a> {
clean: &'a Clean,
changed: bool,
/// Segments left without trackpoints by the clean.
emptied: HashSet<TrackSegmentId>,
/// Files that lost waypoints.
wpt_files: HashSet<FileId>,
}
impl Editor for Cleaner<'_> {
fn file(&mut self, file: &mut File) -> Edit {
let mut edit = Edit::Unchanged;
for trk in &mut file.trk {
if self.track(trk) == Edit::Changed {
edit = Edit::Changed;
}
}
if self.clean.wpt
&& edit_waypoint_chunks(
file,
|wpt| self.clean.removes(wpt.coordinates),
|wpts| {
wpts.retain(|wpt| !self.clean.removes(wpt.coordinates));
true
},
)
{
edit = Edit::Changed;
self.wpt_files.insert(file.id);
}
self.changed |= edit == Edit::Changed;
edit
}
fn segment(&mut self, segment: &mut TrackSegment) -> Edit {
if !self.clean.trkpt || !segment.iter().any(|pt| self.clean.removes(pt.coordinates)) {
return Edit::Unchanged;
}
let kept: Vec<Trackpoint> = segment
.iter()
.filter(|pt| !self.clean.removes(pt.coordinates))
.cloned()
.collect();
if kept.is_empty() {
self.emptied.insert(segment.id);
}
segment.splice(0, segment.len(), kept);
self.changed = true;
Edit::Changed
}
}
/// Removes what the clean left empty: the emptied segments, the tracks that lost all their
/// segments and the files that lost something and have nothing left. 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)))
})
.map(|(id, _)| *id)
.collect();
for id in ids {
let mut file = (*state.files[&id]).clone();
let lost_something = !emptied.is_empty() || wpt_files.contains(&id);
file.trk.retain_mut(|trk| {
let len = trk.trkseg.len();
trk.trkseg.retain(|seg| !emptied.contains(&seg.id));
trk.trkseg.len() == len || !trk.trkseg.is_empty()
});
if lost_something && file.trk.is_empty() && file.wpt.is_empty() {
state.files.remove(&id);
state.order.0.retain(|f| *f != id);
} else {
state.files.insert(id, Rc::new(file));
}
}
*state.selection = fixed_selection(state);
}
fn fixed_selection(state: &State) -> Selection {
let files = &*state.files;
match &*state.selection {
Selection::File { file_ids } => {
let file_ids: HashSet<_> = file_ids
.iter()
.filter(|id| files.contains_key(id))
.copied()
.collect();
if file_ids.is_empty() {
Selection::Empty
} else {
Selection::File { file_ids }
}
}
Selection::Track { file_id, trk_ids } => {
let Some(file) = files.get(file_id) else {
return Selection::Empty;
};
let trk_ids: HashSet<_> = file
.trk
.iter()
.map(|trk| trk.id)
.filter(|id| trk_ids.contains(id))
.collect();
if trk_ids.is_empty() {
Selection::File {
file_ids: [*file_id].into(),
}
} else {
Selection::Track {
file_id: *file_id,
trk_ids,
}
}
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let Some(file) = files.get(file_id) else {
return Selection::Empty;
};
let Some(trk) = file.trk.iter().find(|trk| trk.id == *trk_id) else {
return Selection::File {
file_ids: [*file_id].into(),
};
};
let trkseg_ids: HashSet<_> = trk
.trkseg
.iter()
.map(|seg| seg.id)
.filter(|id| trkseg_ids.contains(id))
.collect();
if trkseg_ids.is_empty() {
Selection::Track {
file_id: *file_id,
trk_ids: [*trk_id].into(),
}
} else {
Selection::TrackSegment {
file_id: *file_id,
trk_id: *trk_id,
trkseg_ids,
}
}
}
Selection::Waypoints { file_id } | Selection::Waypoint { file_id, .. } => {
if files.contains_key(file_id) {
Selection::File {
file_ids: [*file_id].into(),
}
} else {
Selection::Empty
}
}
Selection::Empty => Selection::Empty,
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{FileId, Load, engine::command::fixture::Fixture};
use super::*;
fn loaded() -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
(fx, id)
}
fn bounds(w: f64, s: f64, e: f64, n: f64) -> LngLatBounds {
LngLatBounds {
sw: LngLat { lng: w, lat: s },
ne: LngLat { lng: e, lat: n },
}
}
fn clean(bounds: LngLatBounds, type_: CleanType) -> Clean {
Clean {
bounds,
type_,
trkpt: true,
wpt: true,
}
}
fn count(fx: &Fixture, id: FileId) -> usize {
fx.files
.get(&id)
.into_iter()
.flat_map(|file| &file.trk)
.flat_map(|trk| &trk.trkseg)
.map(|seg| seg.len())
.sum()
}
#[test]
fn test_inside_everything_removes_all_trackpoints() {
let (mut fx, id) = loaded();
assert!(count(&fx, id) > 0);
clean(bounds(-180.0, -90.0, 180.0, 90.0), CleanType::Inside)
.apply(&mut fx.state())
.unwrap();
assert_eq!(count(&fx, id), 0);
// nothing is left of the file
assert!(!fx.files.contains_key(&id));
assert!(fx.order.0.is_empty());
assert_eq!(fx.selection, Selection::Empty);
}
#[test]
fn test_emptied_segments_and_tracks_are_removed() {
let (mut fx, id) = loaded();
let before = fx.files[&id].clone();
let seg = &before.trk[0].trkseg[0];
let pts: Vec<_> = seg.iter().map(|p| p.coordinates).collect();
let mut b = LngLatBounds::default();
pts.iter().for_each(|c| b.extend(*c));
clean(b, CleanType::Inside).apply(&mut fx.state()).unwrap();
let after = &fx.files[&id];
assert!(after.trk.iter().all(|t| !t.trkseg.is_empty()));
assert!(
after
.trk
.iter()
.flat_map(|t| &t.trkseg)
.all(|s| !s.is_empty())
);
}
#[test]
fn test_outside_everything_changes_nothing() {
let (mut fx, id) = loaded();
let before = fx.files[&id].clone();
let result =
clean(bounds(-180.0, -90.0, 180.0, 90.0), CleanType::Outside).apply(&mut fx.state());
assert!(matches!(result, Err(CommandError::NothingToDo)));
assert_eq!(fx.files[&id], before);
}
#[test]
fn test_partial_clean_only_removes_points_inside_and_bumps_revision() {
let (mut fx, id) = loaded();
let before = fx.files[&id].clone();
let first = before.trk[0].trkseg[0].iter().next().unwrap().coordinates;
let total = count(&fx, id);
// tiny rectangle around the first point
let b = bounds(
first.lng - 1e-9,
first.lat - 1e-9,
first.lng + 1e-9,
first.lat + 1e-9,
);
clean(b, CleanType::Inside).apply(&mut fx.state()).unwrap();
let removed = total - count(&fx, id);
assert!(removed >= 1);
let seg = &fx.files[&id].trk[0].trkseg[0];
assert_ne!(seg.rev_id, before.trk[0].trkseg[0].rev_id);
assert!(seg.iter().all(|pt| !b_contains(&b, pt.coordinates)));
}
fn b_contains(b: &LngLatBounds, c: LngLat) -> bool {
c.lng >= b.sw.lng && c.lng <= b.ne.lng && c.lat >= b.sw.lat && c.lat <= b.ne.lat
}
#[test]
fn test_waypoints_flag_and_selection() {
let (mut fx, id) = loaded();
let mut file = (*fx.files[&id]).clone();
file.wpt = vec![std::rc::Rc::new(crate::WaypointChunk {
wpt: vec![Waypoint::default(), Waypoint::default()],
..Default::default()
})];
let ids: Vec<_> = file.wpt[0].wpt.iter().map(|w| w.id).collect();
fx.files.insert(id, std::rc::Rc::new(file));
let everywhere = bounds(-180.0, -90.0, 180.0, 90.0);
let wpt_count = |fx: &Fixture| {
fx.files
.get(&id)
.map_or(0, |f| f.wpt.iter().map(|c| c.wpt.len()).sum::<usize>())
};
// trackpoints only: waypoints are kept
Clean {
wpt: false,
..clean(everywhere, CleanType::Inside)
}
.apply(&mut fx.state())
.unwrap();
assert_eq!(wpt_count(&fx), 2);
// one selected waypoint
fx.selection = Selection::Waypoint {
file_id: id,
wpt_ids: HashSet::from([ids[0]]),
};
clean(everywhere, CleanType::Inside)
.apply(&mut fx.state())
.unwrap();
assert_eq!(wpt_count(&fx), 1);
// whole file
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
clean(everywhere, CleanType::Inside)
.apply(&mut fx.state())
.unwrap();
assert_eq!(wpt_count(&fx), 0);
} }
} }
@@ -0,0 +1,106 @@
use crate::{Apply, CommandError, FileId, State, WaypointId, set_waypoint_fields, update_waypoint};
/// Changes a waypoint of a file, whatever is selected (what the form of the waypoint tool does):
/// its name, description, icon, link, position and elevation. The strings that are empty remove
/// the field.
#[derive(Debug)]
pub struct EditWaypoint<'a> {
pub file_id: FileId,
pub waypoint_id: WaypointId,
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(self, state: &mut State) -> Result<(), CommandError> {
update_waypoint(state, self.file_id, self.waypoint_id, |wpt| {
set_waypoint_fields(
wpt,
(self.lng, self.lat, self.ele),
self.name,
self.desc,
self.icon,
self.link,
)
})
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{
File, Link, Waypoint, WaypointChunk, engine::command::fixture::Fixture, waypoint_ids,
};
use super::*;
#[test]
fn test_edit_the_given_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));
EditWaypoint {
file_id: id,
waypoint_id: ids[0],
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"));
}
}
@@ -1,5 +1,6 @@
mod clean; mod clean;
mod crop; mod crop;
mod edit_waypoint;
mod elevation; mod elevation;
mod extract; mod extract;
mod merge; mod merge;
@@ -12,6 +13,7 @@ mod time;
pub use clean::*; pub use clean::*;
pub use crop::*; pub use crop::*;
pub use edit_waypoint::*;
pub use elevation::*; pub use elevation::*;
pub use extract::*; pub use extract::*;
pub use merge::*; pub use merge::*;
@@ -1,15 +1,111 @@
use crate::{Apply, CommandError, State}; use crate::{Apply, CommandError, FileId, LngLat, State, WaypointId, update_waypoint};
/// Moves a waypoint of a file, whatever is selected (what a drag does): only its position and
/// elevation change.
#[derive(Debug)] #[derive(Debug)]
pub struct MoveWaypoint { pub struct MoveWaypoint {
pub file_id: FileId,
pub waypoint_id: WaypointId,
pub lng: f64, pub lng: f64,
pub lat: f64, pub lat: f64,
pub ele: f64, pub ele: f64,
} }
impl Apply for MoveWaypoint { impl Apply for MoveWaypoint {
fn apply(self, _state: &mut State) -> Result<(), CommandError> { fn apply(self, state: &mut State) -> Result<(), CommandError> {
// TODO update_waypoint(state, self.file_id, self.waypoint_id, |wpt| {
Err(CommandError::NotImplemented("move waypoint")) wpt.coordinates = LngLat {
lng: self.lng,
lat: self.lat,
};
wpt.ele = self.ele;
})
}
}
#[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<crate::WaypointId>) {
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));
} }
} }
@@ -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)] #[derive(Debug)]
pub struct NewWaypoint<'a> { pub struct NewWaypoint<'a> {
pub lng: f64, pub lng: f64,
@@ -11,9 +18,222 @@ pub struct NewWaypoint<'a> {
pub link: &'a str, pub link: &'a str,
} }
fn non_empty(text: &str) -> Option<String> {
(!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<'_> { impl Apply for NewWaypoint<'_> {
fn apply(self, _state: &mut State) -> Result<(), CommandError> { fn apply(self, state: &mut State) -> Result<(), CommandError> {
// TODO let file_ids: Vec<FileId> = match &*state.selection {
Err(CommandError::NotImplemented("new waypoint")) 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<FileId> = 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<Waypoint> {
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);
} }
} }
@@ -106,7 +106,12 @@ mod tests {
fn loaded() -> (Fixture, FileId) { fn loaded() -> (Fixture, FileId) {
let mut fx = Fixture::default(); let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap(); let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load { data: &data }.apply(&mut fx.state()).unwrap(); Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0]; let id = fx.order.0[0];
let trk = &fx.files[&id].trk[0]; let trk = &fx.files[&id].trk[0];
fx.selection = Selection::TrackSegment { fx.selection = Selection::TrackSegment {
@@ -282,7 +287,12 @@ mod tests {
fn test_splice_on_multiple_files_uses_last_in_file_order() { fn test_splice_on_multiple_files_uses_last_in_file_order() {
let (mut fx, first) = loaded(); let (mut fx, first) = loaded();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap(); let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load { data: &data }.apply(&mut fx.state()).unwrap(); Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let second = fx.order.0[1]; let second = fx.order.0[1];
fx.selection = Selection::File { fx.selection = Selection::File {
file_ids: HashSet::from([second, first]), file_ids: HashSet::from([second, first]),
@@ -73,7 +73,12 @@ mod tests {
fn loaded() -> (Fixture, FileId) { fn loaded() -> (Fixture, FileId) {
let mut fx = Fixture::default(); let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap(); let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load { data: &data }.apply(&mut fx.state()).unwrap(); Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0]; let id = fx.order.0[0];
(fx, id) (fx, id)
} }
@@ -12,6 +12,7 @@ use crate::{
pub struct FileStructure { pub struct FileStructure {
pub id: FileId, pub id: FileId,
pub name: String, pub name: String,
pub desc: Option<String>,
pub tracks: Vec<TrackNode>, pub tracks: Vec<TrackNode>,
pub waypoints: Vec<WaypointNode>, pub waypoints: Vec<WaypointNode>,
/// Changes whenever the waypoints of the file (hence their buffer) change. /// Changes whenever the waypoints of the file (hence their buffer) change.
@@ -22,6 +23,11 @@ pub struct FileStructure {
pub struct TrackNode { pub struct TrackNode {
pub id: TrackId, pub id: TrackId,
pub name: Option<String>, 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>,
pub segments: Vec<SegmentNode>, pub segments: Vec<SegmentNode>,
} }
@@ -37,6 +43,7 @@ pub struct SegmentNode {
pub struct WaypointNode { pub struct WaypointNode {
pub id: WaypointId, pub id: WaypointId,
pub name: Option<String>, pub name: Option<String>,
pub sym: Option<String>,
} }
impl FileStructure { impl FileStructure {
@@ -44,12 +51,17 @@ impl FileStructure {
Self { Self {
id: file.id, id: file.id,
name: file.info.name.clone(), name: file.info.name.clone(),
desc: file.info.desc.clone(),
tracks: file tracks: file
.trk .trk
.iter() .iter()
.map(|trk| TrackNode { .map(|trk| TrackNode {
id: trk.id, id: trk.id,
name: trk.info.name.clone(), 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 segments: trk
.trkseg .trkseg
.iter() .iter()
@@ -68,6 +80,7 @@ impl FileStructure {
.map(|wpt| WaypointNode { .map(|wpt| WaypointNode {
id: wpt.id, id: wpt.id,
name: wpt.name.clone(), name: wpt.name.clone(),
sym: wpt.sym.clone(),
}) })
.collect(), .collect(),
wpt_rev_id: file.wpt_rev_id, wpt_rev_id: file.wpt_rev_id,
@@ -87,13 +100,25 @@ mod tests {
fn test_structure_of_a_file() { fn test_structure_of_a_file() {
let mut fx = Fixture::default(); let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap(); let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load { data: &data }.apply(&mut fx.state()).unwrap(); Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let file = &fx.files[&fx.order.0[0]]; let file = &fx.files[&fx.order.0[0]];
let node = FileStructure::new(file); let node = FileStructure::new(file);
assert_eq!((node.id, &node.name), (file.id, &file.info.name)); assert_eq!(
(node.id, &node.name, &node.desc),
(file.id, &file.info.name, &file.info.desc)
);
assert_eq!(node.tracks.len(), file.trk.len()); assert_eq!(node.tracks.len(), file.trk.len());
for (n, t) in node.tracks.iter().zip(&file.trk) { for (n, t) in node.tracks.iter().zip(&file.trk) {
assert_eq!((n.id, &n.name), (t.id, &t.info.name)); 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.segments.len(), t.trkseg.len()); assert_eq!(n.segments.len(), t.trkseg.len());
for (n, s) in n.segments.iter().zip(&t.trkseg) { 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())); assert_eq!((n.id, n.rev_id, n.len), (s.id, s.rev_id, s.len()));
@@ -41,8 +41,18 @@ mod tests {
fn test_only_files_of_the_diff_are_recomputed() { fn test_only_files_of_the_diff_are_recomputed() {
let mut fx = Fixture::default(); let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap(); let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load { data: &data }.apply(&mut fx.state()).unwrap(); Load {
Load { data: &data }.apply(&mut fx.state()).unwrap(); data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let (a, b) = (fx.order.0[0], fx.order.0[1]); let (a, b) = (fx.order.0[0], fx.order.0[1]);
let mut cache = FileStructureCache::default(); let mut cache = FileStructureCache::default();
@@ -1,21 +1,67 @@
use crate::{GlobalStatistics, Statistics, sum_options}; use crate::{GlobalStatistics, Statistics, TrackSegment, sum_options};
/// Value of the timestamps of the trackpoints that have none.
pub const NO_TIME: i64 = i64::MIN;
/// Flattened statistics of the selected segments, one entry per trackpoint, in selection order.
///
/// The distance, time, moving and elevation values are cumulative over the whole selection (the
/// second segment continues where the first one ended), so the statistics of any range of
/// trackpoints are the difference between its two ends: see [`StatisticsBuffer::slice`].
/// Times are in milliseconds, distances in kilometers, speeds in km/h. Missing times are
/// [`NO_TIME`], missing measures are NaN.
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct StatisticsBuffer { pub struct StatisticsBuffer {
/// Statistics of the whole selection.
pub global: GlobalStatistics,
pub total_distance: Vec<f64>, pub total_distance: Vec<f64>,
pub moving_distance: Vec<f64>, pub moving_distance: Vec<f64>,
pub total_time: Vec<i32>, pub total_time: Vec<i64>,
pub moving_time: Vec<i32>, pub moving_time: Vec<i64>,
pub speed: Vec<f64>, pub speed: Vec<f64>,
pub elevation_gain: Vec<f64>, pub elevation_gain: Vec<f64>,
pub elevation_loss: Vec<f64>, pub elevation_loss: Vec<f64>,
pub slope: Vec<f64>, pub slope: Vec<f64>,
pub slope_segment_slope: Vec<f64>, pub slope_segment_slope: Vec<f64>,
pub slope_segment_distance: Vec<f64>, pub slope_segment_distance: Vec<f64>,
pub lng: Vec<f64>,
pub lat: Vec<f64>,
pub ele: Vec<f64>,
/// Timestamps, in milliseconds since the epoch.
pub time: Vec<i64>,
pub hr: Vec<f64>,
pub cad: Vec<f64>,
pub atemp: Vec<f64>,
pub power: Vec<f64>,
/// Surface of the trackpoints: 0 when unknown, else 1 + its code in the categories of the
/// engine (so that it is never 0 for a known surface).
pub surface: Vec<u8>,
/// Highway of the trackpoints, as the surface.
pub highway: Vec<u8>,
/// SAC hiking scale of the trackpoints, as the surface.
pub sac_scale: Vec<u8>,
/// Mountain biking scale of the trackpoints, as the surface.
pub mtb_scale: Vec<u8>,
}
/// 0 for an unknown value, else the code plus one. The categories hold at most 255 values, so it
/// fits.
fn unknown_or_next(code: Option<u8>) -> u8 {
code.and_then(|code| code.checked_add(1)).unwrap_or(0)
} }
impl StatisticsBuffer { impl StatisticsBuffer {
pub fn update(&mut self, stats: &[&Statistics]) { pub fn len(&self) -> usize {
self.total_distance.len()
}
pub fn is_empty(&self) -> bool {
self.total_distance.is_empty()
}
/// `selected`: the segments and their statistics, in order.
pub fn update(&mut self, selected: &[(&TrackSegment, &Statistics)]) {
self.global = GlobalStatistics::default();
self.total_distance.clear(); self.total_distance.clear();
self.moving_distance.clear(); self.moving_distance.clear();
self.total_time.clear(); self.total_time.clear();
@@ -26,10 +72,24 @@ impl StatisticsBuffer {
self.slope.clear(); self.slope.clear();
self.slope_segment_slope.clear(); self.slope_segment_slope.clear();
self.slope_segment_distance.clear(); self.slope_segment_distance.clear();
self.lng.clear();
self.lat.clear();
self.ele.clear();
self.time.clear();
self.hr.clear();
self.cad.clear();
self.atemp.clear();
self.power.clear();
self.surface.clear();
self.highway.clear();
self.sac_scale.clear();
self.mtb_scale.clear();
let mut cumul_stats = GlobalStatistics::default(); let optional = |value: Option<f64>| value.unwrap_or(f64::NAN);
for stats in stats {
for trkpt_stats in stats.local.iter() { for (segment, stats) in selected {
for (trkpt, trkpt_stats) in segment.iter().zip(stats.local.iter()) {
let cumul_stats = &self.global;
self.total_distance self.total_distance
.push(cumul_stats.total_distance + trkpt_stats.total_distance); .push(cumul_stats.total_distance + trkpt_stats.total_distance);
self.moving_distance.push( self.moving_distance.push(
@@ -53,10 +113,59 @@ impl StatisticsBuffer {
.push(trkpt_stats.slope_segment.slope); .push(trkpt_stats.slope_segment.slope);
self.slope_segment_distance self.slope_segment_distance
.push(trkpt_stats.slope_segment.distance); .push(trkpt_stats.slope_segment.distance);
self.lng.push(trkpt.coordinates.lng);
self.lat.push(trkpt.coordinates.lat);
self.ele.push(trkpt.ele);
self.time.push(trkpt.time.unwrap_or(NO_TIME));
self.hr.push(optional(trkpt.hr.map(f64::from)));
self.cad.push(optional(trkpt.cad.map(f64::from)));
self.atemp.push(optional(trkpt.atemp.map(f64::from)));
self.power.push(optional(trkpt.power.map(f64::from)));
self.surface.push(unknown_or_next(trkpt.surface));
self.highway.push(unknown_or_next(trkpt.highway));
self.sac_scale.push(unknown_or_next(trkpt.sac_scale));
self.mtb_scale.push(unknown_or_next(trkpt.mtb_scale));
} }
cumul_stats.merge(&stats.global); self.global.merge(&stats.global);
} }
} }
/// Statistics of the trackpoints from `start` to `end` (both included) of the selection, for
/// example the part of an elevation profile the user dragged over. `None` if the range is
/// not inside the selection.
///
/// It is done in constant time, from the cumulative values at both ends: the bounds and
/// the averages are the ones of the whole selection, they are not computed for the range.
pub fn slice(&self, start: usize, end: usize) -> Option<GlobalStatistics> {
if start > end || end >= self.len() {
return None;
}
let delta = |values: &[f64]| values[end] - values[start];
let delta_time = |values: &[i64]| values[end] - values[start];
let time = |i: usize| (self.time[i] != NO_TIME).then_some(self.time[i]);
Some(GlobalStatistics {
total_distance: delta(&self.total_distance),
moving_distance: self
.global
.moving_distance
.map(|_| delta(&self.moving_distance)),
total_time: self.global.total_time.map(|_| delta_time(&self.total_time)),
moving_time: self
.global
.moving_time
.map(|_| delta_time(&self.moving_time)),
elevation_gain: delta(&self.elevation_gain),
elevation_loss: delta(&self.elevation_loss),
start_time: time(start),
end_time: time(end),
bounds: self.global.bounds,
hr: self.global.hr,
cad: self.global.cad,
atemp: self.global.atemp,
power: self.global.power,
})
}
} }
#[cfg(test)] #[cfg(test)]
@@ -65,28 +174,16 @@ mod tests {
use super::*; use super::*;
fn stats(path: &str) -> Statistics { fn computed(path: &str) -> (TrackSegment, Statistics) {
let data = std::fs::read(path).unwrap(); let data = std::fs::read(path).unwrap();
let file = parse(&data).unwrap(); let file = parse(&data, &mut Default::default()).unwrap();
Statistics::compute(&file.trk[0].trkseg[0]) let segment = file.trk[0].trkseg[0].clone();
let stats = Statistics::compute(&segment);
(segment, stats)
} }
#[test] fn all_lengths(buffer: &StatisticsBuffer) -> Vec<usize> {
fn test_empty() { vec![
let mut buffer = StatisticsBuffer::default();
buffer.update(&[]);
assert!(buffer.total_distance.is_empty());
assert!(buffer.speed.is_empty());
}
#[test]
fn test_single_segment_matches_local_stats() {
let s = stats("data/simple.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[&s]);
let n = s.local.len();
for len in [
buffer.total_distance.len(), buffer.total_distance.len(),
buffer.moving_distance.len(), buffer.moving_distance.len(),
buffer.total_time.len(), buffer.total_time.len(),
@@ -97,21 +194,80 @@ mod tests {
buffer.slope.len(), buffer.slope.len(),
buffer.slope_segment_slope.len(), buffer.slope_segment_slope.len(),
buffer.slope_segment_distance.len(), buffer.slope_segment_distance.len(),
] { buffer.lng.len(),
assert_eq!(len, n); buffer.lat.len(),
buffer.ele.len(),
buffer.time.len(),
buffer.hr.len(),
buffer.cad.len(),
buffer.atemp.len(),
buffer.power.len(),
buffer.surface.len(),
buffer.highway.len(),
buffer.sac_scale.len(),
buffer.mtb_scale.len(),
]
} }
#[test]
fn test_empty() {
let mut buffer = StatisticsBuffer::default();
buffer.update(&[]);
assert!(buffer.is_empty());
assert!(buffer.speed.is_empty());
assert_eq!(buffer.global.total_distance, 0.0);
assert!(buffer.slice(0, 0).is_none());
}
#[test]
fn test_single_segment_matches_local_stats() {
let (segment, s) = computed("data/simple.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
let n = s.local.len();
assert!(all_lengths(&buffer).iter().all(|len| *len == n));
assert_eq!(buffer.len(), n);
for (i, local) in s.local.iter().enumerate() { for (i, local) in s.local.iter().enumerate() {
assert_eq!(buffer.total_distance[i], local.total_distance); assert_eq!(buffer.total_distance[i], local.total_distance);
assert_eq!(buffer.slope[i], local.slope); assert_eq!(buffer.slope[i], local.slope);
} }
// the trackpoints
for (i, trkpt) in segment.iter().enumerate() {
assert_eq!(buffer.lng[i], trkpt.coordinates.lng);
assert_eq!(buffer.lat[i], trkpt.coordinates.lat);
assert_eq!(buffer.ele[i], trkpt.ele);
}
}
#[test]
fn test_global_is_the_merge_of_the_segments() {
let (segment, s) = computed("data/simple.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s), (&segment, &s)]);
let global = &buffer.global;
assert!((global.total_distance - 2.0 * s.global.total_distance).abs() < 1e-9);
assert!((global.elevation_gain - 2.0 * s.global.elevation_gain).abs() < 1e-9);
assert!((global.elevation_loss - 2.0 * s.global.elevation_loss).abs() < 1e-9);
// and it is the last cumulative value of the points
assert_eq!(
global.total_distance,
*buffer.total_distance.last().unwrap()
);
buffer.update(&[(&segment, &s)]);
assert_eq!(buffer.global.total_distance, s.global.total_distance);
buffer.update(&[]);
assert_eq!(buffer.global.total_distance, 0.0);
} }
#[test] #[test]
fn test_distances_accumulate_over_segments() { fn test_distances_accumulate_over_segments() {
let s = stats("data/simple.gpx"); let (segment, s) = computed("data/simple.gpx");
let n = s.local.len(); let n = s.local.len();
let mut buffer = StatisticsBuffer::default(); let mut buffer = StatisticsBuffer::default();
buffer.update(&[&s, &s]); buffer.update(&[(&segment, &s), (&segment, &s)]);
assert_eq!(buffer.total_distance.len(), 2 * n); assert_eq!(buffer.total_distance.len(), 2 * n);
// the second segment starts where the first one ended // the second segment starts where the first one ended
@@ -123,27 +279,204 @@ mod tests {
#[test] #[test]
fn test_total_time_accumulates_over_segments() { fn test_total_time_accumulates_over_segments() {
let s = stats("data/with_time.gpx"); let (segment, s) = computed("data/with_time.gpx");
let n = s.local.len(); let n = s.local.len();
let duration = s.global.total_time.unwrap(); let duration = s.global.total_time.unwrap();
assert!(duration > 0); assert!(duration > 0);
let mut buffer = StatisticsBuffer::default(); let mut buffer = StatisticsBuffer::default();
buffer.update(&[&s, &s]); buffer.update(&[(&segment, &s), (&segment, &s)]);
assert_eq!(buffer.total_time[n - 1], duration); assert_eq!(buffer.total_time[n - 1], duration);
assert_eq!(buffer.total_time[n], duration); assert_eq!(buffer.total_time[n], duration);
assert_eq!(buffer.total_time[2 * n - 1], 2 * duration); assert_eq!(buffer.total_time[2 * n - 1], 2 * duration);
assert!(buffer.total_time.windows(2).all(|w| w[0] <= w[1])); assert!(buffer.total_time.windows(2).all(|w| w[0] <= w[1]));
assert!(buffer.time.iter().all(|t| *t != NO_TIME));
}
#[test]
fn test_missing_times_and_measures_are_nan() {
let (segment, s) = computed("data/simple.gpx");
assert!(segment.iter().all(|trkpt| trkpt.time.is_none()));
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
assert!(buffer.time.iter().all(|t| *t == NO_TIME));
assert!(buffer.hr.iter().all(|v| v.is_nan()));
assert_eq!(buffer.global.hr.count, 0);
assert_eq!(buffer.global.total_time, None);
}
#[test]
fn test_surface_and_highway_codes() {
let (segment, s) = computed("data/with_highway.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
// 0 for the trackpoints that have none, else the code of the engine plus one
assert_eq!(buffer.surface, [1, 1, 0, 2, 2]);
assert_eq!(buffer.highway, [1, 1, 0, 2, 0]);
assert_eq!(buffer.sac_scale, [0, 0, 0, 1, 2]);
assert_eq!(buffer.mtb_scale, [0, 0, 0, 1, 1]);
assert!(all_lengths(&buffer).iter().all(|len| *len == 5));
}
#[test]
fn test_codes_of_the_trackpoints_are_used_as_they_are() {
let (mut segment, _) = computed("data/simple.gpx");
let mut point = segment[0].clone();
point.surface = Some(7);
point.highway = Some(254);
point.sac_scale = Some(0);
point.mtb_scale = Some(3);
segment.splice(0, 1, vec![point]);
let s = Statistics::compute(&segment);
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
assert_eq!((buffer.surface[0], buffer.highway[0]), (8, 255));
assert_eq!((buffer.sac_scale[0], buffer.mtb_scale[0]), (1, 4));
assert_eq!((buffer.surface[1], buffer.highway[1]), (0, 0));
assert_eq!((buffer.sac_scale[1], buffer.mtb_scale[1]), (0, 0));
}
#[test]
fn test_no_codes_without_surface_and_highway() {
let (segment, s) = computed("data/simple.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
assert_eq!(buffer.surface.len(), s.local.len());
assert!(buffer.surface.iter().all(|c| *c == 0));
assert!(buffer.highway.iter().all(|c| *c == 0));
assert!(buffer.sac_scale.iter().all(|c| *c == 0));
assert!(buffer.mtb_scale.iter().all(|c| *c == 0));
// and they are reset by the next update
buffer.update(&[]);
assert!(buffer.surface.is_empty() && buffer.highway.is_empty());
assert!(buffer.sac_scale.is_empty() && buffer.mtb_scale.is_empty());
} }
#[test] #[test]
fn test_update_replaces_previous_content() { fn test_update_replaces_previous_content() {
let s = stats("data/simple.gpx"); let (segment, s) = computed("data/simple.gpx");
let mut buffer = StatisticsBuffer::default(); let mut buffer = StatisticsBuffer::default();
buffer.update(&[&s]); buffer.update(&[(&segment, &s)]);
buffer.update(&[&s]); buffer.update(&[(&segment, &s)]);
assert_eq!(buffer.total_distance.len(), s.local.len()); assert_eq!(buffer.total_distance.len(), s.local.len());
assert!(all_lengths(&buffer).iter().all(|len| *len == s.local.len()));
buffer.update(&[]); buffer.update(&[]);
assert!(buffer.total_distance.is_empty()); assert!(buffer.total_distance.is_empty());
assert!(all_lengths(&buffer).iter().all(|len| *len == 0));
}
#[test]
fn test_slice_of_the_whole_selection_is_the_global_statistics() {
let (segment, s) = computed("data/with_time.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s), (&segment, &s)]);
let slice = buffer.slice(0, buffer.len() - 1).unwrap();
let global = &buffer.global;
assert!((slice.total_distance - global.total_distance).abs() < 1e-9);
assert!((slice.elevation_gain - global.elevation_gain).abs() < 1e-9);
assert!((slice.elevation_loss - global.elevation_loss).abs() < 1e-9);
assert_eq!(slice.total_time, global.total_time);
assert_eq!(slice.moving_time, global.moving_time);
assert_eq!(slice.start_time, global.start_time);
assert_eq!(slice.end_time, global.end_time);
}
#[test]
fn test_slice_of_a_range() {
let (segment, s) = computed("data/with_time.gpx");
let n = s.local.len();
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
let (start, end) = (n / 4, n / 2);
let slice = buffer.slice(start, end).unwrap();
assert!(
(slice.total_distance - (buffer.total_distance[end] - buffer.total_distance[start]))
.abs()
< 1e-12
);
assert_eq!(
slice.total_time,
Some(buffer.total_time[end] - buffer.total_time[start])
);
assert_eq!(
slice.moving_time,
Some(buffer.moving_time[end] - buffer.moving_time[start])
);
assert_eq!(slice.start_time, Some(buffer.time[start]));
assert_eq!(slice.end_time, Some(buffer.time[end]));
assert!(slice.total_distance < buffer.global.total_distance);
assert!(slice.total_speed().is_some());
// a single point has nothing between its ends
let point = buffer.slice(start, start).unwrap();
assert_eq!(point.total_distance, 0.0);
assert_eq!(point.total_time, Some(0));
}
#[test]
fn test_slice_keeps_the_bounds_and_averages_of_the_selection() {
let data = std::fs::read("data/with_hr.gpx").unwrap();
let file = parse(&data, &mut Default::default()).unwrap();
let segment = file.trk[0].trkseg[0].clone();
let s = Statistics::compute(&segment);
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
assert!(buffer.global.hr.count > 0);
let slice = buffer.slice(1, 3).unwrap();
let (global, bounds) = (&buffer.global, &slice.bounds);
assert_eq!(slice.hr, global.hr);
assert_eq!(slice.cad, global.cad);
assert_eq!(slice.atemp, global.atemp);
assert_eq!(slice.power, global.power);
assert_eq!(
(bounds.sw.lng, bounds.sw.lat),
(global.bounds.sw.lng, global.bounds.sw.lat)
);
assert_eq!(
(bounds.ne.lng, bounds.ne.lat),
(global.bounds.ne.lng, global.bounds.ne.lat)
);
}
#[test]
fn test_durations_are_not_limited_to_24_days() {
// 30 days of recording, in milliseconds
let (segment, mut s) = computed("data/with_time.gpx");
let month = 30 * 24 * 3_600_000_i64;
s.global.total_time = Some(month);
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s), (&segment, &s)]);
assert_eq!(buffer.global.total_time, Some(2 * month));
assert!(2 * month > i64::from(i32::MAX));
}
#[test]
fn test_slice_across_segments() {
let (segment, s) = computed("data/simple.gpx");
let n = s.local.len();
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s), (&segment, &s)]);
// from the middle of the first segment to the middle of the second one
let slice = buffer.slice(n / 2, n + n / 2).unwrap();
let expected = buffer.total_distance[n + n / 2] - buffer.total_distance[n / 2];
assert!((slice.total_distance - expected).abs() < 1e-9);
assert_eq!(slice.total_time, None);
}
#[test]
fn test_slice_out_of_range() {
let (segment, s) = computed("data/simple.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
let n = buffer.len();
assert!(buffer.slice(0, n).is_none());
assert!(buffer.slice(n, n).is_none());
assert!(buffer.slice(5, 4).is_none());
assert!(buffer.slice(0, n - 1).is_some());
} }
} }
@@ -1,9 +1,12 @@
use std::{ use std::collections::{HashMap, HashSet, hash_map::Entry};
collections::{HashMap, HashSet, hash_map::Entry},
rc::Rc, use crate::{
File, FileId, GlobalStatistics, Selection, StackEntry, Statistics, Track, TrackSegment,
TrackSegmentRevisionId,
}; };
use crate::{File, Selection, StackEntry, Statistics, Track, TrackSegment, TrackSegmentRevisionId}; /// A segment and its statistics.
pub type SelectedSegment<'a> = (&'a TrackSegment, &'a Statistics);
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct StatisticsCache { pub struct StatisticsCache {
@@ -34,19 +37,38 @@ impl StatisticsCache {
} }
} }
/// Statistics of the segments of the selection.
pub fn get<'a>( pub fn get<'a>(
&'a self, &'a self,
state: Option<&StackEntry>, state: Option<&'a StackEntry>,
selection: &Selection, selection: &Selection,
) -> Vec<&'a Statistics> { ) -> Vec<&'a Statistics> {
let mut stats = vec![]; self.selected(state, selection, &[])
.into_iter()
.map(|(_, stats)| stats)
.collect()
}
/// The segments of the selection with their statistics. The segments of several selected
/// files follow `order` (the files missing from it come last).
pub fn selected<'a>(
&'a self,
state: Option<&'a StackEntry>,
selection: &Selection,
order: &[FileId],
) -> Vec<SelectedSegment<'a>> {
let mut selected = vec![];
if let Some(state) = state { if let Some(state) = state {
match selection { match selection {
Selection::Empty => (), Selection::Empty => (),
Selection::File { file_ids } => { Selection::File { file_ids } => {
for id in file_ids.iter() { let ordered = order
.iter()
.filter(|id| file_ids.contains(id))
.chain(file_ids.iter().filter(|id| !order.contains(id)));
for id in ordered {
if let Some(file) = state.get(id) { if let Some(file) = state.get(id) {
self.add_file_stats(file, &mut stats); self.add_file_stats(file, &mut selected);
} }
} }
} }
@@ -54,7 +76,7 @@ impl StatisticsCache {
if let Some(file) = state.get(file_id) { if let Some(file) = state.get(file_id) {
for trk in file.trk.iter() { for trk in file.trk.iter() {
if trk_ids.contains(&trk.id) { if trk_ids.contains(&trk.id) {
self.add_track_stats(trk, &mut stats); self.add_track_stats(trk, &mut selected);
} }
} }
} }
@@ -69,7 +91,7 @@ impl StatisticsCache {
if *trk_id == trk.id { if *trk_id == trk.id {
for trkseg in trk.trkseg.iter() { for trkseg in trk.trkseg.iter() {
if trkseg_ids.contains(&trkseg.id) { if trkseg_ids.contains(&trkseg.id) {
self.add_segment_stats(trkseg, &mut stats); self.add_segment_stats(trkseg, &mut selected);
} }
} }
break; break;
@@ -79,7 +101,7 @@ impl StatisticsCache {
} }
Selection::Waypoints { file_id } => { Selection::Waypoints { file_id } => {
if let Some(file) = state.get(file_id) { if let Some(file) = state.get(file_id) {
self.add_file_stats(file, &mut stats); self.add_file_stats(file, &mut selected);
} }
} }
Selection::Waypoint { Selection::Waypoint {
@@ -87,36 +109,51 @@ impl StatisticsCache {
wpt_ids: _, wpt_ids: _,
} => { } => {
if let Some(file) = state.get(file_id) { if let Some(file) = state.get(file_id) {
self.add_file_stats(file, &mut stats); self.add_file_stats(file, &mut selected);
} }
} }
} }
} }
stats selected
} }
fn add_file_stats<'a>(&'a self, file: &Rc<File>, stats: &mut Vec<&'a Statistics>) { /// Global statistics of a file: its segments merged, in order.
pub fn file_global(&self, file: &File) -> GlobalStatistics {
let mut selected = vec![];
self.add_file_stats(file, &mut selected);
let mut global = GlobalStatistics::default();
for (_, stats) in selected {
global.merge(&stats.global);
}
global
}
fn add_file_stats<'a>(&'a self, file: &'a File, selected: &mut Vec<SelectedSegment<'a>>) {
for trk in file.trk.iter() { for trk in file.trk.iter() {
self.add_track_stats(trk, stats); self.add_track_stats(trk, selected);
} }
} }
fn add_track_stats<'a>(&'a self, trk: &Track, stats: &mut Vec<&'a Statistics>) { fn add_track_stats<'a>(&'a self, trk: &'a Track, selected: &mut Vec<SelectedSegment<'a>>) {
for trkseg in trk.trkseg.iter() { for trkseg in trk.trkseg.iter() {
self.add_segment_stats(trkseg, stats); self.add_segment_stats(trkseg, selected);
} }
} }
fn add_segment_stats<'a>(&'a self, trkseg: &TrackSegment, stats: &mut Vec<&'a Statistics>) { fn add_segment_stats<'a>(
if let Some(s) = self.map.get(&trkseg.rev_id) { &'a self,
stats.push(s); trkseg: &'a TrackSegment,
selected: &mut Vec<SelectedSegment<'a>>,
) {
if let Some(stats) = self.map.get(&trkseg.rev_id) {
selected.push((trkseg, stats));
} }
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::collections::HashSet; use std::{collections::HashSet, rc::Rc};
use crate::{FileId, parse}; use crate::{FileId, parse};
@@ -124,7 +161,7 @@ mod tests {
fn state(path: &str) -> (StackEntry, Rc<File>) { fn state(path: &str) -> (StackEntry, Rc<File>) {
let data = std::fs::read(path).unwrap(); let data = std::fs::read(path).unwrap();
let file = Rc::new(parse(&data).unwrap()); let file = Rc::new(parse(&data, &mut Default::default()).unwrap());
let mut state = StackEntry::default(); let mut state = StackEntry::default();
state.insert(file.id, file.clone()); state.insert(file.id, file.clone());
(state, file) (state, file)
@@ -230,4 +267,33 @@ mod tests {
cache.update(None); cache.update(None);
assert!(cache.map.is_empty()); assert!(cache.map.is_empty());
} }
#[test]
fn test_segments_of_several_files_follow_the_given_order() {
let (mut state, first) = state("data/simple.gpx");
let (other, second) = {
let data = std::fs::read("data/with_time.gpx").unwrap();
let file = Rc::new(parse(&data, &mut Default::default()).unwrap());
(file.clone(), file)
};
state.insert(other.id, other);
let mut cache = StatisticsCache::default();
cache.update(Some(&state));
let selection = Selection::File {
file_ids: HashSet::from([first.id, second.id]),
};
let ids = |order: &[FileId]| -> Vec<_> {
cache
.selected(Some(&state), &selection, order)
.iter()
.map(|(segment, _)| segment.id)
.collect()
};
let (a, b) = (first.trk[0].trkseg[0].id, second.trk[0].trkseg[0].id);
assert_eq!(ids(&[first.id, second.id]), vec![a, b]);
assert_eq!(ids(&[second.id, first.id]), vec![b, a]);
// the files that are not in the order come last
assert_eq!(ids(&[second.id]), vec![b, a]);
}
} }
File diff suppressed because it is too large. Load diff
+479
View File
@@ -0,0 +1,479 @@
use std::rc::Rc;
use uuid::Uuid;
use crate::{
File, FileId, Selection, StackEntry, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint,
WaypointId,
};
/// 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)]
pub struct ClipboardTrack {
pub track: Track,
pub file_name: String,
}
/// A segment in the clipboard, with the name of its track, or of its file when the track has
/// none (the name of the new file when it is pasted on its own).
#[derive(Debug, Clone)]
pub struct ClipboardSegment {
pub segment: TrackSegment,
pub name: String,
}
/// The elements that were copied or cut, in the order of the files.
///
/// It holds the elements themselves, not references to them: they are cheap to keep (files are
/// shared, and so are the trackpoints of tracks and segments), and the clipboard stays valid
/// when the elements are changed or deleted.
#[derive(Debug, Clone)]
pub enum ClipboardContent {
Files(Vec<Rc<File>>),
Tracks(Vec<ClipboardTrack>),
Segments(Vec<ClipboardSegment>),
Waypoints(Vec<Waypoint>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ClipboardId(Uuid);
impl Default for ClipboardId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
/// What was copied or cut, to be pasted, as many times as needed.
///
/// What is pasted is always the elements as they were when they were copied or cut, whatever
/// happened to them since, even if they were deleted. Pasting what was copied makes copies, with
/// new ids. What was cut is moved, with its ids, the first time it is pasted (the elements that
/// are still there are removed): the clipboard is not cut anymore after that, and the next
/// pastes make copies.
#[derive(Debug, Clone)]
pub struct Clipboard {
id: ClipboardId,
pub content: ClipboardContent,
pub cut: bool,
}
/// Two clipboards are the same when they come from the same copy, and are cut the same way.
impl PartialEq for Clipboard {
fn eq(&self, other: &Self) -> bool {
self.id == other.id && self.cut == other.cut
}
}
impl Clipboard {
/// The clipboard for the selected elements, `None` if nothing is selected (or if what is
/// selected does not exist).
pub fn new(
selection: &Selection,
files: &StackEntry,
order: &[FileId],
cut: bool,
) -> Option<Clipboard> {
let content = match selection {
Selection::Empty => return None,
Selection::File { file_ids } => ClipboardContent::Files(
order
.iter()
.filter(|id| file_ids.contains(id))
.filter_map(|id| files.get(id).cloned())
.collect(),
),
Selection::Track { file_id, trk_ids } => {
let file = files.get(file_id)?;
ClipboardContent::Tracks(
file.trk
.iter()
.filter(|trk| trk_ids.contains(&trk.id))
.map(|trk| ClipboardTrack {
track: trk.clone(),
file_name: file.info.name.clone(),
})
.collect(),
)
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let file = files.get(file_id)?;
let trk = file.trk.iter().find(|trk| trk.id == *trk_id)?;
let name = trk
.info
.name
.clone()
.unwrap_or_else(|| file.info.name.clone());
ClipboardContent::Segments(
trk.trkseg
.iter()
.filter(|seg| trkseg_ids.contains(&seg.id))
.map(|seg| ClipboardSegment {
segment: seg.clone(),
name: name.clone(),
})
.collect(),
)
}
Selection::Waypoints { file_id } => ClipboardContent::Waypoints(
files
.get(file_id)?
.wpt
.iter()
.flat_map(|chunk| chunk.wpt.iter().cloned())
.collect(),
),
Selection::Waypoint { file_id, wpt_ids } => ClipboardContent::Waypoints(
files
.get(file_id)?
.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.filter(|wpt| wpt_ids.contains(&wpt.id))
.cloned()
.collect(),
),
};
let clipboard = Clipboard {
id: ClipboardId::default(),
content,
cut,
};
(!clipboard.is_empty()).then_some(clipboard)
}
pub fn is_empty(&self) -> bool {
match &self.content {
ClipboardContent::Files(files) => files.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.
///
/// | copied | pasted when selected |
/// |-----------|------------------------------------------|
/// | files | nothing, a file |
/// | tracks | nothing, a file, a track |
/// | segments | nothing, a file, a track, a segment |
/// | waypoints | a file, the waypoints, a waypoint |
pub fn can_paste(&self, target: &Selection) -> bool {
use ClipboardContent as C;
use Selection::*;
matches!(
(&self.content, target),
(C::Files(_), Empty | File { .. })
| (C::Tracks(_), Empty | File { .. } | Track { .. })
| (
C::Segments(_),
Empty | File { .. } | Track { .. } | TrackSegment { .. }
)
| (
C::Waypoints(_),
File { .. } | Waypoints { .. } | Waypoint { .. }
)
)
}
}
/// The ids of the elements of the content, by kind.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClipboardIds {
Files(Vec<FileId>),
Tracks(Vec<TrackId>),
Segments(Vec<TrackSegmentId>),
Waypoints(Vec<WaypointId>),
}
impl ClipboardContent {
pub fn ids(&self) -> ClipboardIds {
match self {
ClipboardContent::Files(files) => {
ClipboardIds::Files(files.iter().map(|file| file.id).collect())
}
ClipboardContent::Tracks(tracks) => {
ClipboardIds::Tracks(tracks.iter().map(|trk| trk.track.id).collect())
}
ClipboardContent::Segments(segments) => {
ClipboardIds::Segments(segments.iter().map(|seg| seg.segment.id).collect())
}
ClipboardContent::Waypoints(waypoints) => {
ClipboardIds::Waypoints(waypoints.iter().map(|wpt| wpt.id).collect())
}
}
}
}
#[cfg(test)]
mod tests {
use crate::{TrackInfo, WaypointChunk};
use super::*;
/// A file with 3 tracks (named "t0", "t1", "t2") of 2 segments and 3 waypoints.
fn file(name: &str) -> File {
let mut file = File::default();
file.info.name = name.to_owned();
for i in 0..3 {
file.trk.push(Track {
info: TrackInfo {
name: (i < 2).then(|| format!("t{i}")),
..Default::default()
},
trkseg: vec![TrackSegment::default(), TrackSegment::default()],
..Default::default()
});
}
file.wpt.push(Rc::new(WaypointChunk {
wpt: vec![Waypoint::default(), Waypoint::default()],
..Default::default()
}));
file.wpt.push(Rc::new(WaypointChunk {
wpt: vec![Waypoint::default()],
..Default::default()
}));
file
}
fn files_of(files: Vec<File>) -> StackEntry {
files
.into_iter()
.map(|file| (file.id, Rc::new(file)))
.collect()
}
fn order_of(files: &StackEntry) -> Vec<FileId> {
files.keys().copied().collect()
}
fn tracks(file: &File, ids: &[usize]) -> Selection {
Selection::Track {
file_id: file.id,
trk_ids: ids.iter().map(|i| file.trk[*i].id).collect(),
}
}
#[test]
fn test_the_clipboard_holds_what_is_selected() {
let file = file("source");
let (t0, t2) = (file.trk[0].id, file.trk[2].id);
let (s0, s1) = (file.trk[1].trkseg[0].id, file.trk[1].trkseg[1].id);
let wpt: Vec<_> = file
.wpt
.iter()
.flat_map(|c| c.wpt.iter().map(|w| w.id))
.collect();
let files = files_of(vec![file.clone()]);
// tracks, in the order of the file, with the name of their file
let clipboard =
Clipboard::new(&tracks(&file, &[2, 0]), &files, &order_of(&files), false).unwrap();
assert!(!clipboard.cut);
match &clipboard.content {
ClipboardContent::Tracks(copied) => {
assert_eq!(
copied.iter().map(|c| c.track.id).collect::<Vec<_>>(),
vec![t0, t2]
);
assert!(copied.iter().all(|c| c.file_name == "source"));
}
other => panic!("{other:?}"),
}
// segments, with the name of their track, or else of their file
let segments = |trk: usize, ids: &[TrackSegmentId]| Selection::TrackSegment {
file_id: file.id,
trk_id: file.trk[trk].id,
trkseg_ids: ids.iter().copied().collect(),
};
match Clipboard::new(&segments(1, &[s1, s0]), &files, &order_of(&files), true)
.unwrap()
.content
{
ClipboardContent::Segments(copied) => {
assert_eq!(
copied.iter().map(|c| c.segment.id).collect::<Vec<_>>(),
vec![s0, s1]
);
assert!(copied.iter().all(|c| c.name == "t1"));
}
other => panic!("{other:?}"),
}
let unnamed = file.trk[2].trkseg[0].id;
match Clipboard::new(&segments(2, &[unnamed]), &files, &order_of(&files), true)
.unwrap()
.content
{
ClipboardContent::Segments(copied) => assert_eq!(copied[0].name, "source"),
other => panic!("{other:?}"),
}
// waypoints: some of them, or all of them
let some = Selection::Waypoint {
file_id: file.id,
wpt_ids: [wpt[2], wpt[0]].into(),
};
assert_eq!(
Clipboard::new(&some, &files, &order_of(&files), false)
.unwrap()
.content
.ids(),
ClipboardIds::Waypoints(vec![wpt[0], wpt[2]])
);
assert_eq!(
Clipboard::new(
&Selection::Waypoints { file_id: file.id },
&files,
&order_of(&files),
false
)
.unwrap()
.content
.ids(),
ClipboardIds::Waypoints(wpt)
);
// files
let selection = Selection::File {
file_ids: [file.id].into(),
};
assert_eq!(
Clipboard::new(&selection, &files, &order_of(&files), false)
.unwrap()
.content
.ids(),
ClipboardIds::Files(vec![file.id])
);
}
#[test]
fn test_nothing_to_copy() {
let file = file("f");
let files = files_of(vec![file.clone()]);
assert!(Clipboard::new(&Selection::Empty, &files, &order_of(&files), false).is_none());
// elements that do not exist
let unknown = Selection::Track {
file_id: FileId::default(),
trk_ids: [TrackId::default()].into(),
};
assert!(Clipboard::new(&unknown, &files, &order_of(&files), false).is_none());
let unknown = Selection::Track {
file_id: file.id,
trk_ids: [TrackId::default()].into(),
};
assert!(Clipboard::new(&unknown, &files, &order_of(&files), false).is_none());
assert!(
Clipboard::new(
&Selection::File {
file_ids: [FileId::default()].into()
},
&files,
&order_of(&files),
false
)
.is_none()
);
// a file without waypoints
let empty = files_of(vec![File::default()]);
let id = *empty.keys().next().unwrap();
assert!(
Clipboard::new(
&Selection::Waypoints { file_id: id },
&empty,
&order_of(&empty),
true
)
.is_none()
);
}
#[test]
fn test_two_clipboards_are_the_same_when_they_come_from_the_same_copy() {
let file = file("f");
let files = files_of(vec![file.clone()]);
let selection = tracks(&file, &[0]);
let clipboard = Clipboard::new(&selection, &files, &order_of(&files), false).unwrap();
assert_eq!(clipboard, clipboard.clone());
// the same elements copied again make another clipboard
assert_ne!(
clipboard,
Clipboard::new(&selection, &files, &order_of(&files), false).unwrap()
);
// and it is not the same once it is not cut
let mut copy = clipboard.clone();
copy.cut = true;
assert_ne!(clipboard, copy);
}
#[test]
fn test_the_clipboard_survives_what_happens_to_the_files() {
let file = file("f");
let mut files = files_of(vec![file.clone()]);
let clipboard =
Clipboard::new(&tracks(&file, &[0, 1]), &files, &order_of(&files), true).unwrap();
files.clear();
assert!(!clipboard.is_empty());
assert_eq!(
clipboard.content.ids(),
ClipboardIds::Tracks(vec![file.trk[0].id, file.trk[1].id])
);
}
#[test]
fn test_where_things_can_be_pasted() {
let file = file("f");
let files = files_of(vec![file.clone()]);
let selection_of = |selection: Selection| {
Clipboard::new(&selection, &files, &order_of(&files), false).unwrap()
};
let file_selection = Selection::File {
file_ids: [file.id].into(),
};
let track = tracks(&file, &[0]);
let segment = Selection::TrackSegment {
file_id: file.id,
trk_id: file.trk[0].id,
trkseg_ids: [file.trk[0].trkseg[0].id].into(),
};
let waypoints = Selection::Waypoints { file_id: file.id };
let waypoint = Selection::Waypoint {
file_id: file.id,
wpt_ids: [file.wpt[0].wpt[0].id].into(),
};
let targets = [
Selection::Empty,
file_selection.clone(),
track.clone(),
segment.clone(),
waypoints.clone(),
waypoint.clone(),
];
let expected = [
// nothing, file, track, segment, waypoints, waypoint
(file_selection, [true, true, false, false, false, false]),
(track, [true, true, true, false, false, false]),
(segment, [true, true, true, true, false, false]),
(waypoints, [false, true, false, false, true, true]),
(waypoint, [false, true, false, false, true, true]),
];
for (copied, allowed) in expected {
let clipboard = selection_of(copied.clone());
for (target, allowed) in targets.iter().zip(allowed) {
assert_eq!(
clipboard.can_paste(target),
allowed,
"{copied:?} on {target:?}"
);
}
}
}
}
+4
View File
@@ -1,13 +1,17 @@
mod clipboard;
mod order; mod order;
mod selection; mod selection;
mod stack; mod stack;
pub use clipboard::*;
pub use order::*; pub use order::*;
pub use selection::*; pub use selection::*;
pub use stack::*; pub use stack::*;
pub struct State<'a> { pub struct State<'a> {
pub categories: &'a mut crate::TrackpointCategories,
pub files: &'a mut StackEntry, pub files: &'a mut StackEntry,
pub selection: &'a mut Selection, pub selection: &'a mut Selection,
pub order: &'a mut FileOrder, pub order: &'a mut FileOrder,
pub clipboard: &'a mut Option<Clipboard>,
} }
+678 -8
View File
@@ -2,6 +2,19 @@ use std::collections::HashSet;
use crate::{FileId, StackEntry, TrackId, TrackSegmentId, WaypointId}; use crate::{FileId, StackEntry, TrackId, TrackSegmentId, WaypointId};
/// How a new selection combines with the current one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SelectMode {
/// The new selection replaces the current one.
Replace,
/// The elements are added when they are of the same kind and in the same place as the
/// current selection (see [`Selection::extend`]), otherwise they replace it.
Add,
/// Like `Add`, but the elements already selected are removed instead (see
/// [`Selection::toggle`]).
Toggle,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)] #[derive(Debug, Default, Clone, PartialEq, Eq)]
pub enum Selection { pub enum Selection {
#[default] #[default]
@@ -28,24 +41,309 @@ pub enum Selection {
} }
impl Selection { impl Selection {
/// Drops the files that do not exist anymore (e.g. after an undo). /// 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.
pub fn retain_existing(&mut self, files: &StackEntry) { pub fn retain_existing(&mut self, files: &StackEntry) {
match self { let empty = match self {
Selection::Empty => {} Selection::Empty => false,
Selection::File { file_ids } => { Selection::File { file_ids } => {
file_ids.retain(|id| files.contains_key(id)); file_ids.retain(|id| files.contains_key(id));
if file_ids.is_empty() { file_ids.is_empty()
}
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));
trk_ids.is_empty()
}
None => true,
},
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => match files
.get(file_id)
.and_then(|file| file.trk.iter().find(|trk| trk.id == *trk_id))
{
Some(trk) => {
trkseg_ids.retain(|id| trk.trkseg.iter().any(|seg| seg.id == *id));
trkseg_ids.is_empty()
}
None => true,
},
Selection::Waypoints { file_id } => !files.contains_key(file_id),
Selection::Waypoint { file_id, wpt_ids } => match files.get(file_id) {
Some(file) => {
wpt_ids.retain(|id| {
file.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.any(|wpt| wpt.id == *id)
});
wpt_ids.is_empty()
}
None => true,
},
};
if empty {
*self = Selection::Empty; *self = Selection::Empty;
} }
} }
/// Adds `other` to the selection: the elements are merged when they are of the same kind and
/// in the same place (same file, same track for segments), otherwise `other` replaces the
/// selection.
pub fn extend(&mut self, other: Selection) {
match (&mut *self, other) {
(Selection::File { file_ids }, Selection::File { file_ids: other }) => {
file_ids.extend(other)
}
(
Selection::Track { file_id, trk_ids },
Selection::Track {
file_id: other_file,
trk_ids: other,
},
) if *file_id == other_file => trk_ids.extend(other),
(
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
},
Selection::TrackSegment {
file_id: other_file,
trk_id: other_trk,
trkseg_ids: other,
},
) if *file_id == other_file && *trk_id == other_trk => trkseg_ids.extend(other),
(Selection::Waypoints { file_id }, Selection::Waypoints { file_id: other })
if *file_id == other => {}
(
Selection::Waypoint { file_id, wpt_ids },
Selection::Waypoint {
file_id: other_file,
wpt_ids: other,
},
) if *file_id == other_file => wpt_ids.extend(other),
(selection, other) => *selection = other,
}
}
/// Like [`Selection::extend`], but the elements of `other` that are already selected are
/// removed from the selection instead. A selection left with nothing is empty.
pub fn toggle(&mut self, other: Selection) {
fn toggle_ids<T: std::hash::Hash + Eq>(ids: &mut HashSet<T>, other: HashSet<T>) {
for id in other {
if !ids.remove(&id) {
ids.insert(id);
}
}
}
let empty = match (&mut *self, other) {
(Selection::File { file_ids }, Selection::File { file_ids: other }) => {
toggle_ids(file_ids, other);
file_ids.is_empty()
}
(
Selection::Track { file_id, trk_ids },
Selection::Track {
file_id: other_file,
trk_ids: other,
},
) if *file_id == other_file => {
toggle_ids(trk_ids, other);
trk_ids.is_empty()
}
(
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
},
Selection::TrackSegment {
file_id: other_file,
trk_id: other_trk,
trkseg_ids: other,
},
) if *file_id == other_file && *trk_id == other_trk => {
toggle_ids(trkseg_ids, other);
trkseg_ids.is_empty()
}
(Selection::Waypoints { file_id }, Selection::Waypoints { file_id: other })
if *file_id == other =>
{
true
}
(
Selection::Waypoint { file_id, wpt_ids },
Selection::Waypoint {
file_id: other_file,
wpt_ids: other,
},
) if *file_id == other_file => {
toggle_ids(wpt_ids, other);
wpt_ids.is_empty()
}
(selection, other) => {
*selection = other;
false
}
};
if empty {
*self = Selection::Empty;
}
}
}
/// Position of the first and last of the `selected` ids among the `all` ids.
fn selected_range<T: PartialEq>(
all: impl Iterator<Item = T>,
selected: &HashSet<T>,
) -> Option<(usize, usize)>
where
T: std::hash::Hash + Eq,
{
let mut range: Option<(usize, usize)> = None;
for (i, id) in all.enumerate() {
if selected.contains(&id) {
range = Some(range.map_or((i, i), |(first, _)| (first, i)));
}
}
range
}
/// The id before or after the selected ones among `ids`, without wrapping around.
fn neighbour<T: Copy + PartialEq + std::hash::Hash + Eq>(
ids: &[T],
selected: &HashSet<T>,
down: bool,
) -> Option<T> {
let (first, last) = selected_range(ids.iter().copied(), selected)?;
if down {
ids.get(last + 1).copied()
} else {
first.checked_sub(1).map(|i| ids[i])
}
}
impl Selection {
/// The files holding the selected elements, as a selection of files.
pub fn to_files(&self) -> Selection {
match self {
Selection::Empty => Selection::Empty,
Selection::File { .. } => self.clone(),
Selection::Track { file_id, .. } Selection::Track { file_id, .. }
| Selection::TrackSegment { file_id, .. } | Selection::TrackSegment { file_id, .. }
| Selection::Waypoints { file_id } | Selection::Waypoints { file_id }
| Selection::Waypoint { file_id, .. } => { | Selection::Waypoint { file_id, .. } => Selection::File {
if !files.contains_key(file_id) { file_ids: [*file_id].into(),
*self = Selection::Empty; },
} }
} }
/// 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
/// 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: &StackEntry, order: &[FileId]) -> Option<Selection> {
match self {
Selection::Empty | Selection::File { .. } => Some(Selection::File {
file_ids: order.iter().copied().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(),
}),
Selection::TrackSegment {
file_id, trk_id, ..
} => {
let trk = files
.get(file_id)?
.trk
.iter()
.find(|trk| trk.id == *trk_id)?;
Some(Selection::TrackSegment {
file_id: *file_id,
trk_id: *trk_id,
trkseg_ids: trk.trkseg.iter().map(|seg| seg.id).collect(),
})
}
Selection::Waypoints { .. } => None,
Selection::Waypoint { file_id, .. } => Some(Selection::Waypoint {
file_id: *file_id,
wpt_ids: files
.get(file_id)?
.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.map(|wpt| wpt.id)
.collect(),
}),
}
}
/// 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 ones that are already selected. `None` when there is no such element.
pub fn neighbour(&self, files: &StackEntry, order: &[FileId], down: bool) -> Option<Selection> {
match self {
Selection::Empty | Selection::Waypoints { .. } => None,
Selection::File { file_ids } => {
let (first, last) = selected_range(order.iter(), &file_ids.iter().collect())?;
let limit = if down { last } else { first };
let n = order.len();
let mut i = limit;
loop {
i = if down { (i + 1) % n } else { (i + n - 1) % n };
if i == limit {
return None;
}
if !file_ids.contains(&order[i]) {
return Some(Selection::File {
file_ids: [order[i]].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 {
file_id: *file_id,
trk_ids: [neighbour(&ids, trk_ids, down)?].into(),
})
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let trk = files
.get(file_id)?
.trk
.iter()
.find(|trk| trk.id == *trk_id)?;
let ids: Vec<TrackSegmentId> = trk.trkseg.iter().map(|seg| seg.id).collect();
Some(Selection::TrackSegment {
file_id: *file_id,
trk_id: *trk_id,
trkseg_ids: [neighbour(&ids, trkseg_ids, down)?].into(),
})
}
Selection::Waypoint { file_id, wpt_ids } => {
let ids: Vec<WaypointId> = files
.get(file_id)?
.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.map(|wpt| wpt.id)
.collect();
Some(Selection::Waypoint {
file_id: *file_id,
wpt_ids: [neighbour(&ids, wpt_ids, down)?].into(),
})
}
} }
} }
} }
@@ -54,7 +352,7 @@ impl Selection {
mod tests { mod tests {
use std::rc::Rc; use std::rc::Rc;
use crate::File; use crate::{File, Track, TrackSegment, Waypoint, WaypointChunk};
use super::*; use super::*;
@@ -86,4 +384,376 @@ mod tests {
selection.retain_existing(&files); selection.retain_existing(&files);
assert!(matches!(selection, Selection::Empty)); assert!(matches!(selection, Selection::Empty));
} }
#[test]
fn test_retain_existing_elements() {
let mut file = File::default();
file.trk.push(Track {
trkseg: vec![TrackSegment::default()],
..Default::default()
});
file.wpt.push(Rc::new(WaypointChunk {
wpt: vec![Waypoint::default()],
..Default::default()
}));
let (file_id, trk_id) = (file.id, file.trk[0].id);
let (seg_id, wpt_id) = (file.trk[0].trkseg[0].id, file.wpt[0].wpt[0].id);
let mut files = StackEntry::default();
files.insert(file_id, Rc::new(file));
let mut selection = Selection::Track {
file_id,
trk_ids: [trk_id, TrackId::default()].into(),
};
selection.retain_existing(&files);
assert_eq!(
selection,
Selection::Track {
file_id,
trk_ids: [trk_id].into()
}
);
let mut selection = Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids: [seg_id, TrackSegmentId::default()].into(),
};
selection.retain_existing(&files);
assert!(
matches!(&selection, Selection::TrackSegment { trkseg_ids, .. } if trkseg_ids.len() == 1)
);
// a segment under the wrong track
let mut selection = Selection::TrackSegment {
file_id,
trk_id: TrackId::default(),
trkseg_ids: [seg_id].into(),
};
selection.retain_existing(&files);
assert_eq!(selection, Selection::Empty);
let mut selection = Selection::Waypoint {
file_id,
wpt_ids: [wpt_id, WaypointId::default()].into(),
};
selection.retain_existing(&files);
assert!(matches!(&selection, Selection::Waypoint { wpt_ids, .. } if wpt_ids.len() == 1));
let mut selection = Selection::Waypoint {
file_id,
wpt_ids: [WaypointId::default()].into(),
};
selection.retain_existing(&files);
assert_eq!(selection, Selection::Empty);
}
#[test]
fn test_extend() {
let file_id = FileId::default();
let (a, b) = (TrackId::default(), TrackId::default());
let tracks = |ids: &[TrackId]| Selection::Track {
file_id,
trk_ids: ids.iter().copied().collect(),
};
// same kind and place: merged
let mut selection = tracks(&[a]);
selection.extend(tracks(&[b]));
assert_eq!(selection, tracks(&[a, b]));
// another file, or another kind: replaced
let mut selection = tracks(&[a]);
let other = Selection::Track {
file_id: FileId::default(),
trk_ids: [b].into(),
};
selection.extend(other.clone());
assert_eq!(selection, other);
selection.extend(Selection::Waypoints { file_id });
assert_eq!(selection, Selection::Waypoints { file_id });
// segments of another track: replaced
let seg = TrackSegmentId::default();
let segments = |trk_id| Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids: [seg].into(),
};
let mut selection = segments(a);
selection.extend(segments(b));
assert_eq!(selection, segments(b));
}
#[test]
fn test_toggle() {
let file_id = FileId::default();
let (a, b) = (TrackId::default(), TrackId::default());
let tracks = |ids: &[TrackId]| Selection::Track {
file_id,
trk_ids: ids.iter().copied().collect(),
};
// same kind and place: toggled one by one
let mut selection = tracks(&[a]);
selection.toggle(tracks(&[b]));
assert_eq!(selection, tracks(&[a, b]));
selection.toggle(tracks(&[a]));
assert_eq!(selection, tracks(&[b]));
selection.toggle(tracks(&[a, b]));
assert_eq!(selection, tracks(&[a]));
// nothing left: empty
selection.toggle(tracks(&[a]));
assert_eq!(selection, Selection::Empty);
// another file, or another kind: replaced
let mut selection = tracks(&[a]);
selection.toggle(Selection::Waypoints { file_id });
assert_eq!(selection, Selection::Waypoints { file_id });
// the waypoints node toggles itself off
selection.toggle(Selection::Waypoints { file_id });
assert_eq!(selection, Selection::Empty);
// files
let (f1, f2) = (FileId::default(), FileId::default());
let files = |ids: &[FileId]| Selection::File {
file_ids: ids.iter().copied().collect(),
};
let mut selection = files(&[f1]);
selection.toggle(files(&[f2]));
assert_eq!(selection, files(&[f1, f2]));
selection.toggle(files(&[f1]));
assert_eq!(selection, files(&[f2]));
}
/// A file with 3 tracks of 2 segments each and 3 waypoints, with the ids of everything.
struct Tree {
files: StackEntry,
order: Vec<FileId>,
file: FileId,
tracks: Vec<TrackId>,
segments: Vec<Vec<TrackSegmentId>>,
waypoints: Vec<WaypointId>,
}
fn tree() -> Tree {
let mut file = File::default();
for _ in 0..3 {
file.trk.push(Track {
trkseg: vec![TrackSegment::default(), TrackSegment::default()],
..Default::default()
});
}
file.wpt.push(Rc::new(WaypointChunk {
wpt: vec![Waypoint::default(), Waypoint::default()],
..Default::default()
}));
file.wpt.push(Rc::new(WaypointChunk {
wpt: vec![Waypoint::default()],
..Default::default()
}));
let tree = Tree {
file: file.id,
tracks: file.trk.iter().map(|t| t.id).collect(),
segments: file
.trk
.iter()
.map(|t| t.trkseg.iter().map(|s| s.id).collect())
.collect(),
waypoints: file
.wpt
.iter()
.flat_map(|chunk| &chunk.wpt)
.map(|w| w.id)
.collect(),
order: vec![file.id],
files: StackEntry::from([(file.id, Rc::new(file))]),
};
tree
}
#[test]
fn test_all_at_level() {
let t = tree();
let other = FileId::default();
let order = [other, t.file];
let all = |selection: &Selection| selection.all_at_level(&t.files, &order);
// the files, in the order
let all_files = Selection::File {
file_ids: [other, t.file].into(),
};
assert_eq!(all(&Selection::Empty), Some(all_files.clone()));
assert_eq!(
all(&Selection::File {
file_ids: [t.file].into()
}),
Some(all_files)
);
// the siblings of the selected elements
assert_eq!(
all(&Selection::Track {
file_id: t.file,
trk_ids: [t.tracks[1]].into()
}),
Some(Selection::Track {
file_id: t.file,
trk_ids: t.tracks.iter().copied().collect()
})
);
assert_eq!(
all(&Selection::TrackSegment {
file_id: t.file,
trk_id: t.tracks[2],
trkseg_ids: [t.segments[2][0]].into()
}),
Some(Selection::TrackSegment {
file_id: t.file,
trk_id: t.tracks[2],
trkseg_ids: t.segments[2].iter().copied().collect()
})
);
assert_eq!(
all(&Selection::Waypoint {
file_id: t.file,
wpt_ids: [t.waypoints[0]].into()
}),
Some(Selection::Waypoint {
file_id: t.file,
wpt_ids: t.waypoints.iter().copied().collect()
})
);
// nothing to select
assert_eq!(all(&Selection::Waypoints { file_id: t.file }), None);
let unknown = FileId::default();
assert_eq!(
all(&Selection::Track {
file_id: unknown,
trk_ids: HashSet::new()
}),
None
);
}
#[test]
fn test_neighbour_of_tracks_stops_at_the_ends() {
let t = tree();
let tracks = |ids: &[TrackId]| Selection::Track {
file_id: t.file,
trk_ids: ids.iter().copied().collect(),
};
let next = |selection: &Selection, down| selection.neighbour(&t.files, &t.order, down);
assert_eq!(
next(&tracks(&[t.tracks[1]]), true),
Some(tracks(&[t.tracks[2]]))
);
assert_eq!(
next(&tracks(&[t.tracks[1]]), false),
Some(tracks(&[t.tracks[0]]))
);
assert_eq!(next(&tracks(&[t.tracks[2]]), true), None);
assert_eq!(next(&tracks(&[t.tracks[0]]), false), None);
// from the end of the selected range, whatever its holes
let range = tracks(&[t.tracks[0], t.tracks[1]]);
assert_eq!(next(&range, true), Some(tracks(&[t.tracks[2]])));
assert_eq!(next(&range, false), None);
let holes = tracks(&[t.tracks[0], t.tracks[2]]);
assert_eq!(next(&holes, true), None);
assert_eq!(next(&holes, false), None);
}
#[test]
fn test_neighbour_of_segments_and_waypoints() {
let t = tree();
let segments = |trk: usize, seg: usize| Selection::TrackSegment {
file_id: t.file,
trk_id: t.tracks[trk],
trkseg_ids: [t.segments[trk][seg]].into(),
};
let next = |selection: &Selection, down| selection.neighbour(&t.files, &t.order, down);
// the segments stay in their track
assert_eq!(next(&segments(1, 0), true), Some(segments(1, 1)));
assert_eq!(next(&segments(1, 1), true), None);
assert_eq!(next(&segments(1, 1), false), Some(segments(1, 0)));
assert_eq!(next(&segments(1, 0), false), None);
// the waypoints are in the order of the file, across its chunks
let waypoint = |i: usize| Selection::Waypoint {
file_id: t.file,
wpt_ids: [t.waypoints[i]].into(),
};
assert_eq!(next(&waypoint(1), true), Some(waypoint(2)));
assert_eq!(next(&waypoint(2), true), None);
assert_eq!(next(&waypoint(0), false), None);
assert_eq!(next(&waypoint(2), false), Some(waypoint(1)));
assert_eq!(next(&Selection::Waypoints { file_id: t.file }, true), None);
assert_eq!(next(&Selection::Empty, true), None);
}
#[test]
fn test_neighbour_of_files_wraps_around_and_skips_the_selected() {
let files = StackEntry::default();
let [a, b, c] = [FileId::default(), FileId::default(), FileId::default()];
let order = [a, b, c];
let select = |ids: &[FileId]| Selection::File {
file_ids: ids.iter().copied().collect(),
};
let next = |ids: &[FileId], down| select(ids).neighbour(&files, &order, down);
assert_eq!(next(&[a], true), Some(select(&[b])));
assert_eq!(next(&[c], true), Some(select(&[a])));
assert_eq!(next(&[a], false), Some(select(&[c])));
assert_eq!(next(&[b], false), Some(select(&[a])));
// from the end of the selection, skipping the files already selected
assert_eq!(next(&[a, b], true), Some(select(&[c])));
assert_eq!(next(&[a, b], false), Some(select(&[c])));
assert_eq!(next(&[c, a], true), Some(select(&[b])));
// everything is selected
assert_eq!(next(&[a, b, c], true), None);
// none of them is in the order
assert_eq!(next(&[FileId::default()], true), None);
}
#[test]
fn test_to_files() {
let (file_id, trk_id) = (FileId::default(), TrackId::default());
let file = Selection::File {
file_ids: [file_id].into(),
};
assert_eq!(Selection::Empty.to_files(), Selection::Empty);
assert_eq!(file.to_files(), file);
assert_eq!(
Selection::Track {
file_id,
trk_ids: [trk_id].into()
}
.to_files(),
file
);
assert_eq!(
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids: HashSet::new()
}
.to_files(),
file
);
assert_eq!(Selection::Waypoints { file_id }.to_files(), file);
assert_eq!(
Selection::Waypoint {
file_id,
wpt_ids: HashSet::new()
}
.to_files(),
file
);
let several = Selection::File {
file_ids: [file_id, FileId::default()].into(),
};
assert_eq!(several.to_files(), several);
}
} }
+694 -32
View File
@@ -14,7 +14,7 @@ use std::cell::RefCell;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use gpx_engine::{self as engine, Action, Command, Engine, FileId, LngLat, LngLatBounds}; use gpx_engine::{self as engine, Action, Command, Engine, FileId, LngLat, LngLatBounds};
use js_sys::{Array, Float64Array, Int32Array, Object, Reflect}; use js_sys::{Array, BigInt64Array, Float64Array, Object, Reflect, Uint8Array};
#[wasm_bindgen] #[wasm_bindgen]
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -30,6 +30,26 @@ pub enum CleanType {
Outside, Outside,
} }
/// How a selection combines with the current one: `Replace` it, `Add` to it, or `Toggle` the
/// elements (the ones already selected are removed).
#[wasm_bindgen]
#[derive(Clone, Copy)]
pub enum SelectMode {
Replace,
Add,
Toggle,
}
impl From<SelectMode> for engine::SelectMode {
fn from(m: SelectMode) -> Self {
match m {
SelectMode::Replace => Self::Replace,
SelectMode::Add => Self::Add,
SelectMode::Toggle => Self::Toggle,
}
}
}
impl From<MergeType> for engine::MergeType { impl From<MergeType> for engine::MergeType {
fn from(t: MergeType) -> Self { fn from(t: MergeType) -> Self {
match t { match t {
@@ -66,14 +86,18 @@ fn edit(command: Command) -> bool {
} }
/// Decodes concatenated 16-byte UUIDs. /// Decodes concatenated 16-byte UUIDs.
fn file_ids(bytes: &[u8]) -> Option<Vec<FileId>> { fn uuid_list(bytes: &[u8]) -> Option<Vec<uuid::Uuid>> {
let (chunks, rest) = bytes.as_chunks::<16>(); let (chunks, rest) = bytes.as_chunks::<16>();
rest.is_empty().then(|| { rest.is_empty()
chunks .then(|| chunks.iter().map(|c| uuid::Uuid::from_bytes(*c)).collect())
.iter() }
.map(|c| FileId(uuid::Uuid::from_bytes(*c)))
.collect() fn file_ids(bytes: &[u8]) -> Option<Vec<FileId>> {
}) uuid_list(bytes).map(|ids| ids.into_iter().map(FileId).collect())
}
fn parse_file_id(id: &str) -> Option<FileId> {
uuid::Uuid::parse_str(id).ok().map(FileId)
} }
fn same_len(a: &[f64], b: &[f64], c: &[f64]) -> bool { fn same_len(a: &[f64], b: &[f64], c: &[f64]) -> bool {
@@ -109,14 +133,84 @@ macro_rules! stats_getter {
stats_getter!(total_distance, Float64Array); stats_getter!(total_distance, Float64Array);
stats_getter!(moving_distance, Float64Array); stats_getter!(moving_distance, Float64Array);
stats_getter!(total_time, Int32Array); // Durations since the start of the selection, in milliseconds.
stats_getter!(moving_time, Int32Array); stats_getter!(total_time, BigInt64Array);
stats_getter!(moving_time, BigInt64Array);
stats_getter!(speed, Float64Array); stats_getter!(speed, Float64Array);
stats_getter!(elevation_gain, Float64Array); stats_getter!(elevation_gain, Float64Array);
stats_getter!(elevation_loss, Float64Array); stats_getter!(elevation_loss, Float64Array);
stats_getter!(slope, Float64Array); stats_getter!(slope, Float64Array);
stats_getter!(slope_segment_slope, Float64Array); stats_getter!(slope_segment_slope, Float64Array);
stats_getter!(slope_segment_distance, Float64Array); stats_getter!(slope_segment_distance, Float64Array);
stats_getter!(lng, Float64Array);
stats_getter!(lat, Float64Array);
stats_getter!(ele, Float64Array);
/// Timestamps in milliseconds since the epoch (`NO_TIME`, the smallest i64, when missing).
#[wasm_bindgen]
pub fn timestamps() -> BigInt64Array {
with_stats(|s| unsafe { BigInt64Array::view(&s.time) })
.unwrap_or_else(|| BigInt64Array::new_with_length(0))
}
/// Value of the timestamps of the trackpoints that have none.
#[wasm_bindgen]
pub fn no_time() -> i64 {
engine::NO_TIME
}
stats_getter!(hr, Float64Array);
stats_getter!(cad, Float64Array);
stats_getter!(atemp, Float64Array);
stats_getter!(power, Float64Array);
// Surface, highway, SAC scale and MTB scale of the trackpoints: 0 when unknown, else 1 + the index
// in `surfaces()`, `highways()`, `sac_scales()` or `mtb_scales()`.
stats_getter!(surface, Uint8Array);
stats_getter!(highway, Uint8Array);
stats_getter!(sac_scale, Uint8Array);
stats_getter!(mtb_scale, Uint8Array);
/// The names of the surfaces, in the order of their codes (see `surface`). The list only grows:
/// a name keeps its index.
#[wasm_bindgen]
pub fn surfaces() -> StringList {
with_engine(|e| array(e.categories().surface.names(), |name| name.as_str().into()))
.unwrap_or_default()
.unchecked_into()
}
/// The names of the highways, in the order of their codes (see `highway`).
#[wasm_bindgen]
pub fn highways() -> StringList {
with_engine(|e| array(e.categories().highway.names(), |name| name.as_str().into()))
.unwrap_or_default()
.unchecked_into()
}
/// The names of the SAC scales, in the order of their codes (see `sac_scale`).
#[wasm_bindgen]
pub fn sac_scales() -> StringList {
with_engine(|e| {
array(e.categories().sac_scale.names(), |name| {
name.as_str().into()
})
})
.unwrap_or_default()
.unchecked_into()
}
/// The names of the MTB scales, in the order of their codes (see `mtb_scale`).
#[wasm_bindgen]
pub fn mtb_scales() -> StringList {
with_engine(|e| {
array(e.categories().mtb_scale.names(), |name| {
name.as_str().into()
})
})
.unwrap_or_default()
.unchecked_into()
}
// File commands // File commands
@@ -125,9 +219,26 @@ pub fn new_file(name: &str) -> bool {
edit(Command::New(engine::New { name })) edit(Command::New(engine::New { name }))
} }
/// Loads files as a single command (one undo step). The files are in `data`, one after the
/// other, `lengths` being the number of bytes of each one. `names`: the name of each file when
/// its data has none (the name on disk, without extension). The files that cannot be read are
/// skipped, the first one that was read is selected.
#[wasm_bindgen] #[wasm_bindgen]
pub fn load_file(data: &[u8]) -> bool { pub fn load_files(data: &[u8], lengths: &[u32], names: Vec<String>) -> bool {
edit(Command::Load(engine::Load { data })) if lengths.len() != names.len() {
return false;
}
let mut files = Vec::with_capacity(names.len());
let mut offset = 0;
for (length, name) in lengths.iter().zip(&names) {
let end = offset + *length as usize;
let Some(data) = data.get(offset..end) else {
return false;
};
files.push(engine::Load { data, name });
offset = end;
}
edit(Command::LoadFiles(engine::LoadFiles { files }))
} }
#[wasm_bindgen] #[wasm_bindgen]
@@ -135,9 +246,11 @@ pub fn duplicate() -> bool {
edit(Command::Duplicate(engine::Duplicate)) edit(Command::Duplicate(engine::Duplicate))
} }
/// 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.
#[wasm_bindgen] #[wasm_bindgen]
pub fn delete() -> bool { pub fn delete(whole_files: bool) -> bool {
edit(Command::Delete(engine::Delete)) edit(Command::Delete(engine::Delete { whole_files }))
} }
#[wasm_bindgen] #[wasm_bindgen]
@@ -219,13 +332,68 @@ pub fn new_waypoint(
})) }))
} }
/// Moves a waypoint of a file, whatever is selected.
#[wasm_bindgen] #[wasm_bindgen]
pub fn move_waypoint(lng: f64, lat: f64, ele: f64) -> bool { pub fn move_waypoint(file_id: &str, waypoint_id: &str, lng: f64, lat: f64, ele: f64) -> bool {
edit(Command::MoveWaypoint(engine::MoveWaypoint { 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, lng,
lat, lat,
ele, 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<engine::WaypointId> {
uuid::Uuid::parse_str(id).ok().map(engine::WaypointId)
}
/// Changes a waypoint of a file, whatever is selected: its name, description, icon, link, position
/// and elevation. The strings that are empty remove the field.
#[wasm_bindgen]
#[allow(clippy::too_many_arguments)]
pub fn update_waypoint(
file_id: &str,
waypoint_id: &str,
lng: f64,
lat: f64,
ele: f64,
name: &str,
desc: &str,
icon: &str,
link: &str,
) -> bool {
match (parse_file_id(file_id), parse_waypoint_id(waypoint_id)) {
(Some(file_id), Some(waypoint_id)) => edit(Command::EditWaypoint(engine::EditWaypoint {
file_id,
waypoint_id,
lng,
lat,
ele,
name,
desc,
icon,
link,
})),
_ => false,
}
} }
#[wasm_bindgen] #[wasm_bindgen]
@@ -299,25 +467,245 @@ pub fn redo() -> bool {
execute(Action::Redo) execute(Action::Redo)
} }
#[wasm_bindgen]
pub fn can_undo() -> bool {
with_engine(|e| e.can_undo()).unwrap_or(false)
}
#[wasm_bindgen]
pub fn can_redo() -> bool {
with_engine(|e| e.can_redo()).unwrap_or(false)
}
// Clipboard
/// Puts the selected elements in the clipboard. Nothing happens when nothing is selected.
#[wasm_bindgen]
pub fn copy() -> bool {
execute(Action::Copy)
}
/// Like `copy`, but the elements are moved when they are pasted.
#[wasm_bindgen]
pub fn cut() -> bool {
execute(Action::Cut)
}
/// Pastes the clipboard according to the selection (see `can_paste`). What is pasted is always
/// the elements as they were when they were copied or cut, even if they were changed or deleted
/// since. It can be pasted several times: what was copied is copied again, what was cut is moved
/// the first time (and the clipboard is not cut after that), then it is copied.
#[wasm_bindgen]
pub fn paste() -> bool {
edit(Command::Paste(engine::Paste))
}
/// Whether the clipboard can be pasted with the current selection.
#[wasm_bindgen]
pub fn can_paste() -> bool {
with_engine(|e| e.can_paste()).unwrap_or(false)
}
// Moving elements (drag and drop)
//
// Unlike the other functions, the arguments are objects, as they are in the `Selection` and
// `MoveTarget` types: it happens once per drop, and they are much clearer than a list of ids.
fn property(object: &JsValue, key: &str) -> Option<JsValue> {
Reflect::get(object, &key.into())
.ok()
.filter(|value| !value.is_undefined())
}
fn uuid_property(object: &JsValue, key: &str) -> Option<uuid::Uuid> {
uuid::Uuid::parse_str(&property(object, key)?.as_string()?).ok()
}
fn uuids_property(object: &JsValue, key: &str) -> Option<Vec<uuid::Uuid>> {
Array::from(&property(object, key)?)
.iter()
.map(|id| uuid::Uuid::parse_str(&id.as_string()?).ok())
.collect()
}
/// The inverse of `selection_object`.
fn parse_selection(value: &JsValue) -> Option<engine::Selection> {
use engine::Selection as S;
let file_id = || uuid_property(value, "fileId").map(FileId);
Some(match property(value, "type")?.as_string()?.as_str() {
"empty" => S::Empty,
"file" => S::File {
file_ids: uuids_property(value, "fileIds")?
.into_iter()
.map(FileId)
.collect(),
},
"track" => S::Track {
file_id: file_id()?,
trk_ids: uuids_property(value, "trackIds")?
.into_iter()
.map(engine::TrackId)
.collect(),
},
"segment" => S::TrackSegment {
file_id: file_id()?,
trk_id: engine::TrackId(uuid_property(value, "trackId")?),
trkseg_ids: uuids_property(value, "segmentIds")?
.into_iter()
.map(engine::TrackSegmentId)
.collect(),
},
"waypoints" => S::Waypoints {
file_id: file_id()?,
},
"waypoint" => S::Waypoint {
file_id: file_id()?,
wpt_ids: uuids_property(value, "waypointIds")?
.into_iter()
.map(engine::WaypointId)
.collect(),
},
_ => return None,
})
}
fn parse_move_target(value: &JsValue) -> Option<engine::MoveTarget> {
use engine::MoveTarget as T;
// a negative index is the start, an infinite one is the end
let index = property(value, "index")?.as_f64()?.max(0.0) as usize;
let file_id = || uuid_property(value, "fileId").map(FileId);
Some(match property(value, "type")?.as_string()?.as_str() {
"files" => T::Files { index },
"tracks" => T::Tracks {
file_id: file_id()?,
index,
},
"segments" => T::Segments {
file_id: file_id()?,
trk_id: engine::TrackId(uuid_property(value, "trackId")?),
index,
},
"waypoints" => T::Waypoints {
file_id: file_id()?,
index,
},
_ => return None,
})
}
/// Moves elements (given as a `Selection`) to a place of the file tree, keeping their ids, like
/// dragging and dropping them does. The moved elements are selected.
///
/// Files can only go among the files; tracks among the files (each becomes a file) or the tracks
/// of a file; segments among the files (each becomes a file), the tracks of a file (each becomes
/// a track) or the segments of a track; waypoints, or all the waypoints of a file, among the
/// waypoints of a file.
#[wasm_bindgen]
pub fn move_elements(what: Selection, to: MoveTarget) -> bool {
parse_selection(&what)
.zip(parse_move_target(&to))
.is_some_and(|(what, to)| edit(Command::Move(engine::Move { what, to })))
}
// Selection // Selection
/// `file_ids_bytes`: concatenated 16-byte UUIDs. /// Selects files. `file_ids_bytes`: concatenated 16-byte UUIDs. Unknown files are ignored, and
/// selecting nothing deselects everything. See `SelectMode`.
#[wasm_bindgen] #[wasm_bindgen]
pub fn select(file_ids_bytes: &[u8]) -> bool { pub fn select(file_ids_bytes: &[u8], mode: SelectMode) -> bool {
file_ids(file_ids_bytes).is_some_and(|file_ids| execute(Action::Select { file_ids })) select_elements(
} file_ids(file_ids_bytes).map(|ids| engine::Selection::File {
file_ids: ids.into_iter().collect(),
/// `file_ids_bytes`: concatenated 16-byte UUIDs. }),
#[wasm_bindgen] mode,
pub fn add_select(file_ids_bytes: &[u8]) -> bool { )
file_ids(file_ids_bytes).is_some_and(|file_ids| execute(Action::AddSelect { file_ids }))
} }
/// 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 when nothing is selected.
#[wasm_bindgen] #[wasm_bindgen]
pub fn select_all() -> bool { pub fn select_all() -> bool {
execute(Action::SelectAll) execute(Action::SelectAll)
} }
/// Moves the selection to the next (`down`) or previous element of the same kind, like the arrow
/// keys do in the file list. With `add` (shift + arrow), the element is added to the selection,
/// otherwise it replaces it.
#[wasm_bindgen]
pub fn arrow_select(down: bool, add: bool) -> bool {
execute(Action::ArrowSelect { down, add })
}
// The elements below cross as the id of their file (UUID string) and, like the files, as
// concatenated 16-byte UUIDs. What does not exist is ignored. See `SelectMode`: elements are merged
// 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.
fn select_elements(selection: Option<engine::Selection>, mode: SelectMode) -> bool {
selection.is_some_and(|selection| {
execute(Action::Select {
selection,
mode: mode.into(),
})
})
}
#[wasm_bindgen]
pub fn select_tracks(file_id: &str, track_ids_bytes: &[u8], mode: SelectMode) -> bool {
select_elements(
parse_file_id(file_id)
.zip(uuid_list(track_ids_bytes))
.map(|(file_id, ids)| engine::Selection::Track {
file_id,
trk_ids: ids.into_iter().map(engine::TrackId).collect(),
}),
mode,
)
}
#[wasm_bindgen]
pub fn select_segments(
file_id: &str,
track_id: &str,
segment_ids_bytes: &[u8],
mode: SelectMode,
) -> bool {
select_elements(
parse_file_id(file_id)
.zip(uuid::Uuid::parse_str(track_id).ok())
.zip(uuid_list(segment_ids_bytes))
.map(|((file_id, trk_id), ids)| engine::Selection::TrackSegment {
file_id,
trk_id: engine::TrackId(trk_id),
trkseg_ids: ids.into_iter().map(engine::TrackSegmentId).collect(),
}),
mode,
)
}
/// Selects the node standing for all the waypoints of a file.
#[wasm_bindgen]
pub fn select_waypoint_group(file_id: &str, mode: SelectMode) -> bool {
select_elements(
parse_file_id(file_id).map(|file_id| engine::Selection::Waypoints { file_id }),
mode,
)
}
#[wasm_bindgen]
pub fn select_waypoints(file_id: &str, waypoint_ids_bytes: &[u8], mode: SelectMode) -> bool {
select_elements(
parse_file_id(file_id)
.zip(uuid_list(waypoint_ids_bytes))
.map(|(file_id, ids)| engine::Selection::Waypoint {
file_id,
wpt_ids: ids.into_iter().map(engine::WaypointId).collect(),
}),
mode,
)
}
// File order and structures // File order and structures
// //
// The files are described by one ordered list of ids and one structure object per file, so that // The files are described by one ordered list of ids and one structure object per file, so that
@@ -335,9 +723,47 @@ export type Selection =
| { type: 'segment'; fileId: string; trackId: string; segmentIds: string[] } | { type: 'segment'; fileId: string; trackId: string; segmentIds: string[] }
| { type: 'waypoints'; fileId: string } | { type: 'waypoints'; fileId: string }
| { type: 'waypoint'; fileId: string; waypointIds: 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.
*/
export type MoveTarget =
| { type: 'files'; index: number }
| { 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';
ids: string[];
cut: boolean;
}
export interface FilesUpdate { export interface FilesUpdate {
orderChanged: boolean; orderChanged: boolean;
selectionChanged: boolean; selectionChanged: boolean;
clipboardChanged: boolean;
/** Files to read the structure of. */ /** Files to read the structure of. */
added: string[]; added: string[];
/** Files whose structure changed: reread it. */ /** Files whose structure changed: reread it. */
@@ -348,14 +774,47 @@ export interface FilesUpdate {
export interface FileStructure { export interface FileStructure {
id: string; id: string;
name: string; name: string;
desc?: string;
tracks: TrackNode[]; tracks: TrackNode[];
waypoints: WaypointNode[]; waypoints: WaypointNode[];
/** Changes when the waypoints of the file change: refetch their coordinates. */ /** Changes when the waypoints of the file change: refetch their coordinates. */
waypointsRev: string; waypointsRev: string;
} }
/**
* Global statistics of a file, of the selection, or of a part of it. The optional fields are
* absent when there is no data for them (no timestamps, no heart rate...).
*/
export interface GlobalStatistics {
/** km */
totalDistance: number;
movingDistance?: number;
/** seconds */
totalTime?: number;
movingTime?: number;
elevationGain: number;
elevationLoss: number;
/** ms since epoch */
startTime?: number;
endTime?: number;
/** km/h */
totalSpeed?: number;
movingSpeed?: number;
/** Average and number of trackpoints having the measure. */
hr?: { avg: number; count: number };
cad?: { avg: number; count: number };
atemp?: { avg: number; count: number };
power?: { avg: number; count: number };
/** Absent when there are no trackpoints. */
bounds?: { west: number; south: number; east: number; north: number };
}
export interface TrackNode { export interface TrackNode {
id: string; id: string;
name?: string; name?: string;
desc?: string;
/** Style of the track, only present when the file defines it. */
color?: string;
opacity?: number;
width?: number;
segments: SegmentNode[]; segments: SegmentNode[];
} }
export interface SegmentNode { export interface SegmentNode {
@@ -368,6 +827,7 @@ export interface SegmentNode {
export interface WaypointNode { export interface WaypointNode {
id: string; id: string;
name?: string; name?: string;
sym?: string;
} }
"#; "#;
@@ -375,12 +835,24 @@ export interface WaypointNode {
extern "C" { extern "C" {
#[wasm_bindgen(typescript_type = "Selection")] #[wasm_bindgen(typescript_type = "Selection")]
pub 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")]
pub type Clipboard;
#[wasm_bindgen(typescript_type = "FilesUpdate")] #[wasm_bindgen(typescript_type = "FilesUpdate")]
pub type FilesUpdate; pub type FilesUpdate;
#[wasm_bindgen(typescript_type = "FileStructure | undefined")] #[wasm_bindgen(typescript_type = "FileStructure | undefined")]
pub type FileStructure; pub type FileStructure;
#[wasm_bindgen(typescript_type = "GlobalStatistics | undefined")]
pub type GlobalStatistics;
#[wasm_bindgen(typescript_type = "string[]")] #[wasm_bindgen(typescript_type = "string[]")]
pub type FileOrder; pub type FileOrder;
#[wasm_bindgen(typescript_type = "string[]")]
pub type StringList;
} }
fn set(object: &Object, key: &str, value: impl Into<JsValue>) { fn set(object: &Object, key: &str, value: impl Into<JsValue>) {
@@ -406,11 +878,26 @@ fn named_node(id: uuid::Uuid, name: Option<&str>) -> Object {
fn structure_object(file: &engine::FileStructure) -> Object { fn structure_object(file: &engine::FileStructure) -> Object {
let node = named_node(file.id.0, Some(&file.name)); let node = named_node(file.id.0, Some(&file.name));
if let Some(desc) = &file.desc {
set(&node, "desc", desc.as_str());
}
set( set(
&node, &node,
"tracks", "tracks",
array(&file.tracks, |trk| { array(&file.tracks, |trk| {
let node = named_node(trk.id.0, trk.name.as_deref()); let node = named_node(trk.id.0, trk.name.as_deref());
if let Some(desc) = &trk.desc {
set(&node, "desc", desc.as_str());
}
if let Some(color) = &trk.color {
set(&node, "color", color.as_str());
}
if let Some(opacity) = trk.opacity {
set(&node, "opacity", opacity);
}
if let Some(width) = trk.width {
set(&node, "width", width);
}
set( set(
&node, &node,
"segments", "segments",
@@ -428,7 +915,11 @@ fn structure_object(file: &engine::FileStructure) -> Object {
&node, &node,
"waypoints", "waypoints",
array(&file.waypoints, |wpt| { array(&file.waypoints, |wpt| {
named_node(wpt.id.0, wpt.name.as_deref()).into() let node = named_node(wpt.id.0, wpt.name.as_deref());
if let Some(sym) = &wpt.sym {
set(&node, "sym", sym.as_str());
}
node.into()
}), }),
); );
set(&node, "waypointsRev", file.wpt_rev_id.0.to_string()); set(&node, "waypointsRev", file.wpt_rev_id.0.to_string());
@@ -458,6 +949,77 @@ pub fn file_structure(file_id: &str) -> FileStructure {
structure.unchecked_into() structure.unchecked_into()
} }
fn statistics_object(stats: &engine::GlobalStatistics) -> GlobalStatistics {
let object = Object::new();
let optional = |key: &str, value: Option<f64>| {
if let Some(value) = value {
set(&object, key, value);
}
};
let seconds = |ms: Option<i64>| ms.map(|ms| ms as f64 / 1000.0);
let average = |key: &str, average: &engine::Average| {
if let Some(avg) = average.avg() {
let value = Object::new();
set(&value, "avg", avg);
set(&value, "count", f64::from(average.count));
set(&object, key, value);
}
};
set(&object, "totalDistance", stats.total_distance);
optional("movingDistance", stats.moving_distance);
optional("totalTime", seconds(stats.total_time));
optional("movingTime", seconds(stats.moving_time));
set(&object, "elevationGain", stats.elevation_gain);
set(&object, "elevationLoss", stats.elevation_loss);
optional("startTime", stats.start_time.map(|t| t as f64));
optional("endTime", stats.end_time.map(|t| t as f64));
optional("totalSpeed", stats.total_speed());
optional("movingSpeed", stats.moving_speed());
average("hr", &stats.hr);
average("cad", &stats.cad);
average("atemp", &stats.atemp);
average("power", &stats.power);
let (sw, ne) = (&stats.bounds.sw, &stats.bounds.ne);
if sw.lng <= ne.lng && sw.lat <= ne.lat {
let bounds = Object::new();
set(&bounds, "west", sw.lng);
set(&bounds, "south", sw.lat);
set(&bounds, "east", ne.lng);
set(&bounds, "north", ne.lat);
set(&object, "bounds", bounds);
}
object.unchecked_into()
}
/// Global statistics of a file, `undefined` if the id is unknown.
#[wasm_bindgen]
pub fn file_statistics(file_id: &str) -> GlobalStatistics {
uuid::Uuid::parse_str(file_id)
.ok()
.and_then(|id| with_engine(|e| e.file_statistics(&FileId(id))).flatten())
.map_or(JsValue::UNDEFINED.unchecked_into(), |stats| {
statistics_object(&stats)
})
}
/// Global statistics of the whole selection (the arrays of the statistics buffers cover it).
#[wasm_bindgen]
pub fn selection_statistics() -> GlobalStatistics {
with_stats(|buffer| statistics_object(&buffer.global))
.unwrap_or_else(|| JsValue::UNDEFINED.unchecked_into())
}
/// Global statistics of the trackpoints from `start` to `end` (both included) of the selection,
/// as indexed by the statistics buffers. `undefined` when the range is not in the selection.
#[wasm_bindgen]
pub fn slice_statistics(start: usize, end: usize) -> GlobalStatistics {
with_stats(|buffer| buffer.slice(start, end))
.flatten()
.map_or(JsValue::UNDEFINED.unchecked_into(), |stats| {
statistics_object(&stats)
})
}
fn uuids<T>(items: impl IntoIterator<Item = T>, uuid: impl Fn(T) -> uuid::Uuid) -> Array { fn uuids<T>(items: impl IntoIterator<Item = T>, uuid: impl Fn(T) -> uuid::Uuid) -> Array {
items items
.into_iter() .into_iter()
@@ -465,12 +1027,10 @@ fn uuids<T>(items: impl IntoIterator<Item = T>, uuid: impl Fn(T) -> uuid::Uuid)
.collect() .collect()
} }
/// The current selection (ids are UUID strings). fn selection_object(selection: &engine::Selection) -> Object {
#[wasm_bindgen]
pub fn selection() -> Selection {
use engine::Selection as S; use engine::Selection as S;
let object = Object::new(); let object = Object::new();
with_engine(|e| match e.selection() { match selection {
S::Empty => set(&object, "type", "empty"), S::Empty => set(&object, "type", "empty"),
S::File { file_ids } => { S::File { file_ids } => {
set(&object, "type", "file"); set(&object, "type", "file");
@@ -500,8 +1060,109 @@ pub fn selection() -> Selection {
set(&object, "fileId", file_id.0.to_string()); set(&object, "fileId", file_id.0.to_string());
set(&object, "waypointIds", uuids(wpt_ids, |id| id.0)); set(&object, "waypointIds", uuids(wpt_ids, |id| id.0));
} }
}); }
object.unchecked_into() object
}
/// The current selection (ids are UUID strings).
#[wasm_bindgen]
pub fn selection() -> Selection {
with_engine(|e| selection_object(e.selection()))
.unwrap_or_default()
.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]
pub fn clipboard() -> Clipboard {
with_engine(|e| {
e.clipboard().map_or(JsValue::UNDEFINED, |clipboard| {
let object = Object::new();
let (kind, ids) = match clipboard.content.ids() {
engine::ClipboardIds::Files(ids) => ("files", uuids(ids, |id| id.0)),
engine::ClipboardIds::Tracks(ids) => ("tracks", uuids(ids, |id| id.0)),
engine::ClipboardIds::Segments(ids) => ("segments", uuids(ids, |id| id.0)),
engine::ClipboardIds::Waypoints(ids) => ("waypoints", uuids(ids, |id| id.0)),
};
set(&object, "type", kind);
set(&object, "ids", ids);
set(&object, "cut", clipboard.cut);
object.into()
})
})
.unwrap_or(JsValue::UNDEFINED)
.unchecked_into()
} }
/// What the last action changed. Read it right after each action. /// What the last action changed. Read it right after each action.
@@ -512,6 +1173,7 @@ pub fn last_update() -> FilesUpdate {
let diff = e.last_diff().cloned().unwrap_or_default(); let diff = e.last_diff().cloned().unwrap_or_default();
set(&update, "orderChanged", e.order_changed()); set(&update, "orderChanged", e.order_changed());
set(&update, "selectionChanged", e.selection_changed()); set(&update, "selectionChanged", e.selection_changed());
set(&update, "clipboardChanged", e.clipboard_changed());
set(&update, "added", ids(&diff.added)); set(&update, "added", ids(&diff.added));
set(&update, "modified", ids(&diff.modified)); set(&update, "modified", ids(&diff.modified));
set(&update, "removed", ids(&diff.removed)); set(&update, "removed", ids(&diff.removed));
+1
View File
@@ -9,6 +9,7 @@
"postbuild": "npx tsx src/lib/scripts/sitemap.ts", "postbuild": "npx tsx src/lib/scripts/sitemap.ts",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "node --import tsx --test \"src/**/*.test.ts\"",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore && eslint .", "lint": "prettier --check . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore && eslint .",
"format": "prettier --write . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore" "format": "prettier --write . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore"
+49
View File
@@ -0,0 +1,49 @@
import { readable } from 'svelte/store';
import { engine, FileStateCollectionObserver, type FileState, type Selection } from '$lib/engine';
import { isSelectionHidden } from '$lib/selection-helpers';
/**
* Whether all the selected elements are hidden. It follows the selection and, through the
* observer of the files, the state of every file (hiding, editing).
*/
export const allHidden = readable(false, (set) => {
let selection: Selection = { type: 'empty' };
const states = new Map<string, FileState>();
const unsubscribes = new Map<string, () => void>();
const update = () => set(isSelectionHidden(selection, states));
const unsubscribeSelection = engine.selection.subscribe(($selection) => {
selection = $selection;
update();
});
const observer = new FileStateCollectionObserver(
(files) => {
files.forEach((store, fileId) => {
unsubscribes.set(
fileId,
store.subscribe(($file) => {
states.set(fileId, $file);
update();
})
);
});
},
(fileId) => {
unsubscribes.get(fileId)?.();
unsubscribes.delete(fileId);
states.delete(fileId);
update();
},
() => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
unsubscribes.clear();
states.clear();
}
);
return () => {
unsubscribeSelection();
observer.destroy();
};
});
+29
View File
@@ -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);
});
});
+38
View File
@@ -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<number>,
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;
}
+19 -15
View File
@@ -6,8 +6,9 @@
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte'; import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
import type { Readable } from 'svelte/store'; import type { Readable } from 'svelte/store';
import type { SelectionStatistics } from '$lib/engine';
import type { SlicedStatistics } from '$lib/logic/selection-statistics';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
const { velocityUnits } = settings; const { velocityUnits } = settings;
@@ -16,18 +17,17 @@
let panelWidth: number = $state(0); let panelWidth: number = $state(0);
let { let {
gpxStatistics, statistics: selectionStatistics,
slicedGPXStatistics, slicedStatistics,
orientation, orientation,
}: { }: {
gpxStatistics: Readable<GPXStatisticsGroup>; statistics: Readable<SelectionStatistics>;
slicedGPXStatistics: Readable<[GPXGlobalStatistics, number, number] | undefined>; slicedStatistics: Readable<SlicedStatistics | undefined>;
orientation: 'horizontal' | 'vertical'; orientation: 'horizontal' | 'vertical';
} = $props(); } = $props();
let statistics = $derived( // the highlighted part of the selection, or all of it
$slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics.global let statistics = $derived($slicedStatistics?.global ?? $selectionStatistics.global);
);
</script> </script>
<Card.Root <Card.Root
@@ -46,15 +46,15 @@
<Tooltip label={i18n._('quantities.distance')}> <Tooltip label={i18n._('quantities.distance')}>
<span class="flex flex-row items-center"> <span class="flex flex-row items-center">
<Ruler size="16" class="mr-1" /> <Ruler size="16" class="mr-1" />
<WithUnits value={statistics.distance.total} type="distance" /> <WithUnits value={statistics.totalDistance} type="distance" />
</span> </span>
</Tooltip> </Tooltip>
<Tooltip label={i18n._('quantities.elevation_gain_loss')}> <Tooltip label={i18n._('quantities.elevation_gain_loss')}>
<span class="flex flex-row items-center"> <span class="flex flex-row items-center">
<MoveUpRight size="16" class="mr-1" /> <MoveUpRight size="16" class="mr-1" />
<WithUnits value={statistics.elevation.gain} type="elevation" /> <WithUnits value={statistics.elevationGain} type="elevation" />
<MoveDownRight size="16" class="mx-1" /> <MoveDownRight size="16" class="mx-1" />
<WithUnits value={statistics.elevation.loss} type="elevation" /> <WithUnits value={statistics.elevationLoss} type="elevation" />
</span> </span>
</Tooltip> </Tooltip>
{#if panelHeight > 120 || (orientation === 'horizontal' && panelWidth > 450)} {#if panelHeight > 120 || (orientation === 'horizontal' && panelWidth > 450)}
@@ -67,9 +67,13 @@
> >
<span class="flex flex-row items-center"> <span class="flex flex-row items-center">
<Zap size="16" class="mr-1" /> <Zap size="16" class="mr-1" />
<WithUnits value={statistics.speed.moving} type="speed" showUnits={false} /> <WithUnits
value={statistics.movingSpeed ?? 0}
type="speed"
showUnits={false}
/>
<span class="mx-1">/</span> <span class="mx-1">/</span>
<WithUnits value={statistics.speed.total} type="speed" /> <WithUnits value={statistics.totalSpeed ?? 0} type="speed" />
</span> </span>
</Tooltip> </Tooltip>
{/if} {/if}
@@ -81,9 +85,9 @@
> >
<span class="flex flex-row items-center"> <span class="flex flex-row items-center">
<Timer size="16" class="mr-1" /> <Timer size="16" class="mr-1" />
<WithUnits value={statistics.time.moving} type="time" /> <WithUnits value={statistics.movingTime ?? 0} type="time" />
<span class="mx-1">/</span> <span class="mx-1">/</span>
<WithUnits value={statistics.time.total} type="time" /> <WithUnits value={statistics.totalTime ?? 0} type="time" />
</span> </span>
</Tooltip> </Tooltip>
{/if} {/if}
+52 -125
View File
@@ -53,27 +53,19 @@
import { anySelectedLayer } from '$lib/components/map/layer-control/utils'; import { anySelectedLayer } from '$lib/components/map/layer-control/utils';
import { defaultOverlays } from '$lib/assets/layers'; import { defaultOverlays } from '$lib/assets/layers';
import LayerControlSettings from '$lib/components/map/layer-control/LayerControlSettings.svelte'; import LayerControlSettings from '$lib/components/map/layer-control/LayerControlSettings.svelte';
import { ListFileItem, ListTrackItem } from '$lib/components/file-list/file-list';
import Export from '$lib/components/export/Export.svelte'; import Export from '$lib/components/export/Export.svelte';
import { mode, setMode } from 'mode-watcher'; import { mode, setMode } from 'mode-watcher';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { languages } from '$lib/languages'; import { languages } from '$lib/languages';
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { import { createFile, loadFiles, triggerFileInput } from '$lib/logic/file-actions';
createFile, import { engine } from '$lib/engine';
fileActions, import { allHidden } from '$lib/all-hidden';
loadFiles, import { selectionSize } from '$lib/selection-helpers';
pasteSelection, // TODO centering on the selection still works on the previous implementation
triggerFileInput,
} from '$lib/logic/file-actions';
import { fileStateCollection } from '$lib/logic/file-state';
import { fileActionManager } from '$lib/logic/file-action-manager';
import { copied, selection } from '$lib/logic/selection';
import { allHidden } from '$lib/logic/hidden';
import { boundsManager } from '$lib/logic/bounds'; import { boundsManager } from '$lib/logic/bounds';
import { tick, onMount } from 'svelte'; import { onMount } from 'svelte';
import { allowedPastes } from '$lib/components/file-list/sortable-file-list';
const { const {
distanceUnits, distanceUnits,
@@ -91,8 +83,12 @@
routing, routing,
} = settings; } = settings;
const canUndo = fileActionManager.canUndo; const { files, selection, canUndo, canRedo, canPaste } = engine;
const canRedo = fileActionManager.canRedo;
let selectionCount = $derived(selectionSize($selection));
let noFiles = $derived($files.size === 0);
// metadata and style can be edited for files and tracks only
let filesOrTracksSelected = $derived($selection.type === 'file' || $selection.type === 'track');
function switchBasemaps() { function switchBasemaps() {
[$currentBasemap, $previousBasemap] = [$previousBasemap, $currentBasemap]; [$currentBasemap, $previousBasemap] = [$previousBasemap, $currentBasemap];
@@ -153,27 +149,21 @@
<Shortcut key="O" ctrl={true} /> <Shortcut key="O" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item <Menubar.Item onclick={() => engine.duplicate()} disabled={selectionCount == 0}>
onclick={fileActions.duplicateSelection}
disabled={$selection.size == 0}
>
<Copy size="16" /> <Copy size="16" />
{i18n._('menu.duplicate')} {i18n._('menu.duplicate')}
<Shortcut key="D" ctrl={true} /> <Shortcut key="D" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item <Menubar.Item
onclick={() => tick().then(fileActions.deleteSelectedFiles)} onclick={() => engine.delete(true)}
disabled={$selection.size == 0} disabled={selectionCount == 0}
> >
<FileX size="16" /> <FileX size="16" />
{i18n._('menu.delete')} {i18n._('menu.delete')}
<Shortcut key="⌫" ctrl={true} /> <Shortcut key="⌫" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Item <Menubar.Item onclick={() => engine.deleteAll()} disabled={noFiles}>
onclick={fileActions.deleteAllFiles}
disabled={fileStateCollection.size == 0}
>
<FileX size="16" /> <FileX size="16" />
{i18n._('menu.delete_all')} {i18n._('menu.delete_all')}
<Shortcut key="⌫" ctrl={true} shift={true} /> <Shortcut key="⌫" ctrl={true} shift={true} />
@@ -181,7 +171,7 @@
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item <Menubar.Item
onclick={() => (exportState.current = ExportState.SELECTION)} onclick={() => (exportState.current = ExportState.SELECTION)}
disabled={$selection.size == 0} disabled={selectionCount == 0}
> >
<Download size="16" /> <Download size="16" />
{i18n._('menu.export')} {i18n._('menu.export')}
@@ -189,7 +179,7 @@
</Menubar.Item> </Menubar.Item>
<Menubar.Item <Menubar.Item
onclick={() => (exportState.current = ExportState.ALL)} onclick={() => (exportState.current = ExportState.ALL)}
disabled={fileStateCollection.size == 0} disabled={noFiles}
> >
<Download size="16" /> <Download size="16" />
{i18n._('menu.export_all')} {i18n._('menu.export_all')}
@@ -203,26 +193,19 @@
<span class="hidden md:block">{i18n._('menu.edit')}</span> <span class="hidden md:block">{i18n._('menu.edit')}</span>
</Menubar.Trigger> </Menubar.Trigger>
<Menubar.Content class="border-none"> <Menubar.Content class="border-none">
<Menubar.Item onclick={() => fileActionManager.undo()} disabled={!$canUndo}> <Menubar.Item onclick={() => engine.undo()} disabled={!$canUndo}>
<Undo2 size="16" /> <Undo2 size="16" />
{i18n._('menu.undo')} {i18n._('menu.undo')}
<Shortcut key="Z" ctrl={true} /> <Shortcut key="Z" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Item onclick={() => fileActionManager.redo()} disabled={!$canRedo}> <Menubar.Item onclick={() => engine.redo()} disabled={!$canRedo}>
<Redo2 size="16" /> <Redo2 size="16" />
{i18n._('menu.redo')} {i18n._('menu.redo')}
<Shortcut key="Z" ctrl={true} shift={true} /> <Shortcut key="Z" ctrl={true} shift={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item <Menubar.Item
disabled={$selection.size !== 1 || disabled={selectionCount !== 1 || !filesOrTracksSelected}
!$selection
.getSelected()
.every(
(item) =>
item instanceof ListFileItem ||
item instanceof ListTrackItem
)}
onclick={() => (editMetadata.current = true)} onclick={() => (editMetadata.current = true)}
> >
<Info size="16" /> <Info size="16" />
@@ -230,28 +213,15 @@
<Shortcut key="I" ctrl={true} /> <Shortcut key="I" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Item <Menubar.Item
disabled={$selection.size === 0 || disabled={selectionCount === 0 || !filesOrTracksSelected}
!$selection
.getSelected()
.every(
(item) =>
item instanceof ListFileItem ||
item instanceof ListTrackItem
)}
onclick={() => (editStyle.current = true)} onclick={() => (editStyle.current = true)}
> >
<PaintBucket size="16" /> <PaintBucket size="16" />
{i18n._('menu.style.button')} {i18n._('menu.style.button')}
</Menubar.Item> </Menubar.Item>
<Menubar.Item <Menubar.Item
onclick={() => { onclick={() => engine.setSelectionHidden(!$allHidden)}
if ($allHidden) { disabled={selectionCount == 0}
fileActions.setHiddenToSelection(false);
} else {
fileActions.setHiddenToSelection(true);
}
}}
disabled={$selection.size == 0}
> >
{#if $allHidden} {#if $allHidden}
<Eye size="16" /> <Eye size="16" />
@@ -263,31 +233,20 @@
<Shortcut key="H" ctrl={true} /> <Shortcut key="H" ctrl={true} />
</Menubar.Item> </Menubar.Item>
{#if $treeFileView} {#if $treeFileView}
{#if $selection.getSelected().some((item) => item instanceof ListFileItem)} {#if $selection.type === 'file'}
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item <Menubar.Item
onclick={() => onclick={() => engine.newTrack()}
fileActions.addNewTrack( disabled={selectionCount !== 1}
$selection.getSelected()[0].getFileId()
)}
disabled={$selection.size !== 1}
> >
<Plus size="16" /> <Plus size="16" />
{i18n._('menu.new_track')} {i18n._('menu.new_track')}
</Menubar.Item> </Menubar.Item>
{:else if $selection {:else if $selection.type === 'track'}
.getSelected()
.some((item) => item instanceof ListTrackItem)}
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item <Menubar.Item
onclick={() => { onclick={() => engine.newTrackSegment()}
let item = $selection.getSelected()[0]; disabled={selectionCount !== 1}
fileActions.addNewSegment(
item.getFileId(),
item.getTrackIndex()
);
}}
disabled={$selection.size !== 1}
> >
<Plus size="16" /> <Plus size="16" />
{i18n._('menu.new_segment')} {i18n._('menu.new_segment')}
@@ -295,21 +254,18 @@
{/if} {/if}
{/if} {/if}
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item <Menubar.Item onclick={() => engine.selectAll()} disabled={noFiles}>
onclick={() => selection.selectAll()}
disabled={fileStateCollection.size == 0}
>
<FileStack size="16" /> <FileStack size="16" />
{i18n._('menu.select_all')} {i18n._('menu.select_all')}
<Shortcut key="A" ctrl={true} /> <Shortcut key="A" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Item <Menubar.Item
onclick={() => { onclick={() => {
if ($selection.size > 0) { if (selectionCount > 0) {
boundsManager.centerMapOnSelection(); boundsManager.centerMapOnSelection();
} }
}} }}
disabled={$selection.size == 0} disabled={selectionCount == 0}
> >
<Maximize size="16" /> <Maximize size="16" />
{i18n._('menu.center')} {i18n._('menu.center')}
@@ -317,41 +273,24 @@
</Menubar.Item> </Menubar.Item>
{#if $treeFileView} {#if $treeFileView}
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item <Menubar.Item onclick={() => engine.copy()} disabled={selectionCount === 0}>
onclick={() => selection.copySelection()}
disabled={$selection.size === 0}
>
<ClipboardCopy size="16" /> <ClipboardCopy size="16" />
{i18n._('menu.copy')} {i18n._('menu.copy')}
<Shortcut key="C" ctrl={true} /> <Shortcut key="C" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Item <Menubar.Item onclick={() => engine.cut()} disabled={selectionCount === 0}>
onclick={() => selection.cutSelection()}
disabled={$selection.size === 0}
>
<Scissors size="16" /> <Scissors size="16" />
{i18n._('menu.cut')} {i18n._('menu.cut')}
<Shortcut key="X" ctrl={true} /> <Shortcut key="X" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Item <Menubar.Item disabled={!$canPaste} onclick={() => engine.paste()}>
disabled={$copied === undefined ||
$copied.length === 0 ||
($selection.size > 0 &&
!allowedPastes[$copied[0].level].includes(
$selection.getSelected().pop()!.level
))}
onclick={pasteSelection}
>
<ClipboardPaste size="16" /> <ClipboardPaste size="16" />
{i18n._('menu.paste')} {i18n._('menu.paste')}
<Shortcut key="V" ctrl={true} /> <Shortcut key="V" ctrl={true} />
</Menubar.Item> </Menubar.Item>
{/if} {/if}
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item <Menubar.Item onclick={() => engine.delete()} disabled={selectionCount == 0}>
onclick={() => tick().then(fileActions.deleteSelection)}
disabled={$selection.size == 0}
>
<Trash2 size="16" /> <Trash2 size="16" />
{i18n._('menu.delete')} {i18n._('menu.delete')}
<Shortcut key="⌫" ctrl={true} /> <Shortcut key="⌫" ctrl={true} />
@@ -585,60 +524,55 @@
triggerFileInput(); triggerFileInput();
e.preventDefault(); e.preventDefault();
} else if (e.key === 'd' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'd' && (e.metaKey || e.ctrlKey)) {
fileActions.duplicateSelection(); engine.duplicate();
e.preventDefault(); e.preventDefault();
} else if (e.key === 'c' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'c' && (e.metaKey || e.ctrlKey)) {
if (!targetInput) { if (!targetInput) {
selection.copySelection(); engine.copy();
e.preventDefault(); e.preventDefault();
} }
} else if (e.key === 'x' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'x' && (e.metaKey || e.ctrlKey)) {
if (!targetInput) { if (!targetInput) {
selection.cutSelection(); engine.cut();
e.preventDefault(); e.preventDefault();
} }
} else if (e.key === 'v' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'v' && (e.metaKey || e.ctrlKey)) {
if (!targetInput) { if (!targetInput) {
pasteSelection(); engine.paste();
e.preventDefault(); e.preventDefault();
} }
} else if ((e.key === 's' || e.key == 'S') && (e.metaKey || e.ctrlKey)) { } else if ((e.key === 's' || e.key == 'S') && (e.metaKey || e.ctrlKey)) {
if (e.shiftKey) { if (e.shiftKey) {
if (fileStateCollection.size > 0) { if (!noFiles) {
exportState.current = ExportState.ALL; exportState.current = ExportState.ALL;
} }
} else if ($selection.size > 0) { } else if (selectionCount > 0) {
exportState.current = ExportState.SELECTION; exportState.current = ExportState.SELECTION;
} }
e.preventDefault(); e.preventDefault();
} else if ((e.key === 'z' || e.key == 'Z') && (e.metaKey || e.ctrlKey)) { } else if ((e.key === 'z' || e.key == 'Z') && (e.metaKey || e.ctrlKey)) {
if (e.shiftKey) { if (e.shiftKey) {
fileActionManager.redo(); engine.redo();
} else { } else {
fileActionManager.undo(); engine.undo();
} }
e.preventDefault(); e.preventDefault();
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) { } else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) {
if (!targetInput) { if (!targetInput) {
if (e.shiftKey) { if (e.shiftKey) {
fileActions.deleteAllFiles(); engine.deleteAll();
} else { } else {
fileActions.deleteSelection(); engine.delete();
} }
e.preventDefault(); e.preventDefault();
} }
} else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
if (!targetInput) { if (!targetInput) {
selection.selectAll(); engine.selectAll();
e.preventDefault(); e.preventDefault();
} }
} else if (e.key === 'i' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'i' && (e.metaKey || e.ctrlKey)) {
if ( if (selectionCount === 1 && filesOrTracksSelected) {
$selection.size === 1 &&
$selection
.getSelected()
.every((item) => item instanceof ListFileItem || item instanceof ListTrackItem)
) {
editMetadata.current = true; editMetadata.current = true;
} }
e.preventDefault(); e.preventDefault();
@@ -649,14 +583,10 @@
$treeFileView = !$treeFileView; $treeFileView = !$treeFileView;
e.preventDefault(); e.preventDefault();
} else if (e.key === 'h' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'h' && (e.metaKey || e.ctrlKey)) {
if ($allHidden) { engine.setSelectionHidden(!$allHidden);
fileActions.setHiddenToSelection(false);
} else {
fileActions.setHiddenToSelection(true);
}
e.preventDefault(); e.preventDefault();
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
if ($selection.size > 0) { if (selectionCount > 0) {
boundsManager.centerMapOnSelection(); boundsManager.centerMapOnSelection();
} }
} else if (e.key === 'F1') { } else if (e.key === 'F1') {
@@ -681,10 +611,7 @@
e.key === 'ArrowUp' e.key === 'ArrowUp'
) { ) {
if (!targetInput) { if (!targetInput) {
selection.updateFromKey( engine.arrowSelect(e.key === 'ArrowRight' || e.key === 'ArrowDown', e.shiftKey);
e.key === 'ArrowRight' || e.key === 'ArrowDown',
e.shiftKey
);
e.preventDefault(); e.preventDefault();
} }
} }
@@ -18,7 +18,9 @@
Construction, Construction,
} from '@lucide/svelte'; } from '@lucide/svelte';
import type { Readable, Writable } from 'svelte/store'; import type { Readable, Writable } from 'svelte/store';
import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx'; import type { Coordinates } from 'gpx';
import type { SelectionStatistics } from '$lib/engine';
import type { SlicedStatistics } from '$lib/logic/selection-statistics';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile'; import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile';
@@ -26,15 +28,15 @@
const { velocityUnits } = settings; const { velocityUnits } = settings;
let { let {
gpxStatistics, statistics,
slicedGPXStatistics, slicedStatistics,
hoveredPoint, hoveredPoint,
additionalDatasets, additionalDatasets,
elevationFill, elevationFill,
showControls = true, showControls = true,
}: { }: {
gpxStatistics: Readable<GPXStatisticsGroup>; statistics: Readable<SelectionStatistics>;
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>; slicedStatistics: Writable<SlicedStatistics | undefined>;
hoveredPoint: Writable<Coordinates | null>; hoveredPoint: Writable<Coordinates | null>;
additionalDatasets: Writable<string[]>; additionalDatasets: Writable<string[]>;
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>; elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
@@ -47,8 +49,8 @@
onMount(() => { onMount(() => {
elevationProfile = new ElevationProfile( elevationProfile = new ElevationProfile(
gpxStatistics, statistics,
slicedGPXStatistics, slicedStatistics,
hoveredPoint, hoveredPoint,
additionalDatasets, additionalDatasets,
elevationFill, elevationFill,
@@ -21,8 +21,11 @@ import Chart, {
type TooltipItem, type TooltipItem,
} from 'chart.js/auto'; } from 'chart.js/auto';
import { get, type Readable, type Writable } from 'svelte/store'; import { get, type Readable, type Writable } from 'svelte/store';
import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx'; import type { Coordinates } from 'gpx';
import { NO_TIME, type SelectionStatistics } from '$lib/engine';
import type { SlicedStatistics } from '$lib/logic/selection-statistics';
import { mode } from 'mode-watcher'; import { mode } from 'mode-watcher';
import { categoryAt } from '$lib/trackpoint-categories';
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors'; import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors';
const { distanceUnits, velocityUnits, temperatureUnits } = settings; const { distanceUnits, velocityUnits, temperatureUnits } = settings;
@@ -39,7 +42,10 @@ interface ElevationProfilePoint {
segment: number; segment: number;
length: number; length: number;
}; };
extensions: Record<string, any>; surface?: string;
highway?: string;
sacScale?: string;
mtbScale?: string;
coordinates: Coordinates; coordinates: Coordinates;
index: number; index: number;
} }
@@ -51,23 +57,23 @@ export class ElevationProfile {
private _dragging = false; private _dragging = false;
private _panning = false; private _panning = false;
private _gpxStatistics: Readable<GPXStatisticsGroup>; private _statistics: Readable<SelectionStatistics>;
private _slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>; private _slicedStatistics: Writable<SlicedStatistics | undefined>;
private _hoveredPoint: Writable<Coordinates | null>; private _hoveredPoint: Writable<Coordinates | null>;
private _additionalDatasets: Readable<string[]>; private _additionalDatasets: Readable<string[]>;
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>; private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
constructor( constructor(
gpxStatistics: Readable<GPXStatisticsGroup>, statistics: Readable<SelectionStatistics>,
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>, slicedStatistics: Writable<SlicedStatistics | undefined>,
hoveredPoint: Writable<Coordinates | null>, hoveredPoint: Writable<Coordinates | null>,
additionalDatasets: Readable<string[]>, additionalDatasets: Readable<string[]>,
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>, elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
overlay: HTMLCanvasElement overlay: HTMLCanvasElement
) { ) {
this._gpxStatistics = gpxStatistics; this._statistics = statistics;
this._slicedGPXStatistics = slicedGPXStatistics; this._slicedStatistics = slicedStatistics;
this._hoveredPoint = hoveredPoint; this._hoveredPoint = hoveredPoint;
this._additionalDatasets = additionalDatasets; this._additionalDatasets = additionalDatasets;
this._elevationFill = elevationFill; this._elevationFill = elevationFill;
@@ -78,10 +84,10 @@ export class ElevationProfile {
Chart.register(module.default); Chart.register(module.default);
this.initialize(); this.initialize();
this._gpxStatistics.subscribe(() => { this._statistics.subscribe(() => {
this.updateData(); this.updateData();
}); });
this._slicedGPXStatistics.subscribe(() => { this._slicedStatistics.subscribe(() => {
this.updateOverlay(); this.updateOverlay();
}); });
distanceUnits.subscribe(() => { distanceUnits.subscribe(() => {
@@ -183,14 +189,10 @@ export class ElevationProfile {
segment: point.slope.segment.toFixed(1), segment: point.slope.segment.toFixed(1),
length: getDistanceWithUnits(point.slope.length), length: getDistanceWithUnits(point.slope.length),
}; };
let surface = point.extensions.surface let surface = point.surface ?? 'unknown';
? point.extensions.surface let highway = point.highway ?? 'unknown';
: 'unknown'; let sacScale = point.sacScale;
let highway = point.extensions.highway let mtbScale = point.mtbScale;
? point.extensions.highway
: 'unknown';
let sacScale = point.extensions.sac_scale;
let mtbScale = point.extensions.mtb_scale;
let labels = [ let labels = [
` ${i18n._('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`, ` ${i18n._('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`,
@@ -235,7 +237,7 @@ export class ElevationProfile {
modifierKey: 'shift', modifierKey: 'shift',
onPanStart: () => { onPanStart: () => {
this._panning = true; this._panning = true;
this._slicedGPXStatistics.set(undefined); this._slicedStatistics.set(undefined);
return true; return true;
}, },
onPanComplete: () => { onPanComplete: () => {
@@ -260,7 +262,7 @@ export class ElevationProfile {
return false; return false;
} }
this._slicedGPXStatistics.set(undefined); this._slicedStatistics.set(undefined);
}, },
}, },
limits: { limits: {
@@ -361,14 +363,12 @@ export class ElevationProfile {
if (startIndex === undefined) { if (startIndex === undefined) {
startIndex = endIndex; startIndex = endIndex;
} else if (startIndex !== endIndex) { } else if (startIndex !== endIndex) {
this._slicedGPXStatistics.set([ const start = Math.min(startIndex, endIndex);
get(this._gpxStatistics).sliced( const end = Math.max(startIndex, endIndex);
Math.min(startIndex, endIndex), const global = get(this._statistics).slice(start, end);
Math.max(startIndex, endIndex) if (global) {
), this._slicedStatistics.set({ global, start, end });
Math.min(startIndex, endIndex), }
Math.max(startIndex, endIndex),
]);
} }
} }
} }
@@ -379,7 +379,7 @@ export class ElevationProfile {
this._canvas.style.cursor = ''; this._canvas.style.cursor = '';
endIndex = getIndex(evt); endIndex = getIndex(evt);
if (startIndex === endIndex) { if (startIndex === endIndex) {
this._slicedGPXStatistics.set(undefined); this._slicedStatistics.set(undefined);
} }
}; };
this._canvas.addEventListener('pointerdown', onMouseDown); this._canvas.addEventListener('pointerdown', onMouseDown);
@@ -391,7 +391,7 @@ export class ElevationProfile {
if (!this._chart) { if (!this._chart) {
return; return;
} }
const data = get(this._gpxStatistics); const data = get(this._statistics);
const units = { const units = {
distance: get(distanceUnits), distance: get(distanceUnits),
velocity: get(velocityUnits), velocity: get(velocityUnits),
@@ -399,52 +399,51 @@ export class ElevationProfile {
}; };
const datasets: Array<Array<any>> = [[], [], [], [], [], []]; const datasets: Array<Array<any>> = [[], [], [], [], [], []];
data.forEachTrackPoint((trkpt, distance, speed, slope, index) => { const { global } = data;
for (let index = 0; index < data.length; index++) {
const x = getConvertedDistance(data.totalDistance[index], units.distance);
const ele = data.ele[index];
const timestamp = data.timestamps[index];
datasets[0].push({ datasets[0].push({
x: getConvertedDistance(distance, units.distance), x,
y: trkpt.ele ? getConvertedElevation(trkpt.ele, units.distance) : 0, y: ele ? getConvertedElevation(ele, units.distance) : 0,
time: trkpt.time, time: timestamp === NO_TIME ? undefined : new Date(Number(timestamp)),
slope: slope, slope: {
extensions: trkpt.getExtensions(), at: data.slope[index],
coordinates: trkpt.getCoordinates(), segment: data.slopeSegmentSlope[index],
index: index, length: data.slopeSegmentDistance[index],
},
surface: categoryAt(data.surface, data.surfaces, index),
highway: categoryAt(data.highway, data.highways, index),
sacScale: categoryAt(data.sacScale, data.sacScales, index),
mtbScale: categoryAt(data.mtbScale, data.mtbScales, index),
coordinates: { lat: data.lat[index], lon: data.lng[index] },
index,
}); });
if (data.global.time.total > 0) { if ((global.totalTime ?? 0) > 0) {
datasets[1].push({ datasets[1].push({
x: getConvertedDistance(distance, units.distance), x,
y: getConvertedVelocity(speed, units.velocity, units.distance), y: getConvertedVelocity(data.speed[index], units.velocity, units.distance),
index: index, index,
}); });
} }
if (data.global.hr.count > 0) { if (global.hr) {
datasets[2].push({ datasets[2].push({ x, y: data.hr[index], index });
x: getConvertedDistance(distance, units.distance),
y: trkpt.getHeartRate(),
index: index,
});
} }
if (data.global.cad.count > 0) { if (global.cad) {
datasets[3].push({ datasets[3].push({ x, y: data.cad[index], index });
x: getConvertedDistance(distance, units.distance),
y: trkpt.getCadence(),
index: index,
});
} }
if (data.global.atemp.count > 0) { if (global.atemp) {
datasets[4].push({ datasets[4].push({
x: getConvertedDistance(distance, units.distance), x,
y: getConvertedTemperature(trkpt.getTemperature(), units.temperature), y: getConvertedTemperature(data.atemp[index], units.temperature),
index: index, index,
}); });
} }
if (data.global.power.count > 0) { if (global.power) {
datasets[5].push({ datasets[5].push({ x, y: data.power[index], index });
x: getConvertedDistance(distance, units.distance), }
y: trkpt.getPower(),
index: index,
});
} }
});
this._chart.data.datasets[0] = { this._chart.data.datasets[0] = {
label: i18n._('quantities.elevation'), label: i18n._('quantities.elevation'),
@@ -482,7 +481,7 @@ export class ElevationProfile {
this._chart.options.scales!.x!['min'] = 0; this._chart.options.scales!.x!['min'] = 0;
this._chart.options.scales!.x!['max'] = getConvertedDistance( this._chart.options.scales!.x!['max'] = getConvertedDistance(
data.global.distance.total, global.totalDistance,
units.distance units.distance
); );
@@ -563,10 +562,10 @@ export class ElevationProfile {
this._overlay.style.width = `${this._overlay.width}px`; this._overlay.style.width = `${this._overlay.width}px`;
this._overlay.style.height = `${this._overlay.height}px`; this._overlay.style.height = `${this._overlay.height}px`;
const slicedGPXStatistics = get(this._slicedGPXStatistics); const slicedStatistics = get(this._slicedStatistics);
if (slicedGPXStatistics) { if (slicedStatistics) {
let startIndex = slicedGPXStatistics[1]; let startIndex = slicedStatistics.start;
let endIndex = slicedGPXStatistics[2]; let endIndex = slicedStatistics.end;
// Draw selection rectangle // Draw selection rectangle
let selectionContext = this._overlay.getContext('2d'); let selectionContext = this._overlay.getContext('2d');
@@ -575,14 +574,12 @@ export class ElevationProfile {
selectionContext.globalAlpha = mode.current === 'dark' ? 0.2 : 0.1; selectionContext.globalAlpha = mode.current === 'dark' ? 0.2 : 0.1;
selectionContext.clearRect(0, 0, this._overlay.width, this._overlay.height); selectionContext.clearRect(0, 0, this._overlay.width, this._overlay.height);
const gpxStatistics = get(this._gpxStatistics); const statistics = get(this._statistics);
let startPixel = this._chart.scales.x.getPixelForValue( let startPixel = this._chart.scales.x.getPixelForValue(
getConvertedDistance( getConvertedDistance(statistics.totalDistance[startIndex] ?? 0)
gpxStatistics.getTrackPoint(startIndex)?.distance.total ?? 0
)
); );
let endPixel = this._chart.scales.x.getPixelForValue( let endPixel = this._chart.scales.x.getPixelForValue(
getConvertedDistance(gpxStatistics.getTrackPoint(endIndex)?.distance.total ?? 0) getConvertedDistance(statistics.totalDistance[endIndex] ?? 0)
); );
selectionContext.fillRect( selectionContext.fillRect(
@@ -607,16 +604,12 @@ export class ElevationProfile {
surfaceFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) { surfaceFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
const point = context.p0.raw as ElevationProfilePoint; const point = context.p0.raw as ElevationProfilePoint;
return getSurfaceColor(point.extensions.surface); return getSurfaceColor(point.surface ?? '');
} }
highwayFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) { highwayFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
const point = context.p0.raw as ElevationProfilePoint; const point = context.p0.raw as ElevationProfilePoint;
return getHighwayColor( return getHighwayColor(point.highway ?? '', point.sacScale, point.mtbScale);
point.extensions.highway,
point.extensions.sac_scale,
point.extensions.mtb_scale
);
} }
destroy() { destroy() {
@@ -7,7 +7,6 @@
import LayerControl from '$lib/components/map/layer-control/LayerControl.svelte'; import LayerControl from '$lib/components/map/layer-control/LayerControl.svelte';
import OpenIn from '$lib/components/embedding/OpenIn.svelte'; import OpenIn from '$lib/components/embedding/OpenIn.svelte';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import type { GPXFile } from 'gpx';
import { import {
allowedEmbeddingBasemaps, allowedEmbeddingBasemaps,
getFilesFromEmbeddingOptions, getFilesFromEmbeddingOptions,
@@ -15,12 +14,12 @@
} from './embedding'; } from './embedding';
import { setMode } from 'mode-watcher'; import { setMode } from 'mode-watcher';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { fileStateCollection } from '$lib/logic/file-state'; import { loadFiles } from '$lib/logic/file-actions';
import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics';
import { loadFile } from '$lib/logic/file-actions';
import { selection } from '$lib/logic/selection';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { isSelected, toggle } from '$lib/components/map/layer-control/utils'; import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
import { engine } from '$lib/engine';
import { hoveredPoint } from '$lib/logic/statistics';
import { slicedStatistics } from '$lib/logic/selection-statistics';
let { let {
useHash = true, useHash = true,
@@ -28,9 +27,12 @@
hash = $bindable(), hash = $bindable(),
}: { useHash?: boolean; options: EmbeddingOptions; hash: string } = $props(); }: { useHash?: boolean; options: EmbeddingOptions; hash: string } = $props();
const { files } = engine;
let additionalDatasets = writable<string[]>([]); let additionalDatasets = writable<string[]>([]);
let elevationFill = writable<'slope' | 'surface' | 'highway' | undefined>(undefined); let elevationFill = writable<'slope' | 'surface' | 'highway' | undefined>(undefined);
const { statistics } = engine;
const { const {
currentBasemap, currentBasemap,
selectedBasemapTree, selectedBasemapTree,
@@ -71,26 +73,14 @@
); );
elevationFill.set(options.elevation.fill == 'none' ? undefined : options.elevation.fill); elevationFill.set(options.elevation.fill == 'none' ? undefined : options.elevation.fill);
let downloads: Promise<GPXFile | null>[] = getFilesFromEmbeddingOptions(options).map( let downloads: Promise<File>[] = getFilesFromEmbeddingOptions(options).map((url) => {
(url) => {
return fetch(url) return fetch(url)
.then((response) => response.blob()) .then((response) => response.blob())
.then((blob) => new File([blob], url.split('/').pop() ?? url)) .then((blob) => new File([blob], url.split('/').pop() ?? url));
.then(loadFile);
}
);
Promise.all(downloads).then((answers) => {
const files = answers.filter((file) => file !== null) as GPXFile[];
let ids: string[] = [];
files.forEach((file, index) => {
let id = `gpx-${index}-embed`;
file._data.id = id;
ids.push(id);
});
fileStateCollection.setEmbeddedFiles(files);
$fileOrder = ids;
selection.selectAll();
}); });
Promise.all(downloads)
.then(loadFiles)
.then(() => engine.selectAll());
} }
$effect(() => { $effect(() => {
@@ -102,7 +92,7 @@
<div class="absolute flex flex-col h-full w-full border rounded-xl overflow-clip"> <div class="absolute flex flex-col h-full w-full border rounded-xl overflow-clip">
<div class="grow relative"> <div class="grow relative">
<Map <Map
class="h-full {$fileStateCollection.size > 1 ? 'horizontal' : ''}" class="h-full {$files.size > 1 ? 'horizontal' : ''}"
maptilerKey={options.key} maptilerKey={options.key}
geocoder={false} geocoder={false}
geolocate={true} geolocate={true}
@@ -111,7 +101,7 @@
<OpenIn files={options.files} ids={options.ids} /> <OpenIn files={options.files} ids={options.ids} />
<LayerControl /> <LayerControl />
<GPXLayers /> <GPXLayers />
{#if $fileStateCollection.size > 1} {#if $files.size > 1}
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30"> <div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
<FileList orientation="horizontal" /> <FileList orientation="horizontal" />
</div> </div>
@@ -122,14 +112,14 @@
style={options.elevation.show ? `height: ${options.elevation.height}px` : ''} style={options.elevation.show ? `height: ${options.elevation.height}px` : ''}
> >
<GPXStatistics <GPXStatistics
{gpxStatistics} {statistics}
{slicedGPXStatistics} {slicedStatistics}
orientation={options.elevation.show ? 'vertical' : 'horizontal'} orientation={options.elevation.show ? 'vertical' : 'horizontal'}
/> />
{#if options.elevation.show} {#if options.elevation.show}
<ElevationProfile <ElevationProfile
{gpxStatistics} {statistics}
{slicedGPXStatistics} {slicedStatistics}
{hoveredPoint} {hoveredPoint}
{additionalDatasets} {additionalDatasets}
{elevationFill} {elevationFill}
@@ -1,16 +1,14 @@
<script lang="ts"> <script lang="ts">
import { ScrollArea } from '$lib/components/ui/scroll-area/index'; import { ScrollArea } from '$lib/components/ui/scroll-area/index';
import * as ContextMenu from '$lib/components/ui/context-menu'; import * as ContextMenu from '$lib/components/ui/context-menu';
import FileListNode from './FileListNode.svelte'; import FileListNodeContent from './FileListNodeContent.svelte';
import { onMount, setContext } from 'svelte'; import { onMount, setContext } from 'svelte';
import { ListFileItem, ListLevel, ListRootItem } from './file-list';
import { ClipboardPaste, FileStack, Plus } from '@lucide/svelte'; import { ClipboardPaste, FileStack, Plus } from '@lucide/svelte';
import Shortcut from '$lib/components/Shortcut.svelte'; import Shortcut from '$lib/components/Shortcut.svelte';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { fileStateCollection } from '$lib/logic/file-state'; import { engine } from '$lib/engine';
import { createFile, pasteSelection } from '$lib/logic/file-actions'; import { selectedFileIds } from '$lib/selection-helpers';
import { selection, copied } from '$lib/logic/selection'; import { createFile } from '$lib/logic/file-actions';
import { allowedPastes } from './sortable-file-list';
let { let {
orientation, orientation,
@@ -27,17 +25,12 @@
setContext('orientation', orientation); setContext('orientation', orientation);
setContext('recursive', recursive); setContext('recursive', recursive);
const { files, selection, canPaste } = engine;
onMount(() => { onMount(() => {
if (orientation === 'horizontal') { if (orientation === 'horizontal' && $selection.type !== 'file') {
selection.update(($selection) => { // only files can be selected here
$selection.forEach((item) => { engine.select(selectedFileIds($selection));
if (!(item instanceof ListFileItem)) {
$selection.toggle(item);
$selection.set(new ListFileItem(item.getFileId()), true);
}
});
return $selection;
});
} }
}); });
</script> </script>
@@ -54,7 +47,7 @@
: 'flex-row'} {className ?? ''}" : 'flex-row'} {className ?? ''}"
{style} {style}
> >
<FileListNode node={$fileStateCollection} item={new ListRootItem()} /> <FileListNodeContent node={null} />
{#if orientation === 'vertical'} {#if orientation === 'vertical'}
<ContextMenu.Root> <ContextMenu.Root>
<ContextMenu.Trigger class="grow" /> <ContextMenu.Trigger class="grow" />
@@ -66,20 +59,15 @@
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Separator /> <ContextMenu.Separator />
<ContextMenu.Item <ContextMenu.Item
onclick={() => selection.selectAll()} onclick={() => engine.selectAll()}
disabled={$fileStateCollection.size === 0} disabled={$files.size === 0}
> >
<FileStack size="16" /> <FileStack size="16" />
{i18n._('menu.select_all')} {i18n._('menu.select_all')}
<Shortcut key="A" ctrl={true} /> <Shortcut key="A" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Separator /> <ContextMenu.Separator />
<ContextMenu.Item <ContextMenu.Item disabled={!$canPaste} onclick={() => engine.paste()}>
disabled={$copied === undefined ||
$copied.length === 0 ||
!allowedPastes[$copied[0].level].includes(ListLevel.ROOT)}
onclick={pasteSelection}
>
<ClipboardPaste size="16" /> <ClipboardPaste size="16" />
{i18n._('menu.paste')} {i18n._('menu.paste')}
<Shortcut key="V" ctrl={true} /> <Shortcut key="V" ctrl={true} />
@@ -1,89 +1,93 @@
<script lang="ts"> <script lang="ts">
import {
GPXFile,
Track,
TrackSegment,
Waypoint,
type AnyGPXTreeElement,
type GPXTreeElement,
} from 'gpx';
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index'; import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
import { type Readable } from 'svelte/store';
import FileListNodeContent from './FileListNodeContent.svelte'; import FileListNodeContent from './FileListNodeContent.svelte';
import FileListNodeLabel from './FileListNodeLabel.svelte'; import FileListNodeLabel from './FileListNodeLabel.svelte';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import {
ListFileItem,
ListTrackSegmentItem,
ListWaypointItem,
ListWaypointsItem,
type ListItem,
type ListTrackItem,
} from './file-list';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree'; import { engine, type FileState } from '$lib/engine';
import { selection } from '$lib/logic/selection'; import {
elementId,
hasSelectionWithin,
isSelected,
type FileTreeNode,
} from '$lib/selection-helpers';
let { let {
fileState,
node, node,
item,
}: { }: {
node: fileState: FileState;
| Map<string, Readable<GPXFileWithStatistics | undefined>> node: FileTreeNode;
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint;
item: ListItem;
} = $props(); } = $props();
let recursive = getContext<boolean>('recursive'); let recursive = getContext<boolean>('recursive');
let collapsible: CollapsibleTreeNode | undefined = $state(); let collapsible: CollapsibleTreeNode | undefined = $state();
let label = $derived( let label = $derived.by(() => {
node instanceof GPXFile && item instanceof ListFileItem const { structure } = fileState;
? node.metadata.name switch (node.type) {
: node instanceof Track case 'file':
? (node.name ?? `${i18n._('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`) return structure.name;
: node instanceof TrackSegment case 'track': {
? `${i18n._('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}` const trackIndex = structure.tracks.findIndex((t) => t.id === node.trackId);
: node instanceof Waypoint return (
? (node.name ?? structure.tracks[trackIndex]?.name ?? `${i18n._('gpx.track')} ${trackIndex + 1}`
`${i18n._('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`)
: node instanceof GPXFile && item instanceof ListWaypointsItem
? i18n._('gpx.waypoints')
: ''
); );
}
case 'segment': {
const track = structure.tracks.find((t) => t.id === node.trackId);
const segmentIndex =
track?.segments.findIndex((s) => s.id === node.segmentId) ?? -1;
return `${i18n._('gpx.segment')} ${segmentIndex + 1}`;
}
case 'waypoints':
return i18n._('gpx.waypoints');
case 'waypoint': {
const waypointIndex = structure.waypoints.findIndex(
(w) => w.id === node.waypointId
);
return (
structure.waypoints[waypointIndex]?.name ??
`${i18n._('gpx.waypoint')} ${waypointIndex + 1}`
);
}
}
});
// identifies the node in the tree
let id = $derived(elementId(node));
const { treeFileView } = settings; const { treeFileView } = settings;
const { selection } = engine;
$effect(() => { $effect(() => {
if (collapsible && $treeFileView && $selection.hasAnyChildren(item, false)) { // open the node when something below it is selected
if (
collapsible &&
$treeFileView &&
hasSelectionWithin($selection, node) &&
!isSelected($selection, node)
) {
collapsible.openNode(); collapsible.openNode();
} }
}); });
</script> </script>
{#if node instanceof Map} {#if node.type === 'segment' || node.type === 'waypoint'}
<FileListNodeContent {node} {item} /> <FileListNodeLabel {fileState} {node} {label} />
{:else if node instanceof TrackSegment}
<FileListNodeLabel {node} {item} {label} />
{:else if node instanceof Waypoint}
<FileListNodeLabel {node} {item} {label} />
{:else if recursive} {:else if recursive}
<CollapsibleTreeNode id={item.getId()} bind:this={collapsible}> <CollapsibleTreeNode {id} bind:this={collapsible}>
{#snippet trigger()} {#snippet trigger()}
<FileListNodeLabel {node} {item} {label} /> <FileListNodeLabel {fileState} {node} {label} />
{/snippet} {/snippet}
{#snippet content()} {#snippet content()}
<div class="ml-4"> <div class="ml-4">
{#key node} <FileListNodeContent {fileState} {node} />
<FileListNodeContent {node} {item} />
{/key}
</div> </div>
{/snippet} {/snippet}
</CollapsibleTreeNode> </CollapsibleTreeNode>
{:else} {:else}
<FileListNodeLabel {node} {item} {label} /> <FileListNodeLabel {fileState} {node} {label} />
{/if} {/if}
@@ -1,41 +1,39 @@
<script lang="ts"> <script lang="ts">
import { GPXFile, Track, Waypoint, type AnyGPXTreeElement, type GPXTreeElement } from 'gpx';
import { getContext, onDestroy, onMount } from 'svelte'; import { getContext, onDestroy, onMount } from 'svelte';
import { type Readable } from 'svelte/store';
import FileListNodeStore from './FileListNodeStore.svelte'; import FileListNodeStore from './FileListNodeStore.svelte';
import FileListNode from './FileListNode.svelte'; import FileListNode from './FileListNode.svelte';
import FileListNodeContent from './FileListNodeContent.svelte'; import FileListNodeContent from './FileListNodeContent.svelte';
import { ListFileItem, ListLevel, ListWaypointsItem, type ListItem } from './file-list'; import { ListLevel } from './file-list';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
import { allowedMoves, dragging, SortableFileList } from './sortable-file-list'; import { allowedMoves, dragging, SortableFileList } from './sortable-file-list';
import { engine, type FileState } from '$lib/engine';
import type { FileTreeNode } from '$lib/selection-helpers';
let { let {
fileState,
node, node,
item,
waypointRoot = false, waypointRoot = false,
}: { }: {
node: /** State of the file of the node (not needed for the root). */
| Map<string, Readable<GPXFileWithStatistics | undefined>> fileState?: FileState;
| GPXTreeElement<AnyGPXTreeElement> /** The node whose children are listed, `null` for the list of the files. */
| Waypoint[] node: FileTreeNode | null;
| Waypoint; /** List the node standing for the waypoints of the file, instead of its tracks. */
item: ListItem;
waypointRoot?: boolean; waypointRoot?: boolean;
} = $props(); } = $props();
const { files, order } = engine;
let container: HTMLElement; let container: HTMLElement;
let sortableLevel: ListLevel = let sortableLevel: ListLevel =
node instanceof Map node === null
? ListLevel.FILE ? ListLevel.FILE
: node instanceof GPXFile : node.type === 'file'
? waypointRoot ? waypointRoot
? ListLevel.WAYPOINTS ? ListLevel.WAYPOINTS
: item instanceof ListWaypointsItem
? ListLevel.WAYPOINT
: ListLevel.TRACK : ListLevel.TRACK
: node instanceof Track : node.type === 'waypoints'
? ListLevel.SEGMENT ? ListLevel.WAYPOINT
: ListLevel.WAYPOINT; : ListLevel.SEGMENT;
let orientation = getContext<'vertical' | 'horizontal'>('orientation'); let orientation = getContext<'vertical' | 'horizontal'>('orientation');
let canDrop = $derived($dragging !== null && allowedMoves[$dragging].includes(sortableLevel)); let canDrop = $derived($dragging !== null && allowedMoves[$dragging].includes(sortableLevel));
@@ -43,18 +41,14 @@
let sortable: SortableFileList; let sortable: SortableFileList;
onMount(() => { onMount(() => {
sortable = new SortableFileList( sortable = new SortableFileList(container, node, waypointRoot, sortableLevel, orientation);
container,
node,
item,
waypointRoot,
sortableLevel,
orientation
);
}); });
$effect(() => { $effect(() => {
if (sortable && node) { // the elements of the list changed
$order;
fileState;
if (sortable) {
sortable.updateElements(); sortable.updateElements();
} }
}); });
@@ -70,45 +64,65 @@
? 'flex-col' ? 'flex-col'
: 'flex-row gap-1'} {canDrop ? 'min-h-5' : ''}" : 'flex-row gap-1'} {canDrop ? 'min-h-5' : ''}"
> >
{#if node instanceof Map} {#if node === null}
{#each node as [fileId, file] (fileId)} {#each $order as fileId (fileId)}
{@const file = $files.get(fileId)}
{#if file}
<div data-id={fileId}> <div data-id={fileId}>
<FileListNodeStore {file} /> <FileListNodeStore {file} />
</div> </div>
{/if}
{/each} {/each}
{:else if node instanceof GPXFile} {:else if fileState}
{#if item instanceof ListWaypointsItem} {#if node.type === 'file'}
{#each node.wpt as wpt, i (wpt)} {#if waypointRoot}
<div data-id={i} class="ml-1"> {#if fileState.structure.waypoints.length > 0}
<FileListNode node={wpt} item={item.extend(i)} />
</div>
{/each}
{:else if waypointRoot}
{#if node.wpt.length > 0}
<div data-id="waypoints"> <div data-id="waypoints">
<FileListNode {node} item={item.extend('waypoints')} /> <FileListNode
{fileState}
node={{ type: 'waypoints', fileId: node.fileId }}
/>
</div> </div>
{/if} {/if}
{:else} {:else}
{#each node.children as child, i (child)} {#each fileState.structure.tracks as track (track.id)}
<div data-id={i}> <div data-id={track.id}>
<FileListNode node={child} item={item.extend(i)} /> <FileListNode
{fileState}
node={{ type: 'track', fileId: node.fileId, trackId: track.id }}
/>
</div> </div>
{/each} {/each}
{/if} {/if}
{:else if node instanceof Track} {:else if node.type === 'waypoints'}
{#each node.children as child, i (child)} {#each fileState.structure.waypoints as waypoint (waypoint.id)}
<div data-id={i} class="ml-1"> <div data-id={waypoint.id} class="ml-1">
<FileListNode node={child} item={item.extend(i)} /> <FileListNode
{fileState}
node={{ type: 'waypoint', fileId: node.fileId, waypointId: waypoint.id }}
/>
</div> </div>
{/each} {/each}
{:else if node.type === 'track'}
{#each fileState.structure.tracks.find((t) => t.id === node.trackId)?.segments ?? [] as segment (segment.id)}
<div data-id={segment.id} class="ml-1">
<FileListNode
{fileState}
node={{
type: 'segment',
fileId: node.fileId,
trackId: node.trackId,
segmentId: segment.id,
}}
/>
</div>
{/each}
{/if}
{/if} {/if}
</div> </div>
{#if node instanceof GPXFile && item instanceof ListFileItem} {#if fileState && node?.type === 'file' && !waypointRoot}
{#if !waypointRoot} <FileListNodeContent {fileState} {node} waypointRoot={true} />
<FileListNodeContent {node} {item} waypointRoot={true} />
{/if}
{/if} {/if}
<style lang="postcss"> <style lang="postcss">
@@ -18,86 +18,91 @@
Scissors, Scissors,
FileStack, FileStack,
} from '@lucide/svelte'; } from '@lucide/svelte';
import { import { ListLevel } from './file-list';
ListFileItem,
ListLevel,
ListTrackItem,
ListWaypointItem,
type ListItem,
} from './file-list';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import MetadataDialog from '$lib/components/file-list/metadata/MetadataDialog.svelte'; import MetadataDialog from '$lib/components/file-list/metadata/MetadataDialog.svelte';
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte'; import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
import StyleDialog from '$lib/components/file-list/style/StyleDialog.svelte'; import StyleDialog from '$lib/components/file-list/style/StyleDialog.svelte';
import { editStyle } from '$lib/components/file-list/style/utils.svelte'; import { editStyle } from '$lib/components/file-list/style/utils.svelte';
import { getSymbolKey, symbols } from '$lib/assets/symbols'; import { getSymbolKey, symbols } from '$lib/assets/symbols';
import { selection, copied, cut } from '$lib/logic/selection'; import { engine, type FileState } from '$lib/engine';
import { fileActions, pasteSelection } from '$lib/logic/file-actions'; import { allHidden } from '$lib/all-hidden';
import { allHidden } from '$lib/logic/hidden'; import { normalizeColor } from '$lib/file-colors';
import { isHidden } from '$lib/file-visibility';
import {
elementId,
isInClipboard,
isSelected,
selectedElementIds,
selectionSize,
type FileTreeNode,
} from '$lib/selection-helpers';
import { boundsManager } from '$lib/logic/bounds'; import { boundsManager } from '$lib/logic/bounds';
import { gpxColors, 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';
import { allowedPastes } from './sortable-file-list';
let { let {
fileState,
node, node,
item,
label, label,
}: { }: {
node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint; fileState: FileState;
item: ListItem; node: FileTreeNode;
label: string | undefined; label: string | undefined;
} = $props(); } = $props();
const { selection, clipboard, canPaste } = engine;
let orientation = getContext<'vertical' | 'horizontal'>('orientation'); let orientation = getContext<'vertical' | 'horizontal'>('orientation');
let embedding = getContext<boolean>('embedding'); let embedding = getContext<boolean>('embedding');
let singleSelection = $derived($selection.size === 1); const levels = {
file: ListLevel.FILE,
track: ListLevel.TRACK,
segment: ListLevel.SEGMENT,
waypoints: ListLevel.WAYPOINTS,
waypoint: ListLevel.WAYPOINT,
};
let level = $derived(levels[node.type]);
let singleSelection = $derived(selectionSize($selection) === 1);
let nodeColors: string[] = $derived.by(() => { let nodeColors: string[] = $derived.by(() => {
let colors: string[] = []; const { tracks } = fileState.structure;
if (node) { if (node.type === 'file') {
if (node instanceof GPXFile) { // the colors defined by the tracks, or else the one of the file
let defaultColor = $gpxColors.get(item.getFileId()); const colors = [
let style = node.getStyle(defaultColor); ...new Set(
colors = style.color; tracks.flatMap((track) =>
} else if (node instanceof Track) { track.color !== undefined ? [normalizeColor(track.color)] : []
let style = node.getStyle(); )
if ( ),
style && ];
style['gpx_style:color'] && return colors.length > 0 ? colors : [fileState.color];
!colors.includes(style['gpx_style:color']) } else if (node.type === 'track') {
) { const color = tracks.find((track) => track.id === node.trackId)?.color;
colors.push(style['gpx_style:color']); return [color !== undefined ? normalizeColor(color) : fileState.color];
} }
if (colors.length === 0) { return [];
let defaultColor = $gpxColors.get(item.getFileId());
if (defaultColor) {
colors.push(defaultColor);
}
}
}
}
return colors;
}); });
let symbolKey = $derived(node instanceof Waypoint ? getSymbolKey(node.sym) : undefined); let symbolKey = $derived(
node.type === 'waypoint'
let openEditMetadata: boolean = $derived( ? getSymbolKey(fileState.structure.waypoints.find((w) => w.id === node.waypointId)?.sym)
editMetadata.current && singleSelection && $selection.has(item) : undefined
); );
let selected = $derived(isSelected($selection, node));
let openEditMetadata: boolean = $derived(editMetadata.current && singleSelection && selected);
let openEditStyle: boolean = $derived( let openEditStyle: boolean = $derived(
editStyle.current && editStyle.current &&
$selection.has(item) && selected &&
$selection.getSelected().findIndex((i) => i.getFullId() === item.getFullId()) === 0 selectedElementIds($selection)[0]?.ids[0] === elementId(node)
); );
let hidden = $derived( let hidden = $derived(isHidden(fileState, elementId(node)));
item.level === ListLevel.WAYPOINTS ? node._data.hiddenWpt : node._data.hidden // cut elements are greyed until they are pasted
); let isCut = $derived($clipboard?.cut === true && isInClipboard($clipboard, node));
</script> </script>
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -105,8 +110,8 @@
<ContextMenu.Root <ContextMenu.Root
onOpenChange={(open) => { onOpenChange={(open) => {
if (open) { if (open) {
if (!$selection.has(item)) { if (!selected) {
selection.selectItem(item); engine.selectNode(node);
} }
} }
}} }}
@@ -119,11 +124,11 @@
? 'h-7' ? 'h-7'
: 'h-9 px-1.5'} pointer-events-auto" : 'h-9 px-1.5'} pointer-events-auto"
> >
{#if item instanceof ListFileItem || item instanceof ListTrackItem} {#if node.type === 'file' || node.type === 'track'}
<MetadataDialog bind:open={openEditMetadata} {node} {item} /> <MetadataDialog bind:open={openEditMetadata} {fileState} {node} />
<StyleDialog bind:open={openEditStyle} {item} /> <StyleDialog bind:open={openEditStyle} {fileState} {node} />
{/if} {/if}
{#if item.level === ListLevel.FILE || item.level === ListLevel.TRACK} {#if level === ListLevel.FILE || level === ListLevel.TRACK}
<div <div
class="absolute {orientation === 'vertical' class="absolute {orientation === 'vertical'
? 'top-0 bottom-0 right-0 w-1' ? 'top-0 bottom-0 right-0 w-1'
@@ -141,9 +146,7 @@
<span <span
class="grow text-left truncate ml-1 flex flex-row items-center {hidden class="grow text-left truncate ml-1 flex flex-row items-center {hidden
? 'text-muted-foreground' ? 'text-muted-foreground'
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId()) : ''} {isCut ? 'text-muted-foreground' : ''}"
? 'text-muted-foreground'
: ''}"
oncontextmenu={(e) => { oncontextmenu={(e) => {
if (embedding) { if (embedding) {
e.preventDefault(); e.preventDefault();
@@ -154,37 +157,30 @@
// Add to selection instead of opening context menu // Add to selection instead of opening context menu
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
$selection.toggle(item); engine.selectNode(node, 'toggle');
$selection = $selection;
} }
}} }}
onmouseenter={() => { onmouseenter={() => {
if (item instanceof ListWaypointItem) { if (node.type === 'waypoint' && !isHidden(fileState, node.waypointId)) {
let layer = gpxLayers.getLayer(item.getFileId()); const waypoint = engine.waypoint(node.fileId, node.waypointId);
let file = fileStateCollection.getFile(item.getFileId()); if (waypoint) {
if (layer && file) {
let waypoint = file.wpt[item.getWaypointIndex()];
if (waypoint && !waypoint._data.hidden) {
waypointPopup?.setItem({ waypointPopup?.setItem({
item: waypoint, item: waypoint,
fileId: item.getFileId(), kind: 'waypoint',
fileId: node.fileId,
}); });
} }
} }
}
}} }}
onmouseleave={() => { onmouseleave={() => {
if (item instanceof ListWaypointItem) { if (node.type === 'waypoint') {
let layer = gpxLayers.getLayer(item.getFileId());
if (layer) {
waypointPopup?.setItem(null); waypointPopup?.setItem(null);
} }
}
}} }}
> >
{#if item.level === ListLevel.SEGMENT} {#if level === ListLevel.SEGMENT}
<Waypoints size="16" class="mx-1 shrink-0" /> <Waypoints size="16" class="mx-1 shrink-0" />
{:else if item.level === ListLevel.WAYPOINT} {:else if level === ListLevel.WAYPOINT}
{#if symbolKey && symbols[symbolKey].icon} {#if symbolKey && symbols[symbolKey].icon}
{@const SymbolIcon = symbols[symbolKey].icon} {@const SymbolIcon = symbols[symbolKey].icon}
<SymbolIcon size="16" class="mx-1 shrink-0" /> <SymbolIcon size="16" class="mx-1 shrink-0" />
@@ -211,7 +207,7 @@
</Button> </Button>
</ContextMenu.Trigger> </ContextMenu.Trigger>
<ContextMenu.Content> <ContextMenu.Content>
{#if item instanceof ListFileItem || item instanceof ListTrackItem} {#if node.type === 'file' || node.type === 'track'}
<ContextMenu.Item <ContextMenu.Item
disabled={!singleSelection} disabled={!singleSelection}
onclick={() => (editMetadata.current = true)} onclick={() => (editMetadata.current = true)}
@@ -225,15 +221,7 @@
{i18n._('menu.style.button')} {i18n._('menu.style.button')}
</ContextMenu.Item> </ContextMenu.Item>
{/if} {/if}
<ContextMenu.Item <ContextMenu.Item onclick={() => engine.setSelectionHidden(!$allHidden)}>
onclick={() => {
if ($allHidden) {
fileActions.setHiddenToSelection(false);
} else {
fileActions.setHiddenToSelection(true);
}
}}
>
{#if $allHidden} {#if $allHidden}
<Eye size="16" /> <Eye size="16" />
{i18n._('menu.unhide')} {i18n._('menu.unhide')}
@@ -245,20 +233,16 @@
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Separator /> <ContextMenu.Separator />
{#if orientation === 'vertical'} {#if orientation === 'vertical'}
{#if item instanceof ListFileItem} {#if node.type === 'file'}
<ContextMenu.Item <ContextMenu.Item disabled={!singleSelection} onclick={() => engine.newTrack()}>
disabled={!singleSelection}
onclick={() => fileActions.addNewTrack(item.getFileId())}
>
<Plus size="16" /> <Plus size="16" />
{i18n._('menu.new_track')} {i18n._('menu.new_track')}
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Separator /> <ContextMenu.Separator />
{:else if item instanceof ListTrackItem} {:else if node.type === 'track'}
<ContextMenu.Item <ContextMenu.Item
disabled={!singleSelection} disabled={!singleSelection}
onclick={() => onclick={() => engine.newTrackSegment()}
fileActions.addNewSegment(item.getFileId(), item.getTrackIndex())}
> >
<Plus size="16" /> <Plus size="16" />
{i18n._('menu.new_segment')} {i18n._('menu.new_segment')}
@@ -266,8 +250,8 @@
<ContextMenu.Separator /> <ContextMenu.Separator />
{/if} {/if}
{/if} {/if}
{#if item.level !== ListLevel.WAYPOINTS} {#if level !== ListLevel.WAYPOINTS}
<ContextMenu.Item onclick={() => selection.selectAll()}> <ContextMenu.Item onclick={() => engine.selectAll()}>
<FileStack size="16" /> <FileStack size="16" />
{i18n._('menu.select_all')} {i18n._('menu.select_all')}
<Shortcut key="A" ctrl={true} /> <Shortcut key="A" ctrl={true} />
@@ -279,35 +263,30 @@
<Shortcut key="⏎" ctrl={true} /> <Shortcut key="⏎" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Separator /> <ContextMenu.Separator />
<ContextMenu.Item onclick={fileActions.duplicateSelection}> <ContextMenu.Item onclick={() => engine.duplicate()}>
<Copy size="16" /> <Copy size="16" />
{i18n._('menu.duplicate')} {i18n._('menu.duplicate')}
<Shortcut key="D" ctrl={true} /> <Shortcut key="D" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
{#if orientation === 'vertical'} {#if orientation === 'vertical'}
<ContextMenu.Item onclick={() => selection.copySelection()}> <ContextMenu.Item onclick={() => engine.copy()}>
<ClipboardCopy size="16" /> <ClipboardCopy size="16" />
{i18n._('menu.copy')} {i18n._('menu.copy')}
<Shortcut key="C" ctrl={true} /> <Shortcut key="C" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Item onclick={() => selection.cutSelection()}> <ContextMenu.Item onclick={() => engine.cut()}>
<Scissors size="16" /> <Scissors size="16" />
{i18n._('menu.cut')} {i18n._('menu.cut')}
<Shortcut key="X" ctrl={true} /> <Shortcut key="X" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Item <ContextMenu.Item disabled={!$canPaste} onclick={() => engine.paste()}>
disabled={$copied === undefined ||
$copied.length === 0 ||
!allowedPastes[$copied[0].level].includes(item.level)}
onclick={pasteSelection}
>
<ClipboardPaste size="16" /> <ClipboardPaste size="16" />
{i18n._('menu.paste')} {i18n._('menu.paste')}
<Shortcut key="V" ctrl={true} /> <Shortcut key="V" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
{/if} {/if}
<ContextMenu.Separator /> <ContextMenu.Separator />
<ContextMenu.Item onclick={fileActions.deleteSelection}> <ContextMenu.Item onclick={() => engine.delete()}>
<Trash2 size="16" /> <Trash2 size="16" />
{i18n._('menu.delete')} {i18n._('menu.delete')}
<Shortcut key="⌫" ctrl={true} /> <Shortcut key="⌫" ctrl={true} />
@@ -4,24 +4,21 @@
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import type { Readable } from 'svelte/store'; import type { Readable } from 'svelte/store';
import { ListFileItem } from './file-list'; import type { FileState } from '$lib/engine';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
let { let {
file, file,
}: { }: {
file: Readable<GPXFileWithStatistics | undefined>; file: Readable<FileState>;
} = $props(); } = $props();
let recursive = getContext<boolean>('recursive'); let recursive = getContext<boolean>('recursive');
</script> </script>
{#if $file} {#if recursive}
{#if recursive}
<CollapsibleTree side="left" defaultState="closed" slotInsideTrigger={false}> <CollapsibleTree side="left" defaultState="closed" slotInsideTrigger={false}>
<FileListNode node={$file.file} item={new ListFileItem($file.file._data.id)} /> <FileListNode fileState={$file} node={{ type: 'file', fileId: $file.structure.id }} />
</CollapsibleTree> </CollapsibleTree>
{:else} {:else}
<FileListNode node={$file.file} item={new ListFileItem($file.file._data.id)} /> <FileListNode fileState={$file} node={{ type: 'file', fileId: $file.structure.id }} />
{/if}
{/if} {/if}
@@ -5,35 +5,31 @@
import { Label } from '$lib/components/ui/label/index.js'; import { Label } from '$lib/components/ui/label/index.js';
import * as Popover from '$lib/components/ui/popover'; import * as Popover from '$lib/components/ui/popover';
import { Save } from '@lucide/svelte'; import { Save } from '@lucide/svelte';
import { ListFileItem, ListTrackItem, type ListItem } from '../file-list';
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte'; import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
import { fileActionManager } from '$lib/logic/file-action-manager'; import { engine, type FileState } from '$lib/engine';
import type { FileTreeNode } from '$lib/selection-helpers';
let { let {
fileState,
node, node,
item,
open = $bindable(), open = $bindable(),
}: { }: {
node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint; fileState: FileState;
item: ListItem; node: FileTreeNode;
open: boolean; open: boolean;
} = $props(); } = $props();
let track = $derived(
node.type === 'track'
? fileState.structure.tracks.find((track) => track.id === node.trackId)
: undefined
);
let name: string = $derived( let name: string = $derived(
node instanceof GPXFile node.type === 'file' ? fileState.structure.name : (track?.name ?? '')
? (node.metadata.name ?? '')
: node instanceof Track
? (node.name ?? '')
: ''
); );
let description: string = $derived( let description: string = $derived(
node instanceof GPXFile node.type === 'file' ? (fileState.structure.desc ?? '') : (track?.desc ?? '')
? (node.metadata.desc ?? '')
: node instanceof Track
? (node.desc ?? '')
: ''
); );
$effect(() => { $effect(() => {
@@ -53,19 +49,12 @@
<Button <Button
variant="outline" variant="outline"
onclick={() => { onclick={() => {
fileActionManager.applyToFile(item.getFileId(), (file) => { // closes the dialog now: `open` follows `editMetadata`, and the change of the file
if (item instanceof ListFileItem && node instanceof GPXFile) { // would open the dialog again before the effect below has reset it
file.metadata.name = name; editMetadata.current = false;
file.metadata.desc = description;
if (file.trk.length === 1) {
file.trk[0].name = name;
}
} else if (item instanceof ListTrackItem && node instanceof Track) {
file.trk[item.getTrackIndex()].name = name;
file.trk[item.getTrackIndex()].desc = description;
}
});
open = false; open = false;
// the metadata of the selected elements are changed
engine.metadata(name, description);
}} }}
> >
<Save size="16" /> <Save size="16" />
@@ -1,15 +1,10 @@
import { isMac } from '$lib/utils'; import { isMac } from '$lib/utils';
import Sortable, { type Direction } from 'sortablejs/Sortable'; import Sortable, { type Direction } from 'sortablejs/Sortable';
import { ListItem, ListLevel, ListRootItem } from './file-list'; import { ListLevel } from './file-list';
import { selection } from '$lib/logic/selection'; import { get, writable } from 'svelte/store';
import { getFileIds, moveItems } from '$lib/logic/file-actions';
import { get, writable, type Readable } from 'svelte/store';
import { settings } from '$lib/logic/settings';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
import type { AnyGPXTreeElement, GPXTreeElement, Waypoint } from 'gpx';
import { tick } from 'svelte'; import { tick } from 'svelte';
import { engine, type MoveTarget, type Selection } from '$lib/engine';
const { fileOrder } = settings; import { isSelected, type FileTreeNode } from '$lib/selection-helpers';
export const allowedMoves: Record<ListLevel, ListLevel[]> = { export const allowedMoves: Record<ListLevel, ListLevel[]> = {
[ListLevel.ROOT]: [], [ListLevel.ROOT]: [],
@@ -20,45 +15,34 @@ export const allowedMoves: Record<ListLevel, ListLevel[]> = {
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT], [ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
}; };
export const allowedPastes: Record<ListLevel, ListLevel[]> = {
[ListLevel.ROOT]: [],
[ListLevel.FILE]: [ListLevel.ROOT, ListLevel.FILE],
[ListLevel.TRACK]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK],
[ListLevel.SEGMENT]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
[ListLevel.WAYPOINTS]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
[ListLevel.WAYPOINT]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
};
export const dragging = writable<ListLevel | null>(null); export const dragging = writable<ListLevel | null>(null);
/** The list of each container, to find the lists a drop is about from its event. */
const lists = new WeakMap<HTMLElement, SortableFileList>();
/**
* Makes a list of the file tree sortable and selectable. The elements of the list are the
* children of `parent` (the files when `parent` is null), their ids are in their `data-id`.
*/
export class SortableFileList { export class SortableFileList {
private _node: private _parent: FileTreeNode | null;
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint;
private _item: ListItem;
private _sortableLevel: ListLevel; private _sortableLevel: ListLevel;
private _container: HTMLElement; private _container: HTMLElement;
private _sortable: Sortable | null = null; private _sortable: Sortable | null = null;
private _elements: { [id: string]: HTMLElement } = {}; private _elements: { [id: string]: HTMLElement } = {};
private _updatingSelection: boolean = false; private _updatingSelection: boolean = false;
/** The elements of the list when a drag from it started, to put them back when it ends. */
private _snapshot: ChildNode[] = [];
private _unsubscribes: (() => void)[] = []; private _unsubscribes: (() => void)[] = [];
constructor( constructor(
container: HTMLElement, container: HTMLElement,
node: parent: FileTreeNode | null,
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint,
item: ListItem,
waypointRoot: boolean, waypointRoot: boolean,
sortableLevel: ListLevel, sortableLevel: ListLevel,
orientation: Direction orientation: Direction
) { ) {
this._node = node; this._parent = parent;
this._item = item;
this._sortableLevel = sortableLevel; this._sortableLevel = sortableLevel;
this._container = container; this._container = container;
this._sortable = Sortable.create(container, { this._sortable = Sortable.create(container, {
@@ -76,79 +60,137 @@ export class SortableFileList {
setTimeout(() => this.updateToSelection(e), 50), setTimeout(() => this.updateToSelection(e), 50),
onDeselect: (e: Sortable.SortableEvent) => onDeselect: (e: Sortable.SortableEvent) =>
setTimeout(() => this.updateToSelection(e), 50), setTimeout(() => this.updateToSelection(e), 50),
onStart: () => dragging.set(sortableLevel), onStart: () => {
// all the nodes, not only the elements: the comments are the anchors of the Svelte blocks
this._snapshot = Array.from(container.childNodes);
dragging.set(sortableLevel);
},
onEnd: () => dragging.set(null), onEnd: () => dragging.set(null),
onSort: (e: Sortable.SortableEvent) => this.onSort(e), onSort: (e: Sortable.SortableEvent) => this.onSort(e),
}); });
Object.defineProperty(this._sortable, '_item', { lists.set(container, this);
value: item,
writable: true,
});
Object.defineProperty(this._sortable, '_waypointRoot', {
value: waypointRoot,
writable: true,
});
this._unsubscribes.push( this._unsubscribes.push(
selection.subscribe(() => tick().then(() => this.updateFromSelection())) engine.selection.subscribe(() => tick().then(() => this.updateFromSelection()))
); );
this._unsubscribes.push(fileOrder.subscribe(() => this.updateFromFileOrder())); // after the elements are rendered: sorting a list that does not have the new files yet
// would put them at the beginning
this._unsubscribes.push(
engine.order.subscribe(() => tick().then(() => this.updateFromFileOrder()))
);
}
/** The node of a child of the list. */
childNode(id: string): FileTreeNode | undefined {
const parent = this._parent;
switch (this._sortableLevel) {
case ListLevel.FILE:
return { type: 'file', fileId: id };
case ListLevel.TRACK:
return parent ? { type: 'track', fileId: parent.fileId, trackId: id } : undefined;
case ListLevel.SEGMENT:
return parent?.type === 'track'
? {
type: 'segment',
fileId: parent.fileId,
trackId: parent.trackId,
segmentId: id,
}
: undefined;
case ListLevel.WAYPOINTS:
return parent ? { type: 'waypoints', fileId: parent.fileId } : undefined;
case ListLevel.WAYPOINT:
return parent
? { type: 'waypoint', fileId: parent.fileId, waypointId: id }
: undefined;
}
}
/** The elements of the list in the selection form. */
private selectionOf(ids: string[]): Selection | undefined {
const parent = this._parent;
switch (this._sortableLevel) {
case ListLevel.FILE:
return { type: 'file', fileIds: ids };
case ListLevel.TRACK:
return parent ? { type: 'track', fileId: parent.fileId, trackIds: ids } : undefined;
case ListLevel.SEGMENT:
return parent?.type === 'track'
? {
type: 'segment',
fileId: parent.fileId,
trackId: parent.trackId,
segmentIds: ids,
}
: undefined;
case ListLevel.WAYPOINTS:
return parent ? { type: 'waypoints', fileId: parent.fileId } : undefined;
case ListLevel.WAYPOINT:
return parent
? { type: 'waypoint', fileId: parent.fileId, waypointIds: ids }
: undefined;
}
}
/** A position in the list, as a place to move elements to. */
private targetAt(index: number): MoveTarget | undefined {
const parent = this._parent;
switch (this._sortableLevel) {
case ListLevel.FILE:
return { type: 'files', index };
case ListLevel.TRACK:
return parent ? { type: 'tracks', fileId: parent.fileId, index } : undefined;
case ListLevel.SEGMENT:
return parent?.type === 'track'
? { type: 'segments', fileId: parent.fileId, trackId: parent.trackId, index }
: undefined;
case ListLevel.WAYPOINTS:
// the list holds the node standing for the waypoints: they go after the others
return parent
? { type: 'waypoints', fileId: parent.fileId, index: Infinity }
: undefined;
case ListLevel.WAYPOINT:
return parent ? { type: 'waypoints', fileId: parent.fileId, index } : undefined;
}
} }
onSort(e: Sortable.SortableEvent) { onSort(e: Sortable.SortableEvent) {
this.updateToFileOrder(); const from = lists.get(e.from);
const to = lists.get(e.to);
const from = Sortable.get(e.from);
const to = Sortable.get(e.to);
if (!from || !to) { if (!from || !to) {
return; return;
} }
let fromItem = from._item; if (from === to && this._sortableLevel === ListLevel.FILE) {
let toItem = to._item; // the order of the files is the one of the list
this.updateToFileOrder();
if (this._item === toItem && !(fromItem instanceof ListRootItem)) { return;
// Event is triggered on source and destination list, only handle it once
let fromItems = [];
let toItems = [];
if (from._waypointRoot) {
fromItems = [fromItem.extend('waypoints')];
} else {
let oldIndices: number[] =
e.oldIndicies.length > 0 ? e.oldIndicies.map((i) => i.index) : [e.oldIndex];
oldIndices = oldIndices.filter((i) => i >= 0);
oldIndices.sort((a, b) => a - b);
fromItems = oldIndices.map((i) => fromItem.extend(i));
} }
if (from._waypointRoot && to._waypointRoot) { // The event is triggered on the source and on the destination list: handle it once.
toItems = [toItem.extend('waypoints')]; // Nothing to move in the list of the single node of the waypoints.
} else { if (to !== this || (from === to && this._sortableLevel === ListLevel.WAYPOINTS)) {
if (to._waypointRoot) { return;
toItem = toItem.extend('waypoints');
} }
let newIndices: number[] = const elements: HTMLElement[] = e.items.length > 0 ? e.items : [e.item];
e.newIndicies.length > 0 ? e.newIndicies.map((i) => i.index) : [e.newIndex]; const ids = elements
newIndices = newIndices.filter((i) => i >= 0); .map((element) => element.getAttribute('data-id'))
newIndices.sort((a, b) => a - b); .filter((id): id is string => id !== null);
const newIndices = (
e.newIndicies.length > 0
? e.newIndicies.map((i: { index: number }) => i.index)
: [e.newIndex]
).filter((index: number | undefined): index is number => index !== undefined && index >= 0);
const what = from.selectionOf(ids);
const target = to.targetAt(newIndices.length > 0 ? Math.min(...newIndices) : Infinity);
if (toItem instanceof ListRootItem) { // The list is rendered from the state of the engine: put the elements back where they
let newFileIds = getFileIds(newIndices.length); // were, the engine moves them for real.
toItems = newIndices.map((i, index) => { from._snapshot.forEach((node) => e.from.appendChild(node));
get(fileOrder).splice(i, 0, newFileIds[index]);
return this._item.extend(newFileIds[index]);
});
} else {
toItems = newIndices.map((i) => toItem.extend(i));
}
}
moveItems(fromItem, toItem, fromItems, toItems); if (what && target) {
engine.move(what, target);
} }
} }
@@ -157,11 +199,12 @@ export class SortableFileList {
if (changed.length === 0) { if (changed.length === 0) {
return; return;
} }
const selection_ = get(selection); const selection_ = get(engine.selection);
for (let id of changed) { for (let id of changed) {
let element = this._elements[id]; let element = this._elements[id];
if (element) { let node = this.childNode(id);
if (selection_.has(this._item.extend(id))) { if (element && node) {
if (isSelected(selection_, node)) {
Sortable.utils.select(element); Sortable.utils.select(element);
element.scrollIntoView({ element.scrollIntoView({
behavior: 'smooth', behavior: 'smooth',
@@ -183,27 +226,42 @@ export class SortableFileList {
this._updatingSelection = false; this._updatingSelection = false;
return; return;
} }
selection.update(($selection) => {
$selection.clear(); let selected = Object.entries(this._elements)
Object.entries(this._elements).forEach(([id, element]) => { .filter(([, element]) => element.classList.contains('sortable-selected'))
$selection.set( .map(([id]) => id);
this._item.extend(this.getRealId(id)),
element.classList.contains('sortable-selected')
);
});
if ( if (
e.originalEvent && e.originalEvent &&
!(e.originalEvent.ctrlKey || e.originalEvent.metaKey || e.originalEvent.shiftKey) && !(e.originalEvent.ctrlKey || e.originalEvent.metaKey || e.originalEvent.shiftKey) &&
($selection.size > 1 || (selected.length > 1 || !selected.includes(changed[0]))
!$selection.has(this._item.extend(this.getRealId(changed[0]))))
) { ) {
// Fix bug that sometimes causes a single select to be treated as a multi-select // Fix bug that sometimes causes a single select to be treated as a multi-select
$selection.clear(); selected = [changed[0]];
$selection.set(this._item.extend(this.getRealId(changed[0])), true); }
const parent = this._parent;
if (selected.length === 0 || !this.childNode(selected[0])) {
engine.select([]);
} else {
switch (this._sortableLevel) {
case ListLevel.FILE:
engine.select(selected);
break;
case ListLevel.TRACK:
engine.selectTracks(parent!.fileId, selected);
break;
case ListLevel.SEGMENT:
engine.selectSegments(parent!.fileId, (parent as any).trackId, selected);
break;
case ListLevel.WAYPOINTS:
engine.selectWaypointGroup(parent!.fileId);
break;
case ListLevel.WAYPOINT:
engine.selectWaypoints(parent!.fileId, selected);
break;
}
} }
return $selection;
});
this._updatingSelection = false; this._updatingSelection = false;
} }
@@ -212,7 +270,7 @@ export class SortableFileList {
return; return;
} }
const fileOrder_ = get(fileOrder); const fileOrder_ = get(engine.order);
const sortableOrder = this._sortable.toArray(); const sortableOrder = this._sortable.toArray();
if ( if (
@@ -228,24 +286,25 @@ export class SortableFileList {
return; return;
} }
const fileOrder_ = get(fileOrder); const fileOrder_ = get(engine.order);
const sortableOrder = this._sortable.toArray(); const sortableOrder = this._sortable.toArray();
if ( if (
fileOrder_.length !== sortableOrder.length || fileOrder_.length !== sortableOrder.length ||
fileOrder_.some((value, index) => value !== sortableOrder[index]) fileOrder_.some((value, index) => value !== sortableOrder[index])
) { ) {
fileOrder.set(sortableOrder); engine.reorder(sortableOrder, 0);
} }
} }
updateElements() { updateElements() {
this._elements = {}; this._elements = {};
const files = get(engine.files);
this._container.childNodes.forEach((element) => { this._container.childNodes.forEach((element) => {
if (element instanceof HTMLElement) { if (element instanceof HTMLElement) {
let attr = element.getAttribute('data-id'); let attr = element.getAttribute('data-id');
if (attr) { if (attr) {
if (this._node instanceof Map && !this._node.has(attr)) { if (this._sortableLevel === ListLevel.FILE && !files.has(attr)) {
element.remove(); element.remove();
} else { } else {
this._elements[attr] = element; this._elements[attr] = element;
@@ -262,23 +321,16 @@ export class SortableFileList {
} }
getChangedIds() { getChangedIds() {
let changed: (string | number)[] = []; let changed: string[] = [];
const selection_ = get(selection); const selection_ = get(engine.selection);
Object.entries(this._elements).forEach(([id, element]) => { Object.entries(this._elements).forEach(([id, element]) => {
let realId = this.getRealId(id); const node = this.childNode(id);
let realItem = this._item.extend(realId); let inSelection = node !== undefined && isSelected(selection_, node);
let inSelection = selection_.has(realItem); let isSelectedInList = element.classList.contains('sortable-selected');
let isSelected = element.classList.contains('sortable-selected'); if (inSelection !== isSelectedInList) {
if (inSelection !== isSelected) { changed.push(id);
changed.push(realId);
} }
}); });
return changed; return changed;
} }
getRealId(id: string | number) {
return this._sortableLevel === ListLevel.FILE || this._sortableLevel === ListLevel.WAYPOINTS
? id
: parseInt(id as string);
}
} }
@@ -5,30 +5,26 @@
import { Slider } from '$lib/components/ui/slider'; import { Slider } from '$lib/components/ui/slider';
import * as Popover from '$lib/components/ui/popover'; import * as Popover from '$lib/components/ui/popover';
import { Save } from '@lucide/svelte'; import { Save } from '@lucide/svelte';
import {
ListFileItem,
ListTrackItem,
type ListItem,
} from '$lib/components/file-list/file-list';
import { editStyle } from '$lib/components/file-list/style/utils.svelte'; import { editStyle } from '$lib/components/file-list/style/utils.svelte';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import type { LineStyleExtension } from 'gpx';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { selection } from '$lib/logic/selection'; import { engine, type FileState } from '$lib/engine';
import { fileStateCollection } from '$lib/logic/file-state'; import { normalizeColor } from '$lib/file-colors';
import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers'; import { selectedFileIds, type FileTreeNode } from '$lib/selection-helpers';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { fileActions } from '$lib/logic/file-actions';
let { let {
item, fileState,
node,
open = $bindable(), open = $bindable(),
}: { }: {
item: ListItem; fileState: FileState;
node: FileTreeNode;
open: boolean; open: boolean;
} = $props(); } = $props();
const { defaultOpacity, defaultWidth } = settings; const { defaultOpacity, defaultWidth } = settings;
const { files, selection } = engine;
let color: string = $state(''); let color: string = $state('');
let opacity: number = $state(0); let opacity: number = $state(0);
@@ -37,45 +33,24 @@
let opacityChanged = $state(false); let opacityChanged = $state(false);
let widthChanged = $state(false); let widthChanged = $state(false);
// TODO the inputs used to come from the last selected item, they now come from this node
function setStyleInputs() { function setStyleInputs() {
opacity = $defaultOpacity; opacity = $defaultOpacity;
width = $defaultWidth; width = $defaultWidth;
color = fileState.color;
$selection.forEach((item) => { const { tracks } = fileState.structure;
if (item instanceof ListFileItem) { if (node.type === 'file') {
let file = fileStateCollection.getFile(item.getFileId()); opacity = tracks.find((track) => track.opacity !== undefined)?.opacity ?? opacity;
let layer = gpxLayers.getLayer(item.getFileId()); width = tracks.find((track) => track.width !== undefined)?.width ?? width;
if (file && layer) { } else if (node.type === 'track') {
let style = file.getStyle(); const track = tracks.find((track) => track.id === node.trackId);
color = layer.layerColor; if (track) {
if (style.opacity.length > 0) { color = track.color !== undefined ? normalizeColor(track.color) : color;
opacity = style.opacity[0]; opacity = track.opacity ?? opacity;
} width = track.width ?? width;
if (style.width.length > 0) {
width = style.width[0];
} }
} }
} else if (item instanceof ListTrackItem) {
let file = fileStateCollection.getFile(item.getFileId());
let layer = gpxLayers.getLayer(item.getFileId());
if (file && layer) {
color = layer.layerColor;
let track = file.trk[item.getTrackIndex()];
let style = track.getStyle();
if (style) {
if (style['gpx_style:color']) {
color = style['gpx_style:color'];
}
if (style['gpx_style:opacity']) {
opacity = style['gpx_style:opacity'];
}
if (style['gpx_style:width']) {
width = style['gpx_style:width'];
}
}
}
}
});
colorChanged = false; colorChanged = false;
opacityChanged = false; opacityChanged = false;
@@ -95,28 +70,25 @@
}); });
function applyStyle() { function applyStyle() {
let style: LineStyleExtension = {}; // closes the dialog now: `open` follows `editStyle`, and the style changes the file, which
if (colorChanged) { // would open the dialog again before the effect below has reset it
style['gpx_style:color'] = color; editStyle.current = false;
} open = false;
engine.style({
color: colorChanged ? color : undefined,
opacity: opacityChanged ? opacity : undefined,
width: widthChanged ? width : undefined,
});
if (node.type === 'file' && selectedFileIds($selection).length === $files.size) {
if (opacityChanged) { if (opacityChanged) {
style['gpx_style:opacity'] = opacity; $defaultOpacity = opacity;
} }
if (widthChanged) { if (widthChanged) {
style['gpx_style:width'] = width; $defaultWidth = width;
}
fileActions.setStyleToSelection(style);
if (item instanceof ListFileItem && $selection.size === fileStateCollection.size) {
if (style['gpx_style:opacity']) {
$defaultOpacity = style['gpx_style:opacity'];
}
if (style['gpx_style:width']) {
$defaultWidth = style['gpx_style:width'];
} }
} }
open = false;
} }
</script> </script>
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import { trackpointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup'; import { trackpointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
import { TrackPoint } from 'gpx';
map.onLoad((map_) => { map.onLoad((map_) => {
map_.on('contextmenu', (e) => { map_.on('contextmenu', (e) => {
@@ -16,12 +15,8 @@
return; return;
} }
trackpointPopup?.setItem({ trackpointPopup?.setItem({
item: new TrackPoint({ kind: 'trackpoint',
attributes: { item: { lng: e.lngLat.lng, lat: e.lngLat.lat },
lat: e.lngLat.lat,
lon: e.lngLat.lng,
},
}),
}); });
}); });
}); });
@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { TrackPoint, Waypoint } from 'gpx';
import WaypointPopup from '$lib/components/map/gpx-layer/WaypointPopup.svelte'; import WaypointPopup from '$lib/components/map/gpx-layer/WaypointPopup.svelte';
import TrackpointPopup from '$lib/components/map/gpx-layer/TrackpointPopup.svelte'; import TrackpointPopup from '$lib/components/map/gpx-layer/TrackpointPopup.svelte';
import OverpassPopup from '$lib/components/map/layer-control/OverpassPopup.svelte'; import OverpassPopup from '$lib/components/map/layer-control/OverpassPopup.svelte';
@@ -23,9 +22,9 @@
<div bind:this={container}> <div bind:this={container}>
{#if $item} {#if $item}
{#if $item.item instanceof Waypoint} {#if $item.kind === 'waypoint'}
<WaypointPopup waypoint={$item} /> <WaypointPopup waypoint={$item} />
{:else if $item.item instanceof TrackPoint} {:else if $item.kind === 'trackpoint'}
<TrackpointPopup trackpoint={$item} /> <TrackpointPopup trackpoint={$item} />
{:else} {:else}
<OverpassPopup poi={$item} /> <OverpassPopup poi={$item} />
@@ -2,14 +2,13 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { ClipboardCopy } from '@lucide/svelte'; import { ClipboardCopy } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import type { Coordinates } from 'gpx';
let { let {
coordinates, coordinates,
onCopy = () => {}, onCopy = () => {},
class: className = '', class: className = '',
}: { }: {
coordinates: Coordinates; coordinates: { lng: number; lat: number };
onCopy?: () => void; onCopy?: () => void;
class?: string; class?: string;
} = $props(); } = $props();
@@ -20,7 +19,7 @@
variant="outline" variant="outline"
onclick={() => { onclick={() => {
navigator.clipboard.writeText( navigator.clipboard.writeText(
`${coordinates.lat.toFixed(6)}, ${coordinates.lon.toFixed(6)}` `${coordinates.lat.toFixed(6)}, ${coordinates.lng.toFixed(6)}`
); );
onCopy(); onCopy();
}} }}
@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import type { TrackPoint } from 'gpx';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import CopyCoordinates from '$lib/components/map/gpx-layer/CopyCoordinates.svelte'; import CopyCoordinates from '$lib/components/map/gpx-layer/CopyCoordinates.svelte';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
@@ -7,18 +6,17 @@
import { Compass, Earth, Mountain, Timer } from '@lucide/svelte'; import { Compass, Earth, Mountain, Timer } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import type { PopupItem } from '$lib/components/map/map-popup'; import type { PopupItem } from '$lib/components/map/map-popup';
import type { TrackpointDetails } from '$lib/engine';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
let { trackpoint }: { trackpoint: PopupItem<TrackPoint> } = $props(); let { trackpoint }: { trackpoint: PopupItem<Pick<TrackpointDetails, 'lng' | 'lat'> & Partial<TrackpointDetails>> } = $props();
</script> </script>
<Card.Root class="border-none shadow-md text-base p-2"> <Card.Root class="border-none shadow-md text-base p-2">
<Card.Content class="flex flex-col p-0 text-xs gap-1"> <Card.Content class="flex flex-col p-0 text-xs gap-1">
<div class="flex flex-row items-center gap-1"> <div class="flex flex-row items-center gap-1">
<Compass size="14" /> <Compass size="14" />
{trackpoint.item.getLatitude().toFixed(6)}&deg; {trackpoint.item {trackpoint.item.lat.toFixed(6)}&deg; {trackpoint.item.lng.toFixed(6)}&deg;
.getLongitude()
.toFixed(6)}&deg;
</div> </div>
{#if trackpoint.item.ele !== undefined} {#if trackpoint.item.ele !== undefined}
<div class="flex flex-row items-center gap-1"> <div class="flex flex-row items-center gap-1">
@@ -26,14 +24,14 @@
<WithUnits value={trackpoint.item.ele} type="elevation" /> <WithUnits value={trackpoint.item.ele} type="elevation" />
</div> </div>
{/if} {/if}
{#if trackpoint.item.time} {#if trackpoint.item.time !== undefined}
<div class="flex flex-row items-center gap-1"> <div class="flex flex-row items-center gap-1">
<Timer size="14" /> <Timer size="14" />
{i18n.df.format(trackpoint.item.time)} {i18n.df.format(new Date(trackpoint.item.time))}
</div> </div>
{/if} {/if}
<CopyCoordinates <CopyCoordinates
coordinates={trackpoint.item.attributes} coordinates={trackpoint.item}
onCopy={() => trackpoint.hide?.()} onCopy={() => trackpoint.hide?.()}
class="mt-0.5" class="mt-0.5"
/> />
@@ -41,7 +39,7 @@
<Button <Button
variant="outline" variant="outline"
class="justify-start" class="justify-start"
href={`https://www.openstreetmap.org/edit?#map=${(($map?.getZoom() ?? 17) + 1).toFixed(0)}/${trackpoint.item.getLatitude().toFixed(5)}/${trackpoint.item.getLongitude().toFixed(5)}`} href={`https://www.openstreetmap.org/edit?#map=${(($map?.getZoom() ?? 17) + 1).toFixed(0)}/${trackpoint.item.lat.toFixed(5)}/${trackpoint.item.lng.toFixed(5)}`}
target="_blank" target="_blank"
> >
<Earth size="14" /> <Earth size="14" />
@@ -9,21 +9,23 @@
import { getSymbolKey, symbols } from '$lib/assets/symbols'; import { getSymbolKey, symbols } from '$lib/assets/symbols';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import sanitizeHtml from 'sanitize-html'; import sanitizeHtml from 'sanitize-html';
import type { Waypoint } from 'gpx';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js'; import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import { fileActions } from '$lib/logic/file-actions';
import type { PopupItem } from '$lib/components/map/map-popup'; import type { PopupItem } from '$lib/components/map/map-popup';
import { selection } from '$lib/logic/selection'; import { engine, type WaypointDetails } from '$lib/engine';
import { ListFileItem } from '$lib/components/file-list/file-list'; import { hasSelectionWithin } from '$lib/selection-helpers';
let { let {
waypoint, waypoint,
}: { }: {
waypoint: PopupItem<Waypoint>; waypoint: PopupItem<WaypointDetails>;
} = $props(); } = $props();
const { selection } = engine;
let selected = $derived( let selected = $derived(
waypoint.fileId ? $selection.hasAnyChildren(new ListFileItem(waypoint.fileId)) : false waypoint.fileId
? hasSelectionWithin($selection, { type: 'file', fileId: waypoint.fileId })
: false
); );
let symbolKey = $derived(waypoint ? getSymbolKey(waypoint.item.sym) : undefined); let symbolKey = $derived(waypoint ? getSymbolKey(waypoint.item.sym) : undefined);
@@ -44,9 +46,9 @@
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw] gap-0"> <Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw] gap-0">
<Card.Header class="p-0 gap-0"> <Card.Header class="p-0 gap-0">
<Card.Title class="text-md"> <Card.Title class="text-md">
{#if waypoint.item.link && waypoint.item.link.attributes && waypoint.item.link.attributes.href} {#if waypoint.item.link?.href}
<a href={waypoint.item.link.attributes.href} target="_blank"> <a href={waypoint.item.link.href} target="_blank">
{waypoint.item.name ?? waypoint.item.link.attributes.href} {waypoint.item.name ?? waypoint.item.link.href}
<ExternalLink size="12" class="inline-block mb-1.5" /> <ExternalLink size="12" class="inline-block mb-1.5" />
</a> </a>
{:else} {:else}
@@ -68,13 +70,9 @@
</span> </span>
<Dot size="16" /> <Dot size="16" />
{/if} {/if}
{waypoint.item.getLatitude().toFixed(6)}&deg; {waypoint.item {waypoint.item.lat.toFixed(6)}&deg; {waypoint.item.lng.toFixed(6)}&deg;
.getLongitude()
.toFixed(6)}&deg;
{#if waypoint.item.ele !== undefined}
<Dot size="16" /> <Dot size="16" />
<WithUnits value={waypoint.item.ele} type="elevation" /> <WithUnits value={waypoint.item.ele} type="elevation" />
{/if}
</div> </div>
<ScrollArea class="flex flex-col max-h-[30dvh]"> <ScrollArea class="flex flex-col max-h-[30dvh]">
{#if waypoint.item.desc} {#if waypoint.item.desc}
@@ -85,13 +83,13 @@
{/if} {/if}
</ScrollArea> </ScrollArea>
<div class="mt-2 flex flex-col gap-1"> <div class="mt-2 flex flex-col gap-1">
<CopyCoordinates coordinates={waypoint.item.attributes} /> <CopyCoordinates coordinates={waypoint.item} />
{#if $currentTool === Tool.WAYPOINT && selected} {#if $currentTool === Tool.WAYPOINT && selected}
<Button <Button
variant="outline" variant="outline"
onclick={() => { onclick={() => {
if (waypoint.fileId) { if (waypoint.fileId) {
fileActions.deleteWaypoint(waypoint.fileId, waypoint.item._data.index); engine.deleteWaypoint(waypoint.fileId, waypoint.item.id);
waypoint.hide?.(); waypoint.hide?.();
} }
}} }}
@@ -1,9 +1,9 @@
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { gpxStatistics } from '$lib/logic/statistics'; import { engine } from '$lib/engine';
import { getConvertedDistanceToKilometers } from '$lib/units'; import { getConvertedDistanceToKilometers } from '$lib/units';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import { allHidden } from '$lib/logic/hidden'; import { allHidden } from '$lib/all-hidden';
import type { GeoJSONSource } from 'maplibre-gl'; import type { GeoJSONSource } from 'maplibre-gl';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style'; import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
@@ -16,7 +16,7 @@ export class DistanceMarkers {
unsubscribes: (() => void)[] = []; unsubscribes: (() => void)[] = [];
constructor() { constructor() {
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded)); this.unsubscribes.push(engine.statistics.subscribe(this.updateBinded));
this.unsubscribes.push(distanceMarkers.subscribe(this.updateBinded)); this.unsubscribes.push(distanceMarkers.subscribe(this.updateBinded));
this.unsubscribes.push(distanceUnits.subscribe(this.updateBinded)); this.unsubscribes.push(distanceUnits.subscribe(this.updateBinded));
this.unsubscribes.push(allHidden.subscribe(this.updateBinded)); this.unsubscribes.push(allHidden.subscribe(this.updateBinded));
@@ -101,19 +101,20 @@ export class DistanceMarkers {
} }
getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection { getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection {
let statistics = get(gpxStatistics); // the distance from the start of the selection, and the position, of each trackpoint
const { length, totalDistance, lng, lat } = get(engine.statistics);
let features: GeoJSON.Feature[] = []; let features: GeoJSON.Feature[] = [];
let currentTargetDistance = 1; let currentTargetDistance = 1;
statistics.forEachTrackPoint((trkpt, dist) => { for (let i = 0; i < length; i++) {
if (dist >= getConvertedDistanceToKilometers(currentTargetDistance)) { if (totalDistance[i] >= getConvertedDistanceToKilometers(currentTargetDistance)) {
let distance = currentTargetDistance.toFixed(0); let distance = currentTargetDistance.toFixed(0);
let level = levels.find((level) => currentTargetDistance % level === 0) || 1; let level = levels.find((level) => currentTargetDistance % level === 0) || 1;
features.push({ features.push({
type: 'Feature', type: 'Feature',
geometry: { geometry: {
type: 'Point', type: 'Point',
coordinates: [trkpt.getLongitude(), trkpt.getLatitude()], coordinates: [lng[i], lat[i]],
}, },
properties: { properties: {
distance, distance,
@@ -122,7 +123,7 @@ export class DistanceMarkers {
} as GeoJSON.Feature); } as GeoJSON.Feature);
currentTargetDistance += 1; currentTargetDistance += 1;
} }
}); }
return { return {
type: 'FeatureCollection', type: 'FeatureCollection',
@@ -1,4 +1,5 @@
import { get, type Readable } from 'svelte/store'; import { get, type Readable } from 'svelte/store';
import type { GPXFile } from 'gpx';
import maplibregl, { import maplibregl, {
type GeoJSONSource, type GeoJSONSource,
type FilterSpecification, type FilterSpecification,
@@ -7,79 +8,21 @@ import maplibregl, {
} from 'maplibre-gl'; } from 'maplibre-gl';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import { waypointPopup, trackpointPopup } from './gpx-layer-popup'; import { waypointPopup, trackpointPopup } from './gpx-layer-popup';
import { import { closestPointIndex } from '$lib/closest-point';
ListTrackSegmentItem, import { getElevation, loadSVGIcon } from '$lib/utils';
ListWaypointItem,
ListWaypointsItem,
ListTrackItem,
ListFileItem,
ListRootItem,
} from '$lib/components/file-list/file-list';
import { getClosestLinePoint, getElevation, loadSVGIcon } from '$lib/utils';
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint'; import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
import { MapPin, Square } from 'lucide-static'; import { MapPin, Square } from 'lucide-static';
import { getSymbolKey, symbols } from '$lib/assets/symbols'; import { getSymbolKey, symbols } from '$lib/assets/symbols';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree'; import { fileStateCollection } from '$lib/logic/file-state';
import { selection } from '$lib/logic/selection'; import { engine, type FileState, type Selection } from '$lib/engine';
import { isCovered, hasSelectionWithin, type FileTreeNode } from '$lib/selection-helpers';
import { isSegmentHidden, isWaypointHidden } from '$lib/file-visibility';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { currentTool, Tool } from '$lib/components/toolbar/tools'; import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { fileActionManager } from '$lib/logic/file-action-manager';
import { fileActions } from '$lib/logic/file-actions'; import { fileActions } from '$lib/logic/file-actions';
import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors'; import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor'; import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style'; import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import { gpxColors } from '$lib/components/map/gpx-layer/gpx-layers';
const colors = [
'#ff0000',
'#0000ff',
'#46e646',
'#00ccff',
'#ff9900',
'#ff00ff',
'#ffff32',
'#288228',
'#9933ff',
'#50f0be',
'#8c645a',
];
const colorCount: { [key: string]: number } = {};
for (let color of colors) {
colorCount[color] = 0;
}
// Get the color with the least amount of uses
function getColor(fileId: string) {
let color = colors.reduce((a, b) => (colorCount[a] <= colorCount[b] ? a : b));
colorCount[color]++;
gpxColors.update((colors) => {
colors.set(fileId, color);
return colors;
});
return color;
}
function replaceColor(fileId: string, oldColor: string, newColor: string) {
if (colorCount.hasOwnProperty(oldColor)) {
colorCount[oldColor]--;
}
colorCount[newColor]++;
gpxColors.update((colors) => {
colors.set(fileId, newColor);
return colors;
});
}
function removeColor(fileId: string, color: string) {
if (colorCount.hasOwnProperty(color)) {
colorCount[color]--;
}
gpxColors.update((colors) => {
colors.delete(fileId);
return colors;
});
}
export function getSvgForSymbol(symbol?: string | undefined, layerColor?: string | undefined) { export function getSvgForSymbol(symbol?: string | undefined, layerColor?: string | undefined) {
let symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined; let symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
@@ -115,8 +58,9 @@ const { directionMarkers, treeFileView, defaultOpacity, defaultWidth } = setting
export class GPXLayer { export class GPXLayer {
fileId: string; fileId: string;
file: Readable<GPXFileWithStatistics | undefined>; file: Readable<FileState>;
layerColor: string; layerColor: string = '';
selectionState: Selection = { type: 'empty' };
selected: boolean = false; selected: boolean = false;
currentWaypointData: GeoJSON.FeatureCollection | null = null; currentWaypointData: GeoJSON.FeatureCollection | null = null;
draggedWaypointIndex: number | null = null; draggedWaypointIndex: number | null = null;
@@ -144,10 +88,9 @@ export class GPXLayer {
waypointLayerOnMouseUpBinded: (e: MapLayerMouseEvent | MapLayerTouchEvent) => void = waypointLayerOnMouseUpBinded: (e: MapLayerMouseEvent | MapLayerTouchEvent) => void =
this.waypointLayerOnMouseUp.bind(this); this.waypointLayerOnMouseUp.bind(this);
constructor(fileId: string, file: Readable<GPXFileWithStatistics | undefined>) { constructor(fileId: string, file: Readable<FileState>) {
this.fileId = fileId; this.fileId = fileId;
this.file = file; this.file = file;
this.layerColor = getColor(fileId);
this.unsubscribe.push( this.unsubscribe.push(
map.subscribe(($map) => { map.subscribe(($map) => {
if ($map) { if ($map) {
@@ -158,8 +101,9 @@ export class GPXLayer {
); );
this.unsubscribe.push(file.subscribe(this.updateBinded)); this.unsubscribe.push(file.subscribe(this.updateBinded));
this.unsubscribe.push( this.unsubscribe.push(
selection.subscribe(($selection) => { engine.selection.subscribe(($selection) => {
let newSelected = $selection.hasAnyChildren(new ListFileItem(this.fileId)); this.selectionState = $selection;
let newSelected = hasSelectionWithin($selection, this.fileNode());
if (this.selected || newSelected) { if (this.selected || newSelected) {
this.selected = newSelected; this.selected = newSelected;
this.update(); this.update();
@@ -172,21 +116,29 @@ export class GPXLayer {
this.unsubscribe.push(directionMarkers.subscribe(this.updateBinded)); this.unsubscribe.push(directionMarkers.subscribe(this.updateBinded));
} }
fileNode(): FileTreeNode {
return { type: 'file', fileId: this.fileId };
}
segmentNode(properties: GeoJSON.GeoJsonProperties): FileTreeNode {
return {
type: 'segment',
fileId: this.fileId,
trackId: properties!.trackId,
segmentId: properties!.segmentId,
};
}
update() { update() {
const _map = get(map); const _map = get(map);
const layerEventManager = map.layerEventManager; const layerEventManager = map.layerEventManager;
let file = get(this.file)?.file; const state = get(this.file);
if (!_map || !layerEventManager || !file) { if (!_map || !layerEventManager) {
return; return;
} }
if ( if (this.layerColor !== state.color) {
file._data.style && this.layerColor = state.color;
file._data.style.color &&
this.layerColor !== `#${file._data.style.color}`
) {
replaceColor(this.fileId, this.layerColor, `#${file._data.style.color}`);
this.layerColor = `#${file._data.style.color}`;
} }
this.loadIcons(); this.loadIcons();
@@ -228,16 +180,16 @@ export class GPXLayer {
layerEventManager.on('mousemove', this.fileId, this.layerOnMouseMoveBinded); layerEventManager.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
} }
let visibleTrackSegmentIds: string[] = []; const visibleSegmentIds = state.segments.features
file.forEachSegment((segment, trackIndex, segmentIndex) => { .filter(
if (!segment._data.hidden) { ({ properties }) =>
visibleTrackSegmentIds.push(`${trackIndex}-${segmentIndex}`); !isSegmentHidden(state, properties.trackId, properties.segmentId)
} )
}); .map(({ properties }) => properties.segmentId);
const segmentFilter: FilterSpecification = [ const segmentFilter: FilterSpecification = [
'in', 'in',
['get', 'trackSegmentId'], ['get', 'segmentId'],
['literal', visibleTrackSegmentIds], ['literal', visibleSegmentIds],
]; ];
_map.setFilter(this.fileId, segmentFilter, { validate: false }); _map.setFilter(this.fileId, segmentFilter, { validate: false });
@@ -334,12 +286,9 @@ export class GPXLayer {
); );
} }
let visibleWaypoints: number[] = []; const visibleWaypoints = state.waypoints.features
file.wpt.forEach((waypoint, waypointIndex) => { .filter(({ properties }) => !isWaypointHidden(state, properties.waypointId))
if (!waypoint._data.hidden) { .map(({ properties }) => properties.index);
visibleWaypoints.push(waypointIndex);
}
});
_map.setFilter( _map.setFilter(
this.fileId + '-waypoints', this.fileId + '-waypoints',
@@ -413,8 +362,6 @@ export class GPXLayer {
} }
this.unsubscribe.forEach((unsubscribe) => unsubscribe()); this.unsubscribe.forEach((unsubscribe) => unsubscribe());
removeColor(this.fileId, this.layerColor);
} }
moveToFront() { moveToFront() {
@@ -434,14 +381,9 @@ export class GPXLayer {
} }
layerOnMouseEnter(e: any) { layerOnMouseEnter(e: any) {
let trackIndex = e.features[0].properties.trackIndex;
let segmentIndex = e.features[0].properties.segmentIndex;
if ( if (
get(currentTool) === Tool.SCISSORS && get(currentTool) === Tool.SCISSORS &&
get(selection).hasAnyParent( isCovered(this.selectionState, this.segmentNode(e.features[0].properties))
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
)
) { ) {
mapCursor.notify(MapCursorState.SCISSORS, true); mapCursor.notify(MapCursorState.SCISSORS, true);
} else { } else {
@@ -456,69 +398,65 @@ export class GPXLayer {
layerOnMouseMove(e: any) { layerOnMouseMove(e: any) {
if (e.originalEvent.shiftKey) { if (e.originalEvent.shiftKey) {
let trackIndex = e.features[0].properties.trackIndex; const { segmentId } = e.features[0].properties;
let segmentIndex = e.features[0].properties.segmentIndex; const lngLat = { lng: e.lngLat.lng, lat: e.lngLat.lat };
const index = closestPointIndex(engine.segmentCoordinates(segmentId), lngLat);
const file = get(this.file)?.file; const trackpoint =
if (file) { index === undefined ? undefined : engine.trackpoint(this.fileId, segmentId, index);
const closest = getClosestLinePoint( if (trackpoint) {
file.trk[trackIndex].trkseg[segmentIndex].trkpt, trackpointPopup?.setItem({
{ lat: e.lngLat.lat, lon: e.lngLat.lng } item: trackpoint,
); kind: 'trackpoint',
trackpointPopup?.setItem({ item: closest, fileId: this.fileId }); fileId: this.fileId,
});
} }
} }
} }
layerOnClick(e: MapLayerMouseEvent) { layerOnClick(e: MapLayerMouseEvent) {
const properties = e.features![0].properties!;
const { trackId, segmentId, trackIndex, segmentIndex } = properties;
const selectionType = this.selectionState.type;
if ( if (
get(currentTool) === Tool.ROUTING && get(currentTool) === Tool.ROUTING &&
get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints']) selectionType !== 'empty' &&
selectionType !== 'waypoints' &&
selectionType !== 'waypoint'
) { ) {
return; return;
} }
let trackIndex = e.features![0].properties!.trackIndex;
let segmentIndex = e.features![0].properties!.segmentIndex;
if ( if (
get(currentTool) === Tool.SCISSORS && get(currentTool) === Tool.SCISSORS &&
get(selection).hasAnyParent( isCovered(this.selectionState, this.segmentNode(properties))
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
)
) { ) {
if (get(map)?.queryRenderedFeatures(e.point, { layers: ['split-controls'] }).length) { if (get(map)?.queryRenderedFeatures(e.point, { layers: ['split-controls'] }).length) {
// Clicked on split control, ignoring // Clicked on split control, ignoring
return; return;
} }
fileActions.split(get(splitAs), this.fileId, trackIndex, segmentIndex, { // TODO the engine can split (Command::Split) but needs the index of the trackpoint
lat: e.lngLat.lat, // fileActions.split(get(splitAs), this.fileId, trackIndex, segmentIndex, {
lon: e.lngLat.lng, // lat: e.lngLat.lat,
}); // lon: e.lngLat.lng,
// });
return; return;
} }
let file = get(this.file)?.file; const mode = e.originalEvent.ctrlKey || e.originalEvent.metaKey ? 'add' : 'replace';
if (!file) { const { tracks } = get(this.file).structure;
return; const segmentCount = tracks.reduce((count, track) => count + track.segments.length, 0);
} if (get(treeFileView) && segmentCount > 1) {
let item = undefined;
if (get(treeFileView) && file.getSegments().length > 1) {
// Select inner item // Select inner item
item = const track = tracks.find((track) => track.id === trackId);
file.children[trackIndex].children.length > 1 if (track && track.segments.length > 1) {
? new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex) engine.selectSegments(this.fileId, trackId, [segmentId], mode);
: new ListTrackItem(this.fileId, trackIndex);
} else { } else {
item = new ListFileItem(this.fileId); engine.selectTracks(this.fileId, [trackId], mode);
} }
if (e.originalEvent.ctrlKey || e.originalEvent.metaKey) {
selection.addSelectItem(item);
} else { } else {
selection.selectItem(item); engine.select([this.fileId], mode);
} }
} }
@@ -532,18 +470,19 @@ export class GPXLayer {
if (this.draggedWaypointIndex !== null) { if (this.draggedWaypointIndex !== null) {
return; return;
} }
let file = get(this.file)?.file;
if (!file) {
return;
}
let waypointIndex = e.features![0].properties!.waypointIndex; this.showWaypointPopup(e.features![0].properties!.waypointId);
let waypoint = file.wpt[waypointIndex];
waypointPopup?.setItem({ item: waypoint, fileId: this.fileId });
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, true); mapCursor.notify(MapCursorState.WAYPOINT_HOVER, true);
} }
showWaypointPopup(waypointId: string) {
const waypoint = engine.waypoint(this.fileId, waypointId);
if (waypoint) {
waypointPopup?.setItem({ item: waypoint, kind: 'waypoint', fileId: this.fileId });
}
}
waypointLayerOnMouseLeave() { waypointLayerOnMouseLeave() {
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, false); mapCursor.notify(MapCursorState.WAYPOINT_HOVER, false);
} }
@@ -551,41 +490,36 @@ export class GPXLayer {
waypointLayerOnClick(e: MapLayerMouseEvent) { waypointLayerOnClick(e: MapLayerMouseEvent) {
e.preventDefault(); e.preventDefault();
let waypointIndex = e.features![0].properties!.waypointIndex; const { waypointId } = e.features![0].properties!;
let file = get(this.file)?.file;
if (!file) {
return;
}
let waypoint = file.wpt[waypointIndex];
if (get(currentTool) === Tool.WAYPOINT) { if (get(currentTool) === Tool.WAYPOINT) {
if (this.selected) { if (this.selected) {
if (e.originalEvent.shiftKey) { if (e.originalEvent.shiftKey) {
fileActions.deleteWaypoint(this.fileId, waypointIndex); engine.deleteWaypoint(this.fileId, waypointId);
} else { } else {
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex)); engine.selectWaypoints(this.fileId, [waypointId]);
selectedWaypoint.set([waypoint, this.fileId]); selectedWaypoint.set({ fileId: this.fileId, id: waypointId });
} }
} else { } else {
if (get(treeFileView)) { if (get(treeFileView)) {
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex)); engine.selectWaypoints(this.fileId, [waypointId]);
} else { } else {
selection.selectItem(new ListFileItem(this.fileId)); engine.select([this.fileId]);
} }
selectedWaypoint.set([waypoint, this.fileId]); selectedWaypoint.set({ fileId: this.fileId, id: waypointId });
} }
} else { } else {
if (get(treeFileView)) { if (get(treeFileView)) {
if ((e.originalEvent.ctrlKey || e.originalEvent.metaKey) && this.selected) { const add = e.originalEvent.ctrlKey || e.originalEvent.metaKey;
selection.addSelectItem(new ListWaypointItem(this.fileId, waypointIndex)); engine.selectWaypoints(
} else { this.fileId,
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex)); [waypointId],
} add && this.selected ? 'add' : 'replace'
);
} else { } else {
if (!this.selected) { if (!this.selected) {
selection.selectItem(new ListFileItem(this.fileId)); engine.select([this.fileId]);
} }
waypointPopup?.setItem({ item: waypoint, fileId: this.fileId }); this.showWaypointPopup(waypointId);
} }
} }
} }
@@ -680,115 +614,84 @@ export class GPXLayer {
return; return;
} }
// the dragged waypoint, as it is in the features of the source
const waypointId =
this.currentWaypointData?.features[this.draggedWaypointIndex].properties?.waypointId;
getElevation([ getElevation([
{ {
lat: e.lngLat.lat, lat: e.lngLat.lat,
lon: e.lngLat.lng, lon: e.lngLat.lng,
}, },
]).then((ele) => { ]).then(async (ele) => {
if (this.draggedWaypointIndex === null) { if (this.draggedWaypointIndex === null) {
return; return;
} }
fileActionManager.applyToFile(this.fileId, (file) => { if (waypointId) {
let wpt = file.wpt[this.draggedWaypointIndex!]; await engine.moveWaypoint(
wpt.setCoordinates({ this.fileId,
lat: e.lngLat.lat, waypointId,
lon: e.lngLat.lng, e.lngLat.lng,
}); e.lngLat.lat,
wpt.ele = ele[0]; ele[0]
}); );
}
this.draggedWaypointIndex = null; this.draggedWaypointIndex = null;
}); });
} }
getGeoJSON(): GeoJSON.FeatureCollection { getGeoJSON(): GeoJSON.FeatureCollection {
let file = get(this.file)?.file; const state = get(this.file);
if (!file) { const waypointsSelected = hasSelectionWithin(this.selectionState, {
type: 'waypoints',
fileId: this.fileId,
});
return { return {
type: 'FeatureCollection', type: 'FeatureCollection',
features: [], features: state.segments.features.map((feature) => {
const properties = {
...feature.properties,
opacity: feature.properties.opacity ?? get(defaultOpacity),
width: feature.properties.width ?? get(defaultWidth),
}; };
}
let data = file.toGeoJSON();
let trackIndex = 0,
segmentIndex = 0;
for (let feature of data.features) {
if (!feature.properties) {
feature.properties = {};
}
if (!feature.properties.color) {
feature.properties.color = this.layerColor;
}
if (!feature.properties.opacity) {
feature.properties.opacity = get(defaultOpacity);
}
if (!feature.properties.width) {
feature.properties.width = get(defaultWidth);
}
if ( if (
get(selection).hasAnyParent( isCovered(this.selectionState, this.segmentNode(properties)) ||
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex) waypointsSelected
) ||
get(selection).hasAnyChildren(new ListWaypointsItem(this.fileId), true)
) { ) {
feature.properties.width = feature.properties.width + 2; properties.width = properties.width + 2;
feature.properties.opacity = Math.min(1, feature.properties.opacity + 0.1); properties.opacity = Math.min(1, properties.opacity + 0.1);
} }
feature.properties.trackIndex = trackIndex; return { ...feature, properties };
feature.properties.segmentIndex = segmentIndex; }),
feature.properties.trackSegmentId = `${trackIndex}-${segmentIndex}`; };
segmentIndex++;
if (segmentIndex >= file.trk[trackIndex].trkseg.length) {
segmentIndex = 0;
trackIndex++;
}
}
return data;
} }
getWaypointsGeoJSON(): GeoJSON.FeatureCollection { getWaypointsGeoJSON(): GeoJSON.FeatureCollection {
let file = get(this.file)?.file; return {
let data: GeoJSON.FeatureCollection = {
type: 'FeatureCollection', type: 'FeatureCollection',
features: [], features: get(this.file).waypoints.features.map((feature) => ({
};
if (!file) {
return data;
}
file.wpt.forEach((waypoint, index) => {
data.features.push({
type: 'Feature', type: 'Feature',
geometry: { // copied: the coordinates are changed while a waypoint is dragged
type: 'Point', geometry: { type: 'Point', coordinates: [...feature.geometry.coordinates] },
coordinates: [waypoint.getLongitude(), waypoint.getLatitude()],
},
properties: { properties: {
fileId: this.fileId, fileId: this.fileId,
waypointIndex: index, waypointId: feature.properties.waypointId,
icon: `waypoint-${getSymbolKey(waypoint.sym) ?? 'default'}-${this.layerColor}`, waypointIndex: feature.properties.index,
icon: `waypoint-${getSymbolKey(feature.properties.sym) ?? 'default'}-${this.layerColor}`,
}, },
}); })),
}); };
return data;
} }
loadIcons() { loadIcons() {
const _map = get(map); const _map = get(map);
let file = get(this.file)?.file; if (!_map) {
if (!_map || !file) {
return; return;
} }
let symbols = new Set<string | undefined>(); let symbols = new Set<string | undefined>();
file.wpt.forEach((waypoint) => { get(this.file).waypoints.features.forEach((feature) => {
symbols.add(getSymbolKey(waypoint.sym)); symbols.add(getSymbolKey(feature.properties.sym));
}); });
symbols.forEach((symbol) => { symbols.forEach((symbol) => {
@@ -1,10 +1,10 @@
import { GPXFileStateCollectionObserver } from '$lib/logic/file-state'; import { FileStateCollectionObserver } from '$lib/engine';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import { GPXLayer } from './gpx-layer'; import { GPXLayer } from './gpx-layer';
export class GPXLayerCollection { export class GPXLayerCollection {
private _layers: Map<string, GPXLayer>; private _layers: Map<string, GPXLayer>;
private _fileStateCollectionObserver: GPXFileStateCollectionObserver | null = null; private _fileStateCollectionObserver: FileStateCollectionObserver | null = null;
constructor() { constructor() {
this._layers = new Map<string, GPXLayer>(); this._layers = new Map<string, GPXLayer>();
@@ -14,10 +14,10 @@ export class GPXLayerCollection {
if (this._fileStateCollectionObserver) { if (this._fileStateCollectionObserver) {
return; return;
} }
this._fileStateCollectionObserver = new GPXFileStateCollectionObserver( this._fileStateCollectionObserver = new FileStateCollectionObserver(
(newFiles) => { (newFiles) => {
newFiles.forEach((fileState, fileId) => { newFiles.forEach((fileStore, fileId) => {
const layer = new GPXLayer(fileId, fileState); const layer = new GPXLayer(fileId, fileStore);
this._layers.set(fileId, layer); this._layers.set(fileId, layer);
}); });
}, },
@@ -1,11 +1,13 @@
import { currentTool, Tool } from '$lib/components/toolbar/tools'; import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics'; import { hoveredPoint } from '$lib/logic/statistics';
import type { GeoJSONSource } from 'maplibre-gl'; import type { GeoJSONSource } from 'maplibre-gl';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import { allHidden } from '$lib/logic/hidden'; import { allHidden } from '$lib/all-hidden';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style'; import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import { loadSVGIcon } from '$lib/utils'; import { loadSVGIcon } from '$lib/utils';
import { slicedStatistics } from '$lib/logic/selection-statistics';
import { engine } from '$lib/engine';
const startMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"> const startMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="6" fill="#22c55e" stroke="white" stroke-width="1.5"/> <circle cx="8" cy="8" r="6" fill="#22c55e" stroke="white" stroke-width="1.5"/>
@@ -26,14 +28,16 @@ const hoverMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="h
<circle cx="8" cy="8" r="6" fill="#00b8db" stroke="white" stroke-width="1.5"/> <circle cx="8" cy="8" r="6" fill="#00b8db" stroke="white" stroke-width="1.5"/>
</svg>`; </svg>`;
const { statistics } = engine;
export class StartEndMarkers { export class StartEndMarkers {
updateBinded: () => void = this.update.bind(this); updateBinded: () => void = this.update.bind(this);
unsubscribes: (() => void)[] = []; unsubscribes: (() => void)[] = [];
constructor() { constructor() {
map.onLoad((map_) => map_.on('style.load', this.updateBinded)); map.onLoad((map_) => map_.on('style.load', this.updateBinded));
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded)); this.unsubscribes.push(statistics.subscribe(this.updateBinded));
this.unsubscribes.push(slicedGPXStatistics.subscribe(this.updateBinded)); this.unsubscribes.push(slicedStatistics.subscribe(this.updateBinded));
this.unsubscribes.push(hoveredPoint.subscribe(this.updateBinded)); this.unsubscribes.push(hoveredPoint.subscribe(this.updateBinded));
this.unsubscribes.push(currentTool.subscribe(this.updateBinded)); this.unsubscribes.push(currentTool.subscribe(this.updateBinded));
this.unsubscribes.push(allHidden.subscribe(this.updateBinded)); this.unsubscribes.push(allHidden.subscribe(this.updateBinded));
@@ -46,8 +50,8 @@ export class StartEndMarkers {
this.loadIcons(); this.loadIcons();
const tool = get(currentTool); const tool = get(currentTool);
const statistics = get(gpxStatistics); const stats = get(statistics);
const slicedStatistics = get(slicedGPXStatistics); const sliced = get(slicedStatistics);
const hovered = get(hoveredPoint); const hovered = get(hoveredPoint);
const hidden = get(allHidden); const hidden = get(allHidden);
if (!hidden) { if (!hidden) {
@@ -56,18 +60,17 @@ export class StartEndMarkers {
features: [], features: [],
}; };
if (statistics.global.length > 0 && tool !== Tool.ROUTING) { if (stats.length > 0 && tool !== Tool.ROUTING) {
const start = statistics const start = [stats.lng[sliced?.start ?? 0], stats.lat[sliced?.start ?? 0]];
.getTrackPoint(slicedStatistics?.[1] ?? 0)! const end = [
.trkpt.getCoordinates(); stats.lng[sliced?.end ?? stats.length - 1],
const end = statistics stats.lat[sliced?.end ?? stats.length - 1],
.getTrackPoint(slicedStatistics?.[2] ?? statistics.global.length - 1)! ];
.trkpt.getCoordinates();
data.features.push({ data.features.push({
type: 'Feature', type: 'Feature',
geometry: { geometry: {
type: 'Point', type: 'Point',
coordinates: [start.lon, start.lat], coordinates: start,
}, },
properties: { properties: {
icon: 'start-marker', icon: 'start-marker',
@@ -77,7 +80,7 @@ export class StartEndMarkers {
type: 'Feature', type: 'Feature',
geometry: { geometry: {
type: 'Point', type: 'Point',
coordinates: [end.lon, end.lat], coordinates: end,
}, },
properties: { properties: {
icon: 'end-marker', icon: 'end-marker',
@@ -4,10 +4,10 @@
import { PencilLine, MapPin } from '@lucide/svelte'; import { PencilLine, MapPin } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js'; import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import type { WaypointType } from 'gpx';
import type { PopupItem } from '$lib/components/map/map-popup'; import type { PopupItem } from '$lib/components/map/map-popup';
import { fileActions } from '$lib/logic/file-actions'; import { engine } from '$lib/engine';
import { selection } from '$lib/logic/selection'; import { selectionSize } from '$lib/selection-helpers';
import { getElevation } from '$lib/utils';
let { let {
poi, poi,
@@ -27,28 +27,22 @@
return ''; return '';
}); });
function addToFile() { const { selection } = engine;
async function addToFile() {
const desc = Object.entries(tags) const desc = Object.entries(tags)
.map(([key, value]) => `${key}: ${value}`) .map(([key, value]) => `${key}: ${value}`)
.join('\n'); .join('\n');
let wpt: WaypointType = { const [ele] = await getElevation([{ lat: poi.item.lat, lon: poi.item.lng }]);
attributes: { await engine.newWaypoint({
lng: poi.item.lng,
lat: poi.item.lat, lat: poi.item.lat,
lon: poi.item.lon, ele,
}, name,
name: name, desc,
desc: desc, icon: poi.item.sym ?? '',
cmt: desc, link: tags.website ?? '',
sym: poi.item.sym, });
};
if (tags.website) {
wpt.link = {
attributes: {
href: tags.website,
},
};
}
fileActions.addOrUpdateWaypoint(wpt);
} }
</script> </script>
@@ -58,7 +52,7 @@
<div class="flex flex-col"> <div class="flex flex-col">
<p>{name}</p> <p>{name}</p>
<div class="text-muted-foreground text-xs font-normal"> <div class="text-muted-foreground text-xs font-normal">
{poi.item.lat.toFixed(6)}&deg; {poi.item.lon.toFixed(6)}&deg; {poi.item.lat.toFixed(6)}&deg; {poi.item.lng.toFixed(6)}&deg;
</div> </div>
</div> </div>
@@ -111,7 +105,7 @@
size="sm" size="sm"
class="mt-1 justify-start" class="mt-1 justify-start"
variant="outline" variant="outline"
disabled={$selection.size === 0} disabled={selectionSize($selection) === 0}
onclick={addToFile} onclick={addToFile}
> >
<MapPin size="14" /> <MapPin size="14" />
@@ -143,6 +143,7 @@ export class OverpassLayer {
onHover(e: any) { onHover(e: any) {
this.popup.setItem({ this.popup.setItem({
kind: 'overpass',
item: { item: {
...e.features[0].properties, ...e.features[0].properties,
sym: overpassQueryData[e.features[0].properties.query].symbol ?? '', sym: overpassQueryData[e.features[0].properties.query].symbol ?? '',
@@ -235,7 +236,7 @@ export class OverpassLayer {
properties: { properties: {
id: element.id, id: element.id,
lat: element.center ? element.center.lat : element.lat, lat: element.center ? element.center.lat : element.lat,
lon: element.center ? element.center.lon : element.lon, lng: element.center ? element.center.lon : element.lon,
query: query, query: query,
icon: `overpass-${query}`, icon: `overpass-${query}`,
tags: element.tags, tags: element.tags,
+5 -5
View File
@@ -1,11 +1,13 @@
import { TrackPoint, Waypoint } from 'gpx';
import maplibregl from 'maplibre-gl'; import maplibregl from 'maplibre-gl';
import { mount, tick, unmount } from 'svelte'; import { mount, tick, unmount } from 'svelte';
import { get, writable, type Writable } from 'svelte/store'; import { get, writable, type Writable } from 'svelte/store';
import MapPopupComponent from '$lib/components/map/MapPopup.svelte'; import MapPopupComponent from '$lib/components/map/MapPopup.svelte';
export type PopupItem<T = Waypoint | TrackPoint | any> = { export type PopupItem<T = any> = {
/** Has a position (`lng` and `lat`). */
item: T; item: T;
/** What the item is: a `WaypointDetails`, a `TrackpointDetails` or an Overpass element. */
kind: 'waypoint' | 'trackpoint' | 'overpass';
fileId?: string; fileId?: string;
hide?: () => void; hide?: () => void;
}; };
@@ -77,8 +79,6 @@ export class MapPopup {
if (item === null) { if (item === null) {
return new maplibregl.LngLat(0, 0); return new maplibregl.LngLat(0, 0);
} }
return item.item instanceof Waypoint || item.item instanceof TrackPoint return new maplibregl.LngLat(item.item.lng, item.item.lat);
? item.item.getCoordinates()
: new maplibregl.LngLat(item.item.lon, item.item.lat);
} }
} }
@@ -17,8 +17,7 @@
import { Trash2 } from '@lucide/svelte'; import { Trash2 } from '@lucide/svelte';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import type { GeoJSONSource } from 'maplibre-gl'; import type { GeoJSONSource } from 'maplibre-gl';
import { selection } from '$lib/logic/selection'; import { engine } from '$lib/engine';
import { fileActions } from '$lib/logic/file-actions';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor'; import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style'; import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
@@ -130,7 +129,8 @@
} }
}); });
let validSelection = $derived($selection.size > 0); const selection = engine.selection;
let validSelection = $derived($selection.type !== 'empty');
</script> </script>
<div class="flex flex-col gap-3 w-full max-w-80 items-center {props.class ?? ''}"> <div class="flex flex-col gap-3 w-full max-w-80 items-center {props.class ?? ''}">
@@ -163,17 +163,13 @@
class="w-full" class="w-full"
disabled={!validSelection || rectangleCoordinates.length != 2} disabled={!validSelection || rectangleCoordinates.length != 2}
onclick={() => { onclick={() => {
fileActions.cleanSelection( engine.clean(
[
{ {
lat: Math.min(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat), west: Math.min(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng),
lon: Math.min(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng), south: Math.min(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
east: Math.max(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng),
north: Math.max(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
}, },
{
lat: Math.max(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
lon: Math.max(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng),
},
],
cleanType === CleanType.INSIDE, cleanType === CleanType.INSIDE,
deleteTrackpoints, deleteTrackpoints,
deleteWaypoints deleteWaypoints
@@ -5,15 +5,15 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Select from '$lib/components/ui/select'; import * as Select from '$lib/components/ui/select';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { ListWaypointItem } from '$lib/components/file-list/file-list';
import Help from '$lib/components/Help.svelte'; import Help from '$lib/components/Help.svelte';
import { onDestroy, onMount, untrack } from 'svelte'; import { onDestroy, onMount, untrack } from 'svelte';
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
import { MapPin, CircleX, Save } from '@lucide/svelte'; import { MapPin, CircleX, Save } from '@lucide/svelte';
import { getSymbolKey, symbols } from '$lib/assets/symbols'; import { getSymbolKey, symbols } from '$lib/assets/symbols';
import { selection } from '$lib/logic/selection'; import { engine } from '$lib/engine';
import { selectionSize } from '$lib/selection-helpers';
import { getElevation } from '$lib/utils';
import { selectedWaypoint } from './waypoint'; import { selectedWaypoint } from './waypoint';
import { fileActions } from '$lib/logic/file-actions';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor'; import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import maplibregl from 'maplibre-gl'; import maplibregl from 'maplibre-gl';
@@ -31,7 +31,9 @@
let latitude = $state(0); let latitude = $state(0);
let symbolKey = $derived(getSymbolKey(sym)); let symbolKey = $derived(getSymbolKey(sym));
let canCreate = $derived($selection.size > 0); const { selection } = engine;
let canCreate = $derived(selectionSize($selection) > 0);
let sortedSymbols = $derived( let sortedSymbols = $derived(
Object.entries(symbols).sort((a, b) => { Object.entries(symbols).sort((a, b) => {
@@ -58,24 +60,24 @@
$effect(() => { $effect(() => {
if ($selectedWaypoint) { if ($selectedWaypoint) {
const wpt = $selectedWaypoint[0]; const wpt = $selectedWaypoint.waypoint;
untrack(() => { untrack(() => {
name = wpt.name ?? ''; name = wpt.name ?? '';
description = wpt.desc ?? ''; description = wpt.desc ?? '';
if (wpt.cmt !== undefined && wpt.cmt !== wpt.desc) { if (wpt.cmt !== undefined && wpt.cmt !== wpt.desc) {
description += '\n\n' + wpt.cmt; description += '\n\n' + wpt.cmt;
} }
link = wpt.link?.attributes?.href ?? ''; link = wpt.link?.href ?? '';
sym = wpt.sym ?? ''; sym = wpt.sym ?? '';
longitude = parseFloat(wpt.getLongitude().toFixed(6)); longitude = parseFloat(wpt.lng.toFixed(6));
latitude = parseFloat(wpt.getLatitude().toFixed(6)); latitude = parseFloat(wpt.lat.toFixed(6));
}); });
} else { } else {
untrack(reset); untrack(reset);
} }
}); });
function createOrUpdateWaypoint() { async function createOrUpdateWaypoint() {
if (typeof latitude === 'string') { if (typeof latitude === 'string') {
latitude = parseFloat(latitude); latitude = parseFloat(latitude);
} }
@@ -85,27 +87,36 @@
latitude = parseFloat(latitude.toFixed(6)); latitude = parseFloat(latitude.toFixed(6));
longitude = parseFloat(longitude.toFixed(6)); longitude = parseFloat(longitude.toFixed(6));
fileActions.addOrUpdateWaypoint( const target = $selectedWaypoint;
{ const [ele] = await getElevation([{ lat: latitude, lon: longitude }]);
attributes: { const waypoint = {
lng: longitude,
lat: latitude, lat: latitude,
lon: longitude, ele,
}, name,
name: name.length > 0 ? name : undefined, desc: description,
desc: description.length > 0 ? description : undefined, icon: sym,
cmt: description.length > 0 ? description : undefined, link,
link: link.length > 0 ? { attributes: { href: link } } : undefined, };
sym: sym.length > 0 ? sym : undefined, if (target) {
}, engine.updateWaypoint(target.fileId, target.id, waypoint);
selectedWaypoint.wpt && selectedWaypoint.fileId } else {
? new ListWaypointItem(selectedWaypoint.fileId, selectedWaypoint.wpt._data.index) // it goes in the selected files
: undefined engine.newWaypoint(waypoint);
); }
reset(); reset();
} }
function setCoordinates(e: any) { function setCoordinates(e: any) {
if (
$map?.queryRenderedFeatures(e.point, {
layers: $map.getLayersOrder().filter((layerId) => layerId.endsWith('-waypoints')),
}).length
) {
// Clicked on a waypoint, ignoring
return;
}
latitude = e.lngLat.lat.toFixed(6); latitude = e.lngLat.lat.toFixed(6);
longitude = e.lngLat.lng.toFixed(6); longitude = e.lngLat.lng.toFixed(6);
} }
@@ -1,74 +1,107 @@
import { ListWaypointItem } from '$lib/components/file-list/file-list'; import { get, writable } from 'svelte/store';
import { fileStateCollection } from '$lib/logic/file-state'; import { engine, type WaypointDetails } from '$lib/engine';
import { selection } from '$lib/logic/selection';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import type { Waypoint } from 'gpx';
import { get, writable, type Writable } from 'svelte/store';
export type SelectedWaypoint = {
fileId: string;
id: string;
/** The data of the waypoint, kept up to date with the file. */
waypoint: WaypointDetails;
};
/**
* The waypoint that the waypoint tool is about: the selected one when the file tree is shown and
* a single waypoint is selected, or the one that was set explicitly (clicking a waypoint of the
* map) until the selection changes to something else than its file or itself (that is what
* clicking it selects, depending on the view, and the selection may arrive after it is set).
*/
export class WaypointSelection { export class WaypointSelection {
private _selection: Writable<[Waypoint, string] | undefined>; private _selection = writable<SelectedWaypoint | undefined>(undefined);
private _fileUnsubscribe: (() => void) | undefined; private _fileUnsubscribe: (() => void) | undefined;
private _explicit: { fileId: string; id: string } | undefined;
constructor() { constructor() {
this._selection = writable(undefined); settings.treeFileView.subscribe(() => this.updateFromSelection());
settings.treeFileView.subscribe(() => { engine.selection.subscribe(() => this.updateFromSelection());
this.update();
});
selection.subscribe(() => {
this.update();
});
} }
subscribe( subscribe(
run: (value: [Waypoint, string] | undefined) => void, run: (value: SelectedWaypoint | undefined) => void,
invalidate?: (value?: [Waypoint, string] | undefined) => void invalidate?: (value?: SelectedWaypoint | undefined) => void
) { ) {
return this._selection.subscribe(run, invalidate); return this._selection.subscribe(run, invalidate);
} }
set(value: [Waypoint, string] | undefined) { /** Sets the waypoint, whatever the selection is. */
this._selection.set(value); set(target: { fileId: string; id: string } | undefined) {
this._explicit = target;
this.track(target);
} }
update() { updateFromSelection() {
if (this._fileUnsubscribe) { const selection = get(engine.selection);
this._fileUnsubscribe(); const explicit = this._explicit;
this._fileUnsubscribe = undefined; if (
explicit &&
((selection.type === 'file' &&
selection.fileIds.length === 1 &&
selection.fileIds[0] === explicit.fileId) ||
(selection.type === 'waypoint' &&
selection.fileId === explicit.fileId &&
selection.waypointIds.length === 1 &&
selection.waypointIds[0] === explicit.id))
) {
return;
} }
this._selection.update(() => { this._explicit = undefined;
if (get(settings.treeFileView) && get(selection).size === 1) { if (
let item = get(selection).getSelected()[0]; get(settings.treeFileView) &&
if (item instanceof ListWaypointItem) { selection.type === 'waypoint' &&
let fileState = fileStateCollection.getFileState(item.getFileId()); selection.waypointIds.length === 1
if (fileState) { ) {
let first = true; this.track({ fileId: selection.fileId, id: selection.waypointIds[0] });
this._fileUnsubscribe = fileState.subscribe(() => { } else {
if (first) first = false; this.track(undefined);
else this.update();
});
let waypoint = fileState.file?.wpt[item.getWaypointIndex()];
if (waypoint) {
return [waypoint, item.getFileId()];
} }
} }
}
}
return undefined;
});
}
reset() { reset() {
this._selection.set(undefined); this._explicit = undefined;
this.track(undefined);
} }
get wpt(): Waypoint | undefined { get waypoint(): WaypointDetails | undefined {
const selection = get(this._selection); return get(this._selection)?.waypoint;
return selection ? selection[0] : undefined;
} }
get fileId(): string | undefined { get fileId(): string | undefined {
const selection = get(this._selection); return get(this._selection)?.fileId;
return selection ? selection[1] : undefined; }
get id(): string | undefined {
return get(this._selection)?.id;
}
private track(target: { fileId: string; id: string } | undefined) {
this._fileUnsubscribe?.();
this._fileUnsubscribe = undefined;
if (!target) {
this._selection.set(undefined);
return;
}
// read again each time the file changes
const read = () => {
const waypoint = engine.waypoint(target.fileId, target.id);
this._selection.set(waypoint ? { ...target, waypoint } : undefined);
};
const file = get(engine.files).get(target.fileId);
if (file) {
this._fileUnsubscribe = file.subscribe(read);
} else {
read();
}
} }
} }
+43
View File
@@ -0,0 +1,43 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { defaultFileName } from './default-file-name';
describe('default file name', () => {
it('is the default name when there is no other file with it', () => {
assert.equal(defaultFileName('New file', []), 'New file');
assert.equal(defaultFileName('New file', ['Morning ride', 'track']), 'New file');
});
it('is numbered when other files have the default name', () => {
assert.equal(defaultFileName('New file', ['New file']), 'New file 2');
assert.equal(defaultFileName('New file', ['New file', 'New file 2']), 'New file 3');
});
it('goes after the highest number', () => {
assert.equal(defaultFileName('New file', ['New file 4', 'New file']), 'New file 5');
// a number that was freed is not used again
assert.equal(defaultFileName('New file', ['New file', 'New file 3']), 'New file 4');
// the numbered ones count even if the first one was renamed
assert.equal(defaultFileName('New file', ['New file 2']), 'New file 3');
});
it('ignores the names that only look like it', () => {
assert.equal(defaultFileName('New file', ['New file copy', 'New files 3']), 'New file');
assert.equal(
defaultFileName('New file', ['New file 2b', 'New file 0', 'New file -3']),
'New file'
);
assert.equal(defaultFileName('New file', ['New file ', 'New file 2']), 'New file');
assert.equal(defaultFileName('New file', ['my New file']), 'New file');
});
it('works with any language', () => {
assert.equal(defaultFileName('Nouveau fichier', ['Nouveau fichier']), 'Nouveau fichier 2');
assert.equal(defaultFileName('新文件', ['新文件', '新文件 2']), '新文件 3');
// the default name of another language is just a name
assert.equal(
defaultFileName('Nouveau fichier', ['New file', 'New file 2']),
'Nouveau fichier'
);
});
});
+19
View File
@@ -0,0 +1,19 @@
/**
* The name of a new file: the default name (translated), followed by a number when other files
* already have a default name (the name alone counts as number 1): "New file", "New file 2",
* "New file 3"...
*/
export function defaultFileName(defaultName: string, existingNames: string[]): string {
let highest = 0;
for (const name of existingNames) {
if (name === defaultName) {
highest = Math.max(highest, 1);
} else if (name.startsWith(`${defaultName} `)) {
const suffix = name.slice(defaultName.length + 1);
if (/^[1-9][0-9]*$/.test(suffix)) {
highest = Math.max(highest, parseInt(suffix));
}
}
}
return highest === 0 ? defaultName : `${defaultName} ${highest + 1}`;
}
+695 -26
View File
@@ -1,8 +1,25 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { writable, type Readable } from 'svelte/store'; import { get, writable, type Readable, type Writable } from 'svelte/store';
import type { FileStructure, Selection } from 'gpx-rs'; import { FileColorAllocator, normalizeColor } from '$lib/file-colors';
import { setHidden, type Visibility } from '$lib/file-visibility';
import { selectedElementIds, type FileTreeNode } from '$lib/selection-helpers';
import type { Feature, FeatureCollection, LineString, Point } from 'geojson';
import type {
Clipboard,
GlobalStatistics,
FileStructure,
MoveTarget,
Selection,
WaypointDetails,
TrackpointDetails,
} from 'gpx-rs';
export type { export type {
Clipboard,
WaypointDetails,
TrackpointDetails,
MoveTarget,
GlobalStatistics,
FileStructure, FileStructure,
FilesUpdate, FilesUpdate,
Selection, Selection,
@@ -13,6 +30,180 @@ export type {
type Wasm = typeof import('gpx-rs'); type Wasm = typeof import('gpx-rs');
/**
* How elements combine with the current selection: they `replace` it, are `add`ed to it, or are
* `toggle`d (the ones already selected are removed). Adding and toggling only merge with elements
* of the same kind in the same place (same file, same track for segments), otherwise the new
* elements replace the selection.
*/
export type SelectMode = 'replace' | 'add' | 'toggle';
function selectMode(wasm: Wasm, mode: SelectMode) {
return {
replace: wasm.SelectMode.Replace,
add: wasm.SelectMode.Add,
toggle: wasm.SelectMode.Toggle,
}[mode];
}
export type SegmentProperties = {
fileId: string;
trackId: string;
segmentId: string;
trackIndex: number;
segmentIndex: number;
/** Changes when the trackpoints of the segment change. */
rev: string;
/** Color of the track, or else the base color of the file. */
color: string;
/** Only when the track defines it. */
opacity?: number;
width?: number;
};
export type WaypointProperties = {
fileId: string;
waypointId: string;
index: number;
name?: string;
sym?: string;
};
/** The data of a waypoint to create or to change. Empty strings are fields it does not have. */
export type NewWaypoint = {
lng: number;
lat: number;
ele: number;
name: string;
desc: string;
icon: string;
link: string;
};
/** Everything the UI knows about one file. */
export type FileState = {
structure: FileStructure;
/** Color of the file: the one defined by its tracks if any, otherwise one from the palette. */
color: string;
/** Global statistics of the file. */
statistics: GlobalStatistics;
/** One LineString feature per track segment, in file order. */
segments: FeatureCollection<LineString, SegmentProperties>;
/** One Point feature per waypoint, in file order. */
waypoints: FeatureCollection<Point, WaypointProperties>;
/**
* Visibility the user set explicitly (see `Visibility` and `isHidden`, `isSegmentHidden`,
* `isWaypointHidden` in `file-visibility`). Hidden elements are still part of the statistics.
*/
visibility: Visibility;
};
const EMPTY_STATISTICS: GlobalStatistics = { totalDistance: 0, elevationGain: 0, elevationLoss: 0 };
/**
* Timestamp of the trackpoints that have none, in `SelectionStatistics.timestamps` (the smallest
* 64-bit integer, like `NO_TIME` in the engine).
*/
export const NO_TIME = -(2n ** 63n);
/**
* The statistics of the selection: its global statistics, and the values at each of its
* trackpoints (all the selected segments, one after the other). The arrays are copies.
*
* Distances, times, moving values and elevation gain / loss are cumulative over the whole
* selection. Distances are in km, speeds in km/h, times in ms (timestamps since the epoch, as
* 64-bit integers), elevations in m and slopes in %. Missing timestamps are `NO_TIME`, missing
* measures are NaN.
*/
export type SelectionStatistics = {
global: GlobalStatistics;
/** Number of trackpoints. */
length: number;
totalDistance: Float64Array;
movingDistance: Float64Array;
/** Duration since the start of the selection, in ms. */
totalTime: BigInt64Array;
movingTime: BigInt64Array;
speed: Float64Array;
elevationGain: Float64Array;
elevationLoss: Float64Array;
/** Slope at the trackpoint. */
slope: Float64Array;
/** Slope and length (km) of the smoothed elevation segment the trackpoint belongs to. */
slopeSegmentSlope: Float64Array;
slopeSegmentDistance: Float64Array;
lng: Float64Array;
lat: Float64Array;
ele: Float64Array;
timestamps: BigInt64Array;
hr: Float64Array;
cad: Float64Array;
atemp: Float64Array;
power: Float64Array;
/** Surface of the trackpoints: 0 when unknown, else 1 + the index in `surfaces`. */
surface: Uint8Array;
/** Names of the surfaces, by code: a name keeps its index. */
surfaces: string[];
/** Highway of the trackpoints: 0 when unknown, else 1 + the index in `highways`. */
highway: Uint8Array;
/** Names of the highways, by code. */
highways: string[];
/** SAC hiking scale of the trackpoints: 0 when unknown, else 1 + the index in `sacScales`. */
sacScale: Uint8Array;
/** Names of the SAC scales, by code. */
sacScales: string[];
/** Mountain biking scale of the trackpoints: 0 when unknown, else 1 + the index in `mtbScales`. */
mtbScale: Uint8Array;
/** Names of the mountain biking scales, by code. */
mtbScales: string[];
/**
* Global statistics of the trackpoints from `start` to `end` (both included), for example
* the part of the elevation profile that was dragged over. `undefined` if the range is not
* in the selection, or if the selection is not the current one anymore.
*/
slice(start: number, end: number): GlobalStatistics | undefined;
};
const EMPTY_SELECTION_STATISTICS: SelectionStatistics = {
global: EMPTY_STATISTICS,
length: 0,
totalDistance: new Float64Array(),
movingDistance: new Float64Array(),
totalTime: new BigInt64Array(),
movingTime: new BigInt64Array(),
speed: new Float64Array(),
elevationGain: new Float64Array(),
elevationLoss: new Float64Array(),
slope: new Float64Array(),
slopeSegmentSlope: new Float64Array(),
slopeSegmentDistance: new Float64Array(),
lng: new Float64Array(),
lat: new Float64Array(),
ele: new Float64Array(),
timestamps: new BigInt64Array(),
hr: new Float64Array(),
cad: new Float64Array(),
atemp: new Float64Array(),
power: new Float64Array(),
surface: new Uint8Array(),
surfaces: [],
highway: new Uint8Array(),
highways: [],
sacScale: new Uint8Array(),
sacScales: [],
mtbScale: new Uint8Array(),
mtbScales: [],
slice: () => undefined,
};
function toPositions(flat: Float64Array): [number, number][] {
const positions: [number, number][] = new Array(flat.length / 2);
for (let i = 0; i < positions.length; i++) {
positions[i] = [flat[2 * i], flat[2 * i + 1]];
}
return positions;
}
/** Hyphenated UUID strings (as found in the file structures) to the concatenated 16-byte form. */ /** Hyphenated UUID strings (as found in the file structures) to the concatenated 16-byte form. */
function idsToBytes(ids: string[]): Uint8Array { function idsToBytes(ids: string[]): Uint8Array {
const bytes = new Uint8Array(ids.length * 16); const bytes = new Uint8Array(ids.length * 16);
@@ -29,8 +220,9 @@ function idsToBytes(ids: string[]): Uint8Array {
* Thin wrapper around the WASM engine. * Thin wrapper around the WASM engine.
* *
* The engine owns the files; this class mirrors what the UI needs in stores. After each action, * The engine owns the files; this class mirrors what the UI needs in stores. After each action,
* only what the engine reports as changed is read again: the order of the files, and the * only what the engine reports as changed is read again: the order of the files, and the state
* structure of the files that were added or modified. * of the files that were added or modified. Within a modified file, the GeoJSON features of the
* segments and waypoints that did not change are reused.
*/ */
class Engine { class Engine {
private wasm: Wasm | null = null; private wasm: Wasm | null = null;
@@ -38,16 +230,40 @@ class Engine {
readonly ready: Promise<void>; readonly ready: Promise<void>;
private _order = writable<string[]>([]); private _order = writable<string[]>([]);
private _structures = writable<Map<string, FileStructure>>(new Map()); private _colors = new FileColorAllocator();
/**
* Explicit visibility per file. Not dropped with the files: it is back when a deletion is
* undone. UI state only, it is not part of the engine's history.
*/
private _visibility = new Map<string, Visibility>();
private _files = writable<Map<string, Writable<FileState>>>(new Map());
private _selection = writable<Selection>({ type: 'empty' }); private _selection = writable<Selection>({ type: 'empty' });
private _canUndo = writable(false);
private _canRedo = writable(false);
private _canPaste = writable(false);
private _clipboard = writable<Clipboard | undefined>(undefined);
private _statistics = writable<SelectionStatistics>(EMPTY_SELECTION_STATISTICS);
/** Identifies the statistics currently in the engine's buffers. */
private _statisticsVersion = 0;
/** Ids of the files in display order. */ /** Ids of the files in display order. */
readonly order: Readable<string[]> = { subscribe: this._order.subscribe }; readonly order: Readable<string[]> = { subscribe: this._order.subscribe };
/** Statistics of what is currently selected. */
readonly statistics: Readable<SelectionStatistics> = { subscribe: this._statistics.subscribe };
readonly canUndo: Readable<boolean> = { subscribe: this._canUndo.subscribe };
readonly canRedo: Readable<boolean> = { subscribe: this._canRedo.subscribe };
/** What was copied or cut, waiting to be pasted. */
readonly clipboard: Readable<Clipboard | undefined> = { subscribe: this._clipboard.subscribe };
/** Whether the clipboard can be pasted with the current selection. */
readonly canPaste: Readable<boolean> = { subscribe: this._canPaste.subscribe };
/** What is currently selected. */ /** What is currently selected. */
readonly selection: Readable<Selection> = { subscribe: this._selection.subscribe }; readonly selection: Readable<Selection> = { subscribe: this._selection.subscribe };
/** Structure (names, tracks, segments, waypoints and their ids) of each file. */ /**
readonly structures: Readable<Map<string, FileStructure>> = { * The state of each file, in its own store. The map store only notifies when files are added
subscribe: this._structures.subscribe, * or removed; editing a file only notifies the subscribers of that file's store.
*/
readonly files: Readable<Map<string, Readable<FileState>>> = {
subscribe: this._files.subscribe,
}; };
constructor() { constructor() {
@@ -65,16 +281,38 @@ class Engine {
return this.run((w) => w.new_file(name)); return this.run((w) => w.new_file(name));
} }
loadFile(data: Uint8Array) { /**
return this.run((w) => w.load_file(data)); * Loads files as a single command (one undo step). `name` is the name of a file when its data
* has none (the name on disk, without extension). The files that cannot be read are skipped,
* the first one that was read is selected.
*/
loadFiles(files: { data: Uint8Array; name: string }[]) {
const lengths = Uint32Array.from(files.map((file) => file.data.length));
const data = new Uint8Array(lengths.reduce((sum, length) => sum + length, 0));
let offset = 0;
for (const file of files) {
data.set(file.data, offset);
offset += file.data.length;
}
return this.run((w) =>
w.load_files(
data,
lengths,
files.map((file) => file.name)
)
);
} }
duplicate() { duplicate() {
return this.run((w) => w.duplicate()); return this.run((w) => w.duplicate());
} }
delete() { /**
return this.run((w) => w._delete()); * Deletes the selected elements. With `wholeFiles`, the files holding the selected elements
* are deleted instead, even if only a track or a waypoint is selected.
*/
delete(wholeFiles = false) {
return this.run((w) => w._delete(wholeFiles));
} }
deleteAll() { deleteAll() {
@@ -89,23 +327,247 @@ class Engine {
return this.run((w) => w.redo()); return this.run((w) => w.redo());
} }
select(fileIds: string[]) { /**
return this.run((w) => w.select(idsToBytes(fileIds))); * Selects files. Selecting nothing (or only unknown files) deselects everything. See
} * `SelectMode` for how the files combine with the selection.
*/
addSelect(fileIds: string[]) { select(fileIds: string[], mode: SelectMode = 'replace') {
return this.run((w) => w.add_select(idsToBytes(fileIds))); return this.run((w) => w.select(idsToBytes(fileIds), selectMode(w, mode)));
} }
/**
* 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 when nothing is selected.
*/
selectAll() { selectAll() {
return this.run((w) => w.select_all()); return this.run((w) => w.select_all());
} }
/**
* Moves the selection to the next (`down`) or previous element of the same kind, like the
* arrow keys do. With `add` (shift + arrow), the element is added to the selection, otherwise
* it replaces it.
*/
arrowSelect(down: boolean, add: boolean) {
return this.run((w) => w.arrow_select(down, add));
}
// Selecting elements inside a file. Ids that do not exist are ignored.
selectTracks(fileId: string, trackIds: string[], mode: SelectMode = 'replace') {
return this.run((w) => w.select_tracks(fileId, idsToBytes(trackIds), selectMode(w, mode)));
}
selectSegments(
fileId: string,
trackId: string,
segmentIds: string[],
mode: SelectMode = 'replace'
) {
return this.run((w) =>
w.select_segments(fileId, trackId, idsToBytes(segmentIds), selectMode(w, mode))
);
}
/** Selects the node standing for all the waypoints of the file. */
selectWaypointGroup(fileId: string, mode: SelectMode = 'replace') {
return this.run((w) => w.select_waypoint_group(fileId, selectMode(w, mode)));
}
selectWaypoints(fileId: string, waypointIds: string[], mode: SelectMode = 'replace') {
return this.run((w) =>
w.select_waypoints(fileId, idsToBytes(waypointIds), selectMode(w, mode))
);
}
/** Moves the files, in the given order, to `index` among the other files. */ /** Moves the files, in the given order, to `index` among the other files. */
reorder(fileIds: string[], index: number) { reorder(fileIds: string[], index: number) {
return this.run((w) => w.reorder(idsToBytes(fileIds), index)); return this.run((w) => w.reorder(idsToBytes(fileIds), index));
} }
/** Selects a node of the file tree. */
selectNode(node: FileTreeNode, mode: SelectMode = 'replace') {
switch (node.type) {
case 'file':
return this.select([node.fileId], mode);
case 'track':
return this.selectTracks(node.fileId, [node.trackId], mode);
case 'segment':
return this.selectSegments(node.fileId, node.trackId, [node.segmentId], mode);
case 'waypoints':
return this.selectWaypointGroup(node.fileId, mode);
case 'waypoint':
return this.selectWaypoints(node.fileId, [node.waypointId], mode);
}
}
// Clipboard
/** Puts the selected elements in the clipboard. */
copy() {
return this.run((w) => w.copy());
}
/** Like `copy`, but the elements are moved when they are pasted. */
cut() {
return this.run((w) => w.cut());
}
/**
* Pastes the clipboard, depending on the selection: the files, tracks and segments become new
* files when nothing is selected, tracks and waypoints are added to a selected file,
* segments to a selected track, and pasted after a selected track, segment or waypoint of the
* same kind. The pasted elements are selected.
*
* What is pasted is always the elements as they were when they were copied or cut, even if
* they were changed or deleted since. It can be pasted several times: what was copied is
* copied again, what was cut is moved the first time and copied after that.
*/
paste() {
return this.run((w) => w.paste());
}
/**
* Moves elements to a place of the file tree, keeping their ids, like dragging and dropping
* them does. The position of the target counts the elements of its list that are not moved.
* The moved elements are selected.
*
* Files go among the files; tracks among the files (each becomes a file) or the tracks of a
* file; segments among the files (each becomes a file), the tracks of a file (each becomes a
* track) or the segments of a track; waypoints, or all the waypoints of a file (the waypoints
* node), among the waypoints of a file.
*/
move(what: Selection, to: MoveTarget) {
return this.run((w) => w.move_elements(what, to));
}
// Waypoints
/** All the data of a waypoint, `undefined` if it does not exist (or before the engine is loaded). */
waypoint(fileId: string, waypointId: string): WaypointDetails | undefined {
return this.wasm?.waypoint(fileId, waypointId);
}
/**
* Adds a waypoint to each selected file (or to the file of the selected elements). Empty
* strings are fields that the waypoint does not have.
*/
newWaypoint(waypoint: NewWaypoint) {
return this.run((w) =>
w.new_waypoint(
waypoint.lng,
waypoint.lat,
waypoint.ele,
waypoint.name,
waypoint.desc,
waypoint.icon,
waypoint.link
)
);
}
/** Changes a waypoint, selected or not. Empty strings remove the field. */
updateWaypoint(fileId: string, waypointId: string, waypoint: NewWaypoint) {
return this.run((w) =>
w.update_waypoint(
fileId,
waypointId,
waypoint.lng,
waypoint.lat,
waypoint.ele,
waypoint.name,
waypoint.desc,
waypoint.icon,
waypoint.link
)
);
}
/** Moves a waypoint, selected or not: only its position and elevation change. */
moveWaypoint(fileId: string, waypointId: string, lng: number, lat: number, ele: number) {
return this.run((w) => w.move_waypoint(fileId, waypointId, lng, lat, ele));
}
/** Deletes a waypoint, selected or not. */
deleteWaypoint(fileId: string, waypointId: string) {
return this.run((w) => w.delete_waypoint(fileId, waypointId));
}
/**
* Removes the trackpoints and/or waypoints of the selection inside (or outside) a rectangle.
* Resolves to `false` when nothing was removed.
*/
clean(
bounds: { west: number; south: number; east: number; north: number },
inside: boolean,
trackpoints: boolean,
waypoints: boolean
) {
return this.run((w) =>
w.clean(
bounds.west,
bounds.south,
bounds.east,
bounds.north,
inside ? w.CleanType.Inside : w.CleanType.Outside,
trackpoints,
waypoints
)
);
}
/** A trackpoint of a segment, `undefined` if it does not exist. */
trackpoint(fileId: string, segmentId: string, index: number): TrackpointDetails | undefined {
return this.wasm?.trackpoint(fileId, segmentId, index);
}
// Edits of the selection
newTrack() {
return this.run((w) => w.new_track());
}
newTrackSegment() {
return this.run((w) => w.new_track_segment());
}
metadata(name: string, desc: string) {
return this.run((w) => w.metadata(name, desc));
}
/** Only the given fields are changed. */
style(style: { color?: string; opacity?: number; width?: number }) {
return this.run((w) => w.style(style.color, style.opacity, style.width));
}
/** Hides or shows the selected elements. */
setSelectionHidden(hidden: boolean) {
selectedElementIds(get(this._selection)).forEach(({ fileId, ids }) =>
this.setHidden(fileId, ids, hidden)
);
}
/**
* Hides or shows elements of a file (see `FileState.visibility`). What was set explicitly
* below these elements is reset, so that hiding a file then showing one of its segments
* leaves only that segment visible, and hiding the file again hides all of it.
*/
setHidden(fileId: string, ids: string[], hidden: boolean) {
const store = get(this._files).get(fileId);
if (!store) {
return;
}
const visibility = setHidden(
get(store).structure,
this._visibility.get(fileId) ?? new Map(),
ids,
hidden
);
this._visibility.set(fileId, visibility);
store.update((state) => ({ ...state, visibility }));
}
// Coordinates, as flat [lng, lat, ...] arrays. They are copied out of the WASM memory. // Coordinates, as flat [lng, lat, ...] arrays. They are copied out of the WASM memory.
// Refetch them when the `rev` of the segment (or the `waypointsRev` of the file) changed. // Refetch them when the `rev` of the segment (or the `waypointsRev` of the file) changed.
@@ -128,26 +590,233 @@ class Engine {
/** Reads what the last action changed. */ /** Reads what the last action changed. */
private sync(wasm: Wasm) { private sync(wasm: Wasm) {
const update = wasm.last_update(); const update = wasm.last_update();
this._canUndo.set(wasm.can_undo());
this._canRedo.set(wasm.can_redo());
this._canPaste.set(wasm.can_paste());
if (update.clipboardChanged) {
this._clipboard.set(wasm.clipboard());
}
if (update.selectionChanged) { if (update.selectionChanged) {
this._selection.set(wasm.selection()); this._selection.set(wasm.selection());
} }
if (update.orderChanged) { if (update.orderChanged) {
this._order.set(wasm.file_order()); this._order.set(wasm.file_order());
} }
if (update.added.length + update.modified.length + update.removed.length > 0) { const added: [string, FileState][] = [];
this._structures.update((structures) => { for (const id of update.added) {
const next = new Map(structures); const state = this.readFile(wasm, id);
update.removed.forEach((id) => next.delete(id)); if (state) {
[...update.added, ...update.modified].forEach((id) => { added.push([id, state]);
const structure = wasm.file_structure(id);
if (structure) {
next.set(id, structure);
} }
}
if (added.length > 0 || update.removed.length > 0) {
this._files.update((files) => {
const next = new Map(files);
update.removed.forEach((id) => {
next.delete(id);
this._colors.release(id);
}); });
added.forEach(([id, state]) => next.set(id, writable(state)));
return next; return next;
}); });
} }
if (update.modified.length > 0) {
const files = get(this._files);
for (const id of update.modified) {
const store = files.get(id);
const state = store && this.readFile(wasm, id, get(store));
if (store && state) {
store.set(state);
}
}
}
// the statistics depend on the selection, on its order, and on the files
if (
update.selectionChanged ||
update.orderChanged ||
update.added.length + update.modified.length + update.removed.length > 0
) {
this._statistics.set(this.readStatistics(wasm));
}
}
/** Copies the statistics buffers of the engine (they are overwritten by the next action). */
private readStatistics(wasm: Wasm): SelectionStatistics {
const version = ++this._statisticsVersion;
const totalDistance = wasm.total_distance().slice();
return {
global: wasm.selection_statistics() ?? EMPTY_STATISTICS,
length: totalDistance.length,
totalDistance,
movingDistance: wasm.moving_distance().slice(),
totalTime: wasm.total_time().slice(),
movingTime: wasm.moving_time().slice(),
speed: wasm.speed().slice(),
elevationGain: wasm.elevation_gain().slice(),
elevationLoss: wasm.elevation_loss().slice(),
slope: wasm.slope().slice(),
slopeSegmentSlope: wasm.slope_segment_slope().slice(),
slopeSegmentDistance: wasm.slope_segment_distance().slice(),
lng: wasm.lng().slice(),
lat: wasm.lat().slice(),
ele: wasm.ele().slice(),
timestamps: wasm.timestamps().slice(),
hr: wasm.hr().slice(),
cad: wasm.cad().slice(),
atemp: wasm.atemp().slice(),
power: wasm.power().slice(),
surface: wasm.surface().slice(),
surfaces: wasm.surfaces(),
highway: wasm.highway().slice(),
highways: wasm.highways(),
sacScale: wasm.sac_scale().slice(),
sacScales: wasm.sac_scales(),
mtbScale: wasm.mtb_scale().slice(),
mtbScales: wasm.mtb_scales(),
slice: (start, end) =>
version === this._statisticsVersion ? wasm.slice_statistics(start, end) : undefined,
};
}
/** Reads the state of a file, reusing from `previous` what did not change. */
private readFile(wasm: Wasm, id: string, previous?: FileState): FileState | null {
const structure = wasm.file_structure(id);
if (!structure) {
return null;
}
const color = this._colors.resolve(
id,
structure.tracks.map((track) => track.color)
);
const previousSegments = new Map(
previous?.segments.features.map((f) => [f.properties.segmentId, f]) ?? []
);
const segments: Feature<LineString, SegmentProperties>[] = [];
structure.tracks.forEach((track, trackIndex) => {
track.segments.forEach((segment, segmentIndex) => {
const properties: SegmentProperties = {
fileId: id,
trackId: track.id,
segmentId: segment.id,
trackIndex,
segmentIndex,
rev: segment.rev,
color: track.color !== undefined ? normalizeColor(track.color) : color,
opacity: track.opacity,
width: track.width,
};
const old = previousSegments.get(segment.id);
const unchanged =
old !== undefined &&
(Object.keys(properties) as (keyof SegmentProperties)[]).every(
(key) => old.properties[key] === properties[key]
);
segments.push(
unchanged
? old
: {
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: toPositions(wasm.segment_coordinates(segment.id)),
},
properties,
}
);
});
});
const previousStructure = previous?.structure;
const waypointsUnchanged =
previousStructure !== undefined &&
previousStructure.waypointsRev === structure.waypointsRev &&
previousStructure.waypoints.length === structure.waypoints.length &&
previousStructure.waypoints.every(
(w, i) =>
w.id === structure.waypoints[i].id &&
w.name === structure.waypoints[i].name &&
w.sym === structure.waypoints[i].sym
);
let waypoints = previous?.waypoints;
if (!waypoints || !waypointsUnchanged) {
const coordinates = toPositions(wasm.waypoint_coordinates(id));
waypoints = {
type: 'FeatureCollection',
features: structure.waypoints.map((waypoint, index) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: coordinates[index] },
properties: {
fileId: id,
waypointId: waypoint.id,
index,
name: waypoint.name,
sym: waypoint.sym,
},
})),
};
}
return {
structure,
color,
statistics: wasm.file_statistics(id) ?? EMPTY_STATISTICS,
segments: { type: 'FeatureCollection', features: segments },
waypoints,
visibility: this._visibility.get(id) ?? new Map(),
};
} }
} }
export const engine = new Engine(); export const engine = new Engine();
export type FileStatesCallback = (files: Map<string, Readable<FileState>>) => void;
/**
* Tells when file stores appear and disappear, so that their subscribers know when to
* subscribe and unsubscribe. `onFilesAdded` is called with the stores of the new files only
* (including the ones already present when the observer is created).
*/
export class FileStateCollectionObserver {
private _fileIds = new Set<string>();
private _onFilesAdded: FileStatesCallback;
private _onFileRemoved: (fileId: string) => void;
private _onDestroy: () => void;
private _unsubscribe: () => void;
constructor(
onFilesAdded: FileStatesCallback,
onFileRemoved: (fileId: string) => void,
onDestroy: () => void
) {
this._onFilesAdded = onFilesAdded;
this._onFileRemoved = onFileRemoved;
this._onDestroy = onDestroy;
this._unsubscribe = engine.files.subscribe((files) => {
this._fileIds.forEach((fileId) => {
if (!files.has(fileId)) {
this._onFileRemoved(fileId);
this._fileIds.delete(fileId);
}
});
const newFiles = new Map<string, Readable<FileState>>();
files.forEach((store, fileId) => {
if (!this._fileIds.has(fileId)) {
newFiles.set(fileId, store);
this._fileIds.add(fileId);
}
});
if (newFiles.size > 0) {
this._onFilesAdded(newFiles);
}
});
}
destroy() {
this._onDestroy();
this._unsubscribe();
}
}
+59
View File
@@ -0,0 +1,59 @@
const PALETTE = [
'#ff0000',
'#0000ff',
'#46e646',
'#00ccff',
'#ff9900',
'#ff00ff',
'#ffff32',
'#288228',
'#9933ff',
'#50f0be',
'#8c645a',
];
/** GPX style colors are hex digits, with or without the leading `#`. */
export function normalizeColor(color: string): string {
return /^[0-9a-f]{6}$/i.test(color) ? `#${color}` : color;
}
/**
* Picks the base color of each file: the color defined by the file itself if any, otherwise a
* palette color, the least used one when the file first shows up. A file keeps its palette color
* for its whole lifetime, so that it does not change when style information comes and goes
* (edits, undo, redo).
*/
export class FileColorAllocator {
/** Palette color reserved for each file. */
private _reserved = new Map<string, string>();
/** Color currently displayed for each file. */
private _base = new Map<string, string>();
/** Updates (and returns) the base color of a file, given the colors defined by its tracks. */
resolve(fileId: string, trackColors: (string | undefined)[]): string {
const explicit = trackColors.find((color) => color !== undefined);
if (explicit === undefined && !this._reserved.has(fileId)) {
this._reserved.set(fileId, this.leastUsed());
}
const color =
explicit !== undefined ? normalizeColor(explicit) : this._reserved.get(fileId)!;
this._base.set(fileId, color);
return color;
}
release(fileId: string) {
this._reserved.delete(fileId);
this._base.delete(fileId);
}
private leastUsed(): string {
const usage = new Map(PALETTE.map((color) => [color, 0]));
this._base.forEach((color) => {
const count = usage.get(color);
if (count !== undefined) {
usage.set(color, count + 1);
}
});
return PALETTE.reduce((a, b) => (usage.get(a)! <= usage.get(b)! ? a : b));
}
}
+144
View File
@@ -0,0 +1,144 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { FileStructure } from 'gpx-rs';
import {
isHidden,
isSegmentHidden,
isWaypointHidden,
setHidden,
waypointsKey,
type Visibility,
type VisibilityState,
} from './file-visibility';
const segment = (id: string) => ({ id, rev: `rev-${id}`, length: 2 });
// F
// ├── waypoints: W1, W2
// ├── T1: S1, S2
// └── T2: S3
const structure: FileStructure = {
id: 'F',
name: 'file',
waypointsRev: 'wpt-rev',
waypoints: [{ id: 'W1' }, { id: 'W2' }],
tracks: [
{ id: 'T1', segments: [segment('S1'), segment('S2')] },
{ id: 'T2', segments: [segment('S3')] },
],
};
const WPTS = waypointsKey('F');
const elements = ['F', WPTS, 'W1', 'W2', 'T1', 'S1', 'S2', 'T2', 'S3'];
/** Applies the actions one after the other, starting with everything visible. */
function apply(...actions: [ids: string[], hidden: boolean][]): VisibilityState {
let visibility: Visibility = new Map();
for (const [ids, hidden] of actions) {
visibility = setHidden(structure, visibility, ids, hidden);
}
return { structure, visibility };
}
function hiddenElements(state: VisibilityState): string[] {
return elements.filter((id) => isHidden(state, id));
}
describe('file visibility', () => {
it('shows everything by default', () => {
assert.deepEqual(hiddenElements(apply()), []);
});
it('hides an element and what is below it', () => {
assert.deepEqual(hiddenElements(apply([['T1'], true])), ['T1', 'S1', 'S2']);
assert.deepEqual(hiddenElements(apply([['S1'], true])), ['S1']);
assert.deepEqual(hiddenElements(apply([[WPTS], true])), [WPTS, 'W1', 'W2']);
assert.deepEqual(hiddenElements(apply([['W2'], true])), ['W2']);
});
it('hides a whole file', () => {
assert.deepEqual(hiddenElements(apply([['F'], true])), elements);
});
it('shows again what was hidden', () => {
assert.deepEqual(hiddenElements(apply([['F'], true], [['F'], false])), []);
assert.deepEqual(hiddenElements(apply([['S1'], true], [['S1'], false])), []);
});
it('keeps the other segments hidden when one is shown below a hidden file', () => {
const state = apply([['F'], true], [['S1'], false]);
assert.deepEqual(hiddenElements(state), [WPTS, 'W1', 'W2', 'S2', 'T2', 'S3']);
// the file and the track of the visible segment are not hidden, they have visible content
assert.equal(isHidden(state, 'F'), false);
assert.equal(isHidden(state, 'T1'), false);
assert.equal(isHidden(state, 'S1'), false);
});
it('hides the file again once what was shown is hidden', () => {
const state = apply([['F'], true], [['S1'], false], [['S1'], true]);
assert.deepEqual(hiddenElements(state), elements);
});
it('resets what was shown below when hiding a parent', () => {
const state = apply([['F'], true], [['S1'], false], [['F'], true]);
assert.deepEqual(hiddenElements(state), elements);
assert.equal(state.visibility.has('S1'), false);
});
it('resets what was hidden below when showing a parent', () => {
const state = apply([['S1', 'S3', 'W1'], true], [['F'], false]);
assert.deepEqual(hiddenElements(state), []);
assert.equal(state.visibility.has('S1'), false);
});
it('shows a track hidden below a hidden file, with all its segments', () => {
const state = apply([['F'], true], [['T1'], false]);
assert.deepEqual(hiddenElements(state), [WPTS, 'W1', 'W2', 'T2', 'S3']);
});
it('shows a waypoint below a hidden file', () => {
const state = apply([['F'], true], [['W1'], false]);
assert.deepEqual(hiddenElements(state), ['W2', 'T1', 'S1', 'S2', 'T2', 'S3']);
assert.equal(isHidden(state, WPTS), false);
assert.equal(isHidden(state, 'F'), false);
});
it('hides a segment shown inside a hidden track of a visible file', () => {
const state = apply([['T1'], true], [['S2'], false]);
assert.deepEqual(hiddenElements(state), ['S1']);
});
it('does not touch the other branches of the tree', () => {
const state = apply([['T1'], true], [['T2'], true], [['T1'], false]);
assert.deepEqual(hiddenElements(state), ['T2', 'S3']);
});
it('has leaf helpers agreeing with isHidden', () => {
const states = [
apply(),
apply([['F'], true]),
apply([['F'], true], [['S1'], false], [['W2'], false]),
apply([['T1'], true], [['S2'], false]),
apply([[WPTS], true], [['W1'], false]),
];
for (const state of states) {
for (const track of structure.tracks) {
for (const { id } of track.segments) {
assert.equal(isSegmentHidden(state, track.id, id), isHidden(state, id));
}
}
for (const { id } of structure.waypoints) {
assert.equal(isWaypointHidden(state, id), isHidden(state, id));
}
}
});
it('does not consider unknown ids hidden', () => {
assert.equal(isHidden(apply([['F'], true]), 'unknown'), false);
});
it('does not mutate the previous visibility', () => {
const before: Visibility = new Map([['S1', false]]);
setHidden(structure, before, ['F'], true);
assert.deepEqual([...before], [['S1', false]]);
});
});
+112
View File
@@ -0,0 +1,112 @@
import type { FileStructure } from 'gpx-rs';
/**
* Visibility the user set explicitly, by element id (file, 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.
*/
export type Visibility = ReadonlyMap<string, boolean>;
/** What is needed to tell whether the elements of a file are hidden (a `FileState` fits). */
export type VisibilityState = { structure: FileStructure; visibility: Visibility };
/** Id to hide or show all the waypoints of a file at once. */
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). */
function pathTo(structure: FileStructure, id: string): string[] {
const fileId = structure.id;
if (id === fileId) {
return [fileId];
}
if (id === waypointsKey(fileId)) {
return [fileId, id];
}
if (structure.waypoints.some((waypoint) => waypoint.id === id)) {
return [fileId, waypointsKey(fileId), id];
}
for (const track of structure.tracks) {
if (track.id === id) {
return [fileId, id];
}
if (track.segments.some((segment) => segment.id === id)) {
return [fileId, track.id, id];
}
}
return [];
}
function children(structure: FileStructure, id: string): string[] {
if (id === structure.id) {
return [waypointsKey(id), ...structure.tracks.map((track) => track.id)];
}
if (id === waypointsKey(structure.id)) {
return structure.waypoints.map((waypoint) => waypoint.id);
}
return structure.tracks.find((track) => track.id === id)?.segments.map((s) => s.id) ?? [];
}
function descendants(structure: FileStructure, id: string): string[] {
return children(structure, id).flatMap((child) => [child, ...descendants(structure, child)]);
}
/** The closest explicit visibility on a path (from the file down): whether it is hidden. */
function followsHidden(visibility: Visibility, path: string[]): boolean {
for (let i = path.length - 1; i >= 0; i--) {
const shown = visibility.get(path[i]);
if (shown !== undefined) {
return !shown;
}
}
return false;
}
/**
* Whether an element is hidden: its closest explicit ancestor (or itself) is hidden, and none
* of its descendants was shown. A file whose only visible segment was shown on its own is not
* hidden, its other segments are. Unknown ids are not hidden.
*/
export function isHidden({ structure, visibility }: VisibilityState, id: string): boolean {
return (
followsHidden(visibility, pathTo(structure, id)) &&
!descendants(structure, id).some((descendant) => visibility.get(descendant) === true)
);
}
// Leaf elements have no descendants: no need to look at the structure.
export function isSegmentHidden(
{ structure, visibility }: VisibilityState,
trackId: string,
segmentId: string
): boolean {
return followsHidden(visibility, [structure.id, trackId, segmentId]);
}
export function isWaypointHidden(
{ structure, visibility }: VisibilityState,
waypointId: string
): boolean {
return followsHidden(visibility, [structure.id, waypointsKey(structure.id), waypointId]);
}
/**
* Hides or shows elements of a file, and returns the new visibility. What was set explicitly
* below these elements is reset, so that hiding a file then showing one of its segments leaves
* only that segment visible, and hiding the file again hides all of it.
*/
export function setHidden(
structure: FileStructure,
visibility: Visibility,
ids: string[],
hidden: boolean
): Visibility {
const next = new Map(visibility);
ids.forEach((id) => {
descendants(structure, id).forEach((child) => next.delete(child));
next.set(id, !hidden);
});
return next;
}
+32 -42
View File
@@ -1,22 +1,19 @@
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { selection } from '$lib/logic/selection';
import maplibregl from 'maplibre-gl'; import maplibregl from 'maplibre-gl';
import { ListFileItem, ListWaypointItem } from '$lib/components/file-list/file-list';
import { fileStateCollection, GPXFileStateCollectionObserver } from '$lib/logic/file-state';
import { gpxStatistics } from '$lib/logic/statistics';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import type { GPXFileWithStatistics } from './statistics-tree';
import type { Coordinates } from 'gpx';
import { page } from '$app/state'; import { page } from '$app/state';
import { engine, FileStateCollectionObserver, type FileState } from '$lib/engine';
const { statistics } = engine;
export class BoundsManager { export class BoundsManager {
private _bounds: maplibregl.LngLatBounds = new maplibregl.LngLatBounds(); private _bounds: maplibregl.LngLatBounds = new maplibregl.LngLatBounds();
private _files: Set<string> = new Set(); private _files: Set<string> = new Set();
private _fileStateCollectionObserver: GPXFileStateCollectionObserver | null = null; private _fileStateCollectionObserver: FileStateCollectionObserver | null = null;
private _unsubscribes: (() => void)[] = []; private _unsubscribes: (() => void)[] = [];
constructor() { constructor() {
this._fileStateCollectionObserver = new GPXFileStateCollectionObserver( this._fileStateCollectionObserver = new FileStateCollectionObserver(
(newFiles) => { (newFiles) => {
if (page.url.hash.length == 0) { if (page.url.hash.length == 0) {
this.fitBoundsOnLoad(Array.from(newFiles.keys())); this.fitBoundsOnLoad(Array.from(newFiles.keys()));
@@ -31,7 +28,7 @@ export class BoundsManager {
this.reset(); this.reset();
this._files = new Set(files); this._files = new Set(files);
this._fileStateCollectionObserver = new GPXFileStateCollectionObserver( this._fileStateCollectionObserver = new FileStateCollectionObserver(
(newFiles) => { (newFiles) => {
newFiles.forEach((fileState, fileId) => { newFiles.forEach((fileState, fileId) => {
if (this._files.has(fileId)) { if (this._files.has(fileId)) {
@@ -48,16 +45,21 @@ export class BoundsManager {
); );
} }
addBoundsFromFile(fileId: string, file: GPXFileWithStatistics | undefined) { addBoundsFromFile(fileId: string, file: FileState) {
if (!file || !this._files.has(fileId)) return; if (!file || !this._files.has(fileId)) return;
this._files.delete(fileId); this._files.delete(fileId);
const bounds = file.statistics.getStatisticsFor(new ListFileItem(fileId)).global.bounds; if (file.statistics.bounds) {
let bounds = new maplibregl.LngLatBounds([
file.statistics.bounds.west,
file.statistics.bounds.south,
file.statistics.bounds.east,
file.statistics.bounds.north,
]);
if (!this.validBounds(bounds)) return; if (!this.validBounds(bounds)) return;
this._bounds.extend(bounds);
this._bounds.extend(bounds.southWest); }
this._bounds.extend(bounds.northEast);
if (this._files.size === 0) { if (this._files.size === 0) {
this.finalizeFitBounds(); this.finalizeFitBounds();
@@ -91,42 +93,30 @@ export class BoundsManager {
} }
centerMapOnSelection() { centerMapOnSelection() {
let selected = get(selection).getSelected(); let stats = get(statistics);
let bounds = new maplibregl.LngLatBounds(); if (stats.global.bounds) {
// TODO take waypoints into account
if (selected.find((item) => item instanceof ListWaypointItem)) { let bounds = new maplibregl.LngLatBounds([
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => { stats.global.bounds.west,
let file = fileStateCollection.getFile(fileId); stats.global.bounds.south,
if (file) { stats.global.bounds.east,
items.forEach((item) => { stats.global.bounds.north,
if (item instanceof ListWaypointItem) { ]);
let waypoint = file.wpt[item.getWaypointIndex()]; if (!this.validBounds(bounds)) return;
if (waypoint) {
bounds.extend([waypoint.getLongitude(), waypoint.getLatitude()]);
}
}
});
}
});
} else {
let selectionBounds = get(gpxStatistics).global.bounds;
bounds.setNorthEast(selectionBounds.northEast);
bounds.setSouthWest(selectionBounds.southWest);
}
get(map)?.fitBounds(bounds, { get(map)?.fitBounds(bounds, {
padding: 80, padding: 80,
easing: () => 1, easing: () => 1,
maxZoom: 15, maxZoom: 15,
}); });
} }
}
validBounds(bounds: { southWest: Coordinates; northEast: Coordinates }) { validBounds(bounds: maplibregl.LngLatBounds) {
return ( return (
bounds.southWest.lat !== 90 || bounds.getSouthWest().lat !== 90 ||
bounds.southWest.lon !== 180 || bounds.getSouthWest().lng !== 180 ||
bounds.northEast.lat !== -90 || bounds.getNorthEast().lat !== -90 ||
bounds.northEast.lon !== -180 bounds.getNorthEast().lng !== -180
); );
} }
} }
+30 -352
View File
@@ -10,15 +10,12 @@ import {
ListTrackItem, ListTrackItem,
ListTrackSegmentItem, ListTrackSegmentItem,
ListWaypointItem, ListWaypointItem,
ListWaypointsItem,
sortItems,
type ListItem, type ListItem,
} from '$lib/components/file-list/file-list'; } from '$lib/components/file-list/file-list';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { freeze, type WritableDraft } from 'immer'; import { freeze } from 'immer';
import { import {
GPXFile, GPXFile,
parseGPX,
Track, Track,
TrackPoint, TrackPoint,
TrackSegment, TrackSegment,
@@ -32,6 +29,8 @@ import { settings } from '$lib/logic/settings';
import { getClosestLinePoint, getClosestTrackSegments, getElevation } from '$lib/utils'; import { getClosestLinePoint, getClosestTrackSegments, getElevation } from '$lib/utils';
import { gpxStatistics } from '$lib/logic/statistics'; import { gpxStatistics } from '$lib/logic/statistics';
import { boundsManager } from './bounds'; import { boundsManager } from './bounds';
import { engine } from '$lib/engine';
import { defaultFileName } from '$lib/default-file-name';
// Generate unique file ids, different from the ones in the database // Generate unique file ids, different from the ones in the database
export function getFileIds(n: number) { export function getFileIds(n: number) {
@@ -45,31 +44,21 @@ export function getFileIds(n: number) {
return ids; return ids;
} }
/** The name of a new file: the translated default name, numbered if other files have it. */
export function newFileName() {
const names = [...get(engine.files).values()].map((file) => get(file).structure.name);
return defaultFileName(i18n._('menu.new_file'), names);
}
export function newGPXFile() { export function newGPXFile() {
const newFileName = i18n._('menu.new_file');
let file = new GPXFile(); let file = new GPXFile();
file.metadata.name = newFileName();
let maxNewFileNumber = 0;
fileStateCollection.forEach((fileId, file) => {
if (file.metadata.name && file.metadata.name.startsWith(newFileName)) {
let number = parseInt(file.metadata.name.split(' ').pop() ?? '0');
if (!isNaN(number) && number > maxNewFileNumber) {
maxNewFileNumber = number;
}
}
});
file.metadata.name = `${newFileName} ${maxNewFileNumber + 1}`;
return file; return file;
} }
export function createFile() { export function createFile() {
let file = newGPXFile(); // the engine selects the new file
engine.newFile(newFileName());
fileActions.add(file);
selection.selectFileWhenLoaded(file._data.id);
currentTool.set(Tool.ROUTING); currentTool.set(Tool.ROUTING);
} }
@@ -87,41 +76,27 @@ export function triggerFileInput() {
input.click(); input.click();
} }
export async function loadFiles(list: FileList | File[]) { /**
let files: GPXFile[] = []; * Loads GPX files in the engine, as a single command: one undo step, the first file that could be
for (let i = 0; i < list.length; i++) { * read is selected, and the map fits the files. A file without name is named like the file on
let file = await loadFile(list[i]); * disk. Returns the ids of the files that were added.
if (file) { */
files.push(file); export async function loadFiles(list: FileList | File[]): Promise<string[]> {
} const files = await Promise.all(
} Array.from(list).map(async (file) => ({
data: new Uint8Array(await file.arrayBuffer()),
name: file.name.split('.').slice(0, -1).join('.'),
}))
);
let ids = fileActions.addMultiple(files); const before = new Set(get(engine.order));
selection.selectFileWhenLoaded(ids[0]); await engine.loadFiles(files);
const ids = get(engine.order).filter((id) => !before.has(id));
if (ids.length > 0) {
boundsManager.fitBoundsOnLoad(ids); boundsManager.fitBoundsOnLoad(ids);
}
export async function loadFile(file: File): Promise<GPXFile | null> {
let result = await new Promise<GPXFile | null>((resolve) => {
const reader = new FileReader();
reader.onload = () => {
let data = reader.result?.toString() ?? null;
if (data) {
let gpx = parseGPX(data);
if (gpx.metadata === undefined) {
gpx.metadata = {};
} }
if (gpx.metadata.name === undefined || gpx.metadata.name.trim() === '') { return ids;
gpx.metadata.name = file.name.split('.').slice(0, -1).join('.');
}
resolve(gpx);
} else {
resolve(null);
}
};
reader.readAsText(file);
});
return result;
} }
// Helper functions for file operations // Helper functions for file operations
@@ -595,57 +570,6 @@ export const fileActions = {
} }
}); });
}, },
cleanSelection: (
bounds: [Coordinates, Coordinates],
inside: boolean,
deleteTrackPoints: boolean,
deleteWaypoints: boolean
) => {
if (get(selection).size === 0) {
return;
}
fileActionManager.applyGlobal((draft) => {
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
let file = draft.get(fileId);
if (file) {
if (level === ListLevel.FILE) {
file.clean(bounds, inside, deleteTrackPoints, deleteWaypoints);
} else if (level === ListLevel.TRACK) {
let trackIndices = items.map((item) =>
(item as ListTrackItem).getTrackIndex()
);
file.clean(
bounds,
inside,
deleteTrackPoints,
deleteWaypoints,
trackIndices
);
} else if (level === ListLevel.SEGMENT) {
let trackIndices = [(items[0] as ListTrackSegmentItem).getTrackIndex()];
let segmentIndices = items.map((item) =>
(item as ListTrackSegmentItem).getSegmentIndex()
);
file.clean(
bounds,
inside,
deleteTrackPoints,
deleteWaypoints,
trackIndices,
segmentIndices
);
} else if (level === ListLevel.WAYPOINTS) {
file.clean(bounds, inside, false, deleteWaypoints);
} else if (level === ListLevel.WAYPOINT) {
let waypointIndices = items.map((item) =>
(item as ListWaypointItem).getWaypointIndex()
);
file.clean(bounds, inside, false, deleteWaypoints, [], [], waypointIndices);
}
}
});
});
},
reduce: (itemsAndPoints: Map<ListItem, TrackPoint[]>) => { reduce: (itemsAndPoints: Map<ListItem, TrackPoint[]>) => {
if (itemsAndPoints.size === 0) { if (itemsAndPoints.size === 0) {
return; return;
@@ -891,249 +815,3 @@ export const fileActions = {
}); });
}, },
}; };
export function pasteSelection() {
let fromItems = get(copied);
if (fromItems === undefined || fromItems.length === 0) {
return;
}
let selected = get(selection).getSelected();
if (selected.length === 0) {
selected = [new ListRootItem()];
}
let fromParent = fromItems[0].getParent();
let toParent = selected[selected.length - 1];
let startIndex: number | undefined = undefined;
if (fromItems[0].level === toParent.level) {
if (
toParent instanceof ListTrackItem ||
toParent instanceof ListTrackSegmentItem ||
toParent instanceof ListWaypointItem
) {
startIndex = toParent.getId() + 1;
}
toParent = toParent.getParent();
}
let toItems: ListItem[] = [];
if (toParent.level === ListLevel.ROOT) {
let fileIds = getFileIds(fromItems.length);
fileIds.forEach((fileId) => {
toItems.push(new ListFileItem(fileId));
});
} else {
let toFile = fileStateCollection.getFile(toParent.getFileId());
if (toFile) {
fromItems.forEach((item, index) => {
if (toParent instanceof ListFileItem) {
if (item instanceof ListTrackItem || item instanceof ListTrackSegmentItem) {
toItems.push(
new ListTrackItem(
toParent.getFileId(),
(startIndex ?? toFile.trk.length) + index
)
);
} else if (item instanceof ListWaypointsItem) {
toItems.push(new ListWaypointsItem(toParent.getFileId()));
} else if (item instanceof ListWaypointItem) {
toItems.push(
new ListWaypointItem(
toParent.getFileId(),
(startIndex ?? toFile.wpt.length) + index
)
);
}
} else if (toParent instanceof ListTrackItem) {
if (item instanceof ListTrackSegmentItem) {
let toTrackIndex = toParent.getTrackIndex();
toItems.push(
new ListTrackSegmentItem(
toParent.getFileId(),
toTrackIndex,
(startIndex ?? toFile.trk[toTrackIndex].trkseg.length) + index
)
);
}
} else if (toParent instanceof ListWaypointsItem) {
if (item instanceof ListWaypointItem) {
toItems.push(
new ListWaypointItem(
toParent.getFileId(),
(startIndex ?? toFile.wpt.length) + index
)
);
}
}
});
}
}
if (fromItems.length === toItems.length) {
moveItems(fromParent, toParent, fromItems, toItems, get(cut));
selection.resetCopied();
}
}
export function moveItems(
fromParent: ListItem,
toParent: ListItem,
fromItems: ListItem[],
toItems: ListItem[],
remove: boolean = true
) {
if (fromItems.length === 0) {
return;
}
sortItems(fromItems, false);
sortItems(toItems, false);
let context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[] = [];
fromItems.forEach((item) => {
let file = fileStateCollection.getFile(item.getFileId());
if (file) {
if (item instanceof ListFileItem) {
context.push(file.clone());
} else if (item instanceof ListTrackItem && item.getTrackIndex() < file.trk.length) {
context.push(file.trk[item.getTrackIndex()].clone());
} else if (
item instanceof ListTrackSegmentItem &&
item.getTrackIndex() < file.trk.length &&
item.getSegmentIndex() < file.trk[item.getTrackIndex()].trkseg.length
) {
context.push(file.trk[item.getTrackIndex()].trkseg[item.getSegmentIndex()].clone());
} else if (item instanceof ListWaypointsItem) {
context.push(file.wpt.map((wpt) => wpt.clone()));
} else if (
item instanceof ListWaypointItem &&
item.getWaypointIndex() < file.wpt.length
) {
context.push(file.wpt[item.getWaypointIndex()].clone());
}
}
});
if (remove && !(fromParent instanceof ListRootItem)) {
sortItems(fromItems, true);
}
let files = [fromParent.getFileId(), toParent.getFileId()];
let callbacks = [
(
file: WritableDraft<GPXFile>,
context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]
) => {
fromItems.forEach((item) => {
if (item instanceof ListTrackItem) {
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex(), []);
} else if (item instanceof ListTrackSegmentItem) {
file.replaceTrackSegments(
item.getTrackIndex(),
item.getSegmentIndex(),
item.getSegmentIndex(),
[]
);
} else if (item instanceof ListWaypointsItem) {
file.replaceWaypoints(0, file.wpt.length - 1, []);
} else if (item instanceof ListWaypointItem) {
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex(), []);
}
});
},
(
file: WritableDraft<GPXFile>,
context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]
) => {
toItems.forEach((item, i) => {
if (item instanceof ListTrackItem) {
if (context[i] instanceof Track) {
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [
context[i],
]);
} else if (context[i] instanceof TrackSegment) {
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [
new Track({
trkseg: [context[i]],
}),
]);
}
} else if (
item instanceof ListTrackSegmentItem &&
context[i] instanceof TrackSegment
) {
file.replaceTrackSegments(
item.getTrackIndex(),
item.getSegmentIndex(),
item.getSegmentIndex() - 1,
[context[i]]
);
} else if (item instanceof ListWaypointsItem) {
if (
Array.isArray(context[i]) &&
context[i].length > 0 &&
context[i][0] instanceof Waypoint
) {
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, context[i]);
} else if (context[i] instanceof Waypoint) {
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, [context[i]]);
}
} else if (item instanceof ListWaypointItem && context[i] instanceof Waypoint) {
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex() - 1, [
context[i],
]);
}
});
},
];
if (fromParent instanceof ListRootItem) {
files = [];
callbacks = [];
} else if (!remove) {
files.splice(0, 1);
callbacks.splice(0, 1);
}
fileActionManager.applyEachToFilesAndGlobal(
files,
callbacks,
(files, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
toItems.forEach((item, i) => {
if (item instanceof ListFileItem) {
if (context[i] instanceof GPXFile) {
let newFile = context[i];
if (remove) {
files.delete(newFile._data.id);
}
newFile._data.id = item.getFileId();
files.set(item.getFileId(), freeze(newFile));
} else if (context[i] instanceof Track) {
let newFile = newGPXFile();
newFile._data.id = item.getFileId();
if (context[i].name) {
newFile.metadata.name = context[i].name;
}
newFile.replaceTracks(0, 0, [context[i]]);
files.set(item.getFileId(), freeze(newFile));
} else if (context[i] instanceof TrackSegment) {
let newFile = newGPXFile();
newFile._data.id = item.getFileId();
newFile.replaceTracks(0, 0, [
new Track({
trkseg: [context[i]],
}),
]);
files.set(item.getFileId(), freeze(newFile));
}
}
});
},
context
);
selection.set(toItems);
}
@@ -0,0 +1,16 @@
import { writable } from 'svelte/store';
import { engine, type GlobalStatistics } from '$lib/engine';
/** The part of the selection that is highlighted in the elevation profile. */
export type SlicedStatistics = {
/** Statistics of the part. */
global: GlobalStatistics;
/** Indices of its first and last trackpoints in the statistics of the selection. */
start: number;
end: number;
};
export const slicedStatistics = writable<SlicedStatistics | undefined>(undefined);
// the indices only make sense for the selection they were made on
engine.statistics.subscribe(() => slicedStatistics.set(undefined));
+240
View File
@@ -0,0 +1,240 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { Clipboard, FileStructure, Selection } from 'gpx-rs';
import type { VisibilityState } from './file-visibility';
import {
elementId,
hasSelectionWithin,
isCovered,
isInClipboard,
isEmpty,
isSelected,
isSelectionHidden,
selectedElementIds,
selectedFileIds,
selectionSize,
type FileTreeNode,
} from './selection-helpers';
// Two files; F1 has the tracks T1 (segments S1, S2) and T2 (segment S3), and waypoints W1, W2.
const nodes: Record<string, FileTreeNode> = {
F1: { type: 'file', fileId: 'F1' },
F2: { type: 'file', fileId: 'F2' },
T1: { type: 'track', fileId: 'F1', trackId: 'T1' },
T2: { type: 'track', fileId: 'F1', trackId: 'T2' },
S1: { type: 'segment', fileId: 'F1', trackId: 'T1', segmentId: 'S1' },
S2: { type: 'segment', fileId: 'F1', trackId: 'T1', segmentId: 'S2' },
S3: { type: 'segment', fileId: 'F1', trackId: 'T2', segmentId: 'S3' },
WPTS: { type: 'waypoints', fileId: 'F1' },
W1: { type: 'waypoint', fileId: 'F1', waypointId: 'W1' },
W2: { type: 'waypoint', fileId: 'F1', waypointId: 'W2' },
// same ids in another file
F2_WPTS: { type: 'waypoints', fileId: 'F2' },
};
const empty: Selection = { type: 'empty' };
const files = (...fileIds: string[]): Selection => ({ type: 'file', fileIds });
const tracks = (...trackIds: string[]): Selection => ({ type: 'track', fileId: 'F1', trackIds });
const segments = (trackId: string, ...segmentIds: string[]): Selection => ({
type: 'segment',
fileId: 'F1',
trackId,
segmentIds,
});
const waypoints: Selection = { type: 'waypoints', fileId: 'F1' };
const waypoint = (...waypointIds: string[]): Selection => ({
type: 'waypoint',
fileId: 'F1',
waypointIds,
});
/** The names of the nodes for which `predicate` holds, to compare with what is expected. */
function matching(selection: Selection, predicate: typeof isSelected): string[] {
return Object.keys(nodes).filter((name) => predicate(selection, nodes[name]));
}
describe('selection helpers', () => {
it('tells whether the selection is empty', () => {
assert.equal(isEmpty(empty), true);
assert.equal(isEmpty(files('F1')), false);
assert.equal(isEmpty(waypoints), false);
});
it('lists the files holding something selected', () => {
assert.deepEqual(selectedFileIds(empty), []);
assert.deepEqual(selectedFileIds(files('F1', 'F2')), ['F1', 'F2']);
assert.deepEqual(selectedFileIds(tracks('T1')), ['F1']);
assert.deepEqual(selectedFileIds(segments('T1', 'S1')), ['F1']);
assert.deepEqual(selectedFileIds(waypoints), ['F1']);
assert.deepEqual(selectedFileIds(waypoint('W1')), ['F1']);
});
it('gives the id of the element of a node', () => {
assert.deepEqual(
Object.keys(nodes).map((name) => elementId(nodes[name])),
['F1', 'F2', 'T1', 'T2', 'S1', 'S2', 'S3', 'F1:waypoints', 'W1', 'W2', 'F2:waypoints']
);
});
it('tells which nodes are in the clipboard', () => {
const clipboard = (type: Clipboard['type'], ids: string[]): Clipboard => ({
type,
ids,
cut: true,
});
assert.deepEqual(
matching(empty, (_, node) => isInClipboard(undefined, node)),
[]
);
const inClipboard = (c: Clipboard) =>
Object.keys(nodes).filter((name) => isInClipboard(c, nodes[name]));
assert.deepEqual(inClipboard(clipboard('files', ['F2'])), ['F2']);
assert.deepEqual(inClipboard(clipboard('tracks', ['T2', 'S1'])), ['T2']);
assert.deepEqual(inClipboard(clipboard('segments', ['S1', 'S3'])), ['S1', 'S3']);
assert.deepEqual(inClipboard(clipboard('waypoints', ['W2'])), ['W2']);
// not mixed up with the other kinds of elements
assert.deepEqual(inClipboard(clipboard('files', ['T1', 'S1', 'W1'])), []);
});
it('counts the selected elements', () => {
assert.equal(selectionSize(empty), 0);
assert.equal(selectionSize(files('F1', 'F2')), 2);
assert.equal(selectionSize(tracks('T1')), 1);
assert.equal(selectionSize(segments('T1', 'S1', 'S2')), 2);
assert.equal(selectionSize(waypoints), 1);
assert.equal(selectionSize(waypoint('W1', 'W2')), 2);
});
it('lists the ids of the selected elements by file', () => {
assert.deepEqual(selectedElementIds(empty), []);
assert.deepEqual(selectedElementIds(files('F1', 'F2')), [
{ fileId: 'F1', ids: ['F1'] },
{ fileId: 'F2', ids: ['F2'] },
]);
assert.deepEqual(selectedElementIds(tracks('T1', 'T2')), [
{ fileId: 'F1', ids: ['T1', 'T2'] },
]);
assert.deepEqual(selectedElementIds(segments('T1', 'S1')), [{ fileId: 'F1', ids: ['S1'] }]);
assert.deepEqual(selectedElementIds(waypoints), [{ fileId: 'F1', ids: ['F1:waypoints'] }]);
assert.deepEqual(selectedElementIds(waypoint('W1')), [{ fileId: 'F1', ids: ['W1'] }]);
});
describe('isSelected', () => {
it('matches the selected nodes only', () => {
assert.deepEqual(matching(empty, isSelected), []);
assert.deepEqual(matching(files('F1', 'F2'), isSelected), ['F1', 'F2']);
assert.deepEqual(matching(tracks('T1', 'T2'), isSelected), ['T1', 'T2']);
assert.deepEqual(matching(segments('T1', 'S1'), isSelected), ['S1']);
assert.deepEqual(matching(waypoints, isSelected), ['WPTS']);
assert.deepEqual(matching(waypoint('W2'), isSelected), ['W2']);
});
it('does not mix up the track of a segment, nor the file', () => {
// S3 belongs to T2: selecting "S3 of T1" does not select it
assert.equal(isSelected(segments('T1', 'S3'), nodes.S3), false);
assert.equal(isSelected(files('F2'), nodes.F1), false);
});
});
describe('isCovered', () => {
it('covers everything below a selected file', () => {
assert.deepEqual(
matching(files('F1'), isCovered),
Object.keys(nodes).filter((name) => name !== 'F2' && name !== 'F2_WPTS')
);
});
it('covers the segments of a selected track, not its siblings', () => {
assert.deepEqual(matching(tracks('T1'), isCovered), ['T1', 'S1', 'S2']);
});
it('covers the waypoints below the waypoints node', () => {
assert.deepEqual(matching(waypoints, isCovered), ['WPTS', 'W1', 'W2']);
});
it('does not cover parents, siblings or the other files', () => {
assert.deepEqual(matching(segments('T1', 'S1'), isCovered), ['S1']);
assert.deepEqual(matching(waypoint('W1'), isCovered), ['W1']);
assert.deepEqual(matching(empty, isCovered), []);
});
});
describe('hasSelectionWithin', () => {
it('looks at the descendants of a file, whatever their kind', () => {
for (const selection of [
files('F1'),
tracks('T1'),
segments('T1', 'S1'),
waypoints,
waypoint('W1'),
]) {
assert.equal(hasSelectionWithin(selection, nodes.F1), true);
assert.equal(hasSelectionWithin(selection, nodes.F2), false);
}
assert.equal(hasSelectionWithin(empty, nodes.F1), false);
});
it('looks at the segments of a track', () => {
assert.deepEqual(matching(tracks('T1'), hasSelectionWithin), ['F1', 'T1']);
assert.deepEqual(matching(segments('T1', 'S2'), hasSelectionWithin), [
'F1',
'T1',
'S2',
]);
});
it('looks at the waypoints below the waypoints node', () => {
assert.deepEqual(matching(waypoints, hasSelectionWithin), ['F1', 'WPTS']);
assert.deepEqual(matching(waypoint('W1'), hasSelectionWithin), ['F1', 'WPTS', 'W1']);
});
it('does not look at ancestors', () => {
// a selected file does not mean that its segments hold a selection
assert.deepEqual(matching(files('F1'), hasSelectionWithin), ['F1']);
});
});
describe('isSelectionHidden', () => {
const segment = (id: string) => ({ id, rev: id, length: 2 });
const structure: FileStructure = {
id: 'F1',
name: 'file',
waypointsRev: 'rev',
waypoints: [{ id: 'W1' }, { id: 'W2' }],
tracks: [
{ id: 'T1', segments: [segment('S1'), segment('S2')] },
{ id: 'T2', segments: [segment('S3')] },
],
};
const states = (...hidden: string[]) =>
new Map<string, VisibilityState>([
[
'F1',
{ structure, visibility: new Map(hidden.map((id) => [id, false] as const)) },
],
]);
it('is false when nothing is selected', () => {
assert.equal(isSelectionHidden(empty, states('F1')), false);
});
it('is true when everything selected is hidden', () => {
assert.equal(isSelectionHidden(files('F1'), states('F1')), true);
assert.equal(isSelectionHidden(tracks('T1', 'T2'), states('T1', 'T2')), true);
// through a hidden parent
assert.equal(isSelectionHidden(segments('T1', 'S1', 'S2'), states('T1')), true);
assert.equal(isSelectionHidden(waypoint('W1', 'W2'), states('F1:waypoints')), true);
assert.equal(isSelectionHidden(waypoints, states('F1:waypoints')), true);
});
it('is false as soon as one selected element is visible', () => {
assert.equal(isSelectionHidden(files('F1'), states()), false);
assert.equal(isSelectionHidden(tracks('T1', 'T2'), states('T1')), false);
assert.equal(isSelectionHidden(waypoint('W1', 'W2'), states('W1')), false);
});
it('is false for files that are not known', () => {
assert.equal(isSelectionHidden(files('F1', 'F2'), states('F1')), false);
});
});
});
+205
View File
@@ -0,0 +1,205 @@
import type { Clipboard, Selection } from 'gpx-rs';
import { isHidden, waypointsKey, type VisibilityState } from '$lib/file-visibility';
/**
* An element of the file tree, to ask questions about the selection:
*
* file ─┬─ track ─── segment
* └─ waypoints ─── waypoint
*
* `waypoints` is the node standing for all the waypoints of a file.
*/
export type FileTreeNode =
| { type: 'file'; fileId: string }
| { type: 'track'; fileId: string; trackId: string }
| { type: 'segment'; fileId: string; trackId: string; segmentId: string }
| { type: 'waypoints'; fileId: string }
| { type: 'waypoint'; fileId: string; waypointId: string };
/**
* The id of the element of a node, as used by the visibility (`waypointsKey` for the waypoints
* node).
*/
export function elementId(node: FileTreeNode): string {
switch (node.type) {
case 'file':
return node.fileId;
case 'track':
return node.trackId;
case 'segment':
return node.segmentId;
case 'waypoints':
return waypointsKey(node.fileId);
case 'waypoint':
return node.waypointId;
}
}
export function isEmpty(selection: Selection): boolean {
return selection.type === 'empty';
}
/** Ids of the files holding something selected (or selected themselves). */
export function selectedFileIds(selection: Selection): string[] {
switch (selection.type) {
case 'empty':
return [];
case 'file':
return selection.fileIds;
default:
return [selection.fileId];
}
}
/** Whether the node is one of the elements in the clipboard. */
export function isInClipboard(clipboard: Clipboard | undefined, node: FileTreeNode): boolean {
if (!clipboard) {
return false;
}
switch (node.type) {
case 'file':
return clipboard.type === 'files' && clipboard.ids.includes(node.fileId);
case 'track':
return clipboard.type === 'tracks' && clipboard.ids.includes(node.trackId);
case 'segment':
return clipboard.type === 'segments' && clipboard.ids.includes(node.segmentId);
case 'waypoint':
return clipboard.type === 'waypoints' && clipboard.ids.includes(node.waypointId);
case 'waypoints':
return false;
}
}
/** Number of selected elements (the waypoints node counts for one). */
export function selectionSize(selection: Selection): number {
switch (selection.type) {
case 'empty':
return 0;
case 'file':
return selection.fileIds.length;
case 'track':
return selection.trackIds.length;
case 'segment':
return selection.segmentIds.length;
case 'waypoints':
return 1;
case 'waypoint':
return selection.waypointIds.length;
}
}
/**
* The ids of the selected elements, by file, as used by the visibility (a file, its tracks, its
* segments, its waypoints, or `waypointsKey` for the waypoints node).
*/
export function selectedElementIds(selection: Selection): { fileId: string; ids: string[] }[] {
switch (selection.type) {
case 'empty':
return [];
case 'file':
return selection.fileIds.map((fileId) => ({ fileId, ids: [fileId] }));
case 'track':
return [{ fileId: selection.fileId, ids: selection.trackIds }];
case 'segment':
return [{ fileId: selection.fileId, ids: selection.segmentIds }];
case 'waypoints':
return [{ fileId: selection.fileId, ids: [waypointsKey(selection.fileId)] }];
case 'waypoint':
return [{ fileId: selection.fileId, ids: selection.waypointIds }];
}
}
/** Whether the node itself is selected. */
export function isSelected(selection: Selection, node: FileTreeNode): boolean {
switch (node.type) {
case 'file':
return selection.type === 'file' && selection.fileIds.includes(node.fileId);
case 'track':
return (
selection.type === 'track' &&
selection.fileId === node.fileId &&
selection.trackIds.includes(node.trackId)
);
case 'segment':
return (
selection.type === 'segment' &&
selection.fileId === node.fileId &&
selection.trackId === node.trackId &&
selection.segmentIds.includes(node.segmentId)
);
case 'waypoints':
return selection.type === 'waypoints' && selection.fileId === node.fileId;
case 'waypoint':
return (
selection.type === 'waypoint' &&
selection.fileId === node.fileId &&
selection.waypointIds.includes(node.waypointId)
);
}
}
/** The parent of a node, `undefined` for a file. */
function parent(node: FileTreeNode): FileTreeNode | undefined {
switch (node.type) {
case 'file':
return undefined;
case 'track':
case 'waypoints':
return { type: 'file', fileId: node.fileId };
case 'segment':
return { type: 'track', fileId: node.fileId, trackId: node.trackId };
case 'waypoint':
return { type: 'waypoints', fileId: node.fileId };
}
}
/** Whether the node, or one of its ancestors, is selected: the node is covered by the selection. */
export function isCovered(selection: Selection, node: FileTreeNode): boolean {
for (let n: FileTreeNode | undefined = node; n; n = parent(n)) {
if (isSelected(selection, n)) {
return true;
}
}
return false;
}
/** Whether the node, or one of its descendants, is selected. */
export function hasSelectionWithin(selection: Selection, node: FileTreeNode): boolean {
switch (node.type) {
case 'file':
return selectedFileIds(selection).includes(node.fileId);
case 'track':
return (
isSelected(selection, node) ||
(selection.type === 'segment' &&
selection.fileId === node.fileId &&
selection.trackId === node.trackId)
);
case 'waypoints':
return (
isSelected(selection, node) ||
(selection.type === 'waypoint' && selection.fileId === node.fileId)
);
case 'segment':
case 'waypoint':
return isSelected(selection, node);
}
}
/**
* Whether all the selected elements are hidden (false when nothing is selected). `states` holds
* the files by id: an element of an unknown file is not hidden.
*/
export function isSelectionHidden(
selection: Selection,
states: ReadonlyMap<string, VisibilityState>
): boolean {
const selected = selectedElementIds(selection);
return (
selected.length > 0 &&
selected.every(({ fileId, ids }) => {
const state = states.get(fileId);
return state !== undefined && ids.every((id) => isHidden(state, id));
})
);
}
@@ -0,0 +1,24 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { categoryAt } from './trackpoint-categories';
describe('categoryAt', () => {
const codes = new Uint8Array([1, 1, 0, 2, 0]);
const names = ['asphalt', 'gravel'];
it('is the name of the code', () => {
assert.equal(categoryAt(codes, names, 0), 'asphalt');
assert.equal(categoryAt(codes, names, 3), 'gravel');
});
it('is undefined when the value is unknown', () => {
assert.equal(categoryAt(codes, names, 2), undefined);
assert.equal(categoryAt(codes, names, 4), undefined);
});
it('is undefined out of the trackpoints, or for a code without name', () => {
assert.equal(categoryAt(codes, names, 5), undefined);
assert.equal(categoryAt(new Uint8Array([3]), names, 0), undefined);
assert.equal(categoryAt(new Uint8Array(), [], 0), undefined);
});
});
+12
View File
@@ -0,0 +1,12 @@
/**
* The value of a trackpoint for a category of the engine (surface, highway): `codes` has one entry
* per trackpoint, 0 when the value is unknown, else 1 + the index of the value in `names`.
*/
export function categoryAt(
codes: ArrayLike<number>,
names: readonly string[],
index: number
): string | undefined {
const code = codes[index];
return code ? names[code - 1] : undefined;
}
+36 -10
View File
@@ -18,14 +18,44 @@
} from '@lucide/svelte'; } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
import { exampleGPXFile } from '$lib/assets/example';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import Scissors from '$lib/components/toolbar/tools/scissors/Scissors.svelte'; import Scissors from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
import { currentTool, Tool } from '$lib/components/toolbar/tools'; import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
let gpxStatistics = writable(exampleGPXFile.getStatistics()); // TODO the example comes from the previous implementation
let slicedGPXStatistics = writable(undefined); let statistics = writable({
global: { totalDistance: 0, elevationGain: 0, elevationLoss: 0 },
length: 0,
totalDistance: new Float64Array(),
movingDistance: new Float64Array(),
totalTime: new BigInt64Array(),
movingTime: new BigInt64Array(),
speed: new Float64Array(),
elevationGain: new Float64Array(),
elevationLoss: new Float64Array(),
slope: new Float64Array(),
slopeSegmentSlope: new Float64Array(),
slopeSegmentDistance: new Float64Array(),
lng: new Float64Array(),
lat: new Float64Array(),
ele: new Float64Array(),
timestamps: new BigInt64Array(),
hr: new Float64Array(),
cad: new Float64Array(),
atemp: new Float64Array(),
power: new Float64Array(),
surface: new Uint8Array(),
surfaces: [],
highway: new Uint8Array(),
highways: [],
sacScale: new Uint8Array(),
sacScales: [],
mtbScale: new Uint8Array(),
mtbScales: [],
slice: () => undefined,
});
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null); let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']); let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined); let elevationFill = writable(undefined);
@@ -138,18 +168,14 @@
> >
<div class="grow"> <div class="grow">
<ElevationProfile <ElevationProfile
{gpxStatistics} {statistics}
{slicedGPXStatistics} {slicedStatistics}
{hoveredPoint} {hoveredPoint}
{additionalDatasets} {additionalDatasets}
{elevationFill} {elevationFill}
/> />
</div> </div>
<GPXStatistics <GPXStatistics {statistics} {slicedStatistics} orientation={'horizontal'} />
{gpxStatistics}
{slicedGPXStatistics}
orientation={'horizontal'}
/>
</div> </div>
</div> </div>
<div class="grid md:grid-rows-subgrid md:row-start-3 md:row-end-5 gap-4"> <div class="grid md:grid-rows-subgrid md:row-start-3 md:row-end-5 gap-4">
@@ -16,7 +16,9 @@
import { loadFiles } from '$lib/logic/file-actions'; import { loadFiles } from '$lib/logic/file-actions';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics'; import { hoveredPoint } from '$lib/logic/statistics';
import { slicedStatistics } from '$lib/logic/selection-statistics';
import { engine } from '$lib/engine';
import { getURLForGoogleDriveFile } from '$lib/components/embedding/embedding'; import { getURLForGoogleDriveFile } from '$lib/components/embedding/embedding';
import { db } from '$lib/db'; import { db } from '$lib/db';
import { fileStateCollection } from '$lib/logic/file-state'; import { fileStateCollection } from '$lib/logic/file-state';
@@ -140,14 +142,14 @@
style={$elevationProfile ? `height: ${$bottomPanelSize}px` : ''} style={$elevationProfile ? `height: ${$bottomPanelSize}px` : ''}
> >
<GPXStatistics <GPXStatistics
{gpxStatistics} statistics={engine.statistics}
{slicedGPXStatistics} {slicedStatistics}
orientation={bottomPanelOrientation == 'horizontal' ? 'vertical' : 'horizontal'} orientation={bottomPanelOrientation == 'horizontal' ? 'vertical' : 'horizontal'}
/> />
{#if $elevationProfile} {#if $elevationProfile}
<ElevationProfile <ElevationProfile
{gpxStatistics} statistics={engine.statistics}
{slicedGPXStatistics} {slicedStatistics}
{hoveredPoint} {hoveredPoint}
{additionalDatasets} {additionalDatasets}
{elevationFill} {elevationFill}