Migration to new Rust-based engine (#401)

* wasm first steps

* parsing start

* parsing continued

* progress

* progress

* progress

* progress

* progress

* progress

* progress

* progress

* fixes

* reorganize

* change type

* fix

* progress

* refactoring

* progress

* progress

* progress

* keep style info

* progress

* progress

* remove (copy) in name

* progress

* merge methods into 1

* splice

* progress

* progress

* progress

* cleaning

* progress

* progress

* progress

* visibility

* progress

* progress

* progress

* progress

* progress

* fixes

* progress

* fix markers

* fix

* progress

* progress

* progress

* progress

* progress

* progress

* small fix

* use engine info

* fix popups

* small fix

* progress

* progress

* progress

* clean tool

* handle self closing elements

* reduce data exchanges across boundary

* copy on wasm side to avoid any data corruption

* speed fix

* routing

* refactoring

* reverse

* reverse

* crop

* split

* merge

* progress

* time tool

* fix bounds for waypoints

* reduce

* fix reactivity

* handle corrupt time data

* export

* progress

* remove gpx js package

* remove todo

* update readme

* cleaning

* progress

* fix link fields

* remove unused fields

* refactoring and tests

* import and export of routes, ref #192

* per-chunk categories storage

* cross-tab sync

* outdated state

* refactoring

* reorder methods

* refactoring

* fix

* improve flow

* renaming

* progress

* progress

* fix

* progress

* per file history

* keep original flag

* use history variant

* fix

* progress

* file explorer

* progress

* fix

* sticky folders

* improved sticky folders

* fix sortable

* fix sortable

* fix

* fix tab size

* no file delete on desktop

* fix routing on other file

* fix sorting

* remember language

* open links in browser

* commit missing changes

* avoid selecting on resize

* clarify filename

* explorer actions

* fix folder styling

* drop anywhere in folder

* cleaning

* fixes

* remove focus-ring

* full screen

* small fix

* consistent highlight color

* improved preview

* performance improvements

* sparser data transfers

* sparser data transfers

* show popup once data is loaded

* fix routing control size

* fix

* sparse desktop build

* remove custom ign styles

* use new endpoint for openhikingmap

* strip native menu

* reorder context menu items

* route full support

* route - track conversion

* create route file action

* route export, filter out anchors at high zoom level

* clarify name handling

* move hide & center to view

* routes continued

* update docs

* fix power format, closes #378

* lint fixes

* fix display of additional data with gaps, closes #361

* fix linting issues

* fix import modules

* fix multidrag

* change base to resolve

* fix type errors

* fix layer typings

* fix last type error

* each keys

* improve typings

* deploy to cloudflare

---------

Co-authored-by: vcoppe <vcoppe@hexaly.com>
This commit is contained in:
vcoppeandvcoppe authored and GitHub committed 2026-10-31 18:08:22 +01:00
1 parent 6a0d434371
commit 8fe7d8d64d
484 files changed
+53949 -41152

No files matched your search

+672
View File
@@ -0,0 +1,672 @@
//! The functions that edit the files or change the selection.
use uuid::Uuid;
use super::dto::*;
use crate::{self as engine, Action, Command, Engine, History, LngLat, LngLatBounds};
/// Executes an action and tells what it did.
pub fn execute<H: History>(engine: &mut Engine<H>, action: Action) -> Outcome {
match engine.execute(action) {
Ok(outcome) => Outcome::new(&outcome, None),
Err(error) => Outcome::failed(error.to_string()),
}
}
pub fn edit<H: History>(engine: &mut Engine<H>, command: Command) -> Outcome {
execute(engine, Action::Edit(command))
}
fn file_ids(ids: &[Uuid]) -> Vec<engine::FileId> {
ids.iter().copied().map(engine::FileId).collect()
}
// Selection
/// Selects files. Unknown files are ignored, and selecting nothing deselects everything.
pub fn select<H: History>(
engine: &mut Engine<H>,
file_ids_: &[Uuid],
mode: engine::SelectMode,
) -> Outcome {
select_elements(
engine,
engine::Selection::File {
file_ids: file_ids(file_ids_).into_iter().collect(),
},
mode,
)
}
/// Selects elements: they are merged with the selection when it holds elements of the same kind in
/// the same place (same file, same track for segments), otherwise they replace it.
pub fn select_elements<H: History>(
engine: &mut Engine<H>,
selection: engine::Selection,
mode: engine::SelectMode,
) -> Outcome {
execute(engine, Action::Select { selection, mode })
}
/// Selects all the elements of the same kind as the selected ones, in the same place: all the
/// files, the routes or the tracks of the file, the segments of the track, the waypoints of the file. All the
/// files when nothing is selected.
pub fn select_all<H: History>(engine: &mut Engine<H>) -> Outcome {
execute(engine, Action::SelectAll)
}
/// Moves the selection to the next (`down`) or previous element of the same kind, like the arrow
/// keys do in the file list. With `add` (shift + arrow), the element is added to the selection,
/// otherwise it replaces it.
pub fn arrow_select<H: History>(engine: &mut Engine<H>, down: bool, add: bool) -> Outcome {
execute(engine, Action::ArrowSelect { down, add })
}
pub fn select_routes<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
route_ids: &[Uuid],
mode: engine::SelectMode,
) -> Outcome {
let selection = engine::Selection::Route {
file_id: engine::FileId(file_id),
rte_ids: route_ids.iter().copied().map(engine::RouteId).collect(),
};
select_elements(engine, selection, mode)
}
pub fn select_tracks<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
track_ids: &[Uuid],
mode: engine::SelectMode,
) -> Outcome {
let selection = engine::Selection::Track {
file_id: engine::FileId(file_id),
trk_ids: track_ids.iter().copied().map(engine::TrackId).collect(),
};
select_elements(engine, selection, mode)
}
pub fn select_segments<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
track_id: Uuid,
segment_ids: &[Uuid],
mode: engine::SelectMode,
) -> Outcome {
let selection = engine::Selection::TrackSegment {
file_id: engine::FileId(file_id),
trk_id: engine::TrackId(track_id),
trkseg_ids: segment_ids
.iter()
.copied()
.map(engine::TrackSegmentId)
.collect(),
};
select_elements(engine, selection, mode)
}
/// Selects the node standing for all the waypoints of a file.
pub fn select_waypoint_group<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
mode: engine::SelectMode,
) -> Outcome {
let selection = engine::Selection::Waypoints {
file_id: engine::FileId(file_id),
};
select_elements(engine, selection, mode)
}
pub fn select_waypoints<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
waypoint_ids: &[Uuid],
mode: engine::SelectMode,
) -> Outcome {
let selection = engine::Selection::Waypoint {
file_id: engine::FileId(file_id),
wpt_ids: waypoint_ids
.iter()
.copied()
.map(engine::WaypointId)
.collect(),
};
select_elements(engine, selection, mode)
}
// History
pub fn undo<H: History>(engine: &mut Engine<H>) -> Outcome {
execute(engine, Action::Undo)
}
pub fn redo<H: History>(engine: &mut Engine<H>) -> Outcome {
execute(engine, Action::Redo)
}
// Files
/// Creates a file and selects it. With `route`, it starts with a route. With `lng` and `lat`, it
/// starts with a segment that holds that trackpoint (at `ele`, 0 by default): the one of its route
/// with `route`, of a track otherwise.
pub fn new_file<H: History>(
engine: &mut Engine<H>,
name: &str,
lng: Option<f64>,
lat: Option<f64>,
ele: Option<f64>,
route: bool,
) -> Outcome {
let trackpoint = lng.zip(lat).map(|(lng, lat)| engine::NewTrackpoint {
lng,
lat,
ele: ele.unwrap_or_default(),
});
edit(
engine,
Command::New(engine::New {
name,
trackpoint,
route,
}),
)
}
/// Loads files as a single command (one undo step). The files are in `data`, one after the other,
/// `lengths` being the number of bytes of each one. `names`: the name of each file when its data
/// has none (the name on disk, without extension). The files that cannot be read are skipped, the
/// first one that was read is selected.
pub fn load_files<H: History>(
engine: &mut Engine<H>,
data: &[u8],
lengths: &[usize],
names: &[String],
) -> Outcome {
if lengths.len() != names.len() {
return Outcome::failed(format!(
"{} lengths for {} names",
lengths.len(),
names.len()
));
}
let mut files = Vec::with_capacity(names.len());
let mut offset = 0;
for (length, name) in lengths.iter().zip(names) {
let end = offset + *length;
let Some(data) = data.get(offset..end) else {
return Outcome::failed(format!("the data of {name} is shorter than its length"));
};
files.push(engine::Load { data, name });
offset = end;
}
edit(engine, Command::LoadFiles(engine::LoadFiles { files }))
}
/// Brings changes of the files from the outside in (see [`engine::SyncFiles`]): each file is
/// `(id, data, name)`, and replaces the file of that id, or is added if there is none; `name` is
/// its name when its data has none. The files to `remove` are deleted. The selection is left alone.
pub fn sync_files<H: History>(
engine: &mut Engine<H>,
files: &[(Uuid, &[u8], &str)],
remove: &[Uuid],
) -> Outcome {
let files = files
.iter()
.map(|(id, data, name)| engine::ExternalFile {
id: engine::FileId(*id),
data,
name,
})
.collect();
edit(
engine,
Command::SyncFiles(engine::SyncFiles {
files,
remove: file_ids(remove),
}),
)
}
pub fn duplicate<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::Duplicate(engine::Duplicate))
}
/// Deletes the selected elements. With `whole_files`, the files holding the selected elements are
/// deleted instead, even if only a track or a waypoint is selected.
pub fn delete<H: History>(engine: &mut Engine<H>, whole_files: bool) -> Outcome {
edit(engine, Command::Delete(engine::Delete { whole_files }))
}
pub fn delete_all<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::DeleteAll(engine::DeleteAll))
}
/// Moves the files to `index` among the other files, in the given order (not undoable).
pub fn reorder<H: History>(engine: &mut Engine<H>, file_ids_: &[Uuid], index: usize) -> Outcome {
execute(
engine,
Action::Reorder {
file_ids: file_ids(file_ids_),
index,
},
)
}
/// Names a file in all its states, as if it had always been called so (not undoable): for a file
/// that is written to a file of another name, whose name it takes.
pub fn rename_file<H: History>(engine: &mut Engine<H>, file_id: Uuid, name: &str) -> Outcome {
execute(
engine,
Action::Rename {
file_id: engine::FileId(file_id),
name: name.to_owned(),
},
)
}
/// Puts the selected elements in the clipboard. Nothing happens when nothing is selected.
pub fn copy<H: History>(engine: &mut Engine<H>) -> Outcome {
execute(engine, Action::Copy)
}
/// Like `copy`, but the elements are moved when they are pasted.
pub fn cut<H: History>(engine: &mut Engine<H>) -> Outcome {
execute(engine, Action::Cut)
}
/// Pastes the clipboard according to the selection (see `can_paste`). What is pasted is always the
/// elements as they were when they were copied or cut, even if they were changed or deleted since.
/// It can be pasted several times: what was copied is copied again, what was cut is moved the first
/// time (and the clipboard is not cut after that), then it is copied.
pub fn paste<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::Paste(engine::Paste))
}
/// Moves elements to a place of the file tree, keeping their ids, like dragging and dropping them
/// does. The moved elements are selected.
///
/// Files can only go among the files; tracks among the files (each becomes a file) or the tracks
/// of a file; segments among the files (each becomes a file), the tracks of a file (each becomes
/// a track) or the segments of a track; waypoints, or all the waypoints of a file, among the
/// waypoints of a file.
pub fn move_elements<H: History>(
engine: &mut Engine<H>,
what: Selection,
to: MoveTarget,
) -> Outcome {
edit(
engine,
Command::Move(engine::Move {
what: what.into(),
to: to.into(),
}),
)
}
pub fn metadata<H: History>(engine: &mut Engine<H>, name: &str, desc: &str) -> Outcome {
edit(engine, Command::Metadata(engine::Metadata { name, desc }))
}
/// Only the given fields are changed.
pub fn style<H: History>(
engine: &mut Engine<H>,
color: Option<&str>,
opacity: Option<f64>,
width: Option<f64>,
) -> Outcome {
edit(
engine,
Command::Style(engine::Style {
color,
opacity,
width,
}),
)
}
/// Converts the selected routes to tracks, or the selected tracks or segments to routes.
pub fn convert<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::Convert(engine::Convert))
}
pub fn new_route<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::NewRoute(engine::NewRoute))
}
pub fn new_track<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::NewTrack(engine::NewTrack))
}
pub fn new_track_segment<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::NewTrackSegment(engine::NewTrackSegment))
}
// Waypoints. The strings that are empty are the fields that the waypoint does not have.
/// Adds a waypoint to each selected file (or to the file of the selected elements).
#[allow(clippy::too_many_arguments)]
pub fn new_waypoint<H: History>(
engine: &mut Engine<H>,
lng: f64,
lat: f64,
ele: f64,
name: &str,
desc: &str,
icon: &str,
link: &str,
) -> Outcome {
edit(
engine,
Command::NewWaypoint(engine::NewWaypoint {
lng,
lat,
ele,
name,
desc,
icon,
link,
}),
)
}
/// Moves a waypoint of a file, whatever is selected.
pub fn move_waypoint<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
waypoint_id: Uuid,
lng: f64,
lat: f64,
ele: f64,
) -> Outcome {
edit(
engine,
Command::MoveWaypoint(engine::MoveWaypoint {
file_id: engine::FileId(file_id),
waypoint_id: engine::WaypointId(waypoint_id),
lng,
lat,
ele,
}),
)
}
/// Deletes a waypoint of a file, whatever is selected.
pub fn delete_waypoint<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
waypoint_id: Uuid,
) -> Outcome {
edit(
engine,
Command::DeleteWaypoint(engine::DeleteWaypoint {
file_id: engine::FileId(file_id),
waypoint_id: engine::WaypointId(waypoint_id),
}),
)
}
/// Changes a waypoint of a file, whatever is selected: its name, description, icon, link,
/// position and elevation.
#[allow(clippy::too_many_arguments)]
pub fn update_waypoint<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
waypoint_id: Uuid,
lng: f64,
lat: f64,
ele: f64,
name: &str,
desc: &str,
icon: &str,
link: &str,
) -> Outcome {
edit(
engine,
Command::EditWaypoint(engine::EditWaypoint {
file_id: engine::FileId(file_id),
waypoint_id: engine::WaypointId(waypoint_id),
lng,
lat,
ele,
name,
desc,
icon,
link,
}),
)
}
// Tools
pub fn reverse<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::Reverse(engine::Reverse))
}
/// Adds to each selected segment a reversed copy of itself (without repeating its last point), so
/// that it goes back to where it started.
pub fn round_trip<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::RoundTrip(engine::RoundTrip))
}
/// Keeps the trackpoints `start` to `end` (both included) of the selection, counted over its
/// segments one after the other, and removes the others (and what is left empty).
pub fn crop<H: History>(engine: &mut Engine<H>, start: u32, end: u32) -> Outcome {
if start > end {
return Outcome::failed(format!("the range {start}..{end} is reversed"));
}
edit(engine, 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.
pub fn reduce<H: History>(engine: &mut Engine<H>, tolerance: f64) -> Outcome {
edit(engine, Command::Reduce(engine::Reduce { tolerance }))
}
/// Sets the timestamps of the selection, which starts at `start_time` (ms since the epoch): the
/// trackpoints that have some keep their durations multiplied by `ratio`, the others follow the
/// previous one at `speed` (km/h).
pub fn change_timestamps<H: History>(
engine: &mut Engine<H>,
start_time: f64,
speed: f64,
ratio: f64,
) -> Outcome {
edit(
engine,
Command::Time(engine::Time {
start_time: start_time as i64,
kind: engine::TimeKind::Change { speed, ratio },
}),
)
}
/// Makes up timestamps for the selection, which starts at `start_time` (ms since the epoch) and
/// lasts `total_time` seconds.
pub fn create_artificial_timestamps<H: History>(
engine: &mut Engine<H>,
start_time: f64,
total_time: f64,
) -> Outcome {
edit(
engine,
Command::Time(engine::Time {
start_time: start_time as i64,
kind: engine::TimeKind::Artificial { total_time },
}),
)
}
/// Merges the selection into its first element. `remove_gaps` brings the parts that are connected
/// closer in time, if they have timestamps. Merged files are kept (and the result is a new file) if
/// the history needs it, see [`History::KEEPS_ORIGINALS`].
pub fn merge<H: History>(
engine: &mut Engine<H>,
type_: engine::MergeType,
remove_gaps: bool,
) -> Outcome {
edit(
engine,
Command::Merge(engine::Merge {
type_,
remove_gaps,
keep_originals: H::KEEPS_ORIGINALS,
}),
)
}
/// Extracted files are kept (and the result is new files) if the history needs it, see
/// [`History::KEEPS_ORIGINALS`].
pub fn extract<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(
engine,
Command::Extract(engine::Extract {
keep_originals: H::KEEPS_ORIGINALS,
}),
)
}
/// Sets the elevation of the trackpoints of the selection, one elevation per trackpoint.
pub fn elevation<H: History>(engine: &mut Engine<H>, ele: &[f64]) -> Outcome {
edit(engine, Command::Elevation(engine::Elevation { ele }))
}
/// Removes the trackpoints and/or waypoints of the selection inside (or outside) a rectangle.
#[allow(clippy::too_many_arguments)]
pub fn clean<H: History>(
engine: &mut Engine<H>,
west: f64,
south: f64,
east: f64,
north: f64,
type_: engine::CleanType,
trkpt: bool,
wpt: bool,
) -> Outcome {
edit(
engine,
Command::Clean(engine::Clean {
bounds: LngLatBounds {
sw: LngLat {
lng: west,
lat: south,
},
ne: LngLat {
lng: east,
lat: north,
},
},
type_,
trkpt,
wpt,
}),
)
}
// Routing
//
// The indices of the trackpoints and of the anchors are the ones of the routing buffers: they only
// mean something for the `revision` they were read with (`routing_revision`), and the commands
// that use them do nothing if the selection is not the one of that revision anymore.
/// Makes the call `f`, unless the selection is not the one of the revision anymore.
fn at_revision<H: History>(
engine: &mut Engine<H>,
revision: u32,
f: impl FnOnce(&mut Engine<H>) -> Outcome,
) -> Outcome {
if engine.routing().revision == revision {
f(engine)
} else {
Outcome::failed("the selection changed since the routing buffers were read")
}
}
/// Replaces the trackpoints `start..end` of the selection by the given ones, in a single segment
/// (a pure insertion if `start == end`, a pure removal without new points). `attributes` holds the
/// surface, highway, SAC scale and MTB scale of the new points, as intervals, and `anchors` the
/// indices among the new points of the ones that become anchors.
#[allow(clippy::too_many_arguments)]
pub fn route<H: History>(
engine: &mut Engine<H>,
revision: u32,
start: u32,
end: u32,
lng: &[f64],
lat: &[f64],
ele: &[f64],
attributes: &RouteAttributes,
anchors: &[u32],
) -> Outcome {
if lng.len() != lat.len() || lat.len() != ele.len() {
return Outcome::failed("lng, lat and ele differ in length");
}
at_revision(engine, revision, |engine| {
edit(
engine,
Command::Reroute(engine::Reroute {
start,
end,
lng,
lat,
ele,
surface: attributes.surface.view(),
highway: attributes.highway.view(),
sac_scale: attributes.sac_scale.view(),
mtb_scale: attributes.mtb_scale.view(),
anchors,
}),
)
})
}
/// Makes an anchor of the point of the selected segments that is the closest to the coordinates,
/// inserting a trackpoint on the path if none is there.
pub fn insert_anchor<H: History>(
engine: &mut Engine<H>,
revision: u32,
lng: f64,
lat: f64,
) -> Outcome {
at_revision(engine, revision, |engine| {
edit(
engine,
Command::InsertAnchor(engine::InsertAnchor { lng, lat }),
)
})
}
/// Makes the trackpoint `index` of the selection the start of its segment, which is a loop (the
/// trackpoints before it go to the end, and the loop closes on it).
pub fn change_loop_start<H: History>(engine: &mut Engine<H>, revision: u32, index: u32) -> Outcome {
at_revision(engine, revision, |engine| {
edit(
engine,
Command::ChangeLoopStart(engine::ChangeLoopStart { index }),
)
})
}
/// Cuts the file, the route, 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. A file that
/// is cut is kept (and its two parts are new files) if the history needs it, see
/// [`History::KEEPS_ORIGINALS`].
pub fn split<H: History>(
engine: &mut Engine<H>,
revision: u32,
at: u32,
split_type: engine::SplitType,
) -> Outcome {
at_revision(engine, revision, |engine| {
edit(
engine,
Command::Split(engine::Split {
at,
split_type,
keep_originals: H::KEEPS_ORIGINALS,
}),
)
})
}
+636
View File
@@ -0,0 +1,636 @@
//! What crosses to the frontend, as plain data (serde): the shapes of the objects that the frontend
//! reads, which `wasm/src/ts.rs` declares in TypeScript. Ids are hyphenated UUID strings, times
//! are numbers (ms since the epoch), absent fields are skipped.
use crate as engine;
use engine::{FileId, History};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub fn ids(items: &[FileId]) -> Vec<String> {
items.iter().map(|id| id.0.to_string()).collect()
}
// Outcome
/// What a call that edits the files or changes the selection did.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Outcome {
pub changed: bool,
order_changed: bool,
selection_changed: bool,
clipboard_changed: bool,
added: Vec<String>,
modified: Vec<String>,
removed: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
/// Whether the files changed in a way that has to be saved (not sent).
#[serde(skip)]
pub saves: bool,
}
impl Outcome {
pub fn new(outcome: &engine::Outcome, error: Option<String>) -> Self {
let diff = outcome.diff.clone().unwrap_or_default();
Self {
changed: outcome.changed,
order_changed: outcome.order_changed,
selection_changed: outcome.selection_changed,
clipboard_changed: outcome.clipboard_changed,
added: ids(&diff.added),
modified: ids(&diff.modified),
removed: ids(&diff.removed),
error,
saves: outcome.changed && (outcome.diff.is_some() || outcome.order_changed),
}
}
/// The outcome of a frontend that knows nothing of the engine: all the files are added, and the
/// order, the selection and the clipboard are to be read.
pub fn everything<H: History>(engine: &engine::Engine<H>) -> Self {
Self {
changed: true,
order_changed: true,
selection_changed: true,
clipboard_changed: true,
added: ids(engine.order()),
modified: vec![],
removed: vec![],
error: None,
saves: false,
}
}
/// The outcome of a call that did nothing and has nothing to say.
pub fn failed_none() -> Self {
Self::new(&engine::Outcome::default(), None)
}
/// The ids of the files that the call added.
pub fn added(&self) -> &[String] {
&self.added
}
/// Whether the call did nothing and says why.
pub fn has_error(&self) -> bool {
self.error.is_some()
}
/// What two calls that were made one after the other did together.
pub fn merge(mut self, other: Self) -> Self {
fn union(into: &mut Vec<String>, from: Vec<String>) {
for id in from {
if !into.contains(&id) {
into.push(id);
}
}
}
self.changed |= other.changed;
self.order_changed |= other.order_changed;
self.selection_changed |= other.selection_changed;
self.clipboard_changed |= other.clipboard_changed;
self.saves |= other.saves;
// a file that was added and then modified is still an added one, and one that was added
// and then removed is not there at all
let added_before = self.added.clone();
union(&mut self.added, other.added);
for id in other.modified {
if !self.added.contains(&id) && !self.modified.contains(&id) {
self.modified.push(id);
}
}
for id in other.removed {
self.added.retain(|added| *added != id);
self.modified.retain(|modified| *modified != id);
if !added_before.contains(&id) && !self.removed.contains(&id) {
self.removed.push(id);
}
}
self.error = self.error.or(other.error);
self
}
/// What a call that does nothing returns.
pub fn failed(message: impl Into<String>) -> Self {
Self::new(&engine::Outcome::default(), Some(message.into()))
}
}
// Selection and clipboard
#[derive(Serialize, Deserialize)]
#[serde(
tag = "type",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum Selection {
Empty,
File {
file_ids: Vec<Uuid>,
},
Route {
file_id: Uuid,
route_ids: Vec<Uuid>,
},
Track {
file_id: Uuid,
track_ids: Vec<Uuid>,
},
Segment {
file_id: Uuid,
track_id: Uuid,
segment_ids: Vec<Uuid>,
},
Waypoints {
file_id: Uuid,
},
Waypoint {
file_id: Uuid,
waypoint_ids: Vec<Uuid>,
},
}
impl From<&engine::Selection> for Selection {
fn from(selection: &engine::Selection) -> Self {
use engine::Selection as S;
match selection {
S::Empty => Self::Empty,
S::File { file_ids } => Self::File {
file_ids: file_ids.iter().map(|id| id.0).collect(),
},
S::Route { file_id, rte_ids } => Self::Route {
file_id: file_id.0,
route_ids: rte_ids.iter().map(|id| id.0).collect(),
},
S::Track { file_id, trk_ids } => Self::Track {
file_id: file_id.0,
track_ids: trk_ids.iter().map(|id| id.0).collect(),
},
S::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => Self::Segment {
file_id: file_id.0,
track_id: trk_id.0,
segment_ids: trkseg_ids.iter().map(|id| id.0).collect(),
},
S::Waypoints { file_id } => Self::Waypoints { file_id: file_id.0 },
S::Waypoint { file_id, wpt_ids } => Self::Waypoint {
file_id: file_id.0,
waypoint_ids: wpt_ids.iter().map(|id| id.0).collect(),
},
}
}
}
impl From<Selection> for engine::Selection {
fn from(selection: Selection) -> Self {
use engine::Selection as S;
match selection {
Selection::Empty => S::Empty,
Selection::File { file_ids } => S::File {
file_ids: file_ids.into_iter().map(FileId).collect(),
},
Selection::Route { file_id, route_ids } => S::Route {
file_id: FileId(file_id),
rte_ids: route_ids.into_iter().map(engine::RouteId).collect(),
},
Selection::Track { file_id, track_ids } => S::Track {
file_id: FileId(file_id),
trk_ids: track_ids.into_iter().map(engine::TrackId).collect(),
},
Selection::Segment {
file_id,
track_id,
segment_ids,
} => S::TrackSegment {
file_id: FileId(file_id),
trk_id: engine::TrackId(track_id),
trkseg_ids: segment_ids
.into_iter()
.map(engine::TrackSegmentId)
.collect(),
},
Selection::Waypoints { file_id } => S::Waypoints {
file_id: FileId(file_id),
},
Selection::Waypoint {
file_id,
waypoint_ids,
} => S::Waypoint {
file_id: FileId(file_id),
wpt_ids: waypoint_ids.into_iter().map(engine::WaypointId).collect(),
},
}
}
}
/// Where moved elements go. A negative index is the start, an infinite one the end (which JSON
/// writes as `null`, so the frontend sends a very large number instead).
#[derive(Deserialize)]
#[serde(
tag = "type",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum MoveTarget {
Files {
index: f64,
},
Routes {
file_id: Uuid,
index: f64,
},
Tracks {
file_id: Uuid,
index: f64,
},
Segments {
file_id: Uuid,
track_id: Uuid,
index: f64,
},
Waypoints {
file_id: Uuid,
index: f64,
},
}
impl From<MoveTarget> for engine::MoveTarget {
fn from(target: MoveTarget) -> Self {
use engine::MoveTarget as T;
let index = |index: f64| index.max(0.0) as usize;
match target {
MoveTarget::Files { index: i } => T::Files { index: index(i) },
MoveTarget::Routes { file_id, index: i } => T::Routes {
file_id: FileId(file_id),
index: index(i),
},
MoveTarget::Tracks { file_id, index: i } => T::Tracks {
file_id: FileId(file_id),
index: index(i),
},
MoveTarget::Segments {
file_id,
track_id,
index: i,
} => T::Segments {
file_id: FileId(file_id),
trk_id: engine::TrackId(track_id),
index: index(i),
},
MoveTarget::Waypoints { file_id, index: i } => T::Waypoints {
file_id: FileId(file_id),
index: index(i),
},
}
}
}
#[derive(Serialize)]
pub struct Clipboard {
#[serde(rename = "type")]
kind: &'static str,
ids: Vec<String>,
cut: bool,
}
impl Clipboard {
pub fn new(clipboard: &engine::Clipboard) -> Self {
let strings = |ids: Vec<Uuid>| ids.iter().map(Uuid::to_string).collect();
let (kind, ids) = match clipboard.content.ids() {
engine::ClipboardIds::Files(ids) => {
("files", strings(ids.into_iter().map(|id| id.0).collect()))
}
engine::ClipboardIds::Routes(ids) => {
("routes", strings(ids.into_iter().map(|id| id.0).collect()))
}
engine::ClipboardIds::Tracks(ids) => {
("tracks", strings(ids.into_iter().map(|id| id.0).collect()))
}
engine::ClipboardIds::Segments(ids) => (
"segments",
strings(ids.into_iter().map(|id| id.0).collect()),
),
engine::ClipboardIds::Waypoints(ids) => (
"waypoints",
strings(ids.into_iter().map(|id| id.0).collect()),
),
};
Self {
kind,
ids,
cut: clipboard.cut,
}
}
}
// Files
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileStructure {
id: String,
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
desc: Option<String>,
routes: Vec<RouteNode>,
tracks: Vec<TrackNode>,
waypoints: Vec<WaypointNode>,
waypoints_rev: String,
}
/// The name, description and style of a route or a track.
#[derive(Serialize)]
struct PathInfo {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
desc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
opacity: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
width: Option<f64>,
}
impl From<&engine::PathInfo> for PathInfo {
fn from(info: &engine::PathInfo) -> Self {
Self {
name: info.name.clone(),
desc: info.desc.clone(),
color: info.color.clone(),
opacity: info.opacity,
width: info.width,
}
}
}
#[derive(Serialize)]
struct RouteNode {
id: String,
#[serde(flatten)]
info: PathInfo,
segment: SegmentNode,
}
#[derive(Serialize)]
struct TrackNode {
id: String,
#[serde(flatten)]
info: PathInfo,
segments: Vec<SegmentNode>,
}
#[derive(Serialize)]
struct SegmentNode {
id: String,
rev: String,
length: f64,
}
impl From<&engine::SegmentNode> for SegmentNode {
fn from(seg: &engine::SegmentNode) -> Self {
Self {
id: seg.id.0.to_string(),
rev: seg.rev_id.0.to_string(),
length: seg.len as f64,
}
}
}
#[derive(Serialize)]
struct WaypointNode {
id: String,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
sym: Option<String>,
}
impl FileStructure {
pub fn new(file: &engine::FileStructure) -> Self {
Self {
id: file.id.0.to_string(),
name: file.name.clone(),
desc: file.desc.clone(),
routes: file
.routes
.iter()
.map(|rte| RouteNode {
id: rte.id.0.to_string(),
info: (&rte.info).into(),
segment: (&rte.segment).into(),
})
.collect(),
tracks: file
.tracks
.iter()
.map(|trk| TrackNode {
id: trk.id.0.to_string(),
info: (&trk.info).into(),
segments: trk.segments.iter().map(Into::into).collect(),
})
.collect(),
waypoints: file
.waypoints
.iter()
.map(|wpt| WaypointNode {
id: wpt.id.0.to_string(),
name: wpt.name.clone(),
sym: wpt.sym.clone(),
})
.collect(),
waypoints_rev: file.wpt_rev_id.0.to_string(),
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GlobalStatistics {
total_distance: f64,
#[serde(skip_serializing_if = "Option::is_none")]
moving_distance: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
total_time: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
moving_time: Option<f64>,
elevation_gain: f64,
elevation_loss: f64,
#[serde(skip_serializing_if = "Option::is_none")]
start_time: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
end_time: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
total_speed: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
moving_speed: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
bounds: Option<Bounds>,
}
#[derive(Serialize)]
struct Bounds {
west: f64,
south: f64,
east: f64,
north: f64,
}
impl GlobalStatistics {
pub fn new(stats: &engine::GlobalStatistics) -> Self {
let seconds = |ms: Option<i64>| ms.map(|ms| ms as f64 / 1000.0);
let (sw, ne) = (&stats.bounds.sw, &stats.bounds.ne);
Self {
total_distance: stats.total_distance,
moving_distance: stats.moving_distance,
total_time: seconds(stats.total_time),
moving_time: seconds(stats.moving_time),
elevation_gain: stats.elevation_gain,
elevation_loss: stats.elevation_loss,
start_time: stats.start_time.map(|t| t as f64),
end_time: stats.end_time.map(|t| t as f64),
total_speed: stats.total_speed(),
moving_speed: stats.moving_speed(),
bounds: (sw.lng <= ne.lng && sw.lat <= ne.lat).then_some(Bounds {
west: sw.lng,
south: sw.lat,
east: ne.lng,
north: ne.lat,
}),
}
}
}
#[derive(Serialize)]
pub struct WaypointDetails {
id: String,
lng: f64,
lat: f64,
ele: f64,
#[serde(skip_serializing_if = "Option::is_none")]
time: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
desc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
cmt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
sym: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
links: Option<Vec<Link>>,
}
#[derive(Serialize)]
struct Link {
href: String,
#[serde(skip_serializing_if = "Option::is_none")]
text: Option<String>,
}
impl WaypointDetails {
pub fn new(wpt: &engine::Waypoint) -> Self {
Self {
id: wpt.id.0.to_string(),
lng: wpt.coordinates.lng,
lat: wpt.coordinates.lat,
ele: wpt.ele,
time: wpt.time.map(|t| t as f64),
name: wpt.name.clone(),
desc: wpt.desc.clone(),
cmt: wpt.cmt.clone(),
sym: wpt.sym.clone(),
links: (!wpt.links.is_empty()).then(|| {
wpt.links
.iter()
.map(|link| Link {
href: link.href.clone(),
text: link.text.clone(),
})
.collect()
}),
}
}
}
#[derive(Serialize)]
pub struct TrackpointDetails {
lng: f64,
lat: f64,
ele: f64,
#[serde(skip_serializing_if = "Option::is_none")]
time: Option<f64>,
}
impl TrackpointDetails {
pub fn new(pt: &engine::Trackpoint) -> Self {
Self {
lng: pt.coordinates.lng,
lat: pt.coordinates.lat,
ele: pt.ele,
time: pt.time.map(|t| t as f64),
}
}
}
/// A trackpoint of the selection: what the statistics buffers do not hold for each trackpoint.
/// Absent fields are unknown.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SelectionPoint {
/// Slope (%) at the trackpoint.
#[serde(skip_serializing_if = "Option::is_none")]
pub slope: Option<f64>,
/// ms since the epoch.
#[serde(skip_serializing_if = "Option::is_none")]
pub time: Option<f64>,
/// Slope (%) and length (km) of the smoothed elevation segment of the trackpoint.
#[serde(skip_serializing_if = "Option::is_none")]
pub slope_segment_slope: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub slope_segment_distance: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub surface: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub highway: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sac_scale: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mtb_scale: Option<String>,
}
// Routing
/// A category of the trackpoints of a route, as in `RouteCategory` of the engine.
#[derive(Default, Deserialize)]
pub struct RouteCategory {
pub starts: Vec<u32>,
pub values: Vec<u8>,
pub names: Vec<String>,
}
impl RouteCategory {
pub fn view(&self) -> engine::RouteCategory<'_> {
engine::RouteCategory {
starts: &self.starts,
values: &self.values,
names: &self.names,
}
}
}
#[derive(Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct RouteAttributes {
pub surface: RouteCategory,
pub highway: RouteCategory,
pub sac_scale: RouteCategory,
pub mtb_scale: RouteCategory,
}
+21
View File
@@ -0,0 +1,21 @@
//! The interface to the engine, which the interfaces to the frontend share: the web page
//! (`wasm`) and the desktop app (`tauri`) each expose these functions, and only convert what
//! crosses to their own way of passing data.
//!
//! - `commands`: the functions that edit the files or change the selection. Each returns an
//! `Outcome`: whether anything changed, what changed (to update what the frontend keeps), and why
//! nothing was done when that is so (the command had nothing to do, the data is invalid, an
//! argument is wrong).
//! - `queries`: the functions that read the state of the engine.
//! - `dto`: the data that crosses, as serde types.
//!
//! Ids are `Uuid`s: how the frontend sends them (strings, bytes) is for the interfaces to parse.
//! Coordinates and statistics are slices of numbers, which the interfaces copy to typed arrays.
pub mod commands;
pub mod dto;
pub mod queries;
pub use commands::*;
pub use dto::*;
pub use queries::*;
+277
View File
@@ -0,0 +1,277 @@
//! The functions that read the state of the engine.
//!
//! The files are described by one ordered list of ids and one structure per file, so that the UI
//! can react to what changed only: after each command, its `Outcome` tells which structures to
//! (re)read or drop and whether the order changed. The trackpoints of a segment and the waypoints
//! of a file are in the same order as the coordinates of their buffers.
use uuid::Uuid;
use super::dto::*;
use crate::{self as engine, Engine, FileId, History};
/// Ids of the files in display order.
pub fn file_order<H: History>(engine: &Engine<H>) -> Vec<String> {
ids(engine.order())
}
/// Structure of a file, `None` if the id is unknown.
pub fn file_structure<H: History>(engine: &Engine<H>, file_id: Uuid) -> Option<FileStructure> {
engine
.file_structure(&FileId(file_id))
.map(FileStructure::new)
}
/// Global statistics of a file, `None` if the id is unknown.
pub fn file_statistics<H: History>(engine: &Engine<H>, file_id: Uuid) -> Option<GlobalStatistics> {
engine
.file_statistics(&FileId(file_id))
.map(|stats| GlobalStatistics::new(&stats))
}
/// The current selection.
pub fn selection<H: History>(engine: &Engine<H>) -> Selection {
engine.selection().into()
}
/// What was copied or cut and is waiting to be pasted.
pub fn clipboard<H: History>(engine: &Engine<H>) -> Option<Clipboard> {
engine.clipboard().map(Clipboard::new)
}
pub fn can_undo<H: History>(engine: &Engine<H>) -> bool {
engine.can_undo()
}
pub fn can_redo<H: History>(engine: &Engine<H>) -> bool {
engine.can_redo()
}
/// Whether the clipboard can be pasted with the current selection.
pub fn can_paste<H: History>(engine: &Engine<H>) -> bool {
engine.can_paste()
}
/// All the data of a waypoint of a file, `None` if it does not exist.
pub fn waypoint<H: History>(
engine: &Engine<H>,
file_id: Uuid,
waypoint_id: Uuid,
) -> Option<WaypointDetails> {
engine
.waypoint(&FileId(file_id), &engine::WaypointId(waypoint_id))
.map(WaypointDetails::new)
}
/// A trackpoint of a segment of a file, `None` if it does not exist.
pub fn trackpoint<H: History>(
engine: &Engine<H>,
file_id: Uuid,
segment_id: Uuid,
index: usize,
) -> Option<TrackpointDetails> {
engine
.trackpoint(&FileId(file_id), &engine::TrackSegmentId(segment_id), index)
.map(TrackpointDetails::new)
}
/// A file as GPX (UTF-8 bytes), `None` if it does not exist. The options say which data of the
/// trackpoints to keep.
pub fn export_file<H: History>(
engine: &Engine<H>,
file_id: Uuid,
options: engine::ExportOptions,
) -> Option<Vec<u8>> {
engine.export(&FileId(file_id), options)
}
/// The data that some files have, so that the ones that none has are not offered when exporting
/// them: a bit for the time (1), heart rate (2), cadence (4), temperature (8), power (16) and the
/// OpenStreetMap data (32).
pub fn exportable_data<H: History>(engine: &Engine<H>, file_ids: &[Uuid]) -> u8 {
let ids: Vec<FileId> = file_ids.iter().copied().map(FileId).collect();
let data = engine.exportable_data(&ids);
[
data.time, data.hr, data.cad, data.atemp, data.power, data.osm,
]
.iter()
.enumerate()
.fold(0, |bits, (i, on)| bits | (u8::from(*on) << i))
}
/// 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.
pub fn reduction_distances<H: History>(engine: &Engine<H>) -> Vec<f64> {
engine.reduction_distances()
}
// Coordinates, as flat `[lng, lat, ...]` slices. Compare `rev` / `waypointsRev` of the file
// structures with the previous ones to know which ones actually changed.
/// Coordinates of the trackpoints of a segment (empty if the id is unknown).
pub fn segment_coordinates<H: History>(engine: &Engine<H>, segment_id: Uuid) -> &[f64] {
engine.segment_coordinates(&engine::TrackSegmentId(segment_id))
}
/// Coordinates of the waypoints of a file (empty if the id is unknown).
pub fn waypoint_coordinates<H: History>(engine: &Engine<H>, file_id: Uuid) -> &[f64] {
engine.waypoint_coordinates(&FileId(file_id))
}
// Statistics of the selection
/// Global statistics of the whole selection (the buffers below cover it).
pub fn selection_statistics<H: History>(engine: &Engine<H>) -> GlobalStatistics {
GlobalStatistics::new(&engine.statistics().global)
}
/// Global statistics of the trackpoints from `start` to `end` (both included) of the selection,
/// as indexed by the buffers. `None` when the range is not in the selection.
pub fn slice_statistics<H: History>(
engine: &Engine<H>,
start: usize,
end: usize,
) -> Option<GlobalStatistics> {
engine
.statistics()
.slice(start, end)
.map(|stats| GlobalStatistics::new(&stats))
}
macro_rules! buffers {
($($name:ident),*) => {$(
pub fn $name<H: History>(engine: &Engine<H>) -> &[f64] {
&engine.statistics().$name
}
)*};
}
// One entry per trackpoint of the selection.
buffers!(
total_distance,
elevation_gain,
elevation_loss,
lng,
lat,
ele
);
macro_rules! optional_buffers {
($($name:ident: $type:ty),*) => {$(
/// `None` when no trackpoint of the selection has the value, else an entry per trackpoint,
/// the missing ones being NaN (`NO_TIME` for the timestamps, 0 before the first trackpoint
/// that has them for the cumulative distances and durations).
pub fn $name<H: History>(engine: &Engine<H>) -> Option<&[$type]> {
engine.statistics().$name.as_deref()
}
)*};
}
optional_buffers!(moving_distance: f64, speed: f64, hr: f64, cad: f64, atemp: f64, power: f64);
optional_buffers!(total_time: i64, moving_time: i64);
// The smoothed elevation segments of the selection, over which the slope is averaged, as
// intervals of trackpoints: `slope_segment_starts` holds the index of the first trackpoint of each
// segment, and `slope_segment_slopes` its slope (%). Their length is in `selection_point`.
pub fn slope_segment_starts<H: History>(engine: &Engine<H>) -> &[u32] {
&engine.statistics().slope_segments.starts
}
pub fn slope_segment_slopes<H: History>(engine: &Engine<H>) -> &[f64] {
&engine.statistics().slope_segments.slopes
}
/// What the buffers do not hold for each trackpoint of the selection, for the one at `index`
/// (numbered as in the buffers), `None` if there is none: for example the one that is hovered on
/// the elevation profile.
pub fn selection_point<H: History>(engine: &Engine<H>, index: usize) -> Option<SelectionPoint> {
let stats = engine.statistics();
if index >= stats.len() {
return None;
}
let categories = engine.categories();
let name = |intervals: &engine::Intervals, categories: &engine::Categories| {
let value = intervals.value_at(index);
let code = value.checked_sub(1)?;
categories.name(code).map(str::to_owned)
};
let segment = stats.slope_segments.at(index);
let finite = |value: f64| value.is_finite().then_some(value);
Some(SelectionPoint {
slope: finite(stats.slope[index]),
time: stats
.time
.as_ref()
.map(|time| time[index])
.filter(|time| *time != engine::NO_TIME)
.map(|time| time as f64),
slope_segment_slope: segment.and_then(|i| finite(stats.slope_segments.slopes[i])),
slope_segment_distance: segment.and_then(|i| finite(stats.slope_segments.distances[i])),
surface: name(&stats.surface, &categories.surface),
highway: name(&stats.highway, &categories.highway),
sac_scale: name(&stats.sac_scale, &categories.sac_scale),
mtb_scale: name(&stats.mtb_scale, &categories.mtb_scale),
})
}
// Surface, highway, SAC scale and MTB scale of the trackpoints, as intervals of trackpoints that
// have the same value: `<name>_starts` holds the index of the first trackpoint of each interval,
// and `<name>_values` its value (0 when unknown, else 1 + the index in the names of the category).
// A value that is unknown for every trackpoint is a single interval.
macro_rules! categories {
($starts:ident, $values:ident, $names:ident, $field:ident) => {
pub fn $starts<H: History>(engine: &Engine<H>) -> &[u32] {
&engine.statistics().$field.starts
}
pub fn $values<H: History>(engine: &Engine<H>) -> &[u8] {
&engine.statistics().$field.values
}
/// The names of the category, in the order of their codes. The list only grows: a name
/// keeps its index.
pub fn $names<H: History>(engine: &Engine<H>) -> &[String] {
engine.categories().$field.names()
}
};
}
categories!(surface_starts, surface_values, surfaces, surface);
categories!(highway_starts, highway_values, highways, highway);
categories!(sac_scale_starts, sac_scale_values, sac_scales, sac_scale);
categories!(mtb_scale_starts, mtb_scale_values, mtb_scales, mtb_scale);
// The anchors of the routing tool among the trackpoints of the selection (numbered as in the
// statistics). The ends of the segments are always anchors.
/// The index in the selection of each anchor.
pub fn anchor_indices<H: History>(engine: &Engine<H>) -> &[u32] {
&engine.routing().anchor_indices
}
/// The lowest map zoom level at which each anchor is shown.
pub fn anchor_zooms<H: History>(engine: &Engine<H>) -> &[u8] {
&engine.routing().anchor_zooms
}
/// Index in the selection of the first trackpoint of each selected segment that has some.
pub fn segment_starts<H: History>(engine: &Engine<H>) -> &[u32] {
&engine.routing().segment_starts
}
/// The id of each selected segment that has trackpoints, as `segment_starts`.
pub fn segment_ids<H: History>(engine: &Engine<H>) -> Vec<String> {
engine
.routing()
.segment_ids
.iter()
.map(|id| id.0.to_string())
.collect()
}
/// Changes when the selected segments or their trackpoints change: the indices in the selection
/// are only valid while it stays the same.
pub fn routing_revision<H: History>(engine: &Engine<H>) -> u32 {
engine.routing().revision
}