mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-09 23:26:31 +00:00
merge
This commit is contained in:
1 parent
24b877c4ee
commit
f642d60f42
6 files changed
+634
-182
No files matched your search
@@ -24,3 +24,19 @@ pub struct Waypoint {
|
||||
pub sym: Option<String>,
|
||||
pub type_: Option<String>,
|
||||
}
|
||||
|
||||
impl Waypoint {
|
||||
/// Whether the waypoints are the same, whatever their ids.
|
||||
pub fn same_content(&self, other: &Waypoint) -> bool {
|
||||
self.coordinates.lng == other.coordinates.lng
|
||||
&& self.coordinates.lat == other.coordinates.lat
|
||||
&& self.ele == other.ele
|
||||
&& self.time == other.time
|
||||
&& self.name == other.name
|
||||
&& self.cmt == other.cmt
|
||||
&& self.desc == other.desc
|
||||
&& self.link == other.link
|
||||
&& self.sym == other.sym
|
||||
&& self.type_ == other.type_
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,584 @@
|
||||
use crate::{Apply, CommandError, State};
|
||||
use std::{collections::HashSet, rc::Rc};
|
||||
|
||||
use crate::{
|
||||
Apply, CommandError, FileId, GlobalStatistics, Selection, State, Statistics, Track, TrackId,
|
||||
TrackSegment, TrackSegmentId, Waypoint, Waypoints, replace_trackpoints,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MergeType {
|
||||
/// The trackpoints of the elements are connected in a single segment.
|
||||
Connect,
|
||||
/// The elements are put together, in the first one, which keeps them as they are.
|
||||
Group,
|
||||
}
|
||||
|
||||
/// Merges the selected elements into the first of them:
|
||||
///
|
||||
/// - files: the other files are deleted. The tracks of all of them are in the first file, as
|
||||
/// they are with `Group`, or all connected in the first track with `Connect`. The waypoints of
|
||||
/// all of them are in the first file too, except the ones that are the same.
|
||||
/// - tracks of a file: the first track gets their segments, as they are with `Group`, or all
|
||||
/// connected in one segment with `Connect`. The other tracks are removed.
|
||||
/// - segments of a track: they are all connected in a segment at the position of the first one
|
||||
/// with `Connect`. With `Group` they are put one after the other, at that position.
|
||||
///
|
||||
/// The segment made by `Connect` goes on in time, if there are timestamps, at the moving speed
|
||||
/// of the selection, see [`replace_trackpoints`], which `remove_gaps` is given.
|
||||
///
|
||||
/// There is nothing to do when there is nothing to merge: a single file to group, a single track
|
||||
/// that has no more than one segment, a single segment...
|
||||
#[derive(Debug)]
|
||||
pub struct Merge {
|
||||
pub type_: MergeType,
|
||||
pub remove_gaps: bool,
|
||||
}
|
||||
|
||||
impl Apply for Merge {
|
||||
fn apply(self, _state: &mut State) -> Result<(), CommandError> {
|
||||
// TODO
|
||||
Err(CommandError::NotImplemented("merge"))
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
let selection = state.selection.clone();
|
||||
match &selection {
|
||||
Selection::File { file_ids } => self.merge_files(state, file_ids),
|
||||
Selection::Track { file_id, trk_ids } => self.merge_tracks(state, *file_id, trk_ids),
|
||||
Selection::TrackSegment {
|
||||
file_id,
|
||||
trk_id,
|
||||
trkseg_ids,
|
||||
} => self.merge_segments(state, *file_id, *trk_id, trkseg_ids),
|
||||
Selection::Empty | Selection::Waypoints { .. } | Selection::Waypoint { .. } => {
|
||||
Err(CommandError::NothingToDo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Merge {
|
||||
fn merge_files(
|
||||
&self,
|
||||
state: &mut State,
|
||||
file_ids: &HashSet<FileId>,
|
||||
) -> Result<(), CommandError> {
|
||||
// in the order of the files
|
||||
let ordered: Vec<FileId> = state
|
||||
.order
|
||||
.0
|
||||
.iter()
|
||||
.filter(|id| file_ids.contains(id))
|
||||
.chain(file_ids.iter().filter(|id| !state.order.0.contains(id)))
|
||||
.filter(|id| state.files.contains_key(id))
|
||||
.copied()
|
||||
.collect();
|
||||
let Some(&target_id) = ordered.first() else {
|
||||
return Err(CommandError::NothingToDo);
|
||||
};
|
||||
|
||||
let mut tracks: Vec<Track> = vec![];
|
||||
let mut waypoints: Vec<Waypoint> = vec![];
|
||||
for id in &ordered {
|
||||
let file = &state.files[id];
|
||||
tracks.extend(file.trk.iter().cloned());
|
||||
for waypoint in file.wpt.iter() {
|
||||
if !waypoints.iter().any(|kept| kept.same_content(waypoint)) {
|
||||
waypoints.push(waypoint.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let segments: Vec<TrackSegment> = tracks
|
||||
.iter()
|
||||
.flat_map(|track| track.trkseg.iter().cloned())
|
||||
.collect();
|
||||
|
||||
match self.type_ {
|
||||
MergeType::Group if ordered.len() == 1 => return Err(CommandError::NothingToDo),
|
||||
MergeType::Connect if ordered.len() == 1 && segments.len() <= 1 => {
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
MergeType::Connect if !segments.is_empty() => {
|
||||
// a single track, which is the first one, with the connected segment
|
||||
let merged = connect(&segments, segments[0].id, self.remove_gaps);
|
||||
let first = Track {
|
||||
trkseg: vec![merged],
|
||||
..tracks[0].clone()
|
||||
};
|
||||
tracks = vec![first];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let mut target = (*state.files[&target_id]).clone();
|
||||
target.trk = tracks;
|
||||
target.wpt = Waypoints::default();
|
||||
target.wpt.insert_at(0, waypoints);
|
||||
for id in &ordered[1..] {
|
||||
state.files.remove(id);
|
||||
}
|
||||
state.order.0.retain(|id| !ordered[1..].contains(id));
|
||||
state.files.insert(target_id, Rc::new(target));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge_tracks(
|
||||
&self,
|
||||
state: &mut State,
|
||||
file_id: FileId,
|
||||
trk_ids: &HashSet<TrackId>,
|
||||
) -> Result<(), CommandError> {
|
||||
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
|
||||
let selected: Vec<usize> = (0..file.trk.len())
|
||||
.filter(|i| trk_ids.contains(&file.trk[*i].id))
|
||||
.collect();
|
||||
let Some(&first) = selected.first() else {
|
||||
return Err(CommandError::NothingToDo);
|
||||
};
|
||||
let segments: Vec<TrackSegment> = selected
|
||||
.iter()
|
||||
.flat_map(|i| file.trk[*i].trkseg.iter().cloned())
|
||||
.collect();
|
||||
if selected.len() == 1 && (self.type_ == MergeType::Group || segments.len() <= 1) {
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
|
||||
let merged = match self.type_ {
|
||||
MergeType::Group => segments,
|
||||
MergeType::Connect if segments.is_empty() => vec![],
|
||||
MergeType::Connect => {
|
||||
vec![connect(&segments, segments[0].id, self.remove_gaps)]
|
||||
}
|
||||
};
|
||||
let mut file = (**file).clone();
|
||||
file.trk[first].trkseg = merged;
|
||||
let mut index = 0;
|
||||
file.trk.retain(|_| {
|
||||
index += 1;
|
||||
!selected[1..].contains(&(index - 1))
|
||||
});
|
||||
state.files.insert(file_id, Rc::new(file));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge_segments(
|
||||
&self,
|
||||
state: &mut State,
|
||||
file_id: FileId,
|
||||
trk_id: TrackId,
|
||||
ids: &HashSet<TrackSegmentId>,
|
||||
) -> Result<(), CommandError> {
|
||||
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
|
||||
let trk = file
|
||||
.trk
|
||||
.iter()
|
||||
.position(|trk| trk.id == trk_id)
|
||||
.ok_or(CommandError::NothingToDo)?;
|
||||
let segments = &file.trk[trk].trkseg;
|
||||
let selected: Vec<usize> = (0..segments.len())
|
||||
.filter(|i| ids.contains(&segments[*i].id))
|
||||
.collect();
|
||||
if selected.len() < 2 {
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
let first = selected[0];
|
||||
let chosen: Vec<TrackSegment> = selected.iter().map(|i| segments[*i].clone()).collect();
|
||||
let merged = match self.type_ {
|
||||
MergeType::Connect => vec![connect(&chosen, chosen[0].id, self.remove_gaps)],
|
||||
MergeType::Group => {
|
||||
if selected.iter().enumerate().all(|(i, s)| *s == first + i) {
|
||||
// already one after the other
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
chosen
|
||||
}
|
||||
};
|
||||
|
||||
let mut file = (**file).clone();
|
||||
let mut remaining: Vec<TrackSegment> = file.trk[trk]
|
||||
.trkseg
|
||||
.iter()
|
||||
.filter(|segment| !ids.contains(&segment.id))
|
||||
.cloned()
|
||||
.collect();
|
||||
// the ones before the first selected one are all kept
|
||||
remaining.splice(first..first, merged);
|
||||
file.trk[trk].trkseg = remaining;
|
||||
state.files.insert(file_id, Rc::new(file));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The segments connected in a single one, which has the id `id`: the trackpoints of the second
|
||||
/// go after the ones of the first and so on.
|
||||
///
|
||||
/// If there are timestamps, the new segment goes on in time, see [`replace_trackpoints`]: the
|
||||
/// trackpoints that have none get some, at the moving speed of the segments, and the ones that
|
||||
/// are too early follow the previous ones. The time of the first trackpoint is the one it has, or
|
||||
/// the one it would have if it was as far in time from the first timed one as its distance
|
||||
/// says.
|
||||
fn connect(segments: &[TrackSegment], id: TrackSegmentId, remove_gaps: bool) -> TrackSegment {
|
||||
let mut global = GlobalStatistics::default();
|
||||
let mut distance = 0.0;
|
||||
// the first time, and the distance from the start at which it is
|
||||
let mut first_timed: Option<(i64, f64)> = None;
|
||||
for segment in segments {
|
||||
let stats = Statistics::compute(segment);
|
||||
if first_timed.is_none() {
|
||||
first_timed = segment.iter().zip(&stats.local).find_map(|(point, local)| {
|
||||
point
|
||||
.time
|
||||
.map(|time| (time, distance + local.total_distance))
|
||||
});
|
||||
}
|
||||
distance += stats.global.total_distance;
|
||||
global.merge(&stats.global);
|
||||
}
|
||||
let speed = global.moving_speed().filter(|speed| *speed > 0.0);
|
||||
let start_time = speed
|
||||
.and_then(|speed| first_timed.map(|(time, km)| time - (3_600_000.0 * km / speed) as i64));
|
||||
|
||||
let mut points = vec![];
|
||||
for segment in segments {
|
||||
let end = points.len();
|
||||
replace_trackpoints(
|
||||
&mut points,
|
||||
end,
|
||||
end,
|
||||
segment.iter().cloned().collect(),
|
||||
speed,
|
||||
start_time,
|
||||
remove_gaps,
|
||||
);
|
||||
}
|
||||
let mut merged = TrackSegment::default();
|
||||
merged.splice(0, 0, points);
|
||||
merged.id = id;
|
||||
merged
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{File, Load, engine::command::fixture::Fixture};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn load(fx: &mut Fixture, path: &str) -> FileId {
|
||||
let data = std::fs::read(path).unwrap();
|
||||
Load {
|
||||
data: &data,
|
||||
name: "file",
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
*fx.order.0.last().unwrap()
|
||||
}
|
||||
|
||||
fn merge(type_: MergeType) -> Merge {
|
||||
Merge {
|
||||
type_,
|
||||
remove_gaps: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn select_files(fx: &mut Fixture, ids: &[FileId]) {
|
||||
fx.selection = Selection::File {
|
||||
file_ids: ids.iter().copied().collect(),
|
||||
};
|
||||
}
|
||||
|
||||
fn points(file: &File) -> usize {
|
||||
file.trk
|
||||
.iter()
|
||||
.flat_map(|t| &t.trkseg)
|
||||
.map(|s| s.len())
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn waypoint(name: &str) -> Waypoint {
|
||||
Waypoint {
|
||||
name: Some(name.into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_group_files() {
|
||||
let mut fx = Fixture::default();
|
||||
let a = load(&mut fx, "data/with_tracks_and_segments.gpx");
|
||||
let b = load(&mut fx, "data/simple.gpx");
|
||||
let mut file_a = (*fx.files[&a]).clone();
|
||||
file_a
|
||||
.wpt
|
||||
.insert_at(0, vec![waypoint("x"), waypoint("same")]);
|
||||
fx.files.insert(a, Rc::new(file_a));
|
||||
let mut file_b = (*fx.files[&b]).clone();
|
||||
file_b
|
||||
.wpt
|
||||
.insert_at(0, vec![waypoint("same"), waypoint("y")]);
|
||||
fx.files.insert(b, Rc::new(file_b));
|
||||
let (before_a, before_b) = (fx.files[&a].clone(), fx.files[&b].clone());
|
||||
select_files(&mut fx, &[b, a]);
|
||||
|
||||
merge(MergeType::Group).apply(&mut fx.state()).unwrap();
|
||||
|
||||
// the first file (in the order of the files) gets everything, the other one goes
|
||||
assert_eq!(fx.order.0, [a]);
|
||||
assert!(!fx.files.contains_key(&b));
|
||||
let merged = &fx.files[&a];
|
||||
assert_eq!(merged.trk.len(), before_a.trk.len() + before_b.trk.len());
|
||||
assert_eq!(merged.trk[0].id, before_a.trk[0].id);
|
||||
assert_eq!(
|
||||
merged.trk.last().unwrap().id,
|
||||
before_b.trk.last().unwrap().id
|
||||
);
|
||||
assert_eq!(points(merged), points(&before_a) + points(&before_b));
|
||||
// the waypoints that are the same are only once
|
||||
let names: Vec<_> = merged.wpt.iter().map(|w| w.name.clone().unwrap()).collect();
|
||||
assert_eq!(names, ["x", "same", "y"]);
|
||||
assert_eq!(merged.info, before_a.info);
|
||||
// a single file has nothing to group
|
||||
select_files(&mut fx, &[a]);
|
||||
assert_eq!(
|
||||
merge(MergeType::Group).apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_connect_files() {
|
||||
let mut fx = Fixture::default();
|
||||
let a = load(&mut fx, "data/with_tracks_and_segments.gpx");
|
||||
let b = load(&mut fx, "data/simple.gpx");
|
||||
let (before_a, before_b) = (fx.files[&a].clone(), fx.files[&b].clone());
|
||||
select_files(&mut fx, &[a, b]);
|
||||
|
||||
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
|
||||
|
||||
let merged = &fx.files[&a];
|
||||
assert_eq!(fx.order.0, [a]);
|
||||
// a single track, with a single segment that has all the trackpoints in order
|
||||
assert_eq!(merged.trk.len(), 1);
|
||||
assert_eq!(merged.trk[0].id, before_a.trk[0].id);
|
||||
assert_eq!(merged.trk[0].trkseg.len(), 1);
|
||||
let segment = &merged.trk[0].trkseg[0];
|
||||
assert_eq!(segment.len(), points(&before_a) + points(&before_b));
|
||||
let expected: Vec<f64> = before_a
|
||||
.trk
|
||||
.iter()
|
||||
.chain(&before_b.trk)
|
||||
.flat_map(|t| &t.trkseg)
|
||||
.flat_map(|s| s.iter().map(|p| p.coordinates.lng))
|
||||
.collect();
|
||||
let lngs: Vec<f64> = segment.iter().map(|p| p.coordinates.lng).collect();
|
||||
assert_eq!(lngs, expected);
|
||||
assert_eq!(segment.id, before_a.trk[0].trkseg[0].id);
|
||||
assert_eq!(segment[0].anchor, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_connect_goes_on_in_time() {
|
||||
let mut fx = Fixture::default();
|
||||
// the same times twice: the second part would be too early
|
||||
let a = load(&mut fx, "data/with_time.gpx");
|
||||
let b = load(&mut fx, "data/with_time.gpx");
|
||||
select_files(&mut fx, &[a, b]);
|
||||
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
|
||||
let segment = &fx.files[&a].trk[0].trkseg[0];
|
||||
let times: Vec<i64> = segment.iter().map(|p| p.time.unwrap()).collect();
|
||||
assert_eq!(times.len(), 160);
|
||||
assert!(times.windows(2).all(|w| w[0] <= w[1]), "{times:?}");
|
||||
assert!(times[80] > times[79]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_connect_without_gaps_brings_the_parts_closer() {
|
||||
let mut fx = Fixture::default();
|
||||
let a = load(&mut fx, "data/with_time.gpx");
|
||||
// the second file comes a day later
|
||||
let b = {
|
||||
let data = std::fs::read("data/with_time.gpx").unwrap();
|
||||
Load {
|
||||
data: &data,
|
||||
name: "late",
|
||||
}
|
||||
.apply(&mut fx.state())
|
||||
.unwrap();
|
||||
*fx.order.0.last().unwrap()
|
||||
};
|
||||
let mut file = (*fx.files[&b]).clone();
|
||||
for track in file.trk.iter_mut() {
|
||||
for segment in track.trkseg.iter_mut() {
|
||||
let points: Vec<_> = segment
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut p = p.clone();
|
||||
p.time = p.time.map(|t| t + 86_400_000);
|
||||
p
|
||||
})
|
||||
.collect();
|
||||
let len = segment.len();
|
||||
segment.splice(0, len, points);
|
||||
}
|
||||
}
|
||||
fx.files.insert(b, Rc::new(file));
|
||||
let gap = |fx: &Fixture, remove_gaps: bool| {
|
||||
let mut fx2 = Fixture::default();
|
||||
fx2.files = fx.files.clone();
|
||||
fx2.order = crate::FileOrder(fx.order.0.clone());
|
||||
fx2.selection = Selection::File {
|
||||
file_ids: [a, b].into(),
|
||||
};
|
||||
Merge {
|
||||
type_: MergeType::Connect,
|
||||
remove_gaps,
|
||||
}
|
||||
.apply(&mut fx2.state())
|
||||
.unwrap();
|
||||
let segment = &fx2.files[&a].trk[0].trkseg[0];
|
||||
segment[80].time.unwrap() - segment[79].time.unwrap()
|
||||
};
|
||||
let with_gap = gap(&fx, false);
|
||||
let without = gap(&fx, true);
|
||||
assert!(with_gap > 80_000_000, "{with_gap}");
|
||||
assert!(without < 60_000, "{without}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_tracks() {
|
||||
let mut fx = Fixture::default();
|
||||
let id = load(&mut fx, "data/with_tracks_and_segments.gpx");
|
||||
let file = fx.files[&id].clone();
|
||||
assert!(file.trk.len() >= 2);
|
||||
let ids: HashSet<TrackId> = file.trk.iter().map(|t| t.id).collect();
|
||||
fx.selection = Selection::Track {
|
||||
file_id: id,
|
||||
trk_ids: ids,
|
||||
};
|
||||
let segments: usize = file.trk.iter().map(|t| t.trkseg.len()).sum();
|
||||
|
||||
merge(MergeType::Group).apply(&mut fx.state()).unwrap();
|
||||
let merged = &fx.files[&id];
|
||||
assert_eq!(merged.trk.len(), 1);
|
||||
assert_eq!(merged.trk[0].id, file.trk[0].id);
|
||||
assert_eq!(merged.trk[0].trkseg.len(), segments);
|
||||
assert_eq!(points(merged), points(&file));
|
||||
|
||||
// connected: a single segment
|
||||
fx.files.insert(id, file.clone());
|
||||
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
|
||||
let merged = &fx.files[&id];
|
||||
assert_eq!(merged.trk.len(), 1);
|
||||
assert_eq!(merged.trk[0].trkseg.len(), 1);
|
||||
assert_eq!(merged.trk[0].trkseg[0].len(), points(&file));
|
||||
|
||||
// one track with a single segment: nothing to do
|
||||
fx.selection = Selection::Track {
|
||||
file_id: id,
|
||||
trk_ids: [file.trk[0].id].into(),
|
||||
};
|
||||
for type_ in [MergeType::Group, MergeType::Connect] {
|
||||
assert_eq!(
|
||||
merge(type_).apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_connect_the_segments_of_a_track() {
|
||||
let mut fx = Fixture::default();
|
||||
let id = load(&mut fx, "data/with_tracks_and_segments.gpx");
|
||||
let file = fx.files[&id].clone();
|
||||
let (index, track) = file
|
||||
.trk
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, t)| t.trkseg.len() >= 2)
|
||||
.unwrap();
|
||||
fx.selection = Selection::Track {
|
||||
file_id: id,
|
||||
trk_ids: [track.id].into(),
|
||||
};
|
||||
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
|
||||
let merged = &fx.files[&id].trk[index];
|
||||
assert_eq!(merged.id, track.id);
|
||||
assert_eq!(merged.trkseg.len(), 1);
|
||||
assert_eq!(
|
||||
merged.trkseg[0].len(),
|
||||
track.trkseg.iter().map(|s| s.len()).sum::<usize>()
|
||||
);
|
||||
// the other tracks are untouched
|
||||
assert_eq!(fx.files[&id].trk.len(), file.trk.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_segments() {
|
||||
let mut fx = Fixture::default();
|
||||
let id = load(&mut fx, "data/with_tracks_and_segments.gpx");
|
||||
// a track with three segments
|
||||
let mut three = (*fx.files[&id]).clone();
|
||||
let base = three.trk[0].trkseg[0].clone();
|
||||
three.trk[0].trkseg = (0..3).map(|_| crate::copy_segment(&base)).collect();
|
||||
fx.files.insert(id, Rc::new(three));
|
||||
let file = fx.files[&id].clone();
|
||||
let (index, track) = (0, &file.trk[0]);
|
||||
let segments = &track.trkseg;
|
||||
// the first and the last ones
|
||||
let (first, last) = (&segments[0], &segments[2]);
|
||||
fx.selection = Selection::TrackSegment {
|
||||
file_id: id,
|
||||
trk_id: track.id,
|
||||
trkseg_ids: [first.id, last.id].into(),
|
||||
};
|
||||
|
||||
// grouped: next to each other, at the place of the first one
|
||||
merge(MergeType::Group).apply(&mut fx.state()).unwrap();
|
||||
let ids: Vec<_> = fx.files[&id].trk[index]
|
||||
.trkseg
|
||||
.iter()
|
||||
.map(|s| s.id)
|
||||
.collect();
|
||||
assert_eq!(ids[..3], [first.id, last.id, segments[1].id]);
|
||||
|
||||
// connected: one segment, with the id of the first one, at its place
|
||||
fx.files.insert(id, file.clone());
|
||||
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
|
||||
let merged = &fx.files[&id].trk[index].trkseg;
|
||||
assert_eq!(merged.len(), segments.len() - 1);
|
||||
assert_eq!(merged[0].id, first.id);
|
||||
assert_eq!(merged[0].len(), first.len() + last.len());
|
||||
assert_eq!(merged[1].id, segments[1].id);
|
||||
|
||||
// a single segment, or segments that are already together: nothing to do
|
||||
fx.selection = Selection::TrackSegment {
|
||||
file_id: id,
|
||||
trk_id: track.id,
|
||||
trkseg_ids: [first.id].into(),
|
||||
};
|
||||
assert_eq!(
|
||||
merge(MergeType::Connect).apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
fx.files.insert(id, file.clone());
|
||||
fx.selection = Selection::TrackSegment {
|
||||
file_id: id,
|
||||
trk_id: track.id,
|
||||
trkseg_ids: [first.id, segments[1].id].into(),
|
||||
};
|
||||
assert_eq!(
|
||||
merge(MergeType::Group).apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nothing_to_merge() {
|
||||
let mut fx = Fixture::default();
|
||||
let id = load(&mut fx, "data/simple.gpx");
|
||||
let before = fx.files[&id].clone();
|
||||
for selection in [Selection::Empty, Selection::Waypoints { file_id: id }] {
|
||||
fx.selection = selection;
|
||||
assert_eq!(
|
||||
merge(MergeType::Connect).apply(&mut fx.state()),
|
||||
Err(CommandError::NothingToDo)
|
||||
);
|
||||
}
|
||||
assert!(Rc::ptr_eq(&fx.files[&id], &before));
|
||||
}
|
||||
}
|
||||
@@ -618,10 +618,13 @@ pub fn time() -> bool {
|
||||
edit(Command::Time(engine::Time))
|
||||
}
|
||||
|
||||
/// Merges the selection into its first element (see `MergeType`). `remove_gaps` brings the parts
|
||||
/// that are connected closer in time, if they have timestamps.
|
||||
#[wasm_bindgen]
|
||||
pub fn merge(type_: MergeType) -> bool {
|
||||
pub fn merge(type_: MergeType, remove_gaps: bool) -> bool {
|
||||
edit(Command::Merge(engine::Merge {
|
||||
type_: type_.into(),
|
||||
remove_gaps,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { ListFileItem, ListTrackItem } from '$lib/components/file-list/file-list';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
@@ -16,43 +15,43 @@
|
||||
import { Group } from '@lucide/svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { gpxStatistics } from '$lib/logic/statistics';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { engine } from '$lib/engine';
|
||||
|
||||
let props: {
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
const selection = engine.selection;
|
||||
const statistics = engine.statistics;
|
||||
|
||||
// the number of segments of the selection that have trackpoints
|
||||
const request = engine.requestStatistics();
|
||||
request.set(['anchors']);
|
||||
onDestroy(() => request.release());
|
||||
let segmentCount = $derived($statistics.anchors?.segmentStarts.length ?? 0);
|
||||
|
||||
// several files, tracks or segments, or a single file or track with several segments
|
||||
let canMergeTraces = $derived.by(() => {
|
||||
if ($selection.size > 1) {
|
||||
return true;
|
||||
} else if ($selection.size === 1) {
|
||||
let selected = $selection.getSelected()[0];
|
||||
if (selected instanceof ListFileItem) {
|
||||
let file = fileStateCollection.getFile(selected.getFileId());
|
||||
if (file) {
|
||||
return file.getSegments().length > 1;
|
||||
}
|
||||
} else if (selected instanceof ListTrackItem) {
|
||||
let trackIndex = selected.getTrackIndex();
|
||||
let file = fileStateCollection.getFile(selected.getFileId());
|
||||
if (file && trackIndex < file.trk.length) {
|
||||
return file.trk[trackIndex].getSegments().length > 1;
|
||||
}
|
||||
}
|
||||
switch ($selection.type) {
|
||||
case 'file':
|
||||
return $selection.fileIds.length > 1 || segmentCount > 1;
|
||||
case 'track':
|
||||
return $selection.trackIds.length > 1 || segmentCount > 1;
|
||||
case 'segment':
|
||||
return $selection.segmentIds.length > 1;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// several files or tracks, that can be put together
|
||||
let canMergeContents = $derived(
|
||||
$selection.size > 1 &&
|
||||
$selection
|
||||
.getSelected()
|
||||
.some((item) => item instanceof ListFileItem || item instanceof ListTrackItem)
|
||||
($selection.type === 'file' && $selection.fileIds.length > 1) ||
|
||||
($selection.type === 'track' && $selection.trackIds.length > 1)
|
||||
);
|
||||
|
||||
let hasTimes = $derived(($statistics.global.totalTime ?? 0) > 0);
|
||||
let removeGaps = $state(false);
|
||||
let mergeType = $state(MergeType.TRACES);
|
||||
</script>
|
||||
@@ -68,7 +67,7 @@
|
||||
{i18n._('toolbar.merge.merge_contents')}
|
||||
</Label>
|
||||
</RadioGroup.Root>
|
||||
{#if mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0}
|
||||
{#if mergeType === MergeType.TRACES && hasTimes}
|
||||
<div class="flex flex-row items-center gap-1.5">
|
||||
<Checkbox id="remove-gaps" bind:checked={removeGaps} />
|
||||
<Label for="remove-gaps">{i18n._('toolbar.merge.remove_gaps')}</Label>
|
||||
@@ -80,9 +79,9 @@
|
||||
disabled={(mergeType === MergeType.TRACES && !canMergeTraces) ||
|
||||
(mergeType === MergeType.CONTENTS && !canMergeContents)}
|
||||
onclick={() => {
|
||||
fileActions.mergeSelection(
|
||||
mergeType === MergeType.TRACES,
|
||||
mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0 && removeGaps
|
||||
engine.merge(
|
||||
mergeType === MergeType.TRACES ? 'connect' : 'group',
|
||||
mergeType === MergeType.TRACES && hasTimes && removeGaps
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -32,6 +32,12 @@ export type {
|
||||
|
||||
type Wasm = typeof import('gpx-rs');
|
||||
|
||||
/**
|
||||
* How a merge puts the selected elements together: `connect` makes a single segment of their
|
||||
* trackpoints, `group` keeps them as they are, in the first one.
|
||||
*/
|
||||
export type MergeType = 'connect' | 'group';
|
||||
|
||||
/** What a split cuts in two: the file, the track or the segment of the trackpoint. */
|
||||
export type SplitType = 'files' | 'tracks' | 'segments';
|
||||
|
||||
@@ -648,6 +654,17 @@ class Engine {
|
||||
return this.run((w) => w.reverse());
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the selected files, tracks or segments into the first of them (see `MergeType`). With
|
||||
* `removeGaps`, the parts that are connected get closer in time if they have timestamps.
|
||||
* Resolves to `false` when there is nothing to merge.
|
||||
*/
|
||||
merge(type: MergeType, removeGaps = false) {
|
||||
return this.run((w) =>
|
||||
w.merge(type === 'connect' ? w.MergeType.Connect : w.MergeType.Group, removeGaps)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuts the file, the track or the segment (see `SplitType`) of the trackpoint `at` of the
|
||||
* selection in two, there: the trackpoint ends the first part and starts the second one. The
|
||||
|
||||
@@ -99,154 +99,6 @@ export async function loadFiles(list: FileList | File[]): Promise<string[]> {
|
||||
|
||||
// Helper functions for file operations
|
||||
export const fileActions = {
|
||||
mergeSelection: (mergeTraces: boolean, removeGaps: boolean) => {
|
||||
fileActionManager.applyGlobal((draft) => {
|
||||
let first = true;
|
||||
let target: ListItem = new ListRootItem();
|
||||
let targetFile: GPXFile | undefined = undefined;
|
||||
let toMerge: {
|
||||
trk: Track[];
|
||||
trkseg: TrackSegment[];
|
||||
wpt: Waypoint[];
|
||||
} = {
|
||||
trk: [],
|
||||
trkseg: [],
|
||||
wpt: [],
|
||||
};
|
||||
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||
let file = draft.get(fileId);
|
||||
let originalFile = fileStateCollection.getFile(fileId);
|
||||
if (file && originalFile) {
|
||||
if (level === ListLevel.FILE) {
|
||||
toMerge.trk.push(...originalFile.trk.map((track) => track.clone()));
|
||||
for (const wpt of originalFile.wpt) {
|
||||
if (!toMerge.wpt.some((w) => w.equals(wpt))) {
|
||||
toMerge.wpt.push(wpt.clone());
|
||||
}
|
||||
}
|
||||
if (first) {
|
||||
target = items[0];
|
||||
targetFile = file;
|
||||
} else {
|
||||
draft.delete(fileId);
|
||||
}
|
||||
} else {
|
||||
if (level === ListLevel.TRACK) {
|
||||
items.forEach((item, index) => {
|
||||
let trackIndex = (item as ListTrackItem).getTrackIndex();
|
||||
toMerge.trkseg.splice(
|
||||
0,
|
||||
0,
|
||||
...originalFile.trk[trackIndex].trkseg.map((segment) =>
|
||||
segment.clone()
|
||||
)
|
||||
);
|
||||
if (index === items.length - 1) {
|
||||
// Order is reversed, so the last track is the first one and the one to keep
|
||||
target = item;
|
||||
file.trk[trackIndex].trkseg = [];
|
||||
} else {
|
||||
file.trk.splice(trackIndex, 1);
|
||||
}
|
||||
});
|
||||
} else if (level === ListLevel.SEGMENT) {
|
||||
items.forEach((item, index) => {
|
||||
let trackIndex = (item as ListTrackSegmentItem).getTrackIndex();
|
||||
let segmentIndex = (item as ListTrackSegmentItem).getSegmentIndex();
|
||||
if (index === items.length - 1) {
|
||||
// Order is reversed, so the last segment is the first one and the one to keep
|
||||
target = item;
|
||||
}
|
||||
toMerge.trkseg.splice(
|
||||
0,
|
||||
0,
|
||||
originalFile.trk[trackIndex].trkseg[segmentIndex].clone()
|
||||
);
|
||||
file.trk[trackIndex].trkseg.splice(segmentIndex, 1);
|
||||
});
|
||||
}
|
||||
targetFile = file;
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
});
|
||||
if (mergeTraces) {
|
||||
let statistics = get(gpxStatistics);
|
||||
let speed =
|
||||
statistics.global.speed.moving > 0 ? statistics.global.speed.moving : undefined;
|
||||
let startTime: Date | undefined = undefined;
|
||||
if (speed !== undefined) {
|
||||
if (
|
||||
statistics.global.length > 0 &&
|
||||
statistics.getTrackPoint(0)!.trkpt.time !== undefined
|
||||
) {
|
||||
startTime = statistics.getTrackPoint(0)!.trkpt.time;
|
||||
} else {
|
||||
for (let i = 0; i < statistics.global.length; i++) {
|
||||
const point = statistics.getTrackPoint(i)!;
|
||||
if (point.trkpt.time !== undefined) {
|
||||
startTime = new Date(
|
||||
point.trkpt.time.getTime() -
|
||||
(1000 * 3600 * point.distance.total) / speed
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (toMerge.trk.length > 0 && toMerge.trk[0].trkseg.length > 0) {
|
||||
let s = new TrackSegment();
|
||||
toMerge.trk.map((track) => {
|
||||
track.trkseg.forEach((segment) => {
|
||||
s.replaceTrackPoints(
|
||||
s.trkpt.length,
|
||||
s.trkpt.length,
|
||||
segment.trkpt.slice(),
|
||||
speed,
|
||||
startTime,
|
||||
removeGaps
|
||||
);
|
||||
});
|
||||
});
|
||||
toMerge.trk = [toMerge.trk[0]];
|
||||
toMerge.trk[0].trkseg = [s];
|
||||
}
|
||||
if (toMerge.trkseg.length > 0) {
|
||||
let s = new TrackSegment();
|
||||
toMerge.trkseg.forEach((segment) => {
|
||||
s.replaceTrackPoints(
|
||||
s.trkpt.length,
|
||||
s.trkpt.length,
|
||||
segment.trkpt.slice(),
|
||||
speed,
|
||||
startTime,
|
||||
removeGaps
|
||||
);
|
||||
});
|
||||
toMerge.trkseg = [s];
|
||||
}
|
||||
}
|
||||
if (targetFile) {
|
||||
targetFile = targetFile as GPXFile;
|
||||
if (target instanceof ListFileItem) {
|
||||
targetFile.replaceTracks(0, targetFile.trk.length - 1, toMerge.trk);
|
||||
targetFile.replaceWaypoints(0, targetFile.wpt.length - 1, toMerge.wpt);
|
||||
} else if (target instanceof ListTrackItem) {
|
||||
let trackIndex = target.getTrackIndex();
|
||||
targetFile.replaceTrackSegments(trackIndex, 0, -1, toMerge.trkseg);
|
||||
} else if (target instanceof ListTrackSegmentItem) {
|
||||
let trackIndex = target.getTrackIndex();
|
||||
let segmentIndex = target.getSegmentIndex();
|
||||
targetFile.replaceTrackSegments(
|
||||
trackIndex,
|
||||
segmentIndex,
|
||||
segmentIndex - 1,
|
||||
toMerge.trkseg
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
extractSelection: () => {
|
||||
return fileActionManager.applyGlobal((draft) => {
|
||||
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||
|
||||
Reference in new issue
Block a user