mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-09 23:26:31 +00:00
reduce
This commit is contained in:
1 parent
b29c4cdb08
commit
bec1ce4c9b
13 files changed
+370
-213
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
core::gpx::{TrackSegment, TrackSegmentIndex},
|
core::gpx::{TrackSegment, TrackSegmentIndex},
|
||||||
core::utils::crossarc,
|
core::utils::{crossarc, crossarc_lnglat},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn ramer_douglas_peucker<F>(
|
pub fn ramer_douglas_peucker<F>(
|
||||||
@@ -86,6 +86,85 @@ fn ramer_douglas_peucker_helper<M>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lowest tolerance (in meters) of the reduction of a segment, see [`reduction_distances`].
|
||||||
|
pub const MIN_REDUCE_TOLERANCE: f64 = 0.1;
|
||||||
|
|
||||||
|
/// The distance (in meters) from a trackpoint to the line between two others of a segment.
|
||||||
|
fn lnglat_measure(
|
||||||
|
trkseg: &TrackSegment,
|
||||||
|
) -> impl Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64 + '_ {
|
||||||
|
move |start, end, idx| {
|
||||||
|
crossarc_lnglat(
|
||||||
|
trkseg[start].coordinates,
|
||||||
|
trkseg[end].coordinates,
|
||||||
|
trkseg[idx].coordinates,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The indices of the trackpoints of a segment that are kept when it is reduced with `tolerance`
|
||||||
|
/// meters (Ramer-Douglas-Peucker), in order. The ends are always kept.
|
||||||
|
pub fn reduce_indices(trkseg: &TrackSegment, tolerance: f64) -> Vec<usize> {
|
||||||
|
ramer_douglas_peucker_by(trkseg, &lnglat_measure(trkseg), tolerance)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(idx, _)| idx.flat)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For each trackpoint of a segment, the tolerance (in meters) up to which it is kept by
|
||||||
|
/// [`reduce_indices`]: infinite for the ends, which are always kept, zero for the trackpoints that
|
||||||
|
/// are not selected even at [`MIN_REDUCE_TOLERANCE`]. A trackpoint is kept with a tolerance if its
|
||||||
|
/// value is greater than it.
|
||||||
|
///
|
||||||
|
/// A trackpoint is only selected if the ones that split the path before it are, so its value is
|
||||||
|
/// the smallest distance at which it or one of them was selected.
|
||||||
|
pub fn reduction_distances(trkseg: &TrackSegment) -> Vec<f64> {
|
||||||
|
let mut distances = vec![0.0; trkseg.len()];
|
||||||
|
let (Some(first), Some(last)) = (trkseg.first_index(), trkseg.last_index()) else {
|
||||||
|
return distances;
|
||||||
|
};
|
||||||
|
distances[first.flat] = f64::INFINITY;
|
||||||
|
distances[last.flat] = f64::INFINITY;
|
||||||
|
if trkseg.len() > 2 {
|
||||||
|
let measure = lnglat_measure(trkseg);
|
||||||
|
reduction_distances_helper(trkseg, first, last, &measure, f64::INFINITY, &mut distances);
|
||||||
|
}
|
||||||
|
distances
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reduction_distances_helper<M>(
|
||||||
|
trkseg: &TrackSegment,
|
||||||
|
start: TrackSegmentIndex,
|
||||||
|
end: TrackSegmentIndex,
|
||||||
|
measure: &M,
|
||||||
|
bound: f64,
|
||||||
|
distances: &mut [f64],
|
||||||
|
) where
|
||||||
|
M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64,
|
||||||
|
{
|
||||||
|
let mut max_idx = None;
|
||||||
|
let mut max_dist = 0.0;
|
||||||
|
let mut cur = trkseg.next_index(Some(start));
|
||||||
|
while let Some(idx) = cur {
|
||||||
|
if idx == end {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let dist = measure(start, end, idx);
|
||||||
|
if dist > max_dist {
|
||||||
|
max_idx = Some(idx);
|
||||||
|
max_dist = dist;
|
||||||
|
}
|
||||||
|
cur = trkseg.next_index(cur);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(idx) = max_idx.filter(|_| max_dist > MIN_REDUCE_TOLERANCE) {
|
||||||
|
let distance = max_dist.min(bound);
|
||||||
|
distances[idx.flat] = distance;
|
||||||
|
reduction_distances_helper(trkseg, start, idx, measure, distance, distances);
|
||||||
|
reduction_distances_helper(trkseg, idx, end, measure, distance, distances);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::{Trackpoint, TrackpointChunk};
|
use crate::{Trackpoint, TrackpointChunk};
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ use crate::{Apply, CommandError, State};
|
|||||||
use crate::{
|
use crate::{
|
||||||
ChangeLoopStart, Clean, Crop, Delete, DeleteAll, DeleteWaypoint, Duplicate, EditWaypoint,
|
ChangeLoopStart, Clean, Crop, Delete, DeleteAll, DeleteWaypoint, Duplicate, EditWaypoint,
|
||||||
Elevation, Extract, InsertAnchor, Load, LoadFiles, Merge, Metadata, Move, MoveWaypoint, New,
|
Elevation, Extract, InsertAnchor, Load, LoadFiles, Merge, Metadata, Move, MoveWaypoint, New,
|
||||||
NewTrack, NewTrackSegment, NewWaypoint, Paste, Reverse, RoundTrip, Route, Split, Style, Time,
|
NewTrack, NewTrackSegment, NewWaypoint, Paste, Reduce, Reverse, RoundTrip, Route, Split, Style,
|
||||||
|
Time,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// A user action that edits the files. Each variant wraps the command's own struct, whose
|
/// A user action that edits the files. Each variant wraps the command's own struct, whose
|
||||||
@@ -32,6 +33,7 @@ pub enum Command<'a> {
|
|||||||
EditWaypoint(EditWaypoint<'a>),
|
EditWaypoint(EditWaypoint<'a>),
|
||||||
MoveWaypoint(MoveWaypoint),
|
MoveWaypoint(MoveWaypoint),
|
||||||
Crop(Crop),
|
Crop(Crop),
|
||||||
|
Reduce(Reduce),
|
||||||
Split(Split),
|
Split(Split),
|
||||||
Time(Time),
|
Time(Time),
|
||||||
Merge(Merge),
|
Merge(Merge),
|
||||||
@@ -65,6 +67,7 @@ impl Apply for Command<'_> {
|
|||||||
Command::EditWaypoint(c) => c.apply(state),
|
Command::EditWaypoint(c) => c.apply(state),
|
||||||
Command::MoveWaypoint(c) => c.apply(state),
|
Command::MoveWaypoint(c) => c.apply(state),
|
||||||
Command::Crop(c) => c.apply(state),
|
Command::Crop(c) => c.apply(state),
|
||||||
|
Command::Reduce(c) => c.apply(state),
|
||||||
Command::Split(c) => c.apply(state),
|
Command::Split(c) => c.apply(state),
|
||||||
Command::Time(c) => c.apply(state),
|
Command::Time(c) => c.apply(state),
|
||||||
Command::Merge(c) => c.apply(state),
|
Command::Merge(c) => c.apply(state),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ mod insert_anchor;
|
|||||||
mod merge;
|
mod merge;
|
||||||
mod move_waypoint;
|
mod move_waypoint;
|
||||||
mod new_waypoint;
|
mod new_waypoint;
|
||||||
|
mod reduce;
|
||||||
mod reverse;
|
mod reverse;
|
||||||
mod round_trip;
|
mod round_trip;
|
||||||
mod route;
|
mod route;
|
||||||
@@ -24,6 +25,7 @@ pub use insert_anchor::*;
|
|||||||
pub use merge::*;
|
pub use merge::*;
|
||||||
pub use move_waypoint::*;
|
pub use move_waypoint::*;
|
||||||
pub use new_waypoint::*;
|
pub use new_waypoint::*;
|
||||||
|
pub use reduce::*;
|
||||||
pub use reverse::*;
|
pub use reverse::*;
|
||||||
pub use round_trip::*;
|
pub use round_trip::*;
|
||||||
pub use route::*;
|
pub use route::*;
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::{Apply, CommandError, File, State, Trackpoint, reduce_indices, reduction_distances};
|
||||||
|
|
||||||
|
/// Removes the trackpoints of the selected segments that are less than `tolerance` meters away
|
||||||
|
/// from the line of their neighbours (Ramer-Douglas-Peucker, see [`reduce_indices`]). The ends of the segments are
|
||||||
|
/// kept.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Reduce {
|
||||||
|
pub tolerance: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Apply for Reduce {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
if !(self.tolerance.is_finite() && self.tolerance > 0.0) {
|
||||||
|
return Err(CommandError::InvalidData(format!(
|
||||||
|
"tolerance {} should be positive",
|
||||||
|
self.tolerance
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let locations = state
|
||||||
|
.selection
|
||||||
|
.segment_locations(state.files, &state.order.0);
|
||||||
|
let mut reduced = vec![];
|
||||||
|
for location in &locations {
|
||||||
|
let segment = &state.files[&location.file_id].trk[location.trk].trkseg[location.seg];
|
||||||
|
let kept = reduce_indices(segment, self.tolerance);
|
||||||
|
if kept.len() == segment.len() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let points: Vec<Trackpoint> = kept.iter().map(|&i| segment[i].clone()).collect();
|
||||||
|
reduced.push((*location, points));
|
||||||
|
}
|
||||||
|
if reduced.is_empty() {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the segments of a file are consecutive
|
||||||
|
for group in reduced.chunk_by(|a, b| a.0.file_id == b.0.file_id) {
|
||||||
|
let file_id = group[0].0.file_id;
|
||||||
|
let mut file: File = (*state.files[&file_id]).clone();
|
||||||
|
for (location, points) in group {
|
||||||
|
let segment = &mut file.trk[location.trk].trkseg[location.seg];
|
||||||
|
let len = segment.len();
|
||||||
|
segment.splice(0, len, points.clone());
|
||||||
|
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 count(fx: &Fixture, id: FileId) -> usize {
|
||||||
|
fx.files[&id]
|
||||||
|
.trk
|
||||||
|
.iter()
|
||||||
|
.flat_map(|trk| &trk.trkseg)
|
||||||
|
.map(|seg| seg.len())
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_a_larger_tolerance_keeps_fewer_points_and_the_ends() {
|
||||||
|
let (mut fx, id) = loaded("data/with_time.gpx");
|
||||||
|
let before = count(&fx, id);
|
||||||
|
let first = fx.files[&id].trk[0].trkseg[0][0].coordinates;
|
||||||
|
|
||||||
|
Reduce { tolerance: 5.0 }.apply(&mut fx.state()).unwrap();
|
||||||
|
let medium = count(&fx, id);
|
||||||
|
assert!(medium < before);
|
||||||
|
assert_eq!(fx.files[&id].trk[0].trkseg[0][0].coordinates.lng, first.lng);
|
||||||
|
|
||||||
|
Reduce { tolerance: 500.0 }.apply(&mut fx.state()).unwrap();
|
||||||
|
assert!(count(&fx, id) < medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_the_distances_tell_which_points_are_kept() {
|
||||||
|
for path in ["data/with_time.gpx", "data/with_tracks_and_segments.gpx"] {
|
||||||
|
let (fx, id) = loaded(path);
|
||||||
|
for seg in fx.files[&id].trk.iter().flat_map(|trk| &trk.trkseg) {
|
||||||
|
let distances = reduction_distances(seg);
|
||||||
|
for tolerance in [0.1, 0.5, 1.0, 3.0, 10.0, 30.0, 100.0, 1000.0] {
|
||||||
|
let from_distances: Vec<usize> = (0..seg.len())
|
||||||
|
.filter(|&i| distances[i] > tolerance)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
from_distances,
|
||||||
|
reduce_indices(seg, tolerance),
|
||||||
|
"{path} {tolerance}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_only_the_selected_segments_are_reduced() {
|
||||||
|
let (mut fx, id) = loaded("data/with_time.gpx");
|
||||||
|
let others: usize = fx.files[&id]
|
||||||
|
.trk
|
||||||
|
.iter()
|
||||||
|
.flat_map(|t| &t.trkseg)
|
||||||
|
.skip(1)
|
||||||
|
.map(|s| s.len())
|
||||||
|
.sum();
|
||||||
|
let expected = reduce_indices(&fx.files[&id].trk[0].trkseg[0], 5.0).len();
|
||||||
|
fx.selection = {
|
||||||
|
let track = &fx.files[&id].trk[0];
|
||||||
|
Selection::TrackSegment {
|
||||||
|
file_id: id,
|
||||||
|
trk_id: track.id,
|
||||||
|
trkseg_ids: HashSet::from([track.trkseg[0].id]),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Reduce { tolerance: 5.0 }.apply(&mut fx.state()).unwrap();
|
||||||
|
assert_eq!(count(&fx, id), expected + others);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_tolerance_and_nothing_to_remove() {
|
||||||
|
let (mut fx, _) = loaded("data/simple.gpx");
|
||||||
|
assert!(matches!(
|
||||||
|
Reduce { tolerance: 0.0 }.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::InvalidData(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
Reduce { tolerance: 1e-9 }.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Apply, CommandError, File, SegmentLocation, State, Statistics, TrackSegment, Trackpoint, artificial_weights,
|
Apply, CommandError, File, SegmentLocation, State, Statistics, TrackSegment, Trackpoint,
|
||||||
shifted_and_compressed, with_artificial_timestamps, with_timestamps,
|
artificial_weights, shifted_and_compressed, with_artificial_timestamps, with_timestamps,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// How the timestamps of the selection are set.
|
/// How the timestamps of the selection are set.
|
||||||
@@ -50,7 +50,10 @@ impl Apply for Time {
|
|||||||
let segment_at = |location: &SegmentLocation| -> &TrackSegment {
|
let segment_at = |location: &SegmentLocation| -> &TrackSegment {
|
||||||
&state.files[&location.file_id].trk[location.trk].trkseg[location.seg]
|
&state.files[&location.file_id].trk[location.trk].trkseg[location.seg]
|
||||||
};
|
};
|
||||||
if locations.iter().all(|location| segment_at(location).is_empty()) {
|
if locations
|
||||||
|
.iter()
|
||||||
|
.all(|location| segment_at(location).is_empty())
|
||||||
|
{
|
||||||
return Err(CommandError::NothingToDo);
|
return Err(CommandError::NothingToDo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +167,11 @@ mod tests {
|
|||||||
fx.files[&id]
|
fx.files[&id]
|
||||||
.trk
|
.trk
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|trk| trk.trkseg.iter().flat_map(|seg| seg.iter().map(|pt| pt.time)))
|
.flat_map(|trk| {
|
||||||
|
trk.trkseg
|
||||||
|
.iter()
|
||||||
|
.flat_map(|seg| seg.iter().map(|pt| pt.time))
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use crate::{
|
|||||||
Action, Apply, Clipboard, Command, CoordinatesCache, Diff, FileId, FileOrder, FileStructure,
|
Action, Apply, Clipboard, Command, CoordinatesCache, Diff, FileId, FileOrder, FileStructure,
|
||||||
FileStructureCache, GlobalStatistics, RoutingBuffer, SelectMode, Selection, Stack, State,
|
FileStructureCache, GlobalStatistics, RoutingBuffer, SelectMode, Selection, Stack, State,
|
||||||
StatisticsBuffer, StatisticsCache, TrackSegmentId, Trackpoint, TrackpointCategories, Waypoint,
|
StatisticsBuffer, StatisticsCache, TrackSegmentId, Trackpoint, TrackpointCategories, Waypoint,
|
||||||
WaypointId,
|
WaypointId, reduction_distances,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
@@ -101,6 +101,23 @@ impl Engine {
|
|||||||
(index < segment.len()).then(|| &segment[index])
|
(index < segment.len()).then(|| &segment[index])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// For each trackpoint of the selection (numbered as in the statistics), the tolerance up to
|
||||||
|
/// which `Reduce` keeps it, see [`reduction_distances`].
|
||||||
|
pub fn reduction_distances(&self) -> Vec<f64> {
|
||||||
|
let Some(files) = self.stack.current() else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
self.selection
|
||||||
|
.segment_locations(files, &self.order.0)
|
||||||
|
.iter()
|
||||||
|
.flat_map(|location| {
|
||||||
|
reduction_distances(
|
||||||
|
&files[&location.file_id].trk[location.trk].trkseg[location.seg],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether there is something to undo.
|
/// Whether there is something to undo.
|
||||||
pub fn can_undo(&self) -> bool {
|
pub fn can_undo(&self) -> bool {
|
||||||
self.stack.can_undo()
|
self.stack.can_undo()
|
||||||
|
|||||||
@@ -601,6 +601,21 @@ pub fn crop(start: u32, end: u32) -> bool {
|
|||||||
start <= end && edit(Command::Crop(engine::Crop { start, end }))
|
start <= end && edit(Command::Crop(engine::Crop { start, end }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes the trackpoints of the selection that are less than `tolerance` meters away from the
|
||||||
|
/// line of their neighbours, except the ends of the segments.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn reduce(tolerance: f64) -> bool {
|
||||||
|
edit(Command::Reduce(engine::Reduce { tolerance }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For each trackpoint of the selection (numbered as in the statistics), the highest tolerance
|
||||||
|
/// (m) of `reduce` that keeps it: infinite for the ends of the segments.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn reduction_distances() -> Float64Array {
|
||||||
|
with_engine(|e| Float64Array::from(&e.reduction_distances()[..]))
|
||||||
|
.unwrap_or_else(|| Float64Array::new_with_length(0))
|
||||||
|
}
|
||||||
|
|
||||||
/// Cuts the file, the track or the segment (see `SplitType`) of the trackpoint `at` of the
|
/// 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. Does
|
/// selection in two, there: the trackpoint ends the first part and starts the second one. Does
|
||||||
/// nothing, and returns false, if the revision of the routing buffers is not the given one.
|
/// nothing, and returns false, if the revision of the routing buffers is not the given one.
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { isCovered, hasSelectionWithin, type FileTreeNode } from '$lib/selection
|
|||||||
import { isSegmentHidden, isWaypointHidden } from '$lib/file-visibility';
|
import { isSegmentHidden, isWaypointHidden } from '$lib/file-visibility';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/logic/settings';
|
||||||
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||||
import { fileActions } from '$lib/logic/file-actions';
|
|
||||||
import { splitAtPoint } from '$lib/components/toolbar/tools/scissors/scissors';
|
import { splitAtPoint } from '$lib/components/toolbar/tools/scissors/scissors';
|
||||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||||
|
|||||||
@@ -2,26 +2,39 @@
|
|||||||
import { Label } from '$lib/components/ui/label/index.js';
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Slider } from '$lib/components/ui/slider';
|
import { Slider } from '$lib/components/ui/slider';
|
||||||
import { ListRootItem } from '$lib/components/file-list/file-list';
|
|
||||||
import Help from '$lib/components/Help.svelte';
|
import Help from '$lib/components/Help.svelte';
|
||||||
import { Funnel } from '@lucide/svelte';
|
import { Funnel } from '@lucide/svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
import { selection } from '$lib/logic/selection';
|
import { engine } from '$lib/engine';
|
||||||
import { minTolerance, ReducedGPXLayerCollection, tolerance } from './utils.svelte';
|
import { minTolerance, ReducedLayer, tolerance } from './utils.svelte';
|
||||||
|
|
||||||
let props: { class?: string } = $props();
|
let props: { class?: string } = $props();
|
||||||
|
|
||||||
let sliderValue = $state(50);
|
let sliderValue = $state(50);
|
||||||
const maxTolerance = 10000;
|
const maxTolerance = 10000;
|
||||||
|
|
||||||
|
const selection = engine.selection;
|
||||||
|
const statistics = engine.statistics;
|
||||||
|
|
||||||
|
// the preview needs the start of each segment
|
||||||
|
const request = engine.requestStatistics();
|
||||||
|
request.set(['anchors']);
|
||||||
|
|
||||||
let validSelection = $derived(
|
let validSelection = $derived(
|
||||||
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints'])
|
$selection.type === 'file' || $selection.type === 'track' || $selection.type === 'segment'
|
||||||
);
|
);
|
||||||
|
|
||||||
let reducedLayers = new ReducedGPXLayerCollection();
|
let reducedLayer = new ReducedLayer();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// recomputed when the selection or the files change
|
||||||
|
const stats = $statistics;
|
||||||
|
const distances = engine.reductionDistances();
|
||||||
|
reducedLayer.update(stats, distances, $tolerance);
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
tolerance.set(
|
tolerance.set(
|
||||||
@@ -30,7 +43,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
reducedLayers.destroy();
|
request.release();
|
||||||
|
reducedLayer.destroy();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -44,9 +58,9 @@
|
|||||||
</Label>
|
</Label>
|
||||||
<Label class="flex flex-row justify-between">
|
<Label class="flex flex-row justify-between">
|
||||||
<span>{i18n._('toolbar.reduce.number_of_points')}</span>
|
<span>{i18n._('toolbar.reduce.number_of_points')}</span>
|
||||||
<span class="font-normal">{reducedLayers.currentPoints}/{reducedLayers.maxPoints}</span>
|
<span class="font-normal">{reducedLayer.currentPoints}/{reducedLayer.maxPoints}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Button variant="outline" disabled={!validSelection} onclick={() => reducedLayers.reduce()}>
|
<Button variant="outline" disabled={!validSelection} onclick={() => engine.reduce($tolerance)}>
|
||||||
<Funnel size="16" />
|
<Funnel size="16" />
|
||||||
{i18n._('toolbar.reduce.button')}
|
{i18n._('toolbar.reduce.button')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,205 +1,85 @@
|
|||||||
import { ListItem, ListTrackSegmentItem } from '$lib/components/file-list/file-list';
|
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||||
import { fileActions } from '$lib/logic/file-actions';
|
import type { SelectionStatistics } from '$lib/engine';
|
||||||
import { GPXFileStateCollectionObserver, type GPXFileState } from '$lib/logic/file-state';
|
|
||||||
import { selection } from '$lib/logic/selection';
|
|
||||||
import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx';
|
|
||||||
import type { GeoJSONSource } from 'maplibre-gl';
|
import type { GeoJSONSource } from 'maplibre-gl';
|
||||||
import { get, writable } from 'svelte/store';
|
import { get, writable } from 'svelte/store';
|
||||||
|
|
||||||
|
/** Lowest tolerance (m) of the reduction, the one of the engine. */
|
||||||
export const minTolerance = 0.1;
|
export const minTolerance = 0.1;
|
||||||
|
|
||||||
export class ReducedGPXLayer {
|
|
||||||
private _fileState: GPXFileState;
|
|
||||||
private _updateSimplified: (
|
|
||||||
itemId: string,
|
|
||||||
data: [ListItem, number, SimplifiedTrackPoint[]]
|
|
||||||
) => void;
|
|
||||||
private _unsubscribes: (() => void)[] = [];
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
fileState: GPXFileState,
|
|
||||||
updateSimplified: (itemId: string, data: [ListItem, number, SimplifiedTrackPoint[]]) => void
|
|
||||||
) {
|
|
||||||
this._fileState = fileState;
|
|
||||||
this._updateSimplified = updateSimplified;
|
|
||||||
this._unsubscribes.push(this._fileState.subscribe(() => this.update()));
|
|
||||||
}
|
|
||||||
|
|
||||||
update() {
|
|
||||||
const file = this._fileState.file;
|
|
||||||
if (!file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
|
||||||
let segmentItem = new ListTrackSegmentItem(file._data.id, trackIndex, segmentIndex);
|
|
||||||
this._updateSimplified(segmentItem.getFullId(), [
|
|
||||||
segmentItem,
|
|
||||||
segment.trkpt.length,
|
|
||||||
ramerDouglasPeucker(segment.trkpt, minTolerance),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const tolerance = writable<number>(0);
|
export const tolerance = writable<number>(0);
|
||||||
|
|
||||||
export class ReducedGPXLayerCollection {
|
const SOURCE = 'simplified';
|
||||||
private _layers: Map<string, ReducedGPXLayer> = new Map();
|
|
||||||
private _simplified: Map<string, [ListItem, number, SimplifiedTrackPoint[]]>;
|
|
||||||
private _currentPoints = $state(0);
|
|
||||||
private _maxPoints = $state(0);
|
|
||||||
private _fileStateCollectionObserver: GPXFileStateCollectionObserver;
|
|
||||||
private _updateSimplified = this.updateSimplified.bind(this);
|
|
||||||
private _unsubscribes: (() => void)[] = [];
|
|
||||||
|
|
||||||
constructor() {
|
/**
|
||||||
this._layers = new Map();
|
* Draws on the map what the selection would be reduced to with a tolerance, from the distances of
|
||||||
this._simplified = new Map();
|
* the engine (see `Engine.reductionDistances`). It is the same as the result of `Engine.reduce`.
|
||||||
this._fileStateCollectionObserver = new GPXFileStateCollectionObserver(
|
*/
|
||||||
(newFiles) => {
|
export class ReducedLayer {
|
||||||
newFiles.forEach((fileState, fileId) => {
|
/** The number of trackpoints kept by the tolerance. */
|
||||||
this._layers.set(
|
currentPoints = $state(0);
|
||||||
fileId,
|
/** The number of trackpoints of the selection. */
|
||||||
new ReducedGPXLayer(fileState, this._updateSimplified)
|
maxPoints = $state(0);
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
(fileId) => {
|
|
||||||
this._layers.get(fileId)?.destroy();
|
|
||||||
this._layers.delete(fileId);
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
this._layers.forEach((layer) => layer.destroy());
|
|
||||||
this._layers.clear();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
this._unsubscribes.push(selection.subscribe(() => this.update()));
|
|
||||||
this._unsubscribes.push(tolerance.subscribe(() => this.update()));
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSimplified(itemId: string, data: [ListItem, number, SimplifiedTrackPoint[]]) {
|
update(statistics: SelectionStatistics, distances: Float64Array, tolerance: number) {
|
||||||
this._simplified.set(itemId, data);
|
const { lng, lat, length } = statistics;
|
||||||
if (get(selection).hasAnyParent(data[0])) {
|
const starts = statistics.anchors?.segmentStarts ?? new Uint32Array();
|
||||||
this.update();
|
|
||||||
|
const data: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: [] };
|
||||||
|
let current = 0;
|
||||||
|
// distances are for the same selection as the statistics, unless they are about to change
|
||||||
|
const count = Math.min(length, distances.length);
|
||||||
|
for (let i = 0; i < starts.length; i++) {
|
||||||
|
const end = Math.min(i + 1 < starts.length ? starts[i + 1] : length, count);
|
||||||
|
const coordinates: [number, number][] = [];
|
||||||
|
for (let j = starts[i]; j < end; j++) {
|
||||||
|
if (distances[j] > tolerance) {
|
||||||
|
coordinates.push([lng[j], lat[j]]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
current += coordinates.length;
|
||||||
removeSimplified(itemId: string) {
|
|
||||||
if (this._simplified.delete(itemId)) {
|
|
||||||
this.update();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
update() {
|
|
||||||
this._currentPoints = 0;
|
|
||||||
this._maxPoints = 0;
|
|
||||||
|
|
||||||
let data: GeoJSON.FeatureCollection = {
|
|
||||||
type: 'FeatureCollection',
|
|
||||||
features: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
this._simplified.forEach(([item, maxPts, points], itemFullId) => {
|
|
||||||
if (!get(selection).hasAnyParent(item)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._maxPoints += maxPts;
|
|
||||||
|
|
||||||
let current = points.filter(
|
|
||||||
(point) => point.distance === undefined || point.distance >= get(tolerance)
|
|
||||||
);
|
|
||||||
this._currentPoints += current.length;
|
|
||||||
|
|
||||||
data.features.push({
|
data.features.push({
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
geometry: {
|
geometry: { type: 'LineString', coordinates },
|
||||||
type: 'LineString',
|
|
||||||
coordinates: current.map((point) => [
|
|
||||||
point.point.getLongitude(),
|
|
||||||
point.point.getLatitude(),
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
properties: {},
|
properties: {},
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
this.currentPoints = current;
|
||||||
|
this.maxPoints = length;
|
||||||
|
|
||||||
const map_ = get(map);
|
const map_ = get(map);
|
||||||
if (!map_) {
|
if (!map_) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const source = map_.getSource<GeoJSONSource>(SOURCE);
|
||||||
let source: GeoJSONSource | undefined = map_.getSource('simplified');
|
|
||||||
if (source) {
|
if (source) {
|
||||||
source.setData(data);
|
source.setData(data);
|
||||||
} else {
|
} else {
|
||||||
map_.addSource('simplified', {
|
map_.addSource(SOURCE, { type: 'geojson', data });
|
||||||
type: 'geojson',
|
|
||||||
data: data,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (!map_.getLayer('simplified')) {
|
if (!map_.getLayer(SOURCE)) {
|
||||||
map_.addLayer(
|
map_.addLayer(
|
||||||
{
|
{
|
||||||
id: 'simplified',
|
id: SOURCE,
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: 'simplified',
|
source: SOURCE,
|
||||||
paint: {
|
paint: { 'line-color': 'white', 'line-width': 3 },
|
||||||
'line-color': 'white',
|
|
||||||
'line-width': 3,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
ANCHOR_LAYER_KEY.interactions
|
ANCHOR_LAYER_KEY.interactions
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reduce() {
|
|
||||||
let itemsAndPoints = new Map<ListItem, TrackPoint[]>();
|
|
||||||
this._simplified.forEach(([item, maxPts, points], itemFullId) => {
|
|
||||||
if (!get(selection).hasAnyParent(item)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
itemsAndPoints.set(
|
|
||||||
item,
|
|
||||||
points
|
|
||||||
.filter(
|
|
||||||
(point) => point.distance === undefined || point.distance >= get(tolerance)
|
|
||||||
)
|
|
||||||
.map((point) => point.point)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
fileActions.reduce(itemsAndPoints);
|
|
||||||
}
|
|
||||||
|
|
||||||
get currentPoints() {
|
|
||||||
return this._currentPoints;
|
|
||||||
}
|
|
||||||
|
|
||||||
get maxPoints() {
|
|
||||||
return this._maxPoints;
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this._fileStateCollectionObserver.destroy();
|
|
||||||
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
|
|
||||||
|
|
||||||
const map_ = get(map);
|
const map_ = get(map);
|
||||||
if (!map_) {
|
if (!map_) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (map_.getLayer(SOURCE)) {
|
||||||
if (map_.getLayer('simplified')) {
|
map_.removeLayer(SOURCE);
|
||||||
map_.removeLayer('simplified');
|
|
||||||
}
|
}
|
||||||
if (map_.getSource('simplified')) {
|
if (map_.getSource(SOURCE)) {
|
||||||
map_.removeSource('simplified');
|
map_.removeSource(SOURCE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/logic/settings';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
import { engine } from '$lib/engine';
|
import { engine } from '$lib/engine';
|
||||||
import { fileActions, newFileName } from '$lib/logic/file-actions';
|
import { newFileName } from '$lib/logic/file-actions';
|
||||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||||
import { RoutingControls } from './routing-controls';
|
import { RoutingControls } from './routing-controls';
|
||||||
|
|
||||||
|
|||||||
@@ -701,6 +701,24 @@ class Engine {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the trackpoints of the selection that are less than `tolerance` meters away from the
|
||||||
|
* line of their neighbours, except the ends of the segments. Resolves to `false` when there
|
||||||
|
* is nothing to remove.
|
||||||
|
*/
|
||||||
|
reduce(tolerance: number) {
|
||||||
|
return this.run((w) => w.reduce(tolerance));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For each trackpoint of the selection, in the order of the statistics, the highest tolerance
|
||||||
|
* (m) of `reduce` that keeps it: infinite for the ends of the segments. Not requested with
|
||||||
|
* the statistics: ask when they changed.
|
||||||
|
*/
|
||||||
|
reductionDistances(): Float64Array {
|
||||||
|
return this.wasm?.reduction_distances() ?? new Float64Array();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cuts the file, the track or the segment (see `SplitType`) of the trackpoint `at` of the
|
* 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
|
* selection in two, there: the trackpoint ends the first part and starts the second one. The
|
||||||
|
|||||||
@@ -96,38 +96,3 @@ export async function loadFiles(list: FileList | File[]): Promise<string[]> {
|
|||||||
}
|
}
|
||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions for file operations
|
|
||||||
export const fileActions = {
|
|
||||||
reduce: (itemsAndPoints: Map<ListItem, TrackPoint[]>) => {
|
|
||||||
if (itemsAndPoints.size === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fileActionManager.applyGlobal((draft) => {
|
|
||||||
let allItems = Array.from(itemsAndPoints.keys());
|
|
||||||
applyToOrderedItemsFromFile(allItems, (fileId, level, items) => {
|
|
||||||
let file = draft.get(fileId);
|
|
||||||
if (file) {
|
|
||||||
for (let item of items) {
|
|
||||||
if (item instanceof ListTrackSegmentItem) {
|
|
||||||
let trackIndex = item.getTrackIndex();
|
|
||||||
let segmentIndex = item.getSegmentIndex();
|
|
||||||
let points = itemsAndPoints.get(item);
|
|
||||||
if (points) {
|
|
||||||
file.replaceTrackPoints(
|
|
||||||
trackIndex,
|
|
||||||
segmentIndex,
|
|
||||||
0,
|
|
||||||
file.trk[trackIndex].trkseg[
|
|
||||||
segmentIndex
|
|
||||||
].getNumberOfTrackPoints() - 1,
|
|
||||||
points
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Reference in new issue
Block a user