diff --git a/gpx-rs/engine/src/core/algorithm/simplify.rs b/gpx-rs/engine/src/core/algorithm/simplify.rs index 1960211d6..5dfe90de2 100644 --- a/gpx-rs/engine/src/core/algorithm/simplify.rs +++ b/gpx-rs/engine/src/core/algorithm/simplify.rs @@ -1,6 +1,6 @@ use crate::{ core::gpx::{TrackSegment, TrackSegmentIndex}, - core::utils::crossarc, + core::utils::{crossarc, crossarc_lnglat}, }; pub fn ramer_douglas_peucker( @@ -86,6 +86,85 @@ fn ramer_douglas_peucker_helper( } } +/// 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 { + 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 { + 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( + 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)] mod tests { use crate::{Trackpoint, TrackpointChunk}; diff --git a/gpx-rs/engine/src/engine/command/command.rs b/gpx-rs/engine/src/engine/command/command.rs index 2cc48a779..c2ea204ac 100644 --- a/gpx-rs/engine/src/engine/command/command.rs +++ b/gpx-rs/engine/src/engine/command/command.rs @@ -2,7 +2,8 @@ use crate::{Apply, CommandError, State}; use crate::{ ChangeLoopStart, Clean, Crop, Delete, DeleteAll, DeleteWaypoint, Duplicate, EditWaypoint, 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 @@ -32,6 +33,7 @@ pub enum Command<'a> { EditWaypoint(EditWaypoint<'a>), MoveWaypoint(MoveWaypoint), Crop(Crop), + Reduce(Reduce), Split(Split), Time(Time), Merge(Merge), @@ -65,6 +67,7 @@ impl Apply for Command<'_> { Command::EditWaypoint(c) => c.apply(state), Command::MoveWaypoint(c) => c.apply(state), Command::Crop(c) => c.apply(state), + Command::Reduce(c) => c.apply(state), Command::Split(c) => c.apply(state), Command::Time(c) => c.apply(state), Command::Merge(c) => c.apply(state), diff --git a/gpx-rs/engine/src/engine/command/tools/mod.rs b/gpx-rs/engine/src/engine/command/tools/mod.rs index b1aeb8bba..88cb5bb70 100644 --- a/gpx-rs/engine/src/engine/command/tools/mod.rs +++ b/gpx-rs/engine/src/engine/command/tools/mod.rs @@ -8,6 +8,7 @@ mod insert_anchor; mod merge; mod move_waypoint; mod new_waypoint; +mod reduce; mod reverse; mod round_trip; mod route; @@ -24,6 +25,7 @@ pub use insert_anchor::*; pub use merge::*; pub use move_waypoint::*; pub use new_waypoint::*; +pub use reduce::*; pub use reverse::*; pub use round_trip::*; pub use route::*; diff --git a/gpx-rs/engine/src/engine/command/tools/reduce.rs b/gpx-rs/engine/src/engine/command/tools/reduce.rs new file mode 100644 index 000000000..e6fb1f314 --- /dev/null +++ b/gpx-rs/engine/src/engine/command/tools/reduce.rs @@ -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 = 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 = (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) + )); + } +} diff --git a/gpx-rs/engine/src/engine/command/tools/time.rs b/gpx-rs/engine/src/engine/command/tools/time.rs index fe9b73547..aaa290db3 100644 --- a/gpx-rs/engine/src/engine/command/tools/time.rs +++ b/gpx-rs/engine/src/engine/command/tools/time.rs @@ -1,8 +1,8 @@ use std::rc::Rc; use crate::{ - Apply, CommandError, File, SegmentLocation, State, Statistics, TrackSegment, Trackpoint, artificial_weights, - shifted_and_compressed, with_artificial_timestamps, with_timestamps, + Apply, CommandError, File, SegmentLocation, State, Statistics, TrackSegment, Trackpoint, + artificial_weights, shifted_and_compressed, with_artificial_timestamps, with_timestamps, }; /// How the timestamps of the selection are set. @@ -50,7 +50,10 @@ impl Apply for Time { let segment_at = |location: &SegmentLocation| -> &TrackSegment { &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); } @@ -164,7 +167,11 @@ mod tests { fx.files[&id] .trk .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() } diff --git a/gpx-rs/engine/src/engine/engine.rs b/gpx-rs/engine/src/engine/engine.rs index bcbd55105..0100ee43e 100644 --- a/gpx-rs/engine/src/engine/engine.rs +++ b/gpx-rs/engine/src/engine/engine.rs @@ -4,7 +4,7 @@ use crate::{ Action, Apply, Clipboard, Command, CoordinatesCache, Diff, FileId, FileOrder, FileStructure, FileStructureCache, GlobalStatistics, RoutingBuffer, SelectMode, Selection, Stack, State, StatisticsBuffer, StatisticsCache, TrackSegmentId, Trackpoint, TrackpointCategories, Waypoint, - WaypointId, + WaypointId, reduction_distances, }; #[derive(Debug, Default)] @@ -101,6 +101,23 @@ impl Engine { (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 { + 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. pub fn can_undo(&self) -> bool { self.stack.can_undo() diff --git a/gpx-rs/wasm/src/lib.rs b/gpx-rs/wasm/src/lib.rs index f0e35c976..69b957675 100644 --- a/gpx-rs/wasm/src/lib.rs +++ b/gpx-rs/wasm/src/lib.rs @@ -601,6 +601,21 @@ pub fn crop(start: u32, end: u32) -> bool { 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 /// 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. diff --git a/website/src/lib/components/map/gpx-layer/gpx-layer.ts b/website/src/lib/components/map/gpx-layer/gpx-layer.ts index a3c2006c3..d2068275b 100644 --- a/website/src/lib/components/map/gpx-layer/gpx-layer.ts +++ b/website/src/lib/components/map/gpx-layer/gpx-layer.ts @@ -19,7 +19,6 @@ import { isCovered, hasSelectionWithin, type FileTreeNode } from '$lib/selection import { isSegmentHidden, isWaypointHidden } from '$lib/file-visibility'; import { settings } from '$lib/logic/settings'; 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 { mapCursor, MapCursorState } from '$lib/logic/map-cursor'; import { ANCHOR_LAYER_KEY } from '$lib/components/map/style'; diff --git a/website/src/lib/components/toolbar/tools/reduce/Reduce.svelte b/website/src/lib/components/toolbar/tools/reduce/Reduce.svelte index a652c5f13..f6b5144d3 100644 --- a/website/src/lib/components/toolbar/tools/reduce/Reduce.svelte +++ b/website/src/lib/components/toolbar/tools/reduce/Reduce.svelte @@ -2,26 +2,39 @@ import { Label } from '$lib/components/ui/label/index.js'; import { Button } from '$lib/components/ui/button'; import { Slider } from '$lib/components/ui/slider'; - import { ListRootItem } from '$lib/components/file-list/file-list'; import Help from '$lib/components/Help.svelte'; import { Funnel } from '@lucide/svelte'; import { i18n } from '$lib/i18n.svelte'; import WithUnits from '$lib/components/WithUnits.svelte'; import { onDestroy } from 'svelte'; import { getURLForLanguage } from '$lib/utils'; - import { selection } from '$lib/logic/selection'; - import { minTolerance, ReducedGPXLayerCollection, tolerance } from './utils.svelte'; + import { engine } from '$lib/engine'; + import { minTolerance, ReducedLayer, tolerance } from './utils.svelte'; let props: { class?: string } = $props(); let sliderValue = $state(50); 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( - $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(() => { tolerance.set( @@ -30,7 +43,8 @@ }); onDestroy(() => { - reducedLayers.destroy(); + request.release(); + reducedLayer.destroy(); }); @@ -44,9 +58,9 @@ - diff --git a/website/src/lib/components/toolbar/tools/reduce/utils.svelte.ts b/website/src/lib/components/toolbar/tools/reduce/utils.svelte.ts index 525399074..4f30f6247 100644 --- a/website/src/lib/components/toolbar/tools/reduce/utils.svelte.ts +++ b/website/src/lib/components/toolbar/tools/reduce/utils.svelte.ts @@ -1,205 +1,85 @@ -import { ListItem, ListTrackSegmentItem } from '$lib/components/file-list/file-list'; import { map } from '$lib/components/map/map'; import { ANCHOR_LAYER_KEY } from '$lib/components/map/style'; -import { fileActions } from '$lib/logic/file-actions'; -import { GPXFileStateCollectionObserver, type GPXFileState } from '$lib/logic/file-state'; -import { selection } from '$lib/logic/selection'; -import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx'; +import type { SelectionStatistics } from '$lib/engine'; import type { GeoJSONSource } from 'maplibre-gl'; import { get, writable } from 'svelte/store'; +/** Lowest tolerance (m) of the reduction, the one of the engine. */ 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(0); -export class ReducedGPXLayerCollection { - private _layers: Map = new Map(); - private _simplified: Map; - private _currentPoints = $state(0); - private _maxPoints = $state(0); - private _fileStateCollectionObserver: GPXFileStateCollectionObserver; - private _updateSimplified = this.updateSimplified.bind(this); - private _unsubscribes: (() => void)[] = []; +const SOURCE = 'simplified'; - constructor() { - this._layers = new Map(); - this._simplified = new Map(); - this._fileStateCollectionObserver = new GPXFileStateCollectionObserver( - (newFiles) => { - newFiles.forEach((fileState, fileId) => { - this._layers.set( - fileId, - new ReducedGPXLayer(fileState, this._updateSimplified) - ); - }); - }, - (fileId) => { - this._layers.get(fileId)?.destroy(); - this._layers.delete(fileId); - }, - () => { - this._layers.forEach((layer) => layer.destroy()); - this._layers.clear(); +/** + * Draws on the map what the selection would be reduced to with a tolerance, from the distances of + * the engine (see `Engine.reductionDistances`). It is the same as the result of `Engine.reduce`. + */ +export class ReducedLayer { + /** The number of trackpoints kept by the tolerance. */ + currentPoints = $state(0); + /** The number of trackpoints of the selection. */ + maxPoints = $state(0); + + update(statistics: SelectionStatistics, distances: Float64Array, tolerance: number) { + const { lng, lat, length } = statistics; + const starts = statistics.anchors?.segmentStarts ?? new Uint32Array(); + + 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]]); + } } - ); - this._unsubscribes.push(selection.subscribe(() => this.update())); - this._unsubscribes.push(tolerance.subscribe(() => this.update())); - } - - updateSimplified(itemId: string, data: [ListItem, number, SimplifiedTrackPoint[]]) { - this._simplified.set(itemId, data); - if (get(selection).hasAnyParent(data[0])) { - this.update(); - } - } - - 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; - + current += coordinates.length; data.features.push({ type: 'Feature', - geometry: { - type: 'LineString', - coordinates: current.map((point) => [ - point.point.getLongitude(), - point.point.getLatitude(), - ]), - }, + geometry: { type: 'LineString', coordinates }, properties: {}, }); - }); + } + this.currentPoints = current; + this.maxPoints = length; const map_ = get(map); if (!map_) { return; } - - let source: GeoJSONSource | undefined = map_.getSource('simplified'); + const source = map_.getSource(SOURCE); if (source) { source.setData(data); } else { - map_.addSource('simplified', { - type: 'geojson', - data: data, - }); + map_.addSource(SOURCE, { type: 'geojson', data }); } - if (!map_.getLayer('simplified')) { + if (!map_.getLayer(SOURCE)) { map_.addLayer( { - id: 'simplified', + id: SOURCE, type: 'line', - source: 'simplified', - paint: { - 'line-color': 'white', - 'line-width': 3, - }, + source: SOURCE, + paint: { 'line-color': 'white', 'line-width': 3 }, }, ANCHOR_LAYER_KEY.interactions ); } } - reduce() { - let itemsAndPoints = new Map(); - 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() { - this._fileStateCollectionObserver.destroy(); - this._unsubscribes.forEach((unsubscribe) => unsubscribe()); - const map_ = get(map); if (!map_) { return; } - - if (map_.getLayer('simplified')) { - map_.removeLayer('simplified'); + if (map_.getLayer(SOURCE)) { + map_.removeLayer(SOURCE); } - if (map_.getSource('simplified')) { - map_.removeSource('simplified'); + if (map_.getSource(SOURCE)) { + map_.removeSource(SOURCE); } } } diff --git a/website/src/lib/components/toolbar/tools/routing/Routing.svelte b/website/src/lib/components/toolbar/tools/routing/Routing.svelte index 24c3577f3..2f0fcae22 100644 --- a/website/src/lib/components/toolbar/tools/routing/Routing.svelte +++ b/website/src/lib/components/toolbar/tools/routing/Routing.svelte @@ -30,7 +30,7 @@ import { settings } from '$lib/logic/settings'; import { map } from '$lib/components/map/map'; 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 { RoutingControls } from './routing-controls'; diff --git a/website/src/lib/engine.ts b/website/src/lib/engine.ts index 601c462af..04a504c10 100644 --- a/website/src/lib/engine.ts +++ b/website/src/lib/engine.ts @@ -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 * selection in two, there: the trackpoint ends the first part and starts the second one. The diff --git a/website/src/lib/logic/file-actions.ts b/website/src/lib/logic/file-actions.ts index aff8d0ff6..70637c060 100644 --- a/website/src/lib/logic/file-actions.ts +++ b/website/src/lib/logic/file-actions.ts @@ -96,38 +96,3 @@ export async function loadFiles(list: FileList | File[]): Promise { } return ids; } - -// Helper functions for file operations -export const fileActions = { - reduce: (itemsAndPoints: Map) => { - 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 - ); - } - } - } - } - }); - }); - }, -};