mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-27 10:37:17 +00:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da8a9e3d84 | ||
|
|
c6bd5b06e8 | ||
|
|
c04367dd86 | ||
|
|
5e2f506120 | ||
|
|
c536e6247b | ||
|
|
594d714b7f | ||
|
|
543fecadb8 | ||
|
|
017d8521d6 | ||
|
|
78e9d45072 | ||
|
|
7651d859c4 | ||
|
|
ec448b72e2 | ||
|
|
bf946f179a | ||
|
|
9ff9b5475a | ||
|
|
55f1d275b7 | ||
|
|
1d534881f8 | ||
|
|
03cbacfbb4 | ||
|
|
9d0905959b | ||
|
|
b7ca462f58 | ||
|
|
9015da4876 | ||
|
|
f458a1104f | ||
|
|
caba9388af | ||
|
|
763a7406ce | ||
|
|
68c8933c35 | ||
|
|
84b1558b2e | ||
|
|
cd870f0186 | ||
|
|
9ac2a2b69c | ||
|
|
86aba8f4b1 | ||
|
|
74b674e8e8 |
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>
|
||||
@@ -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>
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ pub struct LngLat {
|
||||
pub lat: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LngLatBounds {
|
||||
pub sw: LngLat,
|
||||
pub ne: LngLat,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod categories;
|
||||
mod chunk;
|
||||
mod common;
|
||||
mod file;
|
||||
@@ -6,6 +7,7 @@ mod track;
|
||||
mod trackpoint;
|
||||
mod waypoint;
|
||||
|
||||
pub use categories::*;
|
||||
pub use chunk::*;
|
||||
pub use common::*;
|
||||
pub use file::*;
|
||||
|
||||
@@ -9,5 +9,12 @@ pub struct Trackpoint {
|
||||
pub hr: Option<u16>,
|
||||
pub cad: 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>,
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{
|
||||
Author, File, Link, LngLat, Track, TrackSegment, Trackpoint, TrackpointChunk, Waypoint,
|
||||
WaypointChunk,
|
||||
Author, File, Link, LngLat, Track, TrackSegment, Trackpoint, TrackpointCategories,
|
||||
TrackpointChunk, Waypoint, WaypointChunk,
|
||||
};
|
||||
use chrono::DateTime;
|
||||
use quick_xml::Error;
|
||||
@@ -29,6 +29,10 @@ enum GPXElement {
|
||||
Heartrate,
|
||||
Cadence,
|
||||
Power,
|
||||
Surface,
|
||||
Highway,
|
||||
SacScale,
|
||||
MtbScale,
|
||||
Symbol,
|
||||
Type,
|
||||
Color,
|
||||
@@ -48,7 +52,9 @@ fn parse_coordinates(attributes: Attributes<'_>) -> LngLat {
|
||||
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 buf = vec![];
|
||||
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("hr") => stack.push(GPXElement::Heartrate),
|
||||
e if e.ends_with("cad") => stack.push(GPXElement::Cadence),
|
||||
"power" => stack.push(GPXElement::Power),
|
||||
e if e.ends_with("power") => 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),
|
||||
"type" => stack.push(GPXElement::Type),
|
||||
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),
|
||||
_ => (),
|
||||
},
|
||||
// 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() {
|
||||
"gpx" => {
|
||||
if !wpt_chunk.wpt.is_empty() {
|
||||
@@ -270,6 +304,30 @@ pub fn parse(data: &[u8]) -> Result<File, Error> {
|
||||
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) => {
|
||||
stack.pop();
|
||||
if let Some(GPXElement::Waypoint(wpt)) = stack.last_mut() {
|
||||
@@ -291,7 +349,7 @@ pub fn parse(data: &[u8]) -> Result<File, Error> {
|
||||
Some(GPXElement::Color) => {
|
||||
stack.pop();
|
||||
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) => {
|
||||
@@ -322,7 +380,35 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
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]
|
||||
@@ -513,6 +599,98 @@ mod tests {
|
||||
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]
|
||||
fn test_parse_trackpoint_cad() {
|
||||
let gpx = parse_data("with_cad");
|
||||
@@ -572,7 +750,7 @@ mod tests {
|
||||
assert_eq!(gpx.trk.len(), 1);
|
||||
let trk = &gpx.trk[0];
|
||||
assert_eq!(trk.trkseg.len(), 1);
|
||||
assert!(trk.info.color.as_ref().is_some_and(|c| c == "#2d3ee9"));
|
||||
assert!(trk.info.color.as_ref().is_some_and(|c| c == "2d3ee9"));
|
||||
assert!(trk.info.opacity.is_some_and(|o| o == 0.5));
|
||||
assert!(trk.info.width.is_some_and(|w| w == 6.0));
|
||||
}
|
||||
|
||||
@@ -41,6 +41,10 @@ impl Statistics {
|
||||
self.accumulate_distance_and_time(prev, cur);
|
||||
self.update_time_bounds(cur.time);
|
||||
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
|
||||
.push(TrackpointStatistics::from_partial_stats(self));
|
||||
}
|
||||
@@ -85,8 +89,8 @@ impl Statistics {
|
||||
trkseg,
|
||||
trkseg.first_index(),
|
||||
trkseg.last_index(),
|
||||
Some(10000),
|
||||
|i, j| time_diff(trkseg[i].time, trkseg[j].time),
|
||||
Some(10_000),
|
||||
|i, j| time_diff(trkseg[j].time, trkseg[i].time),
|
||||
|i, left, right| {
|
||||
self.local[i.flat].speed =
|
||||
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)]
|
||||
pub struct GlobalStatistics {
|
||||
pub total_distance: f64,
|
||||
pub moving_distance: Option<f64>,
|
||||
pub total_time: Option<i32>,
|
||||
pub moving_time: Option<i32>,
|
||||
pub total_time: Option<i64>,
|
||||
pub moving_time: Option<i64>,
|
||||
pub elevation_gain: f64,
|
||||
pub elevation_loss: f64,
|
||||
pub start_time: Option<i64>,
|
||||
pub end_time: Option<i64>,
|
||||
pub bounds: LngLatBounds,
|
||||
pub hr: Average,
|
||||
pub cad: Average,
|
||||
pub atemp: Average,
|
||||
pub power: Average,
|
||||
}
|
||||
|
||||
impl GlobalStatistics {
|
||||
@@ -241,6 +275,10 @@ impl GlobalStatistics {
|
||||
self.start_time = min_options(self.start_time, other.start_time);
|
||||
self.end_time = max_options(self.end_time, other.end_time);
|
||||
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 total_distance: f64,
|
||||
pub moving_distance: Option<f64>,
|
||||
pub total_time: Option<i32>,
|
||||
pub moving_time: Option<i32>,
|
||||
pub total_time: Option<i64>,
|
||||
pub moving_time: Option<i64>,
|
||||
pub speed: Option<f64>,
|
||||
pub elevation_gain: f64,
|
||||
pub elevation_loss: f64,
|
||||
@@ -293,7 +331,7 @@ mod tests {
|
||||
let mut f = File::open("data/with_time.gpx").unwrap();
|
||||
let mut data = String::new();
|
||||
let _ = f.read_to_string(&mut data);
|
||||
let gpx = parse(data.as_bytes()).unwrap();
|
||||
let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap();
|
||||
|
||||
let trkseg = &gpx.trk[0].trkseg[0];
|
||||
let stats = Statistics::compute(trkseg);
|
||||
@@ -310,7 +348,7 @@ mod tests {
|
||||
let mut f = File::open(path).unwrap();
|
||||
let mut data = String::new();
|
||||
let _ = f.read_to_string(&mut data);
|
||||
parse(data.as_bytes()).unwrap()
|
||||
parse(data.as_bytes(), &mut Default::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -417,4 +455,50 @@ mod tests {
|
||||
assert_eq!(cumul.total_time, Some(7_000));
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,12 +18,12 @@ pub fn distance(p1: LngLat, p2: LngLat) -> f64 {
|
||||
EARTH_RADIUS * c
|
||||
}
|
||||
|
||||
pub fn time_diff(a: Option<i64>, b: Option<i64>) -> Option<i32> {
|
||||
Some((a? - b?) as i32)
|
||||
pub fn time_diff(a: Option<i64>, b: Option<i64>) -> Option<i64> {
|
||||
Some(a? - b?)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
@@ -115,6 +115,19 @@ mod tests {
|
||||
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]
|
||||
fn test_distance() {
|
||||
assert_eq!(distance(p(4.0, 50.0), p(4.0, 50.0)), 0.0);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{Command, FileId};
|
||||
use crate::{Command, FileId, SelectMode, Selection};
|
||||
|
||||
/// Everything the frontend can ask the engine to do.
|
||||
///
|
||||
@@ -9,13 +9,28 @@ pub enum Action<'a> {
|
||||
Edit(Command<'a>),
|
||||
Undo,
|
||||
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 {
|
||||
file_ids: Vec<FileId>,
|
||||
},
|
||||
AddSelect {
|
||||
file_ids: Vec<FileId>,
|
||||
selection: Selection,
|
||||
mode: SelectMode,
|
||||
},
|
||||
/// 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,
|
||||
/// 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.
|
||||
Reorder {
|
||||
file_ids: Vec<FileId>,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::{Apply, CommandError, State};
|
||||
use crate::{
|
||||
Clean, Crop, Delete, DeleteAll, Duplicate, Elevation, Extract, Load, Merge, Metadata,
|
||||
MoveWaypoint, New, NewTrack, NewTrackSegment, NewWaypoint, Reverse, SpliceTrackpoints, Split,
|
||||
Style, Time,
|
||||
Clean, Crop, Delete, DeleteAll, DeleteWaypoint, Duplicate, EditWaypoint, Elevation, Extract,
|
||||
Load, LoadFiles, Merge, Metadata, Move, MoveWaypoint, New, NewTrack, NewTrackSegment,
|
||||
NewWaypoint, Paste, Reverse, SpliceTrackpoints, Split, Style, Time,
|
||||
};
|
||||
|
||||
/// A user action that edits the files. Each variant wraps the command's own struct, whose
|
||||
@@ -14,9 +14,13 @@ use crate::{
|
||||
pub enum Command<'a> {
|
||||
New(New<'a>),
|
||||
Load(Load<'a>),
|
||||
LoadFiles(LoadFiles<'a>),
|
||||
Duplicate(Duplicate),
|
||||
Delete(Delete),
|
||||
Paste(Paste),
|
||||
Move(Move),
|
||||
DeleteAll(DeleteAll),
|
||||
DeleteWaypoint(DeleteWaypoint),
|
||||
Metadata(Metadata<'a>),
|
||||
Style(Style<'a>),
|
||||
NewTrack(NewTrack),
|
||||
@@ -24,6 +28,7 @@ pub enum Command<'a> {
|
||||
Reverse(Reverse),
|
||||
SpliceTrackpoints(SpliceTrackpoints<'a>),
|
||||
NewWaypoint(NewWaypoint<'a>),
|
||||
EditWaypoint(EditWaypoint<'a>),
|
||||
MoveWaypoint(MoveWaypoint),
|
||||
Crop(Crop),
|
||||
Split(Split),
|
||||
@@ -39,9 +44,13 @@ impl Apply for Command<'_> {
|
||||
match self {
|
||||
Command::New(c) => c.apply(state),
|
||||
Command::Load(c) => c.apply(state),
|
||||
Command::LoadFiles(c) => c.apply(state),
|
||||
Command::Duplicate(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::DeleteWaypoint(c) => c.apply(state),
|
||||
Command::Metadata(c) => c.apply(state),
|
||||
Command::Style(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::SpliceTrackpoints(c) => c.apply(state),
|
||||
Command::NewWaypoint(c) => c.apply(state),
|
||||
Command::EditWaypoint(c) => c.apply(state),
|
||||
Command::MoveWaypoint(c) => c.apply(state),
|
||||
Command::Crop(c) => c.apply(state),
|
||||
Command::Split(c) => c.apply(state),
|
||||
|
||||
@@ -17,6 +17,10 @@ impl Editor for Metadata<'_> {
|
||||
fn file(&mut self, file: &mut File) -> Edit {
|
||||
file.info.name = self.name.to_owned();
|
||||
file.info.desc = Some(self.desc.to_owned());
|
||||
// a single track is named like the file
|
||||
if let [track] = file.trk.as_mut_slice() {
|
||||
track.info.name = Some(self.name.to_owned());
|
||||
}
|
||||
Edit::Changed
|
||||
}
|
||||
|
||||
@@ -31,7 +35,7 @@ impl Editor for Metadata<'_> {
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{New, Selection, engine::command::fixture::Fixture};
|
||||
use crate::{New, NewTrack, Selection, engine::command::fixture::Fixture};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -64,7 +68,12 @@ mod tests {
|
||||
fn test_metadata_applies_to_selected_track() {
|
||||
let mut fx = Fixture::default();
|
||||
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();
|
||||
fx.selection = Selection::Track {
|
||||
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[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() {
|
||||
let mut fx = Fixture::default();
|
||||
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 before = fx.files[&id].clone();
|
||||
assert!(!before.trk.is_empty());
|
||||
|
||||
@@ -4,12 +4,22 @@ use crate::{
|
||||
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)]
|
||||
pub struct Delete;
|
||||
pub struct Delete {
|
||||
pub whole_files: bool,
|
||||
}
|
||||
|
||||
impl Apply for Delete {
|
||||
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 } => {
|
||||
state.files.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,
|
||||
file_id: FileId,
|
||||
filter: impl Fn(&Waypoint) -> bool,
|
||||
@@ -98,7 +108,12 @@ mod tests {
|
||||
fn loaded() -> (Fixture, FileId) {
|
||||
let mut fx = Fixture::default();
|
||||
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];
|
||||
(fx, id)
|
||||
}
|
||||
@@ -107,7 +122,7 @@ mod tests {
|
||||
fn test_delete_nothing_selected() {
|
||||
let mut fx = Fixture::default();
|
||||
assert_eq!(
|
||||
Delete.apply(&mut fx.state()),
|
||||
Delete { whole_files: false }.apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
}
|
||||
@@ -120,7 +135,9 @@ mod tests {
|
||||
fx.selection = Selection::File {
|
||||
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!(fx.files.contains_key(&keep) && !fx.files.contains_key(&id));
|
||||
assert!(matches!(fx.selection, Selection::Empty));
|
||||
@@ -135,7 +152,9 @@ mod tests {
|
||||
file_id: 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];
|
||||
assert_eq!(file.trk.len(), before - 1);
|
||||
assert!(file.trk.iter().all(|t| t.id != trk_id));
|
||||
@@ -152,7 +171,9 @@ mod tests {
|
||||
trk_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];
|
||||
assert_eq!(trk.trkseg.len(), before - 1);
|
||||
assert!(trk.trkseg.iter().all(|s| s.id != seg_id));
|
||||
@@ -182,7 +203,9 @@ mod tests {
|
||||
wpt_ids: HashSet::from([ids[1]]),
|
||||
};
|
||||
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);
|
||||
let left: Vec<_> = fx.files[&id]
|
||||
.wpt
|
||||
@@ -198,11 +221,13 @@ mod tests {
|
||||
let (mut fx, id) = loaded();
|
||||
with_waypoints(&mut fx, id, 3);
|
||||
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());
|
||||
fx.selection = Selection::Waypoints { file_id: id };
|
||||
assert_eq!(
|
||||
Delete.apply(&mut fx.state()),
|
||||
Delete { whole_files: false }.apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
}
|
||||
@@ -215,7 +240,44 @@ mod tests {
|
||||
trk_ids: HashSet::from([Default::default()]),
|
||||
};
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 crate::{
|
||||
Apply, CommandError, File, FileId, Selection, StackEntry, State, Track, TrackSegment, Waypoint,
|
||||
WaypointChunk,
|
||||
Apply, CommandError, FileId, Selection, StackEntry, State, Waypoint, copy_file, copy_segment,
|
||||
copy_track, copy_waypoint, insert_waypoints,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -112,60 +112,23 @@ fn duplicate_waypoints(
|
||||
filter: impl Fn(&Waypoint) -> bool,
|
||||
) -> Result<Selection, CommandError> {
|
||||
let file = files.get(&file_id).ok_or(CommandError::NothingToDo)?;
|
||||
let mut file = (**file).clone();
|
||||
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
|
||||
let selected: Vec<&Waypoint> = file
|
||||
.wpt
|
||||
.iter()
|
||||
.flat_map(|chunk| &chunk.wpt)
|
||||
.filter(|wpt| filter(wpt))
|
||||
{
|
||||
let mut copy = wpt.clone();
|
||||
copy.id = Default::default();
|
||||
copies.insert(copy.id);
|
||||
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));
|
||||
}
|
||||
.collect();
|
||||
let last = selected.last().ok_or(CommandError::NothingToDo)?.id;
|
||||
let copies: Vec<Waypoint> = selected.into_iter().map(copy_waypoint).collect();
|
||||
let copy_ids = copies.iter().map(|wpt| wpt.id).collect();
|
||||
|
||||
// The copies go right after the last selected waypoint: only the chunk holding it is cut
|
||||
// (when waypoints follow it), all the other chunks are kept as they are.
|
||||
let cut = &file.wpt[last_chunk];
|
||||
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();
|
||||
// The copies go right after the last selected waypoint.
|
||||
let mut file = (**file).clone();
|
||||
insert_waypoints(&mut file, Some(last), copies);
|
||||
files.insert(file_id, Rc::new(file));
|
||||
Ok(Selection::Waypoint {
|
||||
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);
|
||||
}
|
||||
|
||||
// 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)]
|
||||
mod tests {
|
||||
use crate::engine::command::fixture::Fixture;
|
||||
@@ -211,7 +151,12 @@ mod tests {
|
||||
fn loaded() -> (Fixture, FileId) {
|
||||
let mut fx = Fixture::default();
|
||||
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];
|
||||
(fx, id)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,56 @@
|
||||
use crate::{Apply, CommandError, State, parse, produce};
|
||||
use crate::{Apply, CommandError, File, State, TrackpointCategories, parse, produce};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Load<'a> {
|
||||
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<'_> {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
let file = parse(self.data).map_err(|err| CommandError::InvalidData(err.to_string()))?;
|
||||
produce(state, |_| vec![file]);
|
||||
LoadFiles { files: vec![self] }.apply(state)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(())
|
||||
}
|
||||
}
|
||||
@@ -23,7 +65,14 @@ mod tests {
|
||||
fn test_load() {
|
||||
let mut fx = Fixture::default();
|
||||
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);
|
||||
let file = fx.files.values().next().unwrap();
|
||||
assert!(!file.trk.is_empty());
|
||||
@@ -38,6 +87,7 @@ mod tests {
|
||||
|
||||
let result = Load {
|
||||
data: b"<gpx><trk></gpx>",
|
||||
name: "file",
|
||||
}
|
||||
.apply(&mut fx.state());
|
||||
assert!(matches!(result, Err(CommandError::InvalidData(_))));
|
||||
@@ -45,4 +95,126 @@ mod tests {
|
||||
assert_eq!(fx.order.0.len(), 1);
|
||||
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_all;
|
||||
mod delete_waypoint;
|
||||
mod duplicate;
|
||||
mod load;
|
||||
mod move_elements;
|
||||
mod new;
|
||||
mod paste;
|
||||
mod transfer;
|
||||
|
||||
pub use delete::*;
|
||||
pub use delete_all::*;
|
||||
pub use delete_waypoint::*;
|
||||
pub use duplicate::*;
|
||||
pub use load::*;
|
||||
pub use move_elements::*;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -2,21 +2,25 @@
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{FileId, FileOrder, Selection, StackEntry, State};
|
||||
use crate::{Clipboard, FileId, FileOrder, Selection, StackEntry, State, TrackpointCategories};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Fixture {
|
||||
pub files: StackEntry,
|
||||
pub selection: Selection,
|
||||
pub order: FileOrder,
|
||||
pub clipboard: Option<Clipboard>,
|
||||
pub categories: TrackpointCategories,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
pub fn state(&mut self) -> State<'_> {
|
||||
State {
|
||||
categories: &mut self.categories,
|
||||
files: &mut self.files,
|
||||
selection: &mut self.selection,
|
||||
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 insert_waypoints;
|
||||
mod produce;
|
||||
mod update_selected;
|
||||
mod update_waypoint;
|
||||
|
||||
pub use copy::*;
|
||||
pub use edit_waypoint_chunks::*;
|
||||
pub use insert_waypoints::*;
|
||||
pub use produce::*;
|
||||
pub use update_selected::*;
|
||||
pub use update_waypoint::*;
|
||||
@@ -179,7 +179,7 @@ mod tests {
|
||||
fn fixture_with_tracks() -> (Fixture, FileId) {
|
||||
let mut fx = Fixture::default();
|
||||
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;
|
||||
fx.files.insert(id, file);
|
||||
(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(())
|
||||
}
|
||||
@@ -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)]
|
||||
pub enum CleanType {
|
||||
@@ -6,6 +12,9 @@ pub enum CleanType {
|
||||
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)]
|
||||
pub struct Clean {
|
||||
pub bounds: LngLatBounds,
|
||||
@@ -14,9 +23,385 @@ pub struct Clean {
|
||||
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 {
|
||||
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
|
||||
// TODO
|
||||
Err(CommandError::NotImplemented("clean"))
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
match &*state.selection {
|
||||
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 crop;
|
||||
mod edit_waypoint;
|
||||
mod elevation;
|
||||
mod extract;
|
||||
mod merge;
|
||||
@@ -12,6 +13,7 @@ mod time;
|
||||
|
||||
pub use clean::*;
|
||||
pub use crop::*;
|
||||
pub use edit_waypoint::*;
|
||||
pub use elevation::*;
|
||||
pub use extract::*;
|
||||
pub use merge::*;
|
||||
|
||||
@@ -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)]
|
||||
pub struct MoveWaypoint {
|
||||
pub file_id: FileId,
|
||||
pub waypoint_id: WaypointId,
|
||||
pub lng: f64,
|
||||
pub lat: f64,
|
||||
pub ele: f64,
|
||||
}
|
||||
|
||||
impl Apply for MoveWaypoint {
|
||||
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
|
||||
// TODO
|
||||
Err(CommandError::NotImplemented("move waypoint"))
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
update_waypoint(state, self.file_id, self.waypoint_id, |wpt| {
|
||||
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)]
|
||||
pub struct NewWaypoint<'a> {
|
||||
pub lng: f64,
|
||||
@@ -11,9 +18,222 @@ pub struct NewWaypoint<'a> {
|
||||
pub link: &'a str,
|
||||
}
|
||||
|
||||
fn non_empty(text: &str) -> Option<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<'_> {
|
||||
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
|
||||
// TODO
|
||||
Err(CommandError::NotImplemented("new waypoint"))
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
let file_ids: Vec<FileId> = match &*state.selection {
|
||||
Selection::Empty => vec![],
|
||||
Selection::File { file_ids } => state
|
||||
.order
|
||||
.0
|
||||
.iter()
|
||||
.filter(|id| file_ids.contains(id))
|
||||
.copied()
|
||||
.collect(),
|
||||
Selection::Track { file_id, .. }
|
||||
| Selection::TrackSegment { file_id, .. }
|
||||
| Selection::Waypoints { file_id }
|
||||
| Selection::Waypoint { file_id, .. } => vec![*file_id],
|
||||
};
|
||||
let file_ids: Vec<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) {
|
||||
let mut fx = Fixture::default();
|
||||
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 trk = &fx.files[&id].trk[0];
|
||||
fx.selection = Selection::TrackSegment {
|
||||
@@ -282,7 +287,12 @@ mod tests {
|
||||
fn test_splice_on_multiple_files_uses_last_in_file_order() {
|
||||
let (mut fx, first) = loaded();
|
||||
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];
|
||||
fx.selection = Selection::File {
|
||||
file_ids: HashSet::from([second, first]),
|
||||
|
||||
@@ -73,7 +73,12 @@ mod tests {
|
||||
fn loaded() -> (Fixture, FileId) {
|
||||
let mut fx = Fixture::default();
|
||||
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];
|
||||
(fx, id)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::{
|
||||
pub struct FileStructure {
|
||||
pub id: FileId,
|
||||
pub name: String,
|
||||
pub desc: Option<String>,
|
||||
pub tracks: Vec<TrackNode>,
|
||||
pub waypoints: Vec<WaypointNode>,
|
||||
/// Changes whenever the waypoints of the file (hence their buffer) change.
|
||||
@@ -22,6 +23,11 @@ pub struct FileStructure {
|
||||
pub struct TrackNode {
|
||||
pub id: TrackId,
|
||||
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>,
|
||||
}
|
||||
|
||||
@@ -37,6 +43,7 @@ pub struct SegmentNode {
|
||||
pub struct WaypointNode {
|
||||
pub id: WaypointId,
|
||||
pub name: Option<String>,
|
||||
pub sym: Option<String>,
|
||||
}
|
||||
|
||||
impl FileStructure {
|
||||
@@ -44,12 +51,17 @@ impl FileStructure {
|
||||
Self {
|
||||
id: file.id,
|
||||
name: file.info.name.clone(),
|
||||
desc: file.info.desc.clone(),
|
||||
tracks: file
|
||||
.trk
|
||||
.iter()
|
||||
.map(|trk| TrackNode {
|
||||
id: trk.id,
|
||||
name: trk.info.name.clone(),
|
||||
desc: trk.info.desc.clone(),
|
||||
color: trk.info.color.clone(),
|
||||
opacity: trk.info.opacity,
|
||||
width: trk.info.width,
|
||||
segments: trk
|
||||
.trkseg
|
||||
.iter()
|
||||
@@ -68,6 +80,7 @@ impl FileStructure {
|
||||
.map(|wpt| WaypointNode {
|
||||
id: wpt.id,
|
||||
name: wpt.name.clone(),
|
||||
sym: wpt.sym.clone(),
|
||||
})
|
||||
.collect(),
|
||||
wpt_rev_id: file.wpt_rev_id,
|
||||
@@ -87,13 +100,25 @@ mod tests {
|
||||
fn test_structure_of_a_file() {
|
||||
let mut fx = Fixture::default();
|
||||
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 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());
|
||||
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());
|
||||
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()));
|
||||
|
||||
@@ -41,8 +41,18 @@ mod tests {
|
||||
fn test_only_files_of_the_diff_are_recomputed() {
|
||||
let mut fx = Fixture::default();
|
||||
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
|
||||
Load { data: &data }.apply(&mut fx.state()).unwrap();
|
||||
Load { data: &data }.apply(&mut fx.state()).unwrap();
|
||||
Load {
|
||||
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 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)]
|
||||
pub struct StatisticsBuffer {
|
||||
/// Statistics of the whole selection.
|
||||
pub global: GlobalStatistics,
|
||||
pub total_distance: Vec<f64>,
|
||||
pub moving_distance: Vec<f64>,
|
||||
pub total_time: Vec<i32>,
|
||||
pub moving_time: Vec<i32>,
|
||||
pub total_time: Vec<i64>,
|
||||
pub moving_time: Vec<i64>,
|
||||
pub speed: Vec<f64>,
|
||||
pub elevation_gain: Vec<f64>,
|
||||
pub elevation_loss: Vec<f64>,
|
||||
pub slope: Vec<f64>,
|
||||
pub slope_segment_slope: 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 {
|
||||
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.moving_distance.clear();
|
||||
self.total_time.clear();
|
||||
@@ -26,10 +72,24 @@ impl StatisticsBuffer {
|
||||
self.slope.clear();
|
||||
self.slope_segment_slope.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();
|
||||
for stats in stats {
|
||||
for trkpt_stats in stats.local.iter() {
|
||||
let optional = |value: Option<f64>| value.unwrap_or(f64::NAN);
|
||||
|
||||
for (segment, stats) in selected {
|
||||
for (trkpt, trkpt_stats) in segment.iter().zip(stats.local.iter()) {
|
||||
let cumul_stats = &self.global;
|
||||
self.total_distance
|
||||
.push(cumul_stats.total_distance + trkpt_stats.total_distance);
|
||||
self.moving_distance.push(
|
||||
@@ -53,10 +113,59 @@ impl StatisticsBuffer {
|
||||
.push(trkpt_stats.slope_segment.slope);
|
||||
self.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)]
|
||||
@@ -65,28 +174,16 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
fn stats(path: &str) -> Statistics {
|
||||
fn computed(path: &str) -> (TrackSegment, Statistics) {
|
||||
let data = std::fs::read(path).unwrap();
|
||||
let file = parse(&data).unwrap();
|
||||
Statistics::compute(&file.trk[0].trkseg[0])
|
||||
let file = parse(&data, &mut Default::default()).unwrap();
|
||||
let segment = file.trk[0].trkseg[0].clone();
|
||||
let stats = Statistics::compute(&segment);
|
||||
(segment, stats)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty() {
|
||||
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 [
|
||||
fn all_lengths(buffer: &StatisticsBuffer) -> Vec<usize> {
|
||||
vec![
|
||||
buffer.total_distance.len(),
|
||||
buffer.moving_distance.len(),
|
||||
buffer.total_time.len(),
|
||||
@@ -97,21 +194,80 @@ mod tests {
|
||||
buffer.slope.len(),
|
||||
buffer.slope_segment_slope.len(),
|
||||
buffer.slope_segment_distance.len(),
|
||||
] {
|
||||
assert_eq!(len, n);
|
||||
}
|
||||
buffer.lng.len(),
|
||||
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() {
|
||||
assert_eq!(buffer.total_distance[i], local.total_distance);
|
||||
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]
|
||||
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 mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[&s, &s]);
|
||||
buffer.update(&[(&segment, &s), (&segment, &s)]);
|
||||
|
||||
assert_eq!(buffer.total_distance.len(), 2 * n);
|
||||
// the second segment starts where the first one ended
|
||||
@@ -123,27 +279,204 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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 duration = s.global.total_time.unwrap();
|
||||
assert!(duration > 0);
|
||||
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], 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.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]
|
||||
fn test_update_replaces_previous_content() {
|
||||
let s = stats("data/simple.gpx");
|
||||
let (segment, s) = computed("data/simple.gpx");
|
||||
let mut buffer = StatisticsBuffer::default();
|
||||
buffer.update(&[&s]);
|
||||
buffer.update(&[&s]);
|
||||
buffer.update(&[(&segment, &s)]);
|
||||
buffer.update(&[(&segment, &s)]);
|
||||
assert_eq!(buffer.total_distance.len(), s.local.len());
|
||||
assert!(all_lengths(&buffer).iter().all(|len| *len == s.local.len()));
|
||||
buffer.update(&[]);
|
||||
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::{
|
||||
collections::{HashMap, HashSet, hash_map::Entry},
|
||||
rc::Rc,
|
||||
use std::collections::{HashMap, HashSet, hash_map::Entry};
|
||||
|
||||
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)]
|
||||
pub struct StatisticsCache {
|
||||
@@ -34,19 +37,38 @@ impl StatisticsCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistics of the segments of the selection.
|
||||
pub fn get<'a>(
|
||||
&'a self,
|
||||
state: Option<&StackEntry>,
|
||||
state: Option<&'a StackEntry>,
|
||||
selection: &Selection,
|
||||
) -> 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 {
|
||||
match selection {
|
||||
Selection::Empty => (),
|
||||
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) {
|
||||
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) {
|
||||
for trk in file.trk.iter() {
|
||||
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 {
|
||||
for trkseg in trk.trkseg.iter() {
|
||||
if trkseg_ids.contains(&trkseg.id) {
|
||||
self.add_segment_stats(trkseg, &mut stats);
|
||||
self.add_segment_stats(trkseg, &mut selected);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -79,7 +101,7 @@ impl StatisticsCache {
|
||||
}
|
||||
Selection::Waypoints { 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 {
|
||||
@@ -87,36 +109,51 @@ impl StatisticsCache {
|
||||
wpt_ids: _,
|
||||
} => {
|
||||
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() {
|
||||
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() {
|
||||
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>) {
|
||||
if let Some(s) = self.map.get(&trkseg.rev_id) {
|
||||
stats.push(s);
|
||||
fn add_segment_stats<'a>(
|
||||
&'a self,
|
||||
trkseg: &'a TrackSegment,
|
||||
selected: &mut Vec<SelectedSegment<'a>>,
|
||||
) {
|
||||
if let Some(stats) = self.map.get(&trkseg.rev_id) {
|
||||
selected.push((trkseg, stats));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
use std::{collections::HashSet, rc::Rc};
|
||||
|
||||
use crate::{FileId, parse};
|
||||
|
||||
@@ -124,7 +161,7 @@ mod tests {
|
||||
|
||||
fn state(path: &str) -> (StackEntry, Rc<File>) {
|
||||
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();
|
||||
state.insert(file.id, file.clone());
|
||||
(state, file)
|
||||
@@ -230,4 +267,33 @@ mod tests {
|
||||
cache.update(None);
|
||||
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
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
mod clipboard;
|
||||
mod order;
|
||||
mod selection;
|
||||
mod stack;
|
||||
|
||||
pub use clipboard::*;
|
||||
pub use order::*;
|
||||
pub use selection::*;
|
||||
pub use stack::*;
|
||||
|
||||
pub struct State<'a> {
|
||||
pub categories: &'a mut crate::TrackpointCategories,
|
||||
pub files: &'a mut StackEntry,
|
||||
pub selection: &'a mut Selection,
|
||||
pub order: &'a mut FileOrder,
|
||||
pub clipboard: &'a mut Option<Clipboard>,
|
||||
}
|
||||
@@ -2,6 +2,19 @@ use std::collections::HashSet;
|
||||
|
||||
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)]
|
||||
pub enum Selection {
|
||||
#[default]
|
||||
@@ -28,24 +41,309 @@ pub enum 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) {
|
||||
match self {
|
||||
Selection::Empty => {}
|
||||
let empty = match self {
|
||||
Selection::Empty => false,
|
||||
Selection::File { file_ids } => {
|
||||
file_ids.retain(|id| files.contains_key(id));
|
||||
if file_ids.is_empty() {
|
||||
*self = Selection::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;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::TrackSegment { file_id, .. }
|
||||
| Selection::Waypoints { file_id }
|
||||
| Selection::Waypoint { file_id, .. } => {
|
||||
if !files.contains_key(file_id) {
|
||||
*self = Selection::Empty;
|
||||
| Selection::Waypoint { file_id, .. } => Selection::File {
|
||||
file_ids: [*file_id].into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::File;
|
||||
use crate::{File, Track, TrackSegment, Waypoint, WaypointChunk};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -86,4 +384,376 @@ mod tests {
|
||||
selection.retain_existing(&files);
|
||||
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);
|
||||
}
|
||||
}
|
||||
+698
-36
@@ -14,7 +14,7 @@ use std::cell::RefCell;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
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]
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -30,6 +30,26 @@ pub enum CleanType {
|
||||
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 {
|
||||
fn from(t: MergeType) -> Self {
|
||||
match t {
|
||||
@@ -66,14 +86,18 @@ fn edit(command: Command) -> bool {
|
||||
}
|
||||
|
||||
/// 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>();
|
||||
rest.is_empty().then(|| {
|
||||
chunks
|
||||
.iter()
|
||||
.map(|c| FileId(uuid::Uuid::from_bytes(*c)))
|
||||
.collect()
|
||||
})
|
||||
rest.is_empty()
|
||||
.then(|| chunks.iter().map(|c| 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 {
|
||||
@@ -109,14 +133,84 @@ macro_rules! stats_getter {
|
||||
|
||||
stats_getter!(total_distance, Float64Array);
|
||||
stats_getter!(moving_distance, Float64Array);
|
||||
stats_getter!(total_time, Int32Array);
|
||||
stats_getter!(moving_time, Int32Array);
|
||||
// Durations since the start of the selection, in milliseconds.
|
||||
stats_getter!(total_time, BigInt64Array);
|
||||
stats_getter!(moving_time, BigInt64Array);
|
||||
stats_getter!(speed, Float64Array);
|
||||
stats_getter!(elevation_gain, Float64Array);
|
||||
stats_getter!(elevation_loss, Float64Array);
|
||||
stats_getter!(slope, Float64Array);
|
||||
stats_getter!(slope_segment_slope, 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
|
||||
|
||||
@@ -125,9 +219,26 @@ pub fn new_file(name: &str) -> bool {
|
||||
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]
|
||||
pub fn load_file(data: &[u8]) -> bool {
|
||||
edit(Command::Load(engine::Load { data }))
|
||||
pub fn load_files(data: &[u8], lengths: &[u32], names: Vec<String>) -> bool {
|
||||
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]
|
||||
@@ -135,9 +246,11 @@ pub fn duplicate() -> bool {
|
||||
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]
|
||||
pub fn delete() -> bool {
|
||||
edit(Command::Delete(engine::Delete))
|
||||
pub fn delete(whole_files: bool) -> bool {
|
||||
edit(Command::Delete(engine::Delete { whole_files }))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
@@ -219,13 +332,68 @@ pub fn new_waypoint(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Moves a waypoint of a file, whatever is selected.
|
||||
#[wasm_bindgen]
|
||||
pub fn move_waypoint(lng: f64, lat: f64, ele: f64) -> bool {
|
||||
edit(Command::MoveWaypoint(engine::MoveWaypoint {
|
||||
lng,
|
||||
lat,
|
||||
ele,
|
||||
}))
|
||||
pub fn move_waypoint(file_id: &str, waypoint_id: &str, lng: f64, lat: f64, ele: f64) -> bool {
|
||||
match (parse_file_id(file_id), parse_waypoint_id(waypoint_id)) {
|
||||
(Some(file_id), Some(waypoint_id)) => edit(Command::MoveWaypoint(engine::MoveWaypoint {
|
||||
file_id,
|
||||
waypoint_id,
|
||||
lng,
|
||||
lat,
|
||||
ele,
|
||||
})),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes a waypoint of a file, whatever is selected.
|
||||
#[wasm_bindgen]
|
||||
pub fn delete_waypoint(file_id: &str, waypoint_id: &str) -> bool {
|
||||
match (parse_file_id(file_id), parse_waypoint_id(waypoint_id)) {
|
||||
(Some(file_id), Some(waypoint_id)) => {
|
||||
edit(Command::DeleteWaypoint(engine::DeleteWaypoint {
|
||||
file_id,
|
||||
waypoint_id,
|
||||
}))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_waypoint_id(id: &str) -> Option<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]
|
||||
@@ -299,25 +467,245 @@ pub fn redo() -> bool {
|
||||
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
|
||||
|
||||
/// `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]
|
||||
pub fn select(file_ids_bytes: &[u8]) -> bool {
|
||||
file_ids(file_ids_bytes).is_some_and(|file_ids| execute(Action::Select { file_ids }))
|
||||
}
|
||||
|
||||
/// `file_ids_bytes`: concatenated 16-byte UUIDs.
|
||||
#[wasm_bindgen]
|
||||
pub fn add_select(file_ids_bytes: &[u8]) -> bool {
|
||||
file_ids(file_ids_bytes).is_some_and(|file_ids| execute(Action::AddSelect { file_ids }))
|
||||
pub fn select(file_ids_bytes: &[u8], mode: SelectMode) -> bool {
|
||||
select_elements(
|
||||
file_ids(file_ids_bytes).map(|ids| engine::Selection::File {
|
||||
file_ids: ids.into_iter().collect(),
|
||||
}),
|
||||
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.
|
||||
#[wasm_bindgen]
|
||||
pub fn select_all() -> bool {
|
||||
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
|
||||
//
|
||||
// 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: 'waypoints'; fileId: string }
|
||||
| { type: 'waypoint'; fileId: string; waypointIds: string[] };
|
||||
/**
|
||||
* Where moved elements go: a list of the file tree and the position in it, counted among the
|
||||
* elements of the list that are not moved.
|
||||
*/
|
||||
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 {
|
||||
orderChanged: boolean;
|
||||
selectionChanged: boolean;
|
||||
clipboardChanged: boolean;
|
||||
/** Files to read the structure of. */
|
||||
added: string[];
|
||||
/** Files whose structure changed: reread it. */
|
||||
@@ -348,14 +774,47 @@ export interface FilesUpdate {
|
||||
export interface FileStructure {
|
||||
id: string;
|
||||
name: string;
|
||||
desc?: string;
|
||||
tracks: TrackNode[];
|
||||
waypoints: WaypointNode[];
|
||||
/** Changes when the waypoints of the file change: refetch their coordinates. */
|
||||
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 {
|
||||
id: string;
|
||||
name?: string;
|
||||
desc?: string;
|
||||
/** Style of the track, only present when the file defines it. */
|
||||
color?: string;
|
||||
opacity?: number;
|
||||
width?: number;
|
||||
segments: SegmentNode[];
|
||||
}
|
||||
export interface SegmentNode {
|
||||
@@ -368,6 +827,7 @@ export interface SegmentNode {
|
||||
export interface WaypointNode {
|
||||
id: string;
|
||||
name?: string;
|
||||
sym?: string;
|
||||
}
|
||||
"#;
|
||||
|
||||
@@ -375,12 +835,24 @@ export interface WaypointNode {
|
||||
extern "C" {
|
||||
#[wasm_bindgen(typescript_type = "Selection")]
|
||||
pub type Selection;
|
||||
#[wasm_bindgen(typescript_type = "WaypointDetails | undefined")]
|
||||
pub type WaypointDetails;
|
||||
#[wasm_bindgen(typescript_type = "TrackpointDetails | undefined")]
|
||||
pub type TrackpointDetails;
|
||||
#[wasm_bindgen(typescript_type = "MoveTarget")]
|
||||
pub type MoveTarget;
|
||||
#[wasm_bindgen(typescript_type = "Clipboard | undefined")]
|
||||
pub type Clipboard;
|
||||
#[wasm_bindgen(typescript_type = "FilesUpdate")]
|
||||
pub type FilesUpdate;
|
||||
#[wasm_bindgen(typescript_type = "FileStructure | undefined")]
|
||||
pub type FileStructure;
|
||||
#[wasm_bindgen(typescript_type = "GlobalStatistics | undefined")]
|
||||
pub type GlobalStatistics;
|
||||
#[wasm_bindgen(typescript_type = "string[]")]
|
||||
pub type FileOrder;
|
||||
#[wasm_bindgen(typescript_type = "string[]")]
|
||||
pub type StringList;
|
||||
}
|
||||
|
||||
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 {
|
||||
let node = named_node(file.id.0, Some(&file.name));
|
||||
if let Some(desc) = &file.desc {
|
||||
set(&node, "desc", desc.as_str());
|
||||
}
|
||||
set(
|
||||
&node,
|
||||
"tracks",
|
||||
array(&file.tracks, |trk| {
|
||||
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(
|
||||
&node,
|
||||
"segments",
|
||||
@@ -428,7 +915,11 @@ fn structure_object(file: &engine::FileStructure) -> Object {
|
||||
&node,
|
||||
"waypoints",
|
||||
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());
|
||||
@@ -458,6 +949,77 @@ pub fn file_structure(file_id: &str) -> FileStructure {
|
||||
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 {
|
||||
items
|
||||
.into_iter()
|
||||
@@ -465,12 +1027,10 @@ fn uuids<T>(items: impl IntoIterator<Item = T>, uuid: impl Fn(T) -> uuid::Uuid)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The current selection (ids are UUID strings).
|
||||
#[wasm_bindgen]
|
||||
pub fn selection() -> Selection {
|
||||
fn selection_object(selection: &engine::Selection) -> Object {
|
||||
use engine::Selection as S;
|
||||
let object = Object::new();
|
||||
with_engine(|e| match e.selection() {
|
||||
match selection {
|
||||
S::Empty => set(&object, "type", "empty"),
|
||||
S::File { file_ids } => {
|
||||
set(&object, "type", "file");
|
||||
@@ -500,8 +1060,109 @@ pub fn selection() -> Selection {
|
||||
set(&object, "fileId", file_id.0.to_string());
|
||||
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.
|
||||
@@ -512,6 +1173,7 @@ pub fn last_update() -> FilesUpdate {
|
||||
let diff = e.last_diff().cloned().unwrap_or_default();
|
||||
set(&update, "orderChanged", e.order_changed());
|
||||
set(&update, "selectionChanged", e.selection_changed());
|
||||
set(&update, "clipboardChanged", e.clipboard_changed());
|
||||
set(&update, "added", ids(&diff.added));
|
||||
set(&update, "modified", ids(&diff.modified));
|
||||
set(&update, "removed", ids(&diff.removed));
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"postbuild": "npx tsx src/lib/scripts/sitemap.ts",
|
||||
"preview": "vite preview",
|
||||
"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",
|
||||
"lint": "prettier --check . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore && eslint .",
|
||||
"format": "prettier --write . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore"
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -6,8 +6,9 @@
|
||||
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte';
|
||||
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
|
||||
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';
|
||||
|
||||
const { velocityUnits } = settings;
|
||||
@@ -16,18 +17,17 @@
|
||||
let panelWidth: number = $state(0);
|
||||
|
||||
let {
|
||||
gpxStatistics,
|
||||
slicedGPXStatistics,
|
||||
statistics: selectionStatistics,
|
||||
slicedStatistics,
|
||||
orientation,
|
||||
}: {
|
||||
gpxStatistics: Readable<GPXStatisticsGroup>;
|
||||
slicedGPXStatistics: Readable<[GPXGlobalStatistics, number, number] | undefined>;
|
||||
statistics: Readable<SelectionStatistics>;
|
||||
slicedStatistics: Readable<SlicedStatistics | undefined>;
|
||||
orientation: 'horizontal' | 'vertical';
|
||||
} = $props();
|
||||
|
||||
let statistics = $derived(
|
||||
$slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics.global
|
||||
);
|
||||
// the highlighted part of the selection, or all of it
|
||||
let statistics = $derived($slicedStatistics?.global ?? $selectionStatistics.global);
|
||||
</script>
|
||||
|
||||
<Card.Root
|
||||
@@ -46,15 +46,15 @@
|
||||
<Tooltip label={i18n._('quantities.distance')}>
|
||||
<span class="flex flex-row items-center">
|
||||
<Ruler size="16" class="mr-1" />
|
||||
<WithUnits value={statistics.distance.total} type="distance" />
|
||||
<WithUnits value={statistics.totalDistance} type="distance" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label={i18n._('quantities.elevation_gain_loss')}>
|
||||
<span class="flex flex-row items-center">
|
||||
<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" />
|
||||
<WithUnits value={statistics.elevation.loss} type="elevation" />
|
||||
<WithUnits value={statistics.elevationLoss} type="elevation" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
{#if panelHeight > 120 || (orientation === 'horizontal' && panelWidth > 450)}
|
||||
@@ -67,9 +67,13 @@
|
||||
>
|
||||
<span class="flex flex-row items-center">
|
||||
<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>
|
||||
<WithUnits value={statistics.speed.total} type="speed" />
|
||||
<WithUnits value={statistics.totalSpeed ?? 0} type="speed" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
@@ -81,9 +85,9 @@
|
||||
>
|
||||
<span class="flex flex-row items-center">
|
||||
<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>
|
||||
<WithUnits value={statistics.time.total} type="time" />
|
||||
<WithUnits value={statistics.totalTime ?? 0} type="time" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
@@ -53,27 +53,19 @@
|
||||
import { anySelectedLayer } from '$lib/components/map/layer-control/utils';
|
||||
import { defaultOverlays } from '$lib/assets/layers';
|
||||
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 { mode, setMode } from 'mode-watcher';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { languages } from '$lib/languages';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import {
|
||||
createFile,
|
||||
fileActions,
|
||||
loadFiles,
|
||||
pasteSelection,
|
||||
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 { createFile, loadFiles, triggerFileInput } from '$lib/logic/file-actions';
|
||||
import { engine } from '$lib/engine';
|
||||
import { allHidden } from '$lib/all-hidden';
|
||||
import { selectionSize } from '$lib/selection-helpers';
|
||||
// TODO centering on the selection still works on the previous implementation
|
||||
import { boundsManager } from '$lib/logic/bounds';
|
||||
import { tick, onMount } from 'svelte';
|
||||
import { allowedPastes } from '$lib/components/file-list/sortable-file-list';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const {
|
||||
distanceUnits,
|
||||
@@ -91,8 +83,12 @@
|
||||
routing,
|
||||
} = settings;
|
||||
|
||||
const canUndo = fileActionManager.canUndo;
|
||||
const canRedo = fileActionManager.canRedo;
|
||||
const { files, selection, canUndo, canRedo, canPaste } = engine;
|
||||
|
||||
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() {
|
||||
[$currentBasemap, $previousBasemap] = [$previousBasemap, $currentBasemap];
|
||||
@@ -153,27 +149,21 @@
|
||||
<Shortcut key="O" ctrl={true} />
|
||||
</Menubar.Item>
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item
|
||||
onclick={fileActions.duplicateSelection}
|
||||
disabled={$selection.size == 0}
|
||||
>
|
||||
<Menubar.Item onclick={() => engine.duplicate()} disabled={selectionCount == 0}>
|
||||
<Copy size="16" />
|
||||
{i18n._('menu.duplicate')}
|
||||
<Shortcut key="D" ctrl={true} />
|
||||
</Menubar.Item>
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item
|
||||
onclick={() => tick().then(fileActions.deleteSelectedFiles)}
|
||||
disabled={$selection.size == 0}
|
||||
onclick={() => engine.delete(true)}
|
||||
disabled={selectionCount == 0}
|
||||
>
|
||||
<FileX size="16" />
|
||||
{i18n._('menu.delete')}
|
||||
<Shortcut key="⌫" ctrl={true} />
|
||||
</Menubar.Item>
|
||||
<Menubar.Item
|
||||
onclick={fileActions.deleteAllFiles}
|
||||
disabled={fileStateCollection.size == 0}
|
||||
>
|
||||
<Menubar.Item onclick={() => engine.deleteAll()} disabled={noFiles}>
|
||||
<FileX size="16" />
|
||||
{i18n._('menu.delete_all')}
|
||||
<Shortcut key="⌫" ctrl={true} shift={true} />
|
||||
@@ -181,7 +171,7 @@
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item
|
||||
onclick={() => (exportState.current = ExportState.SELECTION)}
|
||||
disabled={$selection.size == 0}
|
||||
disabled={selectionCount == 0}
|
||||
>
|
||||
<Download size="16" />
|
||||
{i18n._('menu.export')}
|
||||
@@ -189,7 +179,7 @@
|
||||
</Menubar.Item>
|
||||
<Menubar.Item
|
||||
onclick={() => (exportState.current = ExportState.ALL)}
|
||||
disabled={fileStateCollection.size == 0}
|
||||
disabled={noFiles}
|
||||
>
|
||||
<Download size="16" />
|
||||
{i18n._('menu.export_all')}
|
||||
@@ -203,26 +193,19 @@
|
||||
<span class="hidden md:block">{i18n._('menu.edit')}</span>
|
||||
</Menubar.Trigger>
|
||||
<Menubar.Content class="border-none">
|
||||
<Menubar.Item onclick={() => fileActionManager.undo()} disabled={!$canUndo}>
|
||||
<Menubar.Item onclick={() => engine.undo()} disabled={!$canUndo}>
|
||||
<Undo2 size="16" />
|
||||
{i18n._('menu.undo')}
|
||||
<Shortcut key="Z" ctrl={true} />
|
||||
</Menubar.Item>
|
||||
<Menubar.Item onclick={() => fileActionManager.redo()} disabled={!$canRedo}>
|
||||
<Menubar.Item onclick={() => engine.redo()} disabled={!$canRedo}>
|
||||
<Redo2 size="16" />
|
||||
{i18n._('menu.redo')}
|
||||
<Shortcut key="Z" ctrl={true} shift={true} />
|
||||
</Menubar.Item>
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item
|
||||
disabled={$selection.size !== 1 ||
|
||||
!$selection
|
||||
.getSelected()
|
||||
.every(
|
||||
(item) =>
|
||||
item instanceof ListFileItem ||
|
||||
item instanceof ListTrackItem
|
||||
)}
|
||||
disabled={selectionCount !== 1 || !filesOrTracksSelected}
|
||||
onclick={() => (editMetadata.current = true)}
|
||||
>
|
||||
<Info size="16" />
|
||||
@@ -230,28 +213,15 @@
|
||||
<Shortcut key="I" ctrl={true} />
|
||||
</Menubar.Item>
|
||||
<Menubar.Item
|
||||
disabled={$selection.size === 0 ||
|
||||
!$selection
|
||||
.getSelected()
|
||||
.every(
|
||||
(item) =>
|
||||
item instanceof ListFileItem ||
|
||||
item instanceof ListTrackItem
|
||||
)}
|
||||
disabled={selectionCount === 0 || !filesOrTracksSelected}
|
||||
onclick={() => (editStyle.current = true)}
|
||||
>
|
||||
<PaintBucket size="16" />
|
||||
{i18n._('menu.style.button')}
|
||||
</Menubar.Item>
|
||||
<Menubar.Item
|
||||
onclick={() => {
|
||||
if ($allHidden) {
|
||||
fileActions.setHiddenToSelection(false);
|
||||
} else {
|
||||
fileActions.setHiddenToSelection(true);
|
||||
}
|
||||
}}
|
||||
disabled={$selection.size == 0}
|
||||
onclick={() => engine.setSelectionHidden(!$allHidden)}
|
||||
disabled={selectionCount == 0}
|
||||
>
|
||||
{#if $allHidden}
|
||||
<Eye size="16" />
|
||||
@@ -263,31 +233,20 @@
|
||||
<Shortcut key="H" ctrl={true} />
|
||||
</Menubar.Item>
|
||||
{#if $treeFileView}
|
||||
{#if $selection.getSelected().some((item) => item instanceof ListFileItem)}
|
||||
{#if $selection.type === 'file'}
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item
|
||||
onclick={() =>
|
||||
fileActions.addNewTrack(
|
||||
$selection.getSelected()[0].getFileId()
|
||||
)}
|
||||
disabled={$selection.size !== 1}
|
||||
onclick={() => engine.newTrack()}
|
||||
disabled={selectionCount !== 1}
|
||||
>
|
||||
<Plus size="16" />
|
||||
{i18n._('menu.new_track')}
|
||||
</Menubar.Item>
|
||||
{:else if $selection
|
||||
.getSelected()
|
||||
.some((item) => item instanceof ListTrackItem)}
|
||||
{:else if $selection.type === 'track'}
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item
|
||||
onclick={() => {
|
||||
let item = $selection.getSelected()[0];
|
||||
fileActions.addNewSegment(
|
||||
item.getFileId(),
|
||||
item.getTrackIndex()
|
||||
);
|
||||
}}
|
||||
disabled={$selection.size !== 1}
|
||||
onclick={() => engine.newTrackSegment()}
|
||||
disabled={selectionCount !== 1}
|
||||
>
|
||||
<Plus size="16" />
|
||||
{i18n._('menu.new_segment')}
|
||||
@@ -295,21 +254,18 @@
|
||||
{/if}
|
||||
{/if}
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item
|
||||
onclick={() => selection.selectAll()}
|
||||
disabled={fileStateCollection.size == 0}
|
||||
>
|
||||
<Menubar.Item onclick={() => engine.selectAll()} disabled={noFiles}>
|
||||
<FileStack size="16" />
|
||||
{i18n._('menu.select_all')}
|
||||
<Shortcut key="A" ctrl={true} />
|
||||
</Menubar.Item>
|
||||
<Menubar.Item
|
||||
onclick={() => {
|
||||
if ($selection.size > 0) {
|
||||
if (selectionCount > 0) {
|
||||
boundsManager.centerMapOnSelection();
|
||||
}
|
||||
}}
|
||||
disabled={$selection.size == 0}
|
||||
disabled={selectionCount == 0}
|
||||
>
|
||||
<Maximize size="16" />
|
||||
{i18n._('menu.center')}
|
||||
@@ -317,41 +273,24 @@
|
||||
</Menubar.Item>
|
||||
{#if $treeFileView}
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item
|
||||
onclick={() => selection.copySelection()}
|
||||
disabled={$selection.size === 0}
|
||||
>
|
||||
<Menubar.Item onclick={() => engine.copy()} disabled={selectionCount === 0}>
|
||||
<ClipboardCopy size="16" />
|
||||
{i18n._('menu.copy')}
|
||||
<Shortcut key="C" ctrl={true} />
|
||||
</Menubar.Item>
|
||||
<Menubar.Item
|
||||
onclick={() => selection.cutSelection()}
|
||||
disabled={$selection.size === 0}
|
||||
>
|
||||
<Menubar.Item onclick={() => engine.cut()} disabled={selectionCount === 0}>
|
||||
<Scissors size="16" />
|
||||
{i18n._('menu.cut')}
|
||||
<Shortcut key="X" ctrl={true} />
|
||||
</Menubar.Item>
|
||||
<Menubar.Item
|
||||
disabled={$copied === undefined ||
|
||||
$copied.length === 0 ||
|
||||
($selection.size > 0 &&
|
||||
!allowedPastes[$copied[0].level].includes(
|
||||
$selection.getSelected().pop()!.level
|
||||
))}
|
||||
onclick={pasteSelection}
|
||||
>
|
||||
<Menubar.Item disabled={!$canPaste} onclick={() => engine.paste()}>
|
||||
<ClipboardPaste size="16" />
|
||||
{i18n._('menu.paste')}
|
||||
<Shortcut key="V" ctrl={true} />
|
||||
</Menubar.Item>
|
||||
{/if}
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item
|
||||
onclick={() => tick().then(fileActions.deleteSelection)}
|
||||
disabled={$selection.size == 0}
|
||||
>
|
||||
<Menubar.Item onclick={() => engine.delete()} disabled={selectionCount == 0}>
|
||||
<Trash2 size="16" />
|
||||
{i18n._('menu.delete')}
|
||||
<Shortcut key="⌫" ctrl={true} />
|
||||
@@ -585,60 +524,55 @@
|
||||
triggerFileInput();
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'd' && (e.metaKey || e.ctrlKey)) {
|
||||
fileActions.duplicateSelection();
|
||||
engine.duplicate();
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'c' && (e.metaKey || e.ctrlKey)) {
|
||||
if (!targetInput) {
|
||||
selection.copySelection();
|
||||
engine.copy();
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if (e.key === 'x' && (e.metaKey || e.ctrlKey)) {
|
||||
if (!targetInput) {
|
||||
selection.cutSelection();
|
||||
engine.cut();
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if (e.key === 'v' && (e.metaKey || e.ctrlKey)) {
|
||||
if (!targetInput) {
|
||||
pasteSelection();
|
||||
engine.paste();
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if ((e.key === 's' || e.key == 'S') && (e.metaKey || e.ctrlKey)) {
|
||||
if (e.shiftKey) {
|
||||
if (fileStateCollection.size > 0) {
|
||||
if (!noFiles) {
|
||||
exportState.current = ExportState.ALL;
|
||||
}
|
||||
} else if ($selection.size > 0) {
|
||||
} else if (selectionCount > 0) {
|
||||
exportState.current = ExportState.SELECTION;
|
||||
}
|
||||
e.preventDefault();
|
||||
} else if ((e.key === 'z' || e.key == 'Z') && (e.metaKey || e.ctrlKey)) {
|
||||
if (e.shiftKey) {
|
||||
fileActionManager.redo();
|
||||
engine.redo();
|
||||
} else {
|
||||
fileActionManager.undo();
|
||||
engine.undo();
|
||||
}
|
||||
e.preventDefault();
|
||||
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) {
|
||||
if (!targetInput) {
|
||||
if (e.shiftKey) {
|
||||
fileActions.deleteAllFiles();
|
||||
engine.deleteAll();
|
||||
} else {
|
||||
fileActions.deleteSelection();
|
||||
engine.delete();
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
|
||||
if (!targetInput) {
|
||||
selection.selectAll();
|
||||
engine.selectAll();
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if (e.key === 'i' && (e.metaKey || e.ctrlKey)) {
|
||||
if (
|
||||
$selection.size === 1 &&
|
||||
$selection
|
||||
.getSelected()
|
||||
.every((item) => item instanceof ListFileItem || item instanceof ListTrackItem)
|
||||
) {
|
||||
if (selectionCount === 1 && filesOrTracksSelected) {
|
||||
editMetadata.current = true;
|
||||
}
|
||||
e.preventDefault();
|
||||
@@ -649,14 +583,10 @@
|
||||
$treeFileView = !$treeFileView;
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'h' && (e.metaKey || e.ctrlKey)) {
|
||||
if ($allHidden) {
|
||||
fileActions.setHiddenToSelection(false);
|
||||
} else {
|
||||
fileActions.setHiddenToSelection(true);
|
||||
}
|
||||
engine.setSelectionHidden(!$allHidden);
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
if ($selection.size > 0) {
|
||||
if (selectionCount > 0) {
|
||||
boundsManager.centerMapOnSelection();
|
||||
}
|
||||
} else if (e.key === 'F1') {
|
||||
@@ -681,10 +611,7 @@
|
||||
e.key === 'ArrowUp'
|
||||
) {
|
||||
if (!targetInput) {
|
||||
selection.updateFromKey(
|
||||
e.key === 'ArrowRight' || e.key === 'ArrowDown',
|
||||
e.shiftKey
|
||||
);
|
||||
engine.arrowSelect(e.key === 'ArrowRight' || e.key === 'ArrowDown', e.shiftKey);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
Construction,
|
||||
} from '@lucide/svelte';
|
||||
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 { i18n } from '$lib/i18n.svelte';
|
||||
import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile';
|
||||
@@ -26,15 +28,15 @@
|
||||
const { velocityUnits } = settings;
|
||||
|
||||
let {
|
||||
gpxStatistics,
|
||||
slicedGPXStatistics,
|
||||
statistics,
|
||||
slicedStatistics,
|
||||
hoveredPoint,
|
||||
additionalDatasets,
|
||||
elevationFill,
|
||||
showControls = true,
|
||||
}: {
|
||||
gpxStatistics: Readable<GPXStatisticsGroup>;
|
||||
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
|
||||
statistics: Readable<SelectionStatistics>;
|
||||
slicedStatistics: Writable<SlicedStatistics | undefined>;
|
||||
hoveredPoint: Writable<Coordinates | null>;
|
||||
additionalDatasets: Writable<string[]>;
|
||||
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
|
||||
@@ -47,8 +49,8 @@
|
||||
|
||||
onMount(() => {
|
||||
elevationProfile = new ElevationProfile(
|
||||
gpxStatistics,
|
||||
slicedGPXStatistics,
|
||||
statistics,
|
||||
slicedStatistics,
|
||||
hoveredPoint,
|
||||
additionalDatasets,
|
||||
elevationFill,
|
||||
|
||||
@@ -21,8 +21,11 @@ import Chart, {
|
||||
type TooltipItem,
|
||||
} from 'chart.js/auto';
|
||||
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 { categoryAt } from '$lib/trackpoint-categories';
|
||||
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors';
|
||||
|
||||
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
||||
@@ -39,7 +42,10 @@ interface ElevationProfilePoint {
|
||||
segment: number;
|
||||
length: number;
|
||||
};
|
||||
extensions: Record<string, any>;
|
||||
surface?: string;
|
||||
highway?: string;
|
||||
sacScale?: string;
|
||||
mtbScale?: string;
|
||||
coordinates: Coordinates;
|
||||
index: number;
|
||||
}
|
||||
@@ -51,23 +57,23 @@ export class ElevationProfile {
|
||||
private _dragging = false;
|
||||
private _panning = false;
|
||||
|
||||
private _gpxStatistics: Readable<GPXStatisticsGroup>;
|
||||
private _slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
|
||||
private _statistics: Readable<SelectionStatistics>;
|
||||
private _slicedStatistics: Writable<SlicedStatistics | undefined>;
|
||||
private _hoveredPoint: Writable<Coordinates | null>;
|
||||
private _additionalDatasets: Readable<string[]>;
|
||||
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
|
||||
|
||||
constructor(
|
||||
gpxStatistics: Readable<GPXStatisticsGroup>,
|
||||
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>,
|
||||
statistics: Readable<SelectionStatistics>,
|
||||
slicedStatistics: Writable<SlicedStatistics | undefined>,
|
||||
hoveredPoint: Writable<Coordinates | null>,
|
||||
additionalDatasets: Readable<string[]>,
|
||||
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
|
||||
canvas: HTMLCanvasElement,
|
||||
overlay: HTMLCanvasElement
|
||||
) {
|
||||
this._gpxStatistics = gpxStatistics;
|
||||
this._slicedGPXStatistics = slicedGPXStatistics;
|
||||
this._statistics = statistics;
|
||||
this._slicedStatistics = slicedStatistics;
|
||||
this._hoveredPoint = hoveredPoint;
|
||||
this._additionalDatasets = additionalDatasets;
|
||||
this._elevationFill = elevationFill;
|
||||
@@ -78,10 +84,10 @@ export class ElevationProfile {
|
||||
Chart.register(module.default);
|
||||
this.initialize();
|
||||
|
||||
this._gpxStatistics.subscribe(() => {
|
||||
this._statistics.subscribe(() => {
|
||||
this.updateData();
|
||||
});
|
||||
this._slicedGPXStatistics.subscribe(() => {
|
||||
this._slicedStatistics.subscribe(() => {
|
||||
this.updateOverlay();
|
||||
});
|
||||
distanceUnits.subscribe(() => {
|
||||
@@ -183,14 +189,10 @@ export class ElevationProfile {
|
||||
segment: point.slope.segment.toFixed(1),
|
||||
length: getDistanceWithUnits(point.slope.length),
|
||||
};
|
||||
let surface = point.extensions.surface
|
||||
? point.extensions.surface
|
||||
: 'unknown';
|
||||
let highway = point.extensions.highway
|
||||
? point.extensions.highway
|
||||
: 'unknown';
|
||||
let sacScale = point.extensions.sac_scale;
|
||||
let mtbScale = point.extensions.mtb_scale;
|
||||
let surface = point.surface ?? 'unknown';
|
||||
let highway = point.highway ?? 'unknown';
|
||||
let sacScale = point.sacScale;
|
||||
let mtbScale = point.mtbScale;
|
||||
|
||||
let labels = [
|
||||
` ${i18n._('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`,
|
||||
@@ -235,7 +237,7 @@ export class ElevationProfile {
|
||||
modifierKey: 'shift',
|
||||
onPanStart: () => {
|
||||
this._panning = true;
|
||||
this._slicedGPXStatistics.set(undefined);
|
||||
this._slicedStatistics.set(undefined);
|
||||
return true;
|
||||
},
|
||||
onPanComplete: () => {
|
||||
@@ -260,7 +262,7 @@ export class ElevationProfile {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._slicedGPXStatistics.set(undefined);
|
||||
this._slicedStatistics.set(undefined);
|
||||
},
|
||||
},
|
||||
limits: {
|
||||
@@ -361,14 +363,12 @@ export class ElevationProfile {
|
||||
if (startIndex === undefined) {
|
||||
startIndex = endIndex;
|
||||
} else if (startIndex !== endIndex) {
|
||||
this._slicedGPXStatistics.set([
|
||||
get(this._gpxStatistics).sliced(
|
||||
Math.min(startIndex, endIndex),
|
||||
Math.max(startIndex, endIndex)
|
||||
),
|
||||
Math.min(startIndex, endIndex),
|
||||
Math.max(startIndex, endIndex),
|
||||
]);
|
||||
const start = Math.min(startIndex, endIndex);
|
||||
const end = Math.max(startIndex, endIndex);
|
||||
const global = get(this._statistics).slice(start, end);
|
||||
if (global) {
|
||||
this._slicedStatistics.set({ global, start, end });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -379,7 +379,7 @@ export class ElevationProfile {
|
||||
this._canvas.style.cursor = '';
|
||||
endIndex = getIndex(evt);
|
||||
if (startIndex === endIndex) {
|
||||
this._slicedGPXStatistics.set(undefined);
|
||||
this._slicedStatistics.set(undefined);
|
||||
}
|
||||
};
|
||||
this._canvas.addEventListener('pointerdown', onMouseDown);
|
||||
@@ -391,7 +391,7 @@ export class ElevationProfile {
|
||||
if (!this._chart) {
|
||||
return;
|
||||
}
|
||||
const data = get(this._gpxStatistics);
|
||||
const data = get(this._statistics);
|
||||
const units = {
|
||||
distance: get(distanceUnits),
|
||||
velocity: get(velocityUnits),
|
||||
@@ -399,52 +399,51 @@ export class ElevationProfile {
|
||||
};
|
||||
|
||||
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({
|
||||
x: getConvertedDistance(distance, units.distance),
|
||||
y: trkpt.ele ? getConvertedElevation(trkpt.ele, units.distance) : 0,
|
||||
time: trkpt.time,
|
||||
slope: slope,
|
||||
extensions: trkpt.getExtensions(),
|
||||
coordinates: trkpt.getCoordinates(),
|
||||
index: index,
|
||||
x,
|
||||
y: ele ? getConvertedElevation(ele, units.distance) : 0,
|
||||
time: timestamp === NO_TIME ? undefined : new Date(Number(timestamp)),
|
||||
slope: {
|
||||
at: data.slope[index],
|
||||
segment: data.slopeSegmentSlope[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({
|
||||
x: getConvertedDistance(distance, units.distance),
|
||||
y: getConvertedVelocity(speed, units.velocity, units.distance),
|
||||
index: index,
|
||||
x,
|
||||
y: getConvertedVelocity(data.speed[index], units.velocity, units.distance),
|
||||
index,
|
||||
});
|
||||
}
|
||||
if (data.global.hr.count > 0) {
|
||||
datasets[2].push({
|
||||
x: getConvertedDistance(distance, units.distance),
|
||||
y: trkpt.getHeartRate(),
|
||||
index: index,
|
||||
});
|
||||
if (global.hr) {
|
||||
datasets[2].push({ x, y: data.hr[index], index });
|
||||
}
|
||||
if (data.global.cad.count > 0) {
|
||||
datasets[3].push({
|
||||
x: getConvertedDistance(distance, units.distance),
|
||||
y: trkpt.getCadence(),
|
||||
index: index,
|
||||
});
|
||||
if (global.cad) {
|
||||
datasets[3].push({ x, y: data.cad[index], index });
|
||||
}
|
||||
if (data.global.atemp.count > 0) {
|
||||
if (global.atemp) {
|
||||
datasets[4].push({
|
||||
x: getConvertedDistance(distance, units.distance),
|
||||
y: getConvertedTemperature(trkpt.getTemperature(), units.temperature),
|
||||
index: index,
|
||||
x,
|
||||
y: getConvertedTemperature(data.atemp[index], units.temperature),
|
||||
index,
|
||||
});
|
||||
}
|
||||
if (data.global.power.count > 0) {
|
||||
datasets[5].push({
|
||||
x: getConvertedDistance(distance, units.distance),
|
||||
y: trkpt.getPower(),
|
||||
index: index,
|
||||
});
|
||||
if (global.power) {
|
||||
datasets[5].push({ x, y: data.power[index], index });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this._chart.data.datasets[0] = {
|
||||
label: i18n._('quantities.elevation'),
|
||||
@@ -482,7 +481,7 @@ export class ElevationProfile {
|
||||
|
||||
this._chart.options.scales!.x!['min'] = 0;
|
||||
this._chart.options.scales!.x!['max'] = getConvertedDistance(
|
||||
data.global.distance.total,
|
||||
global.totalDistance,
|
||||
units.distance
|
||||
);
|
||||
|
||||
@@ -563,10 +562,10 @@ export class ElevationProfile {
|
||||
this._overlay.style.width = `${this._overlay.width}px`;
|
||||
this._overlay.style.height = `${this._overlay.height}px`;
|
||||
|
||||
const slicedGPXStatistics = get(this._slicedGPXStatistics);
|
||||
if (slicedGPXStatistics) {
|
||||
let startIndex = slicedGPXStatistics[1];
|
||||
let endIndex = slicedGPXStatistics[2];
|
||||
const slicedStatistics = get(this._slicedStatistics);
|
||||
if (slicedStatistics) {
|
||||
let startIndex = slicedStatistics.start;
|
||||
let endIndex = slicedStatistics.end;
|
||||
|
||||
// Draw selection rectangle
|
||||
let selectionContext = this._overlay.getContext('2d');
|
||||
@@ -575,14 +574,12 @@ export class ElevationProfile {
|
||||
selectionContext.globalAlpha = mode.current === 'dark' ? 0.2 : 0.1;
|
||||
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(
|
||||
getConvertedDistance(
|
||||
gpxStatistics.getTrackPoint(startIndex)?.distance.total ?? 0
|
||||
)
|
||||
getConvertedDistance(statistics.totalDistance[startIndex] ?? 0)
|
||||
);
|
||||
let endPixel = this._chart.scales.x.getPixelForValue(
|
||||
getConvertedDistance(gpxStatistics.getTrackPoint(endIndex)?.distance.total ?? 0)
|
||||
getConvertedDistance(statistics.totalDistance[endIndex] ?? 0)
|
||||
);
|
||||
|
||||
selectionContext.fillRect(
|
||||
@@ -607,16 +604,12 @@ export class ElevationProfile {
|
||||
|
||||
surfaceFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
|
||||
const point = context.p0.raw as ElevationProfilePoint;
|
||||
return getSurfaceColor(point.extensions.surface);
|
||||
return getSurfaceColor(point.surface ?? '');
|
||||
}
|
||||
|
||||
highwayFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
|
||||
const point = context.p0.raw as ElevationProfilePoint;
|
||||
return getHighwayColor(
|
||||
point.extensions.highway,
|
||||
point.extensions.sac_scale,
|
||||
point.extensions.mtb_scale
|
||||
);
|
||||
return getHighwayColor(point.highway ?? '', point.sacScale, point.mtbScale);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import LayerControl from '$lib/components/map/layer-control/LayerControl.svelte';
|
||||
import OpenIn from '$lib/components/embedding/OpenIn.svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
import type { GPXFile } from 'gpx';
|
||||
import {
|
||||
allowedEmbeddingBasemaps,
|
||||
getFilesFromEmbeddingOptions,
|
||||
@@ -15,12 +14,12 @@
|
||||
} from './embedding';
|
||||
import { setMode } from 'mode-watcher';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics';
|
||||
import { loadFile } from '$lib/logic/file-actions';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { loadFiles } from '$lib/logic/file-actions';
|
||||
import { untrack } from 'svelte';
|
||||
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 {
|
||||
useHash = true,
|
||||
@@ -28,9 +27,12 @@
|
||||
hash = $bindable(),
|
||||
}: { useHash?: boolean; options: EmbeddingOptions; hash: string } = $props();
|
||||
|
||||
const { files } = engine;
|
||||
|
||||
let additionalDatasets = writable<string[]>([]);
|
||||
let elevationFill = writable<'slope' | 'surface' | 'highway' | undefined>(undefined);
|
||||
|
||||
const { statistics } = engine;
|
||||
const {
|
||||
currentBasemap,
|
||||
selectedBasemapTree,
|
||||
@@ -71,26 +73,14 @@
|
||||
);
|
||||
elevationFill.set(options.elevation.fill == 'none' ? undefined : options.elevation.fill);
|
||||
|
||||
let downloads: Promise<GPXFile | null>[] = getFilesFromEmbeddingOptions(options).map(
|
||||
(url) => {
|
||||
return fetch(url)
|
||||
.then((response) => response.blob())
|
||||
.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();
|
||||
let downloads: Promise<File>[] = getFilesFromEmbeddingOptions(options).map((url) => {
|
||||
return fetch(url)
|
||||
.then((response) => response.blob())
|
||||
.then((blob) => new File([blob], url.split('/').pop() ?? url));
|
||||
});
|
||||
Promise.all(downloads)
|
||||
.then(loadFiles)
|
||||
.then(() => engine.selectAll());
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -102,7 +92,7 @@
|
||||
<div class="absolute flex flex-col h-full w-full border rounded-xl overflow-clip">
|
||||
<div class="grow relative">
|
||||
<Map
|
||||
class="h-full {$fileStateCollection.size > 1 ? 'horizontal' : ''}"
|
||||
class="h-full {$files.size > 1 ? 'horizontal' : ''}"
|
||||
maptilerKey={options.key}
|
||||
geocoder={false}
|
||||
geolocate={true}
|
||||
@@ -111,7 +101,7 @@
|
||||
<OpenIn files={options.files} ids={options.ids} />
|
||||
<LayerControl />
|
||||
<GPXLayers />
|
||||
{#if $fileStateCollection.size > 1}
|
||||
{#if $files.size > 1}
|
||||
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
|
||||
<FileList orientation="horizontal" />
|
||||
</div>
|
||||
@@ -122,14 +112,14 @@
|
||||
style={options.elevation.show ? `height: ${options.elevation.height}px` : ''}
|
||||
>
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
orientation={options.elevation.show ? 'vertical' : 'horizontal'}
|
||||
/>
|
||||
{#if options.elevation.show}
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
|
||||
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 { ListFileItem, ListLevel, ListRootItem } from './file-list';
|
||||
import { ClipboardPaste, FileStack, Plus } from '@lucide/svelte';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import { createFile, pasteSelection } from '$lib/logic/file-actions';
|
||||
import { selection, copied } from '$lib/logic/selection';
|
||||
import { allowedPastes } from './sortable-file-list';
|
||||
import { engine } from '$lib/engine';
|
||||
import { selectedFileIds } from '$lib/selection-helpers';
|
||||
import { createFile } from '$lib/logic/file-actions';
|
||||
|
||||
let {
|
||||
orientation,
|
||||
@@ -27,17 +25,12 @@
|
||||
setContext('orientation', orientation);
|
||||
setContext('recursive', recursive);
|
||||
|
||||
const { files, selection, canPaste } = engine;
|
||||
|
||||
onMount(() => {
|
||||
if (orientation === 'horizontal') {
|
||||
selection.update(($selection) => {
|
||||
$selection.forEach((item) => {
|
||||
if (!(item instanceof ListFileItem)) {
|
||||
$selection.toggle(item);
|
||||
$selection.set(new ListFileItem(item.getFileId()), true);
|
||||
}
|
||||
});
|
||||
return $selection;
|
||||
});
|
||||
if (orientation === 'horizontal' && $selection.type !== 'file') {
|
||||
// only files can be selected here
|
||||
engine.select(selectedFileIds($selection));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -54,7 +47,7 @@
|
||||
: 'flex-row'} {className ?? ''}"
|
||||
{style}
|
||||
>
|
||||
<FileListNode node={$fileStateCollection} item={new ListRootItem()} />
|
||||
<FileListNodeContent node={null} />
|
||||
{#if orientation === 'vertical'}
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger class="grow" />
|
||||
@@ -66,20 +59,15 @@
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item
|
||||
onclick={() => selection.selectAll()}
|
||||
disabled={$fileStateCollection.size === 0}
|
||||
onclick={() => engine.selectAll()}
|
||||
disabled={$files.size === 0}
|
||||
>
|
||||
<FileStack size="16" />
|
||||
{i18n._('menu.select_all')}
|
||||
<Shortcut key="A" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item
|
||||
disabled={$copied === undefined ||
|
||||
$copied.length === 0 ||
|
||||
!allowedPastes[$copied[0].level].includes(ListLevel.ROOT)}
|
||||
onclick={pasteSelection}
|
||||
>
|
||||
<ContextMenu.Item disabled={!$canPaste} onclick={() => engine.paste()}>
|
||||
<ClipboardPaste size="16" />
|
||||
{i18n._('menu.paste')}
|
||||
<Shortcut key="V" ctrl={true} />
|
||||
|
||||
@@ -1,89 +1,93 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
GPXFile,
|
||||
Track,
|
||||
TrackSegment,
|
||||
Waypoint,
|
||||
type AnyGPXTreeElement,
|
||||
type GPXTreeElement,
|
||||
} from 'gpx';
|
||||
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
|
||||
import { type Readable } from 'svelte/store';
|
||||
import FileListNodeContent from './FileListNodeContent.svelte';
|
||||
import FileListNodeLabel from './FileListNodeLabel.svelte';
|
||||
import { getContext } from 'svelte';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListTrackSegmentItem,
|
||||
ListWaypointItem,
|
||||
ListWaypointsItem,
|
||||
type ListItem,
|
||||
type ListTrackItem,
|
||||
} from './file-list';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { engine, type FileState } from '$lib/engine';
|
||||
import {
|
||||
elementId,
|
||||
hasSelectionWithin,
|
||||
isSelected,
|
||||
type FileTreeNode,
|
||||
} from '$lib/selection-helpers';
|
||||
|
||||
let {
|
||||
fileState,
|
||||
node,
|
||||
item,
|
||||
}: {
|
||||
node:
|
||||
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
||||
| GPXTreeElement<AnyGPXTreeElement>
|
||||
| Waypoint[]
|
||||
| Waypoint;
|
||||
item: ListItem;
|
||||
fileState: FileState;
|
||||
node: FileTreeNode;
|
||||
} = $props();
|
||||
|
||||
let recursive = getContext<boolean>('recursive');
|
||||
|
||||
let collapsible: CollapsibleTreeNode | undefined = $state();
|
||||
|
||||
let label = $derived(
|
||||
node instanceof GPXFile && item instanceof ListFileItem
|
||||
? node.metadata.name
|
||||
: node instanceof Track
|
||||
? (node.name ?? `${i18n._('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`)
|
||||
: node instanceof TrackSegment
|
||||
? `${i18n._('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}`
|
||||
: node instanceof Waypoint
|
||||
? (node.name ??
|
||||
`${i18n._('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`)
|
||||
: node instanceof GPXFile && item instanceof ListWaypointsItem
|
||||
? i18n._('gpx.waypoints')
|
||||
: ''
|
||||
);
|
||||
let label = $derived.by(() => {
|
||||
const { structure } = fileState;
|
||||
switch (node.type) {
|
||||
case 'file':
|
||||
return structure.name;
|
||||
case 'track': {
|
||||
const trackIndex = structure.tracks.findIndex((t) => t.id === node.trackId);
|
||||
return (
|
||||
structure.tracks[trackIndex]?.name ?? `${i18n._('gpx.track')} ${trackIndex + 1}`
|
||||
);
|
||||
}
|
||||
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 { selection } = engine;
|
||||
|
||||
$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();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if node instanceof Map}
|
||||
<FileListNodeContent {node} {item} />
|
||||
{:else if node instanceof TrackSegment}
|
||||
<FileListNodeLabel {node} {item} {label} />
|
||||
{:else if node instanceof Waypoint}
|
||||
<FileListNodeLabel {node} {item} {label} />
|
||||
{#if node.type === 'segment' || node.type === 'waypoint'}
|
||||
<FileListNodeLabel {fileState} {node} {label} />
|
||||
{:else if recursive}
|
||||
<CollapsibleTreeNode id={item.getId()} bind:this={collapsible}>
|
||||
<CollapsibleTreeNode {id} bind:this={collapsible}>
|
||||
{#snippet trigger()}
|
||||
<FileListNodeLabel {node} {item} {label} />
|
||||
<FileListNodeLabel {fileState} {node} {label} />
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="ml-4">
|
||||
{#key node}
|
||||
<FileListNodeContent {node} {item} />
|
||||
{/key}
|
||||
<FileListNodeContent {fileState} {node} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</CollapsibleTreeNode>
|
||||
{:else}
|
||||
<FileListNodeLabel {node} {item} {label} />
|
||||
<FileListNodeLabel {fileState} {node} {label} />
|
||||
{/if}
|
||||
@@ -1,41 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { GPXFile, Track, Waypoint, type AnyGPXTreeElement, type GPXTreeElement } from 'gpx';
|
||||
import { getContext, onDestroy, onMount } from 'svelte';
|
||||
import { type Readable } from 'svelte/store';
|
||||
import FileListNodeStore from './FileListNodeStore.svelte';
|
||||
import FileListNode from './FileListNode.svelte';
|
||||
import FileListNodeContent from './FileListNodeContent.svelte';
|
||||
import { ListFileItem, ListLevel, ListWaypointsItem, type ListItem } from './file-list';
|
||||
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
|
||||
import { ListLevel } from './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 {
|
||||
fileState,
|
||||
node,
|
||||
item,
|
||||
waypointRoot = false,
|
||||
}: {
|
||||
node:
|
||||
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
||||
| GPXTreeElement<AnyGPXTreeElement>
|
||||
| Waypoint[]
|
||||
| Waypoint;
|
||||
item: ListItem;
|
||||
/** State of the file of the node (not needed for the root). */
|
||||
fileState?: FileState;
|
||||
/** The node whose children are listed, `null` for the list of the files. */
|
||||
node: FileTreeNode | null;
|
||||
/** List the node standing for the waypoints of the file, instead of its tracks. */
|
||||
waypointRoot?: boolean;
|
||||
} = $props();
|
||||
|
||||
const { files, order } = engine;
|
||||
|
||||
let container: HTMLElement;
|
||||
let sortableLevel: ListLevel =
|
||||
node instanceof Map
|
||||
node === null
|
||||
? ListLevel.FILE
|
||||
: node instanceof GPXFile
|
||||
: node.type === 'file'
|
||||
? waypointRoot
|
||||
? ListLevel.WAYPOINTS
|
||||
: item instanceof ListWaypointsItem
|
||||
? ListLevel.WAYPOINT
|
||||
: ListLevel.TRACK
|
||||
: node instanceof Track
|
||||
? ListLevel.SEGMENT
|
||||
: ListLevel.WAYPOINT;
|
||||
: ListLevel.TRACK
|
||||
: node.type === 'waypoints'
|
||||
? ListLevel.WAYPOINT
|
||||
: ListLevel.SEGMENT;
|
||||
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
||||
|
||||
let canDrop = $derived($dragging !== null && allowedMoves[$dragging].includes(sortableLevel));
|
||||
@@ -43,18 +41,14 @@
|
||||
let sortable: SortableFileList;
|
||||
|
||||
onMount(() => {
|
||||
sortable = new SortableFileList(
|
||||
container,
|
||||
node,
|
||||
item,
|
||||
waypointRoot,
|
||||
sortableLevel,
|
||||
orientation
|
||||
);
|
||||
sortable = new SortableFileList(container, node, waypointRoot, sortableLevel, orientation);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (sortable && node) {
|
||||
// the elements of the list changed
|
||||
$order;
|
||||
fileState;
|
||||
if (sortable) {
|
||||
sortable.updateElements();
|
||||
}
|
||||
});
|
||||
@@ -70,45 +64,65 @@
|
||||
? 'flex-col'
|
||||
: 'flex-row gap-1'} {canDrop ? 'min-h-5' : ''}"
|
||||
>
|
||||
{#if node instanceof Map}
|
||||
{#each node as [fileId, file] (fileId)}
|
||||
<div data-id={fileId}>
|
||||
<FileListNodeStore {file} />
|
||||
</div>
|
||||
{/each}
|
||||
{:else if node instanceof GPXFile}
|
||||
{#if item instanceof ListWaypointsItem}
|
||||
{#each node.wpt as wpt, i (wpt)}
|
||||
<div data-id={i} class="ml-1">
|
||||
<FileListNode node={wpt} item={item.extend(i)} />
|
||||
</div>
|
||||
{/each}
|
||||
{:else if waypointRoot}
|
||||
{#if node.wpt.length > 0}
|
||||
<div data-id="waypoints">
|
||||
<FileListNode {node} item={item.extend('waypoints')} />
|
||||
{#if node === null}
|
||||
{#each $order as fileId (fileId)}
|
||||
{@const file = $files.get(fileId)}
|
||||
{#if file}
|
||||
<div data-id={fileId}>
|
||||
<FileListNodeStore {file} />
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{#each node.children as child, i (child)}
|
||||
<div data-id={i}>
|
||||
<FileListNode node={child} item={item.extend(i)} />
|
||||
{/each}
|
||||
{:else if fileState}
|
||||
{#if node.type === 'file'}
|
||||
{#if waypointRoot}
|
||||
{#if fileState.structure.waypoints.length > 0}
|
||||
<div data-id="waypoints">
|
||||
<FileListNode
|
||||
{fileState}
|
||||
node={{ type: 'waypoints', fileId: node.fileId }}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{#each fileState.structure.tracks as track (track.id)}
|
||||
<div data-id={track.id}>
|
||||
<FileListNode
|
||||
{fileState}
|
||||
node={{ type: 'track', fileId: node.fileId, trackId: track.id }}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{:else if node.type === 'waypoints'}
|
||||
{#each fileState.structure.waypoints as waypoint (waypoint.id)}
|
||||
<div data-id={waypoint.id} class="ml-1">
|
||||
<FileListNode
|
||||
{fileState}
|
||||
node={{ type: 'waypoint', fileId: node.fileId, waypointId: waypoint.id }}
|
||||
/>
|
||||
</div>
|
||||
{/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}
|
||||
{:else if node instanceof Track}
|
||||
{#each node.children as child, i (child)}
|
||||
<div data-id={i} class="ml-1">
|
||||
<FileListNode node={child} item={item.extend(i)} />
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if node instanceof GPXFile && item instanceof ListFileItem}
|
||||
{#if !waypointRoot}
|
||||
<FileListNodeContent {node} {item} waypointRoot={true} />
|
||||
{/if}
|
||||
{#if fileState && node?.type === 'file' && !waypointRoot}
|
||||
<FileListNodeContent {fileState} {node} waypointRoot={true} />
|
||||
{/if}
|
||||
|
||||
<style lang="postcss">
|
||||
|
||||
@@ -18,86 +18,91 @@
|
||||
Scissors,
|
||||
FileStack,
|
||||
} from '@lucide/svelte';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListLevel,
|
||||
ListTrackItem,
|
||||
ListWaypointItem,
|
||||
type ListItem,
|
||||
} from './file-list';
|
||||
import { ListLevel } from './file-list';
|
||||
import { getContext } from 'svelte';
|
||||
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import MetadataDialog from '$lib/components/file-list/metadata/MetadataDialog.svelte';
|
||||
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
||||
import StyleDialog from '$lib/components/file-list/style/StyleDialog.svelte';
|
||||
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
import { selection, copied, cut } from '$lib/logic/selection';
|
||||
import { fileActions, pasteSelection } from '$lib/logic/file-actions';
|
||||
import { allHidden } from '$lib/logic/hidden';
|
||||
import { engine, type FileState } from '$lib/engine';
|
||||
import { allHidden } from '$lib/all-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 { 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 { allowedPastes } from './sortable-file-list';
|
||||
|
||||
let {
|
||||
fileState,
|
||||
node,
|
||||
item,
|
||||
label,
|
||||
}: {
|
||||
node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
||||
item: ListItem;
|
||||
fileState: FileState;
|
||||
node: FileTreeNode;
|
||||
label: string | undefined;
|
||||
} = $props();
|
||||
|
||||
const { selection, clipboard, canPaste } = engine;
|
||||
|
||||
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
||||
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 colors: string[] = [];
|
||||
if (node) {
|
||||
if (node instanceof GPXFile) {
|
||||
let defaultColor = $gpxColors.get(item.getFileId());
|
||||
let style = node.getStyle(defaultColor);
|
||||
colors = style.color;
|
||||
} else if (node instanceof Track) {
|
||||
let style = node.getStyle();
|
||||
if (
|
||||
style &&
|
||||
style['gpx_style:color'] &&
|
||||
!colors.includes(style['gpx_style:color'])
|
||||
) {
|
||||
colors.push(style['gpx_style:color']);
|
||||
}
|
||||
if (colors.length === 0) {
|
||||
let defaultColor = $gpxColors.get(item.getFileId());
|
||||
if (defaultColor) {
|
||||
colors.push(defaultColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
const { tracks } = fileState.structure;
|
||||
if (node.type === 'file') {
|
||||
// the colors defined by the tracks, or else the one of the file
|
||||
const colors = [
|
||||
...new Set(
|
||||
tracks.flatMap((track) =>
|
||||
track.color !== undefined ? [normalizeColor(track.color)] : []
|
||||
)
|
||||
),
|
||||
];
|
||||
return colors.length > 0 ? colors : [fileState.color];
|
||||
} else if (node.type === 'track') {
|
||||
const color = tracks.find((track) => track.id === node.trackId)?.color;
|
||||
return [color !== undefined ? normalizeColor(color) : fileState.color];
|
||||
}
|
||||
return colors;
|
||||
return [];
|
||||
});
|
||||
|
||||
let symbolKey = $derived(node instanceof Waypoint ? getSymbolKey(node.sym) : undefined);
|
||||
|
||||
let openEditMetadata: boolean = $derived(
|
||||
editMetadata.current && singleSelection && $selection.has(item)
|
||||
let symbolKey = $derived(
|
||||
node.type === 'waypoint'
|
||||
? getSymbolKey(fileState.structure.waypoints.find((w) => w.id === node.waypointId)?.sym)
|
||||
: undefined
|
||||
);
|
||||
|
||||
let selected = $derived(isSelected($selection, node));
|
||||
let openEditMetadata: boolean = $derived(editMetadata.current && singleSelection && selected);
|
||||
let openEditStyle: boolean = $derived(
|
||||
editStyle.current &&
|
||||
$selection.has(item) &&
|
||||
$selection.getSelected().findIndex((i) => i.getFullId() === item.getFullId()) === 0
|
||||
selected &&
|
||||
selectedElementIds($selection)[0]?.ids[0] === elementId(node)
|
||||
);
|
||||
|
||||
let hidden = $derived(
|
||||
item.level === ListLevel.WAYPOINTS ? node._data.hiddenWpt : node._data.hidden
|
||||
);
|
||||
let hidden = $derived(isHidden(fileState, elementId(node)));
|
||||
// cut elements are greyed until they are pasted
|
||||
let isCut = $derived($clipboard?.cut === true && isInClipboard($clipboard, node));
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
@@ -105,8 +110,8 @@
|
||||
<ContextMenu.Root
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
if (!$selection.has(item)) {
|
||||
selection.selectItem(item);
|
||||
if (!selected) {
|
||||
engine.selectNode(node);
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -119,11 +124,11 @@
|
||||
? 'h-7'
|
||||
: 'h-9 px-1.5'} pointer-events-auto"
|
||||
>
|
||||
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
|
||||
<MetadataDialog bind:open={openEditMetadata} {node} {item} />
|
||||
<StyleDialog bind:open={openEditStyle} {item} />
|
||||
{#if node.type === 'file' || node.type === 'track'}
|
||||
<MetadataDialog bind:open={openEditMetadata} {fileState} {node} />
|
||||
<StyleDialog bind:open={openEditStyle} {fileState} {node} />
|
||||
{/if}
|
||||
{#if item.level === ListLevel.FILE || item.level === ListLevel.TRACK}
|
||||
{#if level === ListLevel.FILE || level === ListLevel.TRACK}
|
||||
<div
|
||||
class="absolute {orientation === 'vertical'
|
||||
? 'top-0 bottom-0 right-0 w-1'
|
||||
@@ -141,9 +146,7 @@
|
||||
<span
|
||||
class="grow text-left truncate ml-1 flex flex-row items-center {hidden
|
||||
? 'text-muted-foreground'
|
||||
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId())
|
||||
? 'text-muted-foreground'
|
||||
: ''}"
|
||||
: ''} {isCut ? 'text-muted-foreground' : ''}"
|
||||
oncontextmenu={(e) => {
|
||||
if (embedding) {
|
||||
e.preventDefault();
|
||||
@@ -154,37 +157,30 @@
|
||||
// Add to selection instead of opening context menu
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
$selection.toggle(item);
|
||||
$selection = $selection;
|
||||
engine.selectNode(node, 'toggle');
|
||||
}
|
||||
}}
|
||||
onmouseenter={() => {
|
||||
if (item instanceof ListWaypointItem) {
|
||||
let layer = gpxLayers.getLayer(item.getFileId());
|
||||
let file = fileStateCollection.getFile(item.getFileId());
|
||||
if (layer && file) {
|
||||
let waypoint = file.wpt[item.getWaypointIndex()];
|
||||
if (waypoint && !waypoint._data.hidden) {
|
||||
waypointPopup?.setItem({
|
||||
item: waypoint,
|
||||
fileId: item.getFileId(),
|
||||
});
|
||||
}
|
||||
if (node.type === 'waypoint' && !isHidden(fileState, node.waypointId)) {
|
||||
const waypoint = engine.waypoint(node.fileId, node.waypointId);
|
||||
if (waypoint) {
|
||||
waypointPopup?.setItem({
|
||||
item: waypoint,
|
||||
kind: 'waypoint',
|
||||
fileId: node.fileId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
onmouseleave={() => {
|
||||
if (item instanceof ListWaypointItem) {
|
||||
let layer = gpxLayers.getLayer(item.getFileId());
|
||||
if (layer) {
|
||||
waypointPopup?.setItem(null);
|
||||
}
|
||||
if (node.type === 'waypoint') {
|
||||
waypointPopup?.setItem(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if item.level === ListLevel.SEGMENT}
|
||||
{#if level === ListLevel.SEGMENT}
|
||||
<Waypoints size="16" class="mx-1 shrink-0" />
|
||||
{:else if item.level === ListLevel.WAYPOINT}
|
||||
{:else if level === ListLevel.WAYPOINT}
|
||||
{#if symbolKey && symbols[symbolKey].icon}
|
||||
{@const SymbolIcon = symbols[symbolKey].icon}
|
||||
<SymbolIcon size="16" class="mx-1 shrink-0" />
|
||||
@@ -211,7 +207,7 @@
|
||||
</Button>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content>
|
||||
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
|
||||
{#if node.type === 'file' || node.type === 'track'}
|
||||
<ContextMenu.Item
|
||||
disabled={!singleSelection}
|
||||
onclick={() => (editMetadata.current = true)}
|
||||
@@ -225,15 +221,7 @@
|
||||
{i18n._('menu.style.button')}
|
||||
</ContextMenu.Item>
|
||||
{/if}
|
||||
<ContextMenu.Item
|
||||
onclick={() => {
|
||||
if ($allHidden) {
|
||||
fileActions.setHiddenToSelection(false);
|
||||
} else {
|
||||
fileActions.setHiddenToSelection(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ContextMenu.Item onclick={() => engine.setSelectionHidden(!$allHidden)}>
|
||||
{#if $allHidden}
|
||||
<Eye size="16" />
|
||||
{i18n._('menu.unhide')}
|
||||
@@ -245,20 +233,16 @@
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
{#if orientation === 'vertical'}
|
||||
{#if item instanceof ListFileItem}
|
||||
<ContextMenu.Item
|
||||
disabled={!singleSelection}
|
||||
onclick={() => fileActions.addNewTrack(item.getFileId())}
|
||||
>
|
||||
{#if node.type === 'file'}
|
||||
<ContextMenu.Item disabled={!singleSelection} onclick={() => engine.newTrack()}>
|
||||
<Plus size="16" />
|
||||
{i18n._('menu.new_track')}
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
{:else if item instanceof ListTrackItem}
|
||||
{:else if node.type === 'track'}
|
||||
<ContextMenu.Item
|
||||
disabled={!singleSelection}
|
||||
onclick={() =>
|
||||
fileActions.addNewSegment(item.getFileId(), item.getTrackIndex())}
|
||||
onclick={() => engine.newTrackSegment()}
|
||||
>
|
||||
<Plus size="16" />
|
||||
{i18n._('menu.new_segment')}
|
||||
@@ -266,8 +250,8 @@
|
||||
<ContextMenu.Separator />
|
||||
{/if}
|
||||
{/if}
|
||||
{#if item.level !== ListLevel.WAYPOINTS}
|
||||
<ContextMenu.Item onclick={() => selection.selectAll()}>
|
||||
{#if level !== ListLevel.WAYPOINTS}
|
||||
<ContextMenu.Item onclick={() => engine.selectAll()}>
|
||||
<FileStack size="16" />
|
||||
{i18n._('menu.select_all')}
|
||||
<Shortcut key="A" ctrl={true} />
|
||||
@@ -279,35 +263,30 @@
|
||||
<Shortcut key="⏎" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item onclick={fileActions.duplicateSelection}>
|
||||
<ContextMenu.Item onclick={() => engine.duplicate()}>
|
||||
<Copy size="16" />
|
||||
{i18n._('menu.duplicate')}
|
||||
<Shortcut key="D" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
{#if orientation === 'vertical'}
|
||||
<ContextMenu.Item onclick={() => selection.copySelection()}>
|
||||
<ContextMenu.Item onclick={() => engine.copy()}>
|
||||
<ClipboardCopy size="16" />
|
||||
{i18n._('menu.copy')}
|
||||
<Shortcut key="C" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item onclick={() => selection.cutSelection()}>
|
||||
<ContextMenu.Item onclick={() => engine.cut()}>
|
||||
<Scissors size="16" />
|
||||
{i18n._('menu.cut')}
|
||||
<Shortcut key="X" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
disabled={$copied === undefined ||
|
||||
$copied.length === 0 ||
|
||||
!allowedPastes[$copied[0].level].includes(item.level)}
|
||||
onclick={pasteSelection}
|
||||
>
|
||||
<ContextMenu.Item disabled={!$canPaste} onclick={() => engine.paste()}>
|
||||
<ClipboardPaste size="16" />
|
||||
{i18n._('menu.paste')}
|
||||
<Shortcut key="V" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
{/if}
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item onclick={fileActions.deleteSelection}>
|
||||
<ContextMenu.Item onclick={() => engine.delete()}>
|
||||
<Trash2 size="16" />
|
||||
{i18n._('menu.delete')}
|
||||
<Shortcut key="⌫" ctrl={true} />
|
||||
|
||||
@@ -4,24 +4,21 @@
|
||||
|
||||
import { getContext } from 'svelte';
|
||||
import type { Readable } from 'svelte/store';
|
||||
import { ListFileItem } from './file-list';
|
||||
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
|
||||
import type { FileState } from '$lib/engine';
|
||||
|
||||
let {
|
||||
file,
|
||||
}: {
|
||||
file: Readable<GPXFileWithStatistics | undefined>;
|
||||
file: Readable<FileState>;
|
||||
} = $props();
|
||||
|
||||
let recursive = getContext<boolean>('recursive');
|
||||
</script>
|
||||
|
||||
{#if $file}
|
||||
{#if recursive}
|
||||
<CollapsibleTree side="left" defaultState="closed" slotInsideTrigger={false}>
|
||||
<FileListNode node={$file.file} item={new ListFileItem($file.file._data.id)} />
|
||||
</CollapsibleTree>
|
||||
{:else}
|
||||
<FileListNode node={$file.file} item={new ListFileItem($file.file._data.id)} />
|
||||
{/if}
|
||||
{#if recursive}
|
||||
<CollapsibleTree side="left" defaultState="closed" slotInsideTrigger={false}>
|
||||
<FileListNode fileState={$file} node={{ type: 'file', fileId: $file.structure.id }} />
|
||||
</CollapsibleTree>
|
||||
{:else}
|
||||
<FileListNode fileState={$file} node={{ type: 'file', fileId: $file.structure.id }} />
|
||||
{/if}
|
||||
@@ -5,35 +5,31 @@
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
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 { 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 {
|
||||
fileState,
|
||||
node,
|
||||
item,
|
||||
open = $bindable(),
|
||||
}: {
|
||||
node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
||||
item: ListItem;
|
||||
fileState: FileState;
|
||||
node: FileTreeNode;
|
||||
open: boolean;
|
||||
} = $props();
|
||||
|
||||
let track = $derived(
|
||||
node.type === 'track'
|
||||
? fileState.structure.tracks.find((track) => track.id === node.trackId)
|
||||
: undefined
|
||||
);
|
||||
let name: string = $derived(
|
||||
node instanceof GPXFile
|
||||
? (node.metadata.name ?? '')
|
||||
: node instanceof Track
|
||||
? (node.name ?? '')
|
||||
: ''
|
||||
node.type === 'file' ? fileState.structure.name : (track?.name ?? '')
|
||||
);
|
||||
let description: string = $derived(
|
||||
node instanceof GPXFile
|
||||
? (node.metadata.desc ?? '')
|
||||
: node instanceof Track
|
||||
? (node.desc ?? '')
|
||||
: ''
|
||||
node.type === 'file' ? (fileState.structure.desc ?? '') : (track?.desc ?? '')
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
@@ -53,19 +49,12 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
fileActionManager.applyToFile(item.getFileId(), (file) => {
|
||||
if (item instanceof ListFileItem && node instanceof GPXFile) {
|
||||
file.metadata.name = name;
|
||||
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;
|
||||
}
|
||||
});
|
||||
// closes the dialog now: `open` follows `editMetadata`, and the change of the file
|
||||
// would open the dialog again before the effect below has reset it
|
||||
editMetadata.current = false;
|
||||
open = false;
|
||||
// the metadata of the selected elements are changed
|
||||
engine.metadata(name, description);
|
||||
}}
|
||||
>
|
||||
<Save size="16" />
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { isMac } from '$lib/utils';
|
||||
import Sortable, { type Direction } from 'sortablejs/Sortable';
|
||||
import { ListItem, ListLevel, ListRootItem } from './file-list';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
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 { ListLevel } from './file-list';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
const { fileOrder } = settings;
|
||||
import { engine, type MoveTarget, type Selection } from '$lib/engine';
|
||||
import { isSelected, type FileTreeNode } from '$lib/selection-helpers';
|
||||
|
||||
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
|
||||
[ListLevel.ROOT]: [],
|
||||
@@ -20,45 +15,34 @@ export const allowedMoves: Record<ListLevel, ListLevel[]> = {
|
||||
[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);
|
||||
|
||||
/** 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 {
|
||||
private _node:
|
||||
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
||||
| GPXTreeElement<AnyGPXTreeElement>
|
||||
| Waypoint[]
|
||||
| Waypoint;
|
||||
private _item: ListItem;
|
||||
private _parent: FileTreeNode | null;
|
||||
private _sortableLevel: ListLevel;
|
||||
private _container: HTMLElement;
|
||||
private _sortable: Sortable | null = null;
|
||||
private _elements: { [id: string]: HTMLElement } = {};
|
||||
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)[] = [];
|
||||
|
||||
constructor(
|
||||
container: HTMLElement,
|
||||
node:
|
||||
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
||||
| GPXTreeElement<AnyGPXTreeElement>
|
||||
| Waypoint[]
|
||||
| Waypoint,
|
||||
item: ListItem,
|
||||
parent: FileTreeNode | null,
|
||||
waypointRoot: boolean,
|
||||
sortableLevel: ListLevel,
|
||||
orientation: Direction
|
||||
) {
|
||||
this._node = node;
|
||||
this._item = item;
|
||||
this._parent = parent;
|
||||
this._sortableLevel = sortableLevel;
|
||||
this._container = container;
|
||||
this._sortable = Sortable.create(container, {
|
||||
@@ -76,79 +60,137 @@ export class SortableFileList {
|
||||
setTimeout(() => this.updateToSelection(e), 50),
|
||||
onDeselect: (e: Sortable.SortableEvent) =>
|
||||
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),
|
||||
onSort: (e: Sortable.SortableEvent) => this.onSort(e),
|
||||
});
|
||||
Object.defineProperty(this._sortable, '_item', {
|
||||
value: item,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(this._sortable, '_waypointRoot', {
|
||||
value: waypointRoot,
|
||||
writable: true,
|
||||
});
|
||||
lists.set(container, this);
|
||||
|
||||
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) {
|
||||
this.updateToFileOrder();
|
||||
|
||||
const from = Sortable.get(e.from);
|
||||
const to = Sortable.get(e.to);
|
||||
|
||||
const from = lists.get(e.from);
|
||||
const to = lists.get(e.to);
|
||||
if (!from || !to) {
|
||||
return;
|
||||
}
|
||||
|
||||
let fromItem = from._item;
|
||||
let toItem = to._item;
|
||||
if (from === to && this._sortableLevel === ListLevel.FILE) {
|
||||
// the order of the files is the one of the list
|
||||
this.updateToFileOrder();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._item === toItem && !(fromItem instanceof ListRootItem)) {
|
||||
// Event is triggered on source and destination list, only handle it once
|
||||
let fromItems = [];
|
||||
let toItems = [];
|
||||
// The event is triggered on the source and on the destination list: handle it once.
|
||||
// Nothing to move in the list of the single node of the waypoints.
|
||||
if (to !== this || (from === to && this._sortableLevel === ListLevel.WAYPOINTS)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
const elements: HTMLElement[] = e.items.length > 0 ? e.items : [e.item];
|
||||
const ids = elements
|
||||
.map((element) => element.getAttribute('data-id'))
|
||||
.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);
|
||||
|
||||
fromItems = oldIndices.map((i) => fromItem.extend(i));
|
||||
}
|
||||
// The list is rendered from the state of the engine: put the elements back where they
|
||||
// were, the engine moves them for real.
|
||||
from._snapshot.forEach((node) => e.from.appendChild(node));
|
||||
|
||||
if (from._waypointRoot && to._waypointRoot) {
|
||||
toItems = [toItem.extend('waypoints')];
|
||||
} else {
|
||||
if (to._waypointRoot) {
|
||||
toItem = toItem.extend('waypoints');
|
||||
}
|
||||
|
||||
let newIndices: number[] =
|
||||
e.newIndicies.length > 0 ? e.newIndicies.map((i) => i.index) : [e.newIndex];
|
||||
newIndices = newIndices.filter((i) => i >= 0);
|
||||
newIndices.sort((a, b) => a - b);
|
||||
|
||||
if (toItem instanceof ListRootItem) {
|
||||
let newFileIds = getFileIds(newIndices.length);
|
||||
toItems = newIndices.map((i, index) => {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
const selection_ = get(selection);
|
||||
const selection_ = get(engine.selection);
|
||||
for (let id of changed) {
|
||||
let element = this._elements[id];
|
||||
if (element) {
|
||||
if (selection_.has(this._item.extend(id))) {
|
||||
let node = this.childNode(id);
|
||||
if (element && node) {
|
||||
if (isSelected(selection_, node)) {
|
||||
Sortable.utils.select(element);
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
@@ -183,27 +226,42 @@ export class SortableFileList {
|
||||
this._updatingSelection = false;
|
||||
return;
|
||||
}
|
||||
selection.update(($selection) => {
|
||||
$selection.clear();
|
||||
Object.entries(this._elements).forEach(([id, element]) => {
|
||||
$selection.set(
|
||||
this._item.extend(this.getRealId(id)),
|
||||
element.classList.contains('sortable-selected')
|
||||
);
|
||||
});
|
||||
|
||||
if (
|
||||
e.originalEvent &&
|
||||
!(e.originalEvent.ctrlKey || e.originalEvent.metaKey || e.originalEvent.shiftKey) &&
|
||||
($selection.size > 1 ||
|
||||
!$selection.has(this._item.extend(this.getRealId(changed[0]))))
|
||||
) {
|
||||
// Fix bug that sometimes causes a single select to be treated as a multi-select
|
||||
$selection.clear();
|
||||
$selection.set(this._item.extend(this.getRealId(changed[0])), true);
|
||||
let selected = Object.entries(this._elements)
|
||||
.filter(([, element]) => element.classList.contains('sortable-selected'))
|
||||
.map(([id]) => id);
|
||||
|
||||
if (
|
||||
e.originalEvent &&
|
||||
!(e.originalEvent.ctrlKey || e.originalEvent.metaKey || e.originalEvent.shiftKey) &&
|
||||
(selected.length > 1 || !selected.includes(changed[0]))
|
||||
) {
|
||||
// Fix bug that sometimes causes a single select to be treated as a multi-select
|
||||
selected = [changed[0]];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -212,7 +270,7 @@ export class SortableFileList {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileOrder_ = get(fileOrder);
|
||||
const fileOrder_ = get(engine.order);
|
||||
const sortableOrder = this._sortable.toArray();
|
||||
|
||||
if (
|
||||
@@ -228,24 +286,25 @@ export class SortableFileList {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileOrder_ = get(fileOrder);
|
||||
const fileOrder_ = get(engine.order);
|
||||
const sortableOrder = this._sortable.toArray();
|
||||
|
||||
if (
|
||||
fileOrder_.length !== sortableOrder.length ||
|
||||
fileOrder_.some((value, index) => value !== sortableOrder[index])
|
||||
) {
|
||||
fileOrder.set(sortableOrder);
|
||||
engine.reorder(sortableOrder, 0);
|
||||
}
|
||||
}
|
||||
|
||||
updateElements() {
|
||||
this._elements = {};
|
||||
const files = get(engine.files);
|
||||
this._container.childNodes.forEach((element) => {
|
||||
if (element instanceof HTMLElement) {
|
||||
let attr = element.getAttribute('data-id');
|
||||
if (attr) {
|
||||
if (this._node instanceof Map && !this._node.has(attr)) {
|
||||
if (this._sortableLevel === ListLevel.FILE && !files.has(attr)) {
|
||||
element.remove();
|
||||
} else {
|
||||
this._elements[attr] = element;
|
||||
@@ -262,23 +321,16 @@ export class SortableFileList {
|
||||
}
|
||||
|
||||
getChangedIds() {
|
||||
let changed: (string | number)[] = [];
|
||||
const selection_ = get(selection);
|
||||
let changed: string[] = [];
|
||||
const selection_ = get(engine.selection);
|
||||
Object.entries(this._elements).forEach(([id, element]) => {
|
||||
let realId = this.getRealId(id);
|
||||
let realItem = this._item.extend(realId);
|
||||
let inSelection = selection_.has(realItem);
|
||||
let isSelected = element.classList.contains('sortable-selected');
|
||||
if (inSelection !== isSelected) {
|
||||
changed.push(realId);
|
||||
const node = this.childNode(id);
|
||||
let inSelection = node !== undefined && isSelected(selection_, node);
|
||||
let isSelectedInList = element.classList.contains('sortable-selected');
|
||||
if (inSelection !== isSelectedInList) {
|
||||
changed.push(id);
|
||||
}
|
||||
});
|
||||
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 * as Popover from '$lib/components/ui/popover';
|
||||
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 { i18n } from '$lib/i18n.svelte';
|
||||
import type { LineStyleExtension } from 'gpx';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
|
||||
import { engine, type FileState } from '$lib/engine';
|
||||
import { normalizeColor } from '$lib/file-colors';
|
||||
import { selectedFileIds, type FileTreeNode } from '$lib/selection-helpers';
|
||||
import { untrack } from 'svelte';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
|
||||
let {
|
||||
item,
|
||||
fileState,
|
||||
node,
|
||||
open = $bindable(),
|
||||
}: {
|
||||
item: ListItem;
|
||||
fileState: FileState;
|
||||
node: FileTreeNode;
|
||||
open: boolean;
|
||||
} = $props();
|
||||
|
||||
const { defaultOpacity, defaultWidth } = settings;
|
||||
const { files, selection } = engine;
|
||||
|
||||
let color: string = $state('');
|
||||
let opacity: number = $state(0);
|
||||
@@ -37,45 +33,24 @@
|
||||
let opacityChanged = $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() {
|
||||
opacity = $defaultOpacity;
|
||||
width = $defaultWidth;
|
||||
color = fileState.color;
|
||||
|
||||
$selection.forEach((item) => {
|
||||
if (item instanceof ListFileItem) {
|
||||
let file = fileStateCollection.getFile(item.getFileId());
|
||||
let layer = gpxLayers.getLayer(item.getFileId());
|
||||
if (file && layer) {
|
||||
let style = file.getStyle();
|
||||
color = layer.layerColor;
|
||||
if (style.opacity.length > 0) {
|
||||
opacity = style.opacity[0];
|
||||
}
|
||||
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'];
|
||||
}
|
||||
}
|
||||
}
|
||||
const { tracks } = fileState.structure;
|
||||
if (node.type === 'file') {
|
||||
opacity = tracks.find((track) => track.opacity !== undefined)?.opacity ?? opacity;
|
||||
width = tracks.find((track) => track.width !== undefined)?.width ?? width;
|
||||
} else if (node.type === 'track') {
|
||||
const track = tracks.find((track) => track.id === node.trackId);
|
||||
if (track) {
|
||||
color = track.color !== undefined ? normalizeColor(track.color) : color;
|
||||
opacity = track.opacity ?? opacity;
|
||||
width = track.width ?? width;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
colorChanged = false;
|
||||
opacityChanged = false;
|
||||
@@ -95,28 +70,25 @@
|
||||
});
|
||||
|
||||
function applyStyle() {
|
||||
let style: LineStyleExtension = {};
|
||||
if (colorChanged) {
|
||||
style['gpx_style:color'] = color;
|
||||
}
|
||||
if (opacityChanged) {
|
||||
style['gpx_style:opacity'] = opacity;
|
||||
}
|
||||
if (widthChanged) {
|
||||
style['gpx_style:width'] = 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'];
|
||||
}
|
||||
}
|
||||
|
||||
// closes the dialog now: `open` follows `editStyle`, and the style changes the file, which
|
||||
// would open the dialog again before the effect below has reset it
|
||||
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) {
|
||||
$defaultOpacity = opacity;
|
||||
}
|
||||
if (widthChanged) {
|
||||
$defaultWidth = width;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { trackpointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
|
||||
import { TrackPoint } from 'gpx';
|
||||
|
||||
map.onLoad((map_) => {
|
||||
map_.on('contextmenu', (e) => {
|
||||
@@ -16,12 +15,8 @@
|
||||
return;
|
||||
}
|
||||
trackpointPopup?.setItem({
|
||||
item: new TrackPoint({
|
||||
attributes: {
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng,
|
||||
},
|
||||
}),
|
||||
kind: 'trackpoint',
|
||||
item: { lng: e.lngLat.lng, lat: e.lngLat.lat },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { TrackPoint, Waypoint } from 'gpx';
|
||||
import WaypointPopup from '$lib/components/map/gpx-layer/WaypointPopup.svelte';
|
||||
import TrackpointPopup from '$lib/components/map/gpx-layer/TrackpointPopup.svelte';
|
||||
import OverpassPopup from '$lib/components/map/layer-control/OverpassPopup.svelte';
|
||||
@@ -23,9 +22,9 @@
|
||||
|
||||
<div bind:this={container}>
|
||||
{#if $item}
|
||||
{#if $item.item instanceof Waypoint}
|
||||
{#if $item.kind === 'waypoint'}
|
||||
<WaypointPopup waypoint={$item} />
|
||||
{:else if $item.item instanceof TrackPoint}
|
||||
{:else if $item.kind === 'trackpoint'}
|
||||
<TrackpointPopup trackpoint={$item} />
|
||||
{:else}
|
||||
<OverpassPopup poi={$item} />
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { ClipboardCopy } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import type { Coordinates } from 'gpx';
|
||||
|
||||
let {
|
||||
coordinates,
|
||||
onCopy = () => {},
|
||||
class: className = '',
|
||||
}: {
|
||||
coordinates: Coordinates;
|
||||
coordinates: { lng: number; lat: number };
|
||||
onCopy?: () => void;
|
||||
class?: string;
|
||||
} = $props();
|
||||
@@ -20,7 +19,7 @@
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${coordinates.lat.toFixed(6)}, ${coordinates.lon.toFixed(6)}`
|
||||
`${coordinates.lat.toFixed(6)}, ${coordinates.lng.toFixed(6)}`
|
||||
);
|
||||
onCopy();
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import type { TrackPoint } from 'gpx';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import CopyCoordinates from '$lib/components/map/gpx-layer/CopyCoordinates.svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
@@ -7,18 +6,17 @@
|
||||
import { Compass, Earth, Mountain, Timer } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import type { PopupItem } from '$lib/components/map/map-popup';
|
||||
import type { TrackpointDetails } from '$lib/engine';
|
||||
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>
|
||||
|
||||
<Card.Root class="border-none shadow-md text-base p-2">
|
||||
<Card.Content class="flex flex-col p-0 text-xs gap-1">
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<Compass size="14" />
|
||||
{trackpoint.item.getLatitude().toFixed(6)}° {trackpoint.item
|
||||
.getLongitude()
|
||||
.toFixed(6)}°
|
||||
{trackpoint.item.lat.toFixed(6)}° {trackpoint.item.lng.toFixed(6)}°
|
||||
</div>
|
||||
{#if trackpoint.item.ele !== undefined}
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
@@ -26,14 +24,14 @@
|
||||
<WithUnits value={trackpoint.item.ele} type="elevation" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if trackpoint.item.time}
|
||||
{#if trackpoint.item.time !== undefined}
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<Timer size="14" />
|
||||
{i18n.df.format(trackpoint.item.time)}
|
||||
{i18n.df.format(new Date(trackpoint.item.time))}
|
||||
</div>
|
||||
{/if}
|
||||
<CopyCoordinates
|
||||
coordinates={trackpoint.item.attributes}
|
||||
coordinates={trackpoint.item}
|
||||
onCopy={() => trackpoint.hide?.()}
|
||||
class="mt-0.5"
|
||||
/>
|
||||
@@ -41,7 +39,7 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
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"
|
||||
>
|
||||
<Earth size="14" />
|
||||
|
||||
@@ -9,21 +9,23 @@
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import type { Waypoint } from 'gpx';
|
||||
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 { selection } from '$lib/logic/selection';
|
||||
import { ListFileItem } from '$lib/components/file-list/file-list';
|
||||
import { engine, type WaypointDetails } from '$lib/engine';
|
||||
import { hasSelectionWithin } from '$lib/selection-helpers';
|
||||
|
||||
let {
|
||||
waypoint,
|
||||
}: {
|
||||
waypoint: PopupItem<Waypoint>;
|
||||
waypoint: PopupItem<WaypointDetails>;
|
||||
} = $props();
|
||||
|
||||
const { selection } = engine;
|
||||
|
||||
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);
|
||||
|
||||
@@ -44,9 +46,9 @@
|
||||
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw] gap-0">
|
||||
<Card.Header class="p-0 gap-0">
|
||||
<Card.Title class="text-md">
|
||||
{#if waypoint.item.link && waypoint.item.link.attributes && waypoint.item.link.attributes.href}
|
||||
<a href={waypoint.item.link.attributes.href} target="_blank">
|
||||
{waypoint.item.name ?? waypoint.item.link.attributes.href}
|
||||
{#if waypoint.item.link?.href}
|
||||
<a href={waypoint.item.link.href} target="_blank">
|
||||
{waypoint.item.name ?? waypoint.item.link.href}
|
||||
<ExternalLink size="12" class="inline-block mb-1.5" />
|
||||
</a>
|
||||
{:else}
|
||||
@@ -68,13 +70,9 @@
|
||||
</span>
|
||||
<Dot size="16" />
|
||||
{/if}
|
||||
{waypoint.item.getLatitude().toFixed(6)}° {waypoint.item
|
||||
.getLongitude()
|
||||
.toFixed(6)}°
|
||||
{#if waypoint.item.ele !== undefined}
|
||||
<Dot size="16" />
|
||||
<WithUnits value={waypoint.item.ele} type="elevation" />
|
||||
{/if}
|
||||
{waypoint.item.lat.toFixed(6)}° {waypoint.item.lng.toFixed(6)}°
|
||||
<Dot size="16" />
|
||||
<WithUnits value={waypoint.item.ele} type="elevation" />
|
||||
</div>
|
||||
<ScrollArea class="flex flex-col max-h-[30dvh]">
|
||||
{#if waypoint.item.desc}
|
||||
@@ -85,13 +83,13 @@
|
||||
{/if}
|
||||
</ScrollArea>
|
||||
<div class="mt-2 flex flex-col gap-1">
|
||||
<CopyCoordinates coordinates={waypoint.item.attributes} />
|
||||
<CopyCoordinates coordinates={waypoint.item} />
|
||||
{#if $currentTool === Tool.WAYPOINT && selected}
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
if (waypoint.fileId) {
|
||||
fileActions.deleteWaypoint(waypoint.fileId, waypoint.item._data.index);
|
||||
engine.deleteWaypoint(waypoint.fileId, waypoint.item.id);
|
||||
waypoint.hide?.();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { gpxStatistics } from '$lib/logic/statistics';
|
||||
import { engine } from '$lib/engine';
|
||||
import { getConvertedDistanceToKilometers } from '$lib/units';
|
||||
import { get } from 'svelte/store';
|
||||
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 { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||
|
||||
@@ -16,7 +16,7 @@ export class DistanceMarkers {
|
||||
unsubscribes: (() => void)[] = [];
|
||||
|
||||
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(distanceUnits.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(allHidden.subscribe(this.updateBinded));
|
||||
@@ -101,19 +101,20 @@ export class DistanceMarkers {
|
||||
}
|
||||
|
||||
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 currentTargetDistance = 1;
|
||||
statistics.forEachTrackPoint((trkpt, dist) => {
|
||||
if (dist >= getConvertedDistanceToKilometers(currentTargetDistance)) {
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (totalDistance[i] >= getConvertedDistanceToKilometers(currentTargetDistance)) {
|
||||
let distance = currentTargetDistance.toFixed(0);
|
||||
let level = levels.find((level) => currentTargetDistance % level === 0) || 1;
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [trkpt.getLongitude(), trkpt.getLatitude()],
|
||||
coordinates: [lng[i], lat[i]],
|
||||
},
|
||||
properties: {
|
||||
distance,
|
||||
@@ -122,7 +123,7 @@ export class DistanceMarkers {
|
||||
} as GeoJSON.Feature);
|
||||
currentTargetDistance += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { get, type Readable } from 'svelte/store';
|
||||
import type { GPXFile } from 'gpx';
|
||||
import maplibregl, {
|
||||
type GeoJSONSource,
|
||||
type FilterSpecification,
|
||||
@@ -7,79 +8,21 @@ import maplibregl, {
|
||||
} from 'maplibre-gl';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { waypointPopup, trackpointPopup } from './gpx-layer-popup';
|
||||
import {
|
||||
ListTrackSegmentItem,
|
||||
ListWaypointItem,
|
||||
ListWaypointsItem,
|
||||
ListTrackItem,
|
||||
ListFileItem,
|
||||
ListRootItem,
|
||||
} from '$lib/components/file-list/file-list';
|
||||
import { getClosestLinePoint, getElevation, loadSVGIcon } from '$lib/utils';
|
||||
import { closestPointIndex } from '$lib/closest-point';
|
||||
import { getElevation, loadSVGIcon } from '$lib/utils';
|
||||
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
|
||||
import { MapPin, Square } from 'lucide-static';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
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 { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||
import { fileActionManager } from '$lib/logic/file-action-manager';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
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) {
|
||||
let symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
|
||||
@@ -115,8 +58,9 @@ const { directionMarkers, treeFileView, defaultOpacity, defaultWidth } = setting
|
||||
|
||||
export class GPXLayer {
|
||||
fileId: string;
|
||||
file: Readable<GPXFileWithStatistics | undefined>;
|
||||
layerColor: string;
|
||||
file: Readable<FileState>;
|
||||
layerColor: string = '';
|
||||
selectionState: Selection = { type: 'empty' };
|
||||
selected: boolean = false;
|
||||
currentWaypointData: GeoJSON.FeatureCollection | null = null;
|
||||
draggedWaypointIndex: number | null = null;
|
||||
@@ -144,10 +88,9 @@ export class GPXLayer {
|
||||
waypointLayerOnMouseUpBinded: (e: MapLayerMouseEvent | MapLayerTouchEvent) => void =
|
||||
this.waypointLayerOnMouseUp.bind(this);
|
||||
|
||||
constructor(fileId: string, file: Readable<GPXFileWithStatistics | undefined>) {
|
||||
constructor(fileId: string, file: Readable<FileState>) {
|
||||
this.fileId = fileId;
|
||||
this.file = file;
|
||||
this.layerColor = getColor(fileId);
|
||||
this.unsubscribe.push(
|
||||
map.subscribe(($map) => {
|
||||
if ($map) {
|
||||
@@ -158,8 +101,9 @@ export class GPXLayer {
|
||||
);
|
||||
this.unsubscribe.push(file.subscribe(this.updateBinded));
|
||||
this.unsubscribe.push(
|
||||
selection.subscribe(($selection) => {
|
||||
let newSelected = $selection.hasAnyChildren(new ListFileItem(this.fileId));
|
||||
engine.selection.subscribe(($selection) => {
|
||||
this.selectionState = $selection;
|
||||
let newSelected = hasSelectionWithin($selection, this.fileNode());
|
||||
if (this.selected || newSelected) {
|
||||
this.selected = newSelected;
|
||||
this.update();
|
||||
@@ -172,21 +116,29 @@ export class GPXLayer {
|
||||
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() {
|
||||
const _map = get(map);
|
||||
const layerEventManager = map.layerEventManager;
|
||||
let file = get(this.file)?.file;
|
||||
if (!_map || !layerEventManager || !file) {
|
||||
const state = get(this.file);
|
||||
if (!_map || !layerEventManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
file._data.style &&
|
||||
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}`;
|
||||
if (this.layerColor !== state.color) {
|
||||
this.layerColor = state.color;
|
||||
}
|
||||
|
||||
this.loadIcons();
|
||||
@@ -228,16 +180,16 @@ export class GPXLayer {
|
||||
layerEventManager.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
|
||||
}
|
||||
|
||||
let visibleTrackSegmentIds: string[] = [];
|
||||
file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||
if (!segment._data.hidden) {
|
||||
visibleTrackSegmentIds.push(`${trackIndex}-${segmentIndex}`);
|
||||
}
|
||||
});
|
||||
const visibleSegmentIds = state.segments.features
|
||||
.filter(
|
||||
({ properties }) =>
|
||||
!isSegmentHidden(state, properties.trackId, properties.segmentId)
|
||||
)
|
||||
.map(({ properties }) => properties.segmentId);
|
||||
const segmentFilter: FilterSpecification = [
|
||||
'in',
|
||||
['get', 'trackSegmentId'],
|
||||
['literal', visibleTrackSegmentIds],
|
||||
['get', 'segmentId'],
|
||||
['literal', visibleSegmentIds],
|
||||
];
|
||||
|
||||
_map.setFilter(this.fileId, segmentFilter, { validate: false });
|
||||
@@ -334,12 +286,9 @@ export class GPXLayer {
|
||||
);
|
||||
}
|
||||
|
||||
let visibleWaypoints: number[] = [];
|
||||
file.wpt.forEach((waypoint, waypointIndex) => {
|
||||
if (!waypoint._data.hidden) {
|
||||
visibleWaypoints.push(waypointIndex);
|
||||
}
|
||||
});
|
||||
const visibleWaypoints = state.waypoints.features
|
||||
.filter(({ properties }) => !isWaypointHidden(state, properties.waypointId))
|
||||
.map(({ properties }) => properties.index);
|
||||
|
||||
_map.setFilter(
|
||||
this.fileId + '-waypoints',
|
||||
@@ -413,8 +362,6 @@ export class GPXLayer {
|
||||
}
|
||||
|
||||
this.unsubscribe.forEach((unsubscribe) => unsubscribe());
|
||||
|
||||
removeColor(this.fileId, this.layerColor);
|
||||
}
|
||||
|
||||
moveToFront() {
|
||||
@@ -434,14 +381,9 @@ export class GPXLayer {
|
||||
}
|
||||
|
||||
layerOnMouseEnter(e: any) {
|
||||
let trackIndex = e.features[0].properties.trackIndex;
|
||||
let segmentIndex = e.features[0].properties.segmentIndex;
|
||||
|
||||
if (
|
||||
get(currentTool) === Tool.SCISSORS &&
|
||||
get(selection).hasAnyParent(
|
||||
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
)
|
||||
isCovered(this.selectionState, this.segmentNode(e.features[0].properties))
|
||||
) {
|
||||
mapCursor.notify(MapCursorState.SCISSORS, true);
|
||||
} else {
|
||||
@@ -456,69 +398,65 @@ export class GPXLayer {
|
||||
|
||||
layerOnMouseMove(e: any) {
|
||||
if (e.originalEvent.shiftKey) {
|
||||
let trackIndex = e.features[0].properties.trackIndex;
|
||||
let segmentIndex = e.features[0].properties.segmentIndex;
|
||||
|
||||
const file = get(this.file)?.file;
|
||||
if (file) {
|
||||
const closest = getClosestLinePoint(
|
||||
file.trk[trackIndex].trkseg[segmentIndex].trkpt,
|
||||
{ lat: e.lngLat.lat, lon: e.lngLat.lng }
|
||||
);
|
||||
trackpointPopup?.setItem({ item: closest, fileId: this.fileId });
|
||||
const { segmentId } = e.features[0].properties;
|
||||
const lngLat = { lng: e.lngLat.lng, lat: e.lngLat.lat };
|
||||
const index = closestPointIndex(engine.segmentCoordinates(segmentId), lngLat);
|
||||
const trackpoint =
|
||||
index === undefined ? undefined : engine.trackpoint(this.fileId, segmentId, index);
|
||||
if (trackpoint) {
|
||||
trackpointPopup?.setItem({
|
||||
item: trackpoint,
|
||||
kind: 'trackpoint',
|
||||
fileId: this.fileId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
layerOnClick(e: MapLayerMouseEvent) {
|
||||
const properties = e.features![0].properties!;
|
||||
const { trackId, segmentId, trackIndex, segmentIndex } = properties;
|
||||
const selectionType = this.selectionState.type;
|
||||
|
||||
if (
|
||||
get(currentTool) === Tool.ROUTING &&
|
||||
get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])
|
||||
selectionType !== 'empty' &&
|
||||
selectionType !== 'waypoints' &&
|
||||
selectionType !== 'waypoint'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let trackIndex = e.features![0].properties!.trackIndex;
|
||||
let segmentIndex = e.features![0].properties!.segmentIndex;
|
||||
|
||||
if (
|
||||
get(currentTool) === Tool.SCISSORS &&
|
||||
get(selection).hasAnyParent(
|
||||
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
)
|
||||
isCovered(this.selectionState, this.segmentNode(properties))
|
||||
) {
|
||||
if (get(map)?.queryRenderedFeatures(e.point, { layers: ['split-controls'] }).length) {
|
||||
// Clicked on split control, ignoring
|
||||
return;
|
||||
}
|
||||
|
||||
fileActions.split(get(splitAs), this.fileId, trackIndex, segmentIndex, {
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng,
|
||||
});
|
||||
// TODO the engine can split (Command::Split) but needs the index of the trackpoint
|
||||
// fileActions.split(get(splitAs), this.fileId, trackIndex, segmentIndex, {
|
||||
// lat: e.lngLat.lat,
|
||||
// lon: e.lngLat.lng,
|
||||
// });
|
||||
return;
|
||||
}
|
||||
|
||||
let file = get(this.file)?.file;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
let item = undefined;
|
||||
if (get(treeFileView) && file.getSegments().length > 1) {
|
||||
const mode = e.originalEvent.ctrlKey || e.originalEvent.metaKey ? 'add' : 'replace';
|
||||
const { tracks } = get(this.file).structure;
|
||||
const segmentCount = tracks.reduce((count, track) => count + track.segments.length, 0);
|
||||
if (get(treeFileView) && segmentCount > 1) {
|
||||
// Select inner item
|
||||
item =
|
||||
file.children[trackIndex].children.length > 1
|
||||
? new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
: new ListTrackItem(this.fileId, trackIndex);
|
||||
const track = tracks.find((track) => track.id === trackId);
|
||||
if (track && track.segments.length > 1) {
|
||||
engine.selectSegments(this.fileId, trackId, [segmentId], mode);
|
||||
} else {
|
||||
engine.selectTracks(this.fileId, [trackId], mode);
|
||||
}
|
||||
} else {
|
||||
item = new ListFileItem(this.fileId);
|
||||
}
|
||||
|
||||
if (e.originalEvent.ctrlKey || e.originalEvent.metaKey) {
|
||||
selection.addSelectItem(item);
|
||||
} else {
|
||||
selection.selectItem(item);
|
||||
engine.select([this.fileId], mode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,18 +470,19 @@ export class GPXLayer {
|
||||
if (this.draggedWaypointIndex !== null) {
|
||||
return;
|
||||
}
|
||||
let file = get(this.file)?.file;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
let waypointIndex = e.features![0].properties!.waypointIndex;
|
||||
let waypoint = file.wpt[waypointIndex];
|
||||
waypointPopup?.setItem({ item: waypoint, fileId: this.fileId });
|
||||
this.showWaypointPopup(e.features![0].properties!.waypointId);
|
||||
|
||||
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() {
|
||||
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, false);
|
||||
}
|
||||
@@ -551,41 +490,36 @@ export class GPXLayer {
|
||||
waypointLayerOnClick(e: MapLayerMouseEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
let waypointIndex = e.features![0].properties!.waypointIndex;
|
||||
let file = get(this.file)?.file;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
let waypoint = file.wpt[waypointIndex];
|
||||
const { waypointId } = e.features![0].properties!;
|
||||
if (get(currentTool) === Tool.WAYPOINT) {
|
||||
if (this.selected) {
|
||||
if (e.originalEvent.shiftKey) {
|
||||
fileActions.deleteWaypoint(this.fileId, waypointIndex);
|
||||
engine.deleteWaypoint(this.fileId, waypointId);
|
||||
} else {
|
||||
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
|
||||
selectedWaypoint.set([waypoint, this.fileId]);
|
||||
engine.selectWaypoints(this.fileId, [waypointId]);
|
||||
selectedWaypoint.set({ fileId: this.fileId, id: waypointId });
|
||||
}
|
||||
} else {
|
||||
if (get(treeFileView)) {
|
||||
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
|
||||
engine.selectWaypoints(this.fileId, [waypointId]);
|
||||
} else {
|
||||
selection.selectItem(new ListFileItem(this.fileId));
|
||||
engine.select([this.fileId]);
|
||||
}
|
||||
selectedWaypoint.set([waypoint, this.fileId]);
|
||||
selectedWaypoint.set({ fileId: this.fileId, id: waypointId });
|
||||
}
|
||||
} else {
|
||||
if (get(treeFileView)) {
|
||||
if ((e.originalEvent.ctrlKey || e.originalEvent.metaKey) && this.selected) {
|
||||
selection.addSelectItem(new ListWaypointItem(this.fileId, waypointIndex));
|
||||
} else {
|
||||
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
|
||||
}
|
||||
const add = e.originalEvent.ctrlKey || e.originalEvent.metaKey;
|
||||
engine.selectWaypoints(
|
||||
this.fileId,
|
||||
[waypointId],
|
||||
add && this.selected ? 'add' : 'replace'
|
||||
);
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
|
||||
// the dragged waypoint, as it is in the features of the source
|
||||
const waypointId =
|
||||
this.currentWaypointData?.features[this.draggedWaypointIndex].properties?.waypointId;
|
||||
getElevation([
|
||||
{
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng,
|
||||
},
|
||||
]).then((ele) => {
|
||||
]).then(async (ele) => {
|
||||
if (this.draggedWaypointIndex === null) {
|
||||
return;
|
||||
}
|
||||
fileActionManager.applyToFile(this.fileId, (file) => {
|
||||
let wpt = file.wpt[this.draggedWaypointIndex!];
|
||||
wpt.setCoordinates({
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng,
|
||||
});
|
||||
wpt.ele = ele[0];
|
||||
});
|
||||
if (waypointId) {
|
||||
await engine.moveWaypoint(
|
||||
this.fileId,
|
||||
waypointId,
|
||||
e.lngLat.lng,
|
||||
e.lngLat.lat,
|
||||
ele[0]
|
||||
);
|
||||
}
|
||||
this.draggedWaypointIndex = null;
|
||||
});
|
||||
}
|
||||
|
||||
getGeoJSON(): GeoJSON.FeatureCollection {
|
||||
let file = get(this.file)?.file;
|
||||
if (!file) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
};
|
||||
}
|
||||
const state = get(this.file);
|
||||
const waypointsSelected = hasSelectionWithin(this.selectionState, {
|
||||
type: 'waypoints',
|
||||
fileId: this.fileId,
|
||||
});
|
||||
|
||||
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 (
|
||||
get(selection).hasAnyParent(
|
||||
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
) ||
|
||||
get(selection).hasAnyChildren(new ListWaypointsItem(this.fileId), true)
|
||||
) {
|
||||
feature.properties.width = feature.properties.width + 2;
|
||||
feature.properties.opacity = Math.min(1, feature.properties.opacity + 0.1);
|
||||
}
|
||||
feature.properties.trackIndex = trackIndex;
|
||||
feature.properties.segmentIndex = segmentIndex;
|
||||
feature.properties.trackSegmentId = `${trackIndex}-${segmentIndex}`;
|
||||
|
||||
segmentIndex++;
|
||||
if (segmentIndex >= file.trk[trackIndex].trkseg.length) {
|
||||
segmentIndex = 0;
|
||||
trackIndex++;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: state.segments.features.map((feature) => {
|
||||
const properties = {
|
||||
...feature.properties,
|
||||
opacity: feature.properties.opacity ?? get(defaultOpacity),
|
||||
width: feature.properties.width ?? get(defaultWidth),
|
||||
};
|
||||
if (
|
||||
isCovered(this.selectionState, this.segmentNode(properties)) ||
|
||||
waypointsSelected
|
||||
) {
|
||||
properties.width = properties.width + 2;
|
||||
properties.opacity = Math.min(1, properties.opacity + 0.1);
|
||||
}
|
||||
return { ...feature, properties };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
getWaypointsGeoJSON(): GeoJSON.FeatureCollection {
|
||||
let file = get(this.file)?.file;
|
||||
|
||||
let data: GeoJSON.FeatureCollection = {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
};
|
||||
|
||||
if (!file) {
|
||||
return data;
|
||||
}
|
||||
|
||||
file.wpt.forEach((waypoint, index) => {
|
||||
data.features.push({
|
||||
features: get(this.file).waypoints.features.map((feature) => ({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [waypoint.getLongitude(), waypoint.getLatitude()],
|
||||
},
|
||||
// copied: the coordinates are changed while a waypoint is dragged
|
||||
geometry: { type: 'Point', coordinates: [...feature.geometry.coordinates] },
|
||||
properties: {
|
||||
fileId: this.fileId,
|
||||
waypointIndex: index,
|
||||
icon: `waypoint-${getSymbolKey(waypoint.sym) ?? 'default'}-${this.layerColor}`,
|
||||
waypointId: feature.properties.waypointId,
|
||||
waypointIndex: feature.properties.index,
|
||||
icon: `waypoint-${getSymbolKey(feature.properties.sym) ?? 'default'}-${this.layerColor}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return data;
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
loadIcons() {
|
||||
const _map = get(map);
|
||||
let file = get(this.file)?.file;
|
||||
if (!_map || !file) {
|
||||
if (!_map) {
|
||||
return;
|
||||
}
|
||||
|
||||
let symbols = new Set<string | undefined>();
|
||||
file.wpt.forEach((waypoint) => {
|
||||
symbols.add(getSymbolKey(waypoint.sym));
|
||||
get(this.file).waypoints.features.forEach((feature) => {
|
||||
symbols.add(getSymbolKey(feature.properties.sym));
|
||||
});
|
||||
|
||||
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 { GPXLayer } from './gpx-layer';
|
||||
|
||||
export class GPXLayerCollection {
|
||||
private _layers: Map<string, GPXLayer>;
|
||||
private _fileStateCollectionObserver: GPXFileStateCollectionObserver | null = null;
|
||||
private _fileStateCollectionObserver: FileStateCollectionObserver | null = null;
|
||||
|
||||
constructor() {
|
||||
this._layers = new Map<string, GPXLayer>();
|
||||
@@ -14,10 +14,10 @@ export class GPXLayerCollection {
|
||||
if (this._fileStateCollectionObserver) {
|
||||
return;
|
||||
}
|
||||
this._fileStateCollectionObserver = new GPXFileStateCollectionObserver(
|
||||
this._fileStateCollectionObserver = new FileStateCollectionObserver(
|
||||
(newFiles) => {
|
||||
newFiles.forEach((fileState, fileId) => {
|
||||
const layer = new GPXLayer(fileId, fileState);
|
||||
newFiles.forEach((fileStore, fileId) => {
|
||||
const layer = new GPXLayer(fileId, fileStore);
|
||||
this._layers.set(fileId, layer);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
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 { get } from 'svelte/store';
|
||||
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 { 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">
|
||||
<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"/>
|
||||
</svg>`;
|
||||
|
||||
const { statistics } = engine;
|
||||
|
||||
export class StartEndMarkers {
|
||||
updateBinded: () => void = this.update.bind(this);
|
||||
unsubscribes: (() => void)[] = [];
|
||||
|
||||
constructor() {
|
||||
map.onLoad((map_) => map_.on('style.load', this.updateBinded));
|
||||
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(slicedGPXStatistics.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(statistics.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(slicedStatistics.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(hoveredPoint.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(currentTool.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(allHidden.subscribe(this.updateBinded));
|
||||
@@ -46,8 +50,8 @@ export class StartEndMarkers {
|
||||
this.loadIcons();
|
||||
|
||||
const tool = get(currentTool);
|
||||
const statistics = get(gpxStatistics);
|
||||
const slicedStatistics = get(slicedGPXStatistics);
|
||||
const stats = get(statistics);
|
||||
const sliced = get(slicedStatistics);
|
||||
const hovered = get(hoveredPoint);
|
||||
const hidden = get(allHidden);
|
||||
if (!hidden) {
|
||||
@@ -56,18 +60,17 @@ export class StartEndMarkers {
|
||||
features: [],
|
||||
};
|
||||
|
||||
if (statistics.global.length > 0 && tool !== Tool.ROUTING) {
|
||||
const start = statistics
|
||||
.getTrackPoint(slicedStatistics?.[1] ?? 0)!
|
||||
.trkpt.getCoordinates();
|
||||
const end = statistics
|
||||
.getTrackPoint(slicedStatistics?.[2] ?? statistics.global.length - 1)!
|
||||
.trkpt.getCoordinates();
|
||||
if (stats.length > 0 && tool !== Tool.ROUTING) {
|
||||
const start = [stats.lng[sliced?.start ?? 0], stats.lat[sliced?.start ?? 0]];
|
||||
const end = [
|
||||
stats.lng[sliced?.end ?? stats.length - 1],
|
||||
stats.lat[sliced?.end ?? stats.length - 1],
|
||||
];
|
||||
data.features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [start.lon, start.lat],
|
||||
coordinates: start,
|
||||
},
|
||||
properties: {
|
||||
icon: 'start-marker',
|
||||
@@ -77,7 +80,7 @@ export class StartEndMarkers {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [end.lon, end.lat],
|
||||
coordinates: end,
|
||||
},
|
||||
properties: {
|
||||
icon: 'end-marker',
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
import { PencilLine, MapPin } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
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 { fileActions } from '$lib/logic/file-actions';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { engine } from '$lib/engine';
|
||||
import { selectionSize } from '$lib/selection-helpers';
|
||||
import { getElevation } from '$lib/utils';
|
||||
|
||||
let {
|
||||
poi,
|
||||
@@ -27,28 +27,22 @@
|
||||
return '';
|
||||
});
|
||||
|
||||
function addToFile() {
|
||||
const { selection } = engine;
|
||||
|
||||
async function addToFile() {
|
||||
const desc = Object.entries(tags)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\n');
|
||||
let wpt: WaypointType = {
|
||||
attributes: {
|
||||
lat: poi.item.lat,
|
||||
lon: poi.item.lon,
|
||||
},
|
||||
name: name,
|
||||
desc: desc,
|
||||
cmt: desc,
|
||||
sym: poi.item.sym,
|
||||
};
|
||||
if (tags.website) {
|
||||
wpt.link = {
|
||||
attributes: {
|
||||
href: tags.website,
|
||||
},
|
||||
};
|
||||
}
|
||||
fileActions.addOrUpdateWaypoint(wpt);
|
||||
const [ele] = await getElevation([{ lat: poi.item.lat, lon: poi.item.lng }]);
|
||||
await engine.newWaypoint({
|
||||
lng: poi.item.lng,
|
||||
lat: poi.item.lat,
|
||||
ele,
|
||||
name,
|
||||
desc,
|
||||
icon: poi.item.sym ?? '',
|
||||
link: tags.website ?? '',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -58,7 +52,7 @@
|
||||
<div class="flex flex-col">
|
||||
<p>{name}</p>
|
||||
<div class="text-muted-foreground text-xs font-normal">
|
||||
{poi.item.lat.toFixed(6)}° {poi.item.lon.toFixed(6)}°
|
||||
{poi.item.lat.toFixed(6)}° {poi.item.lng.toFixed(6)}°
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -111,7 +105,7 @@
|
||||
size="sm"
|
||||
class="mt-1 justify-start"
|
||||
variant="outline"
|
||||
disabled={$selection.size === 0}
|
||||
disabled={selectionSize($selection) === 0}
|
||||
onclick={addToFile}
|
||||
>
|
||||
<MapPin size="14" />
|
||||
|
||||
@@ -143,6 +143,7 @@ export class OverpassLayer {
|
||||
|
||||
onHover(e: any) {
|
||||
this.popup.setItem({
|
||||
kind: 'overpass',
|
||||
item: {
|
||||
...e.features[0].properties,
|
||||
sym: overpassQueryData[e.features[0].properties.query].symbol ?? '',
|
||||
@@ -235,7 +236,7 @@ export class OverpassLayer {
|
||||
properties: {
|
||||
id: element.id,
|
||||
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,
|
||||
icon: `overpass-${query}`,
|
||||
tags: element.tags,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { TrackPoint, Waypoint } from 'gpx';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { mount, tick, unmount } from 'svelte';
|
||||
import { get, writable, type Writable } from 'svelte/store';
|
||||
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;
|
||||
/** What the item is: a `WaypointDetails`, a `TrackpointDetails` or an Overpass element. */
|
||||
kind: 'waypoint' | 'trackpoint' | 'overpass';
|
||||
fileId?: string;
|
||||
hide?: () => void;
|
||||
};
|
||||
@@ -77,8 +79,6 @@ export class MapPopup {
|
||||
if (item === null) {
|
||||
return new maplibregl.LngLat(0, 0);
|
||||
}
|
||||
return item.item instanceof Waypoint || item.item instanceof TrackPoint
|
||||
? item.item.getCoordinates()
|
||||
: new maplibregl.LngLat(item.item.lon, item.item.lat);
|
||||
return new maplibregl.LngLat(item.item.lng, item.item.lat);
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,7 @@
|
||||
import { Trash2 } from '@lucide/svelte';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import type { GeoJSONSource } from 'maplibre-gl';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { engine } from '$lib/engine';
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
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>
|
||||
|
||||
<div class="flex flex-col gap-3 w-full max-w-80 items-center {props.class ?? ''}">
|
||||
@@ -163,17 +163,13 @@
|
||||
class="w-full"
|
||||
disabled={!validSelection || rectangleCoordinates.length != 2}
|
||||
onclick={() => {
|
||||
fileActions.cleanSelection(
|
||||
[
|
||||
{
|
||||
lat: Math.min(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
|
||||
lon: Math.min(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng),
|
||||
},
|
||||
{
|
||||
lat: Math.max(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
|
||||
lon: Math.max(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng),
|
||||
},
|
||||
],
|
||||
engine.clean(
|
||||
{
|
||||
west: 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),
|
||||
},
|
||||
cleanType === CleanType.INSIDE,
|
||||
deleteTrackpoints,
|
||||
deleteWaypoints
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { ListWaypointItem } from '$lib/components/file-list/file-list';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { MapPin, CircleX, Save } from '@lucide/svelte';
|
||||
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 { fileActions } from '$lib/logic/file-actions';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
@@ -31,7 +31,9 @@
|
||||
let latitude = $state(0);
|
||||
let symbolKey = $derived(getSymbolKey(sym));
|
||||
|
||||
let canCreate = $derived($selection.size > 0);
|
||||
const { selection } = engine;
|
||||
|
||||
let canCreate = $derived(selectionSize($selection) > 0);
|
||||
|
||||
let sortedSymbols = $derived(
|
||||
Object.entries(symbols).sort((a, b) => {
|
||||
@@ -58,24 +60,24 @@
|
||||
|
||||
$effect(() => {
|
||||
if ($selectedWaypoint) {
|
||||
const wpt = $selectedWaypoint[0];
|
||||
const wpt = $selectedWaypoint.waypoint;
|
||||
untrack(() => {
|
||||
name = wpt.name ?? '';
|
||||
description = wpt.desc ?? '';
|
||||
if (wpt.cmt !== undefined && wpt.cmt !== wpt.desc) {
|
||||
description += '\n\n' + wpt.cmt;
|
||||
}
|
||||
link = wpt.link?.attributes?.href ?? '';
|
||||
link = wpt.link?.href ?? '';
|
||||
sym = wpt.sym ?? '';
|
||||
longitude = parseFloat(wpt.getLongitude().toFixed(6));
|
||||
latitude = parseFloat(wpt.getLatitude().toFixed(6));
|
||||
longitude = parseFloat(wpt.lng.toFixed(6));
|
||||
latitude = parseFloat(wpt.lat.toFixed(6));
|
||||
});
|
||||
} else {
|
||||
untrack(reset);
|
||||
}
|
||||
});
|
||||
|
||||
function createOrUpdateWaypoint() {
|
||||
async function createOrUpdateWaypoint() {
|
||||
if (typeof latitude === 'string') {
|
||||
latitude = parseFloat(latitude);
|
||||
}
|
||||
@@ -85,27 +87,36 @@
|
||||
latitude = parseFloat(latitude.toFixed(6));
|
||||
longitude = parseFloat(longitude.toFixed(6));
|
||||
|
||||
fileActions.addOrUpdateWaypoint(
|
||||
{
|
||||
attributes: {
|
||||
lat: latitude,
|
||||
lon: longitude,
|
||||
},
|
||||
name: name.length > 0 ? name : undefined,
|
||||
desc: description.length > 0 ? description : undefined,
|
||||
cmt: description.length > 0 ? description : undefined,
|
||||
link: link.length > 0 ? { attributes: { href: link } } : undefined,
|
||||
sym: sym.length > 0 ? sym : undefined,
|
||||
},
|
||||
selectedWaypoint.wpt && selectedWaypoint.fileId
|
||||
? new ListWaypointItem(selectedWaypoint.fileId, selectedWaypoint.wpt._data.index)
|
||||
: undefined
|
||||
);
|
||||
const target = $selectedWaypoint;
|
||||
const [ele] = await getElevation([{ lat: latitude, lon: longitude }]);
|
||||
const waypoint = {
|
||||
lng: longitude,
|
||||
lat: latitude,
|
||||
ele,
|
||||
name,
|
||||
desc: description,
|
||||
icon: sym,
|
||||
link,
|
||||
};
|
||||
if (target) {
|
||||
engine.updateWaypoint(target.fileId, target.id, waypoint);
|
||||
} else {
|
||||
// it goes in the selected files
|
||||
engine.newWaypoint(waypoint);
|
||||
}
|
||||
|
||||
reset();
|
||||
}
|
||||
|
||||
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);
|
||||
longitude = e.lngLat.lng.toFixed(6);
|
||||
}
|
||||
|
||||
@@ -1,74 +1,107 @@
|
||||
import { ListWaypointItem } from '$lib/components/file-list/file-list';
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { engine, type WaypointDetails } from '$lib/engine';
|
||||
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 {
|
||||
private _selection: Writable<[Waypoint, string] | undefined>;
|
||||
private _selection = writable<SelectedWaypoint | undefined>(undefined);
|
||||
private _fileUnsubscribe: (() => void) | undefined;
|
||||
private _explicit: { fileId: string; id: string } | undefined;
|
||||
|
||||
constructor() {
|
||||
this._selection = writable(undefined);
|
||||
settings.treeFileView.subscribe(() => {
|
||||
this.update();
|
||||
});
|
||||
selection.subscribe(() => {
|
||||
this.update();
|
||||
});
|
||||
settings.treeFileView.subscribe(() => this.updateFromSelection());
|
||||
engine.selection.subscribe(() => this.updateFromSelection());
|
||||
}
|
||||
|
||||
subscribe(
|
||||
run: (value: [Waypoint, string] | undefined) => void,
|
||||
invalidate?: (value?: [Waypoint, string] | undefined) => void
|
||||
run: (value: SelectedWaypoint | undefined) => void,
|
||||
invalidate?: (value?: SelectedWaypoint | undefined) => void
|
||||
) {
|
||||
return this._selection.subscribe(run, invalidate);
|
||||
}
|
||||
|
||||
set(value: [Waypoint, string] | undefined) {
|
||||
this._selection.set(value);
|
||||
/** Sets the waypoint, whatever the selection is. */
|
||||
set(target: { fileId: string; id: string } | undefined) {
|
||||
this._explicit = target;
|
||||
this.track(target);
|
||||
}
|
||||
|
||||
update() {
|
||||
if (this._fileUnsubscribe) {
|
||||
this._fileUnsubscribe();
|
||||
this._fileUnsubscribe = undefined;
|
||||
updateFromSelection() {
|
||||
const selection = get(engine.selection);
|
||||
const explicit = this._explicit;
|
||||
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._explicit = undefined;
|
||||
if (
|
||||
get(settings.treeFileView) &&
|
||||
selection.type === 'waypoint' &&
|
||||
selection.waypointIds.length === 1
|
||||
) {
|
||||
this.track({ fileId: selection.fileId, id: selection.waypointIds[0] });
|
||||
} else {
|
||||
this.track(undefined);
|
||||
}
|
||||
this._selection.update(() => {
|
||||
if (get(settings.treeFileView) && get(selection).size === 1) {
|
||||
let item = get(selection).getSelected()[0];
|
||||
if (item instanceof ListWaypointItem) {
|
||||
let fileState = fileStateCollection.getFileState(item.getFileId());
|
||||
if (fileState) {
|
||||
let first = true;
|
||||
this._fileUnsubscribe = fileState.subscribe(() => {
|
||||
if (first) first = false;
|
||||
else this.update();
|
||||
});
|
||||
let waypoint = fileState.file?.wpt[item.getWaypointIndex()];
|
||||
if (waypoint) {
|
||||
return [waypoint, item.getFileId()];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
reset() {
|
||||
this._selection.set(undefined);
|
||||
this._explicit = undefined;
|
||||
this.track(undefined);
|
||||
}
|
||||
|
||||
get wpt(): Waypoint | undefined {
|
||||
const selection = get(this._selection);
|
||||
return selection ? selection[0] : undefined;
|
||||
get waypoint(): WaypointDetails | undefined {
|
||||
return get(this._selection)?.waypoint;
|
||||
}
|
||||
|
||||
get fileId(): string | undefined {
|
||||
const selection = get(this._selection);
|
||||
return selection ? selection[1] : undefined;
|
||||
return get(this._selection)?.fileId;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
+696
-27
@@ -1,8 +1,25 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { writable, type Readable } from 'svelte/store';
|
||||
import type { FileStructure, Selection } from 'gpx-rs';
|
||||
import { get, writable, type Readable, type Writable } from 'svelte/store';
|
||||
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 {
|
||||
Clipboard,
|
||||
WaypointDetails,
|
||||
TrackpointDetails,
|
||||
MoveTarget,
|
||||
GlobalStatistics,
|
||||
FileStructure,
|
||||
FilesUpdate,
|
||||
Selection,
|
||||
@@ -13,6 +30,180 @@ export type {
|
||||
|
||||
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. */
|
||||
function idsToBytes(ids: string[]): Uint8Array {
|
||||
const bytes = new Uint8Array(ids.length * 16);
|
||||
@@ -29,8 +220,9 @@ function idsToBytes(ids: string[]): Uint8Array {
|
||||
* Thin wrapper around the WASM engine.
|
||||
*
|
||||
* 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
|
||||
* structure of the files that were added or modified.
|
||||
* only what the engine reports as changed is read again: the order of the files, and the state
|
||||
* 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 {
|
||||
private wasm: Wasm | null = null;
|
||||
@@ -38,16 +230,40 @@ class Engine {
|
||||
readonly ready: Promise<void>;
|
||||
|
||||
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 _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. */
|
||||
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. */
|
||||
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>> = {
|
||||
subscribe: this._structures.subscribe,
|
||||
/**
|
||||
* The state of each file, in its own store. The map store only notifies when files are added
|
||||
* 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() {
|
||||
@@ -65,16 +281,38 @@ class Engine {
|
||||
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() {
|
||||
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() {
|
||||
@@ -89,23 +327,247 @@ class Engine {
|
||||
return this.run((w) => w.redo());
|
||||
}
|
||||
|
||||
select(fileIds: string[]) {
|
||||
return this.run((w) => w.select(idsToBytes(fileIds)));
|
||||
}
|
||||
|
||||
addSelect(fileIds: string[]) {
|
||||
return this.run((w) => w.add_select(idsToBytes(fileIds)));
|
||||
/**
|
||||
* Selects files. Selecting nothing (or only unknown files) deselects everything. See
|
||||
* `SelectMode` for how the files combine with the selection.
|
||||
*/
|
||||
select(fileIds: string[], mode: SelectMode = 'replace') {
|
||||
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() {
|
||||
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. */
|
||||
reorder(fileIds: string[], index: number) {
|
||||
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.
|
||||
// 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. */
|
||||
private sync(wasm: Wasm) {
|
||||
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) {
|
||||
this._selection.set(wasm.selection());
|
||||
}
|
||||
if (update.orderChanged) {
|
||||
this._order.set(wasm.file_order());
|
||||
}
|
||||
if (update.added.length + update.modified.length + update.removed.length > 0) {
|
||||
this._structures.update((structures) => {
|
||||
const next = new Map(structures);
|
||||
update.removed.forEach((id) => next.delete(id));
|
||||
[...update.added, ...update.modified].forEach((id) => {
|
||||
const structure = wasm.file_structure(id);
|
||||
if (structure) {
|
||||
next.set(id, structure);
|
||||
}
|
||||
const added: [string, FileState][] = [];
|
||||
for (const id of update.added) {
|
||||
const state = this.readFile(wasm, id);
|
||||
if (state) {
|
||||
added.push([id, state]);
|
||||
}
|
||||
}
|
||||
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;
|
||||
});
|
||||
}
|
||||
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 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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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]]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,22 +1,19 @@
|
||||
import { get } from 'svelte/store';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
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 type { GPXFileWithStatistics } from './statistics-tree';
|
||||
import type { Coordinates } from 'gpx';
|
||||
import { page } from '$app/state';
|
||||
import { engine, FileStateCollectionObserver, type FileState } from '$lib/engine';
|
||||
|
||||
const { statistics } = engine;
|
||||
|
||||
export class BoundsManager {
|
||||
private _bounds: maplibregl.LngLatBounds = new maplibregl.LngLatBounds();
|
||||
private _files: Set<string> = new Set();
|
||||
private _fileStateCollectionObserver: GPXFileStateCollectionObserver | null = null;
|
||||
private _fileStateCollectionObserver: FileStateCollectionObserver | null = null;
|
||||
private _unsubscribes: (() => void)[] = [];
|
||||
|
||||
constructor() {
|
||||
this._fileStateCollectionObserver = new GPXFileStateCollectionObserver(
|
||||
this._fileStateCollectionObserver = new FileStateCollectionObserver(
|
||||
(newFiles) => {
|
||||
if (page.url.hash.length == 0) {
|
||||
this.fitBoundsOnLoad(Array.from(newFiles.keys()));
|
||||
@@ -31,7 +28,7 @@ export class BoundsManager {
|
||||
this.reset();
|
||||
|
||||
this._files = new Set(files);
|
||||
this._fileStateCollectionObserver = new GPXFileStateCollectionObserver(
|
||||
this._fileStateCollectionObserver = new FileStateCollectionObserver(
|
||||
(newFiles) => {
|
||||
newFiles.forEach((fileState, 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;
|
||||
|
||||
this._files.delete(fileId);
|
||||
|
||||
const bounds = file.statistics.getStatisticsFor(new ListFileItem(fileId)).global.bounds;
|
||||
if (!this.validBounds(bounds)) return;
|
||||
|
||||
this._bounds.extend(bounds.southWest);
|
||||
this._bounds.extend(bounds.northEast);
|
||||
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;
|
||||
this._bounds.extend(bounds);
|
||||
}
|
||||
|
||||
if (this._files.size === 0) {
|
||||
this.finalizeFitBounds();
|
||||
@@ -91,42 +93,30 @@ export class BoundsManager {
|
||||
}
|
||||
|
||||
centerMapOnSelection() {
|
||||
let selected = get(selection).getSelected();
|
||||
let bounds = new maplibregl.LngLatBounds();
|
||||
|
||||
if (selected.find((item) => item instanceof ListWaypointItem)) {
|
||||
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||
let file = fileStateCollection.getFile(fileId);
|
||||
if (file) {
|
||||
items.forEach((item) => {
|
||||
if (item instanceof ListWaypointItem) {
|
||||
let waypoint = file.wpt[item.getWaypointIndex()];
|
||||
if (waypoint) {
|
||||
bounds.extend([waypoint.getLongitude(), waypoint.getLatitude()]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
let stats = get(statistics);
|
||||
if (stats.global.bounds) {
|
||||
// TODO take waypoints into account
|
||||
let bounds = new maplibregl.LngLatBounds([
|
||||
stats.global.bounds.west,
|
||||
stats.global.bounds.south,
|
||||
stats.global.bounds.east,
|
||||
stats.global.bounds.north,
|
||||
]);
|
||||
if (!this.validBounds(bounds)) return;
|
||||
get(map)?.fitBounds(bounds, {
|
||||
padding: 80,
|
||||
easing: () => 1,
|
||||
maxZoom: 15,
|
||||
});
|
||||
} else {
|
||||
let selectionBounds = get(gpxStatistics).global.bounds;
|
||||
bounds.setNorthEast(selectionBounds.northEast);
|
||||
bounds.setSouthWest(selectionBounds.southWest);
|
||||
}
|
||||
|
||||
get(map)?.fitBounds(bounds, {
|
||||
padding: 80,
|
||||
easing: () => 1,
|
||||
maxZoom: 15,
|
||||
});
|
||||
}
|
||||
|
||||
validBounds(bounds: { southWest: Coordinates; northEast: Coordinates }) {
|
||||
validBounds(bounds: maplibregl.LngLatBounds) {
|
||||
return (
|
||||
bounds.southWest.lat !== 90 ||
|
||||
bounds.southWest.lon !== 180 ||
|
||||
bounds.northEast.lat !== -90 ||
|
||||
bounds.northEast.lon !== -180
|
||||
bounds.getSouthWest().lat !== 90 ||
|
||||
bounds.getSouthWest().lng !== 180 ||
|
||||
bounds.getNorthEast().lat !== -90 ||
|
||||
bounds.getNorthEast().lng !== -180
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,15 +10,12 @@ import {
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem,
|
||||
ListWaypointItem,
|
||||
ListWaypointsItem,
|
||||
sortItems,
|
||||
type ListItem,
|
||||
} from '$lib/components/file-list/file-list';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { freeze, type WritableDraft } from 'immer';
|
||||
import { freeze } from 'immer';
|
||||
import {
|
||||
GPXFile,
|
||||
parseGPX,
|
||||
Track,
|
||||
TrackPoint,
|
||||
TrackSegment,
|
||||
@@ -32,6 +29,8 @@ import { settings } from '$lib/logic/settings';
|
||||
import { getClosestLinePoint, getClosestTrackSegments, getElevation } from '$lib/utils';
|
||||
import { gpxStatistics } from '$lib/logic/statistics';
|
||||
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
|
||||
export function getFileIds(n: number) {
|
||||
@@ -45,31 +44,21 @@ export function getFileIds(n: number) {
|
||||
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() {
|
||||
const newFileName = i18n._('menu.new_file');
|
||||
|
||||
let file = new GPXFile();
|
||||
|
||||
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}`;
|
||||
|
||||
file.metadata.name = newFileName();
|
||||
return file;
|
||||
}
|
||||
|
||||
export function createFile() {
|
||||
let file = newGPXFile();
|
||||
|
||||
fileActions.add(file);
|
||||
selection.selectFileWhenLoaded(file._data.id);
|
||||
// the engine selects the new file
|
||||
engine.newFile(newFileName());
|
||||
currentTool.set(Tool.ROUTING);
|
||||
}
|
||||
|
||||
@@ -87,41 +76,27 @@ export function triggerFileInput() {
|
||||
input.click();
|
||||
}
|
||||
|
||||
export async function loadFiles(list: FileList | File[]) {
|
||||
let files: GPXFile[] = [];
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
let file = await loadFile(list[i]);
|
||||
if (file) {
|
||||
files.push(file);
|
||||
}
|
||||
/**
|
||||
* Loads GPX files in the engine, as a single command: one undo step, the first file that could be
|
||||
* read is selected, and the map fits the files. A file without name is named like the file on
|
||||
* disk. Returns the ids of the files that were added.
|
||||
*/
|
||||
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('.'),
|
||||
}))
|
||||
);
|
||||
|
||||
const before = new Set(get(engine.order));
|
||||
await engine.loadFiles(files);
|
||||
|
||||
const ids = get(engine.order).filter((id) => !before.has(id));
|
||||
if (ids.length > 0) {
|
||||
boundsManager.fitBoundsOnLoad(ids);
|
||||
}
|
||||
|
||||
let ids = fileActions.addMultiple(files);
|
||||
selection.selectFileWhenLoaded(ids[0]);
|
||||
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() === '') {
|
||||
gpx.metadata.name = file.name.split('.').slice(0, -1).join('.');
|
||||
}
|
||||
resolve(gpx);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
return result;
|
||||
return ids;
|
||||
}
|
||||
|
||||
// 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[]>) => {
|
||||
if (itemsAndPoints.size === 0) {
|
||||
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));
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -18,14 +18,44 @@
|
||||
} from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { exampleGPXFile } from '$lib/assets/example';
|
||||
import { writable } from 'svelte/store';
|
||||
import Scissors from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
|
||||
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
let gpxStatistics = writable(exampleGPXFile.getStatistics());
|
||||
let slicedGPXStatistics = writable(undefined);
|
||||
// TODO the example comes from the previous implementation
|
||||
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 additionalDatasets = writable(['speed', 'atemp']);
|
||||
let elevationFill = writable(undefined);
|
||||
@@ -138,18 +168,14 @@
|
||||
>
|
||||
<div class="grow">
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
/>
|
||||
</div>
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
<GPXStatistics {statistics} {slicedStatistics} orientation={'horizontal'} />
|
||||
</div>
|
||||
</div>
|
||||
<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 { onDestroy, onMount } from 'svelte';
|
||||
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 { db } from '$lib/db';
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
@@ -140,14 +142,14 @@
|
||||
style={$elevationProfile ? `height: ${$bottomPanelSize}px` : ''}
|
||||
>
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
statistics={engine.statistics}
|
||||
{slicedStatistics}
|
||||
orientation={bottomPanelOrientation == 'horizontal' ? 'vertical' : 'horizontal'}
|
||||
/>
|
||||
{#if $elevationProfile}
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
statistics={engine.statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
|
||||
Reference in new issue
Block a user