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

+163
View File
@@ -0,0 +1,163 @@
use std::{collections::HashSet, rc::Rc};
use crate::{Apply, CommandError, FileId, Route, Selection, State, Track};
/// Converts the selected routes to tracks, or the selected tracks or segments to routes, in their
/// file:
///
/// - a route becomes a track with its segment. The new tracks go before the other tracks, right
/// after the routes;
/// - a track becomes one route per segment, as a route has a single segment, and goes away. A
/// selected segment becomes a route and leaves its track, which stays even if it has no segment
/// left. The routes have the information of their track, and go after the other routes;
/// - a file with routes only has all of them converted to tracks, and a file with tracks only
/// has all of them converted to routes. The files with both, or neither, stay as they are.
///
/// The segments keep their ids, the new routes and tracks are selected (the files stay selected).
/// There is nothing to do for the other selections, or for tracks without segments.
#[derive(Debug)]
pub struct Convert;
impl Apply for Convert {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let selection = state.selection.clone();
let next = match &selection {
Selection::Route { file_id, rte_ids } => {
let file = Rc::make_mut(
state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?,
);
let (converted, kept) = std::mem::take(&mut file.rte)
.into_iter()
.partition::<Vec<_>, _>(|rte| rte_ids.contains(&rte.id));
file.rte = kept;
let tracks = to_tracks(converted);
let trk_ids: HashSet<_> = tracks.iter().map(|trk| trk.id).collect();
file.trk.splice(0..0, tracks);
Selection::Track {
file_id: *file_id,
trk_ids,
}
}
Selection::Track { file_id, trk_ids } => {
let file = Rc::make_mut(
state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?,
);
let (converted, kept) = std::mem::take(&mut file.trk)
.into_iter()
.partition::<Vec<_>, _>(|trk| trk_ids.contains(&trk.id));
file.trk = kept;
add_routes(&mut file.rte, *file_id, to_routes(converted))
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let file = Rc::make_mut(
state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?,
);
let trk = file
.trk
.iter_mut()
.find(|trk| trk.id == *trk_id)
.ok_or(CommandError::NothingToDo)?;
let (converted, kept) = std::mem::take(&mut trk.trkseg)
.into_iter()
.partition::<Vec<_>, _>(|seg| trkseg_ids.contains(&seg.id));
trk.trkseg = kept;
let info = trk.info.clone();
let routes = converted.into_iter().map(|seg| Route {
id: Default::default(),
info: info.clone(),
seg,
});
add_routes(&mut file.rte, *file_id, routes)
}
Selection::File { file_ids } => {
let mut converted = false;
for file_id in file_ids {
let Some(file) = state.files.get_mut(file_id) else {
continue;
};
if !file.rte.is_empty() && file.trk.is_empty() {
let file = Rc::make_mut(file);
file.trk = to_tracks(std::mem::take(&mut file.rte));
} else if file.rte.is_empty()
&& file.trk.iter().any(|trk| !trk.trkseg.is_empty())
{
let file = Rc::make_mut(file);
file.rte = to_routes(std::mem::take(&mut file.trk)).collect();
} else {
continue;
}
converted = true;
}
return if converted {
Ok(())
} else {
Err(CommandError::NothingToDo)
};
}
Selection::Empty | Selection::Waypoints { .. } | Selection::Waypoint { .. } => {
return Err(CommandError::NothingToDo);
}
};
let empty = match &next {
Selection::Route { rte_ids, .. } => rte_ids.is_empty(),
Selection::Track { trk_ids, .. } => trk_ids.is_empty(),
_ => true,
};
if empty {
// nothing was converted (the changed copy of the file is dropped)
return Err(CommandError::NothingToDo);
}
*state.selection = next;
Ok(())
}
}
/// The tracks of the routes, each with the segment of its route.
fn to_tracks(routes: Vec<Route>) -> Vec<Track> {
routes
.into_iter()
.map(|rte| Track {
id: Default::default(),
info: rte.info,
trkseg: vec![rte.seg],
})
.collect()
}
/// The routes of the tracks, one per segment, each with the information of its track.
fn to_routes(tracks: Vec<Track>) -> impl Iterator<Item = Route> {
tracks.into_iter().flat_map(|trk| {
trk.trkseg.into_iter().map(move |seg| Route {
id: Default::default(),
info: trk.info.clone(),
seg,
})
})
}
/// Adds the routes after the other routes, and gives the selection of them.
fn add_routes(
rte: &mut Vec<Route>,
file_id: FileId,
routes: impl Iterator<Item = Route>,
) -> Selection {
let start = rte.len();
rte.extend(routes);
Selection::Route {
file_id,
rte_ids: rte[start..].iter().map(|rte| rte.id).collect(),
}
}
+178
View File
@@ -0,0 +1,178 @@
use crate::{
Apply, CommandError, Edit, Editor, File, Route, State, Track, TrackInfo, update_selected,
};
#[derive(Debug)]
pub struct Metadata<'a> {
pub name: &'a str,
pub desc: &'a str,
}
impl Apply for Metadata<'_> {
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
update_selected(state, &mut self);
Ok(())
}
}
impl Metadata<'_> {
fn set(&self, info: &mut TrackInfo) -> Edit {
info.name = Some(self.name.to_owned());
info.desc = Some(self.desc.to_owned());
Edit::Changed
}
}
impl Editor for Metadata<'_> {
fn file(&mut self, file: &mut File) -> Edit {
file.info.name = self.name.to_owned();
file.info.desc = Some(self.desc.to_owned());
// a single route or track is named like the file
match (file.rte.as_mut_slice(), file.trk.as_mut_slice()) {
([route], []) => route.info.name = Some(self.name.to_owned()),
([], [track]) => track.info.name = Some(self.name.to_owned()),
_ => {}
}
Edit::Changed
}
fn route(&mut self, route: &mut Route) -> Edit {
self.set(&mut route.info)
}
fn track(&mut self, track: &mut Track) -> Edit {
self.set(&mut track.info)
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{New, NewTrack, Selection, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_metadata_applies_to_selected_file() {
let mut fx = Fixture::default();
New {
name: "first",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
New {
name: "second",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap(); // now selected
let selected = *fx.selected_files().iter().next().unwrap();
Metadata {
name: "renamed",
desc: "description",
}
.apply(&mut fx.state())
.unwrap();
for (id, file) in fx.files.iter() {
if *id == selected {
assert_eq!(file.info.name, "renamed");
assert_eq!(file.info.desc.as_deref(), Some("description"));
} else {
assert_eq!(file.info.name, "first");
assert_eq!(file.info.desc, None);
}
}
}
#[test]
fn test_metadata_applies_to_selected_track() {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks.gpx").unwrap();
crate::Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let file = fx.files.values().next().unwrap().clone();
fx.selection = Selection::Track {
file_id: file.id,
trk_ids: HashSet::from([file.trk[0].id]),
};
Metadata {
name: "renamed",
desc: "",
}
.apply(&mut fx.state())
.unwrap();
let after = &fx.files[&file.id];
assert_eq!(after.info.name, file.info.name);
assert_eq!(after.trk[0].info.name.as_deref(), Some("renamed"));
assert_eq!(after.trk[1].info, file.trk[1].info);
}
/// A new file with `nb_tracks` tracks (without name), selected.
fn file_with_tracks(fx: &mut Fixture, nb_tracks: usize) {
New {
name: "file",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
for _ in 0..nb_tracks {
NewTrack.apply(&mut fx.state()).unwrap();
}
}
fn rename(fx: &mut Fixture) {
Metadata {
name: "renamed",
desc: "",
}
.apply(&mut fx.state())
.unwrap();
}
#[test]
fn test_single_track_is_renamed_like_the_file() {
let mut fx = Fixture::default();
file_with_tracks(&mut fx, 1);
rename(&mut fx);
let file = fx.files.values().next().unwrap();
assert_eq!(file.info.name, "renamed");
assert_eq!(file.trk[0].info.name.as_deref(), Some("renamed"));
}
#[test]
fn test_single_track_with_a_name_is_renamed_too() {
let mut fx = Fixture::default();
file_with_tracks(&mut fx, 1);
let id = *fx.files.keys().next().unwrap();
std::rc::Rc::make_mut(fx.files.get_mut(&id).unwrap()).trk[0]
.info
.name = Some("track".to_owned());
rename(&mut fx);
let file = &fx.files[&id];
assert_eq!(file.info.name, "renamed");
assert_eq!(file.trk[0].info.name.as_deref(), Some("renamed"));
}
#[test]
fn test_several_tracks_are_not_renamed() {
let mut fx = Fixture::default();
file_with_tracks(&mut fx, 2);
rename(&mut fx);
let file = fx.files.values().next().unwrap();
assert_eq!(file.info.name, "renamed");
assert!(file.trk.iter().all(|trk| trk.info.name.is_none()));
}
}
+13
View File
@@ -0,0 +1,13 @@
mod convert;
mod metadata;
mod new_route;
mod new_track;
mod new_track_segment;
mod style;
pub use convert::*;
pub use metadata::*;
pub use new_route::*;
pub use new_track::*;
pub use new_track_segment::*;
pub use style::*;
@@ -0,0 +1,55 @@
use crate::{Apply, CommandError, Edit, Editor, File, Route, State, update_selected};
use super::new_track::common_style;
/// Adds an empty route to the selected files, with the style that their routes and tracks agree
/// on (see [`crate::NewTrack`]).
#[derive(Debug)]
pub struct NewRoute;
impl Apply for NewRoute {
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
update_selected(state, &mut self);
Ok(())
}
}
impl Editor for NewRoute {
fn file(&mut self, file: &mut File) -> Edit {
file.rte.push(Route {
info: common_style(file),
..Default::default()
});
Edit::Changed
}
}
#[cfg(test)]
mod tests {
use crate::{New, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_new_route_has_the_style_of_the_tracks_and_an_empty_segment() {
let mut fx = Fixture::default();
New {
name: "file",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
crate::NewTrack.apply(&mut fx.state()).unwrap();
let file = std::rc::Rc::make_mut(fx.files.values_mut().next().unwrap());
file.trk[0].info.color = Some("ff0000".into());
NewRoute.apply(&mut fx.state()).unwrap();
NewRoute.apply(&mut fx.state()).unwrap();
let file = fx.files.values().next().unwrap();
assert_eq!(file.rte.len(), 2);
assert_ne!(file.rte[0].id, file.rte[1].id);
assert!(file.rte[0].seg.is_empty());
assert_eq!(file.rte[1].info.color.as_deref(), Some("ff0000"));
}
}
@@ -0,0 +1,98 @@
use crate::{Apply, CommandError, Edit, Editor, File, State, Track, TrackInfo, update_selected};
#[derive(Debug)]
pub struct NewTrack;
impl Apply for NewTrack {
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
update_selected(state, &mut self);
Ok(())
}
}
impl Editor for NewTrack {
fn file(&mut self, file: &mut File) -> Edit {
file.trk.push(Track {
info: common_style(file),
..Default::default()
});
Edit::Changed
}
}
/// The style that all the routes and tracks of the file agree on: each attribute is set if they
/// all have the same value for it.
pub(super) fn common_style(file: &File) -> TrackInfo {
let infos: Vec<&TrackInfo> = file
.rte
.iter()
.map(|rte| &rte.info)
.chain(file.trk.iter().map(|trk| &trk.info))
.collect();
TrackInfo {
color: common(&infos, |info| &info.color),
opacity: common(&infos, |info| &info.opacity),
width: common(&infos, |info| &info.width),
..Default::default()
}
}
/// Returns the value of a style attribute if all the infos agree on it.
fn common<T: Clone + PartialEq>(
infos: &[&TrackInfo],
get: impl Fn(&TrackInfo) -> &Option<T>,
) -> Option<T> {
let mut infos = infos.iter();
let first = get(infos.next()?);
infos
.all(|info| get(info) == first)
.then(|| first.clone())?
}
#[cfg(test)]
mod tests {
use crate::{New, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_new_track() {
let mut fx = Fixture::default();
New {
name: "file",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
let file = fx.files.values().next().unwrap();
assert_eq!(file.trk.len(), 2);
assert_ne!(file.trk[0].id, file.trk[1].id);
}
#[test]
fn test_new_track_inherits_agreeing_style_only() {
let mut fx = Fixture::default();
New {
name: "file",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
let file = std::rc::Rc::make_mut(fx.files.values_mut().next().unwrap());
for (i, trk) in file.trk.iter_mut().enumerate() {
trk.info.color = Some("ff0000".into());
trk.info.opacity = Some(0.5 + i as f64 * 0.1);
}
NewTrack.apply(&mut fx.state()).unwrap();
let info = &fx.files.values().next().unwrap().trk[2].info;
assert_eq!(info.color.as_deref(), Some("ff0000"));
assert_eq!(info.opacity, None);
assert_eq!(info.width, None);
}
}
@@ -0,0 +1,42 @@
use crate::{Apply, CommandError, Edit, Editor, State, Track, TrackSegment, update_selected};
#[derive(Debug)]
pub struct NewTrackSegment;
impl Apply for NewTrackSegment {
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
update_selected(state, &mut self);
Ok(())
}
}
impl Editor for NewTrackSegment {
fn track(&mut self, track: &mut Track) -> Edit {
track.trkseg.push(TrackSegment::default());
Edit::Changed
}
}
#[cfg(test)]
mod tests {
use crate::{New, NewTrack, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_new_track_segment_on_each_track_of_selected_file() {
let mut fx = Fixture::default();
New {
name: "file",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
NewTrack.apply(&mut fx.state()).unwrap();
NewTrackSegment.apply(&mut fx.state()).unwrap();
let file = fx.files.values().next().unwrap();
assert!(file.trk.iter().all(|t| t.trkseg.len() == 1));
}
}
+81
View File
@@ -0,0 +1,81 @@
use crate::{Apply, CommandError, Edit, Editor, Route, State, Track, TrackInfo, update_selected};
#[derive(Debug)]
pub struct Style<'a> {
pub color: Option<&'a str>,
pub opacity: Option<f64>,
pub width: Option<f64>,
}
impl Apply for Style<'_> {
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
update_selected(state, &mut self);
Ok(())
}
}
impl Style<'_> {
fn set(&self, info: &mut TrackInfo) -> Edit {
if let Some(color) = self.color {
info.color = Some(color.to_owned());
}
if self.opacity.is_some() {
info.opacity = self.opacity;
}
if self.width.is_some() {
info.width = self.width;
}
Edit::Changed
}
}
impl Editor for Style<'_> {
fn route(&mut self, route: &mut Route) -> Edit {
self.set(&mut route.info)
}
fn track(&mut self, track: &mut Track) -> Edit {
self.set(&mut track.info)
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{Selection, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_style_applies_to_all_tracks_of_selected_file_and_keeps_unset_fields() {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_style.gpx").unwrap();
crate::Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = *fx.files.keys().next().unwrap();
let before = fx.files[&id].clone();
assert!(!before.trk.is_empty());
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
Style {
color: Some("ff0000"),
opacity: None,
width: Some(7.0),
}
.apply(&mut fx.state())
.unwrap();
for (b, a) in before.trk.iter().zip(fx.files[&id].trk.iter()) {
assert_eq!(a.info.color.as_deref(), Some("ff0000"));
assert_eq!(a.info.width, Some(7.0));
assert_eq!(a.info.opacity, b.info.opacity);
}
}
}