This commit is contained in:
vcoppe committed 2026-10-07 22:20:40 +02:00
1 parent f642d60f42
commit 82d2f79da0
7 files changed
+770 -198

No files matched your search

@@ -1,13 +1,143 @@
use crate::{Apply, CommandError, State}; use std::rc::Rc;
use crate::{Apply, CommandError, File, State};
/// Sets the elevation of the trackpoints of the selected segments (the ones of the statistics of
/// the selection, in the same order): `ele` has one elevation per trackpoint.
#[derive(Debug)] #[derive(Debug)]
pub struct Elevation<'a> { pub struct Elevation<'a> {
pub ele: &'a [f64], pub ele: &'a [f64],
} }
impl Apply for Elevation<'_> { impl Apply for Elevation<'_> {
fn apply(self, _state: &mut State) -> Result<(), CommandError> { fn apply(self, state: &mut State) -> Result<(), CommandError> {
// TODO let locations = state
Err(CommandError::NotImplemented("elevation")) .selection
.segment_locations(state.files, &state.order.0);
let count: usize = locations
.iter()
.map(|location| {
state.files[&location.file_id].trk[location.trk].trkseg[location.seg].len()
})
.sum();
if count == 0 {
return Err(CommandError::NothingToDo);
}
if count != self.ele.len() {
return Err(CommandError::InvalidData(format!(
"{} elevations for {count} trackpoints",
self.ele.len()
)));
}
let mut ele = self.ele.iter().copied();
// the segments of a file are consecutive
for group in locations.chunk_by(|a, b| a.file_id == b.file_id) {
let file_id = group[0].file_id;
let mut file: File = (*state.files[&file_id]).clone();
for location in group {
let segment = &mut file.trk[location.trk].trkseg[location.seg];
segment.update_all(|_, pt| pt.ele = ele.next().unwrap_or(pt.ele));
segment.rev_id = Default::default();
}
state.files.insert(file_id, Rc::new(file));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{FileId, Load, Selection, engine::command::fixture::Fixture};
use super::*;
fn loaded(path: &str) -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read(path).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 trackpoint_elevations(fx: &Fixture, id: FileId) -> Vec<f64> {
fx.files[&id]
.trk
.iter()
.flat_map(|trk| {
trk.trkseg
.iter()
.flat_map(|seg| seg.iter().map(|pt| pt.ele))
})
.collect()
}
#[test]
fn test_a_file_gets_elevation_on_all_its_trackpoints() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
let count = trackpoint_elevations(&fx, id).len();
let ele: Vec<f64> = (0..count).map(|i| 100.0 + i as f64).collect();
Elevation { ele: &ele }.apply(&mut fx.state()).unwrap();
assert_eq!(trackpoint_elevations(&fx, id), ele);
}
#[test]
fn test_segments_only_touch_their_trackpoints() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
let before = trackpoint_elevations(&fx, id);
let track = &fx.files[&id].trk[0];
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: track.id,
trkseg_ids: HashSet::from([track.trkseg[0].id]),
};
let rev_ids: Vec<_> = track.trkseg.iter().map(|seg| seg.rev_id).collect();
let len = track.trkseg[0].len();
Elevation {
ele: &vec![5.0; len],
}
.apply(&mut fx.state())
.unwrap();
let after = trackpoint_elevations(&fx, id);
assert_eq!(after[..len], vec![5.0; len]);
assert_eq!(after[len..], before[len..]);
let segments = &fx.files[&id].trk[0].trkseg;
assert_ne!(segments[0].rev_id, rev_ids[0]);
assert_eq!(segments[1].rev_id, rev_ids[1]);
}
#[test]
fn test_wrong_number_of_elevations() {
let (mut fx, id) = loaded("data/simple.gpx");
let before = trackpoint_elevations(&fx, id);
assert!(matches!(
Elevation { ele: &[1.0] }.apply(&mut fx.state()),
Err(CommandError::InvalidData(_))
));
assert_eq!(trackpoint_elevations(&fx, id), before);
}
#[test]
fn test_nothing_selected() {
let (mut fx, _) = loaded("data/simple.gpx");
fx.selection = Selection::Empty;
assert!(matches!(
Elevation { ele: &[] }.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
));
} }
} }
@@ -1,11 +1,570 @@
use crate::{Apply, CommandError, State}; use std::{collections::HashSet, rc::Rc};
use crate::{
Apply, CommandError, File, FileId, LngLat, LngLatBounds, Selection, State, Track, TrackId,
TrackSegment, Waypoint, Waypoints, copy_waypoint, crossarc_lnglat, distance,
};
/// Splits the selected elements that are made of several segments into elements that have one
/// each:
///
/// - files: a file with several tracks becomes one file per track, in which the track is split
/// into one track per segment. A file with a single track becomes one file per segment. The new
/// files are in the place of the file, which goes, and are selected. They are named after the
/// track, or the file, and numbered. The waypoints go to the files that have the segments
/// closest to them (to the first file if the file has no trackpoint).
/// - tracks: each selected track is replaced by one track per segment, which are selected. They
/// are named after the track and numbered.
///
/// What has a single segment is left as it is. There is nothing to do if nothing was changed.
#[derive(Debug)] #[derive(Debug)]
pub struct Extract; pub struct Extract;
impl Apply for Extract { impl Apply for Extract {
fn apply(self, _state: &mut State) -> Result<(), CommandError> { fn apply(self, state: &mut State) -> Result<(), CommandError> {
// TODO let selection = state.selection.clone();
Err(CommandError::NotImplemented("extract")) match &selection {
Selection::File { file_ids } => extract_files(state, file_ids),
Selection::Track { file_id, trk_ids } => extract_tracks(state, *file_id, trk_ids),
_ => Err(CommandError::NothingToDo),
}
}
}
fn extract_files(state: &mut State, file_ids: &HashSet<FileId>) -> Result<(), CommandError> {
// the files that have something to extract, with the files that come out of them
let mut extracted: Vec<(FileId, Vec<File>)> = vec![];
for id in state.order.0.iter().filter(|id| file_ids.contains(id)) {
let file = &state.files[id];
let segments: usize = file.trk.iter().map(|track| track.trkseg.len()).sum();
if segments > 1 {
extracted.push((*id, extract_file(file)));
}
}
if extracted.is_empty() {
return Err(CommandError::NothingToDo);
}
let mut order = vec![];
for id in state.order.0.iter() {
match extracted.iter().find(|(original, _)| original == id) {
Some((_, files)) => order.extend(files.iter().map(|file| file.id)),
None => order.push(*id),
}
}
let mut selected: HashSet<FileId> = file_ids.clone();
for (original, files) in extracted {
state.files.remove(&original);
selected.remove(&original);
for file in files {
selected.insert(file.id);
state.files.insert(file.id, Rc::new(file));
}
}
state.order.0 = order;
*state.selection = Selection::File { file_ids: selected };
Ok(())
}
/// The files that a file with several segments becomes.
fn extract_file(file: &File) -> Vec<File> {
// for each new file: its tracks and its name, and the segments that go with it
struct Part {
tracks: Vec<Track>,
name: String,
}
let (parts, owner): (Vec<Part>, Vec<usize>) = if file.trk.len() > 1 {
// one file per track, the segments of a track follow it
let parts = file
.trk
.iter()
.enumerate()
.map(|(i, track)| Part {
tracks: split_track(track),
name: track
.info
.name
.clone()
.unwrap_or_else(|| format!("{} ({})", file.info.name, i + 1)),
})
.collect();
let owner = file
.trk
.iter()
.enumerate()
.flat_map(|(i, track)| track.trkseg.iter().map(move |_| i))
.collect();
(parts, owner)
} else {
// one file per segment
let track = &file.trk[0];
let base = track
.info
.name
.clone()
.unwrap_or_else(|| file.info.name.clone());
let parts = track
.trkseg
.iter()
.enumerate()
.map(|(i, segment)| Part {
tracks: vec![Track {
id: if i == 0 { track.id } else { Default::default() },
trkseg: vec![segment.clone()],
..track.clone()
}],
name: format!("{base} ({})", i + 1),
})
.collect();
let owner = (0..track.trkseg.len()).collect();
(parts, owner)
};
// the waypoints go with the segments that are the closest to them
let segments: Vec<&TrackSegment> = file.trk.iter().flat_map(|track| &track.trkseg).collect();
let mut waypoints: Vec<Vec<Waypoint>> = vec![vec![]; parts.len()];
for waypoint in file.wpt.iter() {
let mut owners: Vec<usize> = closest_segments(&segments, waypoint.coordinates)
.into_iter()
.map(|segment| owner[segment])
.collect();
owners.sort_unstable();
owners.dedup();
if owners.is_empty() {
owners.push(0);
}
for (n, part) in owners.into_iter().enumerate() {
// a waypoint that goes to several files has a new id in all but the first
waypoints[part].push(if n == 0 {
waypoint.clone()
} else {
copy_waypoint(waypoint)
});
}
}
parts
.into_iter()
.zip(waypoints)
.map(|(part, waypoints)| {
let mut extracted = File {
id: Default::default(),
trk: part.tracks,
wpt: Waypoints::default(),
..file.clone()
};
extracted.info.name = part.name;
extracted.wpt.insert_at(0, waypoints);
extracted
})
.collect()
}
fn extract_tracks(
state: &mut State,
file_id: FileId,
trk_ids: &HashSet<TrackId>,
) -> Result<(), CommandError> {
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let mut changed = false;
let mut tracks = vec![];
let mut selected: HashSet<TrackId> = trk_ids.clone();
for track in &file.trk {
if trk_ids.contains(&track.id) && track.trkseg.len() > 1 {
changed = true;
selected.remove(&track.id);
let pieces = split_track(track);
selected.extend(pieces.iter().map(|piece| piece.id));
tracks.extend(pieces);
} else {
tracks.push(track.clone());
}
}
if !changed {
return Err(CommandError::NothingToDo);
}
let mut file = (**file).clone();
file.trk = tracks;
state.files.insert(file_id, Rc::new(file));
*state.selection = Selection::Track {
file_id,
trk_ids: selected,
};
Ok(())
}
/// The tracks that a track becomes: one per segment, the first one with the id of the track. The
/// others have the same info, with the name of the track and the number of the segment, if it has
/// a name. A track with at most one segment stays as it is.
fn split_track(track: &Track) -> Vec<Track> {
if track.trkseg.len() <= 1 {
return vec![track.clone()];
}
track
.trkseg
.iter()
.enumerate()
.map(|(i, segment)| {
let mut piece = Track {
id: if i == 0 { track.id } else { Default::default() },
trkseg: vec![segment.clone()],
..track.clone()
};
piece.info.name = track
.info
.name
.as_ref()
.map(|name| format!("{name} ({})", i + 1));
piece
})
.collect()
}
/// The indices of the segments that have the trackpoint that is the closest to `point`, which are
/// several when the closest trackpoints are as close.
///
/// The segments are looked at from the closest to the farthest bounds, which is how they are
/// left out: the ones whose bounds are farther than the closest trackpoint found.
fn closest_segments(segments: &[&TrackSegment], point: LngLat) -> Vec<usize> {
let mut by_bounds: Vec<(f64, usize)> = segments
.iter()
.enumerate()
.filter(|(_, segment)| !segment.is_empty())
.map(|(i, segment)| {
let mut bounds = LngLatBounds::default();
segment
.iter()
.for_each(|trkpt| bounds.extend(trkpt.coordinates));
(distance_to_bounds(&bounds, point), i)
})
.collect();
by_bounds.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut closest = f64::MAX;
let mut indices: Vec<usize> = vec![];
for (bounds_distance, i) in by_bounds {
if bounds_distance > closest {
break;
}
for trkpt in segments[i].iter() {
// in meters, like the distances to the bounds
let d = distance(trkpt.coordinates, point) * 1000.0;
if d < closest {
closest = d;
indices = vec![i];
} else if d == closest {
indices.push(i);
}
}
}
indices.sort_unstable();
indices.dedup();
indices
}
/// 0 inside the bounds, else the distance in meters to the closest side.
fn distance_to_bounds(bounds: &LngLatBounds, point: LngLat) -> f64 {
let (sw, ne) = (bounds.sw, bounds.ne);
if (sw.lng..=ne.lng).contains(&point.lng) && (sw.lat..=ne.lat).contains(&point.lat) {
return 0.0;
}
let nw = LngLat {
lng: sw.lng,
lat: ne.lat,
};
let se = LngLat {
lng: ne.lng,
lat: sw.lat,
};
[(nw, ne), (ne, se), (se, sw), (sw, nw)]
.into_iter()
.map(|(a, b)| crossarc_lnglat(a, b, point))
.fold(f64::MAX, f64::min)
}
#[cfg(test)]
mod tests {
use crate::{TrackInfo, Trackpoint, engine::command::fixture::Fixture};
use super::*;
/// A segment of 5 trackpoints along the equator, from the longitude `from`.
fn segment(from: f64) -> TrackSegment {
let mut segment = TrackSegment::default();
let points = (0..5)
.map(|i| Trackpoint {
coordinates: LngLat {
lng: from + i as f64 * 0.001,
lat: 0.0,
},
..Default::default()
})
.collect();
segment.splice(0, 0, points);
segment
}
fn track(name: Option<&str>, from: &[f64]) -> Track {
Track {
info: TrackInfo {
name: name.map(str::to_string),
..Default::default()
},
trkseg: from.iter().map(|from| segment(*from)).collect(),
..Default::default()
}
}
fn waypoint(name: &str, lng: f64) -> Waypoint {
Waypoint {
name: Some(name.into()),
coordinates: LngLat { lng, lat: 0.001 },
..Default::default()
}
}
fn fixture(file: File) -> (Fixture, FileId) {
let mut fx = Fixture::default();
let id = file.id;
fx.files.insert(id, Rc::new(file));
fx.order.0.push(id);
fx.selection = Selection::File {
file_ids: [id].into(),
};
(fx, id)
}
fn file(name: &str, tracks: Vec<Track>, waypoints: Vec<Waypoint>) -> File {
let mut file = File {
trk: tracks,
..Default::default()
};
file.info.name = name.into();
file.wpt.insert_at(0, waypoints);
file
}
fn names(fx: &Fixture) -> Vec<String> {
fx.order
.0
.iter()
.map(|id| fx.files[id].info.name.clone())
.collect()
}
#[test]
fn test_a_file_with_one_track_becomes_one_file_per_segment() {
let original = file(
"route",
vec![track(Some("climb"), &[0.0, 10.0, 20.0])],
// near the first segment, near the last one, and in between, closer to the middle one
vec![
waypoint("a", 0.002),
waypoint("c", 20.002),
waypoint("b", 10.0),
],
);
let track_id = original.trk[0].id;
let segment_ids: Vec<_> = original.trk[0].trkseg.iter().map(|s| s.id).collect();
let (mut fx, id) = fixture(original);
let other = file("other", vec![track(None, &[50.0])], vec![]);
let other_id = other.id;
fx.files.insert(other_id, Rc::new(other));
fx.order.0.push(other_id);
Extract.apply(&mut fx.state()).unwrap();
// in the place of the file, in order, named after the track
assert!(!fx.files.contains_key(&id));
assert_eq!(names(&fx), ["climb (1)", "climb (2)", "climb (3)", "other"]);
assert_eq!(fx.order.0[3], other_id);
for (i, file_id) in fx.order.0[..3].iter().enumerate() {
let file = &fx.files[file_id];
assert_eq!(file.trk.len(), 1);
assert_eq!(file.trk[0].trkseg.len(), 1);
assert_eq!(file.trk[0].trkseg[0].id, segment_ids[i]);
assert_eq!(file.trk[0].info.name.as_deref(), Some("climb"));
}
assert_eq!(fx.files[&fx.order.0[0]].trk[0].id, track_id);
// the waypoints are with the closest segment
let waypoints = |i: usize| -> Vec<String> {
fx.files[&fx.order.0[i]]
.wpt
.iter()
.map(|w| w.name.clone().unwrap())
.collect()
};
assert_eq!(waypoints(0), ["a"]);
assert_eq!(waypoints(1), ["b"]);
assert_eq!(waypoints(2), ["c"]);
// the new files are selected, the other one is not
assert!(matches!(&fx.selection, Selection::File { file_ids }
if file_ids.len() == 3 && !file_ids.contains(&other_id)));
}
#[test]
fn test_a_file_with_several_tracks_becomes_one_file_per_track() {
let original = file(
"route",
vec![
track(Some("first"), &[0.0, 10.0]),
track(None, &[20.0]),
track(Some("third"), &[30.0, 40.0]),
],
vec![],
);
let ids: Vec<_> = original.trk.iter().map(|t| t.id).collect();
let (mut fx, _) = fixture(original);
Extract.apply(&mut fx.state()).unwrap();
// named after the track, or the file
assert_eq!(names(&fx), ["first", "route (2)", "third"]);
let tracks = |i: usize| &fx.files[&fx.order.0[i]].trk;
// a track with several segments is split in as many tracks
assert_eq!(tracks(0).len(), 2);
assert_eq!(tracks(0)[0].info.name.as_deref(), Some("first (1)"));
assert_eq!(tracks(0)[1].info.name.as_deref(), Some("first (2)"));
assert_eq!(tracks(0)[0].id, ids[0]);
// and one that has a single segment is not renamed
assert_eq!(tracks(1).len(), 1);
assert_eq!(tracks(1)[0].id, ids[1]);
assert_eq!(tracks(1)[0].info.name, None);
assert_eq!(tracks(2).len(), 2);
}
#[test]
fn test_waypoints_with_closest_segments_that_tie_are_copied() {
// the segments share a trackpoint, where the waypoint is: it is as close to both
let a = segment(0.0);
let b = segment(a[4].coordinates.lng);
let shared = Waypoint {
name: Some("mid".into()),
coordinates: a[4].coordinates,
..Default::default()
};
let original = file(
"f",
vec![Track {
trkseg: vec![a, b],
..Default::default()
}],
vec![shared, waypoint("lost", 100.0)],
);
let (mut fx, _) = fixture(original);
Extract.apply(&mut fx.state()).unwrap();
let first = &fx.files[&fx.order.0[0]];
let second = &fx.files[&fx.order.0[1]];
// the tie goes to both, with different ids
let mid_in = |f: &File| {
f.wpt
.iter()
.find(|w| w.name.as_deref() == Some("mid"))
.map(|w| w.id)
};
assert!(mid_in(first).is_some() && mid_in(second).is_some());
assert_ne!(mid_in(first), mid_in(second));
// a far waypoint goes to its closest segment, which is the last one
assert!(second.wpt.iter().any(|w| w.name.as_deref() == Some("lost")));
}
#[test]
fn test_a_waypoint_of_a_file_without_trackpoints_is_kept() {
let mut empty = TrackSegment::default();
empty.rev_id = Default::default();
let original = file(
"f",
vec![Track {
trkseg: vec![empty.clone(), TrackSegment::default()],
..Default::default()
}],
vec![waypoint("w", 1.0)],
);
let (mut fx, _) = fixture(original);
Extract.apply(&mut fx.state()).unwrap();
assert_eq!(fx.files[&fx.order.0[0]].wpt.len(), 1);
assert_eq!(fx.files[&fx.order.0[1]].wpt.len(), 0);
}
#[test]
fn test_extract_tracks_in_place() {
let original = file(
"route",
vec![
track(Some("a"), &[0.0]),
track(Some("b"), &[10.0, 20.0, 30.0]),
track(None, &[40.0, 50.0]),
],
vec![],
);
let ids: Vec<_> = original.trk.iter().map(|t| t.id).collect();
let (mut fx, id) = fixture(original);
fx.selection = Selection::Track {
file_id: id,
trk_ids: [ids[1], ids[2]].into(),
};
Extract.apply(&mut fx.state()).unwrap();
let file = &fx.files[&id];
let names: Vec<_> = file.trk.iter().map(|t| t.info.name.clone()).collect();
assert_eq!(
names,
[
Some("a".into()),
Some("b (1)".into()),
Some("b (2)".into()),
Some("b (3)".into()),
None,
None
]
);
assert_eq!(file.trk.len(), 6);
assert!(file.trk.iter().all(|t| t.trkseg.len() == 1));
assert_eq!(file.trk[0].id, ids[0]);
assert_eq!(file.trk[1].id, ids[1]);
// the new tracks are selected
let Selection::Track { trk_ids, .. } = &fx.selection else {
panic!("tracks are selected");
};
assert_eq!(trk_ids.len(), 5);
assert!(trk_ids.contains(&ids[1]) && !trk_ids.contains(&ids[0]));
}
#[test]
fn test_nothing_to_extract() {
// a single segment
let original = file("f", vec![track(Some("t"), &[0.0])], vec![]);
let track_id = original.trk[0].id;
let segment_id = original.trk[0].trkseg[0].id;
let (mut fx, id) = fixture(original);
let before = fx.files[&id].clone();
assert_eq!(
Extract.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
fx.selection = Selection::Track {
file_id: id,
trk_ids: [track_id].into(),
};
assert_eq!(
Extract.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
for selection in [
Selection::Empty,
Selection::Waypoints { file_id: id },
Selection::TrackSegment {
file_id: id,
trk_id: track_id,
trkseg_ids: [segment_id].into(),
},
] {
fx.selection = selection;
assert_eq!(
Extract.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
assert!(Rc::ptr_eq(&fx.files[&id], &before));
} }
} }
+1
View File
@@ -633,6 +633,7 @@ pub fn extract() -> bool {
edit(Command::Extract(engine::Extract)) edit(Command::Extract(engine::Extract))
} }
/// Sets the elevation of the trackpoints of the selection, one elevation per trackpoint.
#[wasm_bindgen] #[wasm_bindgen]
pub fn elevation(ele: &[f64]) -> bool { pub fn elevation(ele: &[f64]) -> bool {
edit(Command::Elevation(engine::Elevation { ele })) edit(Command::Elevation(engine::Elevation { ele }))
@@ -3,15 +3,28 @@
import Help from '$lib/components/Help.svelte'; import Help from '$lib/components/Help.svelte';
import { MountainSnow } from '@lucide/svelte'; import { MountainSnow } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { getURLForLanguage } from '$lib/utils'; import { getElevation, getURLForLanguage } from '$lib/utils';
import { selection } from '$lib/logic/selection'; import { engine } from '$lib/engine';
import { fileActions } from '$lib/logic/file-actions'; import { get } from 'svelte/store';
let props: { let props: {
class?: string; class?: string;
} = $props(); } = $props();
let validSelection = $derived($selection.size > 0); const statistics = engine.statistics;
let validSelection = $derived($statistics.length > 0);
async function addElevation() {
const stats = get(statistics);
const points = Array.from(stats.lng, (lon, i) => ({ lon, lat: stats.lat[i] }));
const ele = await getElevation(points);
// the selection or the files may have changed while the elevation was loading
if (get(statistics) === stats) {
await engine.setElevation(Float64Array.from(ele));
}
}
</script> </script>
<div class="flex flex-col gap-3 w-full max-w-80 {props.class ?? ''}"> <div class="flex flex-col gap-3 w-full max-w-80 {props.class ?? ''}">
@@ -19,7 +32,7 @@
variant="outline" variant="outline"
class="whitespace-normal h-fit min-h-8 py-1" class="whitespace-normal h-fit min-h-8 py-1"
disabled={!validSelection} disabled={!validSelection}
onclick={() => fileActions.addElevationToSelection()} onclick={() => addElevation()}
> >
<MountainSnow size="16" class="shrink-0" /> <MountainSnow size="16" class="shrink-0" />
{i18n._('toolbar.elevation.button')} {i18n._('toolbar.elevation.button')}
@@ -1,51 +1,54 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Ungroup } from '@lucide/svelte'; import { Ungroup } from '@lucide/svelte';
import {
ListFileItem,
ListTrackItem,
ListTrackSegmentItem,
ListWaypointItem,
ListWaypointsItem,
} from '$lib/components/file-list/file-list';
import Help from '$lib/components/Help.svelte'; import Help from '$lib/components/Help.svelte';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
import { selection } from '$lib/logic/selection'; import { get } from 'svelte/store';
import { fileStateCollection } from '$lib/logic/file-state'; import { engine } from '$lib/engine';
import { fileActions } from '$lib/logic/file-actions';
let props: { let props: {
class?: string; class?: string;
} = $props(); } = $props();
let validSelection = $derived( const selection = engine.selection;
$selection.size > 0 && const files = engine.files;
$selection.getSelected().every((item) => { const statistics = engine.statistics;
if (
item instanceof ListWaypointsItem || /** The number of segments of a file or of one of its tracks. */
item instanceof ListWaypointItem || function segmentCount(fileId: string, trackId?: string) {
item instanceof ListTrackSegmentItem const file = $files.get(fileId);
) { const tracks = file ? get(file).structure.tracks : [];
return false; return tracks
} .filter((track) => trackId === undefined || track.id === trackId)
let file = fileStateCollection.getFile(item.getFileId()); .reduce((count, track) => count + track.segments.length, 0);
if (file) { }
if (item instanceof ListFileItem) {
return file.getSegments().length > 1; // files or tracks, which all have several segments
} else if (item instanceof ListTrackItem) { let validSelection = $derived.by(() => {
if (item.getTrackIndex() < file.trk.length) { // what is selected can change without the selection: the files are edited
return file.trk[item.getTrackIndex()].getSegments().length > 1; void $statistics;
} switch ($selection.type) {
} case 'file':
} return (
$selection.fileIds.length > 0 &&
$selection.fileIds.every((fileId) => segmentCount(fileId) > 1)
);
case 'track': {
const { fileId, trackIds } = $selection;
return (
trackIds.length > 0 &&
trackIds.every((trackId) => segmentCount(fileId, trackId) > 1)
);
}
default:
return false; return false;
}) }
); });
</script> </script>
<div class="flex flex-col gap-3 w-full max-w-80 {props.class ?? ''}"> <div class="flex flex-col gap-3 w-full max-w-80 {props.class ?? ''}">
<Button variant="outline" disabled={!validSelection} onclick={fileActions.extractSelection}> <Button variant="outline" disabled={!validSelection} onclick={() => engine.extract()}>
<Ungroup size="16" /> <Ungroup size="16" />
{i18n._('toolbar.extract.button')} {i18n._('toolbar.extract.button')}
</Button> </Button>
+18
View File
@@ -654,6 +654,24 @@ class Engine {
return this.run((w) => w.reverse()); return this.run((w) => w.reverse());
} }
/**
* Splits the selected files and tracks that have several segments into elements that have
* one each (one file per track or per segment, one track per segment), which get selected.
* Resolves to `false` when there is nothing to extract.
*/
extract() {
return this.run((w) => w.extract());
}
/**
* Sets the elevation of the trackpoints of the selection, one elevation (m) per trackpoint, in
* the order of the statistics. Resolves to `false` when there is nothing to set, or when the
* number of elevations is not the number of trackpoints.
*/
setElevation(ele: Float64Array) {
return this.run((w) => w.elevation(ele));
}
/** /**
* Merges the selected files, tracks or segments into the first of them (see `MergeType`). With * 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. * `removeGaps`, the parts that are connected get closer in time if they have timestamps.
-152
View File
@@ -99,90 +99,6 @@ export async function loadFiles(list: FileList | File[]): Promise<string[]> {
// Helper functions for file operations // Helper functions for file operations
export const fileActions = { export const fileActions = {
extractSelection: () => {
return fileActionManager.applyGlobal((draft) => {
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
if (level === ListLevel.FILE) {
let file = fileStateCollection.getFile(fileId);
let statistics = fileStateCollection.getStatistics(fileId);
if (file && statistics) {
if (file.trk.length > 1) {
let fileIds = getFileIds(file.trk.length);
let closest = file.wpt.map((wpt) =>
getClosestTrackSegments(file, statistics, wpt.getCoordinates())
);
file.trk.forEach((track, index) => {
let newFile = file.clone();
let tracks = track.trkseg.map((segment, segmentIndex) => {
let t = track.clone();
t.replaceTrackSegments(0, track.trkseg.length - 1, [segment]);
if (track.name) {
t.name = `${track.name} (${segmentIndex + 1})`;
}
return t;
});
newFile.replaceTracks(0, file.trk.length - 1, tracks);
newFile.replaceWaypoints(
0,
file.wpt.length - 1,
file.wpt.filter((wpt, wptIndex) =>
closest[wptIndex].some(
([trackIndex, segmentIndex]) => trackIndex === index
)
)
);
newFile._data.id = fileIds[index];
newFile.metadata.name =
track.name ?? `${file.metadata.name} (${index + 1})`;
draft.set(newFile._data.id, freeze(newFile));
});
} else if (file.trk.length === 1) {
let fileIds = getFileIds(file.trk[0].trkseg.length);
let closest = file.wpt.map((wpt) =>
getClosestTrackSegments(file, statistics, wpt.getCoordinates())
);
file.trk[0].trkseg.forEach((segment, index) => {
let newFile = file.clone();
newFile.replaceTrackSegments(0, 0, file.trk[0].trkseg.length - 1, [
segment,
]);
newFile.replaceWaypoints(
0,
file.wpt.length - 1,
file.wpt.filter((wpt, wptIndex) =>
closest[wptIndex].some(
([trackIndex, segmentIndex]) => segmentIndex === index
)
)
);
newFile._data.id = fileIds[index];
newFile.metadata.name = `${file.trk[0].name ?? file.metadata.name} (${index + 1})`;
draft.set(newFile._data.id, freeze(newFile));
});
}
draft.delete(fileId);
}
} else if (level === ListLevel.TRACK) {
let file = draft.get(fileId);
if (file) {
for (let item of items) {
let trackIndex = (item as ListTrackItem).getTrackIndex();
let track = file.trk[trackIndex];
let tracks = track.trkseg.map((segment, segmentIndex) => {
let t = track.clone();
t.replaceTrackSegments(0, track.trkseg.length - 1, [segment]);
if (track.name) {
t.name = `${track.name} (${segmentIndex + 1})`;
}
return t;
});
file.replaceTracks(trackIndex, trackIndex, tracks);
}
}
}
});
});
},
reduce: (itemsAndPoints: Map<ListItem, TrackPoint[]>) => { reduce: (itemsAndPoints: Map<ListItem, TrackPoint[]>) => {
if (itemsAndPoints.size === 0) { if (itemsAndPoints.size === 0) {
return; return;
@@ -214,72 +130,4 @@ export const fileActions = {
}); });
}); });
}, },
addElevationToSelection: async () => {
if (get(selection).size === 0) {
return;
}
let points: (TrackPoint | Waypoint)[] = [];
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
let file = fileStateCollection.getFile(fileId);
if (file) {
if (level === ListLevel.FILE) {
points.push(...file.getTrackPoints());
points.push(...file.wpt);
} else if (level === ListLevel.TRACK) {
let trackIndices = items.map((item) => (item as ListTrackItem).getTrackIndex());
trackIndices.forEach((trackIndex) => {
points.push(...file.trk[trackIndex].getTrackPoints());
});
} else if (level === ListLevel.SEGMENT) {
let trackIndex = (items[0] as ListTrackSegmentItem).getTrackIndex();
let segmentIndices = items.map((item) =>
(item as ListTrackSegmentItem).getSegmentIndex()
);
segmentIndices.forEach((segmentIndex) => {
points.push(...file.trk[trackIndex].trkseg[segmentIndex].getTrackPoints());
});
} else if (level === ListLevel.WAYPOINTS) {
points.push(...file.wpt);
} else if (level === ListLevel.WAYPOINT) {
let waypointIndices = items.map((item) =>
(item as ListWaypointItem).getWaypointIndex()
);
points.push(...waypointIndices.map((waypointIndex) => file.wpt[waypointIndex]));
}
}
});
if (points.length === 0) {
return;
}
getElevation(points).then((elevations) => {
fileActionManager.applyGlobal((draft) => {
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
let file = draft.get(fileId);
if (file) {
if (level === ListLevel.FILE) {
file.addElevation(elevations);
} else if (level === ListLevel.TRACK) {
let trackIndices = items.map((item) =>
(item as ListTrackItem).getTrackIndex()
);
file.addElevation(elevations, trackIndices, undefined, []);
} else if (level === ListLevel.SEGMENT) {
let trackIndices = [(items[0] as ListTrackSegmentItem).getTrackIndex()];
let segmentIndices = items.map((item) =>
(item as ListTrackSegmentItem).getSegmentIndex()
);
file.addElevation(elevations, trackIndices, segmentIndices, []);
} else if (level === ListLevel.WAYPOINTS) {
file.addElevation(elevations, [], [], undefined);
} else if (level === ListLevel.WAYPOINT) {
let waypointIndices = items.map((item) =>
(item as ListWaypointItem).getWaypointIndex()
);
file.addElevation(elevations, [], [], waypointIndices);
}
}
});
});
});
},
}; };