mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-27 02:27:18 +00:00
Compare commits
52
Commits
05510ece92
...
tauri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45f877ec78 | ||
|
|
0f957fb1f2 | ||
|
|
764d0b233a | ||
|
|
b6c0f5190a | ||
|
|
c842da8dc1 | ||
|
|
5bc35f30d9 | ||
|
|
873e2e1654 | ||
|
|
6c2d2879f4 | ||
|
|
f5670cdd8e | ||
|
|
741d35db1e | ||
|
|
6556c29492 | ||
|
|
7fa850e4c8 | ||
|
|
f9e8d4c644 | ||
|
|
a07da16163 | ||
|
|
4583874e7e | ||
|
|
48325e6016 | ||
|
|
9033401094 | ||
|
|
b64c192c4f | ||
|
|
9dd4af98a5 | ||
|
|
6a0d434371 | ||
|
|
b319440a57 | ||
|
|
b0d78b5f3c | ||
|
|
2e8b2d6f30 | ||
|
|
5259d007c9 | ||
|
|
cb36725a46 | ||
|
|
deef1bab47 | ||
|
|
108880f8cd | ||
|
|
37a659831b | ||
|
|
fde6bafc10 | ||
|
|
4537ea4730 | ||
|
|
1954bbd764 | ||
|
|
9d2da80893 | ||
|
|
043d368c74 | ||
|
|
bb11d3375b | ||
|
|
bfe5961e27 | ||
|
|
17559913fd | ||
|
|
55590d68a6 | ||
|
|
8e9f16c460 | ||
|
|
c60b64f24f | ||
|
|
fdb6fe4e52 | ||
|
|
0bf168e67e | ||
|
|
d762a45eb9 | ||
|
|
548ab9a459 | ||
|
|
315c1f6a61 | ||
|
|
98257bee12 | ||
|
|
5561b7d9fe | ||
|
|
320887206c | ||
|
|
04a1bf6a55 | ||
|
|
0989371874 | ||
|
|
3dcd6e52d3 | ||
|
|
0f06d0d461 | ||
|
|
db33310a10 |
No files matched your search
@@ -49,7 +49,7 @@ pub fn select_elements<H: History>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Selects all the elements of the same kind as the selected ones, in the same place: all the
|
/// Selects all the elements of the same kind as the selected ones, in the same place: all the
|
||||||
/// files, the tracks of the file, the segments of the track, the waypoints of the file. 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.
|
/// files when nothing is selected.
|
||||||
pub fn select_all<H: History>(engine: &mut Engine<H>) -> Outcome {
|
pub fn select_all<H: History>(engine: &mut Engine<H>) -> Outcome {
|
||||||
execute(engine, Action::SelectAll)
|
execute(engine, Action::SelectAll)
|
||||||
@@ -62,6 +62,19 @@ pub fn arrow_select<H: History>(engine: &mut Engine<H>, down: bool, add: bool) -
|
|||||||
execute(engine, Action::ArrowSelect { down, add })
|
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>(
|
pub fn select_tracks<H: History>(
|
||||||
engine: &mut Engine<H>,
|
engine: &mut Engine<H>,
|
||||||
file_id: Uuid,
|
file_id: Uuid,
|
||||||
@@ -135,21 +148,30 @@ pub fn redo<H: History>(engine: &mut Engine<H>) -> Outcome {
|
|||||||
|
|
||||||
// Files
|
// Files
|
||||||
|
|
||||||
/// Creates a file and selects it. With `lng` and `lat`, it starts with a track and a segment that
|
/// Creates a file and selects it. With `route`, it starts with a route. With `lng` and `lat`, it
|
||||||
/// hold that trackpoint (at `ele`, 0 by default).
|
/// 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>(
|
pub fn new_file<H: History>(
|
||||||
engine: &mut Engine<H>,
|
engine: &mut Engine<H>,
|
||||||
name: &str,
|
name: &str,
|
||||||
lng: Option<f64>,
|
lng: Option<f64>,
|
||||||
lat: Option<f64>,
|
lat: Option<f64>,
|
||||||
ele: Option<f64>,
|
ele: Option<f64>,
|
||||||
|
route: bool,
|
||||||
) -> Outcome {
|
) -> Outcome {
|
||||||
let trackpoint = lng.zip(lat).map(|(lng, lat)| engine::NewTrackpoint {
|
let trackpoint = lng.zip(lat).map(|(lng, lat)| engine::NewTrackpoint {
|
||||||
lng,
|
lng,
|
||||||
lat,
|
lat,
|
||||||
ele: ele.unwrap_or_default(),
|
ele: ele.unwrap_or_default(),
|
||||||
});
|
});
|
||||||
edit(engine, Command::New(engine::New { name, trackpoint }))
|
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,
|
/// Loads files as a single command (one undo step). The files are in `data`, one after the other,
|
||||||
@@ -304,6 +326,15 @@ pub fn style<H: History>(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
pub fn new_track<H: History>(engine: &mut Engine<H>) -> Outcome {
|
||||||
edit(engine, Command::NewTrack(engine::NewTrack))
|
edit(engine, Command::NewTrack(engine::NewTrack))
|
||||||
}
|
}
|
||||||
@@ -575,7 +606,7 @@ pub fn route<H: History>(
|
|||||||
at_revision(engine, revision, |engine| {
|
at_revision(engine, revision, |engine| {
|
||||||
edit(
|
edit(
|
||||||
engine,
|
engine,
|
||||||
Command::Route(engine::Route {
|
Command::Reroute(engine::Reroute {
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
lng,
|
lng,
|
||||||
@@ -618,7 +649,7 @@ pub fn change_loop_start<H: History>(engine: &mut Engine<H>, revision: u32, inde
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cuts the file, the track or the segment (see `SplitType`) of the trackpoint `at` of the
|
/// 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
|
/// 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
|
/// is cut is kept (and its two parts are new files) if the history needs it, see
|
||||||
/// [`History::KEEPS_ORIGINALS`].
|
/// [`History::KEEPS_ORIGINALS`].
|
||||||
|
|||||||
+100
-16
@@ -131,6 +131,10 @@ pub enum Selection {
|
|||||||
File {
|
File {
|
||||||
file_ids: Vec<Uuid>,
|
file_ids: Vec<Uuid>,
|
||||||
},
|
},
|
||||||
|
Route {
|
||||||
|
file_id: Uuid,
|
||||||
|
route_ids: Vec<Uuid>,
|
||||||
|
},
|
||||||
Track {
|
Track {
|
||||||
file_id: Uuid,
|
file_id: Uuid,
|
||||||
track_ids: Vec<Uuid>,
|
track_ids: Vec<Uuid>,
|
||||||
@@ -157,6 +161,10 @@ impl From<&engine::Selection> for Selection {
|
|||||||
S::File { file_ids } => Self::File {
|
S::File { file_ids } => Self::File {
|
||||||
file_ids: file_ids.iter().map(|id| id.0).collect(),
|
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 {
|
S::Track { file_id, trk_ids } => Self::Track {
|
||||||
file_id: file_id.0,
|
file_id: file_id.0,
|
||||||
track_ids: trk_ids.iter().map(|id| id.0).collect(),
|
track_ids: trk_ids.iter().map(|id| id.0).collect(),
|
||||||
@@ -187,6 +195,10 @@ impl From<Selection> for engine::Selection {
|
|||||||
Selection::File { file_ids } => S::File {
|
Selection::File { file_ids } => S::File {
|
||||||
file_ids: file_ids.into_iter().map(FileId).collect(),
|
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 {
|
Selection::Track { file_id, track_ids } => S::Track {
|
||||||
file_id: FileId(file_id),
|
file_id: FileId(file_id),
|
||||||
trk_ids: track_ids.into_iter().map(engine::TrackId).collect(),
|
trk_ids: track_ids.into_iter().map(engine::TrackId).collect(),
|
||||||
@@ -229,6 +241,10 @@ pub enum MoveTarget {
|
|||||||
Files {
|
Files {
|
||||||
index: f64,
|
index: f64,
|
||||||
},
|
},
|
||||||
|
Routes {
|
||||||
|
file_id: Uuid,
|
||||||
|
index: f64,
|
||||||
|
},
|
||||||
Tracks {
|
Tracks {
|
||||||
file_id: Uuid,
|
file_id: Uuid,
|
||||||
index: f64,
|
index: f64,
|
||||||
@@ -250,6 +266,10 @@ impl From<MoveTarget> for engine::MoveTarget {
|
|||||||
let index = |index: f64| index.max(0.0) as usize;
|
let index = |index: f64| index.max(0.0) as usize;
|
||||||
match target {
|
match target {
|
||||||
MoveTarget::Files { index: i } => T::Files { index: index(i) },
|
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 {
|
MoveTarget::Tracks { file_id, index: i } => T::Tracks {
|
||||||
file_id: FileId(file_id),
|
file_id: FileId(file_id),
|
||||||
index: index(i),
|
index: index(i),
|
||||||
@@ -286,6 +306,9 @@ impl Clipboard {
|
|||||||
engine::ClipboardIds::Files(ids) => {
|
engine::ClipboardIds::Files(ids) => {
|
||||||
("files", strings(ids.into_iter().map(|id| id.0).collect()))
|
("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) => {
|
engine::ClipboardIds::Tracks(ids) => {
|
||||||
("tracks", strings(ids.into_iter().map(|id| id.0).collect()))
|
("tracks", strings(ids.into_iter().map(|id| id.0).collect()))
|
||||||
}
|
}
|
||||||
@@ -315,14 +338,15 @@ pub struct FileStructure {
|
|||||||
name: String,
|
name: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
desc: Option<String>,
|
desc: Option<String>,
|
||||||
|
routes: Vec<RouteNode>,
|
||||||
tracks: Vec<TrackNode>,
|
tracks: Vec<TrackNode>,
|
||||||
waypoints: Vec<WaypointNode>,
|
waypoints: Vec<WaypointNode>,
|
||||||
waypoints_rev: String,
|
waypoints_rev: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The name, description and style of a route or a track.
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct TrackNode {
|
struct PathInfo {
|
||||||
id: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -333,6 +357,33 @@ struct TrackNode {
|
|||||||
opacity: Option<f64>,
|
opacity: Option<f64>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
width: Option<f64>,
|
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>,
|
segments: Vec<SegmentNode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,6 +394,16 @@ struct SegmentNode {
|
|||||||
length: f64,
|
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)]
|
#[derive(Serialize)]
|
||||||
struct WaypointNode {
|
struct WaypointNode {
|
||||||
id: String,
|
id: String,
|
||||||
@@ -358,25 +419,22 @@ impl FileStructure {
|
|||||||
id: file.id.0.to_string(),
|
id: file.id.0.to_string(),
|
||||||
name: file.name.clone(),
|
name: file.name.clone(),
|
||||||
desc: file.desc.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: file
|
||||||
.tracks
|
.tracks
|
||||||
.iter()
|
.iter()
|
||||||
.map(|trk| TrackNode {
|
.map(|trk| TrackNode {
|
||||||
id: trk.id.0.to_string(),
|
id: trk.id.0.to_string(),
|
||||||
name: trk.name.clone(),
|
info: (&trk.info).into(),
|
||||||
desc: trk.desc.clone(),
|
segments: trk.segments.iter().map(Into::into).collect(),
|
||||||
color: trk.color.clone(),
|
|
||||||
opacity: trk.opacity,
|
|
||||||
width: trk.width,
|
|
||||||
segments: trk
|
|
||||||
.segments
|
|
||||||
.iter()
|
|
||||||
.map(|seg| SegmentNode {
|
|
||||||
id: seg.id.0.to_string(),
|
|
||||||
rev: seg.rev_id.0.to_string(),
|
|
||||||
length: seg.len as f64,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
waypoints: file
|
waypoints: file
|
||||||
@@ -522,6 +580,32 @@ impl TrackpointDetails {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
// Routing
|
||||||
|
|
||||||
/// A category of the trackpoints of a route, as in `RouteCategory` of the engine.
|
/// A category of the trackpoints of a route, as in `RouteCategory` of the engine.
|
||||||
|
|||||||
@@ -76,9 +76,7 @@ pub fn trackpoint<H: History>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A file as GPX (UTF-8 bytes), `None` if it does not exist. The options say which data of the
|
/// A file as GPX (UTF-8 bytes), `None` if it does not exist. The options say which data of the
|
||||||
/// trackpoints to keep, and with `as_route` that the segments are written as routes (`rte`)
|
/// trackpoints to keep.
|
||||||
/// instead of tracks: the anchors are the route points, and the trackpoints between them the
|
|
||||||
/// points of their path.
|
|
||||||
pub fn export_file<H: History>(
|
pub fn export_file<H: History>(
|
||||||
engine: &Engine<H>,
|
engine: &Engine<H>,
|
||||||
file_id: Uuid,
|
file_id: Uuid,
|
||||||
@@ -153,9 +151,6 @@ buffers!(
|
|||||||
total_distance,
|
total_distance,
|
||||||
elevation_gain,
|
elevation_gain,
|
||||||
elevation_loss,
|
elevation_loss,
|
||||||
slope,
|
|
||||||
slope_segment_slope,
|
|
||||||
slope_segment_distance,
|
|
||||||
lng,
|
lng,
|
||||||
lat,
|
lat,
|
||||||
ele
|
ele
|
||||||
@@ -175,9 +170,49 @@ macro_rules! optional_buffers {
|
|||||||
optional_buffers!(moving_distance: f64, speed: f64, hr: f64, cad: f64, atemp: f64, power: f64);
|
optional_buffers!(moving_distance: f64, speed: f64, hr: f64, cad: f64, atemp: f64, power: f64);
|
||||||
optional_buffers!(total_time: i64, moving_time: i64);
|
optional_buffers!(total_time: i64, moving_time: i64);
|
||||||
|
|
||||||
/// Timestamps in milliseconds since the epoch (`NO_TIME` when missing), see `optional_buffers`.
|
// The smoothed elevation segments of the selection, over which the slope is averaged, as
|
||||||
pub fn timestamps<H: History>(engine: &Engine<H>) -> Option<&[i64]> {
|
// intervals of trackpoints: `slope_segment_starts` holds the index of the first trackpoint of each
|
||||||
engine.statistics().time.as_deref()
|
// 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
|
// Surface, highway, SAC scale and MTB scale of the trackpoints, as intervals of trackpoints that
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//! between them are kept: every part comes after the previous one in the new order, as late as the
|
//! between them are kept: every part comes after the previous one in the new order, as late as the
|
||||||
//! original one came after the part that followed it.
|
//! original one came after the part that followed it.
|
||||||
|
|
||||||
use crate::{Track, TrackSegment, Trackpoint};
|
use crate::{Route, Track, TrackSegment, Trackpoint};
|
||||||
|
|
||||||
/// The time of the first trackpoint of a segment.
|
/// The time of the first trackpoint of a segment.
|
||||||
pub fn start_time(segment: &TrackSegment) -> Option<i64> {
|
pub fn start_time(segment: &TrackSegment) -> Option<i64> {
|
||||||
@@ -94,6 +94,19 @@ pub fn reverse_segments(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reverses the routes of a file, and their trackpoints, like the segments of a track (see
|
||||||
|
/// [`reverse_segments`]).
|
||||||
|
pub fn reverse_routes(routes: &mut [Route], original_next: Option<i64>, new_previous: Option<i64>) {
|
||||||
|
routes.reverse();
|
||||||
|
let (mut next, mut previous) = (original_next, new_previous);
|
||||||
|
for route in routes.iter_mut() {
|
||||||
|
let original_start = start_time(&route.seg);
|
||||||
|
reverse_segment(&mut route.seg, next, previous);
|
||||||
|
next = original_start;
|
||||||
|
previous = end_time(&route.seg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Reverses the tracks of a file, and their segments and trackpoints.
|
/// Reverses the tracks of a file, and their segments and trackpoints.
|
||||||
pub fn reverse_tracks(tracks: &mut [Track], original_next: Option<i64>, new_previous: Option<i64>) {
|
pub fn reverse_tracks(tracks: &mut [Track], original_next: Option<i64>, new_previous: Option<i64>) {
|
||||||
tracks.reverse();
|
tracks.reverse();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::{Link, Track, Waypoints};
|
use crate::{Link, Route, Track, TrackSegment, Waypoints};
|
||||||
|
|
||||||
use super::common::uuid_id;
|
use super::common::uuid_id;
|
||||||
|
|
||||||
@@ -8,6 +8,9 @@ uuid_id!(FileId);
|
|||||||
pub struct File {
|
pub struct File {
|
||||||
pub id: FileId,
|
pub id: FileId,
|
||||||
pub info: FileInfo,
|
pub info: FileInfo,
|
||||||
|
/// The routes come before the tracks, as in GPX: everything that goes through the segments
|
||||||
|
/// of a file (statistics, the elevation profile...) takes the ones of the routes first.
|
||||||
|
pub rte: Vec<Route>,
|
||||||
pub trk: Vec<Track>,
|
pub trk: Vec<Track>,
|
||||||
pub wpt: Waypoints,
|
pub wpt: Waypoints,
|
||||||
}
|
}
|
||||||
@@ -29,3 +32,50 @@ pub struct Author {
|
|||||||
/// The author has a single link, unlike the other elements.
|
/// The author has a single link, unlike the other elements.
|
||||||
pub link: Option<Link>,
|
pub link: Option<Link>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where a segment is in its file: the one of a route, or one of a track.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SegmentIndex {
|
||||||
|
Route(usize),
|
||||||
|
Track { trk: usize, seg: usize },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl File {
|
||||||
|
/// The segments of the routes, then the ones of the tracks.
|
||||||
|
pub fn segments(&self) -> impl Iterator<Item = &TrackSegment> {
|
||||||
|
self.rte
|
||||||
|
.iter()
|
||||||
|
.map(|rte| &rte.seg)
|
||||||
|
.chain(self.trk.iter().flat_map(|trk| &trk.trkseg))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the segments are, in the order of [`File::segments`].
|
||||||
|
pub fn segment_indices(&self) -> impl Iterator<Item = SegmentIndex> + '_ {
|
||||||
|
(0..self.rte.len())
|
||||||
|
.map(SegmentIndex::Route)
|
||||||
|
.chain(self.trk.iter().enumerate().flat_map(|(trk, track)| {
|
||||||
|
(0..track.trkseg.len()).map(move |seg| SegmentIndex::Track { trk, seg })
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The segment at `index`. Panics if there is none.
|
||||||
|
pub fn segment(&self, index: SegmentIndex) -> &TrackSegment {
|
||||||
|
match index {
|
||||||
|
SegmentIndex::Route(rte) => &self.rte[rte].seg,
|
||||||
|
SegmentIndex::Track { trk, seg } => &self.trk[trk].trkseg[seg],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The segment at `index`. Panics if there is none.
|
||||||
|
pub fn segment_mut(&mut self, index: SegmentIndex) -> &mut TrackSegment {
|
||||||
|
match index {
|
||||||
|
SegmentIndex::Route(rte) => &mut self.rte[rte].seg,
|
||||||
|
SegmentIndex::Track { trk, seg } => &mut self.trk[trk].trkseg[seg],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the file has no route nor track.
|
||||||
|
pub fn has_no_path(&self) -> bool {
|
||||||
|
self.rte.is_empty() && self.trk.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ mod chunk;
|
|||||||
mod chunked;
|
mod chunked;
|
||||||
mod common;
|
mod common;
|
||||||
mod file;
|
mod file;
|
||||||
|
mod route;
|
||||||
mod segment;
|
mod segment;
|
||||||
mod track;
|
mod track;
|
||||||
mod trackpoint;
|
mod trackpoint;
|
||||||
@@ -14,6 +15,7 @@ pub use chunk::*;
|
|||||||
pub use chunked::*;
|
pub use chunked::*;
|
||||||
pub use common::*;
|
pub use common::*;
|
||||||
pub use file::*;
|
pub use file::*;
|
||||||
|
pub use route::*;
|
||||||
pub use segment::*;
|
pub use segment::*;
|
||||||
pub use track::*;
|
pub use track::*;
|
||||||
pub use trackpoint::*;
|
pub use trackpoint::*;
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
use crate::{TrackInfo, TrackSegment};
|
||||||
|
|
||||||
|
use super::common::uuid_id;
|
||||||
|
|
||||||
|
uuid_id!(RouteId);
|
||||||
|
|
||||||
|
/// A route of the file (`rte`): like a track, but with a single segment, its points.
|
||||||
|
///
|
||||||
|
/// It has the information of a track: GPX gives the same to both.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct Route {
|
||||||
|
pub id: RouteId,
|
||||||
|
pub info: TrackInfo,
|
||||||
|
pub seg: TrackSegment,
|
||||||
|
}
|
||||||
+43
-44
@@ -1,6 +1,6 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
Author, Chunk, File, Link, LngLat, Track, TrackSegment, Trackpoint, TrackpointCategories,
|
Author, Chunk, File, Link, LngLat, Route, Track, TrackSegment, Trackpoint,
|
||||||
TrackpointChunk, Waypoint, WaypointChunk, distance,
|
TrackpointCategories, TrackpointChunk, Waypoint, WaypointChunk, distance,
|
||||||
};
|
};
|
||||||
use chrono::DateTime;
|
use chrono::DateTime;
|
||||||
use quick_xml::Error;
|
use quick_xml::Error;
|
||||||
@@ -18,6 +18,7 @@ enum GPXElement {
|
|||||||
Author(Author),
|
Author(Author),
|
||||||
Link(Link),
|
Link(Link),
|
||||||
Text,
|
Text,
|
||||||
|
/// A track, or a route, whose information is read the same way.
|
||||||
Track(Track),
|
Track(Track),
|
||||||
Segment(TrackSegment),
|
Segment(TrackSegment),
|
||||||
Trackpoint(Trackpoint),
|
Trackpoint(Trackpoint),
|
||||||
@@ -396,7 +397,7 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
|
|||||||
let mut stack: Vec<GPXElement> = vec![];
|
let mut stack: Vec<GPXElement> = vec![];
|
||||||
let mut trkpt_chunk = TrackpointChunk::default();
|
let mut trkpt_chunk = TrackpointChunk::default();
|
||||||
let mut wpt_chunk = WaypointChunk::default();
|
let mut wpt_chunk = WaypointChunk::default();
|
||||||
// Routes are read as tracks of a single segment, see `RouteReader`. The points of the detailed
|
// The segment of a route is read by `RouteReader`. The points of the detailed
|
||||||
// path that a route point comes with (`gpxx:rpt`) are collected until the route point is read.
|
// path that a route point comes with (`gpxx:rpt`) are collected until the route point is read.
|
||||||
let mut route = RouteReader::default();
|
let mut route = RouteReader::default();
|
||||||
let mut rpt_points: Vec<Trackpoint> = vec![];
|
let mut rpt_points: Vec<Trackpoint> = vec![];
|
||||||
@@ -530,12 +531,12 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"rte" => {
|
"rte" => {
|
||||||
if let Some(GPXElement::Track(mut trk)) = stack.pop() {
|
if let Some(GPXElement::Track(trk)) = stack.pop() {
|
||||||
let trkseg = std::mem::take(&mut route).finish();
|
gpx.rte.push(Route {
|
||||||
if !trkseg.is_empty() {
|
info: trk.info,
|
||||||
trk.trkseg.push(trkseg);
|
seg: std::mem::take(&mut route).finish(),
|
||||||
}
|
..Default::default()
|
||||||
gpx.trk.push(trk);
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"rtept" => {
|
"rtept" => {
|
||||||
@@ -740,38 +741,37 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_routes_as_tracks() {
|
fn test_parse_routes() {
|
||||||
let gpx = parse_data("with_routes");
|
let gpx = parse_data("with_routes");
|
||||||
|
|
||||||
assert_eq!(gpx.trk.len(), 2);
|
assert!(gpx.trk.is_empty());
|
||||||
let trk = &gpx.trk[0];
|
assert_eq!(gpx.rte.len(), 2);
|
||||||
assert_eq!(trk.info.name.as_deref(), Some("route 1"));
|
let rte = &gpx.rte[0];
|
||||||
assert_eq!(trk.info.type_.as_deref(), Some("Cycling"));
|
assert_eq!(rte.info.name.as_deref(), Some("route 1"));
|
||||||
assert_eq!(trk.trkseg.len(), 1);
|
assert_eq!(rte.info.type_.as_deref(), Some("Cycling"));
|
||||||
let trkseg = &trk.trkseg[0];
|
assert_eq!(rte.seg.len(), 49);
|
||||||
assert_eq!(trkseg.len(), 49);
|
let trkpt = &rte.seg[0];
|
||||||
let trkpt = &trkseg[0];
|
|
||||||
assert_eq!(trkpt.coordinates.lat, 50.790867);
|
assert_eq!(trkpt.coordinates.lat, 50.790867);
|
||||||
assert_eq!(trkpt.coordinates.lng, 4.404968);
|
assert_eq!(trkpt.coordinates.lng, 4.404968);
|
||||||
assert_eq!(trkpt.ele, 109.0);
|
assert_eq!(trkpt.ele, 109.0);
|
||||||
|
|
||||||
let trk = &gpx.trk[1];
|
let rte = &gpx.rte[1];
|
||||||
assert_eq!(trk.info.name.as_deref(), Some("route 2"));
|
assert_eq!(rte.info.name.as_deref(), Some("route 2"));
|
||||||
assert_eq!(trk.trkseg[0].len(), 28);
|
assert_eq!(rte.seg.len(), 28);
|
||||||
|
assert_ne!(gpx.rte[0].id, rte.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_route_point_extensions() {
|
fn test_parse_route_point_extensions() {
|
||||||
let gpx = parse_data("with_route_extensions");
|
let gpx = parse_data("with_route_extensions");
|
||||||
|
|
||||||
// a route without points has no segment
|
// a route without points has an empty segment
|
||||||
assert_eq!(gpx.trk.len(), 2);
|
assert_eq!(gpx.rte.len(), 2);
|
||||||
assert!(gpx.trk[1].trkseg.is_empty());
|
assert!(gpx.rte[1].seg.is_empty());
|
||||||
|
|
||||||
let trk = &gpx.trk[0];
|
let rte = &gpx.rte[0];
|
||||||
assert_eq!(trk.info.name.as_deref(), Some("with extensions"));
|
assert_eq!(rte.info.name.as_deref(), Some("with extensions"));
|
||||||
assert_eq!(trk.trkseg.len(), 1);
|
let points: Vec<_> = rte.seg.iter().collect();
|
||||||
let points: Vec<_> = trk.trkseg[0].iter().collect();
|
|
||||||
// the route points are kept, the points of the detailed path follow the one they come with
|
// the route points are kept, the points of the detailed path follow the one they come with
|
||||||
let coordinates: Vec<_> = points
|
let coordinates: Vec<_> = points
|
||||||
.iter()
|
.iter()
|
||||||
@@ -788,14 +788,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_route_points_are_the_anchors_and_nothing_else_is() {
|
fn test_route_points_are_the_anchors_and_nothing_else_is() {
|
||||||
let gpx = parse_data("with_route_extensions");
|
let gpx = parse_data("with_route_extensions");
|
||||||
let anchors: Vec<_> = gpx.trk[0].trkseg[0].iter().map(|p| p.anchor).collect();
|
let anchors: Vec<_> = gpx.rte[0].seg.iter().map(|p| p.anchor).collect();
|
||||||
assert_eq!(anchors, [Some(0), None, None, Some(0), Some(0)]);
|
assert_eq!(anchors, [Some(0), None, None, Some(0), Some(0)]);
|
||||||
|
|
||||||
// no anchor is computed for the points of a route that has none in between
|
// no anchor is computed for the points of a route that has none in between
|
||||||
let gpx = parse_data("with_routes");
|
let gpx = parse_data("with_routes");
|
||||||
for trk in &gpx.trk {
|
for rte in &gpx.rte {
|
||||||
let segment = &trk.trkseg[0];
|
assert!(rte.seg.iter().all(|p| p.anchor == Some(0)));
|
||||||
assert!(segment.iter().all(|p| p.anchor == Some(0)));
|
|
||||||
}
|
}
|
||||||
// while a track gets the anchors that its shape needs
|
// while a track gets the anchors that its shape needs
|
||||||
let track = parse_data("with_tracks");
|
let track = parse_data("with_tracks");
|
||||||
@@ -807,14 +806,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_parse_the_points_of_the_detailed_path_of_a_route() {
|
fn test_parse_the_points_of_the_detailed_path_of_a_route() {
|
||||||
let gpx = parse_data("with_route_points");
|
let gpx = parse_data("with_route_points");
|
||||||
assert_eq!(gpx.trk.len(), 1);
|
assert_eq!(gpx.rte.len(), 1);
|
||||||
let trk = &gpx.trk[0];
|
let rte = &gpx.rte[0];
|
||||||
assert_eq!(trk.info.name.as_deref(), Some("Lausanne"));
|
assert_eq!(rte.info.name.as_deref(), Some("Lausanne"));
|
||||||
assert_eq!(trk.info.cmt.as_deref(), Some("comment"));
|
assert_eq!(rte.info.cmt.as_deref(), Some("comment"));
|
||||||
assert_eq!(trk.info.desc.as_deref(), Some("description"));
|
assert_eq!(rte.info.desc.as_deref(), Some("description"));
|
||||||
assert_eq!(trk.info.type_.as_deref(), Some("Cycling"));
|
assert_eq!(rte.info.type_.as_deref(), Some("Cycling"));
|
||||||
|
|
||||||
let points: Vec<_> = trk.trkseg[0].iter().cloned().collect();
|
let points: Vec<_> = rte.seg.iter().cloned().collect();
|
||||||
// 3 route points and 4 points between them, the repeated one being kept
|
// 3 route points and 4 points between them, the repeated one being kept
|
||||||
assert_eq!(points.len(), 7);
|
assert_eq!(points.len(), 7);
|
||||||
let kinds: Vec<_> = points.iter().map(|p| p.anchor.is_some()).collect();
|
let kinds: Vec<_> = points.iter().map(|p| p.anchor.is_some()).collect();
|
||||||
@@ -846,7 +845,7 @@ mod tests {
|
|||||||
</rtept>
|
</rtept>
|
||||||
</rte></gpx>"#;
|
</rte></gpx>"#;
|
||||||
let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap();
|
let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap();
|
||||||
let points: Vec<_> = gpx.trk[0].trkseg[0].iter().cloned().collect();
|
let points: Vec<_> = gpx.rte[0].seg.iter().cloned().collect();
|
||||||
let coordinates: Vec<_> = points.iter().map(|p| p.coordinates.lat).collect();
|
let coordinates: Vec<_> = points.iter().map(|p| p.coordinates.lat).collect();
|
||||||
assert_eq!(coordinates, [1.0, 2.0, 2.1, 2.2]);
|
assert_eq!(coordinates, [1.0, 2.0, 2.1, 2.2]);
|
||||||
// the route point is an anchor, and so is the end of the path
|
// the route point is an anchor, and so is the end of the path
|
||||||
@@ -861,7 +860,7 @@ mod tests {
|
|||||||
<gpxx:RoutePointExtension><gpxx:rpt lat="1.5" lon="1.5"/></gpxx:RoutePointExtension>
|
<gpxx:RoutePointExtension><gpxx:rpt lat="1.5" lon="1.5"/></gpxx:RoutePointExtension>
|
||||||
</extensions></rtept></rte></gpx>"#;
|
</extensions></rtept></rte></gpx>"#;
|
||||||
let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap();
|
let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap();
|
||||||
let anchors: Vec<_> = gpx.trk[0].trkseg[0].iter().map(|p| p.anchor).collect();
|
let anchors: Vec<_> = gpx.rte[0].seg.iter().map(|p| p.anchor).collect();
|
||||||
assert_eq!(anchors, [Some(0), Some(0)]);
|
assert_eq!(anchors, [Some(0), Some(0)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -930,14 +929,14 @@ mod tests {
|
|||||||
assert_eq!(waypoints[0].links[2].text.as_deref(), Some("three"));
|
assert_eq!(waypoints[0].links[2].text.as_deref(), Some("three"));
|
||||||
assert!(waypoints[1].links.is_empty());
|
assert!(waypoints[1].links.is_empty());
|
||||||
|
|
||||||
// tracks, and routes which are read as tracks
|
// tracks, and routes
|
||||||
assert_eq!(gpx.trk.len(), 2);
|
assert_eq!((gpx.trk.len(), gpx.rte.len()), (1, 1));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
hrefs(&gpx.trk[0].info.links),
|
hrefs(&gpx.trk[0].info.links),
|
||||||
["https://example.com/trk-1", "https://example.com/trk-2"]
|
["https://example.com/trk-1", "https://example.com/trk-2"]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
hrefs(&gpx.trk[1].info.links),
|
hrefs(&gpx.rte[0].info.links),
|
||||||
["https://example.com/rte-1", "https://example.com/rte-2"]
|
["https://example.com/rte-1", "https://example.com/rte-2"]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+147
-122
@@ -8,9 +8,6 @@ use crate::{File, Link, Track, Trackpoint, TrackpointCategories, Waypoint};
|
|||||||
|
|
||||||
/// What the written file contains: the data of the trackpoints that are `false` are left out.
|
/// What the written file contains: the data of the trackpoints that are `false` are left out.
|
||||||
/// The same type tells which data a file has, see [`File::exportable_data`].
|
/// The same type tells which data a file has, see [`File::exportable_data`].
|
||||||
///
|
|
||||||
/// With `as_route`, the file is written with routes instead of tracks, see [`write`]. It is not
|
|
||||||
/// data: it is off in `ALL` and `NONE`, and in what [`File::exportable_data`] gives.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct ExportOptions {
|
pub struct ExportOptions {
|
||||||
pub time: bool,
|
pub time: bool,
|
||||||
@@ -20,8 +17,6 @@ pub struct ExportOptions {
|
|||||||
pub power: bool,
|
pub power: bool,
|
||||||
/// The OpenStreetMap data: surface, highway, SAC scale and MTB scale.
|
/// The OpenStreetMap data: surface, highway, SAC scale and MTB scale.
|
||||||
pub osm: bool,
|
pub osm: bool,
|
||||||
/// Writes every segment as a route (`rte`) instead of a track.
|
|
||||||
pub as_route: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExportOptions {
|
impl ExportOptions {
|
||||||
@@ -36,7 +31,6 @@ impl ExportOptions {
|
|||||||
atemp: value,
|
atemp: value,
|
||||||
power: value,
|
power: value,
|
||||||
osm: value,
|
osm: value,
|
||||||
as_route: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +43,6 @@ impl ExportOptions {
|
|||||||
atemp: self.atemp || other.atemp,
|
atemp: self.atemp || other.atemp,
|
||||||
power: self.power || other.power,
|
power: self.power || other.power,
|
||||||
osm: self.osm || other.osm,
|
osm: self.osm || other.osm,
|
||||||
as_route: self.as_route || other.as_route,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,12 +52,7 @@ impl File {
|
|||||||
/// options of [`write`] can leave out.
|
/// options of [`write`] can leave out.
|
||||||
pub fn exportable_data(&self) -> ExportOptions {
|
pub fn exportable_data(&self) -> ExportOptions {
|
||||||
let mut data = ExportOptions::NONE;
|
let mut data = ExportOptions::NONE;
|
||||||
for point in self
|
for point in self.segments().flat_map(|seg| seg.iter()) {
|
||||||
.trk
|
|
||||||
.iter()
|
|
||||||
.flat_map(|trk| &trk.trkseg)
|
|
||||||
.flat_map(|seg| seg.iter())
|
|
||||||
{
|
|
||||||
data.time |= point.time.is_some();
|
data.time |= point.time.is_some();
|
||||||
data.hr |= point.hr.is_some();
|
data.hr |= point.hr.is_some();
|
||||||
data.cad |= point.cad.is_some();
|
data.cad |= point.cad.is_some();
|
||||||
@@ -82,6 +70,10 @@ impl File {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The highest zoom level from which an anchor is shown that makes it a route point, see [`write`]:
|
||||||
|
/// the anchors that only appear when zooming in further are details of the path.
|
||||||
|
pub const ROUTE_POINT_MAX_ZOOM: u8 = 12;
|
||||||
|
|
||||||
const SCHEMA_LOCATION: &str = "http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd";
|
const SCHEMA_LOCATION: &str = "http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd";
|
||||||
|
|
||||||
type XmlWriter = Writer<Vec<u8>>;
|
type XmlWriter = Writer<Vec<u8>>;
|
||||||
@@ -89,10 +81,10 @@ type XmlWriter = Writer<Vec<u8>>;
|
|||||||
/// Writes a file as GPX 1.1, in UTF-8. The surface, highway, SAC scale and MTB scale of the trackpoints are
|
/// Writes a file as GPX 1.1, in UTF-8. The surface, highway, SAC scale and MTB scale of the trackpoints are
|
||||||
/// the names of their codes in `categories`.
|
/// the names of their codes in `categories`.
|
||||||
///
|
///
|
||||||
/// The segments are written as tracks, or with `options.as_route` as routes: a segment becomes a
|
/// The routes are written as routes (`rte`): the anchors of their segment that are shown from the
|
||||||
/// `rte` that has the information of its track, its anchors are the `rtept` and the trackpoints
|
/// zoom level [`ROUTE_POINT_MAX_ZOOM`] or a lower one are the `rtept`, and the trackpoints between
|
||||||
/// between two anchors are the `gpxx:rpt` of the `rtept` before them (which only have a
|
/// two of them are the `gpxx:rpt` of the `rtept` before them (which only have a position, unlike
|
||||||
/// position, unlike the `rtept`). The first and last trackpoints always are `rtept`.
|
/// the `rtept`). The first and last trackpoints always are `rtept`.
|
||||||
pub fn write(file: &File, categories: &TrackpointCategories, options: ExportOptions) -> Vec<u8> {
|
pub fn write(file: &File, categories: &TrackpointCategories, options: ExportOptions) -> Vec<u8> {
|
||||||
let mut writer = Writer::new_with_indent(Vec::new(), b' ', 4);
|
let mut writer = Writer::new_with_indent(Vec::new(), b' ', 4);
|
||||||
// writing to memory does not fail
|
// writing to memory does not fail
|
||||||
@@ -100,9 +92,9 @@ pub fn write(file: &File, categories: &TrackpointCategories, options: ExportOpti
|
|||||||
writer.into_inner()
|
writer.into_inner()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_file(
|
fn write_file<'a>(
|
||||||
w: &mut XmlWriter,
|
w: &mut XmlWriter,
|
||||||
file: &File,
|
file: &'a File,
|
||||||
categories: &TrackpointCategories,
|
categories: &TrackpointCategories,
|
||||||
options: ExportOptions,
|
options: ExportOptions,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
@@ -136,20 +128,18 @@ fn write_file(
|
|||||||
for wpt in file.wpt.iter() {
|
for wpt in file.wpt.iter() {
|
||||||
write_waypoint(w, wpt, options)?;
|
write_waypoint(w, wpt, options)?;
|
||||||
}
|
}
|
||||||
|
// a single unnamed route or track is named like the file
|
||||||
|
let alone = file.rte.len() + file.trk.len() == 1;
|
||||||
|
let name = |info: &'a crate::TrackInfo| match &info.name {
|
||||||
|
Some(name) if !name.is_empty() => Some(name.as_str()),
|
||||||
|
_ if alone => Some(file.info.name.as_str()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
for rte in &file.rte {
|
||||||
|
write_route(w, &rte.info, &rte.seg, name(&rte.info), categories, options)?;
|
||||||
|
}
|
||||||
for trk in &file.trk {
|
for trk in &file.trk {
|
||||||
// a single unnamed track is named like the file
|
write_track(w, trk, name(&trk.info), categories, options)?;
|
||||||
let name = match (&trk.info.name, file.trk.len()) {
|
|
||||||
(Some(name), _) if !name.is_empty() => Some(name.as_str()),
|
|
||||||
(_, 1) => Some(file.info.name.as_str()),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
if options.as_route {
|
|
||||||
for segment in trk.trkseg.iter().filter(|segment| !segment.is_empty()) {
|
|
||||||
write_route(w, trk, segment, name, categories, options)?;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
write_track(w, trk, name, categories, options)?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
@@ -311,22 +301,28 @@ fn write_track(
|
|||||||
|
|
||||||
fn write_route(
|
fn write_route(
|
||||||
w: &mut XmlWriter,
|
w: &mut XmlWriter,
|
||||||
trk: &Track,
|
info: &crate::TrackInfo,
|
||||||
segment: &crate::TrackSegment,
|
segment: &crate::TrackSegment,
|
||||||
name: Option<&str>,
|
name: Option<&str>,
|
||||||
categories: &TrackpointCategories,
|
categories: &TrackpointCategories,
|
||||||
options: ExportOptions,
|
options: ExportOptions,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
w.create_element("rte").write_inner_content(|w| {
|
w.create_element("rte").write_inner_content(|w| {
|
||||||
write_track_info(w, name, &trk.info)?;
|
write_track_info(w, name, info)?;
|
||||||
let points: Vec<&Trackpoint> = segment.iter().collect();
|
let points: Vec<&Trackpoint> = segment.iter().collect();
|
||||||
let last = points.len() - 1;
|
let last = points.len().saturating_sub(1);
|
||||||
let is_anchor = |i: usize| i == 0 || i == last || points[i].anchor.is_some();
|
let is_route_point = |i: usize| {
|
||||||
|
i == 0
|
||||||
|
|| i == last
|
||||||
|
|| points[i]
|
||||||
|
.anchor
|
||||||
|
.is_some_and(|zoom| zoom <= ROUTE_POINT_MAX_ZOOM)
|
||||||
|
};
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i < points.len() {
|
while i < points.len() {
|
||||||
// the trackpoints up to the next anchor
|
// the trackpoints up to the next route point
|
||||||
let mut next = i + 1;
|
let mut next = i + 1;
|
||||||
while next < points.len() && !is_anchor(next) {
|
while next < points.len() && !is_route_point(next) {
|
||||||
next += 1;
|
next += 1;
|
||||||
}
|
}
|
||||||
write_point(
|
write_point(
|
||||||
@@ -455,9 +451,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn points(file: &File) -> Vec<Trackpoint> {
|
fn points(file: &File) -> Vec<Trackpoint> {
|
||||||
file.trk
|
file.segments()
|
||||||
.iter()
|
|
||||||
.flat_map(|trk| &trk.trkseg)
|
|
||||||
.flat_map(|seg| seg.iter().cloned())
|
.flat_map(|seg| seg.iter().cloned())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -595,11 +589,20 @@ mod tests {
|
|||||||
assert!(file.exportable_data().power);
|
assert!(file.exportable_data().power);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn as_route() -> ExportOptions {
|
/// The file with each segment of its tracks as a route, which has the information of its
|
||||||
ExportOptions {
|
/// track.
|
||||||
as_route: true,
|
fn as_routes(mut file: File) -> File {
|
||||||
..ExportOptions::ALL
|
file.rte = std::mem::take(&mut file.trk)
|
||||||
}
|
.into_iter()
|
||||||
|
.flat_map(|trk| {
|
||||||
|
trk.trkseg.into_iter().map(move |seg| crate::Route {
|
||||||
|
info: trk.info.clone(),
|
||||||
|
seg,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
file
|
||||||
}
|
}
|
||||||
|
|
||||||
fn count(text: &str, pattern: &str) -> usize {
|
fn count(text: &str, pattern: &str) -> usize {
|
||||||
@@ -607,19 +610,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_export_as_route_writes_routes_instead_of_tracks() {
|
fn test_routes_are_written_with_their_route_points_and_their_path() {
|
||||||
let (file, categories) = read("with_route_points");
|
let (file, categories) = read("with_route_points");
|
||||||
let text = written_text(&file, &categories, as_route());
|
let text = written_text(&file, &categories, ExportOptions::ALL);
|
||||||
|
|
||||||
assert_eq!(count(&text, "<rte>"), 1);
|
assert_eq!(count(&text, "<rte>"), 1);
|
||||||
assert_eq!(count(&text, "<trk>"), 0);
|
assert_eq!(count(&text, "<trk>"), 0);
|
||||||
assert_eq!(count(&text, "<trkseg>"), 0);
|
|
||||||
assert_eq!(count(&text, "<trkpt"), 0);
|
assert_eq!(count(&text, "<trkpt"), 0);
|
||||||
// the anchors are the route points, the trackpoints between them the points of their path
|
// the anchors are the route points, the trackpoints between them the points of their path
|
||||||
assert_eq!(count(&text, "<rtept"), 3);
|
assert_eq!(count(&text, "<rtept"), 3);
|
||||||
assert_eq!(count(&text, "<gpxx:rpt"), 4);
|
assert_eq!(count(&text, "<gpxx:rpt"), 4);
|
||||||
assert_eq!(count(&text, "<gpxx:RoutePointExtension>"), 2);
|
assert_eq!(count(&text, "<gpxx:RoutePointExtension>"), 2);
|
||||||
// a route has the information of its track
|
|
||||||
assert!(text.contains("<name>Lausanne</name>"));
|
assert!(text.contains("<name>Lausanne</name>"));
|
||||||
assert!(text.contains("<cmt>comment</cmt>"));
|
assert!(text.contains("<cmt>comment</cmt>"));
|
||||||
assert!(text.contains("<desc>description</desc>"));
|
assert!(text.contains("<desc>description</desc>"));
|
||||||
@@ -634,14 +635,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_a_route_survives_being_exported_as_a_route() {
|
fn test_a_route_survives_being_written() {
|
||||||
let (file, mut categories) = read("with_route_points");
|
let (file, mut categories) = read("with_route_points");
|
||||||
let written = write(&file, &categories, as_route());
|
let written = write(&file, &categories, ExportOptions::ALL);
|
||||||
let again = parse(&written, &mut categories).unwrap();
|
let again = parse(&written, &mut categories).unwrap();
|
||||||
|
|
||||||
assert_eq!(again.trk.len(), 1);
|
assert_eq!(again.rte.len(), 1);
|
||||||
assert_eq!(again.trk[0].info, file.trk[0].info);
|
assert_eq!(again.rte[0].info, file.rte[0].info);
|
||||||
let (a, b) = (&file.trk[0].trkseg[0], &again.trk[0].trkseg[0]);
|
let (a, b) = (&file.rte[0].seg, &again.rte[0].seg);
|
||||||
assert_eq!(a.len(), b.len());
|
assert_eq!(a.len(), b.len());
|
||||||
for (a, b) in a.iter().zip(b.iter()) {
|
for (a, b) in a.iter().zip(b.iter()) {
|
||||||
assert_eq!(a.coordinates.lat, b.coordinates.lat);
|
assert_eq!(a.coordinates.lat, b.coordinates.lat);
|
||||||
@@ -659,85 +660,121 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_tracks_become_one_route_per_segment_with_the_information_of_the_track() {
|
fn test_the_anchors_shown_up_to_zoom_12_are_the_route_points() {
|
||||||
let (file, mut categories) = read("with_tracks_and_segments");
|
let (file, mut categories) = read("with_tracks_and_segments");
|
||||||
let segments: usize = file.trk.iter().map(|t| t.trkseg.len()).sum();
|
let file = as_routes(file);
|
||||||
assert!(segments > 2);
|
assert!(file.rte.len() > 2);
|
||||||
let written = write(&file, &categories, as_route());
|
let written = write(&file, &categories, ExportOptions::ALL);
|
||||||
let text = String::from_utf8(written.clone()).unwrap();
|
|
||||||
assert_eq!(count(&text, "<rte>"), segments);
|
|
||||||
assert_eq!(count(&text, "<trk>"), 0);
|
|
||||||
// every route starts and ends with a route point
|
|
||||||
assert_eq!(count(&text, "</rte>"), segments);
|
|
||||||
|
|
||||||
let again = parse(&written, &mut categories).unwrap();
|
let again = parse(&written, &mut categories).unwrap();
|
||||||
assert_eq!(again.trk.len(), segments);
|
assert_eq!(again.rte.len(), file.rte.len());
|
||||||
let mut routes = again.trk.iter();
|
let anchors = |s: &crate::TrackSegment, max_zoom: u8| {
|
||||||
for track in &file.trk {
|
s.iter()
|
||||||
for segment in &track.trkseg {
|
.enumerate()
|
||||||
let route = routes.next().unwrap();
|
.filter(|(_, p)| p.anchor.is_some_and(|zoom| zoom <= max_zoom))
|
||||||
// the information of the track, repeated for each of its segments
|
.map(|(i, _)| i)
|
||||||
assert_eq!(route.info.name, track.info.name);
|
.collect::<Vec<_>>()
|
||||||
assert_eq!(route.info.type_, track.info.type_);
|
};
|
||||||
let (a, b) = (segment, &route.trkseg[0]);
|
for (a, b) in file.rte.iter().zip(&again.rte) {
|
||||||
assert_eq!(a.len(), b.len());
|
assert_eq!(a.info.name, b.info.name);
|
||||||
for (a, b) in a.iter().zip(b.iter()) {
|
assert_eq!(a.seg.len(), b.seg.len());
|
||||||
assert_eq!(
|
// the route points come back as anchors, the other anchors as points of the path
|
||||||
(a.coordinates.lat, a.coordinates.lng),
|
assert_eq!(
|
||||||
(b.coordinates.lat, b.coordinates.lng)
|
anchors(&a.seg, ROUTE_POINT_MAX_ZOOM),
|
||||||
);
|
anchors(&b.seg, u8::MAX)
|
||||||
}
|
);
|
||||||
// the anchors of the segment are the ones of its route
|
|
||||||
let anchors = |s: &crate::TrackSegment| {
|
|
||||||
s.iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter(|(_, p)| p.anchor.is_some())
|
|
||||||
.map(|(i, _)| i)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
};
|
|
||||||
assert_eq!(anchors(a), anchors(b));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anchors_shown_from_a_higher_zoom_are_points_of_the_path() {
|
||||||
|
let (file, categories) = read("simple");
|
||||||
|
let mut file = as_routes(file);
|
||||||
|
let segment = &mut file.rte[0].seg;
|
||||||
|
segment.update_all(|_, point| point.anchor = None);
|
||||||
|
let len = segment.len();
|
||||||
|
segment.set_anchor(5, ROUTE_POINT_MAX_ZOOM);
|
||||||
|
segment.set_anchor(10, ROUTE_POINT_MAX_ZOOM + 1);
|
||||||
|
let text = written_text(&file, &categories, ExportOptions::ALL);
|
||||||
|
// the ends, and the anchor shown from zoom 12
|
||||||
|
assert_eq!(count(&text, "<rtept"), 3);
|
||||||
|
assert_eq!(count(&text, "<gpxx:rpt"), len - 3);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_the_ends_of_a_route_are_route_points_even_without_anchors() {
|
fn test_the_ends_of_a_route_are_route_points_even_without_anchors() {
|
||||||
let (mut file, categories) = read("simple");
|
let (file, categories) = read("simple");
|
||||||
file.trk[0].trkseg[0].update_all(|_, point| point.anchor = None);
|
let mut file = as_routes(file);
|
||||||
let len = file.trk[0].trkseg[0].len();
|
file.rte[0].seg.update_all(|_, point| point.anchor = None);
|
||||||
let text = written_text(&file, &categories, as_route());
|
let len = file.rte[0].seg.len();
|
||||||
|
let text = written_text(&file, &categories, ExportOptions::ALL);
|
||||||
assert_eq!(count(&text, "<rtept"), 2);
|
assert_eq!(count(&text, "<rtept"), 2);
|
||||||
assert_eq!(count(&text, "<gpxx:rpt"), len - 2);
|
assert_eq!(count(&text, "<gpxx:rpt"), len - 2);
|
||||||
// and a single trackpoint is a route of one point
|
// and a single trackpoint is a route of one point
|
||||||
file.trk[0].trkseg[0].splice(1, len, vec![]);
|
file.rte[0].seg.splice(1, len, vec![]);
|
||||||
let text = written_text(&file, &categories, as_route());
|
let text = written_text(&file, &categories, ExportOptions::ALL);
|
||||||
assert_eq!(count(&text, "<rtept"), 1);
|
assert_eq!(count(&text, "<rtept"), 1);
|
||||||
assert_eq!(count(&text, "<gpxx:rpt"), 0);
|
assert_eq!(count(&text, "<gpxx:rpt"), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_empty_segments_are_not_routes() {
|
fn test_routes_keep_their_style() {
|
||||||
let (mut file, categories) = read("simple");
|
|
||||||
file.trk[0].trkseg.push(crate::TrackSegment::default());
|
|
||||||
let text = written_text(&file, &categories, as_route());
|
|
||||||
assert_eq!(count(&text, "<rte>"), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_routes_have_the_style_of_their_track() {
|
|
||||||
let (file, categories) = read("with_style");
|
let (file, categories) = read("with_style");
|
||||||
let text = written_text(&file, &categories, as_route());
|
let file = as_routes(file);
|
||||||
|
let text = written_text(&file, &categories, ExportOptions::ALL);
|
||||||
assert!(count(&text, "<gpx_style:line>") >= 1);
|
assert!(count(&text, "<gpx_style:line>") >= 1);
|
||||||
assert_eq!(count(&text, "<gpx_style:line>"), count(&text, "<rte>"));
|
assert_eq!(count(&text, "<gpx_style:line>"), count(&text, "<rte>"));
|
||||||
let again = parse(text.as_bytes(), &mut Default::default()).unwrap();
|
let again = parse(text.as_bytes(), &mut Default::default()).unwrap();
|
||||||
assert_eq!(again.trk[0].info.color, file.trk[0].info.color);
|
assert_eq!(again.rte[0].info.color, file.rte[0].info.color);
|
||||||
assert_eq!(again.trk[0].info.width, file.trk[0].info.width);
|
assert_eq!(again.rte[0].info.width, file.rte[0].info.width);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_routes_are_written_as_routes_before_the_tracks() {
|
||||||
|
let (mut file, categories) = read("with_tracks");
|
||||||
|
let (routes, _) = read("with_routes");
|
||||||
|
file.rte = routes.rte.clone();
|
||||||
|
let text = written_text(&file, &categories, ExportOptions::ALL);
|
||||||
|
assert_eq!(count(&text, "<rte>"), routes.rte.len());
|
||||||
|
assert_eq!(count(&text, "<trk>"), file.trk.len());
|
||||||
|
assert!(text.rfind("</rte>").unwrap() < text.find("<trk>").unwrap());
|
||||||
|
|
||||||
|
let again = parse(text.as_bytes(), &mut categories.clone()).unwrap();
|
||||||
|
assert_eq!(again.rte.len(), file.rte.len());
|
||||||
|
assert_eq!(again.trk.len(), file.trk.len());
|
||||||
|
for (a, b) in file.rte.iter().zip(&again.rte) {
|
||||||
|
assert_eq!(a.info, b.info);
|
||||||
|
same_points(
|
||||||
|
&a.seg.iter().cloned().collect::<Vec<_>>(),
|
||||||
|
&b.seg.iter().cloned().collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
same_points(&points(&file), &points(&again));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_an_empty_route_is_written_and_a_single_route_is_named_like_the_file() {
|
||||||
|
let mut file = File::default();
|
||||||
|
file.info.name = "file".into();
|
||||||
|
file.rte.push(crate::Route::default());
|
||||||
|
let text = written_text(&file, &Default::default(), ExportOptions::ALL);
|
||||||
|
assert_eq!(count(&text, "<rte>"), 1);
|
||||||
|
assert_eq!(count(&text, "<rtept"), 0);
|
||||||
|
let again = parse(text.as_bytes(), &mut Default::default()).unwrap();
|
||||||
|
assert_eq!(again.rte.len(), 1);
|
||||||
|
assert_eq!(again.rte[0].info.name.as_deref(), Some("file"));
|
||||||
|
|
||||||
|
// not when there are others
|
||||||
|
file.trk.push(Track::default());
|
||||||
|
let text = written_text(&file, &Default::default(), ExportOptions::ALL);
|
||||||
|
// the metadata only
|
||||||
|
assert_eq!(count(&text, "<name>file</name>"), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_route_points_keep_their_data_and_the_options_apply_to_them() {
|
fn test_route_points_keep_their_data_and_the_options_apply_to_them() {
|
||||||
let (file, categories) = read("with_hr");
|
let (file, categories) = read("with_hr");
|
||||||
let with_all = written_text(&file, &categories, as_route());
|
let file = as_routes(file);
|
||||||
|
let with_all = written_text(&file, &categories, ExportOptions::ALL);
|
||||||
assert!(with_all.contains("<gpxtpx:hr>"));
|
assert!(with_all.contains("<gpxtpx:hr>"));
|
||||||
let without = written_text(
|
let without = written_text(
|
||||||
&file,
|
&file,
|
||||||
@@ -745,22 +782,10 @@ mod tests {
|
|||||||
ExportOptions {
|
ExportOptions {
|
||||||
hr: false,
|
hr: false,
|
||||||
time: false,
|
time: false,
|
||||||
as_route: true,
|
|
||||||
..ExportOptions::ALL
|
..ExportOptions::ALL
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert!(!without.contains("<gpxtpx:hr>"));
|
assert!(!without.contains("<gpxtpx:hr>"));
|
||||||
assert!(!without.contains("<time>"));
|
assert!(!without.contains("<time>"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_the_route_option_is_not_data() {
|
|
||||||
let (file, _) = read("with_route_points");
|
|
||||||
assert!(!file.exportable_data().as_route);
|
|
||||||
// it is a way to write the data, never part of them
|
|
||||||
let all = std::hint::black_box(ExportOptions::ALL);
|
|
||||||
assert!(!all.as_route && !ExportOptions::NONE.as_route);
|
|
||||||
assert!(as_route().union(ExportOptions::NONE).as_route);
|
|
||||||
assert!(!ExportOptions::ALL.union(ExportOptions::NONE).as_route);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
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.
|
||||||
|
///
|
||||||
|
/// The segments keep their ids, the new routes and tracks are 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: Vec<Track> = converted
|
||||||
|
.into_iter()
|
||||||
|
.map(|rte| Track {
|
||||||
|
id: Default::default(),
|
||||||
|
info: rte.info,
|
||||||
|
trkseg: vec![rte.seg],
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
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;
|
||||||
|
let routes = converted.into_iter().flat_map(|trk| {
|
||||||
|
trk.trkseg.into_iter().map(move |seg| Route {
|
||||||
|
id: Default::default(),
|
||||||
|
info: trk.info.clone(),
|
||||||
|
seg,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
add_routes(&mut file.rte, *file_id, routes)
|
||||||
|
}
|
||||||
|
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::Empty
|
||||||
|
| Selection::File { .. }
|
||||||
|
| 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
use crate::{Apply, CommandError, Edit, Editor, File, State, Track, update_selected};
|
use crate::{
|
||||||
|
Apply, CommandError, Edit, Editor, File, Route, State, Track, TrackInfo, update_selected,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Metadata<'a> {
|
pub struct Metadata<'a> {
|
||||||
@@ -13,21 +15,33 @@ impl Apply for Metadata<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<'_> {
|
impl Editor for Metadata<'_> {
|
||||||
fn file(&mut self, file: &mut File) -> Edit {
|
fn file(&mut self, file: &mut File) -> Edit {
|
||||||
file.info.name = self.name.to_owned();
|
file.info.name = self.name.to_owned();
|
||||||
file.info.desc = Some(self.desc.to_owned());
|
file.info.desc = Some(self.desc.to_owned());
|
||||||
// a single track is named like the file
|
// a single route or track is named like the file
|
||||||
if let [track] = file.trk.as_mut_slice() {
|
match (file.rte.as_mut_slice(), file.trk.as_mut_slice()) {
|
||||||
track.info.name = Some(self.name.to_owned());
|
([route], []) => route.info.name = Some(self.name.to_owned()),
|
||||||
|
([], [track]) => track.info.name = Some(self.name.to_owned()),
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
Edit::Changed
|
Edit::Changed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn route(&mut self, route: &mut Route) -> Edit {
|
||||||
|
self.set(&mut route.info)
|
||||||
|
}
|
||||||
|
|
||||||
fn track(&mut self, track: &mut Track) -> Edit {
|
fn track(&mut self, track: &mut Track) -> Edit {
|
||||||
track.info.name = Some(self.name.to_owned());
|
self.set(&mut track.info)
|
||||||
track.info.desc = Some(self.desc.to_owned());
|
|
||||||
Edit::Changed
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,12 +59,14 @@ mod tests {
|
|||||||
New {
|
New {
|
||||||
name: "first",
|
name: "first",
|
||||||
trackpoint: None,
|
trackpoint: None,
|
||||||
|
route: false,
|
||||||
}
|
}
|
||||||
.apply(&mut fx.state())
|
.apply(&mut fx.state())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
New {
|
New {
|
||||||
name: "second",
|
name: "second",
|
||||||
trackpoint: None,
|
trackpoint: None,
|
||||||
|
route: false,
|
||||||
}
|
}
|
||||||
.apply(&mut fx.state())
|
.apply(&mut fx.state())
|
||||||
.unwrap(); // now selected
|
.unwrap(); // now selected
|
||||||
@@ -108,6 +124,7 @@ mod tests {
|
|||||||
New {
|
New {
|
||||||
name: "file",
|
name: "file",
|
||||||
trackpoint: None,
|
trackpoint: None,
|
||||||
|
route: false,
|
||||||
}
|
}
|
||||||
.apply(&mut fx.state())
|
.apply(&mut fx.state())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
mod convert;
|
||||||
mod metadata;
|
mod metadata;
|
||||||
|
mod new_route;
|
||||||
mod new_track;
|
mod new_track;
|
||||||
mod new_track_segment;
|
mod new_track_segment;
|
||||||
mod style;
|
mod style;
|
||||||
|
|
||||||
|
pub use convert::*;
|
||||||
pub use metadata::*;
|
pub use metadata::*;
|
||||||
|
pub use new_route::*;
|
||||||
pub use new_track::*;
|
pub use new_track::*;
|
||||||
pub use new_track_segment::*;
|
pub use new_track_segment::*;
|
||||||
pub use style::*;
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::{Apply, CommandError, Edit, Editor, File, State, Track, update_selected};
|
use crate::{Apply, CommandError, Edit, Editor, File, State, Track, TrackInfo, update_selected};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct NewTrack;
|
pub struct NewTrack;
|
||||||
@@ -12,20 +12,41 @@ impl Apply for NewTrack {
|
|||||||
|
|
||||||
impl Editor for NewTrack {
|
impl Editor for NewTrack {
|
||||||
fn file(&mut self, file: &mut File) -> Edit {
|
fn file(&mut self, file: &mut File) -> Edit {
|
||||||
let mut track = Track::default();
|
file.trk.push(Track {
|
||||||
track.info.color = common(file, |t| &t.info.color);
|
info: common_style(file),
|
||||||
track.info.opacity = common(file, |t| &t.info.opacity);
|
..Default::default()
|
||||||
track.info.width = common(file, |t| &t.info.width);
|
});
|
||||||
file.trk.push(track);
|
|
||||||
Edit::Changed
|
Edit::Changed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the value of a style attribute if all existing tracks agree on it.
|
/// The style that all the routes and tracks of the file agree on: each attribute is set if they
|
||||||
fn common<T: Clone + PartialEq>(file: &File, get: impl Fn(&Track) -> &Option<T>) -> Option<T> {
|
/// all have the same value for it.
|
||||||
let mut tracks = file.trk.iter();
|
pub(super) fn common_style(file: &File) -> TrackInfo {
|
||||||
let first = get(tracks.next()?);
|
let infos: Vec<&TrackInfo> = file
|
||||||
tracks.all(|t| get(t) == first).then(|| first.clone())?
|
.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)]
|
#[cfg(test)]
|
||||||
@@ -40,6 +61,7 @@ mod tests {
|
|||||||
New {
|
New {
|
||||||
name: "file",
|
name: "file",
|
||||||
trackpoint: None,
|
trackpoint: None,
|
||||||
|
route: false,
|
||||||
}
|
}
|
||||||
.apply(&mut fx.state())
|
.apply(&mut fx.state())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -56,6 +78,7 @@ mod tests {
|
|||||||
New {
|
New {
|
||||||
name: "file",
|
name: "file",
|
||||||
trackpoint: None,
|
trackpoint: None,
|
||||||
|
route: false,
|
||||||
}
|
}
|
||||||
.apply(&mut fx.state())
|
.apply(&mut fx.state())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ mod tests {
|
|||||||
New {
|
New {
|
||||||
name: "file",
|
name: "file",
|
||||||
trackpoint: None,
|
trackpoint: None,
|
||||||
|
route: false,
|
||||||
}
|
}
|
||||||
.apply(&mut fx.state())
|
.apply(&mut fx.state())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::{Apply, CommandError, Edit, Editor, State, Track, update_selected};
|
use crate::{Apply, CommandError, Edit, Editor, Route, State, Track, TrackInfo, update_selected};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Style<'a> {
|
pub struct Style<'a> {
|
||||||
@@ -14,21 +14,31 @@ impl Apply for Style<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Editor for Style<'_> {
|
impl Style<'_> {
|
||||||
fn track(&mut self, track: &mut Track) -> Edit {
|
fn set(&self, info: &mut TrackInfo) -> Edit {
|
||||||
if let Some(color) = self.color {
|
if let Some(color) = self.color {
|
||||||
track.info.color = Some(color.to_owned());
|
info.color = Some(color.to_owned());
|
||||||
}
|
}
|
||||||
if self.opacity.is_some() {
|
if self.opacity.is_some() {
|
||||||
track.info.opacity = self.opacity;
|
info.opacity = self.opacity;
|
||||||
}
|
}
|
||||||
if self.width.is_some() {
|
if self.width.is_some() {
|
||||||
track.info.width = self.width;
|
info.width = self.width;
|
||||||
}
|
}
|
||||||
Edit::Changed
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::rc::Rc;
|
|||||||
use crate::{Apply, CommandError, FileId, Files, Selection, State, Waypoint};
|
use crate::{Apply, CommandError, FileId, Files, Selection, State, Waypoint};
|
||||||
|
|
||||||
/// Deletes the selected elements. With `whole_files`, the files holding the selected elements
|
/// 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.
|
/// are deleted instead, even if only a route, a track or a waypoint is selected.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Delete {
|
pub struct Delete {
|
||||||
pub whole_files: bool,
|
pub whole_files: bool,
|
||||||
@@ -23,6 +23,21 @@ impl Apply for Delete {
|
|||||||
state.order.0.retain(|id| !file_ids.contains(id));
|
state.order.0.retain(|id| !file_ids.contains(id));
|
||||||
Selection::Empty
|
Selection::Empty
|
||||||
}
|
}
|
||||||
|
Selection::Route { file_id, rte_ids } => {
|
||||||
|
let file = state
|
||||||
|
.files
|
||||||
|
.get_mut(file_id)
|
||||||
|
.ok_or(CommandError::NothingToDo)?;
|
||||||
|
let file = Rc::make_mut(file);
|
||||||
|
let len = file.rte.len();
|
||||||
|
file.rte.retain(|rte| !rte_ids.contains(&rte.id));
|
||||||
|
if file.rte.len() == len {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
Selection::File {
|
||||||
|
file_ids: [*file_id].into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
Selection::Track { file_id, trk_ids } => {
|
Selection::Track { file_id, trk_ids } => {
|
||||||
let file = state
|
let file = state
|
||||||
.files
|
.files
|
||||||
@@ -131,6 +146,7 @@ mod tests {
|
|||||||
crate::New {
|
crate::New {
|
||||||
name: "keep",
|
name: "keep",
|
||||||
trackpoint: None,
|
trackpoint: None,
|
||||||
|
route: false,
|
||||||
}
|
}
|
||||||
.apply(&mut fx.state())
|
.apply(&mut fx.state())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -265,6 +281,7 @@ mod tests {
|
|||||||
crate::New {
|
crate::New {
|
||||||
name: "keep",
|
name: "keep",
|
||||||
trackpoint: None,
|
trackpoint: None,
|
||||||
|
route: false,
|
||||||
}
|
}
|
||||||
.apply(&mut fx.state())
|
.apply(&mut fx.state())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -31,12 +31,14 @@ mod tests {
|
|||||||
crate::New {
|
crate::New {
|
||||||
name: "a",
|
name: "a",
|
||||||
trackpoint: None,
|
trackpoint: None,
|
||||||
|
route: false,
|
||||||
}
|
}
|
||||||
.apply(&mut fx.state())
|
.apply(&mut fx.state())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
crate::New {
|
crate::New {
|
||||||
name: "b",
|
name: "b",
|
||||||
trackpoint: None,
|
trackpoint: None,
|
||||||
|
route: false,
|
||||||
}
|
}
|
||||||
.apply(&mut fx.state())
|
.apply(&mut fx.state())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use std::{collections::HashSet, rc::Rc};
|
use std::{collections::HashSet, rc::Rc};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Apply, CommandError, FileId, Files, Selection, State, Waypoint, copy_file, copy_segment,
|
Apply, CommandError, FileId, Files, Selection, State, Waypoint, copy_file, copy_route,
|
||||||
copy_track, copy_waypoint,
|
copy_segment, copy_track, copy_waypoint,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -35,6 +35,30 @@ impl Apply for Duplicate {
|
|||||||
state.order.0.splice(at..at, order);
|
state.order.0.splice(at..at, order);
|
||||||
Selection::File { file_ids: copies }
|
Selection::File { file_ids: copies }
|
||||||
}
|
}
|
||||||
|
Selection::Route { file_id, rte_ids } => {
|
||||||
|
let file = state
|
||||||
|
.files
|
||||||
|
.get_mut(file_id)
|
||||||
|
.ok_or(CommandError::NothingToDo)?;
|
||||||
|
let file = Rc::make_mut(file);
|
||||||
|
let mut copies = HashSet::new();
|
||||||
|
append_copies(
|
||||||
|
&mut file.rte,
|
||||||
|
|rte| rte_ids.contains(&rte.id),
|
||||||
|
|rte| {
|
||||||
|
let copy = copy_route(rte);
|
||||||
|
copies.insert(copy.id);
|
||||||
|
copy
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if copies.is_empty() {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
Selection::Route {
|
||||||
|
file_id: *file_id,
|
||||||
|
rte_ids: copies,
|
||||||
|
}
|
||||||
|
}
|
||||||
Selection::Track { file_id, trk_ids } => {
|
Selection::Track { file_id, trk_ids } => {
|
||||||
let file = state
|
let file = state
|
||||||
.files
|
.files
|
||||||
@@ -193,6 +217,7 @@ mod tests {
|
|||||||
crate::New {
|
crate::New {
|
||||||
name,
|
name,
|
||||||
trackpoint: None,
|
trackpoint: None,
|
||||||
|
route: false,
|
||||||
}
|
}
|
||||||
.apply(&mut fx.state())
|
.apply(&mut fx.state())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ mod tests {
|
|||||||
crate::New {
|
crate::New {
|
||||||
name: "keep",
|
name: "keep",
|
||||||
trackpoint: None,
|
trackpoint: None,
|
||||||
|
route: false,
|
||||||
}
|
}
|
||||||
.apply(&mut fx.state())
|
.apply(&mut fx.state())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -140,6 +141,7 @@ mod tests {
|
|||||||
crate::New {
|
crate::New {
|
||||||
name: "before",
|
name: "before",
|
||||||
trackpoint: None,
|
trackpoint: None,
|
||||||
|
route: false,
|
||||||
}
|
}
|
||||||
.apply(&mut fx.state())
|
.apply(&mut fx.state())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -189,6 +191,7 @@ mod tests {
|
|||||||
crate::New {
|
crate::New {
|
||||||
name: "keep",
|
name: "keep",
|
||||||
trackpoint: None,
|
trackpoint: None,
|
||||||
|
route: false,
|
||||||
}
|
}
|
||||||
.apply(&mut fx.state())
|
.apply(&mut fx.state())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ use crate::{
|
|||||||
pub enum MoveTarget {
|
pub enum MoveTarget {
|
||||||
/// The list of files.
|
/// The list of files.
|
||||||
Files { index: usize },
|
Files { index: usize },
|
||||||
|
/// The routes of a file.
|
||||||
|
Routes { file_id: FileId, index: usize },
|
||||||
/// The tracks of a file.
|
/// The tracks of a file.
|
||||||
Tracks { file_id: FileId, index: usize },
|
Tracks { file_id: FileId, index: usize },
|
||||||
/// The segments of a track.
|
/// The segments of a track.
|
||||||
@@ -26,9 +28,10 @@ pub enum MoveTarget {
|
|||||||
///
|
///
|
||||||
/// What can go where:
|
/// What can go where:
|
||||||
/// - files: among the files;
|
/// - files: among the files;
|
||||||
/// - tracks: among the files (each track becomes a file), or among the tracks of a file;
|
/// - routes and tracks: among the files (each one becomes a file), or among the routes or the
|
||||||
/// - segments: among the files (each segment becomes a file), among the tracks of a file (each
|
/// tracks of a file, where they keep their kind (see [`Destination`]);
|
||||||
/// segment becomes a track), or among the segments of a track;
|
/// - segments: among the files (each segment becomes a file), among the routes or the tracks of a
|
||||||
|
/// file (each segment becomes a route or a track), or among the segments of a track;
|
||||||
/// - waypoints, or all the waypoints of a file (the waypoints node): among the waypoints of a
|
/// - waypoints, or all the waypoints of a file (the waypoints node): among the waypoints of a
|
||||||
/// file.
|
/// file.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -41,6 +44,9 @@ impl From<MoveTarget> for Destination {
|
|||||||
fn from(target: MoveTarget) -> Self {
|
fn from(target: MoveTarget) -> Self {
|
||||||
match target {
|
match target {
|
||||||
MoveTarget::Files { index } => Destination::Files(Place::Index(index)),
|
MoveTarget::Files { index } => Destination::Files(Place::Index(index)),
|
||||||
|
MoveTarget::Routes { file_id, index } => {
|
||||||
|
Destination::Routes(file_id, Place::Index(index))
|
||||||
|
}
|
||||||
MoveTarget::Tracks { file_id, index } => {
|
MoveTarget::Tracks { file_id, index } => {
|
||||||
Destination::Tracks(file_id, Place::Index(index))
|
Destination::Tracks(file_id, Place::Index(index))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
use crate::{Apply, CommandError, File, LngLat, State, Track, TrackSegment, Trackpoint, produce};
|
use crate::{
|
||||||
|
Apply, CommandError, File, LngLat, Route, State, Track, TrackSegment, Trackpoint, produce,
|
||||||
|
};
|
||||||
|
|
||||||
/// Creates an empty file and selects it. With a `trackpoint`, the file starts with a track and a
|
/// Creates an empty file and selects it. With `route`, the file starts with a route, which is
|
||||||
/// segment that hold it: it is how the routing tool starts a route on an empty map, in a single
|
/// empty. With a `trackpoint`, the file starts with a segment that holds it, the one of its route
|
||||||
/// undo step.
|
/// with `route` or of a track otherwise: it is how the routing tool starts a route on an empty map,
|
||||||
|
/// in a single undo step.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct New<'a> {
|
pub struct New<'a> {
|
||||||
pub name: &'a str,
|
pub name: &'a str,
|
||||||
pub trackpoint: Option<NewTrackpoint>,
|
pub trackpoint: Option<NewTrackpoint>,
|
||||||
|
pub route: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
@@ -21,8 +25,8 @@ impl Apply for New<'_> {
|
|||||||
produce(state, |_| {
|
produce(state, |_| {
|
||||||
let mut file = File::default();
|
let mut file = File::default();
|
||||||
file.info.name = self.name.to_owned();
|
file.info.name = self.name.to_owned();
|
||||||
|
let mut segment = TrackSegment::default();
|
||||||
if let Some(point) = self.trackpoint {
|
if let Some(point) = self.trackpoint {
|
||||||
let mut segment = TrackSegment::default();
|
|
||||||
// the ends of a segment are anchors of the routing tool
|
// the ends of a segment are anchors of the routing tool
|
||||||
segment.splice(
|
segment.splice(
|
||||||
0,
|
0,
|
||||||
@@ -36,6 +40,13 @@ impl Apply for New<'_> {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
}],
|
}],
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
if self.route {
|
||||||
|
file.rte.push(Route {
|
||||||
|
seg: segment,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
} else if self.trackpoint.is_some() {
|
||||||
file.trk.push(Track {
|
file.trk.push(Track {
|
||||||
trkseg: vec![segment],
|
trkseg: vec![segment],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -59,7 +70,8 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
New {
|
New {
|
||||||
name: "new",
|
name: "new",
|
||||||
trackpoint: None
|
trackpoint: None,
|
||||||
|
route: false,
|
||||||
}
|
}
|
||||||
.apply(&mut fx.state())
|
.apply(&mut fx.state())
|
||||||
.is_ok()
|
.is_ok()
|
||||||
@@ -83,6 +95,7 @@ mod tests {
|
|||||||
New {
|
New {
|
||||||
name: "new",
|
name: "new",
|
||||||
trackpoint: Some(point),
|
trackpoint: Some(point),
|
||||||
|
route: false,
|
||||||
}
|
}
|
||||||
.apply(&mut fx.state())
|
.apply(&mut fx.state())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -98,4 +111,38 @@ mod tests {
|
|||||||
assert_eq!(segment[0].anchor, Some(0));
|
assert_eq!(segment[0].anchor, Some(0));
|
||||||
assert_eq!(fx.selected_files(), [file.id].into());
|
assert_eq!(fx.selected_files(), [file.id].into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_with_a_route() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
New {
|
||||||
|
name: "new",
|
||||||
|
trackpoint: None,
|
||||||
|
route: true,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let file = fx.files.values().next().unwrap();
|
||||||
|
assert_eq!((file.rte.len(), file.trk.len()), (1, 0));
|
||||||
|
assert!(file.rte[0].seg.is_empty());
|
||||||
|
assert_eq!(fx.selected_files(), [file.id].into());
|
||||||
|
|
||||||
|
// the trackpoint goes to the route
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
New {
|
||||||
|
name: "new",
|
||||||
|
trackpoint: Some(NewTrackpoint {
|
||||||
|
lng: 1.0,
|
||||||
|
lat: 2.0,
|
||||||
|
ele: 3.0,
|
||||||
|
}),
|
||||||
|
route: true,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let file = fx.files.values().next().unwrap();
|
||||||
|
assert_eq!((file.rte.len(), file.trk.len()), (1, 0));
|
||||||
|
assert_eq!(file.rte[0].seg.len(), 1);
|
||||||
|
assert_eq!(file.rte[0].seg[0].anchor, Some(0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,18 +1,21 @@
|
|||||||
use std::{collections::HashSet, hash::Hash};
|
use std::{collections::HashSet, hash::Hash};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Apply, ClipboardContent, CommandError, Destination, FileId, Place, Selection, State, TrackId,
|
Apply, ClipboardContent, CommandError, Destination, FileId, Place, RouteId, Selection, State,
|
||||||
TrackSegmentId, WaypointId, transfer, waypoint_ids,
|
TrackId, TrackSegmentId, WaypointId, transfer, waypoint_ids,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Pastes what was copied or cut (see [`crate::Clipboard`]).
|
/// Pastes what was copied or cut (see [`crate::Clipboard`]).
|
||||||
///
|
///
|
||||||
/// Where it goes depends on the selection, see [`crate::Clipboard::can_paste`]:
|
/// Where it goes depends on the selection, see [`crate::Clipboard::can_paste`]:
|
||||||
/// - nothing selected: the files are copied, and tracks and segments become new files (named
|
/// - nothing selected: the files are copied, and routes, tracks and segments become new files
|
||||||
/// like their track, or like the file they come from);
|
/// (named like their route or track, or like the file they come from);
|
||||||
/// - a file: the tracks are added to it, segments become new tracks in it, and the waypoints are
|
/// - a file: the routes and tracks are added to it, segments become new tracks in it, and the
|
||||||
/// added to its own;
|
/// waypoints are added to its own;
|
||||||
/// - a track: the segments are added to it, tracks go after it in its file;
|
/// - a route: routes, and segments as new routes, go after it in its file, tracks go before the
|
||||||
|
/// other tracks (right after the routes);
|
||||||
|
/// - a track: the segments are added to it, tracks go after it in its file, and routes after the
|
||||||
|
/// other routes;
|
||||||
/// - a segment: the segments go after it in its track;
|
/// - a segment: the segments go after it in its track;
|
||||||
/// - the waypoints or a waypoint: the waypoints are added to their file, after the waypoint.
|
/// - the waypoints or a waypoint: the waypoints are added to their file, after the waypoint.
|
||||||
///
|
///
|
||||||
@@ -46,6 +49,14 @@ impl Apply for Paste {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn route_ids_of(state: &State, file_id: FileId) -> Vec<RouteId> {
|
||||||
|
state
|
||||||
|
.files
|
||||||
|
.get(&file_id)
|
||||||
|
.map(|file| file.rte.iter().map(|rte| rte.id).collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
fn track_ids_of(state: &State, file_id: FileId) -> Vec<TrackId> {
|
fn track_ids_of(state: &State, file_id: FileId) -> Vec<TrackId> {
|
||||||
state
|
state
|
||||||
.files
|
.files
|
||||||
@@ -79,14 +90,34 @@ fn destination(state: &State, content: &ClipboardContent) -> Result<Destination,
|
|||||||
};
|
};
|
||||||
Ok(match (content, &*state.selection) {
|
Ok(match (content, &*state.selection) {
|
||||||
(ClipboardContent::Files(_), _) => Destination::Files(Place::End),
|
(ClipboardContent::Files(_), _) => Destination::Files(Place::End),
|
||||||
(ClipboardContent::Tracks(_) | ClipboardContent::Segments(_), Selection::Empty) => {
|
(
|
||||||
Destination::Files(Place::End)
|
ClipboardContent::Routes(_)
|
||||||
|
| ClipboardContent::Tracks(_)
|
||||||
|
| ClipboardContent::Segments(_),
|
||||||
|
Selection::Empty,
|
||||||
|
) => Destination::Files(Place::End),
|
||||||
|
(ClipboardContent::Routes(_), Selection::File { file_ids }) => {
|
||||||
|
Destination::Routes(last_file(file_ids)?, Place::End)
|
||||||
|
}
|
||||||
|
// routes and tracks keep their kind (see `Destination`)
|
||||||
|
(
|
||||||
|
ClipboardContent::Routes(_)
|
||||||
|
| ClipboardContent::Tracks(_)
|
||||||
|
| ClipboardContent::Segments(_),
|
||||||
|
Selection::Route { file_id, rte_ids },
|
||||||
|
) => {
|
||||||
|
let last =
|
||||||
|
last_selected(route_ids_of(state, *file_id).into_iter(), rte_ids).ok_or(nothing)?;
|
||||||
|
Destination::Routes(*file_id, Place::After(last))
|
||||||
}
|
}
|
||||||
(
|
(
|
||||||
ClipboardContent::Tracks(_) | ClipboardContent::Segments(_),
|
ClipboardContent::Tracks(_) | ClipboardContent::Segments(_),
|
||||||
Selection::File { file_ids },
|
Selection::File { file_ids },
|
||||||
) => Destination::Tracks(last_file(file_ids)?, Place::End),
|
) => Destination::Tracks(last_file(file_ids)?, Place::End),
|
||||||
(ClipboardContent::Tracks(_), Selection::Track { file_id, trk_ids }) => {
|
(
|
||||||
|
ClipboardContent::Routes(_) | ClipboardContent::Tracks(_),
|
||||||
|
Selection::Track { file_id, trk_ids },
|
||||||
|
) => {
|
||||||
let last =
|
let last =
|
||||||
last_selected(track_ids_of(state, *file_id).into_iter(), trk_ids).ok_or(nothing)?;
|
last_selected(track_ids_of(state, *file_id).into_iter(), trk_ids).ok_or(nothing)?;
|
||||||
Destination::Tracks(*file_id, Place::After(last))
|
Destination::Tracks(*file_id, Place::After(last))
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ mod tests {
|
|||||||
New {
|
New {
|
||||||
name,
|
name,
|
||||||
trackpoint: None,
|
trackpoint: None,
|
||||||
|
route: false,
|
||||||
}
|
}
|
||||||
.apply(&mut fx.state())
|
.apply(&mut fx.state())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
use std::{collections::HashSet, rc::Rc};
|
use std::{collections::HashSet, rc::Rc};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
ClipboardContent, ClipboardSegment, ClipboardTrack, CommandError, File, FileId, Selection,
|
ClipboardContent, ClipboardRoute, ClipboardSegment, ClipboardTrack, CommandError, File, FileId,
|
||||||
State, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint, WaypointId, copy_file,
|
Route, RouteId, Selection, State, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint,
|
||||||
copy_segment, copy_track, copy_waypoint,
|
WaypointId, copy_file, copy_route, copy_segment, copy_track, copy_waypoint,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Where elements are put, among the others of the same list.
|
/// Where elements are put, among the others of the same list.
|
||||||
@@ -37,15 +37,20 @@ impl<T: Copy + PartialEq> Place<T> {
|
|||||||
|
|
||||||
/// Where elements go. What can go where:
|
/// Where elements go. What can go where:
|
||||||
/// - files: among the files;
|
/// - files: among the files;
|
||||||
|
/// - routes: among the files (each route becomes a file, named like the route, or like the file
|
||||||
|
/// it comes from), or among the routes of a file. Among the tracks of a file, they stay routes,
|
||||||
|
/// which come before the tracks: they go after the other routes;
|
||||||
/// - tracks: among the files (each track becomes a file, named like the track, or like the file
|
/// - tracks: among the files (each track becomes a file, named like the track, or like the file
|
||||||
/// it comes from), or among the tracks of a file;
|
/// it comes from), or among the tracks of a file. Among the routes of a file, they stay tracks,
|
||||||
|
/// which come after the routes: they go before the other tracks;
|
||||||
/// - segments: among the files (each segment becomes a file with a track, named like its track,
|
/// - segments: among the files (each segment becomes a file with a track, named like its track,
|
||||||
/// or like its file), among the tracks of a file (each segment becomes a track), or among the
|
/// or like its file), among the routes or the tracks of a file (each segment becomes a route or
|
||||||
/// segments of a track;
|
/// a track), or among the segments of a track;
|
||||||
/// - waypoints: among the waypoints of a file.
|
/// - waypoints: among the waypoints of a file.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub enum Destination {
|
pub enum Destination {
|
||||||
Files(Place<FileId>),
|
Files(Place<FileId>),
|
||||||
|
Routes(FileId, Place<RouteId>),
|
||||||
Tracks(FileId, Place<TrackId>),
|
Tracks(FileId, Place<TrackId>),
|
||||||
Segments(FileId, TrackId, Place<TrackSegmentId>),
|
Segments(FileId, TrackId, Place<TrackSegmentId>),
|
||||||
Waypoints(FileId, Place<WaypointId>),
|
Waypoints(FileId, Place<WaypointId>),
|
||||||
@@ -93,6 +98,14 @@ pub fn remove_elements(state: &mut State, content: &ClipboardContent) {
|
|||||||
state.files.retain(|id, _| !ids.contains(id));
|
state.files.retain(|id, _| !ids.contains(id));
|
||||||
state.order.0.retain(|id| !ids.contains(id));
|
state.order.0.retain(|id| !ids.contains(id));
|
||||||
}
|
}
|
||||||
|
ClipboardContent::Routes(routes) => {
|
||||||
|
let ids: HashSet<RouteId> = routes.iter().map(|rte| rte.route.id).collect();
|
||||||
|
for file in state.files.values_mut() {
|
||||||
|
if file.rte.iter().any(|rte| ids.contains(&rte.id)) {
|
||||||
|
Rc::make_mut(file).rte.retain(|rte| !ids.contains(&rte.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
ClipboardContent::Tracks(tracks) => {
|
ClipboardContent::Tracks(tracks) => {
|
||||||
let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.track.id).collect();
|
let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.track.id).collect();
|
||||||
for file in state.files.values_mut() {
|
for file in state.files.values_mut() {
|
||||||
@@ -141,7 +154,9 @@ pub fn transfer(
|
|||||||
// the destination has to be there, before anything is changed
|
// the destination has to be there, before anything is changed
|
||||||
let exists = match to {
|
let exists = match to {
|
||||||
Destination::Files(_) => true,
|
Destination::Files(_) => true,
|
||||||
Destination::Tracks(file_id, _) => state.files.contains_key(&file_id),
|
Destination::Routes(file_id, _) | Destination::Tracks(file_id, _) => {
|
||||||
|
state.files.contains_key(&file_id)
|
||||||
|
}
|
||||||
Destination::Segments(file_id, trk_id, _) => state
|
Destination::Segments(file_id, trk_id, _) => state
|
||||||
.files
|
.files
|
||||||
.get(&file_id)
|
.get(&file_id)
|
||||||
@@ -152,17 +167,21 @@ pub fn transfer(
|
|||||||
(content, to),
|
(content, to),
|
||||||
(ClipboardContent::Files(_), Destination::Files(_))
|
(ClipboardContent::Files(_), Destination::Files(_))
|
||||||
| (
|
| (
|
||||||
ClipboardContent::Tracks(_),
|
ClipboardContent::Routes(_) | ClipboardContent::Tracks(_),
|
||||||
Destination::Files(_) | Destination::Tracks(..)
|
Destination::Files(_) | Destination::Routes(..) | Destination::Tracks(..)
|
||||||
)
|
)
|
||||||
| (
|
| (
|
||||||
ClipboardContent::Segments(_),
|
ClipboardContent::Segments(_),
|
||||||
Destination::Files(_) | Destination::Tracks(..) | Destination::Segments(..),
|
Destination::Files(_)
|
||||||
|
| Destination::Routes(..)
|
||||||
|
| Destination::Tracks(..)
|
||||||
|
| Destination::Segments(..),
|
||||||
)
|
)
|
||||||
| (ClipboardContent::Waypoints(_), Destination::Waypoints(..))
|
| (ClipboardContent::Waypoints(_), Destination::Waypoints(..))
|
||||||
);
|
);
|
||||||
let empty = match content {
|
let empty = match content {
|
||||||
ClipboardContent::Files(files) => files.is_empty(),
|
ClipboardContent::Files(files) => files.is_empty(),
|
||||||
|
ClipboardContent::Routes(routes) => routes.is_empty(),
|
||||||
ClipboardContent::Tracks(tracks) => tracks.is_empty(),
|
ClipboardContent::Tracks(tracks) => tracks.is_empty(),
|
||||||
ClipboardContent::Segments(segments) => segments.is_empty(),
|
ClipboardContent::Segments(segments) => segments.is_empty(),
|
||||||
ClipboardContent::Waypoints(waypoints) => waypoints.is_empty(),
|
ClipboardContent::Waypoints(waypoints) => waypoints.is_empty(),
|
||||||
@@ -189,6 +208,7 @@ pub fn transfer(
|
|||||||
.collect();
|
.collect();
|
||||||
Ok(add_files(state, files, place))
|
Ok(add_files(state, files, place))
|
||||||
}
|
}
|
||||||
|
(ClipboardContent::Routes(routes), to) => transfer_routes(state, routes, copy, to),
|
||||||
(ClipboardContent::Tracks(tracks), to) => transfer_tracks(state, tracks, copy, to),
|
(ClipboardContent::Tracks(tracks), to) => transfer_tracks(state, tracks, copy, to),
|
||||||
(ClipboardContent::Segments(segments), to) => transfer_segments(state, segments, copy, to),
|
(ClipboardContent::Segments(segments), to) => transfer_segments(state, segments, copy, to),
|
||||||
(ClipboardContent::Waypoints(waypoints), Destination::Waypoints(file_id, place)) => {
|
(ClipboardContent::Waypoints(waypoints), Destination::Waypoints(file_id, place)) => {
|
||||||
@@ -198,6 +218,89 @@ pub fn transfer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Puts the routes among the routes of a file, at `place`, and gives the selection of them.
|
||||||
|
fn insert_routes(
|
||||||
|
state: &mut State,
|
||||||
|
file_id: FileId,
|
||||||
|
place: Place<RouteId>,
|
||||||
|
routes: Vec<Route>,
|
||||||
|
) -> Result<Selection, CommandError> {
|
||||||
|
let ids: HashSet<RouteId> = routes.iter().map(|rte| rte.id).collect();
|
||||||
|
let file = file_mut(state, file_id)?;
|
||||||
|
let existing: Vec<RouteId> = file.rte.iter().map(|rte| rte.id).collect();
|
||||||
|
let at = place.position(&existing);
|
||||||
|
file.rte.splice(at..at, routes);
|
||||||
|
Ok(Selection::Route {
|
||||||
|
file_id,
|
||||||
|
rte_ids: ids,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transfer_routes(
|
||||||
|
state: &mut State,
|
||||||
|
routes: &[ClipboardRoute],
|
||||||
|
copy: bool,
|
||||||
|
to: Destination,
|
||||||
|
) -> Result<Selection, CommandError> {
|
||||||
|
let routes: Vec<(Route, &str)> = routes
|
||||||
|
.iter()
|
||||||
|
.map(|clip| {
|
||||||
|
let route = if copy {
|
||||||
|
copy_route(&clip.route)
|
||||||
|
} else {
|
||||||
|
clip.route.clone()
|
||||||
|
};
|
||||||
|
(route, clip.file_name.as_str())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
match to {
|
||||||
|
Destination::Files(place) => {
|
||||||
|
let files = routes
|
||||||
|
.into_iter()
|
||||||
|
.map(|(route, file_name)| {
|
||||||
|
let name = route
|
||||||
|
.info
|
||||||
|
.name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| file_name.to_owned());
|
||||||
|
Rc::new(File {
|
||||||
|
rte: vec![route],
|
||||||
|
..new_file(&name, vec![])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(add_files(state, files, place))
|
||||||
|
}
|
||||||
|
Destination::Routes(file_id, place) => {
|
||||||
|
let routes = routes.into_iter().map(|(route, _)| route).collect();
|
||||||
|
insert_routes(state, file_id, place, routes)
|
||||||
|
}
|
||||||
|
Destination::Tracks(file_id, _) => {
|
||||||
|
let routes = routes.into_iter().map(|(route, _)| route).collect();
|
||||||
|
insert_routes(state, file_id, Place::End, routes)
|
||||||
|
}
|
||||||
|
_ => Err(CommandError::NothingToDo),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts the tracks among the tracks of a file, at `place`, and gives the selection of them.
|
||||||
|
fn insert_tracks(
|
||||||
|
state: &mut State,
|
||||||
|
file_id: FileId,
|
||||||
|
place: Place<TrackId>,
|
||||||
|
tracks: Vec<Track>,
|
||||||
|
) -> Result<Selection, CommandError> {
|
||||||
|
let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.id).collect();
|
||||||
|
let file = file_mut(state, file_id)?;
|
||||||
|
let existing: Vec<TrackId> = file.trk.iter().map(|trk| trk.id).collect();
|
||||||
|
let at = place.position(&existing);
|
||||||
|
file.trk.splice(at..at, tracks);
|
||||||
|
Ok(Selection::Track {
|
||||||
|
file_id,
|
||||||
|
trk_ids: ids,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn transfer_tracks(
|
fn transfer_tracks(
|
||||||
state: &mut State,
|
state: &mut State,
|
||||||
tracks: &[ClipboardTrack],
|
tracks: &[ClipboardTrack],
|
||||||
@@ -231,16 +334,12 @@ fn transfer_tracks(
|
|||||||
Ok(add_files(state, files, place))
|
Ok(add_files(state, files, place))
|
||||||
}
|
}
|
||||||
Destination::Tracks(file_id, place) => {
|
Destination::Tracks(file_id, place) => {
|
||||||
let tracks: Vec<Track> = tracks.into_iter().map(|(track, _)| track).collect();
|
let tracks = tracks.into_iter().map(|(track, _)| track).collect();
|
||||||
let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.id).collect();
|
insert_tracks(state, file_id, place, tracks)
|
||||||
let file = file_mut(state, file_id)?;
|
}
|
||||||
let existing: Vec<TrackId> = file.trk.iter().map(|trk| trk.id).collect();
|
Destination::Routes(file_id, _) => {
|
||||||
let at = place.position(&existing);
|
let tracks = tracks.into_iter().map(|(track, _)| track).collect();
|
||||||
file.trk.splice(at..at, tracks);
|
insert_tracks(state, file_id, Place::Index(0), tracks)
|
||||||
Ok(Selection::Track {
|
|
||||||
file_id,
|
|
||||||
trk_ids: ids,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
_ => Err(CommandError::NothingToDo),
|
_ => Err(CommandError::NothingToDo),
|
||||||
}
|
}
|
||||||
@@ -276,20 +375,22 @@ fn transfer_segments(
|
|||||||
.collect();
|
.collect();
|
||||||
Ok(add_files(state, files, place))
|
Ok(add_files(state, files, place))
|
||||||
}
|
}
|
||||||
|
Destination::Routes(file_id, place) => {
|
||||||
|
let routes = segments
|
||||||
|
.into_iter()
|
||||||
|
.map(|(seg, _)| Route {
|
||||||
|
seg,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
insert_routes(state, file_id, place, routes)
|
||||||
|
}
|
||||||
Destination::Tracks(file_id, place) => {
|
Destination::Tracks(file_id, place) => {
|
||||||
let tracks: Vec<Track> = segments
|
let tracks = segments
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(segment, _)| own_track(segment))
|
.map(|(segment, _)| own_track(segment))
|
||||||
.collect();
|
.collect();
|
||||||
let ids = tracks.iter().map(|trk| trk.id).collect();
|
insert_tracks(state, file_id, place, tracks)
|
||||||
let file = file_mut(state, file_id)?;
|
|
||||||
let existing: Vec<TrackId> = file.trk.iter().map(|trk| trk.id).collect();
|
|
||||||
let at = place.position(&existing);
|
|
||||||
file.trk.splice(at..at, tracks);
|
|
||||||
Ok(Selection::Track {
|
|
||||||
file_id,
|
|
||||||
trk_ids: ids,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
Destination::Segments(file_id, trk_id, place) => {
|
Destination::Segments(file_id, trk_id, place) => {
|
||||||
let segments: Vec<TrackSegment> =
|
let segments: Vec<TrackSegment> =
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ mod file;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) mod fixture;
|
pub(crate) mod fixture;
|
||||||
mod pattern;
|
mod pattern;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod routes_tests;
|
||||||
mod tools;
|
mod tools;
|
||||||
|
|
||||||
pub use apply::*;
|
pub use apply::*;
|
||||||
@@ -31,13 +33,15 @@ pub enum Command<'a> {
|
|||||||
Move(Move),
|
Move(Move),
|
||||||
DeleteAll(DeleteAll),
|
DeleteAll(DeleteAll),
|
||||||
DeleteWaypoint(DeleteWaypoint),
|
DeleteWaypoint(DeleteWaypoint),
|
||||||
|
Convert(Convert),
|
||||||
Metadata(Metadata<'a>),
|
Metadata(Metadata<'a>),
|
||||||
Style(Style<'a>),
|
Style(Style<'a>),
|
||||||
|
NewRoute(NewRoute),
|
||||||
NewTrack(NewTrack),
|
NewTrack(NewTrack),
|
||||||
NewTrackSegment(NewTrackSegment),
|
NewTrackSegment(NewTrackSegment),
|
||||||
Reverse(Reverse),
|
Reverse(Reverse),
|
||||||
RoundTrip(RoundTrip),
|
RoundTrip(RoundTrip),
|
||||||
Route(Route<'a>),
|
Reroute(Reroute<'a>),
|
||||||
NewWaypoint(NewWaypoint<'a>),
|
NewWaypoint(NewWaypoint<'a>),
|
||||||
EditWaypoint(EditWaypoint<'a>),
|
EditWaypoint(EditWaypoint<'a>),
|
||||||
MoveWaypoint(MoveWaypoint),
|
MoveWaypoint(MoveWaypoint),
|
||||||
@@ -66,13 +70,15 @@ impl Apply for Command<'_> {
|
|||||||
Command::Move(c) => c.apply(state),
|
Command::Move(c) => c.apply(state),
|
||||||
Command::DeleteAll(c) => c.apply(state),
|
Command::DeleteAll(c) => c.apply(state),
|
||||||
Command::DeleteWaypoint(c) => c.apply(state),
|
Command::DeleteWaypoint(c) => c.apply(state),
|
||||||
|
Command::Convert(c) => c.apply(state),
|
||||||
Command::Metadata(c) => c.apply(state),
|
Command::Metadata(c) => c.apply(state),
|
||||||
Command::Style(c) => c.apply(state),
|
Command::Style(c) => c.apply(state),
|
||||||
|
Command::NewRoute(c) => c.apply(state),
|
||||||
Command::NewTrack(c) => c.apply(state),
|
Command::NewTrack(c) => c.apply(state),
|
||||||
Command::NewTrackSegment(c) => c.apply(state),
|
Command::NewTrackSegment(c) => c.apply(state),
|
||||||
Command::Reverse(c) => c.apply(state),
|
Command::Reverse(c) => c.apply(state),
|
||||||
Command::RoundTrip(c) => c.apply(state),
|
Command::RoundTrip(c) => c.apply(state),
|
||||||
Command::Route(c) => c.apply(state),
|
Command::Reroute(c) => c.apply(state),
|
||||||
Command::NewWaypoint(c) => c.apply(state),
|
Command::NewWaypoint(c) => c.apply(state),
|
||||||
Command::EditWaypoint(c) => c.apply(state),
|
Command::EditWaypoint(c) => c.apply(state),
|
||||||
Command::MoveWaypoint(c) => c.apply(state),
|
Command::MoveWaypoint(c) => c.apply(state),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::{File, Track, TrackSegment, Waypoint};
|
use crate::{File, Route, Track, TrackSegment, Waypoint};
|
||||||
|
|
||||||
// The trackpoints of a segment are shared chunks, so copying elements is cheap.
|
// The trackpoints of a segment are shared chunks, so copying elements is cheap.
|
||||||
|
|
||||||
@@ -18,10 +18,20 @@ pub fn copy_track(track: &Track) -> Track {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A copy of the file and of its tracks and segments, with new ids.
|
/// A copy of the route and of its segment, with new ids.
|
||||||
|
pub fn copy_route(route: &Route) -> Route {
|
||||||
|
Route {
|
||||||
|
id: Default::default(),
|
||||||
|
seg: copy_segment(&route.seg),
|
||||||
|
..route.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A copy of the file and of its routes, tracks and segments, with new ids.
|
||||||
pub fn copy_file(file: &File) -> File {
|
pub fn copy_file(file: &File) -> File {
|
||||||
File {
|
File {
|
||||||
id: Default::default(),
|
id: Default::default(),
|
||||||
|
rte: file.rte.iter().map(copy_route).collect(),
|
||||||
trk: file.trk.iter().map(copy_track).collect(),
|
trk: file.trk.iter().map(copy_track).collect(),
|
||||||
..file.clone()
|
..file.clone()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ pub fn update_segments(
|
|||||||
let file = files
|
let file = files
|
||||||
.entry(location.file_id)
|
.entry(location.file_id)
|
||||||
.or_insert_with(|| (*state.files[&location.file_id]).clone());
|
.or_insert_with(|| (*state.files[&location.file_id]).clone());
|
||||||
f(i, &mut file.trk[location.trk].trkseg[location.seg]);
|
f(i, file.segment_mut(location.index));
|
||||||
}
|
}
|
||||||
for (file_id, file) in files {
|
for (file_id, file) in files {
|
||||||
state.files.insert(file_id, Rc::new(file));
|
state.files.insert(file_id, Rc::new(file));
|
||||||
@@ -31,6 +31,8 @@ mod tests {
|
|||||||
|
|
||||||
use crate::engine::command::fixture::Fixture;
|
use crate::engine::command::fixture::Fixture;
|
||||||
|
|
||||||
|
use crate::SegmentIndex;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn fixture() -> (Fixture, FileId) {
|
fn fixture() -> (Fixture, FileId) {
|
||||||
@@ -49,13 +51,11 @@ mod tests {
|
|||||||
let locations = [
|
let locations = [
|
||||||
SegmentLocation {
|
SegmentLocation {
|
||||||
file_id: id,
|
file_id: id,
|
||||||
trk: 1,
|
index: SegmentIndex::Track { trk: 1, seg: 0 },
|
||||||
seg: 0,
|
|
||||||
},
|
},
|
||||||
SegmentLocation {
|
SegmentLocation {
|
||||||
file_id: id,
|
file_id: id,
|
||||||
trk: 0,
|
index: SegmentIndex::Track { trk: 0, seg: 0 },
|
||||||
seg: 0,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let mut calls = vec![];
|
let mut calls = vec![];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use crate::{File, FileId, Files, Selection, State, Track, TrackSegment, Waypoint};
|
use crate::{File, FileId, Files, Route, Selection, State, Track, TrackSegment, Waypoint};
|
||||||
|
|
||||||
/// What an [`Editor`] hook did to the element it was given.
|
/// What an [`Editor`] hook did to the element it was given.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -21,13 +21,19 @@ impl Edit {
|
|||||||
|
|
||||||
/// Independent edit of each selected element, without needing the rest of the state.
|
/// Independent edit of each selected element, without needing the rest of the state.
|
||||||
///
|
///
|
||||||
/// A selected file, track or segment is passed to the hook of its own level. By default a
|
/// A selected file, route, track or segment is passed to the hook of its own level. By default
|
||||||
/// file is forwarded to its tracks and a track to its segments, so an editor only overrides
|
/// a file is forwarded to its routes and tracks, and a route or a track to its segments, so an
|
||||||
/// the level(s) it cares about (metadata: file and track, style: track, reverse: segment).
|
/// editor only overrides the level(s) it cares about (metadata: file, route and track, style:
|
||||||
/// Waypoints are only edited when selected, never through their file.
|
/// route and track, reverse: segment). Waypoints are only edited when selected, never through
|
||||||
|
/// their file.
|
||||||
pub trait Editor {
|
pub trait Editor {
|
||||||
fn file(&mut self, file: &mut File) -> Edit {
|
fn file(&mut self, file: &mut File) -> Edit {
|
||||||
edit_each(&mut file.trk, |trk| self.track(trk))
|
let routes = edit_each(&mut file.rte, |rte| self.route(rte));
|
||||||
|
routes.merge(edit_each(&mut file.trk, |trk| self.track(trk)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn route(&mut self, route: &mut Route) -> Edit {
|
||||||
|
edit_segment(self, &mut route.seg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn track(&mut self, track: &mut Track) -> Edit {
|
fn track(&mut self, track: &mut Track) -> Edit {
|
||||||
@@ -95,7 +101,7 @@ fn edit_waypoints<E: Editor + ?Sized>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Applies the editor to every selected file, track or segment.
|
/// Applies the editor to every selected file, route, track or segment.
|
||||||
pub fn update_selected<E: Editor>(state: &mut State, editor: &mut E) {
|
pub fn update_selected<E: Editor>(state: &mut State, editor: &mut E) {
|
||||||
match &*state.selection {
|
match &*state.selection {
|
||||||
Selection::File { file_ids } => {
|
Selection::File { file_ids } => {
|
||||||
@@ -103,6 +109,15 @@ pub fn update_selected<E: Editor>(state: &mut State, editor: &mut E) {
|
|||||||
update_file(state.files, *id, |file| editor.file(file));
|
update_file(state.files, *id, |file| editor.file(file));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Selection::Route { file_id, rte_ids } => {
|
||||||
|
update_file(state.files, *file_id, |file| {
|
||||||
|
edit_where(
|
||||||
|
&mut file.rte,
|
||||||
|
|rte| rte_ids.contains(&rte.id),
|
||||||
|
|rte| editor.route(rte),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
Selection::Track { file_id, trk_ids } => {
|
Selection::Track { file_id, trk_ids } => {
|
||||||
update_file(state.files, *file_id, |file| {
|
update_file(state.files, *file_id, |file| {
|
||||||
edit_where(
|
edit_where(
|
||||||
|
|||||||
@@ -0,0 +1,684 @@
|
|||||||
|
//! The commands on routes: they work as on tracks, a route being a track of a single segment.
|
||||||
|
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
Apply, Clean, CleanType, Clipboard, ClipboardContent, CommandError, Convert, Crop, Delete,
|
||||||
|
Duplicate, Extract, File, FileId, LngLat, LngLatBounds, Merge, MergeType, Metadata, Move,
|
||||||
|
MoveTarget, Paste, Reverse, RouteId, SegmentIndex, Selection, Split, SplitType, Style,
|
||||||
|
engine::command::fixture::Fixture, parse,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A file with the 2 routes of `with_routes` and the 2 tracks (of 2 segments) of
|
||||||
|
/// `with_tracks_and_segments`, which is the only file and is in the order.
|
||||||
|
fn fixture() -> (Fixture, FileId) {
|
||||||
|
let read = |name: &str| {
|
||||||
|
let data = std::fs::read(format!("data/{name}.gpx")).unwrap();
|
||||||
|
parse(&data, &mut Default::default()).unwrap()
|
||||||
|
};
|
||||||
|
let mut file = read("with_tracks_and_segments");
|
||||||
|
file.rte = read("with_routes").rte;
|
||||||
|
assert_eq!((file.rte.len(), file.trk.len()), (2, 2));
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let id = file.id;
|
||||||
|
fx.files.insert(id, Rc::new(file));
|
||||||
|
fx.order.0.push(id);
|
||||||
|
(fx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file(fx: &Fixture, id: FileId) -> &File {
|
||||||
|
&fx.files[&id]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn routes(fx: &Fixture, id: FileId, indices: &[usize]) -> Selection {
|
||||||
|
Selection::Route {
|
||||||
|
file_id: id,
|
||||||
|
rte_ids: indices.iter().map(|i| file(fx, id).rte[*i].id).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn whole(id: FileId) -> Selection {
|
||||||
|
Selection::File {
|
||||||
|
file_ids: [id].into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lengths(file: &File) -> Vec<usize> {
|
||||||
|
file.segments().map(|segment| segment.len()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_the_segments_of_a_file_are_the_ones_of_its_routes_then_its_tracks() {
|
||||||
|
let (fx, id) = fixture();
|
||||||
|
let file = file(&fx, id);
|
||||||
|
let locations = whole(id).segment_locations(&fx.files, &fx.order.0);
|
||||||
|
let indices: Vec<SegmentIndex> = locations.iter().map(|l| l.index).collect();
|
||||||
|
assert_eq!(
|
||||||
|
indices,
|
||||||
|
[
|
||||||
|
SegmentIndex::Route(0),
|
||||||
|
SegmentIndex::Route(1),
|
||||||
|
SegmentIndex::Track { trk: 0, seg: 0 },
|
||||||
|
SegmentIndex::Track { trk: 0, seg: 1 },
|
||||||
|
SegmentIndex::Track { trk: 1, seg: 0 },
|
||||||
|
SegmentIndex::Track { trk: 1, seg: 1 },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
// a selection of routes covers their segments only
|
||||||
|
let selected = routes(&fx, id, &[1]).segment_locations(&fx.files, &fx.order.0);
|
||||||
|
assert_eq!(selected.len(), 1);
|
||||||
|
assert_eq!(selected[0].segment(&fx.files).id, file.rte[1].seg.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_routes_are_selected_like_tracks() {
|
||||||
|
let (fx, id) = fixture();
|
||||||
|
let ids: Vec<RouteId> = file(&fx, id).rte.iter().map(|r| r.id).collect();
|
||||||
|
|
||||||
|
let mut selection = routes(&fx, id, &[0]);
|
||||||
|
selection.extend(routes(&fx, id, &[1]));
|
||||||
|
assert_eq!(selection, routes(&fx, id, &[0, 1]));
|
||||||
|
selection.toggle(routes(&fx, id, &[0]));
|
||||||
|
assert_eq!(selection, routes(&fx, id, &[1]));
|
||||||
|
// routes and tracks are not selected together
|
||||||
|
let track = Selection::Track {
|
||||||
|
file_id: id,
|
||||||
|
trk_ids: [file(&fx, id).trk[0].id].into(),
|
||||||
|
};
|
||||||
|
selection.extend(track.clone());
|
||||||
|
assert_eq!(selection, track);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
routes(&fx, id, &[0]).all_at_level(&fx.files, &fx.order.0),
|
||||||
|
Some(routes(&fx, id, &[0, 1]))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
routes(&fx, id, &[0]).neighbour(&fx.files, &fx.order.0, true),
|
||||||
|
Some(routes(&fx, id, &[1]))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
routes(&fx, id, &[1]).neighbour(&fx.files, &fx.order.0, true),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(routes(&fx, id, &[1]).to_files(), whole(id));
|
||||||
|
|
||||||
|
let mut gone = Selection::Route {
|
||||||
|
file_id: id,
|
||||||
|
rte_ids: [ids[0], RouteId::default()].into(),
|
||||||
|
};
|
||||||
|
gone.retain_existing(&fx.files);
|
||||||
|
assert_eq!(gone, routes(&fx, id, &[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_delete_and_duplicate_routes() {
|
||||||
|
let (mut fx, id) = fixture();
|
||||||
|
let kept = file(&fx, id).rte[1].id;
|
||||||
|
fx.selection = routes(&fx, id, &[0]);
|
||||||
|
Delete { whole_files: false }
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(file(&fx, id).rte.len(), 1);
|
||||||
|
assert_eq!(file(&fx, id).rte[0].id, kept);
|
||||||
|
assert_eq!(file(&fx, id).trk.len(), 2);
|
||||||
|
assert_eq!(fx.selection, whole(id));
|
||||||
|
|
||||||
|
fx.selection = routes(&fx, id, &[0]);
|
||||||
|
Duplicate.apply(&mut fx.state()).unwrap();
|
||||||
|
let file = file(&fx, id);
|
||||||
|
assert_eq!(file.rte.len(), 2);
|
||||||
|
assert_ne!(file.rte[1].id, kept);
|
||||||
|
assert_ne!(file.rte[1].seg.id, file.rte[0].seg.id);
|
||||||
|
assert_eq!(file.rte[1].info, file.rte[0].info);
|
||||||
|
assert_eq!(
|
||||||
|
fx.selection,
|
||||||
|
Selection::Route {
|
||||||
|
file_id: id,
|
||||||
|
rte_ids: [file.rte[1].id].into()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_metadata_and_style_of_routes() {
|
||||||
|
let (mut fx, id) = fixture();
|
||||||
|
fx.selection = routes(&fx, id, &[1]);
|
||||||
|
Metadata {
|
||||||
|
name: "renamed",
|
||||||
|
desc: "about",
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
Style {
|
||||||
|
color: Some("00ff00"),
|
||||||
|
opacity: None,
|
||||||
|
width: Some(3.0),
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let file = file(&fx, id);
|
||||||
|
let info = &file.rte[1].info;
|
||||||
|
assert_eq!(info.name.as_deref(), Some("renamed"));
|
||||||
|
assert_eq!(info.desc.as_deref(), Some("about"));
|
||||||
|
assert_eq!(info.color.as_deref(), Some("00ff00"));
|
||||||
|
assert_eq!(info.width, Some(3.0));
|
||||||
|
assert_ne!(file.rte[0].info.name.as_deref(), Some("renamed"));
|
||||||
|
|
||||||
|
// the style of a file is the one of its routes and tracks
|
||||||
|
fx.selection = whole(id);
|
||||||
|
Style {
|
||||||
|
color: Some("ff0000"),
|
||||||
|
opacity: None,
|
||||||
|
width: None,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let file = &fx.files[&id];
|
||||||
|
assert!(
|
||||||
|
file.rte
|
||||||
|
.iter()
|
||||||
|
.map(|r| &r.info)
|
||||||
|
.chain(file.trk.iter().map(|t| &t.info))
|
||||||
|
.all(|info| info.color.as_deref() == Some("ff0000"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_copy_and_paste_routes() {
|
||||||
|
let (mut fx, id) = fixture();
|
||||||
|
let copied = file(&fx, id).rte[0].clone();
|
||||||
|
fx.clipboard = Clipboard::new(&routes(&fx, id, &[0]), &fx.files, &fx.order.0, false);
|
||||||
|
assert!(matches!(
|
||||||
|
fx.clipboard.as_ref().unwrap().content,
|
||||||
|
ClipboardContent::Routes(_)
|
||||||
|
));
|
||||||
|
|
||||||
|
// after the selected route
|
||||||
|
fx.selection = routes(&fx, id, &[1]);
|
||||||
|
Paste.apply(&mut fx.state()).unwrap();
|
||||||
|
let file = file(&fx, id);
|
||||||
|
assert_eq!(file.rte.len(), 3);
|
||||||
|
assert_ne!(file.rte[2].id, copied.id);
|
||||||
|
assert_eq!(file.rte[2].seg.len(), copied.seg.len());
|
||||||
|
assert!(
|
||||||
|
matches!(&fx.selection, Selection::Route { rte_ids, .. } if rte_ids.contains(&file.rte[2].id))
|
||||||
|
);
|
||||||
|
|
||||||
|
// nothing selected: a new file, named like the route
|
||||||
|
fx.selection = Selection::Empty;
|
||||||
|
Paste.apply(&mut fx.state()).unwrap();
|
||||||
|
assert_eq!(fx.order.0.len(), 2);
|
||||||
|
let new = &fx.files[&fx.order.0[1]];
|
||||||
|
assert_eq!(new.rte.len(), 1);
|
||||||
|
assert!(new.trk.is_empty());
|
||||||
|
assert_eq!(Some(&new.info.name), copied.info.name.as_ref());
|
||||||
|
|
||||||
|
// a track selected: among the routes of its file
|
||||||
|
fx.selection = Selection::Track {
|
||||||
|
file_id: id,
|
||||||
|
trk_ids: [fx.files[&id].trk[0].id].into(),
|
||||||
|
};
|
||||||
|
Paste.apply(&mut fx.state()).unwrap();
|
||||||
|
assert_eq!(fx.files[&id].rte.len(), 4);
|
||||||
|
|
||||||
|
// segments do not go in a route, but become routes next to it
|
||||||
|
let file = &fx.files[&id];
|
||||||
|
let track = &file.trk[0];
|
||||||
|
fx.clipboard = Clipboard::new(
|
||||||
|
&Selection::TrackSegment {
|
||||||
|
file_id: id,
|
||||||
|
trk_id: track.id,
|
||||||
|
trkseg_ids: [track.trkseg[1].id].into(),
|
||||||
|
},
|
||||||
|
&fx.files,
|
||||||
|
&fx.order.0,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
fx.selection = routes(&fx, id, &[0]);
|
||||||
|
Paste.apply(&mut fx.state()).unwrap();
|
||||||
|
let file = &fx.files[&id];
|
||||||
|
assert_eq!(file.rte.len(), 5);
|
||||||
|
assert_eq!(file.rte[1].seg.len(), file.trk[0].trkseg[1].len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cut_tracks_pasted_on_a_route_go_first_among_the_tracks() {
|
||||||
|
let (mut fx, id) = fixture();
|
||||||
|
let moved = file(&fx, id).trk[1].id;
|
||||||
|
fx.clipboard = Clipboard::new(
|
||||||
|
&Selection::Track {
|
||||||
|
file_id: id,
|
||||||
|
trk_ids: [moved].into(),
|
||||||
|
},
|
||||||
|
&fx.files,
|
||||||
|
&fx.order.0,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
fx.selection = routes(&fx, id, &[0]);
|
||||||
|
Paste.apply(&mut fx.state()).unwrap();
|
||||||
|
let file = file(&fx, id);
|
||||||
|
assert_eq!(file.trk.len(), 2);
|
||||||
|
assert_eq!(file.trk[0].id, moved);
|
||||||
|
assert_eq!(file.rte.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_move_routes() {
|
||||||
|
let (mut fx, id) = fixture();
|
||||||
|
let ids: Vec<RouteId> = file(&fx, id).rte.iter().map(|r| r.id).collect();
|
||||||
|
Move {
|
||||||
|
what: routes(&fx, id, &[1]),
|
||||||
|
to: MoveTarget::Routes {
|
||||||
|
file_id: id,
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let order: Vec<RouteId> = file(&fx, id).rte.iter().map(|r| r.id).collect();
|
||||||
|
assert_eq!(order, [ids[1], ids[0]]);
|
||||||
|
|
||||||
|
// to the files: a file of its own
|
||||||
|
Move {
|
||||||
|
what: routes(&fx, id, &[0]),
|
||||||
|
to: MoveTarget::Files { index: 1 },
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(file(&fx, id).rte.len(), 1);
|
||||||
|
let new = &fx.files[&fx.order.0[1]];
|
||||||
|
assert_eq!(new.rte[0].id, ids[1]);
|
||||||
|
|
||||||
|
// a segment moved among the routes becomes one
|
||||||
|
let track = &file(&fx, id).trk[0];
|
||||||
|
let segment = track.trkseg[0].id;
|
||||||
|
Move {
|
||||||
|
what: Selection::TrackSegment {
|
||||||
|
file_id: id,
|
||||||
|
trk_id: track.id,
|
||||||
|
trkseg_ids: [segment].into(),
|
||||||
|
},
|
||||||
|
to: MoveTarget::Routes {
|
||||||
|
file_id: id,
|
||||||
|
index: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let file = file(&fx, id);
|
||||||
|
assert_eq!(file.rte[1].seg.id, segment);
|
||||||
|
assert_eq!(file.trk[0].trkseg.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_routes_and_tracks_moved_among_each_other_keep_their_kind() {
|
||||||
|
let (mut fx, id) = fixture();
|
||||||
|
let route = file(&fx, id).rte[0].id;
|
||||||
|
let track = file(&fx, id).trk[1].id;
|
||||||
|
|
||||||
|
// a route among the tracks: after the other routes, which come first
|
||||||
|
Move {
|
||||||
|
what: routes(&fx, id, &[0]),
|
||||||
|
to: MoveTarget::Tracks {
|
||||||
|
file_id: id,
|
||||||
|
index: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let moved = file(&fx, id);
|
||||||
|
assert_eq!((moved.rte.len(), moved.trk.len()), (2, 2));
|
||||||
|
assert_eq!(moved.rte[1].id, route);
|
||||||
|
assert_eq!(
|
||||||
|
fx.selection,
|
||||||
|
Selection::Route {
|
||||||
|
file_id: id,
|
||||||
|
rte_ids: [route].into()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// a track among the routes: before the other tracks, which come after the routes
|
||||||
|
Move {
|
||||||
|
what: Selection::Track {
|
||||||
|
file_id: id,
|
||||||
|
trk_ids: [track].into(),
|
||||||
|
},
|
||||||
|
to: MoveTarget::Routes {
|
||||||
|
file_id: id,
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let moved = file(&fx, id);
|
||||||
|
assert_eq!((moved.rte.len(), moved.trk.len()), (2, 2));
|
||||||
|
assert_eq!(moved.trk[0].id, track);
|
||||||
|
|
||||||
|
// and copied routes pasted on a track stay routes
|
||||||
|
fx.clipboard = Clipboard::new(&routes(&fx, id, &[0]), &fx.files, &fx.order.0, false);
|
||||||
|
fx.selection = Selection::Track {
|
||||||
|
file_id: id,
|
||||||
|
trk_ids: [track].into(),
|
||||||
|
};
|
||||||
|
Paste.apply(&mut fx.state()).unwrap();
|
||||||
|
let pasted = file(&fx, id);
|
||||||
|
assert_eq!((pasted.rte.len(), pasted.trk.len()), (3, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_merge_routes() {
|
||||||
|
let (mut fx, id) = fixture();
|
||||||
|
let before = lengths(file(&fx, id));
|
||||||
|
let first = file(&fx, id).rte[0].id;
|
||||||
|
fx.selection = routes(&fx, id, &[0, 1]);
|
||||||
|
let merge = |type_| Merge {
|
||||||
|
type_,
|
||||||
|
remove_gaps: false,
|
||||||
|
keep_originals: false,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
merge(MergeType::Group).apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
|
||||||
|
let file = file(&fx, id);
|
||||||
|
assert_eq!(file.rte.len(), 1);
|
||||||
|
assert_eq!(file.rte[0].id, first);
|
||||||
|
assert_eq!(file.rte[0].seg.len(), before[0] + before[1]);
|
||||||
|
assert_eq!(file.trk.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_merge_files_with_routes() {
|
||||||
|
let (mut fx, id) = fixture();
|
||||||
|
let other = File {
|
||||||
|
rte: file(&fx, id).rte.iter().map(crate::copy_route).collect(),
|
||||||
|
..File::default()
|
||||||
|
};
|
||||||
|
let other_id = other.id;
|
||||||
|
fx.files.insert(other_id, Rc::new(other));
|
||||||
|
fx.order.0.push(other_id);
|
||||||
|
let total: usize = lengths(file(&fx, id)).iter().sum::<usize>()
|
||||||
|
+ lengths(&fx.files[&other_id]).iter().sum::<usize>();
|
||||||
|
|
||||||
|
let mut grouped = Fixture {
|
||||||
|
files: fx.files.clone(),
|
||||||
|
order: fx.order.clone(),
|
||||||
|
selection: Selection::File {
|
||||||
|
file_ids: [id, other_id].into(),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
Merge {
|
||||||
|
type_: MergeType::Group,
|
||||||
|
remove_gaps: false,
|
||||||
|
keep_originals: false,
|
||||||
|
}
|
||||||
|
.apply(&mut grouped.state())
|
||||||
|
.unwrap();
|
||||||
|
let merged = &grouped.files[&id];
|
||||||
|
assert_eq!((merged.rte.len(), merged.trk.len()), (4, 2));
|
||||||
|
assert!(!grouped.files.contains_key(&other_id));
|
||||||
|
|
||||||
|
// connected in the first route, which comes first
|
||||||
|
fx.selection = Selection::File {
|
||||||
|
file_ids: [id, other_id].into(),
|
||||||
|
};
|
||||||
|
Merge {
|
||||||
|
type_: MergeType::Connect,
|
||||||
|
remove_gaps: false,
|
||||||
|
keep_originals: false,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let merged = &fx.files[&id];
|
||||||
|
assert_eq!((merged.rte.len(), merged.trk.len()), (1, 0));
|
||||||
|
assert_eq!(merged.rte[0].seg.len(), total);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_a_file_with_routes() {
|
||||||
|
let (mut fx, id) = fixture();
|
||||||
|
let names: Vec<_> = file(&fx, id)
|
||||||
|
.rte
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.info.name.clone().unwrap())
|
||||||
|
.collect();
|
||||||
|
fx.selection = whole(id);
|
||||||
|
Extract {
|
||||||
|
keep_originals: false,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
// one file per route and one per track
|
||||||
|
assert_eq!(fx.order.0.len(), 4);
|
||||||
|
let files: Vec<&File> = fx.order.0.iter().map(|id| &*fx.files[id]).collect();
|
||||||
|
for (file, name) in files.iter().zip(&names) {
|
||||||
|
assert_eq!((file.rte.len(), file.trk.len()), (1, 0));
|
||||||
|
assert_eq!(&file.info.name, name);
|
||||||
|
}
|
||||||
|
for file in &files[2..] {
|
||||||
|
assert_eq!((file.rte.len(), file.trk.len()), (0, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// a route has nothing to extract
|
||||||
|
let (mut fx, id) = fixture();
|
||||||
|
fx.selection = routes(&fx, id, &[0]);
|
||||||
|
assert_eq!(
|
||||||
|
Extract {
|
||||||
|
keep_originals: false
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_split_a_route() {
|
||||||
|
let (mut fx, id) = fixture();
|
||||||
|
let before = file(&fx, id).rte[1].clone();
|
||||||
|
fx.selection = routes(&fx, id, &[1]);
|
||||||
|
Split {
|
||||||
|
at: 10,
|
||||||
|
split_type: SplitType::Routes,
|
||||||
|
keep_originals: false,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
// two routes, which share the trackpoint
|
||||||
|
let split = file(&fx, id);
|
||||||
|
assert_eq!(split.rte.len(), 3);
|
||||||
|
assert_eq!(split.rte[1].id, before.id);
|
||||||
|
assert_ne!(split.rte[2].id, before.id);
|
||||||
|
assert_eq!(split.rte[1].seg.len(), 11);
|
||||||
|
assert_eq!(split.rte[2].seg.len(), before.seg.len() - 10);
|
||||||
|
assert_eq!(split.rte[2].info, before.info);
|
||||||
|
|
||||||
|
// a route is not a track nor a segment, and the other way around
|
||||||
|
let (mut fx, id) = fixture();
|
||||||
|
fx.selection = whole(id);
|
||||||
|
let segment_start = lengths(file(&fx, id))[..2].iter().sum::<usize>() as u32;
|
||||||
|
for (at, split_type) in [
|
||||||
|
(10, SplitType::Tracks),
|
||||||
|
(10, SplitType::Segments),
|
||||||
|
(segment_start + 10, SplitType::Routes),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
Split {
|
||||||
|
at,
|
||||||
|
split_type,
|
||||||
|
keep_originals: false,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(file(&fx, id).rte.len(), 2);
|
||||||
|
|
||||||
|
// into two files: the following routes and the tracks go to the second one
|
||||||
|
let (mut fx, id) = fixture();
|
||||||
|
let first_len = file(&fx, id).rte[0].seg.len();
|
||||||
|
fx.selection = whole(id);
|
||||||
|
Split {
|
||||||
|
at: 5,
|
||||||
|
split_type: SplitType::Files,
|
||||||
|
keep_originals: false,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(fx.order.0.len(), 2);
|
||||||
|
let (a, b) = (&fx.files[&fx.order.0[0]], &fx.files[&fx.order.0[1]]);
|
||||||
|
assert_eq!(a.id, id);
|
||||||
|
assert_eq!((a.rte.len(), a.trk.len()), (1, 0));
|
||||||
|
assert_eq!(a.rte[0].seg.len(), 6);
|
||||||
|
assert_eq!((b.rte.len(), b.trk.len()), (2, 2));
|
||||||
|
assert_eq!(b.rte[0].seg.len(), first_len - 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_crop_removes_the_routes_left_without_trackpoints() {
|
||||||
|
let (mut fx, id) = fixture();
|
||||||
|
let len = lengths(file(&fx, id));
|
||||||
|
fx.selection = whole(id);
|
||||||
|
// the second route only
|
||||||
|
let start = len[0] as u32;
|
||||||
|
Crop {
|
||||||
|
start,
|
||||||
|
end: start + len[1] as u32 - 1,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let file = file(&fx, id);
|
||||||
|
assert_eq!((file.rte.len(), file.trk.len()), (1, 0));
|
||||||
|
assert_eq!(file.rte[0].seg.len(), len[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clean_removes_the_routes_left_without_trackpoints() {
|
||||||
|
let (mut fx, id) = fixture();
|
||||||
|
fx.selection = routes(&fx, id, &[0]);
|
||||||
|
let everywhere = LngLatBounds {
|
||||||
|
sw: LngLat {
|
||||||
|
lng: -180.0,
|
||||||
|
lat: -90.0,
|
||||||
|
},
|
||||||
|
ne: LngLat {
|
||||||
|
lng: 180.0,
|
||||||
|
lat: 90.0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Clean {
|
||||||
|
bounds: everywhere,
|
||||||
|
type_: CleanType::Inside,
|
||||||
|
trkpt: true,
|
||||||
|
wpt: false,
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(file(&fx, id).rte.len(), 1);
|
||||||
|
assert_eq!(fx.selection, whole(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reverse_a_route_and_the_routes_of_a_file() {
|
||||||
|
let (mut fx, id) = fixture();
|
||||||
|
let before = file(&fx, id).clone();
|
||||||
|
fx.selection = routes(&fx, id, &[0]);
|
||||||
|
Reverse.apply(&mut fx.state()).unwrap();
|
||||||
|
let route = &file(&fx, id).rte[0].seg;
|
||||||
|
let original = &before.rte[0].seg;
|
||||||
|
assert_eq!(
|
||||||
|
route[0].coordinates.lat,
|
||||||
|
original[original.len() - 1].coordinates.lat
|
||||||
|
);
|
||||||
|
assert_eq!(file(&fx, id).rte[1].seg.rev_id, before.rte[1].seg.rev_id);
|
||||||
|
|
||||||
|
let (mut fx, id) = fixture();
|
||||||
|
let before = file(&fx, id).clone();
|
||||||
|
fx.selection = whole(id);
|
||||||
|
Reverse.apply(&mut fx.state()).unwrap();
|
||||||
|
let file = file(&fx, id);
|
||||||
|
// the routes are in the opposite order, and still before the tracks
|
||||||
|
assert_eq!(file.rte[0].id, before.rte[1].id);
|
||||||
|
assert_eq!(file.trk[0].id, before.trk[1].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_routes_to_tracks_and_back() {
|
||||||
|
let (mut fx, id) = fixture();
|
||||||
|
let before = file(&fx, id).clone();
|
||||||
|
|
||||||
|
// a route becomes a track, first among the tracks, with its segment and its information
|
||||||
|
fx.selection = routes(&fx, id, &[1]);
|
||||||
|
Convert.apply(&mut fx.state()).unwrap();
|
||||||
|
let converted = file(&fx, id);
|
||||||
|
assert_eq!((converted.rte.len(), converted.trk.len()), (1, 3));
|
||||||
|
assert_eq!(converted.trk[0].info, before.rte[1].info);
|
||||||
|
assert_eq!(converted.trk[0].trkseg.len(), 1);
|
||||||
|
assert_eq!(converted.trk[0].trkseg[0].id, before.rte[1].seg.id);
|
||||||
|
assert_eq!(
|
||||||
|
fx.selection,
|
||||||
|
Selection::Track {
|
||||||
|
file_id: id,
|
||||||
|
trk_ids: [converted.trk[0].id].into()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// and back: a route again, after the other routes
|
||||||
|
Convert.apply(&mut fx.state()).unwrap();
|
||||||
|
let back = file(&fx, id);
|
||||||
|
assert_eq!((back.rte.len(), back.trk.len()), (2, 2));
|
||||||
|
assert_eq!(back.rte[1].seg.id, before.rte[1].seg.id);
|
||||||
|
assert_eq!(back.rte[1].info, before.rte[1].info);
|
||||||
|
assert!(
|
||||||
|
matches!(&fx.selection, Selection::Route { rte_ids, .. } if rte_ids.contains(&back.rte[1].id))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_a_track_to_one_route_per_segment() {
|
||||||
|
let (mut fx, id) = fixture();
|
||||||
|
let track = file(&fx, id).trk[0].clone();
|
||||||
|
assert_eq!(track.trkseg.len(), 2);
|
||||||
|
fx.selection = Selection::Track {
|
||||||
|
file_id: id,
|
||||||
|
trk_ids: [track.id].into(),
|
||||||
|
};
|
||||||
|
Convert.apply(&mut fx.state()).unwrap();
|
||||||
|
let converted = file(&fx, id);
|
||||||
|
assert_eq!((converted.rte.len(), converted.trk.len()), (4, 1));
|
||||||
|
for (route, segment) in converted.rte[2..].iter().zip(&track.trkseg) {
|
||||||
|
assert_eq!(route.seg.id, segment.id);
|
||||||
|
assert_eq!(route.info, track.info);
|
||||||
|
}
|
||||||
|
assert!(matches!(&fx.selection, Selection::Route { rte_ids, .. } if rte_ids.len() == 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_segments_to_routes_keeps_their_track() {
|
||||||
|
let (mut fx, id) = fixture();
|
||||||
|
let track = file(&fx, id).trk[1].clone();
|
||||||
|
fx.selection = Selection::TrackSegment {
|
||||||
|
file_id: id,
|
||||||
|
trk_id: track.id,
|
||||||
|
trkseg_ids: [track.trkseg[0].id, track.trkseg[1].id].into(),
|
||||||
|
};
|
||||||
|
Convert.apply(&mut fx.state()).unwrap();
|
||||||
|
let converted = file(&fx, id);
|
||||||
|
assert_eq!((converted.rte.len(), converted.trk.len()), (4, 2));
|
||||||
|
assert!(converted.trk[1].trkseg.is_empty());
|
||||||
|
assert_eq!(converted.rte[2].seg.id, track.trkseg[0].id);
|
||||||
|
|
||||||
|
// nothing to convert otherwise
|
||||||
|
for selection in [
|
||||||
|
whole(id),
|
||||||
|
Selection::Empty,
|
||||||
|
Selection::Waypoints { file_id: id },
|
||||||
|
] {
|
||||||
|
fx.selection = selection;
|
||||||
|
assert_eq!(
|
||||||
|
Convert.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ impl Apply for ChangeLoopStart {
|
|||||||
let index = self.index as usize;
|
let index = self.index as usize;
|
||||||
let target = find_target(state, index, index)?.ok_or(CommandError::NothingToDo)?;
|
let target = find_target(state, index, index)?.ok_or(CommandError::NothingToDo)?;
|
||||||
let mut file = (*state.files[&target.location.file_id]).clone();
|
let mut file = (*state.files[&target.location.file_id]).clone();
|
||||||
let segment = &mut file.trk[target.location.trk].trkseg[target.location.seg];
|
let segment = file.segment_mut(target.location.index);
|
||||||
let (start, len) = (target.start, segment.len());
|
let (start, len) = (target.start, segment.len());
|
||||||
if start == 0 || start >= len {
|
if start == 0 || start >= len {
|
||||||
// it already is the start, or it is not a trackpoint
|
// it already is the start, or it is not a trackpoint
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ pub enum CleanType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Removes the trackpoints and/or waypoints of the selection lying inside (or outside) a
|
/// Removes the trackpoints and/or waypoints of the selection lying inside (or outside) a
|
||||||
/// rectangle. A selected file cleans its tracks and its waypoints, a selected track or segment
|
/// rectangle. A selected file cleans its routes, its tracks and its waypoints, a selected route,
|
||||||
/// only trackpoints, selected waypoints only waypoints.
|
/// track or segment only trackpoints, selected waypoints only waypoints.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Clean {
|
pub struct Clean {
|
||||||
pub bounds: LngLatBounds,
|
pub bounds: LngLatBounds,
|
||||||
@@ -85,6 +85,11 @@ struct Cleaner<'a> {
|
|||||||
impl Editor for Cleaner<'_> {
|
impl Editor for Cleaner<'_> {
|
||||||
fn file(&mut self, file: &mut File) -> Edit {
|
fn file(&mut self, file: &mut File) -> Edit {
|
||||||
let mut edit = Edit::Unchanged;
|
let mut edit = Edit::Unchanged;
|
||||||
|
for rte in &mut file.rte {
|
||||||
|
if self.route(rte) == Edit::Changed {
|
||||||
|
edit = Edit::Changed;
|
||||||
|
}
|
||||||
|
}
|
||||||
for trk in &mut file.trk {
|
for trk in &mut file.trk {
|
||||||
if self.track(trk) == Edit::Changed {
|
if self.track(trk) == Edit::Changed {
|
||||||
edit = Edit::Changed;
|
edit = Edit::Changed;
|
||||||
@@ -124,24 +129,21 @@ impl Editor for Cleaner<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes what the clean left empty: the emptied segments and the tracks that lost all their
|
/// Removes what the clean left empty: the emptied segments, the routes of these segments and the
|
||||||
/// segments. A file is never removed, even if nothing is left in it: it stays, empty. The
|
/// tracks that lost all their segments. A file is never removed, even if nothing is left in it: it stays, empty. The
|
||||||
/// selection falls back to the closest element still existing.
|
/// selection falls back to the closest element still existing.
|
||||||
fn prune(state: &mut State, emptied: &HashSet<TrackSegmentId>, wpt_files: &HashSet<FileId>) {
|
fn prune(state: &mut State, emptied: &HashSet<TrackSegmentId>, wpt_files: &HashSet<FileId>) {
|
||||||
let ids: Vec<FileId> = state
|
let ids: Vec<FileId> = state
|
||||||
.files
|
.files
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(id, file)| {
|
.filter(|(id, file)| {
|
||||||
wpt_files.contains(id)
|
wpt_files.contains(id) || file.segments().any(|seg| emptied.contains(&seg.id))
|
||||||
|| file
|
|
||||||
.trk
|
|
||||||
.iter()
|
|
||||||
.any(|trk| trk.trkseg.iter().any(|seg| emptied.contains(&seg.id)))
|
|
||||||
})
|
})
|
||||||
.map(|(id, _)| *id)
|
.map(|(id, _)| *id)
|
||||||
.collect();
|
.collect();
|
||||||
for id in ids {
|
for id in ids {
|
||||||
let mut file = (*state.files[&id]).clone();
|
let mut file = (*state.files[&id]).clone();
|
||||||
|
file.rte.retain(|rte| !emptied.contains(&rte.seg.id));
|
||||||
file.trk.retain_mut(|trk| {
|
file.trk.retain_mut(|trk| {
|
||||||
let len = trk.trkseg.len();
|
let len = trk.trkseg.len();
|
||||||
trk.trkseg.retain(|seg| !emptied.contains(&seg.id));
|
trk.trkseg.retain(|seg| !emptied.contains(&seg.id));
|
||||||
@@ -167,6 +169,27 @@ fn fixed_selection(state: &State) -> Selection {
|
|||||||
Selection::File { file_ids }
|
Selection::File { file_ids }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Selection::Route { file_id, rte_ids } => {
|
||||||
|
let Some(file) = files.get(file_id) else {
|
||||||
|
return Selection::Empty;
|
||||||
|
};
|
||||||
|
let rte_ids: HashSet<_> = file
|
||||||
|
.rte
|
||||||
|
.iter()
|
||||||
|
.map(|rte| rte.id)
|
||||||
|
.filter(|id| rte_ids.contains(id))
|
||||||
|
.collect();
|
||||||
|
if rte_ids.is_empty() {
|
||||||
|
Selection::File {
|
||||||
|
file_ids: [*file_id].into(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Selection::Route {
|
||||||
|
file_id: *file_id,
|
||||||
|
rte_ids,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Selection::Track { file_id, trk_ids } => {
|
Selection::Track { file_id, trk_ids } => {
|
||||||
let Some(file) = files.get(file_id) else {
|
let Some(file) = files.get(file_id) else {
|
||||||
return Selection::Empty;
|
return Selection::Empty;
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ use crate::{
|
|||||||
/// Keeps the trackpoints `start` to `end` (both included) of the selection, which are counted
|
/// Keeps the trackpoints `start` to `end` (both included) of the selection, which are counted
|
||||||
/// over its segments one after the other (as in the `RoutingBuffer`), and removes the others.
|
/// over its segments one after the other (as in the `RoutingBuffer`), and removes the others.
|
||||||
///
|
///
|
||||||
/// The segments that have no trackpoint left are removed. So are the tracks that have no segment
|
/// The segments that have no trackpoint left are removed, and so are the routes of these segments.
|
||||||
/// left, if tracks or files are selected (not if only segments are), and the files that have no
|
/// So are the tracks that have no segment left, if tracks or files are selected (not if only
|
||||||
/// trackpoint left, if files are selected. A range that goes past the last trackpoint stops at it.
|
/// segments are), and the files that have no trackpoint left, if files are selected. A range that goes past the last trackpoint stops at it.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Crop {
|
pub struct Crop {
|
||||||
pub start: u32,
|
pub start: u32,
|
||||||
@@ -43,8 +43,7 @@ impl Apply for Crop {
|
|||||||
.segment_locations(state.files, &state.order.0)
|
.segment_locations(state.files, &state.order.0)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|location| {
|
.map(|location| {
|
||||||
let len =
|
let len = location.segment(state.files).len();
|
||||||
state.files[&location.file_id].trk[location.trk].trkseg[location.seg].len();
|
|
||||||
let keep = (len > 0 && start < offset + len && end >= offset)
|
let keep = (len > 0 && start < offset + len && end >= offset)
|
||||||
.then(|| (start.saturating_sub(offset), (end - offset).min(len - 1)));
|
.then(|| (start.saturating_sub(offset), (end - offset).min(len - 1)));
|
||||||
offset += len;
|
offset += len;
|
||||||
@@ -73,14 +72,14 @@ impl Apply for Crop {
|
|||||||
let mut removed: HashSet<TrackSegmentId> = HashSet::new();
|
let mut removed: HashSet<TrackSegmentId> = HashSet::new();
|
||||||
let mut had_points: HashSet<FileId> = HashSet::new();
|
let mut had_points: HashSet<FileId> = HashSet::new();
|
||||||
for plan in &plans {
|
for plan in &plans {
|
||||||
let SegmentLocation { file_id, trk, seg } = plan.location;
|
let SegmentLocation { file_id, index } = plan.location;
|
||||||
let file = files
|
let file = files
|
||||||
.entry(file_id)
|
.entry(file_id)
|
||||||
.or_insert_with(|| (*state.files[&file_id]).clone());
|
.or_insert_with(|| (*state.files[&file_id]).clone());
|
||||||
if plan.len > 0 {
|
if plan.len > 0 {
|
||||||
had_points.insert(file_id);
|
had_points.insert(file_id);
|
||||||
}
|
}
|
||||||
let segment = &mut file.trk[trk].trkseg[seg];
|
let segment = file.segment_mut(index);
|
||||||
match plan.keep {
|
match plan.keep {
|
||||||
None => {
|
None => {
|
||||||
removed.insert(segment.id);
|
removed.insert(segment.id);
|
||||||
@@ -91,6 +90,8 @@ impl Apply for Crop {
|
|||||||
|
|
||||||
let mut gone: Vec<FileId> = vec![];
|
let mut gone: Vec<FileId> = vec![];
|
||||||
for (file_id, file) in files.iter_mut() {
|
for (file_id, file) in files.iter_mut() {
|
||||||
|
// a route has its segment, or is not there
|
||||||
|
file.rte.retain(|route| !removed.contains(&route.seg.id));
|
||||||
// the tracks that lost segments
|
// the tracks that lost segments
|
||||||
let touched: Vec<bool> = file
|
let touched: Vec<bool> = file
|
||||||
.trk
|
.trk
|
||||||
@@ -108,7 +109,7 @@ impl Apply for Crop {
|
|||||||
file.trk
|
file.trk
|
||||||
.retain(|track| !(touched.next().unwrap() && track.trkseg.is_empty()));
|
.retain(|track| !(touched.next().unwrap() && track.trkseg.is_empty()));
|
||||||
}
|
}
|
||||||
if prune_files && file.trk.is_empty() && had_points.contains(file_id) {
|
if prune_files && file.has_no_path() && had_points.contains(file_id) {
|
||||||
gone.push(*file_id);
|
gone.push(*file_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ impl Apply for Elevation<'_> {
|
|||||||
.segment_locations(state.files, &state.order.0);
|
.segment_locations(state.files, &state.order.0);
|
||||||
let count: usize = locations
|
let count: usize = locations
|
||||||
.iter()
|
.iter()
|
||||||
.map(|location| {
|
.map(|location| location.segment(state.files).len())
|
||||||
state.files[&location.file_id].trk[location.trk].trkseg[location.seg].len()
|
|
||||||
})
|
|
||||||
.sum();
|
.sum();
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
return Err(CommandError::NothingToDo);
|
return Err(CommandError::NothingToDo);
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
use std::{collections::HashSet, rc::Rc};
|
use std::{collections::HashSet, rc::Rc};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Apply, CommandError, File, FileId, LngLat, LngLatBounds, Selection, State, Track, TrackId,
|
Apply, CommandError, File, FileId, LngLat, LngLatBounds, Route, Selection, State, Track,
|
||||||
TrackSegment, Waypoint, Waypoints, copy_file, copy_waypoint, crossarc_lnglat, distance,
|
TrackId, TrackSegment, Waypoint, Waypoints, copy_file, copy_waypoint, crossarc_lnglat,
|
||||||
|
distance,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Splits the selected elements that are made of several segments into elements that have one
|
/// Splits the selected elements that are made of several segments into elements that have one
|
||||||
/// each:
|
/// each:
|
||||||
///
|
///
|
||||||
/// - files: a file with several tracks becomes one file per track, in which the track is split
|
/// - files: a file with several routes and tracks becomes one file per route and per track, in
|
||||||
/// into one track per segment. A file with a single track becomes one file per segment. The new
|
/// which the track is split into one track per segment. A file with a single track becomes one
|
||||||
/// files are in the place of the file, which goes, and are selected. They are named after the
|
/// file per segment. The new files are in the place of the file, which goes, and are selected.
|
||||||
/// track, or the file, and numbered. The waypoints go to the files that have the segments
|
/// They are named after the route or track, or the file, and numbered. The waypoints go to the files that have the segments
|
||||||
/// closest to them (to the first file if the file has no trackpoint).
|
/// closest to them (to the first file if the file has no trackpoint).
|
||||||
/// - tracks: each selected track is replaced by one track per segment, which are selected. They
|
/// - tracks: each selected track is replaced by one track per segment, which are selected. They
|
||||||
/// are named after the track and numbered.
|
/// are named after the track and numbered.
|
||||||
///
|
///
|
||||||
/// What has a single segment is left as it is. There is nothing to do if nothing was changed.
|
/// What has a single segment is left as it is, which routes always are. There is nothing to do if nothing was changed.
|
||||||
///
|
///
|
||||||
/// With `keep_originals`, the files that are extracted stay where they are, and the new files come
|
/// With `keep_originals`, the files that are extracted stay where they are, and the new files come
|
||||||
/// right after each of them, and only the new files are selected. Extracting
|
/// right after each of them, and only the new files are selected. Extracting
|
||||||
@@ -46,8 +47,7 @@ fn extract_files(
|
|||||||
let mut extracted: Vec<(FileId, Vec<File>)> = vec![];
|
let mut extracted: Vec<(FileId, Vec<File>)> = vec![];
|
||||||
for id in state.order.0.iter().filter(|id| file_ids.contains(id)) {
|
for id in state.order.0.iter().filter(|id| file_ids.contains(id)) {
|
||||||
let file = &state.files[id];
|
let file = &state.files[id];
|
||||||
let segments: usize = file.trk.iter().map(|track| track.trkseg.len()).sum();
|
if file.segments().count() > 1 {
|
||||||
if segments > 1 {
|
|
||||||
let mut files = extract_file(file);
|
let mut files = extract_file(file);
|
||||||
if keep_originals {
|
if keep_originals {
|
||||||
// the tracks and segments are the ones of the file, which stays: they need new ids
|
// the tracks and segments are the ones of the file, which stays: they need new ids
|
||||||
@@ -95,60 +95,65 @@ fn extract_files(
|
|||||||
|
|
||||||
/// The files that a file with several segments becomes.
|
/// The files that a file with several segments becomes.
|
||||||
fn extract_file(file: &File) -> Vec<File> {
|
fn extract_file(file: &File) -> Vec<File> {
|
||||||
// for each new file: its tracks and its name, and the segments that go with it
|
// for each new file: its routes, its tracks and its name, and the segments that go with it
|
||||||
struct Part {
|
struct Part {
|
||||||
|
routes: Vec<Route>,
|
||||||
tracks: Vec<Track>,
|
tracks: Vec<Track>,
|
||||||
name: String,
|
name: String,
|
||||||
}
|
}
|
||||||
let (parts, owner): (Vec<Part>, Vec<usize>) = if file.trk.len() > 1 {
|
let name = |name: &Option<String>, i: usize| {
|
||||||
// one file per track, the segments of a track follow it
|
name.clone()
|
||||||
let parts = file
|
.unwrap_or_else(|| format!("{} ({})", file.info.name, i + 1))
|
||||||
.trk
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, track)| Part {
|
|
||||||
tracks: split_track(track),
|
|
||||||
name: track
|
|
||||||
.info
|
|
||||||
.name
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| format!("{} ({})", file.info.name, i + 1)),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let owner = file
|
|
||||||
.trk
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.flat_map(|(i, track)| track.trkseg.iter().map(move |_| i))
|
|
||||||
.collect();
|
|
||||||
(parts, owner)
|
|
||||||
} else {
|
|
||||||
// one file per segment
|
|
||||||
let track = &file.trk[0];
|
|
||||||
let base = track
|
|
||||||
.info
|
|
||||||
.name
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| file.info.name.clone());
|
|
||||||
let parts = track
|
|
||||||
.trkseg
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, segment)| Part {
|
|
||||||
tracks: vec![Track {
|
|
||||||
id: if i == 0 { track.id } else { Default::default() },
|
|
||||||
trkseg: vec![segment.clone()],
|
|
||||||
..track.clone()
|
|
||||||
}],
|
|
||||||
name: format!("{base} ({})", i + 1),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let owner = (0..track.trkseg.len()).collect();
|
|
||||||
(parts, owner)
|
|
||||||
};
|
};
|
||||||
|
let (parts, owner): (Vec<Part>, Vec<usize>) =
|
||||||
|
if file.rte.len() + file.trk.len() > 1 {
|
||||||
|
// one file per route and per track, the segments of a track follow it
|
||||||
|
let routes = file.rte.iter().enumerate().map(|(i, route)| Part {
|
||||||
|
routes: vec![route.clone()],
|
||||||
|
tracks: vec![],
|
||||||
|
name: name(&route.info.name, i),
|
||||||
|
});
|
||||||
|
let tracks = file.trk.iter().enumerate().map(|(i, track)| Part {
|
||||||
|
routes: vec![],
|
||||||
|
tracks: split_track(track),
|
||||||
|
name: name(&track.info.name, file.rte.len() + i),
|
||||||
|
});
|
||||||
|
let parts = routes.chain(tracks).collect();
|
||||||
|
let owner =
|
||||||
|
(0..file.rte.len())
|
||||||
|
.chain(file.trk.iter().enumerate().flat_map(|(i, track)| {
|
||||||
|
track.trkseg.iter().map(move |_| file.rte.len() + i)
|
||||||
|
}))
|
||||||
|
.collect();
|
||||||
|
(parts, owner)
|
||||||
|
} else {
|
||||||
|
// one file per segment
|
||||||
|
let track = &file.trk[0];
|
||||||
|
let base = track
|
||||||
|
.info
|
||||||
|
.name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| file.info.name.clone());
|
||||||
|
let parts = track
|
||||||
|
.trkseg
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, segment)| Part {
|
||||||
|
routes: vec![],
|
||||||
|
tracks: vec![Track {
|
||||||
|
id: if i == 0 { track.id } else { Default::default() },
|
||||||
|
trkseg: vec![segment.clone()],
|
||||||
|
..track.clone()
|
||||||
|
}],
|
||||||
|
name: format!("{base} ({})", i + 1),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let owner = (0..track.trkseg.len()).collect();
|
||||||
|
(parts, owner)
|
||||||
|
};
|
||||||
|
|
||||||
// the waypoints go with the segments that are the closest to them
|
// the waypoints go with the segments that are the closest to them
|
||||||
let segments: Vec<&TrackSegment> = file.trk.iter().flat_map(|track| &track.trkseg).collect();
|
let segments: Vec<&TrackSegment> = file.segments().collect();
|
||||||
let mut waypoints: Vec<Vec<Waypoint>> = vec![vec![]; parts.len()];
|
let mut waypoints: Vec<Vec<Waypoint>> = vec![vec![]; parts.len()];
|
||||||
for waypoint in file.wpt.iter() {
|
for waypoint in file.wpt.iter() {
|
||||||
let mut owners: Vec<usize> = closest_segments(&segments, waypoint.coordinates)
|
let mut owners: Vec<usize> = closest_segments(&segments, waypoint.coordinates)
|
||||||
@@ -176,6 +181,7 @@ fn extract_file(file: &File) -> Vec<File> {
|
|||||||
.map(|(part, waypoints)| {
|
.map(|(part, waypoints)| {
|
||||||
let mut extracted = File {
|
let mut extracted = File {
|
||||||
id: Default::default(),
|
id: Default::default(),
|
||||||
|
rte: part.routes,
|
||||||
trk: part.tracks,
|
trk: part.tracks,
|
||||||
wpt: Waypoints::default(),
|
wpt: Waypoints::default(),
|
||||||
..file.clone()
|
..file.clone()
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ impl Apply for InsertAnchor {
|
|||||||
.selection
|
.selection
|
||||||
.segment_locations(state.files, &state.order.0)
|
.segment_locations(state.files, &state.order.0)
|
||||||
{
|
{
|
||||||
let segment = &state.files[&location.file_id].trk[location.trk].trkseg[location.seg];
|
let segment = location.segment(state.files);
|
||||||
let mut points = segment.iter();
|
let mut points = segment.iter();
|
||||||
let Some(mut previous) = points.next() else {
|
let Some(mut previous) = points.next() else {
|
||||||
continue;
|
continue;
|
||||||
@@ -81,7 +81,7 @@ impl Apply for InsertAnchor {
|
|||||||
let closest = closest.ok_or(CommandError::NothingToDo)?;
|
let closest = closest.ok_or(CommandError::NothingToDo)?;
|
||||||
|
|
||||||
let mut file = (*state.files[&closest.location.file_id]).clone();
|
let mut file = (*state.files[&closest.location.file_id]).clone();
|
||||||
let segment = &mut file.trk[closest.location.trk].trkseg[closest.location.seg];
|
let segment = file.segment_mut(closest.location.index);
|
||||||
let before = closest.before;
|
let before = closest.before;
|
||||||
// on a trackpoint already, if it is closer than `SNAP_DISTANCE`
|
// on a trackpoint already, if it is closer than `SNAP_DISTANCE`
|
||||||
let snapped = if segment.len() == 1 {
|
let snapped = if segment.len() == 1 {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use std::{collections::HashSet, rc::Rc};
|
use std::{collections::HashSet, rc::Rc};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Apply, CommandError, FileId, GlobalStatistics, Selection, State, Statistics, Track, TrackId,
|
Apply, CommandError, FileId, GlobalStatistics, Route, RouteId, Selection, State, Statistics,
|
||||||
TrackSegment, TrackSegmentId, Waypoint, Waypoints, copy_track, replace_trackpoints,
|
Track, TrackId, TrackSegment, TrackSegmentId, Waypoint, Waypoints, copy_route, copy_track,
|
||||||
|
replace_trackpoints,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
|
||||||
@@ -16,13 +17,16 @@ pub enum MergeType {
|
|||||||
|
|
||||||
/// Merges the selected elements into the first of them:
|
/// Merges the selected elements into the first of them:
|
||||||
///
|
///
|
||||||
/// - files: the other files are deleted. The tracks of all of them are in the first file, as
|
/// - files: the other files are deleted. The routes and tracks of all of them are in the first
|
||||||
/// they are with `Group`, or all connected in the first track with `Connect`. The waypoints of
|
/// file, as they are with `Group`, or all connected in the first of them (a route or a track,
|
||||||
|
/// the routes of a file coming before its tracks) with `Connect`. The waypoints of
|
||||||
/// all of them are in the first file too, except the ones that are the same. With
|
/// all of them are in the first file too, except the ones that are the same. With
|
||||||
/// `keep_originals`, no file is deleted nor changed: the result is a new file (with the info of
|
/// `keep_originals`, no file is deleted nor changed: the result is a new file (with the info of
|
||||||
/// the first one and new ids), which comes right after the last of the files and is selected.
|
/// the first one and new ids), which comes right after the last of the files and is selected.
|
||||||
/// - tracks of a file: the first track gets their segments, as they are with `Group`, or all
|
/// - tracks of a file: the first track gets their segments, as they are with `Group`, or all
|
||||||
/// connected in one segment with `Connect`. The other tracks are removed.
|
/// connected in one segment with `Connect`. The other tracks are removed.
|
||||||
|
/// - routes of a file: the first route gets their trackpoints, all connected with `Connect`, and
|
||||||
|
/// the other routes are removed. A route has a single segment, so there is nothing to `Group`.
|
||||||
/// - segments of a track: they are all connected in a segment at the position of the first one
|
/// - segments of a track: they are all connected in a segment at the position of the first one
|
||||||
/// with `Connect`. With `Group` they are put one after the other, at that position.
|
/// with `Connect`. With `Group` they are put one after the other, at that position.
|
||||||
///
|
///
|
||||||
@@ -44,6 +48,7 @@ impl Apply for Merge {
|
|||||||
let selection = state.selection.clone();
|
let selection = state.selection.clone();
|
||||||
match &selection {
|
match &selection {
|
||||||
Selection::File { file_ids } => self.merge_files(state, file_ids),
|
Selection::File { file_ids } => self.merge_files(state, file_ids),
|
||||||
|
Selection::Route { file_id, rte_ids } => self.merge_routes(state, *file_id, rte_ids),
|
||||||
Selection::Track { file_id, trk_ids } => self.merge_tracks(state, *file_id, trk_ids),
|
Selection::Track { file_id, trk_ids } => self.merge_tracks(state, *file_id, trk_ids),
|
||||||
Selection::TrackSegment {
|
Selection::TrackSegment {
|
||||||
file_id,
|
file_id,
|
||||||
@@ -77,21 +82,26 @@ impl Merge {
|
|||||||
return Err(CommandError::NothingToDo);
|
return Err(CommandError::NothingToDo);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut routes: Vec<Route> = vec![];
|
||||||
let mut tracks: Vec<Track> = vec![];
|
let mut tracks: Vec<Track> = vec![];
|
||||||
|
let mut segments: Vec<TrackSegment> = vec![];
|
||||||
let mut waypoints: Vec<Waypoint> = vec![];
|
let mut waypoints: Vec<Waypoint> = vec![];
|
||||||
for id in &ordered {
|
for id in &ordered {
|
||||||
let file = &state.files[id];
|
let file = &state.files[id];
|
||||||
|
routes.extend(file.rte.iter().cloned());
|
||||||
tracks.extend(file.trk.iter().cloned());
|
tracks.extend(file.trk.iter().cloned());
|
||||||
|
segments.extend(file.segments().cloned());
|
||||||
for waypoint in file.wpt.iter() {
|
for waypoint in file.wpt.iter() {
|
||||||
if !waypoints.iter().any(|kept| kept.same_content(waypoint)) {
|
if !waypoints.iter().any(|kept| kept.same_content(waypoint)) {
|
||||||
waypoints.push(waypoint.clone());
|
waypoints.push(waypoint.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let segments: Vec<TrackSegment> = tracks
|
// the first route or track, which holds the connected segment
|
||||||
.iter()
|
let first_is_route = ordered.iter().find_map(|id| {
|
||||||
.flat_map(|track| track.trkseg.iter().cloned())
|
let file = &state.files[id];
|
||||||
.collect();
|
(!file.has_no_path()).then(|| !file.rte.is_empty())
|
||||||
|
});
|
||||||
|
|
||||||
match self.type_ {
|
match self.type_ {
|
||||||
MergeType::Group if ordered.len() == 1 => return Err(CommandError::NothingToDo),
|
MergeType::Group if ordered.len() == 1 => return Err(CommandError::NothingToDo),
|
||||||
@@ -99,24 +109,35 @@ impl Merge {
|
|||||||
return Err(CommandError::NothingToDo);
|
return Err(CommandError::NothingToDo);
|
||||||
}
|
}
|
||||||
MergeType::Connect if !segments.is_empty() => {
|
MergeType::Connect if !segments.is_empty() => {
|
||||||
// a single track, which is the first one, with the connected segment
|
// a single route or track, which is the first one, with the connected segment
|
||||||
let merged = connect(&segments, segments[0].id, self.remove_gaps);
|
let merged = connect(&segments, segments[0].id, self.remove_gaps);
|
||||||
let first = Track {
|
if first_is_route == Some(true) {
|
||||||
trkseg: vec![merged],
|
let first = Route {
|
||||||
..tracks[0].clone()
|
seg: merged,
|
||||||
};
|
..routes[0].clone()
|
||||||
tracks = vec![first];
|
};
|
||||||
|
(routes, tracks) = (vec![first], vec![]);
|
||||||
|
} else {
|
||||||
|
let first = Track {
|
||||||
|
trkseg: vec![merged],
|
||||||
|
..tracks[0].clone()
|
||||||
|
};
|
||||||
|
(routes, tracks) = (vec![], vec![first]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut target = (*state.files[&target_id]).clone();
|
let mut target = (*state.files[&target_id]).clone();
|
||||||
|
target.rte = routes;
|
||||||
target.trk = tracks;
|
target.trk = tracks;
|
||||||
target.wpt = Waypoints::default();
|
target.wpt = Waypoints::default();
|
||||||
target.wpt.insert_at(0, waypoints);
|
target.wpt.insert_at(0, waypoints);
|
||||||
if self.keep_originals {
|
if self.keep_originals {
|
||||||
// a new file, whose tracks and segments do not share their ids with the originals
|
// a new file, whose routes, tracks and segments do not share their ids with the
|
||||||
|
// originals
|
||||||
target.id = Default::default();
|
target.id = Default::default();
|
||||||
|
target.rte = target.rte.iter().map(copy_route).collect();
|
||||||
target.trk = target.trk.iter().map(copy_track).collect();
|
target.trk = target.trk.iter().map(copy_track).collect();
|
||||||
let at = state
|
let at = state
|
||||||
.order
|
.order
|
||||||
@@ -139,6 +160,33 @@ impl Merge {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn merge_routes(
|
||||||
|
&self,
|
||||||
|
state: &mut State,
|
||||||
|
file_id: FileId,
|
||||||
|
rte_ids: &HashSet<RouteId>,
|
||||||
|
) -> Result<(), CommandError> {
|
||||||
|
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
|
||||||
|
let selected: Vec<usize> = (0..file.rte.len())
|
||||||
|
.filter(|i| rte_ids.contains(&file.rte[*i].id))
|
||||||
|
.collect();
|
||||||
|
if selected.len() < 2 || self.type_ == MergeType::Group {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
|
let first = selected[0];
|
||||||
|
let segments: Vec<TrackSegment> =
|
||||||
|
selected.iter().map(|i| file.rte[*i].seg.clone()).collect();
|
||||||
|
let mut file = (**file).clone();
|
||||||
|
file.rte[first].seg = connect(&segments, segments[0].id, self.remove_gaps);
|
||||||
|
let mut index = 0;
|
||||||
|
file.rte.retain(|_| {
|
||||||
|
index += 1;
|
||||||
|
!selected[1..].contains(&(index - 1))
|
||||||
|
});
|
||||||
|
state.files.insert(file_id, Rc::new(file));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn merge_tracks(
|
fn merge_tracks(
|
||||||
&self,
|
&self,
|
||||||
state: &mut State,
|
state: &mut State,
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ impl Apply for NewWaypoint<'_> {
|
|||||||
.filter(|id| file_ids.contains(id))
|
.filter(|id| file_ids.contains(id))
|
||||||
.copied()
|
.copied()
|
||||||
.collect(),
|
.collect(),
|
||||||
Selection::Track { file_id, .. }
|
Selection::Route { file_id, .. }
|
||||||
|
| Selection::Track { file_id, .. }
|
||||||
| Selection::TrackSegment { file_id, .. }
|
| Selection::TrackSegment { file_id, .. }
|
||||||
| Selection::Waypoints { file_id }
|
| Selection::Waypoints { file_id }
|
||||||
| Selection::Waypoint { file_id, .. } => vec![*file_id],
|
| Selection::Waypoint { file_id, .. } => vec![*file_id],
|
||||||
@@ -118,12 +119,14 @@ mod tests {
|
|||||||
New {
|
New {
|
||||||
name: "a",
|
name: "a",
|
||||||
trackpoint: None,
|
trackpoint: None,
|
||||||
|
route: false,
|
||||||
}
|
}
|
||||||
.apply(&mut fx.state())
|
.apply(&mut fx.state())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
New {
|
New {
|
||||||
name: "b",
|
name: "b",
|
||||||
trackpoint: None,
|
trackpoint: None,
|
||||||
|
route: false,
|
||||||
}
|
}
|
||||||
.apply(&mut fx.state())
|
.apply(&mut fx.state())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -169,6 +172,7 @@ mod tests {
|
|||||||
New {
|
New {
|
||||||
name: "a",
|
name: "a",
|
||||||
trackpoint: None,
|
trackpoint: None,
|
||||||
|
route: false,
|
||||||
}
|
}
|
||||||
.apply(&mut fx.state())
|
.apply(&mut fx.state())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -196,6 +200,7 @@ mod tests {
|
|||||||
New {
|
New {
|
||||||
name,
|
name,
|
||||||
trackpoint: None,
|
trackpoint: None,
|
||||||
|
route: false,
|
||||||
}
|
}
|
||||||
.apply(&mut fx.state())
|
.apply(&mut fx.state())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -234,6 +239,7 @@ mod tests {
|
|||||||
New {
|
New {
|
||||||
name: "a",
|
name: "a",
|
||||||
trackpoint: None,
|
trackpoint: None,
|
||||||
|
route: false,
|
||||||
}
|
}
|
||||||
.apply(&mut fx.state())
|
.apply(&mut fx.state())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -250,6 +256,7 @@ mod tests {
|
|||||||
New {
|
New {
|
||||||
name: "a",
|
name: "a",
|
||||||
trackpoint: None,
|
trackpoint: None,
|
||||||
|
route: false,
|
||||||
}
|
}
|
||||||
.apply(&mut fx.state())
|
.apply(&mut fx.state())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ impl Apply for Reduce {
|
|||||||
let mut changed = vec![];
|
let mut changed = vec![];
|
||||||
let mut kept_points = vec![];
|
let mut kept_points = vec![];
|
||||||
for location in &locations {
|
for location in &locations {
|
||||||
let segment = &state.files[&location.file_id].trk[location.trk].trkseg[location.seg];
|
let segment = location.segment(state.files);
|
||||||
let kept = reduce_indices(segment, self.tolerance);
|
let kept = reduce_indices(segment, self.tolerance);
|
||||||
if kept.len() == segment.len() {
|
if kept.len() == segment.len() {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Apply, CommandError, File, FileId, Selection, State, TrackId, TrackSegmentId, end_time,
|
Apply, CommandError, File, FileId, RouteId, Selection, State, TrackId, TrackSegmentId,
|
||||||
reverse_segment, reverse_segments, reverse_tracks, start_time, track_end_time,
|
end_time, reverse_routes, reverse_segment, reverse_segments, reverse_tracks, start_time,
|
||||||
track_start_time,
|
track_end_time, track_start_time,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Reverses the direction of the selected elements: the whole of each selected file (its tracks,
|
/// Reverses the direction of the selected elements: the whole of each selected file (its routes,
|
||||||
/// the segments of its tracks and their trackpoints are in the opposite order), of each selected
|
/// its tracks, the segments of its tracks and their trackpoints are in the opposite order; the
|
||||||
/// track (its segments and their trackpoints), or each selected segment on its own (its
|
/// routes and the tracks are two wholes, as the routes stay before the tracks), of each selected
|
||||||
|
/// track (its segments and their trackpoints), or each selected route or segment on its own (its
|
||||||
/// trackpoints). The timestamps are mirrored, see [`reverse_segment`].
|
/// trackpoints). The timestamps are mirrored, see [`reverse_segment`].
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Reverse;
|
pub struct Reverse;
|
||||||
@@ -20,9 +21,7 @@ impl Apply for Reverse {
|
|||||||
.segment_locations(state.files, &state.order.0);
|
.segment_locations(state.files, &state.order.0);
|
||||||
let points: usize = locations
|
let points: usize = locations
|
||||||
.iter()
|
.iter()
|
||||||
.map(|location| {
|
.map(|location| location.segment(state.files).len())
|
||||||
state.files[&location.file_id].trk[location.trk].trkseg[location.seg].len()
|
|
||||||
})
|
|
||||||
.sum();
|
.sum();
|
||||||
if points <= 1 {
|
if points <= 1 {
|
||||||
return Err(CommandError::NothingToDo);
|
return Err(CommandError::NothingToDo);
|
||||||
@@ -40,12 +39,18 @@ impl Apply for Reverse {
|
|||||||
Selection::File { file_ids } => {
|
Selection::File { file_ids } => {
|
||||||
for file_id in file_ids {
|
for file_id in file_ids {
|
||||||
edit(file_id, &|file| {
|
edit(file_id, &|file| {
|
||||||
|
let end = file.rte.last().and_then(|rte| end_time(&rte.seg));
|
||||||
|
let start = file.rte.first().and_then(|rte| start_time(&rte.seg));
|
||||||
|
reverse_routes(&mut file.rte, end, start);
|
||||||
let end = file.trk.last().and_then(track_end_time);
|
let end = file.trk.last().and_then(track_end_time);
|
||||||
let start = file.trk.first().and_then(track_start_time);
|
let start = file.trk.first().and_then(track_start_time);
|
||||||
reverse_tracks(&mut file.trk, end, start);
|
reverse_tracks(&mut file.trk, end, start);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Selection::Route { file_id, rte_ids } => edit(file_id, &|file| {
|
||||||
|
reverse_selected_routes(file, rte_ids);
|
||||||
|
}),
|
||||||
Selection::Track { file_id, trk_ids } => edit(file_id, &|file| {
|
Selection::Track { file_id, trk_ids } => edit(file_id, &|file| {
|
||||||
reverse_selected_tracks(file, trk_ids);
|
reverse_selected_tracks(file, trk_ids);
|
||||||
}),
|
}),
|
||||||
@@ -66,6 +71,13 @@ impl Apply for Reverse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reverse_selected_routes(file: &mut File, ids: &std::collections::HashSet<RouteId>) {
|
||||||
|
for route in file.rte.iter_mut().filter(|route| ids.contains(&route.id)) {
|
||||||
|
let (end, start) = (end_time(&route.seg), start_time(&route.seg));
|
||||||
|
reverse_segment(&mut route.seg, end, start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn reverse_selected_tracks(file: &mut File, ids: &std::collections::HashSet<TrackId>) {
|
fn reverse_selected_tracks(file: &mut File, ids: &std::collections::HashSet<TrackId>) {
|
||||||
for track in file.trk.iter_mut().filter(|track| ids.contains(&track.id)) {
|
for track in file.trk.iter_mut().filter(|track| ids.contains(&track.id)) {
|
||||||
let end = track.trkseg.last().and_then(end_time);
|
let end = track.trkseg.last().and_then(end_time);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ impl Apply for RoundTrip {
|
|||||||
.selection
|
.selection
|
||||||
.segment_locations(state.files, &state.order.0)
|
.segment_locations(state.files, &state.order.0)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|l| state.files[&l.file_id].trk[l.trk].trkseg[l.seg].len() >= 2)
|
.filter(|l| l.segment(state.files).len() >= 2)
|
||||||
.collect();
|
.collect();
|
||||||
if locations.is_empty() {
|
if locations.is_empty() {
|
||||||
return Err(CommandError::NothingToDo);
|
return Err(CommandError::NothingToDo);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Apply, Categories, CommandError, FileId, SegmentLocation, Selection, State, Statistics, Track,
|
Apply, Categories, CommandError, FileId, SegmentIndex, SegmentLocation, Selection, State,
|
||||||
TrackSegment, Trackpoint, distance, replace_trackpoints,
|
Statistics, Track, TrackSegment, Trackpoint, distance, replace_trackpoints,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The values of an OSM attribute (surface, highway...) of the new trackpoints of a [`Route`], as
|
/// The values of an OSM attribute (surface, highway...) of the new trackpoints of a [`Reroute`], as
|
||||||
/// the intervals of consecutive trackpoints that share one: `starts[i]` is the index of the first
|
/// the intervals of consecutive trackpoints that share one: `starts[i]` is the index of the first
|
||||||
/// trackpoint of the interval `i`, and `values[i]` its value, 0 when unknown, else 1 + the index
|
/// trackpoint of the interval `i`, and `values[i]` its value, 0 when unknown, else 1 + the index
|
||||||
/// of its name in `names`. No intervals means that the attribute is unknown everywhere.
|
/// of its name in `names`. No intervals means that the attribute is unknown everywhere.
|
||||||
@@ -64,7 +64,7 @@ impl RouteCategory<'_> {
|
|||||||
/// anchors anyway. The ones right before and after the range, between which the route goes, become
|
/// anchors anyway. The ones right before and after the range, between which the route goes, become
|
||||||
/// shown at every zoom level as well.
|
/// shown at every zoom level as well.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Route<'a> {
|
pub struct Reroute<'a> {
|
||||||
pub start: u32,
|
pub start: u32,
|
||||||
pub end: u32,
|
pub end: u32,
|
||||||
pub lng: &'a [f64],
|
pub lng: &'a [f64],
|
||||||
@@ -89,7 +89,7 @@ pub(super) struct Target {
|
|||||||
pub end: usize,
|
pub end: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Apply for Route<'_> {
|
impl Apply for Reroute<'_> {
|
||||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
let (start, end) = (self.start as usize, self.end as usize);
|
let (start, end) = (self.start as usize, self.end as usize);
|
||||||
let len = self.lng.len();
|
let len = self.lng.len();
|
||||||
@@ -152,14 +152,17 @@ impl Apply for Route<'_> {
|
|||||||
}
|
}
|
||||||
let seg = file.trk[trk].trkseg.len() - 1;
|
let seg = file.trk[trk].trkseg.len() - 1;
|
||||||
Target {
|
Target {
|
||||||
location: SegmentLocation { file_id, trk, seg },
|
location: SegmentLocation {
|
||||||
|
file_id,
|
||||||
|
index: SegmentIndex::Track { trk, seg },
|
||||||
|
},
|
||||||
start: 0,
|
start: 0,
|
||||||
end: 0,
|
end: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let segment = &mut file.trk[target.location.trk].trkseg[target.location.seg];
|
let segment = file.segment_mut(target.location.index);
|
||||||
let new_len = points.len();
|
let new_len = points.len();
|
||||||
let (speed, start_time) = speed_and_start_time(segment, &target, &points);
|
let (speed, start_time) = speed_and_start_time(segment, &target, &points);
|
||||||
let has_times = !segment.is_empty() && segment[0].time.is_some();
|
let has_times = !segment.is_empty() && segment[0].time.is_some();
|
||||||
@@ -208,7 +211,7 @@ pub(super) fn find_target(
|
|||||||
let segments: Vec<(SegmentLocation, usize)> = locations
|
let segments: Vec<(SegmentLocation, usize)> = locations
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|location| {
|
.map(|location| {
|
||||||
let segment = &state.files[&location.file_id].trk[location.trk].trkseg[location.seg];
|
let segment = location.segment(state.files);
|
||||||
(location, segment.len())
|
(location, segment.len())
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -382,8 +385,8 @@ mod tests {
|
|||||||
lng: &'a [f64],
|
lng: &'a [f64],
|
||||||
lat: &'a [f64],
|
lat: &'a [f64],
|
||||||
ele: &'a [f64],
|
ele: &'a [f64],
|
||||||
) -> Route<'a> {
|
) -> Reroute<'a> {
|
||||||
Route {
|
Reroute {
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
lng,
|
lng,
|
||||||
@@ -459,7 +462,7 @@ mod tests {
|
|||||||
route(0, 0, &[1.0], &[], &[]).apply(&mut fx.state()),
|
route(0, 0, &[1.0], &[], &[]).apply(&mut fx.state()),
|
||||||
route(0, len + 1, &[], &[], &[]).apply(&mut fx.state()),
|
route(0, len + 1, &[], &[], &[]).apply(&mut fx.state()),
|
||||||
route(len + 1, len + 1, &[1.0], &[1.0], &[1.0]).apply(&mut fx.state()),
|
route(len + 1, len + 1, &[1.0], &[1.0], &[1.0]).apply(&mut fx.state()),
|
||||||
Route {
|
Reroute {
|
||||||
anchors: &[1],
|
anchors: &[1],
|
||||||
..route(0, 0, &[1.0], &[1.0], &[1.0])
|
..route(0, 0, &[1.0], &[1.0], &[1.0])
|
||||||
}
|
}
|
||||||
@@ -589,7 +592,7 @@ mod tests {
|
|||||||
|
|
||||||
// 5 new points replacing the 2 in the middle, two of them are anchors
|
// 5 new points replacing the 2 in the middle, two of them are anchors
|
||||||
let (lng, lat, ele) = ([1.0; 5], [2.0; 5], [3.0; 5]);
|
let (lng, lat, ele) = ([1.0; 5], [2.0; 5], [3.0; 5]);
|
||||||
Route {
|
Reroute {
|
||||||
anchors: &[1, 3],
|
anchors: &[1, 3],
|
||||||
..route(1, 3, &lng, &lat, &ele)
|
..route(1, 3, &lng, &lat, &ele)
|
||||||
}
|
}
|
||||||
@@ -639,7 +642,7 @@ mod tests {
|
|||||||
let names = ["asphalt".to_string(), "gravel".to_string()];
|
let names = ["asphalt".to_string(), "gravel".to_string()];
|
||||||
let (lng, lat, ele) = ([1.0; 4], [2.0; 4], [3.0; 4]);
|
let (lng, lat, ele) = ([1.0; 4], [2.0; 4], [3.0; 4]);
|
||||||
// asphalt, asphalt, unknown, gravel
|
// asphalt, asphalt, unknown, gravel
|
||||||
Route {
|
Reroute {
|
||||||
surface: RouteCategory {
|
surface: RouteCategory {
|
||||||
starts: &[0, 2, 3],
|
starts: &[0, 2, 3],
|
||||||
values: &[1, 0, 2],
|
values: &[1, 0, 2],
|
||||||
@@ -683,7 +686,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
for surface in broken {
|
for surface in broken {
|
||||||
let result = Route {
|
let result = Reroute {
|
||||||
surface,
|
surface,
|
||||||
..route(0, 0, &lng, &lat, &ele)
|
..route(0, 0, &lng, &lat, &ele)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Apply, CommandError, File, Selection, State, Track, TrackSegment, Waypoints, copy_file,
|
Apply, CommandError, File, Route, SegmentIndex, Selection, State, Track, TrackSegment,
|
||||||
copy_segment, copy_track, copy_waypoint,
|
Waypoints, copy_file, copy_route, copy_segment, copy_track, copy_waypoint,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::route::find_target;
|
use super::route::find_target;
|
||||||
@@ -13,24 +13,27 @@ use super::route::find_target;
|
|||||||
pub enum SplitType {
|
pub enum SplitType {
|
||||||
/// The file becomes two files, which both have the waypoints (copies, in the second one).
|
/// The file becomes two files, which both have the waypoints (copies, in the second one).
|
||||||
Files,
|
Files,
|
||||||
|
/// The route becomes two routes.
|
||||||
|
Routes,
|
||||||
/// The track becomes two tracks.
|
/// The track becomes two tracks.
|
||||||
Tracks,
|
Tracks,
|
||||||
/// The segment becomes two segments.
|
/// The segment of a track becomes two segments.
|
||||||
Segments,
|
Segments,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cuts the file, the track or the segment of the trackpoint `at` of the selection (see
|
/// Cuts the file, the route, the track or the segment of the trackpoint `at` of the selection (see
|
||||||
/// `RoutingBuffer`) in two, there: the trackpoint is the last one of the first part and the first
|
/// `RoutingBuffer`) in two, there: the trackpoint is the last one of the first part and the first
|
||||||
/// one of the second part.
|
/// one of the second part. Cutting a route needs the trackpoint to be in a route, and cutting a
|
||||||
|
/// track or a segment needs it to be in a track: there is nothing to do otherwise.
|
||||||
///
|
///
|
||||||
/// The first part keeps the ids of what is cut, the second one has new ids, as the elements that
|
/// The first part keeps the ids of what is cut, the second one has new ids, as the elements that
|
||||||
/// come after the trackpoint (the following segments of the track, the following tracks of the
|
/// come after the trackpoint (the following segments of the track, the following routes and
|
||||||
/// file), which are moved to it. When the file is cut, the new file comes right after it. The
|
/// tracks of the file, the routes coming before the tracks), which are moved to it. When the file
|
||||||
/// selection does not change.
|
/// is cut, the new file comes right after it. The selection does not change.
|
||||||
///
|
///
|
||||||
/// With `keep_originals`, a file that is cut (`SplitType::Files`) is left as it is: its two parts
|
/// With `keep_originals`, a file that is cut (`SplitType::Files`) is left as it is: its two parts
|
||||||
/// are two new files, with new ids, which come right after it and are selected. Cutting a track
|
/// are two new files, with new ids, which come right after it and are selected. Cutting a route, a
|
||||||
/// or a segment changes the file in place anyway, so the flag has no effect on them.
|
/// track or a segment changes the file in place anyway, so the flag has no effect on them.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Split {
|
pub struct Split {
|
||||||
pub at: u32,
|
pub at: u32,
|
||||||
@@ -43,104 +46,115 @@ impl Apply for Split {
|
|||||||
let at = self.at as usize;
|
let at = self.at as usize;
|
||||||
let target = find_target(state, at, at)?.ok_or(CommandError::NothingToDo)?;
|
let target = find_target(state, at, at)?.ok_or(CommandError::NothingToDo)?;
|
||||||
let location = target.location;
|
let location = target.location;
|
||||||
let (file_id, trk, seg, index) =
|
let (file_id, index) = (location.file_id, target.start);
|
||||||
(location.file_id, location.trk, location.seg, target.start);
|
let in_route = matches!(location.index, SegmentIndex::Route(_));
|
||||||
|
let fits = match self.split_type {
|
||||||
|
SplitType::Files => true,
|
||||||
|
SplitType::Routes => in_route,
|
||||||
|
SplitType::Tracks | SplitType::Segments => !in_route,
|
||||||
|
};
|
||||||
|
if !fits {
|
||||||
|
return Err(CommandError::NothingToDo);
|
||||||
|
}
|
||||||
let mut file = (*state.files[&file_id]).clone();
|
let mut file = (*state.files[&file_id]).clone();
|
||||||
if index >= file.trk[trk].trkseg[seg].len() {
|
if index >= file.segment(location.index).len() {
|
||||||
return Err(CommandError::InvalidData(
|
return Err(CommandError::InvalidData(
|
||||||
"trackpoint out of the segment".into(),
|
"trackpoint out of the segment".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let track = file.trk[trk].clone();
|
// with `SplitType::Files`, what goes to the second file: what comes after the trackpoint
|
||||||
let segment = &track.trkseg[seg];
|
let mut second_file = File {
|
||||||
let (first, second) = (keep_up_to(segment, index), keep_from(segment, index));
|
id: Default::default(),
|
||||||
// the track is cut: what is before the segment, and its first part, then the second part
|
rte: vec![],
|
||||||
// and what is after it
|
trk: vec![],
|
||||||
let split_track = || {
|
wpt: Waypoints::default(),
|
||||||
let mut first_track = track.clone();
|
..file.clone()
|
||||||
first_track.trkseg.truncate(seg);
|
|
||||||
first_track.trkseg.push(first.clone());
|
|
||||||
let mut second_track = Track {
|
|
||||||
id: Default::default(),
|
|
||||||
trkseg: vec![second.clone()],
|
|
||||||
..track.clone()
|
|
||||||
};
|
|
||||||
second_track
|
|
||||||
.trkseg
|
|
||||||
.extend(track.trkseg[seg + 1..].iter().map(copy_segment));
|
|
||||||
(first_track, second_track)
|
|
||||||
};
|
};
|
||||||
|
match location.index {
|
||||||
match self.split_type {
|
SegmentIndex::Route(rte) => {
|
||||||
SplitType::Segments => {
|
let route = file.rte[rte].clone();
|
||||||
file.trk[trk].trkseg.splice(seg..=seg, [first, second]);
|
let first = Route {
|
||||||
}
|
seg: keep_up_to(&route.seg, index),
|
||||||
SplitType::Tracks => {
|
..route.clone()
|
||||||
let (first_track, second_track) = split_track();
|
};
|
||||||
file.trk.splice(trk..=trk, [first_track, second_track]);
|
let second = Route {
|
||||||
}
|
|
||||||
SplitType::Files if self.keep_originals => {
|
|
||||||
let (first_track, second_track) = split_track();
|
|
||||||
// the parts are new files: nothing is shared by id with the file, nor between them
|
|
||||||
let first_file = copy_file(&File {
|
|
||||||
trk: file.trk[..trk]
|
|
||||||
.iter()
|
|
||||||
.cloned()
|
|
||||||
.chain([first_track])
|
|
||||||
.collect(),
|
|
||||||
..file.clone()
|
|
||||||
});
|
|
||||||
let mut second_file = File {
|
|
||||||
id: Default::default(),
|
id: Default::default(),
|
||||||
trk: vec![second_track],
|
seg: keep_from(&route.seg, index),
|
||||||
wpt: Waypoints::default(),
|
..route.clone()
|
||||||
..file.clone()
|
|
||||||
};
|
};
|
||||||
second_file
|
if self.split_type == SplitType::Routes {
|
||||||
.trk
|
file.rte.splice(rte..=rte, [first, second]);
|
||||||
.extend(file.trk[trk + 1..].iter().map(copy_track));
|
state.files.insert(file_id, Rc::new(file));
|
||||||
second_file
|
return Ok(());
|
||||||
.wpt
|
}
|
||||||
.insert_at(0, file.wpt.iter().map(copy_waypoint).collect());
|
// the following routes, and the tracks, which come after the routes
|
||||||
|
second_file.rte = [second]
|
||||||
let ids = [first_file.id, second_file.id];
|
.into_iter()
|
||||||
let position = state.order.0.iter().position(|id| *id == file_id);
|
.chain(file.rte[rte + 1..].iter().map(copy_route))
|
||||||
let at = position.map_or(state.order.0.len(), |p| p + 1);
|
.collect();
|
||||||
state.order.0.splice(at..at, ids);
|
second_file.trk = file.trk.iter().map(copy_track).collect();
|
||||||
state.files.insert(first_file.id, Rc::new(first_file));
|
file.rte.truncate(rte);
|
||||||
state.files.insert(second_file.id, Rc::new(second_file));
|
file.rte.push(first);
|
||||||
*state.selection = Selection::File {
|
file.trk.clear();
|
||||||
file_ids: ids.into(),
|
|
||||||
};
|
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
SplitType::Files => {
|
SegmentIndex::Track { trk, seg } => {
|
||||||
let (first_track, second_track) = split_track();
|
let track = file.trk[trk].clone();
|
||||||
let mut second_file = File {
|
let segment = &track.trkseg[seg];
|
||||||
|
let (first, second) = (keep_up_to(segment, index), keep_from(segment, index));
|
||||||
|
if self.split_type == SplitType::Segments {
|
||||||
|
file.trk[trk].trkseg.splice(seg..=seg, [first, second]);
|
||||||
|
state.files.insert(file_id, Rc::new(file));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// the track is cut: what is before the segment, and its first part, then the
|
||||||
|
// second part and what is after it
|
||||||
|
let mut first_track = track.clone();
|
||||||
|
first_track.trkseg.truncate(seg);
|
||||||
|
first_track.trkseg.push(first);
|
||||||
|
let mut second_track = Track {
|
||||||
id: Default::default(),
|
id: Default::default(),
|
||||||
trk: vec![second_track],
|
trkseg: vec![second],
|
||||||
wpt: Waypoints::default(),
|
..track.clone()
|
||||||
..file.clone()
|
|
||||||
};
|
};
|
||||||
second_file
|
second_track
|
||||||
.trk
|
.trkseg
|
||||||
.extend(file.trk[trk + 1..].iter().map(copy_track));
|
.extend(track.trkseg[seg + 1..].iter().map(copy_segment));
|
||||||
second_file
|
if self.split_type == SplitType::Tracks {
|
||||||
.wpt
|
file.trk.splice(trk..=trk, [first_track, second_track]);
|
||||||
.insert_at(0, file.wpt.iter().map(copy_waypoint).collect());
|
state.files.insert(file_id, Rc::new(file));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
second_file.trk = [second_track]
|
||||||
|
.into_iter()
|
||||||
|
.chain(file.trk[trk + 1..].iter().map(copy_track))
|
||||||
|
.collect();
|
||||||
file.trk.truncate(trk);
|
file.trk.truncate(trk);
|
||||||
file.trk.push(first_track);
|
file.trk.push(first_track);
|
||||||
let position = state.order.0.iter().position(|id| *id == file_id);
|
|
||||||
state.order.0.insert(
|
|
||||||
position.map_or(state.order.0.len(), |p| p + 1),
|
|
||||||
second_file.id,
|
|
||||||
);
|
|
||||||
state.files.insert(second_file.id, Rc::new(second_file));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state.files.insert(file_id, Rc::new(file));
|
|
||||||
|
// the file is cut: both parts have the waypoints
|
||||||
|
second_file
|
||||||
|
.wpt
|
||||||
|
.insert_at(0, file.wpt.iter().map(copy_waypoint).collect());
|
||||||
|
let position = state.order.0.iter().position(|id| *id == file_id);
|
||||||
|
let at = position.map_or(state.order.0.len(), |p| p + 1);
|
||||||
|
if self.keep_originals {
|
||||||
|
// the parts are new files: nothing is shared by id with the file, nor between them
|
||||||
|
let first_file = copy_file(&file);
|
||||||
|
let ids = [first_file.id, second_file.id];
|
||||||
|
state.order.0.splice(at..at, ids);
|
||||||
|
state.files.insert(first_file.id, Rc::new(first_file));
|
||||||
|
state.files.insert(second_file.id, Rc::new(second_file));
|
||||||
|
*state.selection = Selection::File {
|
||||||
|
file_ids: ids.into(),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
state.order.0.insert(at, second_file.id);
|
||||||
|
state.files.insert(second_file.id, Rc::new(second_file));
|
||||||
|
state.files.insert(file_id, Rc::new(file));
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,10 +45,8 @@ impl Apply for Time {
|
|||||||
let locations = state
|
let locations = state
|
||||||
.selection
|
.selection
|
||||||
.segment_locations(state.files, &state.order.0);
|
.segment_locations(state.files, &state.order.0);
|
||||||
let segments: Vec<&TrackSegment> = locations
|
let segments: Vec<&TrackSegment> =
|
||||||
.iter()
|
locations.iter().map(|l| l.segment(state.files)).collect();
|
||||||
.map(|l| &state.files[&l.file_id].trk[l.trk].trkseg[l.seg])
|
|
||||||
.collect();
|
|
||||||
if segments.iter().all(|segment| segment.is_empty()) {
|
if segments.iter().all(|segment| segment.is_empty()) {
|
||||||
return Err(CommandError::NothingToDo);
|
return Err(CommandError::NothingToDo);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use crate::{FileId, FileWaypointsRevisionId, Files, TrackSegmentId, TrackSegmentRevisionId};
|
use crate::{FileId, FileWaypointsRevisionId, Files, TrackSegmentId, TrackSegmentRevisionId};
|
||||||
|
|
||||||
/// Coordinates buffers, as flat `[lng, lat, lng, lat, ...]` arrays so that they can be handed
|
/// Coordinates buffers, as flat `[lng, lat, lng, lat, ...]` arrays so that they can be handed
|
||||||
/// to the UI without per-point calls: one per track segment (its trackpoints) and one per file
|
/// to the UI without per-point calls: one per segment of a route or track (its trackpoints) and one per file
|
||||||
/// (its waypoints). A buffer is only rebuilt when the revision of its segment (or the waypoints
|
/// (its waypoints). A buffer is only rebuilt when the revision of its segment (or the waypoints
|
||||||
/// of its file) changes, so editing a segment does not touch the buffers of the others.
|
/// of its file) changes, so editing a segment does not touch the buffers of the others.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
@@ -30,7 +30,7 @@ impl CoordinatesCache {
|
|||||||
self.waypoints
|
self.waypoints
|
||||||
.insert(file.id, (file.wpt.rev_id, coordinates));
|
.insert(file.id, (file.wpt.rev_id, coordinates));
|
||||||
}
|
}
|
||||||
for seg in file.trk.iter().flat_map(|trk| &trk.trkseg) {
|
for seg in file.segments() {
|
||||||
segments.insert(seg.id);
|
segments.insert(seg.id);
|
||||||
if self
|
if self
|
||||||
.segments
|
.segments
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
File, FileId, FileWaypointsRevisionId, TrackId, TrackSegmentId, TrackSegmentRevisionId,
|
File, FileId, FileWaypointsRevisionId, RouteId, TrackId, TrackInfo, TrackSegment,
|
||||||
WaypointId,
|
TrackSegmentId, TrackSegmentRevisionId, WaypointId,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// What the UI needs to display a file: its name and the structure of its tracks and waypoints,
|
/// What the UI needs to display a file: its name and the structure of its routes, tracks and
|
||||||
|
/// waypoints,
|
||||||
/// with the ids that reference every element. Coordinates are not part of it, they are read
|
/// with the ids that reference every element. Coordinates are not part of it, they are read
|
||||||
/// from the buffers of the [`CoordinatesCache`](crate::CoordinatesCache) (the `i`-th waypoint
|
/// from the buffers of the [`CoordinatesCache`](crate::CoordinatesCache) (the `i`-th waypoint
|
||||||
/// of a file is the `i`-th coordinates pair of its waypoint buffer, same for the trackpoints of
|
/// of a file is the `i`-th coordinates pair of its waypoint buffer, same for the trackpoints of
|
||||||
@@ -13,21 +14,47 @@ pub struct FileStructure {
|
|||||||
pub id: FileId,
|
pub id: FileId,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub desc: Option<String>,
|
pub desc: Option<String>,
|
||||||
|
pub routes: Vec<RouteNode>,
|
||||||
pub tracks: Vec<TrackNode>,
|
pub tracks: Vec<TrackNode>,
|
||||||
pub waypoints: Vec<WaypointNode>,
|
pub waypoints: Vec<WaypointNode>,
|
||||||
/// Changes whenever the waypoints of the file (hence their buffer) change.
|
/// Changes whenever the waypoints of the file (hence their buffer) change.
|
||||||
pub wpt_rev_id: FileWaypointsRevisionId,
|
pub wpt_rev_id: FileWaypointsRevisionId,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The name, description and style of a route or a track (the style only when the file defines
|
||||||
|
/// it).
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct TrackNode {
|
pub struct PathInfo {
|
||||||
pub id: TrackId,
|
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub desc: Option<String>,
|
pub desc: Option<String>,
|
||||||
/// Style of the track, only when the file defines it.
|
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
pub opacity: Option<f64>,
|
pub opacity: Option<f64>,
|
||||||
pub width: Option<f64>,
|
pub width: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&TrackInfo> for PathInfo {
|
||||||
|
fn from(info: &TrackInfo) -> Self {
|
||||||
|
Self {
|
||||||
|
name: info.name.clone(),
|
||||||
|
desc: info.desc.clone(),
|
||||||
|
color: info.color.clone(),
|
||||||
|
opacity: info.opacity,
|
||||||
|
width: info.width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct RouteNode {
|
||||||
|
pub id: RouteId,
|
||||||
|
pub info: PathInfo,
|
||||||
|
pub segment: SegmentNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct TrackNode {
|
||||||
|
pub id: TrackId,
|
||||||
|
pub info: PathInfo,
|
||||||
pub segments: Vec<SegmentNode>,
|
pub segments: Vec<SegmentNode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,31 +73,38 @@ pub struct WaypointNode {
|
|||||||
pub sym: Option<String>,
|
pub sym: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<&TrackSegment> for SegmentNode {
|
||||||
|
fn from(seg: &TrackSegment) -> Self {
|
||||||
|
Self {
|
||||||
|
id: seg.id,
|
||||||
|
rev_id: seg.rev_id,
|
||||||
|
len: seg.len(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl FileStructure {
|
impl FileStructure {
|
||||||
pub fn new(file: &File) -> Self {
|
pub fn new(file: &File) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: file.id,
|
id: file.id,
|
||||||
name: file.info.name.clone(),
|
name: file.info.name.clone(),
|
||||||
desc: file.info.desc.clone(),
|
desc: file.info.desc.clone(),
|
||||||
|
routes: file
|
||||||
|
.rte
|
||||||
|
.iter()
|
||||||
|
.map(|rte| RouteNode {
|
||||||
|
id: rte.id,
|
||||||
|
info: (&rte.info).into(),
|
||||||
|
segment: (&rte.seg).into(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
tracks: file
|
tracks: file
|
||||||
.trk
|
.trk
|
||||||
.iter()
|
.iter()
|
||||||
.map(|trk| TrackNode {
|
.map(|trk| TrackNode {
|
||||||
id: trk.id,
|
id: trk.id,
|
||||||
name: trk.info.name.clone(),
|
info: (&trk.info).into(),
|
||||||
desc: trk.info.desc.clone(),
|
segments: trk.trkseg.iter().map(Into::into).collect(),
|
||||||
color: trk.info.color.clone(),
|
|
||||||
opacity: trk.info.opacity,
|
|
||||||
width: trk.info.width,
|
|
||||||
segments: trk
|
|
||||||
.trkseg
|
|
||||||
.iter()
|
|
||||||
.map(|seg| SegmentNode {
|
|
||||||
id: seg.id,
|
|
||||||
rev_id: seg.rev_id,
|
|
||||||
len: seg.len(),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
waypoints: file
|
waypoints: file
|
||||||
@@ -112,11 +146,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(node.tracks.len(), file.trk.len());
|
assert_eq!(node.tracks.len(), file.trk.len());
|
||||||
for (n, t) in node.tracks.iter().zip(&file.trk) {
|
for (n, t) in node.tracks.iter().zip(&file.trk) {
|
||||||
assert_eq!((n.id, &n.name, &n.desc), (t.id, &t.info.name, &t.info.desc));
|
assert_eq!(n.id, t.id);
|
||||||
assert_eq!(
|
assert_eq!(n.info, PathInfo::from(&t.info));
|
||||||
(&n.color, n.opacity, n.width),
|
|
||||||
(&t.info.color, t.info.opacity, t.info.width)
|
|
||||||
);
|
|
||||||
assert_eq!(n.segments.len(), t.trkseg.len());
|
assert_eq!(n.segments.len(), t.trkseg.len());
|
||||||
for (n, s) in n.segments.iter().zip(&t.trkseg) {
|
for (n, s) in n.segments.iter().zip(&t.trkseg) {
|
||||||
assert_eq!((n.id, n.rev_id, n.len), (s.id, s.rev_id, s.len()));
|
assert_eq!((n.id, n.rev_id, n.len), (s.id, s.rev_id, s.len()));
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ impl RoutingBuffer {
|
|||||||
.map(|files| selection.segment_locations(files, order))
|
.map(|files| selection.segment_locations(files, order))
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
{
|
{
|
||||||
let segment = &files.unwrap()[&location.file_id].trk[location.trk].trkseg[location.seg];
|
let segment = location.segment(files.unwrap());
|
||||||
signature.push((segment.id, segment.rev_id));
|
signature.push((segment.id, segment.rev_id));
|
||||||
if segment.is_empty() {
|
if segment.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::{GlobalStatistics, Statistics, TrackSegment, sum_options};
|
use crate::{GlobalStatistics, SlopeSegment, Statistics, TrackSegment, sum_options};
|
||||||
|
|
||||||
/// Value of the timestamps of the trackpoints that have none.
|
/// Value of the timestamps of the trackpoints that have none.
|
||||||
pub const NO_TIME: i64 = i64::MIN;
|
pub const NO_TIME: i64 = i64::MIN;
|
||||||
@@ -27,8 +27,8 @@ pub struct StatisticsBuffer {
|
|||||||
pub elevation_gain: Vec<f64>,
|
pub elevation_gain: Vec<f64>,
|
||||||
pub elevation_loss: Vec<f64>,
|
pub elevation_loss: Vec<f64>,
|
||||||
pub slope: Vec<f64>,
|
pub slope: Vec<f64>,
|
||||||
pub slope_segment_slope: Vec<f64>,
|
/// The smoothed elevation segments that the trackpoints belong to, see [`SlopeSegments`].
|
||||||
pub slope_segment_distance: Vec<f64>,
|
pub slope_segments: SlopeSegments,
|
||||||
pub lng: Vec<f64>,
|
pub lng: Vec<f64>,
|
||||||
pub lat: Vec<f64>,
|
pub lat: Vec<f64>,
|
||||||
pub ele: Vec<f64>,
|
pub ele: Vec<f64>,
|
||||||
@@ -62,6 +62,18 @@ pub struct Intervals {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Intervals {
|
impl Intervals {
|
||||||
|
/// The value of the trackpoint at `index` (the one of the last interval past the trackpoints),
|
||||||
|
/// 0 (unknown) when there are no trackpoints.
|
||||||
|
pub fn value_at(&self, index: usize) -> u8 {
|
||||||
|
match self
|
||||||
|
.starts
|
||||||
|
.partition_point(|start| *start as usize <= index)
|
||||||
|
{
|
||||||
|
0 => 0,
|
||||||
|
next => self.values[next - 1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn clear(&mut self) {
|
fn clear(&mut self) {
|
||||||
self.starts.clear();
|
self.starts.clear();
|
||||||
self.values.clear();
|
self.values.clear();
|
||||||
@@ -76,6 +88,50 @@ impl Intervals {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The smoothed elevation segments of the selection, over which the slope is averaged, as the
|
||||||
|
/// intervals of trackpoints that belong to the same one: segment `i` starts at the trackpoint
|
||||||
|
/// `starts[i]` and ends where the next one starts, or at the last trackpoint. It has a `slope` (%)
|
||||||
|
/// and a length (`distance`, km). A segment never spans two segments of the selection. All are
|
||||||
|
/// empty when there are no trackpoints.
|
||||||
|
#[derive(Debug, Default, PartialEq)]
|
||||||
|
pub struct SlopeSegments {
|
||||||
|
pub starts: Vec<u32>,
|
||||||
|
pub slopes: Vec<f64>,
|
||||||
|
pub distances: Vec<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SlopeSegments {
|
||||||
|
/// The index of the segment of the trackpoint at `index` (the last one past the trackpoints),
|
||||||
|
/// `None` when there are no trackpoints.
|
||||||
|
pub fn at(&self, index: usize) -> Option<usize> {
|
||||||
|
let next = self
|
||||||
|
.starts
|
||||||
|
.partition_point(|start| *start as usize <= index);
|
||||||
|
next.checked_sub(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear(&mut self) {
|
||||||
|
self.starts.clear();
|
||||||
|
self.slopes.clear();
|
||||||
|
self.distances.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds the trackpoint at `index`, the indices being pushed in order: it starts a segment if
|
||||||
|
/// it is the first of a segment of the selection (`first`), or if its segment is not the one
|
||||||
|
/// of the previous trackpoint.
|
||||||
|
fn push(&mut self, index: usize, segment: &SlopeSegment, first: bool) {
|
||||||
|
// compared as bits, so that a NaN slope (no distance) is the same as the previous one
|
||||||
|
let same = |values: &[f64], value: f64| {
|
||||||
|
values.last().map(|last| last.to_bits()) == Some(value.to_bits())
|
||||||
|
};
|
||||||
|
if first || !same(&self.slopes, segment.slope) || !same(&self.distances, segment.distance) {
|
||||||
|
self.starts.push(index as u32);
|
||||||
|
self.slopes.push(segment.slope);
|
||||||
|
self.distances.push(segment.distance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 0 for an unknown value, else the code plus one. The categories hold at most 255 values, so it
|
/// 0 for an unknown value, else the code plus one. The categories hold at most 255 values, so it
|
||||||
/// fits.
|
/// fits.
|
||||||
fn unknown_or_next(code: Option<u8>) -> u8 {
|
fn unknown_or_next(code: Option<u8>) -> u8 {
|
||||||
@@ -117,8 +173,7 @@ impl StatisticsBuffer {
|
|||||||
self.elevation_gain.clear();
|
self.elevation_gain.clear();
|
||||||
self.elevation_loss.clear();
|
self.elevation_loss.clear();
|
||||||
self.slope.clear();
|
self.slope.clear();
|
||||||
self.slope_segment_slope.clear();
|
self.slope_segments.clear();
|
||||||
self.slope_segment_distance.clear();
|
|
||||||
self.lng.clear();
|
self.lng.clear();
|
||||||
self.lat.clear();
|
self.lat.clear();
|
||||||
self.ele.clear();
|
self.ele.clear();
|
||||||
@@ -133,6 +188,7 @@ impl StatisticsBuffer {
|
|||||||
self.mtb_scale.clear();
|
self.mtb_scale.clear();
|
||||||
|
|
||||||
for (segment, stats) in selected {
|
for (segment, stats) in selected {
|
||||||
|
let segment_start = self.total_distance.len();
|
||||||
for (trkpt, trkpt_stats) in segment.iter().zip(stats.local.iter()) {
|
for (trkpt, trkpt_stats) in segment.iter().zip(stats.local.iter()) {
|
||||||
let index = self.total_distance.len();
|
let index = self.total_distance.len();
|
||||||
let cumul_stats = &self.global;
|
let cumul_stats = &self.global;
|
||||||
@@ -162,10 +218,8 @@ impl StatisticsBuffer {
|
|||||||
self.elevation_loss
|
self.elevation_loss
|
||||||
.push(cumul_stats.elevation_loss + trkpt_stats.elevation_loss);
|
.push(cumul_stats.elevation_loss + trkpt_stats.elevation_loss);
|
||||||
self.slope.push(trkpt_stats.slope);
|
self.slope.push(trkpt_stats.slope);
|
||||||
self.slope_segment_slope
|
self.slope_segments
|
||||||
.push(trkpt_stats.slope_segment.slope);
|
.push(index, &trkpt_stats.slope_segment, index == segment_start);
|
||||||
self.slope_segment_distance
|
|
||||||
.push(trkpt_stats.slope_segment.distance);
|
|
||||||
self.lng.push(trkpt.coordinates.lng);
|
self.lng.push(trkpt.coordinates.lng);
|
||||||
self.lat.push(trkpt.coordinates.lat);
|
self.lat.push(trkpt.coordinates.lat);
|
||||||
self.ele.push(trkpt.ele);
|
self.ele.push(trkpt.ele);
|
||||||
@@ -229,25 +283,12 @@ mod tests {
|
|||||||
(segment, stats)
|
(segment, stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The value of the trackpoint at `index`, 0 before the first interval.
|
|
||||||
fn value_at(intervals: &Intervals, index: usize) -> u8 {
|
|
||||||
match intervals
|
|
||||||
.starts
|
|
||||||
.partition_point(|start| *start as usize <= index)
|
|
||||||
{
|
|
||||||
0 => 0,
|
|
||||||
next => intervals.values[next - 1],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn all_lengths(buffer: &StatisticsBuffer) -> Vec<usize> {
|
fn all_lengths(buffer: &StatisticsBuffer) -> Vec<usize> {
|
||||||
vec![
|
vec![
|
||||||
buffer.total_distance.len(),
|
buffer.total_distance.len(),
|
||||||
buffer.elevation_gain.len(),
|
buffer.elevation_gain.len(),
|
||||||
buffer.elevation_loss.len(),
|
buffer.elevation_loss.len(),
|
||||||
buffer.slope.len(),
|
buffer.slope.len(),
|
||||||
buffer.slope_segment_slope.len(),
|
|
||||||
buffer.slope_segment_distance.len(),
|
|
||||||
buffer.lng.len(),
|
buffer.lng.len(),
|
||||||
buffer.lat.len(),
|
buffer.lat.len(),
|
||||||
buffer.ele.len(),
|
buffer.ele.len(),
|
||||||
@@ -276,7 +317,19 @@ mod tests {
|
|||||||
for (i, local) in s.local.iter().enumerate() {
|
for (i, local) in s.local.iter().enumerate() {
|
||||||
assert_eq!(buffer.total_distance[i], local.total_distance);
|
assert_eq!(buffer.total_distance[i], local.total_distance);
|
||||||
assert_eq!(buffer.slope[i], local.slope);
|
assert_eq!(buffer.slope[i], local.slope);
|
||||||
|
let segment = buffer.slope_segments.at(i).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
buffer.slope_segments.slopes[segment].to_bits(),
|
||||||
|
local.slope_segment.slope.to_bits()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
buffer.slope_segments.distances[segment],
|
||||||
|
local.slope_segment.distance
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
// a segment per smoothed elevation segment, not per trackpoint
|
||||||
|
assert!(buffer.slope_segments.starts.len() < n);
|
||||||
|
assert_eq!(buffer.slope_segments.starts[0], 0);
|
||||||
// the trackpoints
|
// the trackpoints
|
||||||
for (i, trkpt) in segment.iter().enumerate() {
|
for (i, trkpt) in segment.iter().enumerate() {
|
||||||
assert_eq!(buffer.lng[i], trkpt.coordinates.lng);
|
assert_eq!(buffer.lng[i], trkpt.coordinates.lng);
|
||||||
@@ -454,7 +507,7 @@ mod tests {
|
|||||||
assert!(all_lengths(&buffer).iter().all(|len| *len == 5));
|
assert!(all_lengths(&buffer).iter().all(|len| *len == 5));
|
||||||
|
|
||||||
// and they can be looked up
|
// and they can be looked up
|
||||||
let values: Vec<u8> = (0..6).map(|i| value_at(&buffer.surface, i)).collect();
|
let values: Vec<u8> = (0..6).map(|i| buffer.surface.value_at(i)).collect();
|
||||||
assert_eq!(values, [1, 1, 0, 2, 2, 2]);
|
assert_eq!(values, [1, 1, 0, 2, 2, 2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,10 +547,10 @@ mod tests {
|
|||||||
buffer.update(&[(&segment, &s)]);
|
buffer.update(&[(&segment, &s)]);
|
||||||
let at = |i: usize| {
|
let at = |i: usize| {
|
||||||
(
|
(
|
||||||
value_at(&buffer.surface, i),
|
buffer.surface.value_at(i),
|
||||||
value_at(&buffer.highway, i),
|
buffer.highway.value_at(i),
|
||||||
value_at(&buffer.sac_scale, i),
|
buffer.sac_scale.value_at(i),
|
||||||
value_at(&buffer.mtb_scale, i),
|
buffer.mtb_scale.value_at(i),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
assert_eq!(at(0), (8, 255, 1, 4));
|
assert_eq!(at(0), (8, 255, 1, 4));
|
||||||
@@ -522,7 +575,7 @@ mod tests {
|
|||||||
assert_eq!(buffer.highway, Intervals::default());
|
assert_eq!(buffer.highway, Intervals::default());
|
||||||
assert_eq!(buffer.sac_scale, Intervals::default());
|
assert_eq!(buffer.sac_scale, Intervals::default());
|
||||||
assert_eq!(buffer.mtb_scale, Intervals::default());
|
assert_eq!(buffer.mtb_scale, Intervals::default());
|
||||||
assert_eq!(value_at(&buffer.surface, 0), 0);
|
assert_eq!(buffer.surface.value_at(0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -18,15 +18,13 @@ impl StatisticsCache {
|
|||||||
let mut stale: HashSet<TrackSegmentRevisionId> = self.map.keys().copied().collect();
|
let mut stale: HashSet<TrackSegmentRevisionId> = self.map.keys().copied().collect();
|
||||||
if let Some(state) = state {
|
if let Some(state) = state {
|
||||||
for file in state.values() {
|
for file in state.values() {
|
||||||
for trk in file.trk.iter() {
|
for trkseg in file.segments() {
|
||||||
for trkseg in trk.trkseg.iter() {
|
match self.map.entry(trkseg.rev_id) {
|
||||||
match self.map.entry(trkseg.rev_id) {
|
Entry::Occupied(_) => {
|
||||||
Entry::Occupied(_) => {
|
stale.remove(&trkseg.rev_id);
|
||||||
stale.remove(&trkseg.rev_id);
|
}
|
||||||
}
|
Entry::Vacant(e) => {
|
||||||
Entry::Vacant(e) => {
|
e.insert(Statistics::compute(trkseg));
|
||||||
e.insert(Statistics::compute(trkseg));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,6 +70,15 @@ impl StatisticsCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Selection::Route { file_id, rte_ids } => {
|
||||||
|
if let Some(file) = state.get(file_id) {
|
||||||
|
for rte in file.rte.iter() {
|
||||||
|
if rte_ids.contains(&rte.id) {
|
||||||
|
self.add_segment_stats(&rte.seg, &mut selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Selection::Track { file_id, trk_ids } => {
|
Selection::Track { file_id, trk_ids } => {
|
||||||
if let Some(file) = state.get(file_id) {
|
if let Some(file) = state.get(file_id) {
|
||||||
for trk in file.trk.iter() {
|
for trk in file.trk.iter() {
|
||||||
@@ -129,8 +136,8 @@ impl StatisticsCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn add_file_stats<'a>(&'a self, file: &'a File, selected: &mut Vec<SelectedSegment<'a>>) {
|
fn add_file_stats<'a>(&'a self, file: &'a File, selected: &mut Vec<SelectedSegment<'a>>) {
|
||||||
for trk in file.trk.iter() {
|
for trkseg in file.segments() {
|
||||||
self.add_track_stats(trk, selected);
|
self.add_segment_stats(trkseg, selected);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ impl<H: History> Engine<H> {
|
|||||||
.find(|wpt| wpt.id == *id)
|
.find(|wpt| wpt.id == *id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A trackpoint of a segment of a file. `None` if it does not exist.
|
/// A trackpoint of a segment (of a route or a track) of a file. `None` if it does not exist.
|
||||||
pub fn trackpoint(
|
pub fn trackpoint(
|
||||||
&self,
|
&self,
|
||||||
file_id: &FileId,
|
file_id: &FileId,
|
||||||
@@ -93,9 +93,7 @@ impl<H: History> Engine<H> {
|
|||||||
.history
|
.history
|
||||||
.current()?
|
.current()?
|
||||||
.get(file_id)?
|
.get(file_id)?
|
||||||
.trk
|
.segments()
|
||||||
.iter()
|
|
||||||
.flat_map(|trk| &trk.trkseg)
|
|
||||||
.find(|seg| seg.id == *segment_id)?;
|
.find(|seg| seg.id == *segment_id)?;
|
||||||
(index < segment.len()).then(|| &segment[index])
|
(index < segment.len()).then(|| &segment[index])
|
||||||
}
|
}
|
||||||
@@ -109,11 +107,7 @@ impl<H: History> Engine<H> {
|
|||||||
self.selection
|
self.selection
|
||||||
.segment_locations(files, &self.order.0)
|
.segment_locations(files, &self.order.0)
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|location| {
|
.flat_map(|location| reduction_distances(location.segment(files)))
|
||||||
reduction_distances(
|
|
||||||
&files[&location.file_id].trk[location.trk].trkseg[location.seg],
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,18 @@ use std::rc::Rc;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
File, FileId, Files, Selection, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint,
|
File, FileId, Files, Route, RouteId, Selection, Track, TrackId, TrackSegment, TrackSegmentId,
|
||||||
WaypointId,
|
Waypoint, WaypointId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// A route in the clipboard, with the name of the file it comes from (the name of the new file
|
||||||
|
/// when it is pasted on its own).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ClipboardRoute {
|
||||||
|
pub route: Route,
|
||||||
|
pub file_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// A track in the clipboard, with the name of the file it comes from (the name of the new file
|
/// A track in the clipboard, with the name of the file it comes from (the name of the new file
|
||||||
/// when it is pasted on its own).
|
/// when it is pasted on its own).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -31,6 +39,7 @@ pub struct ClipboardSegment {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum ClipboardContent {
|
pub enum ClipboardContent {
|
||||||
Files(Vec<Rc<File>>),
|
Files(Vec<Rc<File>>),
|
||||||
|
Routes(Vec<ClipboardRoute>),
|
||||||
Tracks(Vec<ClipboardTrack>),
|
Tracks(Vec<ClipboardTrack>),
|
||||||
Segments(Vec<ClipboardSegment>),
|
Segments(Vec<ClipboardSegment>),
|
||||||
Waypoints(Vec<Waypoint>),
|
Waypoints(Vec<Waypoint>),
|
||||||
@@ -84,6 +93,19 @@ impl Clipboard {
|
|||||||
.filter_map(|id| files.get(id).cloned())
|
.filter_map(|id| files.get(id).cloned())
|
||||||
.collect(),
|
.collect(),
|
||||||
),
|
),
|
||||||
|
Selection::Route { file_id, rte_ids } => {
|
||||||
|
let file = files.get(file_id)?;
|
||||||
|
ClipboardContent::Routes(
|
||||||
|
file.rte
|
||||||
|
.iter()
|
||||||
|
.filter(|rte| rte_ids.contains(&rte.id))
|
||||||
|
.map(|rte| ClipboardRoute {
|
||||||
|
route: rte.clone(),
|
||||||
|
file_name: file.info.name.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
Selection::Track { file_id, trk_ids } => {
|
Selection::Track { file_id, trk_ids } => {
|
||||||
let file = files.get(file_id)?;
|
let file = files.get(file_id)?;
|
||||||
ClipboardContent::Tracks(
|
ClipboardContent::Tracks(
|
||||||
@@ -144,32 +166,37 @@ impl Clipboard {
|
|||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
match &self.content {
|
match &self.content {
|
||||||
ClipboardContent::Files(files) => files.is_empty(),
|
ClipboardContent::Files(files) => files.is_empty(),
|
||||||
|
ClipboardContent::Routes(routes) => routes.is_empty(),
|
||||||
ClipboardContent::Tracks(tracks) => tracks.is_empty(),
|
ClipboardContent::Tracks(tracks) => tracks.is_empty(),
|
||||||
ClipboardContent::Segments(segments) => segments.is_empty(),
|
ClipboardContent::Segments(segments) => segments.is_empty(),
|
||||||
ClipboardContent::Waypoints(waypoints) => waypoints.is_empty(),
|
ClipboardContent::Waypoints(waypoints) => waypoints.is_empty(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the content can be pasted when `target` is selected: files and tracks can be
|
/// Whether the content can be pasted when `target` is selected: files, routes and tracks can
|
||||||
/// pasted anywhere above them in the file tree, and nothing selected stands for the list of
|
/// be pasted anywhere above them in the file tree (routes and tracks are at the same level),
|
||||||
/// files.
|
/// and nothing selected stands for the list of files.
|
||||||
///
|
///
|
||||||
/// | copied | pasted when selected |
|
/// | copied | pasted when selected |
|
||||||
/// |-----------|------------------------------------------|
|
/// |-----------|-----------------------------------------------|
|
||||||
/// | files | nothing, a file |
|
/// | files | nothing, a file |
|
||||||
/// | tracks | nothing, a file, a track |
|
/// | routes | nothing, a file, a route, a track |
|
||||||
/// | segments | nothing, a file, a track, a segment |
|
/// | tracks | nothing, a file, a route, a track |
|
||||||
/// | waypoints | a file, the waypoints, a waypoint |
|
/// | segments | nothing, a file, a route, a track, a segment |
|
||||||
|
/// | waypoints | a file, the waypoints, a waypoint |
|
||||||
pub fn can_paste(&self, target: &Selection) -> bool {
|
pub fn can_paste(&self, target: &Selection) -> bool {
|
||||||
use ClipboardContent as C;
|
use ClipboardContent as C;
|
||||||
use Selection::*;
|
use Selection::*;
|
||||||
matches!(
|
matches!(
|
||||||
(&self.content, target),
|
(&self.content, target),
|
||||||
(C::Files(_), Empty | File { .. })
|
(C::Files(_), Empty | File { .. })
|
||||||
| (C::Tracks(_), Empty | File { .. } | Track { .. })
|
| (
|
||||||
|
C::Routes(_) | C::Tracks(_),
|
||||||
|
Empty | File { .. } | Route { .. } | Track { .. }
|
||||||
|
)
|
||||||
| (
|
| (
|
||||||
C::Segments(_),
|
C::Segments(_),
|
||||||
Empty | File { .. } | Track { .. } | TrackSegment { .. }
|
Empty | File { .. } | Route { .. } | Track { .. } | TrackSegment { .. }
|
||||||
)
|
)
|
||||||
| (
|
| (
|
||||||
C::Waypoints(_),
|
C::Waypoints(_),
|
||||||
@@ -183,6 +210,7 @@ impl Clipboard {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum ClipboardIds {
|
pub enum ClipboardIds {
|
||||||
Files(Vec<FileId>),
|
Files(Vec<FileId>),
|
||||||
|
Routes(Vec<RouteId>),
|
||||||
Tracks(Vec<TrackId>),
|
Tracks(Vec<TrackId>),
|
||||||
Segments(Vec<TrackSegmentId>),
|
Segments(Vec<TrackSegmentId>),
|
||||||
Waypoints(Vec<WaypointId>),
|
Waypoints(Vec<WaypointId>),
|
||||||
@@ -194,6 +222,9 @@ impl ClipboardContent {
|
|||||||
ClipboardContent::Files(files) => {
|
ClipboardContent::Files(files) => {
|
||||||
ClipboardIds::Files(files.iter().map(|file| file.id).collect())
|
ClipboardIds::Files(files.iter().map(|file| file.id).collect())
|
||||||
}
|
}
|
||||||
|
ClipboardContent::Routes(routes) => {
|
||||||
|
ClipboardIds::Routes(routes.iter().map(|rte| rte.route.id).collect())
|
||||||
|
}
|
||||||
ClipboardContent::Tracks(tracks) => {
|
ClipboardContent::Tracks(tracks) => {
|
||||||
ClipboardIds::Tracks(tracks.iter().map(|trk| trk.track.id).collect())
|
ClipboardIds::Tracks(tracks.iter().map(|trk| trk.track.id).collect())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use crate::{FileId, Files, TrackId, TrackSegmentId, WaypointId};
|
use crate::{
|
||||||
|
File, FileId, Files, RouteId, SegmentIndex, TrackId, TrackSegment, TrackSegmentId, WaypointId,
|
||||||
|
};
|
||||||
|
|
||||||
/// How a new selection combines with the current one.
|
/// How a new selection combines with the current one.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
|
||||||
@@ -23,6 +25,10 @@ pub enum Selection {
|
|||||||
File {
|
File {
|
||||||
file_ids: HashSet<FileId>,
|
file_ids: HashSet<FileId>,
|
||||||
},
|
},
|
||||||
|
Route {
|
||||||
|
file_id: FileId,
|
||||||
|
rte_ids: HashSet<RouteId>,
|
||||||
|
},
|
||||||
Track {
|
Track {
|
||||||
file_id: FileId,
|
file_id: FileId,
|
||||||
trk_ids: HashSet<TrackId>,
|
trk_ids: HashSet<TrackId>,
|
||||||
@@ -41,12 +47,19 @@ pub enum Selection {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where a track segment is: its file, then its track and its own position in it.
|
/// Where a segment is: its file, then its place in it (its route, or its track and its own
|
||||||
|
/// position in it).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct SegmentLocation {
|
pub struct SegmentLocation {
|
||||||
pub file_id: FileId,
|
pub file_id: FileId,
|
||||||
pub trk: usize,
|
pub index: SegmentIndex,
|
||||||
pub seg: usize,
|
}
|
||||||
|
|
||||||
|
impl SegmentLocation {
|
||||||
|
/// The segment, in `files`. Panics if it is not there.
|
||||||
|
pub fn segment<'a>(&self, files: &'a Files) -> &'a TrackSegment {
|
||||||
|
files[&self.file_id].segment(self.index)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Selection {
|
impl Selection {
|
||||||
@@ -89,23 +102,18 @@ impl Selection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Selection {
|
impl Selection {
|
||||||
/// The track segments covered by the selection, in order: the segments of several selected
|
/// The segments covered by the selection, in order: the segments of several selected files
|
||||||
/// files follow `order` (the files missing from it come last), then the tracks and segments
|
/// follow `order` (the files missing from it come last), then the routes, tracks and segments
|
||||||
/// follow the file. Waypoints are not segments: selecting only waypoints covers none.
|
/// follow the file (see [`File::segments`]). Waypoints are not segments: selecting only
|
||||||
|
/// waypoints covers none.
|
||||||
pub fn segment_locations(&self, files: &Files, order: &[FileId]) -> Vec<SegmentLocation> {
|
pub fn segment_locations(&self, files: &Files, order: &[FileId]) -> Vec<SegmentLocation> {
|
||||||
let mut locations = vec![];
|
let mut locations = vec![];
|
||||||
let mut add = |file_id: FileId,
|
let mut add = |file_id: FileId, filter: &dyn Fn(&File, SegmentIndex) -> bool| {
|
||||||
trk_filter: &dyn Fn(&crate::Track) -> bool,
|
|
||||||
seg_filter: &dyn Fn(&crate::TrackSegment) -> bool| {
|
|
||||||
let Some(file) = files.get(&file_id) else {
|
let Some(file) = files.get(&file_id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
for (trk, track) in file.trk.iter().enumerate().filter(|(_, t)| trk_filter(t)) {
|
for index in file.segment_indices().filter(|index| filter(file, *index)) {
|
||||||
for (seg, segment) in track.trkseg.iter().enumerate() {
|
locations.push(SegmentLocation { file_id, index });
|
||||||
if seg_filter(segment) {
|
|
||||||
locations.push(SegmentLocation { file_id, trk, seg });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
match self {
|
match self {
|
||||||
@@ -116,25 +124,35 @@ impl Selection {
|
|||||||
.filter(|id| file_ids.contains(id))
|
.filter(|id| file_ids.contains(id))
|
||||||
.chain(file_ids.iter().filter(|id| !order.contains(id)));
|
.chain(file_ids.iter().filter(|id| !order.contains(id)));
|
||||||
for id in ordered {
|
for id in ordered {
|
||||||
add(*id, &|_| true, &|_| true);
|
add(*id, &|_, _| true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Selection::Track { file_id, trk_ids } => {
|
Selection::Route { file_id, rte_ids } => add(*file_id, &|file, index| match index {
|
||||||
add(*file_id, &|trk| trk_ids.contains(&trk.id), &|_| true)
|
SegmentIndex::Route(rte) => rte_ids.contains(&file.rte[rte].id),
|
||||||
}
|
SegmentIndex::Track { .. } => false,
|
||||||
|
}),
|
||||||
|
Selection::Track { file_id, trk_ids } => add(*file_id, &|file, index| match index {
|
||||||
|
SegmentIndex::Track { trk, .. } => trk_ids.contains(&file.trk[trk].id),
|
||||||
|
SegmentIndex::Route(_) => false,
|
||||||
|
}),
|
||||||
Selection::TrackSegment {
|
Selection::TrackSegment {
|
||||||
file_id,
|
file_id,
|
||||||
trk_id,
|
trk_id,
|
||||||
trkseg_ids,
|
trkseg_ids,
|
||||||
} => add(*file_id, &|trk| trk.id == *trk_id, &|seg| {
|
} => add(*file_id, &|file, index| match index {
|
||||||
trkseg_ids.contains(&seg.id)
|
SegmentIndex::Track { trk, seg } => {
|
||||||
|
let track = &file.trk[trk];
|
||||||
|
track.id == *trk_id && trkseg_ids.contains(&track.trkseg[seg].id)
|
||||||
|
}
|
||||||
|
SegmentIndex::Route(_) => false,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
locations
|
locations
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drops what does not exist anymore (e.g. after an undo): files, and the tracks, segments
|
/// Drops what does not exist anymore (e.g. after an undo): files, and the routes, tracks,
|
||||||
/// and waypoints of the files that remain. A selection left with nothing becomes empty.
|
/// segments and waypoints of the files that remain. A selection left with nothing becomes
|
||||||
|
/// empty.
|
||||||
pub fn retain_existing(&mut self, files: &Files) {
|
pub fn retain_existing(&mut self, files: &Files) {
|
||||||
let empty = match self {
|
let empty = match self {
|
||||||
Selection::Empty => false,
|
Selection::Empty => false,
|
||||||
@@ -142,6 +160,13 @@ impl Selection {
|
|||||||
file_ids.retain(|id| files.contains_key(id));
|
file_ids.retain(|id| files.contains_key(id));
|
||||||
file_ids.is_empty()
|
file_ids.is_empty()
|
||||||
}
|
}
|
||||||
|
Selection::Route { file_id, rte_ids } => match files.get(file_id) {
|
||||||
|
Some(file) => {
|
||||||
|
rte_ids.retain(|id| file.rte.iter().any(|rte| rte.id == *id));
|
||||||
|
rte_ids.is_empty()
|
||||||
|
}
|
||||||
|
None => true,
|
||||||
|
},
|
||||||
Selection::Track { file_id, trk_ids } => match files.get(file_id) {
|
Selection::Track { file_id, trk_ids } => match files.get(file_id) {
|
||||||
Some(file) => {
|
Some(file) => {
|
||||||
trk_ids.retain(|id| file.trk.iter().any(|trk| trk.id == *id));
|
trk_ids.retain(|id| file.trk.iter().any(|trk| trk.id == *id));
|
||||||
@@ -185,6 +210,13 @@ impl Selection {
|
|||||||
(Selection::File { file_ids }, Selection::File { file_ids: other }) => {
|
(Selection::File { file_ids }, Selection::File { file_ids: other }) => {
|
||||||
file_ids.extend(other)
|
file_ids.extend(other)
|
||||||
}
|
}
|
||||||
|
(
|
||||||
|
Selection::Route { file_id, rte_ids },
|
||||||
|
Selection::Route {
|
||||||
|
file_id: other_file,
|
||||||
|
rte_ids: other,
|
||||||
|
},
|
||||||
|
) if *file_id == other_file => rte_ids.extend(other),
|
||||||
(
|
(
|
||||||
Selection::Track { file_id, trk_ids },
|
Selection::Track { file_id, trk_ids },
|
||||||
Selection::Track {
|
Selection::Track {
|
||||||
@@ -232,6 +264,16 @@ impl Selection {
|
|||||||
toggle_ids(file_ids, other);
|
toggle_ids(file_ids, other);
|
||||||
file_ids.is_empty()
|
file_ids.is_empty()
|
||||||
}
|
}
|
||||||
|
(
|
||||||
|
Selection::Route { file_id, rte_ids },
|
||||||
|
Selection::Route {
|
||||||
|
file_id: other_file,
|
||||||
|
rte_ids: other,
|
||||||
|
},
|
||||||
|
) if *file_id == other_file => {
|
||||||
|
toggle_ids(rte_ids, other);
|
||||||
|
rte_ids.is_empty()
|
||||||
|
}
|
||||||
(
|
(
|
||||||
Selection::Track { file_id, trk_ids },
|
Selection::Track { file_id, trk_ids },
|
||||||
Selection::Track {
|
Selection::Track {
|
||||||
@@ -325,7 +367,8 @@ impl Selection {
|
|||||||
match self {
|
match self {
|
||||||
Selection::Empty => Selection::Empty,
|
Selection::Empty => Selection::Empty,
|
||||||
Selection::File { .. } => self.clone(),
|
Selection::File { .. } => self.clone(),
|
||||||
Selection::Track { file_id, .. }
|
Selection::Route { file_id, .. }
|
||||||
|
| Selection::Track { file_id, .. }
|
||||||
| Selection::TrackSegment { file_id, .. }
|
| Selection::TrackSegment { file_id, .. }
|
||||||
| Selection::Waypoints { file_id }
|
| Selection::Waypoints { file_id }
|
||||||
| Selection::Waypoint { file_id, .. } => Selection::File {
|
| Selection::Waypoint { file_id, .. } => Selection::File {
|
||||||
@@ -335,7 +378,7 @@ impl Selection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Selects all the elements of the same kind as the selected ones, in the same place: all
|
/// Selects all the elements of the same kind as the selected ones, in the same place: all
|
||||||
/// the files, all the tracks of the file, all the segments of the track, all the waypoints
|
/// the files, all the routes (or tracks) of the file, all the segments of the track, all the waypoints
|
||||||
/// of the file. All the files when nothing is selected. `None` when there is nothing to
|
/// of the file. All the files when nothing is selected. `None` when there is nothing to
|
||||||
/// select (the waypoints node, or a file that does not exist).
|
/// select (the waypoints node, or a file that does not exist).
|
||||||
pub fn all_at_level(&self, files: &Files, order: &[FileId]) -> Option<Selection> {
|
pub fn all_at_level(&self, files: &Files, order: &[FileId]) -> Option<Selection> {
|
||||||
@@ -343,6 +386,10 @@ impl Selection {
|
|||||||
Selection::Empty | Selection::File { .. } => Some(Selection::File {
|
Selection::Empty | Selection::File { .. } => Some(Selection::File {
|
||||||
file_ids: order.iter().copied().collect(),
|
file_ids: order.iter().copied().collect(),
|
||||||
}),
|
}),
|
||||||
|
Selection::Route { file_id, .. } => Some(Selection::Route {
|
||||||
|
file_id: *file_id,
|
||||||
|
rte_ids: files.get(file_id)?.rte.iter().map(|rte| rte.id).collect(),
|
||||||
|
}),
|
||||||
Selection::Track { file_id, .. } => Some(Selection::Track {
|
Selection::Track { file_id, .. } => Some(Selection::Track {
|
||||||
file_id: *file_id,
|
file_id: *file_id,
|
||||||
trk_ids: files.get(file_id)?.trk.iter().map(|trk| trk.id).collect(),
|
trk_ids: files.get(file_id)?.trk.iter().map(|trk| trk.id).collect(),
|
||||||
@@ -371,7 +418,7 @@ impl Selection {
|
|||||||
|
|
||||||
/// The element next to the selected ones, as a selection of that one element: the next one
|
/// The element next to the selected ones, as a selection of that one element: the next one
|
||||||
/// (`down`) or the previous one among the elements of the same kind, in the same place.
|
/// (`down`) or the previous one among the elements of the same kind, in the same place.
|
||||||
/// The tracks, segments and waypoints stop at their ends, the files wrap around and skip
|
/// The routes, tracks, segments and waypoints stop at their ends, the files wrap around and skip
|
||||||
/// the ones that are already selected. `None` when there is no such element.
|
/// the ones that are already selected. `None` when there is no such element.
|
||||||
pub fn neighbour(&self, files: &Files, order: &[FileId], down: bool) -> Option<Selection> {
|
pub fn neighbour(&self, files: &Files, order: &[FileId], down: bool) -> Option<Selection> {
|
||||||
match self {
|
match self {
|
||||||
@@ -393,6 +440,13 @@ impl Selection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Selection::Route { file_id, rte_ids } => {
|
||||||
|
let ids: Vec<RouteId> = files.get(file_id)?.rte.iter().map(|r| r.id).collect();
|
||||||
|
Some(Selection::Route {
|
||||||
|
file_id: *file_id,
|
||||||
|
rte_ids: [neighbour(&ids, rte_ids, down)?].into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
Selection::Track { file_id, trk_ids } => {
|
Selection::Track { file_id, trk_ids } => {
|
||||||
let ids: Vec<TrackId> = files.get(file_id)?.trk.iter().map(|t| t.id).collect();
|
let ids: Vec<TrackId> = files.get(file_id)?.trk.iter().map(|t| t.id).collect();
|
||||||
Some(Selection::Track {
|
Some(Selection::Track {
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ fn new(engine: &mut TestEngine, name: &str) -> bool {
|
|||||||
Command::New(New {
|
Command::New(New {
|
||||||
name,
|
name,
|
||||||
trackpoint: None,
|
trackpoint: None,
|
||||||
|
route: false,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1489,6 +1490,7 @@ mod per_file_history {
|
|||||||
.execute(Action::Edit(Command::New(New {
|
.execute(Action::Edit(Command::New(New {
|
||||||
name,
|
name,
|
||||||
trackpoint: None,
|
trackpoint: None,
|
||||||
|
route: false,
|
||||||
})))
|
})))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
*engine.order().last().unwrap()
|
*engine.order().last().unwrap()
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ use serde::{Deserialize, Serialize};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
File, FileId, FileInfo, Track, TrackId, TrackInfo, TrackSegment, TrackSegmentId,
|
File, FileId, FileInfo, Route, RouteId, Track, TrackId, TrackInfo, TrackSegment,
|
||||||
TrackpointCategories, TrackpointChunk, TrackpointChunkId, Waypoint, WaypointChunk,
|
TrackSegmentId, TrackpointCategories, TrackpointChunk, TrackpointChunkId, Waypoint,
|
||||||
WaypointChunkId, Waypoints,
|
WaypointChunk, WaypointChunkId, Waypoints,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The version that is written.
|
/// The version that is written.
|
||||||
@@ -54,10 +54,17 @@ fn parse<'a, T: Deserialize<'a>>(data: &'a [u8]) -> Result<T, DecodeError> {
|
|||||||
/// A file without its trackpoints and waypoints, which are in chunks.
|
/// A file without its trackpoints and waypoints, which are in chunks.
|
||||||
pub struct FileRecord {
|
pub struct FileRecord {
|
||||||
info: FileInfo,
|
info: FileInfo,
|
||||||
|
routes: Vec<RouteRecord>,
|
||||||
tracks: Vec<TrackRecord>,
|
tracks: Vec<TrackRecord>,
|
||||||
waypoints: Vec<WaypointChunkId>,
|
waypoints: Vec<WaypointChunkId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct RouteRecord {
|
||||||
|
id: RouteId,
|
||||||
|
info: TrackInfo,
|
||||||
|
segment: SegmentRecord,
|
||||||
|
}
|
||||||
|
|
||||||
struct TrackRecord {
|
struct TrackRecord {
|
||||||
id: TrackId,
|
id: TrackId,
|
||||||
info: TrackInfo,
|
info: TrackInfo,
|
||||||
@@ -69,24 +76,35 @@ struct SegmentRecord {
|
|||||||
chunks: Vec<TrackpointChunkId>,
|
chunks: Vec<TrackpointChunkId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<v1::SegmentRecord1> for SegmentRecord {
|
||||||
|
fn from(segment: v1::SegmentRecord1) -> Self {
|
||||||
|
Self {
|
||||||
|
id: TrackSegmentId(segment.id),
|
||||||
|
chunks: segment.chunks.into_iter().map(TrackpointChunkId).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<v1::FileRecord1> for FileRecord {
|
impl From<v1::FileRecord1> for FileRecord {
|
||||||
fn from(record: v1::FileRecord1) -> Self {
|
fn from(record: v1::FileRecord1) -> Self {
|
||||||
Self {
|
Self {
|
||||||
info: record.info.into(),
|
info: record.info.into(),
|
||||||
|
routes: record
|
||||||
|
.routes
|
||||||
|
.into_iter()
|
||||||
|
.map(|route| RouteRecord {
|
||||||
|
id: RouteId(route.id),
|
||||||
|
info: route.info.into(),
|
||||||
|
segment: route.segment.into(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
tracks: record
|
tracks: record
|
||||||
.tracks
|
.tracks
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|track| TrackRecord {
|
.map(|track| TrackRecord {
|
||||||
id: TrackId(track.id),
|
id: TrackId(track.id),
|
||||||
info: track.info.into(),
|
info: track.info.into(),
|
||||||
segments: track
|
segments: track.segments.into_iter().map(Into::into).collect(),
|
||||||
.segments
|
|
||||||
.into_iter()
|
|
||||||
.map(|segment| SegmentRecord {
|
|
||||||
id: TrackSegmentId(segment.id),
|
|
||||||
chunks: segment.chunks.into_iter().map(TrackpointChunkId).collect(),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
waypoints: record.waypoints.into_iter().map(WaypointChunkId).collect(),
|
waypoints: record.waypoints.into_iter().map(WaypointChunkId).collect(),
|
||||||
@@ -108,16 +126,27 @@ impl FileRecord {
|
|||||||
info: self.info,
|
info: self.info,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
for track in self.tracks {
|
let segment = |record: SegmentRecord| {
|
||||||
let mut trkseg = Vec::with_capacity(track.segments.len());
|
let mut built = TrackSegment::default();
|
||||||
for segment in track.segments {
|
built.id = record.id;
|
||||||
let mut built = TrackSegment::default();
|
for chunk in &record.chunks {
|
||||||
built.id = segment.id;
|
built.push_shared(trackpoint_chunks(chunk)?);
|
||||||
for chunk in &segment.chunks {
|
|
||||||
built.push_shared(trackpoint_chunks(chunk)?);
|
|
||||||
}
|
|
||||||
trkseg.push(built);
|
|
||||||
}
|
}
|
||||||
|
Some(built)
|
||||||
|
};
|
||||||
|
for route in self.routes {
|
||||||
|
file.rte.push(Route {
|
||||||
|
id: route.id,
|
||||||
|
info: route.info,
|
||||||
|
seg: segment(route.segment)?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for track in self.tracks {
|
||||||
|
let trkseg = track
|
||||||
|
.segments
|
||||||
|
.into_iter()
|
||||||
|
.map(segment)
|
||||||
|
.collect::<Option<Vec<_>>>()?;
|
||||||
file.trk.push(Track {
|
file.trk.push(Track {
|
||||||
id: track.id,
|
id: track.id,
|
||||||
info: track.info,
|
info: track.info,
|
||||||
@@ -292,6 +321,20 @@ mod tests {
|
|||||||
});
|
});
|
||||||
let mut empty = TrackSegment::default();
|
let mut empty = TrackSegment::default();
|
||||||
empty.id = TrackSegmentId(id(6));
|
empty.id = TrackSegmentId(id(6));
|
||||||
|
// a route, whose segment shares the chunk of the track
|
||||||
|
let mut route_segment = TrackSegment::default();
|
||||||
|
route_segment.id = TrackSegmentId(id(10));
|
||||||
|
route_segment.push_shared(segment.chunks()[0].clone());
|
||||||
|
file.rte.push(Route {
|
||||||
|
id: RouteId(id(9)),
|
||||||
|
info: TrackInfo {
|
||||||
|
name: Some("route".into()),
|
||||||
|
type_: Some("Hiking".into()),
|
||||||
|
color: Some("00ff00".into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
seg: route_segment,
|
||||||
|
});
|
||||||
file.trk.push(Track {
|
file.trk.push(Track {
|
||||||
id: TrackId(id(2)),
|
id: TrackId(id(2)),
|
||||||
info: TrackInfo {
|
info: TrackInfo {
|
||||||
@@ -352,9 +395,7 @@ mod tests {
|
|||||||
HashMap<WaypointChunkId, Rc<WaypointChunk>>,
|
HashMap<WaypointChunkId, Rc<WaypointChunk>>,
|
||||||
) {
|
) {
|
||||||
let trackpoints = file
|
let trackpoints = file
|
||||||
.trk
|
.segments()
|
||||||
.iter()
|
|
||||||
.flat_map(|track| &track.trkseg)
|
|
||||||
.flat_map(|segment| segment.chunks().iter().map(|c| (c.id, c.clone())))
|
.flat_map(|segment| segment.chunks().iter().map(|c| (c.id, c.clone())))
|
||||||
.collect();
|
.collect();
|
||||||
let waypoints = file
|
let waypoints = file
|
||||||
@@ -373,19 +414,28 @@ mod tests {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn assert_same_segment(a: &TrackSegment, b: &TrackSegment) {
|
||||||
|
assert_eq!(a.id, b.id);
|
||||||
|
assert_eq!(a.len(), b.len());
|
||||||
|
for (a, b) in a.chunks().iter().zip(b.chunks()) {
|
||||||
|
assert!(Rc::ptr_eq(a, b), "the chunks are the ones that were given");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn assert_same_file(a: &File, b: &File) {
|
fn assert_same_file(a: &File, b: &File) {
|
||||||
assert_eq!(a.id, b.id);
|
assert_eq!(a.id, b.id);
|
||||||
assert_eq!(a.info, b.info);
|
assert_eq!(a.info, b.info);
|
||||||
|
assert_eq!(a.rte.len(), b.rte.len());
|
||||||
|
for (a, b) in a.rte.iter().zip(&b.rte) {
|
||||||
|
assert_eq!((a.id, &a.info), (b.id, &b.info));
|
||||||
|
assert_same_segment(&a.seg, &b.seg);
|
||||||
|
}
|
||||||
assert_eq!(a.trk.len(), b.trk.len());
|
assert_eq!(a.trk.len(), b.trk.len());
|
||||||
for (a, b) in a.trk.iter().zip(&b.trk) {
|
for (a, b) in a.trk.iter().zip(&b.trk) {
|
||||||
assert_eq!((a.id, &a.info), (b.id, &b.info));
|
assert_eq!((a.id, &a.info), (b.id, &b.info));
|
||||||
assert_eq!(a.trkseg.len(), b.trkseg.len());
|
assert_eq!(a.trkseg.len(), b.trkseg.len());
|
||||||
for (a, b) in a.trkseg.iter().zip(&b.trkseg) {
|
for (a, b) in a.trkseg.iter().zip(&b.trkseg) {
|
||||||
assert_eq!(a.id, b.id);
|
assert_same_segment(a, b);
|
||||||
assert_eq!(a.len(), b.len());
|
|
||||||
for (a, b) in a.chunks().iter().zip(b.chunks()) {
|
|
||||||
assert!(Rc::ptr_eq(a, b), "the chunks are the ones that were given");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert_eq!(a.wpt.len(), b.wpt.len());
|
assert_eq!(a.wpt.len(), b.wpt.len());
|
||||||
@@ -425,7 +475,7 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.build(file.id, &|_| None, &|_| None)
|
.build(file.id, &|_| None, &|_| None)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(built.trk.is_empty() && built.wpt.is_empty());
|
assert!(built.rte.is_empty() && built.trk.is_empty() && built.wpt.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The names of the categories of a trackpoint.
|
/// The names of the categories of a trackpoint.
|
||||||
@@ -611,13 +661,15 @@ mod tests {
|
|||||||
"70733a2f2f6578616d706c652e636f6d2f736f6d656f6e65021868747470733a2f2f6578616d706c652e636f",
|
"70733a2f2f6578616d706c652e636f6d2f736f6d656f6e65021868747470733a2f2f6578616d706c652e636f",
|
||||||
"6d2f66696c65012074657874206f662068747470733a2f2f6578616d706c652e636f6d2f66696c651a687474",
|
"6d2f66696c65012074657874206f662068747470733a2f2f6578616d706c652e636f6d2f66696c651a687474",
|
||||||
"70733a2f2f6578616d706c652e636f6d2f66696c652d32012274657874206f662068747470733a2f2f657861",
|
"70733a2f2f6578616d706c652e636f6d2f66696c652d32012274657874206f662068747470733a2f2f657861",
|
||||||
"6d706c652e636f6d2f66696c652d320180a0abfef96201100000000000000000000000000000000201057472",
|
"6d706c652e636f6d2f66696c652d320180a0abfef9620110000000000000000000000000000000090105726f",
|
||||||
"61636b0103636d740104646573630103737263021968747470733a2f2f6578616d706c652e636f6d2f747261",
|
"75746500000000010648696b696e6701063030666630300000100000000000000000000000000000000a0110",
|
||||||
"636b012174657874206f662068747470733a2f2f6578616d706c652e636f6d2f747261636b1b68747470733a",
|
"000000000000000000000000000000040110000000000000000000000000000000020105747261636b010363",
|
||||||
"2f2f6578616d706c652e636f6d2f747261636b2d32012374657874206f662068747470733a2f2f6578616d70",
|
"6d740104646573630103737263021968747470733a2f2f6578616d706c652e636f6d2f747261636b01217465",
|
||||||
"6c652e636f6d2f747261636b2d3201074379636c696e67010666663030303001000000000000e03f01000000",
|
"7874206f662068747470733a2f2f6578616d706c652e636f6d2f747261636b1b68747470733a2f2f6578616d",
|
||||||
"0000001840021000000000000000000000000000000003011000000000000000000000000000000004100000",
|
"706c652e636f6d2f747261636b2d32012374657874206f662068747470733a2f2f6578616d706c652e636f6d",
|
||||||
"000000000000000000000000000600011000000000000000000000000000000005",
|
"2f747261636b2d3201074379636c696e67010666663030303001000000000000e03f01000000000000184002",
|
||||||
|
"1000000000000000000000000000000003011000000000000000000000000000000004100000000000000000",
|
||||||
|
"000000000000000600011000000000000000000000000000000005",
|
||||||
);
|
);
|
||||||
const TRACKPOINTS_V1: &str = concat!(
|
const TRACKPOINTS_V1: &str = concat!(
|
||||||
"01020667726176656c0761737068616c74010470617468010668696b696e6701046d74623003000000000000",
|
"01020667726176656c0761737068616c74010470617468010668696b696e6701046d74623003000000000000",
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Author, Categories, File, FileInfo, Link, LngLat, TrackInfo, Trackpoint, TrackpointCategories,
|
Author, Categories, File, FileInfo, Link, LngLat, TrackInfo, TrackSegment, Trackpoint,
|
||||||
Waypoint, WaypointId,
|
TrackpointCategories, Waypoint, WaypointId,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
@@ -133,10 +133,18 @@ impl From<TrackInfo1> for TrackInfo {
|
|||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct FileRecord1 {
|
pub struct FileRecord1 {
|
||||||
pub info: FileInfo1,
|
pub info: FileInfo1,
|
||||||
|
pub routes: Vec<RouteRecord1>,
|
||||||
pub tracks: Vec<TrackRecord1>,
|
pub tracks: Vec<TrackRecord1>,
|
||||||
pub waypoints: Vec<Uuid>,
|
pub waypoints: Vec<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct RouteRecord1 {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub info: TrackInfo1,
|
||||||
|
pub segment: SegmentRecord1,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct TrackRecord1 {
|
pub struct TrackRecord1 {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -150,24 +158,35 @@ pub struct SegmentRecord1 {
|
|||||||
pub chunks: Vec<Uuid>,
|
pub chunks: Vec<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SegmentRecord1 {
|
||||||
|
fn new(segment: &TrackSegment) -> Self {
|
||||||
|
Self {
|
||||||
|
id: segment.id.0,
|
||||||
|
chunks: segment.chunks().iter().map(|chunk| chunk.id.0).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl FileRecord1 {
|
impl FileRecord1 {
|
||||||
pub fn new(file: &File) -> Self {
|
pub fn new(file: &File) -> Self {
|
||||||
Self {
|
Self {
|
||||||
info: (&file.info).into(),
|
info: (&file.info).into(),
|
||||||
|
routes: file
|
||||||
|
.rte
|
||||||
|
.iter()
|
||||||
|
.map(|route| RouteRecord1 {
|
||||||
|
id: route.id.0,
|
||||||
|
info: (&route.info).into(),
|
||||||
|
segment: SegmentRecord1::new(&route.seg),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
tracks: file
|
tracks: file
|
||||||
.trk
|
.trk
|
||||||
.iter()
|
.iter()
|
||||||
.map(|track| TrackRecord1 {
|
.map(|track| TrackRecord1 {
|
||||||
id: track.id.0,
|
id: track.id.0,
|
||||||
info: (&track.info).into(),
|
info: (&track.info).into(),
|
||||||
segments: track
|
segments: track.trkseg.iter().map(SegmentRecord1::new).collect(),
|
||||||
.trkseg
|
|
||||||
.iter()
|
|
||||||
.map(|segment| SegmentRecord1 {
|
|
||||||
id: segment.id.0,
|
|
||||||
chunks: segment.chunks().iter().map(|chunk| chunk.id.0).collect(),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
waypoints: file.wpt.chunks().iter().map(|chunk| chunk.id.0).collect(),
|
waypoints: file.wpt.chunks().iter().map(|chunk| chunk.id.0).collect(),
|
||||||
|
|||||||
@@ -217,8 +217,7 @@ impl Persister {
|
|||||||
.files
|
.files
|
||||||
.get(id)
|
.get(id)
|
||||||
.is_none_or(|saved| !Rc::ptr_eq(saved, file));
|
.is_none_or(|saved| !Rc::ptr_eq(saved, file));
|
||||||
let segments = file.trk.iter().flat_map(|track| &track.trkseg);
|
for chunk in file.segments().flat_map(|segment| segment.chunks()) {
|
||||||
for chunk in segments.flat_map(|segment| segment.chunks()) {
|
|
||||||
let key = ChunkKey::trackpoints(chunk.id.0);
|
let key = ChunkKey::trackpoints(chunk.id.0);
|
||||||
if chunks.insert(key) && changed && !self.chunks.contains(&key) {
|
if chunks.insert(key) && changed && !self.chunks.contains(&key) {
|
||||||
batch
|
batch
|
||||||
|
|||||||
@@ -34,7 +34,3 @@ pub fn optional(array: Option<Vec<u8>>) -> Bytes {
|
|||||||
pub fn f64_bytes(values: &[f64]) -> Vec<u8> {
|
pub fn f64_bytes(values: &[f64]) -> Vec<u8> {
|
||||||
values.iter().flat_map(|v| v.to_le_bytes()).collect()
|
values.iter().flat_map(|v| v.to_le_bytes()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn i64_bytes(values: &[i64]) -> Vec<u8> {
|
|
||||||
values.iter().flat_map(|v| v.to_le_bytes()).collect()
|
|
||||||
}
|
|
||||||
@@ -88,6 +88,16 @@ pub fn open_paths(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The files are open for good: the preview among them is not a preview anymore (the user did
|
||||||
|
/// something to it that the engine does not see, like hiding it).
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn keep_files(state: State<AppState>, file_ids: Vec<Uuid>) {
|
||||||
|
state.core(move |core| {
|
||||||
|
core.workspace.keep(&file_ids);
|
||||||
|
core.commit(None);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Writes the files among `ids` that are not the ones on the disk, asking where to put the ones
|
/// Writes the files among `ids` that are not the ones on the disk, asking where to put the ones
|
||||||
/// that have no place: the file dialog for a single one, a folder for several of them. Says whether
|
/// that have no place: the file dialog for a single one, a folder for several of them. Says whether
|
||||||
/// they were written, which is not the case if the user cancelled.
|
/// they were written, which is not the case if the user cancelled.
|
||||||
@@ -303,7 +313,6 @@ pub struct ExportArguments {
|
|||||||
atemp: bool,
|
atemp: bool,
|
||||||
power: bool,
|
power: bool,
|
||||||
osm: bool,
|
osm: bool,
|
||||||
as_route: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<ExportArguments> for ExportOptions {
|
impl From<ExportArguments> for ExportOptions {
|
||||||
@@ -315,7 +324,6 @@ impl From<ExportArguments> for ExportOptions {
|
|||||||
atemp: arguments.atemp,
|
atemp: arguments.atemp,
|
||||||
power: arguments.power,
|
power: arguments.power,
|
||||||
osm: arguments.osm,
|
osm: arguments.osm,
|
||||||
as_route: arguments.as_route,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ pub fn style(
|
|||||||
state.run(move |e| api::style(e, color.as_deref(), opacity, width))
|
state.run(move |e| api::style(e, color.as_deref(), opacity, width))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn convert(state: State<AppState>) -> api::Outcome {
|
||||||
|
state.run(api::convert)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn new_route(state: State<AppState>) -> api::Outcome {
|
||||||
|
state.run(api::new_route)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
pub fn new_track(state: State<AppState>) -> api::Outcome {
|
pub fn new_track(state: State<AppState>) -> api::Outcome {
|
||||||
state.run(api::new_track)
|
state.run(api::new_track)
|
||||||
|
|||||||
@@ -16,10 +16,16 @@ pub fn new_file(
|
|||||||
lng: Option<f64>,
|
lng: Option<f64>,
|
||||||
lat: Option<f64>,
|
lat: Option<f64>,
|
||||||
ele: Option<f64>,
|
ele: Option<f64>,
|
||||||
|
route: Option<bool>,
|
||||||
folder: Option<PathBuf>,
|
folder: Option<PathBuf>,
|
||||||
) -> api::Outcome {
|
) -> api::Outcome {
|
||||||
|
let route = route.unwrap_or_default();
|
||||||
// in `folder` if it is given, otherwise like the selected file, otherwise in the open folder
|
// in `folder` if it is given, otherwise like the selected file, otherwise in the open folder
|
||||||
state.core(move |core| core.creating(folder, true, |e| api::new_file(e, &name, lng, lat, ele)))
|
state.core(move |core| {
|
||||||
|
core.creating(folder, true, |e| {
|
||||||
|
api::new_file(e, &name, lng, lat, ele, route)
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The files are in the body, one after the other, the headers `lengths` and `names` (JSON,
|
/// The files are in the body, one after the other, the headers `lengths` and `names` (JSON,
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ pub fn export_file(
|
|||||||
atemp: bool,
|
atemp: bool,
|
||||||
power: bool,
|
power: bool,
|
||||||
osm: bool,
|
osm: bool,
|
||||||
as_route: bool,
|
|
||||||
) -> Bytes {
|
) -> Bytes {
|
||||||
let options = ExportOptions {
|
let options = ExportOptions {
|
||||||
time,
|
time,
|
||||||
@@ -88,7 +87,6 @@ pub fn export_file(
|
|||||||
atemp,
|
atemp,
|
||||||
power,
|
power,
|
||||||
osm,
|
osm,
|
||||||
as_route,
|
|
||||||
};
|
};
|
||||||
optional(state.read(move |e| api::export_file(e, file_id, options)))
|
optional(state.read(move |e| api::export_file(e, file_id, options)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,16 @@ pub fn arrow_select(state: State<AppState>, down: bool, add: bool) -> api::Outco
|
|||||||
state.run(move |e| api::arrow_select(e, down, add))
|
state.run(move |e| api::arrow_select(e, down, add))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn select_routes(
|
||||||
|
state: State<AppState>,
|
||||||
|
file_id: Uuid,
|
||||||
|
route_ids: Vec<Uuid>,
|
||||||
|
mode: SelectMode,
|
||||||
|
) -> api::Outcome {
|
||||||
|
state.run(move |e| api::select_routes(e, file_id, &route_ids, mode))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
pub fn select_tracks(
|
pub fn select_tracks(
|
||||||
state: State<AppState>,
|
state: State<AppState>,
|
||||||
|
|||||||
@@ -17,15 +17,7 @@ macro_rules! f64_getters {
|
|||||||
)*};
|
)*};
|
||||||
}
|
}
|
||||||
|
|
||||||
f64_getters!(
|
f64_getters!(total_distance, slope_segment_slopes, lng, lat, ele);
|
||||||
total_distance,
|
|
||||||
slope,
|
|
||||||
slope_segment_slope,
|
|
||||||
slope_segment_distance,
|
|
||||||
lng,
|
|
||||||
lat,
|
|
||||||
ele
|
|
||||||
);
|
|
||||||
|
|
||||||
macro_rules! optional_f64_getters {
|
macro_rules! optional_f64_getters {
|
||||||
($($name:ident),*) => {$(
|
($($name:ident),*) => {$(
|
||||||
@@ -38,10 +30,16 @@ macro_rules! optional_f64_getters {
|
|||||||
|
|
||||||
optional_f64_getters!(speed, hr, cad, atemp, power);
|
optional_f64_getters!(speed, hr, cad, atemp, power);
|
||||||
|
|
||||||
/// Timestamps in milliseconds since the epoch (`NO_TIME` when missing), as `i64`.
|
/// The index of the first trackpoint of each smoothed elevation segment of the selection.
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
pub fn timestamps(state: State<AppState>) -> Bytes {
|
pub fn slope_segment_starts(state: State<AppState>) -> Bytes {
|
||||||
optional(state.read(|e| api::timestamps(e).map(i64_bytes)))
|
u32s(&state.read(|e| api::slope_segment_starts(e).to_vec()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the buffers do not hold for each trackpoint of the selection, for the one at `index`.
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn selection_point(state: State<AppState>, index: usize) -> Option<api::SelectionPoint> {
|
||||||
|
state.read(move |e| api::selection_point(e, index))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
|
|||||||
+34
-4
@@ -48,6 +48,9 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
// on macOS, the app has a menu bar of its own (Windows and Linux have none by default)
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let builder = builder.menu(menu);
|
||||||
register_commands(builder)
|
register_commands(builder)
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
@@ -80,6 +83,30 @@ pub fn run() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn menu<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<tauri::menu::Menu<R>> {
|
||||||
|
use tauri::menu::{AboutMetadata, Menu, PredefinedMenuItem, Submenu};
|
||||||
|
// the name of the product, as macOS would otherwise name the items after the binary
|
||||||
|
let name = app.package_info().name.clone();
|
||||||
|
let about = AboutMetadata {
|
||||||
|
name: Some(name.clone()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
Menu::with_items(
|
||||||
|
app,
|
||||||
|
&[&Submenu::with_items(
|
||||||
|
app,
|
||||||
|
&name,
|
||||||
|
true,
|
||||||
|
&[
|
||||||
|
&PredefinedMenuItem::about(app, Some(&format!("About {name}")), Some(about))?,
|
||||||
|
&PredefinedMenuItem::separator(app)?,
|
||||||
|
&PredefinedMenuItem::quit(app, Some(&format!("Quit {name}")))?,
|
||||||
|
],
|
||||||
|
)?],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Gives the builder the commands that reach the engine (which it has to be given as `AppState`).
|
/// Gives the builder the commands that reach the engine (which it has to be given as `AppState`).
|
||||||
fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Builder<R> {
|
fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Builder<R> {
|
||||||
builder.invoke_handler(tauri::generate_handler![
|
builder.invoke_handler(tauri::generate_handler![
|
||||||
@@ -87,6 +114,7 @@ fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Bu
|
|||||||
selection::select,
|
selection::select,
|
||||||
selection::select_all,
|
selection::select_all,
|
||||||
selection::arrow_select,
|
selection::arrow_select,
|
||||||
|
selection::select_routes,
|
||||||
selection::select_tracks,
|
selection::select_tracks,
|
||||||
selection::select_segments,
|
selection::select_segments,
|
||||||
selection::select_waypoint_group,
|
selection::select_waypoint_group,
|
||||||
@@ -107,6 +135,8 @@ fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Bu
|
|||||||
// edits and tools
|
// edits and tools
|
||||||
edit::metadata,
|
edit::metadata,
|
||||||
edit::style,
|
edit::style,
|
||||||
|
edit::convert,
|
||||||
|
edit::new_route,
|
||||||
edit::new_track,
|
edit::new_track,
|
||||||
edit::new_track_segment,
|
edit::new_track_segment,
|
||||||
edit::new_waypoint,
|
edit::new_waypoint,
|
||||||
@@ -145,9 +175,8 @@ fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Bu
|
|||||||
queries::waypoint_coordinates,
|
queries::waypoint_coordinates,
|
||||||
// statistics
|
// statistics
|
||||||
statistics::total_distance,
|
statistics::total_distance,
|
||||||
statistics::slope,
|
statistics::slope_segment_starts,
|
||||||
statistics::slope_segment_slope,
|
statistics::slope_segment_slopes,
|
||||||
statistics::slope_segment_distance,
|
|
||||||
statistics::lng,
|
statistics::lng,
|
||||||
statistics::lat,
|
statistics::lat,
|
||||||
statistics::ele,
|
statistics::ele,
|
||||||
@@ -156,7 +185,7 @@ fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Bu
|
|||||||
statistics::cad,
|
statistics::cad,
|
||||||
statistics::atemp,
|
statistics::atemp,
|
||||||
statistics::power,
|
statistics::power,
|
||||||
statistics::timestamps,
|
statistics::selection_point,
|
||||||
statistics::selection_statistics,
|
statistics::selection_statistics,
|
||||||
statistics::slice_statistics,
|
statistics::slice_statistics,
|
||||||
statistics::surface_starts,
|
statistics::surface_starts,
|
||||||
@@ -186,6 +215,7 @@ fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Bu
|
|||||||
disk::workspace_state,
|
disk::workspace_state,
|
||||||
disk::open_files,
|
disk::open_files,
|
||||||
disk::open_paths,
|
disk::open_paths,
|
||||||
|
disk::keep_files,
|
||||||
disk::open_folder,
|
disk::open_folder,
|
||||||
disk::close_folder,
|
disk::close_folder,
|
||||||
disk::close_files,
|
disk::close_files,
|
||||||
|
|||||||
+4
-2
@@ -108,8 +108,10 @@ impl Core {
|
|||||||
self.workspace.keep(&[id]);
|
self.workspace.keep(&[id]);
|
||||||
}
|
}
|
||||||
let (renamed, errors) = self.workspace.follow_names(&mut self.engine, &names);
|
let (renamed, errors) = self.workspace.follow_names(&mut self.engine, &names);
|
||||||
if kept || renamed {
|
// and it goes when something else is selected
|
||||||
self.commit(None);
|
let dropped = self.workspace.drop_preview(&mut self.engine);
|
||||||
|
if kept || renamed || dropped.is_some() {
|
||||||
|
self.commit(dropped.as_ref());
|
||||||
} else {
|
} else {
|
||||||
self.persist();
|
self.persist();
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-10
@@ -142,6 +142,18 @@ fn reads_the_statistics_of_the_selection() {
|
|||||||
// optional arrays start with a byte telling whether they are there
|
// optional arrays start with a byte telling whether they are there
|
||||||
let hr = app.bytes("hr", json!({}));
|
let hr = app.bytes("hr", json!({}));
|
||||||
assert_eq!(hr[0], 0);
|
assert_eq!(hr[0], 0);
|
||||||
|
// the slope segments are intervals of trackpoints
|
||||||
|
let starts = app.bytes("slope_segment_starts", json!({}));
|
||||||
|
let slopes = f64s(&app.bytes("slope_segment_slopes", json!({})));
|
||||||
|
assert_eq!(starts.len() / 4, slopes.len());
|
||||||
|
assert!(!slopes.is_empty() && slopes.len() < distance.len());
|
||||||
|
let point = app.call("selection_point", json!({ "index": 1 }));
|
||||||
|
assert!(point["slopeSegmentDistance"].is_number());
|
||||||
|
assert!(point.get("time").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
app.call("selection_point", json!({ "index": distance.len() })),
|
||||||
|
Value::Null
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
app.call("slice_statistics", json!({ "start": 1, "end": 3 }))["totalDistance"].is_number()
|
app.call("slice_statistics", json!({ "start": 1, "end": 3 }))["totalDistance"].is_number()
|
||||||
);
|
);
|
||||||
@@ -245,7 +257,7 @@ fn waypoints_exports_and_routing_revisions() {
|
|||||||
|
|
||||||
let exported = app.bytes(
|
let exported = app.bytes(
|
||||||
"export_file",
|
"export_file",
|
||||||
json!({ "fileId": file, "time": true, "hr": true, "cad": true, "atemp": true, "power": true, "osm": true, "asRoute": false }),
|
json!({ "fileId": file, "time": true, "hr": true, "cad": true, "atemp": true, "power": true, "osm": true }),
|
||||||
);
|
);
|
||||||
assert_eq!(exported[0], 1);
|
assert_eq!(exported[0], 1);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -434,14 +446,11 @@ fn a_file_outside_the_open_folder_is_read_again_when_the_disk_changes() {
|
|||||||
outcome.added()[0].clone()
|
outcome.added()[0].clone()
|
||||||
});
|
});
|
||||||
let id = uuid::Uuid::parse_str(&id).unwrap();
|
let id = uuid::Uuid::parse_str(&id).unwrap();
|
||||||
let name = |state: &crate::state::AppState| {
|
// the name comes from the path, which does not change: the content does
|
||||||
state.core(move |core| {
|
let signature = |state: &crate::state::AppState| {
|
||||||
core.engine
|
state.core(move |core| crate::workspace::signature(&core.engine, id))
|
||||||
.file_structure(&gpx_engine::FileId(id))
|
|
||||||
.map(|structure| structure.name.clone())
|
|
||||||
})
|
|
||||||
};
|
};
|
||||||
let before = name(&state);
|
let before = signature(&state);
|
||||||
|
|
||||||
std::fs::copy("../gpx-rs/data/with_time.gpx", &path).unwrap();
|
std::fs::copy("../gpx-rs/data/with_time.gpx", &path).unwrap();
|
||||||
// the debounced watcher tells the frontend that the engine changed
|
// the debounced watcher tells the frontend that the engine changed
|
||||||
@@ -453,7 +462,7 @@ fn a_file_outside_the_open_folder_is_read_again_when_the_disk_changes() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert!(changed, "the file was not read again");
|
assert!(changed, "the file was not read again");
|
||||||
assert_ne!(name(&state), before);
|
assert_ne!(signature(&state), before);
|
||||||
std::fs::remove_dir_all(folder).unwrap();
|
std::fs::remove_dir_all(folder).unwrap();
|
||||||
std::fs::remove_dir_all(directory).unwrap();
|
std::fs::remove_dir_all(directory).unwrap();
|
||||||
}
|
}
|
||||||
@@ -474,7 +483,7 @@ fn files_made_from_a_file_of_the_open_folder_are_written_next_to_it_at_once() {
|
|||||||
assert_eq!(opened.added().len(), 1);
|
assert_eq!(opened.added().len(), 1);
|
||||||
let copy = core.creating(None, false, gpx_engine::api::duplicate);
|
let copy = core.creating(None, false, gpx_engine::api::duplicate);
|
||||||
let fresh = core.creating(None, true, |e| {
|
let fresh = core.creating(None, true, |e| {
|
||||||
gpx_engine::api::new_file(e, "fresh", None, None, None)
|
gpx_engine::api::new_file(e, "fresh", None, None, None, false)
|
||||||
});
|
});
|
||||||
// a copy of the file goes next to it, a new file with nothing selected in the selection
|
// a copy of the file goes next to it, a new file with nothing selected in the selection
|
||||||
// of the file that was just made
|
// of the file that was just made
|
||||||
@@ -725,3 +734,43 @@ fn a_file_open_as_a_preview_is_replaced_until_it_is_kept_or_edited() {
|
|||||||
);
|
);
|
||||||
std::fs::remove_dir_all(folder).unwrap();
|
std::fs::remove_dir_all(folder).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_preview_goes_when_something_else_is_selected_unless_it_was_kept() {
|
||||||
|
let app = App::new();
|
||||||
|
let folder = folder_with(&["a", "b", "c"]);
|
||||||
|
let open = |name: &str, preview: bool| {
|
||||||
|
app.call(
|
||||||
|
"open_paths",
|
||||||
|
json!({ "paths": [folder.join(format!("{name}.gpx"))], "preview": preview }),
|
||||||
|
)["added"][0]
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_string)
|
||||||
|
};
|
||||||
|
let order = |app: &App| app.call("file_order", json!({})).as_array().unwrap().len();
|
||||||
|
let a = open("a", false).unwrap();
|
||||||
|
|
||||||
|
// looked at, then something else is selected: it goes
|
||||||
|
let b = open("b", true).unwrap();
|
||||||
|
assert_eq!(order(&app), 2);
|
||||||
|
app.call("select", json!({ "fileIds": [a], "mode": "replace" }));
|
||||||
|
assert_eq!(order(&app), 1);
|
||||||
|
|
||||||
|
// nothing selected: it stays
|
||||||
|
open("b", true);
|
||||||
|
app.call("select", json!({ "fileIds": [], "mode": "replace" }));
|
||||||
|
assert_eq!(order(&app), 2);
|
||||||
|
|
||||||
|
// kept (hidden, its menu opened...): it stays when something else is selected
|
||||||
|
let c = open("c", true).unwrap();
|
||||||
|
assert_eq!(order(&app), 2, "c replaces b");
|
||||||
|
app.call("keep_files", json!({ "fileIds": [c] }));
|
||||||
|
assert_eq!(
|
||||||
|
app.call("workspace_state", json!({}))["files"][&c]["preview"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
app.call("select", json!({ "fileIds": [a], "mode": "replace" }));
|
||||||
|
assert_eq!(order(&app), 2);
|
||||||
|
let _ = b;
|
||||||
|
std::fs::remove_dir_all(folder).unwrap();
|
||||||
|
}
|
||||||
+18
-6
@@ -450,6 +450,17 @@ impl Workspace {
|
|||||||
closed.merge(opened)
|
closed.merge(opened)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Closes the preview when something else is selected (not nothing, nor something in it): it was
|
||||||
|
/// only looked at. Says what changed in the engine, `None` if it stays.
|
||||||
|
pub fn drop_preview(&mut self, engine: &mut Engine) -> Option<api::Outcome> {
|
||||||
|
let id = self.preview?;
|
||||||
|
let selected = engine.selection().file_ids();
|
||||||
|
if selected.is_empty() || selected.contains(&FileId(id)) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(self.close_files(engine, &[id], false))
|
||||||
|
}
|
||||||
|
|
||||||
/// The files are open for good: none of them is the preview anymore.
|
/// The files are open for good: none of them is the preview anymore.
|
||||||
pub fn keep(&mut self, ids: &[Uuid]) {
|
pub fn keep(&mut self, ids: &[Uuid]) {
|
||||||
if self.preview.is_some_and(|preview| ids.contains(&preview)) {
|
if self.preview.is_some_and(|preview| ids.contains(&preview)) {
|
||||||
@@ -1180,6 +1191,7 @@ mod tests {
|
|||||||
/// What the file holds: the name of its track (its own name is the one of the file).
|
/// What the file holds: the name of its track (its own name is the one of the file).
|
||||||
fn content(&self, id: Uuid) -> String {
|
fn content(&self, id: Uuid) -> String {
|
||||||
self.engine.file_structure(&FileId(id)).unwrap().tracks[0]
|
self.engine.file_structure(&FileId(id)).unwrap().tracks[0]
|
||||||
|
.info
|
||||||
.name
|
.name
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@@ -1235,7 +1247,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_file_that_is_not_on_the_disk_is_unsaved_until_it_is_given_a_place() {
|
fn a_file_that_is_not_on_the_disk_is_unsaved_until_it_is_given_a_place() {
|
||||||
let mut fx = Fixture::new();
|
let mut fx = Fixture::new();
|
||||||
api::new_file(&mut fx.engine, "fresh", None, None, None);
|
api::new_file(&mut fx.engine, "fresh", None, None, None, false);
|
||||||
let id = fx.ids()[0];
|
let id = fx.ids()[0];
|
||||||
assert!(fx.workspace.is_dirty(&fx.engine, id));
|
assert!(fx.workspace.is_dirty(&fx.engine, id));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1260,9 +1272,9 @@ mod tests {
|
|||||||
fn several_files_without_a_place_go_in_a_folder_with_names_that_are_free() {
|
fn several_files_without_a_place_go_in_a_folder_with_names_that_are_free() {
|
||||||
let mut fx = Fixture::new();
|
let mut fx = Fixture::new();
|
||||||
fx.write("fresh.gpx", "already there");
|
fx.write("fresh.gpx", "already there");
|
||||||
api::new_file(&mut fx.engine, "fresh", None, None, None);
|
api::new_file(&mut fx.engine, "fresh", None, None, None, false);
|
||||||
api::new_file(&mut fx.engine, "a/b: c", None, None, None);
|
api::new_file(&mut fx.engine, "a/b: c", None, None, None, false);
|
||||||
api::new_file(&mut fx.engine, "fresh", None, None, None);
|
api::new_file(&mut fx.engine, "fresh", None, None, None, false);
|
||||||
let ids = fx.ids();
|
let ids = fx.ids();
|
||||||
let folder = fx.folder.clone();
|
let folder = fx.folder.clone();
|
||||||
fx.workspace
|
fx.workspace
|
||||||
@@ -1685,7 +1697,7 @@ mod tests {
|
|||||||
fx.open_folder();
|
fx.open_folder();
|
||||||
let a = fx.ids()[0];
|
let a = fx.ids()[0];
|
||||||
fx.rename(a, "edited");
|
fx.rename(a, "edited");
|
||||||
api::new_file(&mut fx.engine, "fresh", None, None, None);
|
api::new_file(&mut fx.engine, "fresh", None, None, None, false);
|
||||||
let fresh = fx.id_named("fresh");
|
let fresh = fx.id_named("fresh");
|
||||||
fx.workspace.close_files(&mut fx.engine, &[a, fresh], true);
|
fx.workspace.close_files(&mut fx.engine, &[a, fresh], true);
|
||||||
assert!(fx.ids().is_empty());
|
assert!(fx.ids().is_empty());
|
||||||
@@ -1701,7 +1713,7 @@ mod tests {
|
|||||||
let mut fx = Fixture::new();
|
let mut fx = Fixture::new();
|
||||||
fx.write("a.gpx", "a");
|
fx.write("a.gpx", "a");
|
||||||
fx.open_folder();
|
fx.open_folder();
|
||||||
api::new_file(&mut fx.engine, "fresh", None, None, None);
|
api::new_file(&mut fx.engine, "fresh", None, None, None, false);
|
||||||
assert_eq!(fx.ids().len(), 2);
|
assert_eq!(fx.ids().len(), 2);
|
||||||
|
|
||||||
// the new file has nowhere to go: it has to be discarded
|
// the new file has nowhere to go: it has to be discarded
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm --prefix ../website run dev -- --port 4283",
|
"beforeDevCommand": "npm --prefix ../website run dev -- --port 4283",
|
||||||
"devUrl": "http://localhost:4283",
|
"devUrl": "http://localhost:4283",
|
||||||
"beforeBuildCommand": "BASE_PATH='' npm --prefix ../website run build",
|
"beforeBuildCommand": "npm --prefix ../website run build:desktop",
|
||||||
"frontendDist": "../website/build"
|
"frontendDist": "../website/build"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
|
|||||||
@@ -18,6 +18,17 @@ pub fn style(color: Option<String>, opacity: Option<f64>, width: Option<f64>) ->
|
|||||||
run(|e| api::style(e, color.as_deref(), opacity, width))
|
run(|e| api::style(e, color.as_deref(), opacity, width))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Converts the selected routes to tracks, or the selected tracks or segments to routes.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn convert() -> Outcome {
|
||||||
|
run(api::convert)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn new_route() -> Outcome {
|
||||||
|
run(api::new_route)
|
||||||
|
}
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn new_track() -> Outcome {
|
pub fn new_track() -> Outcome {
|
||||||
run(api::new_track)
|
run(api::new_track)
|
||||||
|
|||||||
@@ -10,11 +10,18 @@ use crate::ts::*;
|
|||||||
|
|
||||||
// File commands
|
// File commands
|
||||||
|
|
||||||
/// Creates a file and selects it. With `lng` and `lat`, it starts with a track and a segment that
|
/// Creates a file and selects it. With `route`, it starts with a route. With `lng` and `lat`, it
|
||||||
/// hold that trackpoint (at `ele`, 0 by default).
|
/// starts with a segment that holds that trackpoint (at `ele`, 0 by default): the one of its route
|
||||||
|
/// with `route`, of a track otherwise.
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn new_file(name: &str, lng: Option<f64>, lat: Option<f64>, ele: Option<f64>) -> Outcome {
|
pub fn new_file(
|
||||||
run(|e| api::new_file(e, name, lng, lat, ele))
|
name: &str,
|
||||||
|
lng: Option<f64>,
|
||||||
|
lat: Option<f64>,
|
||||||
|
ele: Option<f64>,
|
||||||
|
route: bool,
|
||||||
|
) -> Outcome {
|
||||||
|
run(|e| api::new_file(e, name, lng, lat, ele, route))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads files as a single command (one undo step). The files are in `data`, one after the
|
/// Loads files as a single command (one undo step). The files are in `data`, one after the
|
||||||
|
|||||||
@@ -45,10 +45,8 @@ pub fn selection_statistics() -> GlobalStatistics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A file as GPX (UTF-8 bytes), `undefined` if it does not exist. The options say which data of the trackpoints
|
/// A file as GPX (UTF-8 bytes), `undefined` if it does not exist. The options say which data of the trackpoints
|
||||||
/// to keep, and with `as_route` that the segments are written as routes (`rte`) instead of tracks:
|
/// to keep.
|
||||||
/// the anchors are the route points, and the trackpoints between them the points of their path.
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub fn export_file(
|
pub fn export_file(
|
||||||
file_id: &str,
|
file_id: &str,
|
||||||
time: bool,
|
time: bool,
|
||||||
@@ -57,7 +55,6 @@ pub fn export_file(
|
|||||||
atemp: bool,
|
atemp: bool,
|
||||||
power: bool,
|
power: bool,
|
||||||
osm: bool,
|
osm: bool,
|
||||||
as_route: bool,
|
|
||||||
) -> Option<Vec<u8>> {
|
) -> Option<Vec<u8>> {
|
||||||
let id = parse_uuid(file_id)?;
|
let id = parse_uuid(file_id)?;
|
||||||
let options = ExportOptions {
|
let options = ExportOptions {
|
||||||
@@ -67,7 +64,6 @@ pub fn export_file(
|
|||||||
atemp,
|
atemp,
|
||||||
power,
|
power,
|
||||||
osm,
|
osm,
|
||||||
as_route,
|
|
||||||
};
|
};
|
||||||
with_engine(|e| api::export_file(e, id, options)).flatten()
|
with_engine(|e| api::export_file(e, id, options)).flatten()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ macro_rules! stats_getter {
|
|||||||
stats_getter!(total_distance, Float64Array);
|
stats_getter!(total_distance, Float64Array);
|
||||||
stats_getter!(elevation_gain, Float64Array);
|
stats_getter!(elevation_gain, Float64Array);
|
||||||
stats_getter!(elevation_loss, Float64Array);
|
stats_getter!(elevation_loss, Float64Array);
|
||||||
stats_getter!(slope, Float64Array);
|
|
||||||
stats_getter!(slope_segment_slope, Float64Array);
|
|
||||||
stats_getter!(slope_segment_distance, Float64Array);
|
|
||||||
stats_getter!(lng, Float64Array);
|
stats_getter!(lng, Float64Array);
|
||||||
stats_getter!(lat, Float64Array);
|
stats_getter!(lat, Float64Array);
|
||||||
stats_getter!(ele, Float64Array);
|
stats_getter!(ele, Float64Array);
|
||||||
@@ -49,14 +46,6 @@ optional_stats_getter!(moving_distance, Float64Array);
|
|||||||
optional_stats_getter!(total_time, BigInt64Array);
|
optional_stats_getter!(total_time, BigInt64Array);
|
||||||
optional_stats_getter!(moving_time, BigInt64Array);
|
optional_stats_getter!(moving_time, BigInt64Array);
|
||||||
optional_stats_getter!(speed, Float64Array);
|
optional_stats_getter!(speed, Float64Array);
|
||||||
// Timestamps in milliseconds since the epoch (`NO_TIME`, the smallest i64, when missing).
|
|
||||||
optional_stats_getter!(timestamps, BigInt64Array);
|
|
||||||
|
|
||||||
/// Value of the timestamps of the trackpoints that have none.
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn no_time() -> i64 {
|
|
||||||
gpx_engine::NO_TIME
|
|
||||||
}
|
|
||||||
|
|
||||||
optional_stats_getter!(hr, Float64Array);
|
optional_stats_getter!(hr, Float64Array);
|
||||||
optional_stats_getter!(cad, Float64Array);
|
optional_stats_getter!(cad, Float64Array);
|
||||||
@@ -96,6 +85,19 @@ categories_getters!(highway_starts, highway_values, highways);
|
|||||||
categories_getters!(sac_scale_starts, sac_scale_values, sac_scales);
|
categories_getters!(sac_scale_starts, sac_scale_values, sac_scales);
|
||||||
categories_getters!(mtb_scale_starts, mtb_scale_values, mtb_scales);
|
categories_getters!(mtb_scale_starts, mtb_scale_values, mtb_scales);
|
||||||
|
|
||||||
|
// 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`.
|
||||||
|
stats_getter!(slope_segment_starts, Uint32Array);
|
||||||
|
stats_getter!(slope_segment_slopes, Float64Array);
|
||||||
|
|
||||||
|
/// What the buffers do not hold for each trackpoint of the selection, for the one at `index`
|
||||||
|
/// (numbered as in the buffers), `undefined` if there is none.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn selection_point(index: usize) -> SelectionPoint {
|
||||||
|
optional_js(with_engine(|e| api::selection_point(e, index)).flatten()).unchecked_into()
|
||||||
|
}
|
||||||
|
|
||||||
/// For each trackpoint of the selection (numbered as in the statistics), the highest 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.
|
/// (m) of `reduce` that keeps it: infinite for the ends of the segments.
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ pub fn arrow_select(down: bool, add: bool) -> Outcome {
|
|||||||
// with the selection when it holds elements of the same kind in the same place (same file, same
|
// 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.
|
// track for segments), otherwise they replace it.
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn select_routes(file_id: &str, route_ids_bytes: &[u8], mode: SelectMode) -> Outcome {
|
||||||
|
match parse_uuid(file_id).zip(uuid_list(route_ids_bytes)) {
|
||||||
|
Some((file_id, ids)) => run(|e| api::select_routes(e, file_id, &ids, mode.into())),
|
||||||
|
None => fail("the ids are not UUIDs"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn select_tracks(file_id: &str, track_ids_bytes: &[u8], mode: SelectMode) -> Outcome {
|
pub fn select_tracks(file_id: &str, track_ids_bytes: &[u8], mode: SelectMode) -> Outcome {
|
||||||
match parse_uuid(file_id).zip(uuid_list(track_ids_bytes)) {
|
match parse_uuid(file_id).zip(uuid_list(track_ids_bytes)) {
|
||||||
|
|||||||
+42
-25
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
#![allow(clippy::needless_borrows_for_generic_args)]
|
#![allow(clippy::needless_borrows_for_generic_args)]
|
||||||
|
|
||||||
use js_sys::{Array, BigInt64Array, Float64Array, Object, Reflect};
|
use js_sys::{Array, Float64Array, Object, Reflect};
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
use wasm_bindgen_test::*;
|
use wasm_bindgen_test::*;
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ use crate::types::*;
|
|||||||
const SIMPLE: &[u8] = include_bytes!("../../gpx-rs/data/simple.gpx");
|
const SIMPLE: &[u8] = include_bytes!("../../gpx-rs/data/simple.gpx");
|
||||||
const WITH_TIME: &[u8] = include_bytes!("../../gpx-rs/data/with_time.gpx");
|
const WITH_TIME: &[u8] = include_bytes!("../../gpx-rs/data/with_time.gpx");
|
||||||
const WITH_SURFACE: &[u8] = include_bytes!("../../gpx-rs/data/with_surface.gpx");
|
const WITH_SURFACE: &[u8] = include_bytes!("../../gpx-rs/data/with_surface.gpx");
|
||||||
|
const WITH_ROUTES: &[u8] = include_bytes!("../../gpx-rs/data/with_routes.gpx");
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
static LAST: std::cell::RefCell<JsValue> = const { std::cell::RefCell::new(JsValue::UNDEFINED) };
|
static LAST: std::cell::RefCell<JsValue> = const { std::cell::RefCell::new(JsValue::UNDEFINED) };
|
||||||
@@ -83,7 +84,7 @@ fn uuid_bytes(ids: &[String]) -> Vec<u8> {
|
|||||||
fn test_new_file_is_listed_and_selected() {
|
fn test_new_file_is_listed_and_selected() {
|
||||||
start();
|
start();
|
||||||
assert!(run(crate::command::files::new_file(
|
assert!(run(crate::command::files::new_file(
|
||||||
"route", None, None, None
|
"route", None, None, None, false
|
||||||
)));
|
)));
|
||||||
assert_eq!(last_error(), None);
|
assert_eq!(last_error(), None);
|
||||||
|
|
||||||
@@ -112,10 +113,7 @@ fn test_unknown_file_has_no_structure() {
|
|||||||
assert!(crate::queries::files::file_structure(&id).is_undefined());
|
assert!(crate::queries::files::file_structure(&id).is_undefined());
|
||||||
assert!(crate::queries::files::file_structure("not an id").is_undefined());
|
assert!(crate::queries::files::file_structure("not an id").is_undefined());
|
||||||
assert!(crate::queries::files::file_statistics(&id).is_undefined());
|
assert!(crate::queries::files::file_statistics(&id).is_undefined());
|
||||||
assert!(
|
assert!(crate::queries::files::export_file(&id, true, true, true, true, true, true).is_none());
|
||||||
crate::queries::files::export_file(&id, true, true, true, true, true, true, false)
|
|
||||||
.is_none()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
crate::queries::coordinates::segment_coordinates(&id).length(),
|
crate::queries::coordinates::segment_coordinates(&id).length(),
|
||||||
0
|
0
|
||||||
@@ -144,7 +142,10 @@ fn test_loaded_file_has_statistics_and_coordinates() {
|
|||||||
assert_eq!(crate::queries::statistics::lng().length(), n);
|
assert_eq!(crate::queries::statistics::lng().length(), n);
|
||||||
assert_eq!(crate::queries::statistics::ele().length(), n);
|
assert_eq!(crate::queries::statistics::ele().length(), n);
|
||||||
// no timestamps in this file
|
// no timestamps in this file
|
||||||
assert!(crate::queries::statistics::timestamps().is_none());
|
let point = crate::queries::statistics::selection_point(0);
|
||||||
|
assert!(get(&point, "slopeSegmentDistance").as_f64().is_some());
|
||||||
|
assert!(get(&point, "time").is_undefined());
|
||||||
|
assert!(crate::queries::statistics::selection_point(n as usize).is_undefined());
|
||||||
|
|
||||||
let structure = crate::queries::files::file_structure(&id);
|
let structure = crate::queries::files::file_structure(&id);
|
||||||
let segment = get(
|
let segment = get(
|
||||||
@@ -165,8 +166,8 @@ fn test_loaded_file_has_statistics_and_coordinates() {
|
|||||||
#[wasm_bindgen_test]
|
#[wasm_bindgen_test]
|
||||||
fn test_timestamps_and_categories() {
|
fn test_timestamps_and_categories() {
|
||||||
started_with(&[(WITH_TIME, "time")]);
|
started_with(&[(WITH_TIME, "time")]);
|
||||||
let times: BigInt64Array = crate::queries::statistics::timestamps().unwrap();
|
let point = crate::queries::statistics::selection_point(0);
|
||||||
assert!(times.length() > 0);
|
assert!(get(&point, "time").as_f64().unwrap() > 0.0);
|
||||||
assert!(crate::queries::statistics::total_time().is_some());
|
assert!(crate::queries::statistics::total_time().is_some());
|
||||||
|
|
||||||
started_with(&[(WITH_SURFACE, "surface")]);
|
started_with(&[(WITH_SURFACE, "surface")]);
|
||||||
@@ -179,6 +180,14 @@ fn test_timestamps_and_categories() {
|
|||||||
crate::queries::statistics::surface_starts().length(),
|
crate::queries::statistics::surface_starts().length(),
|
||||||
values.len() as u32
|
values.len() as u32
|
||||||
);
|
);
|
||||||
|
// the name of the surface of a trackpoint that has one
|
||||||
|
let starts = crate::queries::statistics::surface_starts().to_vec();
|
||||||
|
let known = values.iter().position(|v| *v > 0).unwrap();
|
||||||
|
let point = crate::queries::statistics::selection_point(starts[known] as usize);
|
||||||
|
assert_eq!(
|
||||||
|
get(&point, "surface").as_string(),
|
||||||
|
Some(surfaces[values[known] as usize - 1].clone())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[wasm_bindgen_test]
|
#[wasm_bindgen_test]
|
||||||
@@ -189,7 +198,9 @@ fn test_the_reason_of_a_failure_can_be_read() {
|
|||||||
assert!(!run(crate::command::tools::reverse()));
|
assert!(!run(crate::command::tools::reverse()));
|
||||||
assert_eq!(last_error().as_deref(), Some("nothing to do"));
|
assert_eq!(last_error().as_deref(), Some("nothing to do"));
|
||||||
// the next call starts afresh
|
// the next call starts afresh
|
||||||
assert!(run(crate::command::files::new_file("a", None, None, None)));
|
assert!(run(crate::command::files::new_file(
|
||||||
|
"a", None, None, None, false
|
||||||
|
)));
|
||||||
assert_eq!(last_error(), None);
|
assert_eq!(last_error(), None);
|
||||||
|
|
||||||
// invalid data
|
// invalid data
|
||||||
@@ -242,7 +253,9 @@ fn test_the_reason_of_a_failure_can_be_read() {
|
|||||||
assert!(last_error().is_some());
|
assert!(last_error().is_some());
|
||||||
|
|
||||||
// answers are not failures
|
// answers are not failures
|
||||||
assert!(run(crate::command::files::new_file("b", None, None, None)));
|
assert!(run(crate::command::files::new_file(
|
||||||
|
"b", None, None, None, false
|
||||||
|
)));
|
||||||
assert!(!crate::command::files::can_paste());
|
assert!(!crate::command::files::can_paste());
|
||||||
assert_eq!(last_error(), None);
|
assert_eq!(last_error(), None);
|
||||||
}
|
}
|
||||||
@@ -251,7 +264,9 @@ fn test_the_reason_of_a_failure_can_be_read() {
|
|||||||
fn test_undo_and_redo() {
|
fn test_undo_and_redo() {
|
||||||
started_with(&[]);
|
started_with(&[]);
|
||||||
assert!(!can_undo() && !can_redo());
|
assert!(!can_undo() && !can_redo());
|
||||||
assert!(run(crate::command::files::new_file("a", None, None, None)));
|
assert!(run(crate::command::files::new_file(
|
||||||
|
"a", None, None, None, false
|
||||||
|
)));
|
||||||
assert!(can_undo() && !can_redo());
|
assert!(can_undo() && !can_redo());
|
||||||
assert!(run(undo()));
|
assert!(run(undo()));
|
||||||
assert!(order().is_empty());
|
assert!(order().is_empty());
|
||||||
@@ -268,8 +283,7 @@ fn test_export_and_exportable_data() {
|
|||||||
let ids = order();
|
let ids = order();
|
||||||
|
|
||||||
let bytes =
|
let bytes =
|
||||||
crate::queries::files::export_file(&ids[0], true, true, true, true, true, true, false)
|
crate::queries::files::export_file(&ids[0], true, true, true, true, true, true).unwrap();
|
||||||
.unwrap();
|
|
||||||
assert!(String::from_utf8(bytes).unwrap().contains("<gpx"));
|
assert!(String::from_utf8(bytes).unwrap().contains("<gpx"));
|
||||||
|
|
||||||
// the time is the first bit
|
// the time is the first bit
|
||||||
@@ -289,18 +303,17 @@ fn test_export_and_exportable_data() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[wasm_bindgen_test]
|
#[wasm_bindgen_test]
|
||||||
fn test_export_as_route() {
|
fn test_export_tracks_and_routes() {
|
||||||
started_with(&[(SIMPLE, "plain")]);
|
started_with(&[(SIMPLE, "track"), (WITH_ROUTES, "routes")]);
|
||||||
let id = order().remove(0);
|
let text = |id: &str| {
|
||||||
let text = |as_route| {
|
|
||||||
let bytes =
|
let bytes =
|
||||||
crate::queries::files::export_file(&id, true, true, true, true, true, true, as_route)
|
crate::queries::files::export_file(id, true, true, true, true, true, true).unwrap();
|
||||||
.unwrap();
|
|
||||||
String::from_utf8(bytes).unwrap()
|
String::from_utf8(bytes).unwrap()
|
||||||
};
|
};
|
||||||
let tracks = text(false);
|
let ids = order();
|
||||||
|
let tracks = text(&ids[0]);
|
||||||
assert!(tracks.contains("<trk>") && !tracks.contains("<rte>"));
|
assert!(tracks.contains("<trk>") && !tracks.contains("<rte>"));
|
||||||
let routes = text(true);
|
let routes = text(&ids[1]);
|
||||||
assert!(routes.contains("<rte>") && !routes.contains("<trk>"));
|
assert!(routes.contains("<rte>") && !routes.contains("<trk>"));
|
||||||
assert!(routes.contains("<rtept"));
|
assert!(routes.contains("<rtept"));
|
||||||
}
|
}
|
||||||
@@ -379,7 +392,7 @@ fn test_move_elements_takes_js_objects() {
|
|||||||
fn test_waypoints() {
|
fn test_waypoints() {
|
||||||
started_with(&[]);
|
started_with(&[]);
|
||||||
assert!(run(crate::command::files::new_file(
|
assert!(run(crate::command::files::new_file(
|
||||||
"file", None, None, None
|
"file", None, None, None, false
|
||||||
)));
|
)));
|
||||||
let file = order().remove(0);
|
let file = order().remove(0);
|
||||||
assert!(run(crate::command::waypoints::new_waypoint(
|
assert!(run(crate::command::waypoints::new_waypoint(
|
||||||
@@ -483,7 +496,9 @@ fn test_editing_tools_change_the_statistics() {
|
|||||||
#[wasm_bindgen_test]
|
#[wasm_bindgen_test]
|
||||||
fn test_last_update_describes_the_last_call_only() {
|
fn test_last_update_describes_the_last_call_only() {
|
||||||
started_with(&[]);
|
started_with(&[]);
|
||||||
assert!(run(crate::command::files::new_file("a", None, None, None)));
|
assert!(run(crate::command::files::new_file(
|
||||||
|
"a", None, None, None, false
|
||||||
|
)));
|
||||||
let update = last_outcome();
|
let update = last_outcome();
|
||||||
assert_eq!(array(get(&update, "added")).length(), 1);
|
assert_eq!(array(get(&update, "added")).length(), 1);
|
||||||
assert_eq!(get(&update, "orderChanged").as_bool(), Some(true));
|
assert_eq!(get(&update, "orderChanged").as_bool(), Some(true));
|
||||||
@@ -495,7 +510,9 @@ fn test_last_update_describes_the_last_call_only() {
|
|||||||
assert_eq!(get(&update, "orderChanged").as_bool(), Some(false));
|
assert_eq!(get(&update, "orderChanged").as_bool(), Some(false));
|
||||||
assert_eq!(get(&update, "selectionChanged").as_bool(), Some(false));
|
assert_eq!(get(&update, "selectionChanged").as_bool(), Some(false));
|
||||||
|
|
||||||
assert!(run(crate::command::files::new_file("b", None, None, None)));
|
assert!(run(crate::command::files::new_file(
|
||||||
|
"b", None, None, None, false
|
||||||
|
)));
|
||||||
assert!(!run(crate::command::tools::crop(3, 1)));
|
assert!(!run(crate::command::tools::crop(3, 1)));
|
||||||
let update = last_outcome();
|
let update = last_outcome();
|
||||||
assert_eq!(array(get(&update, "added")).length(), 0);
|
assert_eq!(array(get(&update, "added")).length(), 0);
|
||||||
|
|||||||
+37
-5
@@ -28,16 +28,19 @@ const FILE_STRUCTURE_TS: &str = r#"
|
|||||||
export type Selection =
|
export type Selection =
|
||||||
| { type: 'empty' }
|
| { type: 'empty' }
|
||||||
| { type: 'file'; fileIds: string[] }
|
| { type: 'file'; fileIds: string[] }
|
||||||
|
| { type: 'route'; fileId: string; routeIds: string[] }
|
||||||
| { type: 'track'; fileId: string; trackIds: string[] }
|
| { type: 'track'; fileId: string; trackIds: string[] }
|
||||||
| { type: 'segment'; fileId: string; trackId: string; segmentIds: string[] }
|
| { type: 'segment'; fileId: string; trackId: string; segmentIds: string[] }
|
||||||
| { type: 'waypoints'; fileId: string }
|
| { type: 'waypoints'; fileId: string }
|
||||||
| { type: 'waypoint'; fileId: string; waypointIds: string[] };
|
| { type: 'waypoint'; fileId: string; waypointIds: string[] };
|
||||||
/**
|
/**
|
||||||
* Where moved elements go: a list of the file tree and the position in it, counted among the
|
* Where moved elements go: a list of the file tree and the position in it, counted among the
|
||||||
* elements of the list that are not moved.
|
* elements of the list that are not moved. Routes and tracks keep their kind: moved among the
|
||||||
|
* other kind, routes go after the routes, tracks before the tracks.
|
||||||
*/
|
*/
|
||||||
export type MoveTarget =
|
export type MoveTarget =
|
||||||
| { type: 'files'; index: number }
|
| { type: 'files'; index: number }
|
||||||
|
| { type: 'routes'; fileId: string; index: number }
|
||||||
| { type: 'tracks'; fileId: string; index: number }
|
| { type: 'tracks'; fileId: string; index: number }
|
||||||
| { type: 'segments'; fileId: string; trackId: string; index: number }
|
| { type: 'segments'; fileId: string; trackId: string; index: number }
|
||||||
| { type: 'waypoints'; fileId: string; index: number };
|
| { type: 'waypoints'; fileId: string; index: number };
|
||||||
@@ -64,9 +67,26 @@ export interface TrackpointDetails {
|
|||||||
/** ms since epoch */
|
/** ms since epoch */
|
||||||
time?: number;
|
time?: number;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* A trackpoint of the selection: what the statistics buffers do not hold for each trackpoint.
|
||||||
|
* Absent fields are unknown.
|
||||||
|
*/
|
||||||
|
export interface SelectionPoint {
|
||||||
|
/** Slope (%) at the trackpoint. */
|
||||||
|
slope?: number;
|
||||||
|
/** ms since epoch */
|
||||||
|
time?: number;
|
||||||
|
/** Slope (%) and length (km) of the smoothed elevation segment of the trackpoint. */
|
||||||
|
slopeSegmentSlope?: number;
|
||||||
|
slopeSegmentDistance?: number;
|
||||||
|
surface?: string;
|
||||||
|
highway?: string;
|
||||||
|
sacScale?: string;
|
||||||
|
mtbScale?: string;
|
||||||
|
}
|
||||||
/** What was copied or cut, to be pasted: the kind of the elements and their ids. */
|
/** What was copied or cut, to be pasted: the kind of the elements and their ids. */
|
||||||
export interface Clipboard {
|
export interface Clipboard {
|
||||||
type: 'files' | 'tracks' | 'segments' | 'waypoints';
|
type: 'files' | 'routes' | 'tracks' | 'segments' | 'waypoints';
|
||||||
ids: string[];
|
ids: string[];
|
||||||
cut: boolean;
|
cut: boolean;
|
||||||
}
|
}
|
||||||
@@ -102,6 +122,8 @@ export interface FileStructure {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
desc?: string;
|
desc?: string;
|
||||||
|
/** The routes come before the tracks, in the file and in the statistics. */
|
||||||
|
routes: RouteNode[];
|
||||||
tracks: TrackNode[];
|
tracks: TrackNode[];
|
||||||
waypoints: WaypointNode[];
|
waypoints: WaypointNode[];
|
||||||
/** Changes when the waypoints of the file change: refetch their coordinates. */
|
/** Changes when the waypoints of the file change: refetch their coordinates. */
|
||||||
@@ -129,14 +151,22 @@ export interface GlobalStatistics {
|
|||||||
/** Absent when there are no trackpoints. */
|
/** Absent when there are no trackpoints. */
|
||||||
bounds?: { west: number; south: number; east: number; north: number };
|
bounds?: { west: number; south: number; east: number; north: number };
|
||||||
}
|
}
|
||||||
export interface TrackNode {
|
/** The name, description and style of a route or a track. */
|
||||||
id: string;
|
export interface PathInfo {
|
||||||
name?: string;
|
name?: string;
|
||||||
desc?: string;
|
desc?: string;
|
||||||
/** Style of the track, only present when the file defines it. */
|
/** Style, only present when the file defines it. */
|
||||||
color?: string;
|
color?: string;
|
||||||
opacity?: number;
|
opacity?: number;
|
||||||
width?: number;
|
width?: number;
|
||||||
|
}
|
||||||
|
/** A route: like a track, with a single segment. */
|
||||||
|
export interface RouteNode extends PathInfo {
|
||||||
|
id: string;
|
||||||
|
segment: SegmentNode;
|
||||||
|
}
|
||||||
|
export interface TrackNode extends PathInfo {
|
||||||
|
id: string;
|
||||||
segments: SegmentNode[];
|
segments: SegmentNode[];
|
||||||
}
|
}
|
||||||
export interface SegmentNode {
|
export interface SegmentNode {
|
||||||
@@ -161,6 +191,8 @@ extern "C" {
|
|||||||
pub type WaypointDetails;
|
pub type WaypointDetails;
|
||||||
#[wasm_bindgen(typescript_type = "TrackpointDetails | undefined")]
|
#[wasm_bindgen(typescript_type = "TrackpointDetails | undefined")]
|
||||||
pub type TrackpointDetails;
|
pub type TrackpointDetails;
|
||||||
|
#[wasm_bindgen(typescript_type = "SelectionPoint | undefined")]
|
||||||
|
pub type SelectionPoint;
|
||||||
#[wasm_bindgen(typescript_type = "MoveTarget")]
|
#[wasm_bindgen(typescript_type = "MoveTarget")]
|
||||||
pub type MoveTarget;
|
pub type MoveTarget;
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ pub enum MergeType {
|
|||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
pub enum SplitType {
|
pub enum SplitType {
|
||||||
Files,
|
Files,
|
||||||
|
Routes,
|
||||||
Tracks,
|
Tracks,
|
||||||
Segments,
|
Segments,
|
||||||
}
|
}
|
||||||
@@ -23,6 +24,7 @@ impl From<SplitType> for engine::SplitType {
|
|||||||
fn from(t: SplitType) -> Self {
|
fn from(t: SplitType) -> Self {
|
||||||
match t {
|
match t {
|
||||||
SplitType::Files => Self::Files,
|
SplitType::Files => Self::Files,
|
||||||
|
SplitType::Routes => Self::Routes,
|
||||||
SplitType::Tracks => Self::Tracks,
|
SplitType::Tracks => Self::Tracks,
|
||||||
SplitType::Segments => Self::Segments,
|
SplitType::Segments => Self::Segments,
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+89
-67
@@ -18,7 +18,7 @@
|
|||||||
"gpx-wasm": "file:../wasm/pkg",
|
"gpx-wasm": "file:../wasm/pkg",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"mapillary-js": "^4.1.2",
|
"mapillary-js": "^4.1.2",
|
||||||
"maplibre-gl": "^5.21.1",
|
"maplibre-gl": "^6.15.0",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
"sortablejs": "^1.15.6"
|
"sortablejs": "^1.15.6"
|
||||||
},
|
},
|
||||||
@@ -1695,13 +1695,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mapbox/point-geometry": {
|
"node_modules/@mapbox/point-geometry": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/@mapbox/sphericalmercator": {
|
"node_modules/@mapbox/sphericalmercator": {
|
||||||
@@ -1712,24 +1717,32 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@mapbox/tiny-sdf": {
|
"node_modules/@mapbox/tiny-sdf": {
|
||||||
"version": "2.0.7",
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-gBvcNDBBB66bgQYvnaJ6Jk+VCaMmVq5+IZR2kWPisT88Z8s0kBQt6hfVi8QgRW8Vpg0UWZzBA4ZSyzHHC+VeRA==",
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/@mapbox/unitbezier": {
|
"node_modules/@mapbox/unitbezier": {
|
||||||
"version": "0.0.1",
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/@mapbox/vector-tile": {
|
"node_modules/@mapbox/vector-tile": {
|
||||||
"version": "2.0.4",
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mapbox/point-geometry": "~1.1.0",
|
"@mapbox/point-geometry": "~1.1.0",
|
||||||
"@types/geojson": "^7946.0.16",
|
"@types/geojson": "^7946.0.16",
|
||||||
"pbf": "^4.0.1"
|
"pbf": "^5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mapbox/vector-tile/node_modules/pbf": {
|
"node_modules/@mapbox/vector-tile/node_modules/pbf": {
|
||||||
"version": "4.0.1",
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"resolve-protobuf-schema": "^2.1.0"
|
"resolve-protobuf-schema": "^2.1.0"
|
||||||
@@ -1738,18 +1751,13 @@
|
|||||||
"pbf": "bin/pbf"
|
"pbf": "bin/pbf"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mapbox/whoots-js": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@maplibre/geojson-vt": {
|
"node_modules/@maplibre/geojson-vt": {
|
||||||
"version": "6.0.4",
|
"version": "6.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.3.0.tgz",
|
||||||
|
"integrity": "sha512-3Zw2jsU9pIJG3G2CQFbLjWj2MlAHodhD7cSY+15iywUuLmmkgzcKRnueVRZ4AcC/5wOtfhidcJAAzBpoBNs3Xg==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"kdbush": "^4.0.2"
|
"kdbush": "^4.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@maplibre/maplibre-gl-geocoder": {
|
"node_modules/@maplibre/maplibre-gl-geocoder": {
|
||||||
@@ -1770,15 +1778,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||||
"version": "24.8.1",
|
"version": "26.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.4.tgz",
|
||||||
|
"integrity": "sha512-hG4RZR4PBd/+6A3ODdZ3AnQRF106ZQk1y3bhwDg8bH/A4Oz2tnRUJVVWs9BZa6gXZ1d3UKe89C+Mm+olGBELvA==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
|
"@mapbox/jsonlint-lines-primitives": "^2.0.3",
|
||||||
"@mapbox/unitbezier": "^0.0.1",
|
"@mapbox/unitbezier": "^1.0.0",
|
||||||
"json-stringify-pretty-compact": "^4.0.0",
|
"json-stringify-pretty-compact": "^4.0.0",
|
||||||
"minimist": "^1.2.8",
|
"minimist": "^1.2.8",
|
||||||
"quickselect": "^3.0.0",
|
"quickselect": "^3.0.0",
|
||||||
"rw": "^1.3.3",
|
|
||||||
"tinyqueue": "^3.0.0"
|
"tinyqueue": "^3.0.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -1788,31 +1797,29 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@maplibre/mlt": {
|
"node_modules/@maplibre/mlt": {
|
||||||
"version": "1.1.8",
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==",
|
||||||
"license": "(MIT OR Apache-2.0)",
|
"license": "(MIT OR Apache-2.0)",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mapbox/point-geometry": "^1.1.0"
|
"@mapbox/point-geometry": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@maplibre/vt-pbf": {
|
"node_modules/@maplibre/vt-pbf": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
|
||||||
|
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mapbox/point-geometry": "^1.1.0",
|
"@mapbox/point-geometry": "^1.1.0",
|
||||||
"@mapbox/vector-tile": "^2.0.4",
|
|
||||||
"@maplibre/geojson-vt": "^5.0.4",
|
|
||||||
"@types/geojson": "^7946.0.16",
|
"@types/geojson": "^7946.0.16",
|
||||||
"@types/supercluster": "^7.1.3",
|
"pbf": "^5.1.0"
|
||||||
"pbf": "^4.0.1",
|
|
||||||
"supercluster": "^8.0.1"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": {
|
|
||||||
"version": "5.0.4",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/@maplibre/vt-pbf/node_modules/pbf": {
|
"node_modules/@maplibre/vt-pbf/node_modules/pbf": {
|
||||||
"version": "4.0.1",
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"resolve-protobuf-schema": "^2.1.0"
|
"resolve-protobuf-schema": "^2.1.0"
|
||||||
@@ -2758,13 +2765,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/supercluster": {
|
|
||||||
"version": "7.1.3",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/geojson": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/three": {
|
"node_modules/@types/three": {
|
||||||
"version": "0.134.0",
|
"version": "0.134.0",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -3098,6 +3098,15 @@
|
|||||||
"node": "18 || 20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bidi-js": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bits-ui": {
|
"node_modules/bits-ui": {
|
||||||
"version": "2.17.2",
|
"version": "2.17.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -4177,6 +4186,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/json-stringify-pretty-compact": {
|
"node_modules/json-stringify-pretty-compact": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/jszip": {
|
"node_modules/jszip": {
|
||||||
@@ -4190,7 +4201,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/kdbush": {
|
"node_modules/kdbush": {
|
||||||
"version": "4.0.2",
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
@@ -4611,25 +4624,26 @@
|
|||||||
"license": "(MIT AND Zlib)"
|
"license": "(MIT AND Zlib)"
|
||||||
},
|
},
|
||||||
"node_modules/maplibre-gl": {
|
"node_modules/maplibre-gl": {
|
||||||
"version": "5.22.0",
|
"version": "6.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.15.0.tgz",
|
||||||
|
"integrity": "sha512-kxFetbvUHDOVaAP1CYb226Mdk2QhzIPddqM0xJTFD35wu78Mpd6NM7+H68ALjc6Oa0kAOX5EAdMKyvD2u3qfcg==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
|
||||||
"@mapbox/point-geometry": "^1.1.0",
|
"@mapbox/point-geometry": "^1.1.0",
|
||||||
"@mapbox/tiny-sdf": "^2.0.7",
|
"@mapbox/tiny-sdf": "^2.3.0",
|
||||||
"@mapbox/unitbezier": "^0.0.1",
|
"@mapbox/unitbezier": "^1.0.0",
|
||||||
"@mapbox/vector-tile": "^2.0.4",
|
"@mapbox/vector-tile": "^3.0.0",
|
||||||
"@mapbox/whoots-js": "^3.1.0",
|
"@maplibre/geojson-vt": "^6.3.0",
|
||||||
"@maplibre/geojson-vt": "^6.0.4",
|
"@maplibre/maplibre-gl-style-spec": "^26.4.4",
|
||||||
"@maplibre/maplibre-gl-style-spec": "^24.8.1",
|
"@maplibre/mlt": "^1.3.0",
|
||||||
"@maplibre/mlt": "^1.1.8",
|
"@maplibre/vt-pbf": "^4.3.2",
|
||||||
"@maplibre/vt-pbf": "^4.3.0",
|
|
||||||
"@types/geojson": "^7946.0.16",
|
"@types/geojson": "^7946.0.16",
|
||||||
"earcut": "^3.0.2",
|
"bidi-js": "^1.1.0",
|
||||||
|
"earcut": "^3.2.4",
|
||||||
"gl-matrix": "^3.4.4",
|
"gl-matrix": "^3.4.4",
|
||||||
"kdbush": "^4.0.2",
|
"kdbush": "^4.1.0",
|
||||||
"murmurhash-js": "^1.0.0",
|
"murmurhash-js": "^1.0.0",
|
||||||
"pbf": "^4.0.1",
|
"pbf": "^5.1.2",
|
||||||
"potpack": "^2.1.0",
|
"potpack": "^2.1.0",
|
||||||
"quickselect": "^3.0.0",
|
"quickselect": "^3.0.0",
|
||||||
"tinyqueue": "^3.0.0"
|
"tinyqueue": "^3.0.0"
|
||||||
@@ -4643,11 +4657,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/maplibre-gl/node_modules/earcut": {
|
"node_modules/maplibre-gl/node_modules/earcut": {
|
||||||
"version": "3.0.2",
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/maplibre-gl/node_modules/pbf": {
|
"node_modules/maplibre-gl/node_modules/pbf": {
|
||||||
"version": "4.0.1",
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"resolve-protobuf-schema": "^2.1.0"
|
"resolve-protobuf-schema": "^2.1.0"
|
||||||
@@ -4708,6 +4726,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/minimist": {
|
"node_modules/minimist": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
@@ -5256,6 +5276,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/quickselect": {
|
"node_modules/quickselect": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/rbush": {
|
"node_modules/rbush": {
|
||||||
@@ -5294,6 +5316,15 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve-from": {
|
"node_modules/resolve-from": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||||
@@ -5391,10 +5422,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rw": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/rxjs": {
|
"node_modules/rxjs": {
|
||||||
"version": "7.8.2",
|
"version": "7.8.2",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
@@ -5628,13 +5655,6 @@
|
|||||||
"xtend": "^4.0.0"
|
"xtend": "^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/supercluster": {
|
|
||||||
"version": "8.0.1",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"kdbush": "^4.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
@@ -5876,6 +5896,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/tinyqueue": {
|
"node_modules/tinyqueue": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/totalist": {
|
"node_modules/totalist": {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"build:desktop": "BUILD_TARGET=desktop BASE_PATH='' vite build",
|
||||||
"prebuild": "npx tsx src/lib/scripts/pwa-manifest.ts",
|
"prebuild": "npx tsx src/lib/scripts/pwa-manifest.ts",
|
||||||
"postbuild": "npx tsx src/lib/scripts/sitemap.ts",
|
"postbuild": "npx tsx src/lib/scripts/sitemap.ts",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
@@ -77,7 +78,7 @@
|
|||||||
"gpx-wasm": "file:../wasm/pkg",
|
"gpx-wasm": "file:../wasm/pkg",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"mapillary-js": "^4.1.2",
|
"mapillary-js": "^4.1.2",
|
||||||
"maplibre-gl": "^5.21.1",
|
"maplibre-gl": "^6.15.0",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
"sortablejs": "^1.15.6"
|
"sortablejs": "^1.15.6"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,8 +46,13 @@ export async function handle({ event, resolve }) {
|
|||||||
<meta name="twitter:url" content="https://gpx.studio/" />
|
<meta name="twitter:url" content="https://gpx.studio/" />
|
||||||
<meta name="twitter:site" content="@gpxstudio" />
|
<meta name="twitter:site" content="@gpxstudio" />
|
||||||
<meta name="twitter:creator" content="@gpxstudio" />
|
<meta name="twitter:creator" content="@gpxstudio" />
|
||||||
<link rel="alternate" hreflang="x-default" href="https://gpx.studio${getURLForLanguage('en', path)}" />
|
<link rel="alternate" hreflang="x-default" href="https://gpx.studio${getURLForLanguage('en', path)}" />`;
|
||||||
|
|
||||||
|
// the manifests are made for the website, not for the desktop app (`npm run build:desktop`)
|
||||||
|
if (process.env.BUILD_TARGET !== 'desktop') {
|
||||||
|
headTag += `
|
||||||
<link rel="manifest" href="/${language}.manifest.webmanifest" />`;
|
<link rel="manifest" href="/${language}.manifest.webmanifest" />`;
|
||||||
|
}
|
||||||
|
|
||||||
if (page !== '404') {
|
if (page !== '404') {
|
||||||
for (let lang of Object.keys(languages)) {
|
for (let lang of Object.keys(languages)) {
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ export function createFile() {
|
|||||||
currentTool.set(Tool.ROUTING);
|
currentTool.set(Tool.ROUTING);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Creates a file that starts with an empty route, which the routing tool then draws. */
|
||||||
|
export function createRouteFile() {
|
||||||
|
engine.newFile(newFileName(), undefined, true);
|
||||||
|
currentTool.set(Tool.ROUTING);
|
||||||
|
}
|
||||||
|
|
||||||
/** Asks for the files to open: with the file picker of the system in the desktop app. */
|
/** Asks for the files to open: with the file picker of the system in the desktop app. */
|
||||||
export async function openFiles() {
|
export async function openFiles() {
|
||||||
if (get(engine.workspace)) {
|
if (get(engine.workspace)) {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
Maximize2,
|
Maximize2,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
FolderX,
|
FolderX,
|
||||||
|
ArrowRightLeft,
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
||||||
@@ -67,6 +68,7 @@
|
|||||||
closeFiles,
|
closeFiles,
|
||||||
closeFolder,
|
closeFolder,
|
||||||
createFile,
|
createFile,
|
||||||
|
createRouteFile,
|
||||||
loadFiles,
|
loadFiles,
|
||||||
openFiles,
|
openFiles,
|
||||||
openFolder,
|
openFolder,
|
||||||
@@ -105,8 +107,10 @@
|
|||||||
let desktop = $derived($workspace !== undefined);
|
let desktop = $derived($workspace !== undefined);
|
||||||
let selectedIds = $derived(selectedFileIds($selection));
|
let selectedIds = $derived(selectedFileIds($selection));
|
||||||
let anyUnsaved = $derived(Object.values($workspace?.files ?? {}).some((file) => file.dirty));
|
let anyUnsaved = $derived(Object.values($workspace?.files ?? {}).some((file) => file.dirty));
|
||||||
// metadata and style can be edited for files and tracks only
|
// metadata and style can be edited for files, routes and tracks only
|
||||||
let filesOrTracksSelected = $derived($selection.type === 'file' || $selection.type === 'track');
|
let filesOrTracksSelected = $derived(
|
||||||
|
$selection.type === 'file' || $selection.type === 'route' || $selection.type === 'track'
|
||||||
|
);
|
||||||
|
|
||||||
function switchBasemaps() {
|
function switchBasemaps() {
|
||||||
[$currentBasemap, $previousBasemap] = [$previousBasemap, $currentBasemap];
|
[$currentBasemap, $previousBasemap] = [$previousBasemap, $currentBasemap];
|
||||||
@@ -157,9 +161,13 @@
|
|||||||
<Menubar.Content class="border-none">
|
<Menubar.Content class="border-none">
|
||||||
<Menubar.Item onclick={createFile}>
|
<Menubar.Item onclick={createFile}>
|
||||||
<Plus size="16" />
|
<Plus size="16" />
|
||||||
{i18n._('menu.new')}
|
{i18n._('menu.new_file')}
|
||||||
<Shortcut key="+" ctrl={true} />
|
<Shortcut key="+" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
|
<Menubar.Item onclick={createRouteFile}>
|
||||||
|
<Plus size="16" />
|
||||||
|
{i18n._('menu.new_route_file')}
|
||||||
|
</Menubar.Item>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item onclick={openFiles}>
|
<Menubar.Item onclick={openFiles}>
|
||||||
<FolderOpen size="16" />
|
<FolderOpen size="16" />
|
||||||
@@ -308,6 +316,13 @@
|
|||||||
<Plus size="16" />
|
<Plus size="16" />
|
||||||
{i18n._('menu.new_track')}
|
{i18n._('menu.new_track')}
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
|
<Menubar.Item
|
||||||
|
onclick={() => engine.newRoute()}
|
||||||
|
disabled={selectionCount !== 1}
|
||||||
|
>
|
||||||
|
<Plus size="16" />
|
||||||
|
{i18n._('menu.new_route')}
|
||||||
|
</Menubar.Item>
|
||||||
{:else if $selection.type === 'track'}
|
{:else if $selection.type === 'track'}
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
@@ -318,6 +333,19 @@
|
|||||||
{i18n._('menu.new_segment')}
|
{i18n._('menu.new_segment')}
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if $selection.type === 'route' || $selection.type === 'track' || $selection.type === 'segment'}
|
||||||
|
{#if $selection.type !== 'track'}
|
||||||
|
<Menubar.Separator />
|
||||||
|
{/if}
|
||||||
|
<Menubar.Item onclick={() => engine.convert()}>
|
||||||
|
<ArrowRightLeft size="16" />
|
||||||
|
{i18n._(
|
||||||
|
$selection.type === 'route'
|
||||||
|
? 'menu.convert_to_track'
|
||||||
|
: 'menu.convert_to_route'
|
||||||
|
)}
|
||||||
|
</Menubar.Item>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item onclick={() => engine.selectAll()} disabled={noFiles}>
|
<Menubar.Item onclick={() => engine.selectAll()} disabled={noFiles}>
|
||||||
|
|||||||
@@ -24,14 +24,15 @@ import { get, type Readable, type Writable } from 'svelte/store';
|
|||||||
import type { Coordinates } from '$lib/geo/coordinates';
|
import type { Coordinates } from '$lib/geo/coordinates';
|
||||||
import {
|
import {
|
||||||
engine,
|
engine,
|
||||||
NO_TIME,
|
EMPTY_SELECTION_STATISTICS,
|
||||||
|
type SelectionPoint,
|
||||||
type SelectionStatistics,
|
type SelectionStatistics,
|
||||||
type StatisticsMetric,
|
type StatisticsMetric,
|
||||||
type StatisticsRequest,
|
type StatisticsRequest,
|
||||||
} from '$lib/engine';
|
} from '$lib/engine';
|
||||||
import type { SlicedStatistics } from '$lib/state/selection-statistics';
|
import type { SlicedStatistics } from '$lib/state/selection-statistics';
|
||||||
import { mode } from 'mode-watcher';
|
import { mode } from 'mode-watcher';
|
||||||
import { categoryValues } from '$lib/engine/trackpoint-categories';
|
import { categoryValue, intervalAt } from '$lib/engine/trackpoint-categories';
|
||||||
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/data/colors';
|
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/data/colors';
|
||||||
|
|
||||||
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
||||||
@@ -44,20 +45,13 @@ function optional(value: number, convert: (value: number) => number = (v) => v)
|
|||||||
return Number.isNaN(value) ? undefined : convert(value);
|
return Number.isNaN(value) ? undefined : convert(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A point of the chart. The rest of the data of the trackpoint is only read when it is hovered
|
||||||
|
* (see `SelectionStatistics.point`), and the categories of the fill from their intervals.
|
||||||
|
*/
|
||||||
interface ElevationProfilePoint {
|
interface ElevationProfilePoint {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
time?: Date;
|
|
||||||
slope: {
|
|
||||||
at: number;
|
|
||||||
segment: number;
|
|
||||||
length: number;
|
|
||||||
};
|
|
||||||
surface?: string;
|
|
||||||
highway?: string;
|
|
||||||
sacScale?: string;
|
|
||||||
mtbScale?: string;
|
|
||||||
coordinates: Coordinates;
|
|
||||||
index: number;
|
index: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +63,16 @@ export class ElevationProfile {
|
|||||||
private _panning = false;
|
private _panning = false;
|
||||||
|
|
||||||
private _statistics: Readable<SelectionStatistics>;
|
private _statistics: Readable<SelectionStatistics>;
|
||||||
|
/** The statistics that the chart shows, which the fills read for each of its segments. */
|
||||||
|
private _data: SelectionStatistics = EMPTY_SELECTION_STATISTICS;
|
||||||
|
/**
|
||||||
|
* The hovered trackpoint, and its data once it is read from the engine (`undefined` if the
|
||||||
|
* statistics changed in the meantime).
|
||||||
|
*/
|
||||||
|
private _hovered: { index: number; loaded: boolean; point?: SelectionPoint } | null = null;
|
||||||
|
private _loadingHovered = false;
|
||||||
|
/** The last move over the chart, held until the data of its trackpoint is read. */
|
||||||
|
private _heldMove: MouseEvent | null = null;
|
||||||
private _slicedStatistics: Writable<SlicedStatistics | undefined>;
|
private _slicedStatistics: Writable<SlicedStatistics | undefined>;
|
||||||
private _hoveredPoint: Writable<Coordinates | null>;
|
private _hoveredPoint: Writable<Coordinates | null>;
|
||||||
private _additionalDatasets: Readable<string[]>;
|
private _additionalDatasets: Readable<string[]>;
|
||||||
@@ -197,11 +201,14 @@ export class ElevationProfile {
|
|||||||
label: (context: TooltipItem<'line'>) => {
|
label: (context: TooltipItem<'line'>) => {
|
||||||
let point = context.raw as ElevationProfilePoint;
|
let point = context.raw as ElevationProfilePoint;
|
||||||
if (context.datasetIndex === 0) {
|
if (context.datasetIndex === 0) {
|
||||||
if (this._dragging) {
|
this._hoveredPoint.set(
|
||||||
this._hoveredPoint.set(null);
|
this._dragging
|
||||||
} else {
|
? null
|
||||||
this._hoveredPoint.set(point.coordinates);
|
: {
|
||||||
}
|
lat: this._data.lat[point.index],
|
||||||
|
lng: this._data.lng[point.index],
|
||||||
|
}
|
||||||
|
);
|
||||||
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
|
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
|
||||||
} else if (context.datasetIndex === 1) {
|
} else if (context.datasetIndex === 1) {
|
||||||
return `${get(velocityUnits) === 'speed' ? i18n._('quantities.speed') : i18n._('quantities.pace')}: ${getVelocityWithUnits(point.y, false)}`;
|
return `${get(velocityUnits) === 'speed' ? i18n._('quantities.speed') : i18n._('quantities.pace')}: ${getVelocityWithUnits(point.y, false)}`;
|
||||||
@@ -219,28 +226,42 @@ export class ElevationProfile {
|
|||||||
let context = contexts.filter((context) => context.datasetIndex === 0);
|
let context = contexts.filter((context) => context.datasetIndex === 0);
|
||||||
if (context.length === 0) return;
|
if (context.length === 0) return;
|
||||||
let point = context[0].raw as ElevationProfilePoint;
|
let point = context[0].raw as ElevationProfilePoint;
|
||||||
let slope = {
|
// the rest of the data is there once it is read (see `hover`)
|
||||||
at: point.slope.at.toFixed(1),
|
const hovered = this._hovered;
|
||||||
segment: point.slope.segment.toFixed(1),
|
const details =
|
||||||
length: getDistanceWithUnits(point.slope.length),
|
hovered?.index === point.index ? hovered.point : undefined;
|
||||||
};
|
const elevationFill = get(this._elevationFill);
|
||||||
let surface = point.surface ?? 'unknown';
|
|
||||||
let highway = point.highway ?? 'unknown';
|
|
||||||
let sacScale = point.sacScale;
|
|
||||||
let mtbScale = point.mtbScale;
|
|
||||||
|
|
||||||
let labels = [
|
let labels = [
|
||||||
` ${i18n._('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`,
|
` ${i18n._('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`,
|
||||||
` ${i18n._('quantities.slope')}: ${slope.at} %${get(this._elevationFill) === 'slope' ? ` (${slope.length} @${slope.segment} %)` : ''}`,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (get(this._elevationFill) === 'surface') {
|
if (!details) {
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
if (details.slope !== undefined) {
|
||||||
|
let slope = `${details.slope.toFixed(1)} %`;
|
||||||
|
if (
|
||||||
|
elevationFill === 'slope' &&
|
||||||
|
details.slopeSegmentSlope !== undefined &&
|
||||||
|
details.slopeSegmentDistance !== undefined
|
||||||
|
) {
|
||||||
|
slope += ` (${getDistanceWithUnits(details.slopeSegmentDistance)} @${details.slopeSegmentSlope.toFixed(1)} %)`;
|
||||||
|
}
|
||||||
|
labels.push(` ${i18n._('quantities.slope')}: ${slope}`);
|
||||||
|
}
|
||||||
|
let surface = details.surface ?? 'unknown';
|
||||||
|
let highway = details.highway ?? 'unknown';
|
||||||
|
let sacScale = details.sacScale;
|
||||||
|
let mtbScale = details.mtbScale;
|
||||||
|
|
||||||
|
if (elevationFill === 'surface') {
|
||||||
labels.push(
|
labels.push(
|
||||||
` ${i18n._('quantities.surface')}: ${i18n._(`toolbar.routing.surface.${surface}`)}`
|
` ${i18n._('quantities.surface')}: ${i18n._(`toolbar.routing.surface.${surface}`)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (get(this._elevationFill) === 'highway') {
|
if (elevationFill === 'highway') {
|
||||||
labels.push(
|
labels.push(
|
||||||
` ${i18n._('quantities.highway')}: ${i18n._(`toolbar.routing.highway.${highway}`)}${
|
` ${i18n._('quantities.highway')}: ${i18n._(`toolbar.routing.highway.${highway}`)}${
|
||||||
sacScale
|
sacScale
|
||||||
@@ -255,9 +276,9 @@ export class ElevationProfile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (point.time) {
|
if (details.time !== undefined) {
|
||||||
labels.push(
|
labels.push(
|
||||||
` ${i18n._('quantities.time')}: ${i18n.df.format(point.time)}`
|
` ${i18n._('quantities.time')}: ${i18n.df.format(new Date(details.time))}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,10 +360,32 @@ export class ElevationProfile {
|
|||||||
events: ['mouseout'],
|
events: ['mouseout'],
|
||||||
afterEvent: (chart: Chart, args: { event: ChartEvent }) => {
|
afterEvent: (chart: Chart, args: { event: ChartEvent }) => {
|
||||||
if (args.event.type === 'mouseout') {
|
if (args.event.type === 'mouseout') {
|
||||||
|
this._hovered = null;
|
||||||
|
this._heldMove = null;
|
||||||
this._hoveredPoint.set(null);
|
this._hoveredPoint.set(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// The tooltip only shows a trackpoint once all its data is read: a move to
|
||||||
|
// another one is held until then, and replayed (see `loadHovered`).
|
||||||
|
id: 'waitForTrackpoint',
|
||||||
|
events: ['mousemove'],
|
||||||
|
beforeEvent: (chart: Chart, args: { event: ChartEvent }) => {
|
||||||
|
const index = this.indexAt(args.event);
|
||||||
|
if (index === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hovered = this._hovered;
|
||||||
|
if (hovered?.index === index && hovered.loaded) {
|
||||||
|
this._heldMove = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._heldMove = args.event.native as MouseEvent;
|
||||||
|
this.hover(index);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -430,6 +473,9 @@ export class ElevationProfile {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = get(this._statistics);
|
const data = get(this._statistics);
|
||||||
|
this._data = data;
|
||||||
|
// the indices of the trackpoints are the ones of other statistics
|
||||||
|
this._hovered = null;
|
||||||
const units = {
|
const units = {
|
||||||
distance: get(distanceUnits),
|
distance: get(distanceUnits),
|
||||||
velocity: get(velocityUnits),
|
velocity: get(velocityUnits),
|
||||||
@@ -438,28 +484,11 @@ export class ElevationProfile {
|
|||||||
|
|
||||||
const datasets: Array<Array<any>> = [[], [], [], [], [], []];
|
const datasets: Array<Array<any>> = [[], [], [], [], [], []];
|
||||||
const { global } = data;
|
const { global } = data;
|
||||||
const surfaces = categoryValues(data.surface, data.length);
|
|
||||||
const highways = categoryValues(data.highway, data.length);
|
|
||||||
const sacScales = categoryValues(data.sacScale, data.length);
|
|
||||||
const mtbScales = categoryValues(data.mtbScale, data.length);
|
|
||||||
for (let index = 0; index < data.length; index++) {
|
for (let index = 0; index < data.length; index++) {
|
||||||
const x = getConvertedDistance(data.totalDistance[index], units.distance);
|
const x = getConvertedDistance(data.totalDistance[index], units.distance);
|
||||||
const ele = data.ele[index];
|
|
||||||
const timestamp = data.timestamps?.[index] ?? NO_TIME;
|
|
||||||
datasets[0].push({
|
datasets[0].push({
|
||||||
x,
|
x,
|
||||||
y: getConvertedElevation(ele, units.distance),
|
y: getConvertedElevation(data.ele[index], units.distance),
|
||||||
time: timestamp === NO_TIME ? undefined : new Date(Number(timestamp)),
|
|
||||||
slope: {
|
|
||||||
at: data.slope[index],
|
|
||||||
segment: data.slopeSegmentSlope?.[index] ?? NaN,
|
|
||||||
length: data.slopeSegmentDistance?.[index] ?? NaN,
|
|
||||||
},
|
|
||||||
surface: surfaces[index],
|
|
||||||
highway: highways[index],
|
|
||||||
sacScale: sacScales[index],
|
|
||||||
mtbScale: mtbScales[index],
|
|
||||||
coordinates: { lat: data.lat[index], lng: data.lng[index] },
|
|
||||||
index,
|
index,
|
||||||
});
|
});
|
||||||
if (data.speed && (global.totalTime ?? 0) > 0) {
|
if (data.speed && (global.totalTime ?? 0) > 0) {
|
||||||
@@ -643,21 +672,84 @@ export class ElevationProfile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
slopeFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
|
/** The trackpoint that the tooltip shows for an event, `undefined` outside of the chart. */
|
||||||
const point = context.p0.raw as ElevationProfilePoint;
|
private indexAt(event: ChartEvent): number | undefined {
|
||||||
return getSlopeColor(point.slope.segment);
|
const elements = this._chart?.getElementsAtEventForMode(
|
||||||
|
event as unknown as Event,
|
||||||
|
'nearest',
|
||||||
|
{ axis: 'x', intersect: false },
|
||||||
|
false
|
||||||
|
);
|
||||||
|
const element = elements?.find((element) => element.datasetIndex === 0);
|
||||||
|
// the data of the chart can be decimated: its points are the ones of the elements
|
||||||
|
const point = element && this._chart?.data.datasets[0].data[element.index];
|
||||||
|
return (point as ElevationProfilePoint | undefined)?.index;
|
||||||
}
|
}
|
||||||
|
|
||||||
surfaceFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
|
/**
|
||||||
const point = context.p0.raw as ElevationProfilePoint;
|
* Reads the data of the hovered trackpoint from the engine, for the tooltip. One trackpoint is
|
||||||
return getSurfaceColor(point.surface ?? '');
|
* read at a time, the last one hovered.
|
||||||
|
*/
|
||||||
|
hover(index: number) {
|
||||||
|
if (this._hovered?.index === index) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._hovered = { index, loaded: false };
|
||||||
|
this.loadHovered();
|
||||||
}
|
}
|
||||||
|
|
||||||
highwayFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
|
private async loadHovered() {
|
||||||
const point = context.p0.raw as ElevationProfilePoint;
|
if (this._loadingHovered) {
|
||||||
return getHighwayColor(point.highway ?? '', point.sacScale, point.mtbScale);
|
return; // the one being read is followed by the last one hovered
|
||||||
|
}
|
||||||
|
this._loadingHovered = true;
|
||||||
|
try {
|
||||||
|
while (this._hovered && !this._hovered.loaded) {
|
||||||
|
const hovered = this._hovered;
|
||||||
|
const point = await this._data.point(hovered.index);
|
||||||
|
if (this._hovered !== hovered) {
|
||||||
|
continue; // another trackpoint is hovered, or none
|
||||||
|
}
|
||||||
|
hovered.loaded = true;
|
||||||
|
hovered.point = point;
|
||||||
|
// the move that was held can now show the tooltip
|
||||||
|
const move = this._heldMove;
|
||||||
|
this._heldMove = null;
|
||||||
|
if (move) {
|
||||||
|
this._canvas.dispatchEvent(new MouseEvent(move.type, move));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this._loadingHovered = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The trackpoint that a segment of the chart starts at. */
|
||||||
|
private static segmentStart(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
|
||||||
|
return (context.p0.raw as ElevationProfilePoint).index;
|
||||||
|
}
|
||||||
|
|
||||||
|
slopeFillCallback = (context: ScriptableLineSegmentContext & { p0: { raw: any } }) => {
|
||||||
|
const segments = this._data.slopeSegments;
|
||||||
|
const index = ElevationProfile.segmentStart(context);
|
||||||
|
const segment = segments ? intervalAt(segments.starts, index) : -1;
|
||||||
|
return getSlopeColor(segment >= 0 ? segments!.slopes[segment] : NaN);
|
||||||
|
};
|
||||||
|
|
||||||
|
surfaceFillCallback = (context: ScriptableLineSegmentContext & { p0: { raw: any } }) => {
|
||||||
|
const index = ElevationProfile.segmentStart(context);
|
||||||
|
return getSurfaceColor(categoryValue(this._data.surface, index) ?? '');
|
||||||
|
};
|
||||||
|
|
||||||
|
highwayFillCallback = (context: ScriptableLineSegmentContext & { p0: { raw: any } }) => {
|
||||||
|
const index = ElevationProfile.segmentStart(context);
|
||||||
|
return getHighwayColor(
|
||||||
|
categoryValue(this._data.highway, index) ?? '',
|
||||||
|
categoryValue(this._data.sacScale, index),
|
||||||
|
categoryValue(this._data.mtbScale, index)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this._request.release();
|
this._request.release();
|
||||||
if (this._chart) {
|
if (this._chart) {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
// not what the click that was just made depends on, only the selection and the folder
|
// not what the click that was just made depends on, only the selection and the folder
|
||||||
const clicked = untrack(() => clickedInExplorer.path);
|
const clicked = untrack(() => clickedInExplorer.path);
|
||||||
if (selectedPath === clicked) {
|
if (clicked !== undefined && selectedPath === clicked) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clickedInExplorer.path = undefined;
|
clickedInExplorer.path = undefined;
|
||||||
@@ -175,14 +175,17 @@
|
|||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
@reference "../../../app.css";
|
@reference "../../../app.css";
|
||||||
|
|
||||||
/* a selected file, and a selected folder with its chevron: the whole row of its node */
|
/* the color of the file list: a file and a folder (the whole row of its node, with its
|
||||||
|
chevron) under the pointer, or selected */
|
||||||
|
[data-explorer] :global([data-kind='file']:hover),
|
||||||
|
[data-explorer] :global([data-slot='button']:hover),
|
||||||
[data-explorer] :global([data-kind='file'][aria-selected='true']),
|
[data-explorer] :global([data-kind='file'][aria-selected='true']),
|
||||||
[data-explorer] :global([data-slot='button']:has([aria-selected='true'])) {
|
[data-explorer] :global([data-slot='button']:has([aria-selected='true'])) {
|
||||||
@apply bg-accent;
|
@apply bg-[var(--selection)];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* the folder where what is dragged would go */
|
/* the folder where what is dragged would go */
|
||||||
[data-explorer] :global([data-slot='button']:has([data-drop-target])) {
|
[data-explorer] :global([data-slot='button']:has([data-drop-target])) {
|
||||||
@apply ring-1 ring-primary bg-accent/60;
|
@apply ring-1 ring-primary bg-[var(--selection)];
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as ContextMenu from '$lib/components/ui/context-menu';
|
import * as ContextMenu from '$lib/components/ui/context-menu';
|
||||||
import {
|
import {
|
||||||
ExternalLink,
|
ArrowUpRight,
|
||||||
FileX,
|
FileX,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
FolderX,
|
FolderX,
|
||||||
PencilLine,
|
PenLine,
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
@@ -116,12 +116,16 @@
|
|||||||
/** The folder where what is made from the menu goes: the folder of the row. */
|
/** The folder where what is made from the menu goes: the folder of the row. */
|
||||||
let folder = $derived(kind === 'file' ? undefined : path);
|
let folder = $derived(kind === 'file' ? undefined : path);
|
||||||
|
|
||||||
/** Asks for the name of a file or a folder to make in the folder of the row (see `creating`). */
|
/**
|
||||||
function create(what: 'file' | 'folder') {
|
* Asks for the name of a file or a folder to make in the folder of the row (see `creating`).
|
||||||
|
* With `route`, the file starts with an empty route.
|
||||||
|
*/
|
||||||
|
function create(what: 'file' | 'folder', route = false) {
|
||||||
if (folder === undefined) return;
|
if (folder === undefined) return;
|
||||||
// where what is being named is shown
|
// where what is being named is shown
|
||||||
folders.open(folder);
|
folders.open(folder);
|
||||||
creating.kind = what;
|
creating.kind = what;
|
||||||
|
creating.route = route;
|
||||||
creating.parent = folder;
|
creating.parent = folder;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +232,10 @@
|
|||||||
|
|
||||||
<ContextMenu.Root
|
<ContextMenu.Root
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
if (open && fileId !== undefined) {
|
||||||
|
// the file is open for good once its menu is
|
||||||
|
engine.keepPreview([fileId]);
|
||||||
|
}
|
||||||
if (open && !selected && kind !== 'root') {
|
if (open && !selected && kind !== 'root') {
|
||||||
select([path]);
|
select([path]);
|
||||||
}
|
}
|
||||||
@@ -282,6 +290,10 @@
|
|||||||
<Plus size="16" />
|
<Plus size="16" />
|
||||||
{i18n._('menu.explorer_menu.new_file')}
|
{i18n._('menu.explorer_menu.new_file')}
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
|
<ContextMenu.Item onclick={() => create('file', true)}>
|
||||||
|
<Plus size="16" />
|
||||||
|
{i18n._('menu.explorer_menu.new_route_file')}
|
||||||
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Item onclick={() => create('folder')}>
|
<ContextMenu.Item onclick={() => create('folder')}>
|
||||||
<Plus size="16" />
|
<Plus size="16" />
|
||||||
{i18n._('menu.explorer_menu.new_folder')}
|
{i18n._('menu.explorer_menu.new_folder')}
|
||||||
@@ -292,38 +304,39 @@
|
|||||||
<FolderOpen size="16" />
|
<FolderOpen size="16" />
|
||||||
{i18n._('menu.explorer_menu.open')}
|
{i18n._('menu.explorer_menu.open')}
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
{#if openIds().length > 0}
|
|
||||||
<ContextMenu.Item onclick={() => closeFiles(openIds())}>
|
|
||||||
<FileX size="16" />
|
|
||||||
{i18n._('menu.close')}
|
|
||||||
</ContextMenu.Item>
|
|
||||||
{/if}
|
|
||||||
<ContextMenu.Separator />
|
|
||||||
{/if}
|
{/if}
|
||||||
|
<ContextMenu.Item onclick={() => engine.revealPath(path)}>
|
||||||
|
<ArrowUpRight size="16" />
|
||||||
|
{i18n._(isMac() ? 'menu.explorer_menu.reveal_finder' : 'menu.explorer_menu.reveal')}
|
||||||
|
</ContextMenu.Item>
|
||||||
|
<ContextMenu.Separator />
|
||||||
{#if kind !== 'root'}
|
{#if kind !== 'root'}
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
disabled={targets().length !== 1}
|
disabled={targets().length !== 1}
|
||||||
onclick={() => (renaming.path = path)}
|
onclick={() => (renaming.path = path)}
|
||||||
>
|
>
|
||||||
<PencilLine size="16" />
|
<PenLine size="16" />
|
||||||
{i18n._('menu.explorer_menu.rename')}
|
{i18n._('menu.explorer_menu.rename')}
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Item onclick={remove}>
|
|
||||||
<Trash2 size="16" />
|
|
||||||
{i18n._('menu.delete')}
|
|
||||||
</ContextMenu.Item>
|
|
||||||
<ContextMenu.Separator />
|
<ContextMenu.Separator />
|
||||||
{/if}
|
{/if}
|
||||||
<ContextMenu.Item onclick={() => engine.revealPath(path)}>
|
|
||||||
<ExternalLink size="16" />
|
|
||||||
{i18n._(isMac() ? 'menu.explorer_menu.reveal_finder' : 'menu.explorer_menu.reveal')}
|
|
||||||
</ContextMenu.Item>
|
|
||||||
{#if kind === 'root'}
|
{#if kind === 'root'}
|
||||||
<ContextMenu.Separator />
|
|
||||||
<ContextMenu.Item onclick={closeFolder}>
|
<ContextMenu.Item onclick={closeFolder}>
|
||||||
<FolderX size="16" />
|
<FolderX size="16" />
|
||||||
{i18n._('menu.close_folder')}
|
{i18n._('menu.close_folder')}
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
|
{:else}
|
||||||
|
{#if kind === 'file' && openIds().length > 0}
|
||||||
|
<ContextMenu.Item onclick={() => closeFiles(openIds())}>
|
||||||
|
<FileX size="16" />
|
||||||
|
{i18n._('menu.close')}
|
||||||
|
</ContextMenu.Item>
|
||||||
|
<ContextMenu.Separator />
|
||||||
|
{/if}
|
||||||
|
<ContextMenu.Item onclick={remove}>
|
||||||
|
<Trash2 size="16" />
|
||||||
|
{i18n._('menu.delete')}
|
||||||
|
</ContextMenu.Item>
|
||||||
{/if}
|
{/if}
|
||||||
</ContextMenu.Content>
|
</ContextMenu.Content>
|
||||||
</ContextMenu.Root>
|
</ContextMenu.Root>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
|
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
|
||||||
import { FileText, Folder } from '@lucide/svelte';
|
import { FileText, Folder, Route } from '@lucide/svelte';
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
async function create(value: string, retry: boolean) {
|
async function create(value: string, retry: boolean) {
|
||||||
const kind = pending;
|
const kind = pending;
|
||||||
const parent = folder;
|
const parent = folder;
|
||||||
|
const route = creating.route;
|
||||||
if (kind === undefined || parent === undefined) return;
|
if (kind === undefined || parent === undefined) return;
|
||||||
const name = value.trim();
|
const name = value.trim();
|
||||||
const taken = entries.some(
|
const taken = entries.some(
|
||||||
@@ -60,7 +61,7 @@
|
|||||||
if (name === '' || taken) return;
|
if (name === '' || taken) return;
|
||||||
let created: string | undefined;
|
let created: string | undefined;
|
||||||
if (kind === 'file') {
|
if (kind === 'file') {
|
||||||
const id = await engine.newFileIn(name, parent);
|
const id = await engine.newFileIn(name, parent, route);
|
||||||
created = id === undefined ? undefined : get(workspace)?.files[id]?.path;
|
created = id === undefined ? undefined : get(workspace)?.files[id]?.path;
|
||||||
if (created !== undefined) {
|
if (created !== undefined) {
|
||||||
currentTool.set(Tool.ROUTING);
|
currentTool.set(Tool.ROUTING);
|
||||||
@@ -94,14 +95,20 @@
|
|||||||
<!-- the name of what is being made, which it is made with -->
|
<!-- the name of what is being made, which it is made with -->
|
||||||
{#snippet named(kind: 'file' | 'folder')}
|
{#snippet named(kind: 'file' | 'folder')}
|
||||||
<div class="flex flex-row items-center h-7 pl-1 pr-1 {kind === 'file' ? 'gap-1.5' : 'gap-1'}">
|
<div class="flex flex-row items-center h-7 pl-1 pr-1 {kind === 'file' ? 'gap-1.5' : 'gap-1'}">
|
||||||
{#if kind === 'file'}
|
{#if kind === 'file' && creating.route}
|
||||||
|
<Route size="16" class="shrink-0" />
|
||||||
|
{:else if kind === 'file'}
|
||||||
<FileText size="16" class="shrink-0" />
|
<FileText size="16" class="shrink-0" />
|
||||||
{:else}
|
{:else}
|
||||||
<Folder size="16" class="shrink-0" />
|
<Folder size="16" class="shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
<NameInput
|
<NameInput
|
||||||
placeholder={i18n._(
|
placeholder={i18n._(
|
||||||
kind === 'file' ? 'menu.explorer_menu.new_file' : 'menu.explorer_menu.new_folder'
|
kind === 'folder'
|
||||||
|
? 'menu.explorer_menu.new_folder'
|
||||||
|
: creating.route
|
||||||
|
? 'menu.explorer_menu.new_route_file'
|
||||||
|
: 'menu.explorer_menu.new_file'
|
||||||
)}
|
)}
|
||||||
oncommit={create}
|
oncommit={create}
|
||||||
oncancel={() => (creating.parent = undefined)}
|
oncancel={() => (creating.parent = undefined)}
|
||||||
@@ -136,8 +143,7 @@
|
|||||||
path={entry.path}
|
path={entry.path}
|
||||||
name={entry.name}
|
name={entry.name}
|
||||||
kind="file"
|
kind="file"
|
||||||
class="w-full h-7 px-1 text-sm hover:bg-muted {fileIdAt($workspace, entry.path) ===
|
class="w-full h-7 px-1 text-sm {fileIdAt($workspace, entry.path) === undefined
|
||||||
undefined
|
|
||||||
? 'text-muted-foreground'
|
? 'text-muted-foreground'
|
||||||
: ''}"
|
: ''}"
|
||||||
style="scroll-margin-top: {level.top}px"
|
style="scroll-margin-top: {level.top}px"
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ export const renaming: { path: string | undefined } = $state({ path: undefined }
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The file or folder that is being made in a folder (`parent`) of the explorer: it is only made once
|
* The file or folder that is being made in a folder (`parent`) of the explorer: it is only made once
|
||||||
* the user gave it a name.
|
* the user gave it a name. With `route`, the file starts with an empty route.
|
||||||
*/
|
*/
|
||||||
export const creating: { parent: string | undefined; kind: 'file' | 'folder' } = $state({
|
export const creating: { parent: string | undefined; kind: 'file' | 'folder'; route: boolean } =
|
||||||
parent: undefined,
|
$state({
|
||||||
kind: 'file',
|
parent: undefined,
|
||||||
});
|
kind: 'file',
|
||||||
|
route: false,
|
||||||
|
});
|
||||||
|
|
||||||
/** What is dragged in the explorer, and the folder it would be dropped in. */
|
/** What is dragged in the explorer, and the folder it would be dropped in. */
|
||||||
export const dragging: { paths: string[]; target: string | undefined } = $state({
|
export const dragging: { paths: string[]; target: string | undefined } = $state({
|
||||||
|
|||||||
@@ -21,7 +21,6 @@
|
|||||||
Orbit,
|
Orbit,
|
||||||
Thermometer,
|
Thermometer,
|
||||||
SquareActivity,
|
SquareActivity,
|
||||||
Route,
|
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { engine, type ExportOptions } from '$lib/engine';
|
import { engine, type ExportOptions } from '$lib/engine';
|
||||||
@@ -38,7 +37,6 @@
|
|||||||
atemp: true,
|
atemp: true,
|
||||||
power: true,
|
power: true,
|
||||||
osm: false,
|
osm: false,
|
||||||
asRoute: false,
|
|
||||||
});
|
});
|
||||||
// the files that are exported
|
// the files that are exported
|
||||||
let fileIds: string[] = $derived.by(() => {
|
let fileIds: string[] = $derived.by(() => {
|
||||||
@@ -61,7 +59,6 @@
|
|||||||
atemp: false,
|
atemp: false,
|
||||||
power: false,
|
power: false,
|
||||||
osm: false,
|
osm: false,
|
||||||
asRoute: false,
|
|
||||||
});
|
});
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const ids = fileIds;
|
const ids = fileIds;
|
||||||
@@ -75,8 +72,6 @@
|
|||||||
atemp: !available.atemp,
|
atemp: !available.atemp,
|
||||||
power: !available.power,
|
power: !available.power,
|
||||||
osm: !available.osm,
|
osm: !available.osm,
|
||||||
// always possible
|
|
||||||
asRoute: false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -131,7 +126,6 @@
|
|||||||
atemp: exportOptions.atemp && !hide.atemp,
|
atemp: exportOptions.atemp && !hide.atemp,
|
||||||
power: exportOptions.power && !hide.power,
|
power: exportOptions.power && !hide.power,
|
||||||
osm: exportOptions.osm && !hide.osm,
|
osm: exportOptions.osm && !hide.osm,
|
||||||
asRoute: exportOptions.asRoute,
|
|
||||||
};
|
};
|
||||||
if (exportState.current === ExportState.SELECTION) {
|
if (exportState.current === ExportState.SELECTION) {
|
||||||
exportSelectedFiles(options);
|
exportSelectedFiles(options);
|
||||||
@@ -211,13 +205,6 @@
|
|||||||
{i18n._('quantities.osm_extensions')}
|
{i18n._('quantities.osm_extensions')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-1.5">
|
|
||||||
<Checkbox id="export-as-route" bind:checked={exportOptions.asRoute} />
|
|
||||||
<Label for="export-as-route" class="flex flex-row items-center gap-1">
|
|
||||||
<Route size="16" />
|
|
||||||
{i18n._('menu.export_as_route', 'Export as route')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
|
|||||||
@@ -30,6 +30,12 @@
|
|||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
case 'file':
|
case 'file':
|
||||||
return structure.name;
|
return structure.name;
|
||||||
|
case 'route': {
|
||||||
|
const routeIndex = structure.routes.findIndex((r) => r.id === node.routeId);
|
||||||
|
return (
|
||||||
|
structure.routes[routeIndex]?.name ?? `${i18n._('gpx.route')} ${routeIndex + 1}`
|
||||||
|
);
|
||||||
|
}
|
||||||
case 'track': {
|
case 'track': {
|
||||||
const trackIndex = structure.tracks.findIndex((t) => t.id === node.trackId);
|
const trackIndex = structure.tracks.findIndex((t) => t.id === node.trackId);
|
||||||
return (
|
return (
|
||||||
@@ -75,7 +81,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if node.type === 'segment' || node.type === 'waypoint'}
|
{#if node.type === 'route' || node.type === 'segment' || node.type === 'waypoint'}
|
||||||
<FileListNodeLabel {fileState} {node} {label} />
|
<FileListNodeLabel {fileState} {node} {label} />
|
||||||
{:else if recursive}
|
{:else if recursive}
|
||||||
<CollapsibleTreeNode {id} bind:this={collapsible}>
|
<CollapsibleTreeNode {id} bind:this={collapsible}>
|
||||||
@@ -84,7 +90,11 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet content()}
|
{#snippet content()}
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<FileListNodeContent {fileState} {node} />
|
{#if node.type === 'file' && fileState.structure.routes.length > 0}
|
||||||
|
<FileListNodeContent {fileState} {node} part="routes" />
|
||||||
|
{:else}
|
||||||
|
<FileListNodeContent {fileState} {node} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</CollapsibleTreeNode>
|
</CollapsibleTreeNode>
|
||||||
|
|||||||
@@ -10,16 +10,21 @@
|
|||||||
let {
|
let {
|
||||||
fileState,
|
fileState,
|
||||||
node,
|
node,
|
||||||
waypointRoot = false,
|
part = 'tracks',
|
||||||
}: {
|
}: {
|
||||||
/** State of the file of the node (not needed for the root). */
|
/** State of the file of the node (not needed for the root). */
|
||||||
fileState?: FileState;
|
fileState?: FileState;
|
||||||
/** The node whose children are listed, `null` for the list of the files. */
|
/** The node whose children are listed, `null` for the list of the files. */
|
||||||
node: FileTreeNode | null;
|
node: FileTreeNode | null;
|
||||||
/** List the node standing for the waypoints of the file, instead of its tracks. */
|
/**
|
||||||
waypointRoot?: boolean;
|
* For a file, which of its lists this is: its routes, its tracks, or the node standing for
|
||||||
|
* its waypoints. Each one is followed by the next ones.
|
||||||
|
*/
|
||||||
|
part?: 'routes' | 'tracks' | 'waypoints';
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
let waypointRoot = part === 'waypoints';
|
||||||
|
|
||||||
const { files, order } = engine;
|
const { files, order } = engine;
|
||||||
|
|
||||||
let container: HTMLElement;
|
let container: HTMLElement;
|
||||||
@@ -27,9 +32,11 @@
|
|||||||
node === null
|
node === null
|
||||||
? 'file'
|
? 'file'
|
||||||
: node.type === 'file'
|
: node.type === 'file'
|
||||||
? waypointRoot
|
? part === 'waypoints'
|
||||||
? 'waypoints'
|
? 'waypoints'
|
||||||
: 'track'
|
: part === 'routes'
|
||||||
|
? 'route'
|
||||||
|
: 'track'
|
||||||
: node.type === 'waypoints'
|
: node.type === 'waypoints'
|
||||||
? 'waypoint'
|
? 'waypoint'
|
||||||
: 'segment';
|
: 'segment';
|
||||||
@@ -74,7 +81,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{:else if fileState}
|
{:else if fileState}
|
||||||
{#if node.type === 'file'}
|
{#if node.type === 'file'}
|
||||||
{#if waypointRoot}
|
{#if part === 'waypoints'}
|
||||||
{#if fileState.structure.waypoints.length > 0}
|
{#if fileState.structure.waypoints.length > 0}
|
||||||
<div data-id="waypoints">
|
<div data-id="waypoints">
|
||||||
<FileListNode
|
<FileListNode
|
||||||
@@ -83,6 +90,15 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else if part === 'routes'}
|
||||||
|
{#each fileState.structure.routes as route (route.id)}
|
||||||
|
<div data-id={route.id}>
|
||||||
|
<FileListNode
|
||||||
|
{fileState}
|
||||||
|
node={{ type: 'route', fileId: node.fileId, routeId: route.id }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
{#each fileState.structure.tracks as track (track.id)}
|
{#each fileState.structure.tracks as track (track.id)}
|
||||||
<div data-id={track.id}>
|
<div data-id={track.id}>
|
||||||
@@ -120,8 +136,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if fileState && node?.type === 'file' && !waypointRoot}
|
{#if fileState && node?.type === 'file' && part === 'routes'}
|
||||||
<FileListNodeContent {fileState} {node} waypointRoot={true} />
|
<FileListNodeContent {fileState} {node} part="tracks" />
|
||||||
|
{:else if fileState && node?.type === 'file' && part === 'tracks'}
|
||||||
|
<FileListNodeContent {fileState} {node} part="waypoints" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
|
|||||||
@@ -18,8 +18,10 @@
|
|||||||
Scissors,
|
Scissors,
|
||||||
FileStack,
|
FileStack,
|
||||||
FileX,
|
FileX,
|
||||||
|
Route,
|
||||||
|
ArrowRightLeft,
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { getContext } from 'svelte';
|
import { getContext, untrack } from 'svelte';
|
||||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import MetadataDialog from '$lib/components/file-list/metadata/MetadataDialog.svelte';
|
import MetadataDialog from '$lib/components/file-list/metadata/MetadataDialog.svelte';
|
||||||
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
||||||
@@ -34,6 +36,7 @@
|
|||||||
elementId,
|
elementId,
|
||||||
isInClipboard,
|
isInClipboard,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
pathInfo,
|
||||||
selectedElementIds,
|
selectedElementIds,
|
||||||
selectedFileIds,
|
selectedFileIds,
|
||||||
selectionSize,
|
selectionSize,
|
||||||
@@ -62,20 +65,23 @@
|
|||||||
|
|
||||||
let singleSelection = $derived(selectionSize($selection) === 1);
|
let singleSelection = $derived(selectionSize($selection) === 1);
|
||||||
|
|
||||||
|
// a route or a track: it has a name, a description and a style, like a file
|
||||||
|
let isPath = $derived(node.type === 'route' || node.type === 'track');
|
||||||
|
|
||||||
let nodeColors: string[] = $derived.by(() => {
|
let nodeColors: string[] = $derived.by(() => {
|
||||||
const { tracks } = fileState.structure;
|
const { routes, tracks } = fileState.structure;
|
||||||
if (node.type === 'file') {
|
if (node.type === 'file') {
|
||||||
// the colors defined by the tracks, or else the one of the file
|
// the colors defined by the routes and tracks, or else the one of the file
|
||||||
const colors = [
|
const colors = [
|
||||||
...new Set(
|
...new Set(
|
||||||
tracks.flatMap((track) =>
|
[...routes, ...tracks].flatMap((path) =>
|
||||||
track.color !== undefined ? [normalizeColor(track.color)] : []
|
path.color !== undefined ? [normalizeColor(path.color)] : []
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
return colors.length > 0 ? colors : [fileState.color];
|
return colors.length > 0 ? colors : [fileState.color];
|
||||||
} else if (node.type === 'track') {
|
} else if (isPath) {
|
||||||
const color = tracks.find((track) => track.id === node.trackId)?.color;
|
const color = pathInfo(fileState.structure, node)?.color;
|
||||||
return [color !== undefined ? normalizeColor(color) : fileState.color];
|
return [color !== undefined ? normalizeColor(color) : fileState.color];
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
@@ -94,6 +100,12 @@
|
|||||||
selected &&
|
selected &&
|
||||||
selectedElementIds($selection)[0]?.ids[0] === elementId(node)
|
selectedElementIds($selection)[0]?.ids[0] === elementId(node)
|
||||||
);
|
);
|
||||||
|
$effect(() => {
|
||||||
|
// the file is open for good once its info or its appearance is (the desktop app)
|
||||||
|
if (openEditMetadata || openEditStyle) {
|
||||||
|
untrack(() => engine.keepPreview([node.fileId]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// the desktop app: the files are linked to the file system, they are closed rather than deleted
|
// the desktop app: the files are linked to the file system, they are closed rather than deleted
|
||||||
let desktop = $derived($workspace !== undefined);
|
let desktop = $derived($workspace !== undefined);
|
||||||
@@ -114,6 +126,8 @@
|
|||||||
<ContextMenu.Root
|
<ContextMenu.Root
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
// the file is open for good once its menu is (the desktop app)
|
||||||
|
engine.keepPreview([node.fileId]);
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
engine.selectNode(node);
|
engine.selectNode(node);
|
||||||
}
|
}
|
||||||
@@ -133,11 +147,11 @@
|
|||||||
? 'w-full h-7'
|
? 'w-full h-7'
|
||||||
: 'w-max h-9 px-1.5'} pointer-events-auto"
|
: 'w-max h-9 px-1.5'} pointer-events-auto"
|
||||||
>
|
>
|
||||||
{#if node.type === 'file' || node.type === 'track'}
|
{#if node.type === 'file' || isPath}
|
||||||
<MetadataDialog bind:open={openEditMetadata} {fileState} {node} />
|
<MetadataDialog bind:open={openEditMetadata} {fileState} {node} />
|
||||||
<StyleDialog bind:open={openEditStyle} {fileState} {node} />
|
<StyleDialog bind:open={openEditStyle} {fileState} {node} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if level === 'file' || level === 'track'}
|
{#if level === 'file' || isPath}
|
||||||
<div
|
<div
|
||||||
class="absolute {orientation === 'vertical'
|
class="absolute {orientation === 'vertical'
|
||||||
? 'top-0 bottom-0 right-0 w-1'
|
? 'top-0 bottom-0 right-0 w-1'
|
||||||
@@ -189,7 +203,9 @@
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#if level === 'segment'}
|
{#if level === 'route'}
|
||||||
|
<Route size="16" class="mx-1 shrink-0" />
|
||||||
|
{:else if level === 'segment'}
|
||||||
<Waypoints size="16" class="mx-1 shrink-0" />
|
<Waypoints size="16" class="mx-1 shrink-0" />
|
||||||
{:else if level === 'waypoint'}
|
{:else if level === 'waypoint'}
|
||||||
{#if symbolKey && symbols[symbolKey].icon}
|
{#if symbolKey && symbols[symbolKey].icon}
|
||||||
@@ -230,7 +246,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</ContextMenu.Trigger>
|
</ContextMenu.Trigger>
|
||||||
<ContextMenu.Content>
|
<ContextMenu.Content>
|
||||||
{#if node.type === 'file' || node.type === 'track'}
|
{#if node.type === 'file' || isPath}
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
disabled={!singleSelection}
|
disabled={!singleSelection}
|
||||||
onclick={() => (editMetadata.current = true)}
|
onclick={() => (editMetadata.current = true)}
|
||||||
@@ -261,6 +277,10 @@
|
|||||||
<Plus size="16" />
|
<Plus size="16" />
|
||||||
{i18n._('menu.new_track')}
|
{i18n._('menu.new_track')}
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
|
<ContextMenu.Item disabled={!singleSelection} onclick={() => engine.newRoute()}>
|
||||||
|
<Plus size="16" />
|
||||||
|
{i18n._('menu.new_route')}
|
||||||
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Separator />
|
<ContextMenu.Separator />
|
||||||
{:else if node.type === 'track'}
|
{:else if node.type === 'track'}
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
@@ -270,6 +290,14 @@
|
|||||||
<Plus size="16" />
|
<Plus size="16" />
|
||||||
{i18n._('menu.new_segment')}
|
{i18n._('menu.new_segment')}
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
|
{/if}
|
||||||
|
{#if isPath || node.type === 'segment'}
|
||||||
|
<ContextMenu.Item onclick={() => engine.convert()}>
|
||||||
|
<ArrowRightLeft size="16" />
|
||||||
|
{i18n._(
|
||||||
|
node.type === 'route' ? 'menu.convert_to_track' : 'menu.convert_to_route'
|
||||||
|
)}
|
||||||
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Separator />
|
<ContextMenu.Separator />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
||||||
import { engine, type FileState } from '$lib/engine';
|
import { engine, type FileState } from '$lib/engine';
|
||||||
import type { FileTreeNode } from '$lib/engine/selection-helpers';
|
import { pathInfo, type FileTreeNode } from '$lib/engine/selection-helpers';
|
||||||
import { showEngineError } from '$lib/actions/file-actions';
|
import { showEngineError } from '$lib/actions/file-actions';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -21,16 +21,13 @@
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let track = $derived(
|
// the route or the track
|
||||||
node.type === 'track'
|
let path = $derived(pathInfo(fileState.structure, node));
|
||||||
? fileState.structure.tracks.find((track) => track.id === node.trackId)
|
|
||||||
: undefined
|
|
||||||
);
|
|
||||||
let name: string = $derived(
|
let name: string = $derived(
|
||||||
node.type === 'file' ? fileState.structure.name : (track?.name ?? '')
|
node.type === 'file' ? fileState.structure.name : (path?.name ?? '')
|
||||||
);
|
);
|
||||||
let description: string = $derived(
|
let description: string = $derived(
|
||||||
node.type === 'file' ? (fileState.structure.desc ?? '') : (track?.desc ?? '')
|
node.type === 'file' ? (fileState.structure.desc ?? '') : (path?.desc ?? '')
|
||||||
);
|
);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
@@ -5,13 +5,22 @@ import { tick } from 'svelte';
|
|||||||
import { engine, type MoveTarget, type Selection } from '$lib/engine';
|
import { engine, type MoveTarget, type Selection } from '$lib/engine';
|
||||||
import { isSelected, type FileTreeNode } from '$lib/engine/selection-helpers';
|
import { isSelected, type FileTreeNode } from '$lib/engine/selection-helpers';
|
||||||
|
|
||||||
/** What a list holds: the files, the tracks of a file, the segments of a track, or the waypoints. */
|
/**
|
||||||
export type ListLevel = 'file' | 'track' | 'segment' | 'waypoints' | 'waypoint';
|
* What a list holds: the files, the routes or the tracks of a file, the segments of a track, or the
|
||||||
|
* waypoints.
|
||||||
|
*/
|
||||||
|
export type ListLevel = 'file' | 'route' | 'track' | 'segment' | 'waypoints' | 'waypoint';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the elements of a list can be moved to (a segment on its own can become a route). Routes
|
||||||
|
* and tracks can be dropped among each other, but keep their kind: the engine puts them with the
|
||||||
|
* others of their kind, the routes coming first.
|
||||||
|
*/
|
||||||
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
|
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
|
||||||
file: ['file'],
|
file: ['file'],
|
||||||
track: ['file', 'track'],
|
route: ['file', 'route', 'track'],
|
||||||
segment: ['file', 'track', 'segment'],
|
track: ['file', 'route', 'track'],
|
||||||
|
segment: ['file', 'route', 'track', 'segment'],
|
||||||
waypoints: ['waypoints'],
|
waypoints: ['waypoints'],
|
||||||
waypoint: ['waypoints', 'waypoint'],
|
waypoint: ['waypoints', 'waypoint'],
|
||||||
};
|
};
|
||||||
@@ -69,10 +78,12 @@ export class SortableFileList {
|
|||||||
setTimeout(() => this.updateToSelection(e), 50),
|
setTimeout(() => this.updateToSelection(e), 50),
|
||||||
onDeselect: (e: Sortable.SortableEvent) =>
|
onDeselect: (e: Sortable.SortableEvent) =>
|
||||||
setTimeout(() => this.updateToSelection(e), 50),
|
setTimeout(() => this.updateToSelection(e), 50),
|
||||||
onStart: () => {
|
onStart: (e: Sortable.SortableEvent) => {
|
||||||
// all the nodes, not only the elements: the comments are the anchors of the Svelte blocks
|
// all the nodes, not only the elements: the comments are the anchors of the Svelte blocks
|
||||||
this._snapshot = Array.from(container.childNodes);
|
this._snapshot = Array.from(container.childNodes);
|
||||||
dragging.set(sortableLevel);
|
dragging.set(sortableLevel);
|
||||||
|
// the files that are dragged (or whose elements are) are open for good
|
||||||
|
engine.keepPreview(this.draggedFiles(e));
|
||||||
},
|
},
|
||||||
onEnd: () => dragging.set(null),
|
onEnd: () => dragging.set(null),
|
||||||
onSort: (e: Sortable.SortableEvent) => this.onSort(e),
|
onSort: (e: Sortable.SortableEvent) => this.onSort(e),
|
||||||
@@ -84,12 +95,25 @@ export class SortableFileList {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The files of what is dragged: the dragged files, or the file of the dragged elements. */
|
||||||
|
private draggedFiles(e: Sortable.SortableEvent): string[] {
|
||||||
|
if (this._parent) {
|
||||||
|
return [this._parent.fileId];
|
||||||
|
}
|
||||||
|
const elements: HTMLElement[] = e.items.length > 0 ? e.items : [e.item];
|
||||||
|
return elements
|
||||||
|
.map((element) => element.getAttribute('data-id'))
|
||||||
|
.filter((id): id is string => id !== null);
|
||||||
|
}
|
||||||
|
|
||||||
/** The node of a child of the list. */
|
/** The node of a child of the list. */
|
||||||
childNode(id: string): FileTreeNode | undefined {
|
childNode(id: string): FileTreeNode | undefined {
|
||||||
const parent = this._parent;
|
const parent = this._parent;
|
||||||
switch (this._sortableLevel) {
|
switch (this._sortableLevel) {
|
||||||
case 'file':
|
case 'file':
|
||||||
return { type: 'file', fileId: id };
|
return { type: 'file', fileId: id };
|
||||||
|
case 'route':
|
||||||
|
return parent ? { type: 'route', fileId: parent.fileId, routeId: id } : undefined;
|
||||||
case 'track':
|
case 'track':
|
||||||
return parent ? { type: 'track', fileId: parent.fileId, trackId: id } : undefined;
|
return parent ? { type: 'track', fileId: parent.fileId, trackId: id } : undefined;
|
||||||
case 'segment':
|
case 'segment':
|
||||||
@@ -116,6 +140,8 @@ export class SortableFileList {
|
|||||||
switch (this._sortableLevel) {
|
switch (this._sortableLevel) {
|
||||||
case 'file':
|
case 'file':
|
||||||
return { type: 'file', fileIds: ids };
|
return { type: 'file', fileIds: ids };
|
||||||
|
case 'route':
|
||||||
|
return parent ? { type: 'route', fileId: parent.fileId, routeIds: ids } : undefined;
|
||||||
case 'track':
|
case 'track':
|
||||||
return parent ? { type: 'track', fileId: parent.fileId, trackIds: ids } : undefined;
|
return parent ? { type: 'track', fileId: parent.fileId, trackIds: ids } : undefined;
|
||||||
case 'segment':
|
case 'segment':
|
||||||
@@ -142,6 +168,8 @@ export class SortableFileList {
|
|||||||
switch (this._sortableLevel) {
|
switch (this._sortableLevel) {
|
||||||
case 'file':
|
case 'file':
|
||||||
return { type: 'files', index };
|
return { type: 'files', index };
|
||||||
|
case 'route':
|
||||||
|
return parent ? { type: 'routes', fileId: parent.fileId, index } : undefined;
|
||||||
case 'track':
|
case 'track':
|
||||||
return parent ? { type: 'tracks', fileId: parent.fileId, index } : undefined;
|
return parent ? { type: 'tracks', fileId: parent.fileId, index } : undefined;
|
||||||
case 'segment':
|
case 'segment':
|
||||||
@@ -262,6 +290,9 @@ export class SortableFileList {
|
|||||||
case 'file':
|
case 'file':
|
||||||
request = engine.select(selected);
|
request = engine.select(selected);
|
||||||
break;
|
break;
|
||||||
|
case 'route':
|
||||||
|
request = engine.selectRoutes(parent!.fileId, selected);
|
||||||
|
break;
|
||||||
case 'track':
|
case 'track':
|
||||||
request = engine.selectTracks(parent!.fileId, selected);
|
request = engine.selectTracks(parent!.fileId, selected);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import { settings } from '$lib/state/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import { engine, type FileState } from '$lib/engine';
|
import { engine, type FileState } from '$lib/engine';
|
||||||
import { normalizeColor } from '$lib/engine/file-colors';
|
import { normalizeColor } from '$lib/engine/file-colors';
|
||||||
import { selectedFileIds, type FileTreeNode } from '$lib/engine/selection-helpers';
|
import { pathInfo, selectedFileIds, type FileTreeNode } from '$lib/engine/selection-helpers';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -38,17 +38,15 @@
|
|||||||
width = $defaultWidth;
|
width = $defaultWidth;
|
||||||
color = fileState.color;
|
color = fileState.color;
|
||||||
|
|
||||||
const { tracks } = fileState.structure;
|
const paths = [...fileState.structure.routes, ...fileState.structure.tracks];
|
||||||
|
const path = pathInfo(fileState.structure, node);
|
||||||
if (node.type === 'file') {
|
if (node.type === 'file') {
|
||||||
opacity = tracks.find((track) => track.opacity !== undefined)?.opacity ?? opacity;
|
opacity = paths.find((path) => path.opacity !== undefined)?.opacity ?? opacity;
|
||||||
width = tracks.find((track) => track.width !== undefined)?.width ?? width;
|
width = paths.find((path) => path.width !== undefined)?.width ?? width;
|
||||||
} else if (node.type === 'track') {
|
} else if (path) {
|
||||||
const track = tracks.find((track) => track.id === node.trackId);
|
color = path.color !== undefined ? normalizeColor(path.color) : color;
|
||||||
if (track) {
|
opacity = path.opacity ?? opacity;
|
||||||
color = track.color !== undefined ? normalizeColor(track.color) : color;
|
width = path.width ?? width;
|
||||||
opacity = track.opacity ?? opacity;
|
|
||||||
width = track.width ?? width;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
colorChanged = false;
|
colorChanged = false;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
map_.queryRenderedFeatures(e.point, {
|
map_.queryRenderedFeatures(e.point, {
|
||||||
layers: map_
|
layers: map_
|
||||||
.getLayersOrder()
|
.getLayersOrder()
|
||||||
.filter((layerId) => layerId.startsWith('routing-controls')),
|
.filter((layerId) => layerId == 'routing-controls'),
|
||||||
}).length
|
}).length
|
||||||
) {
|
) {
|
||||||
// Clicked on routing control, ignoring
|
// Clicked on routing control, ignoring
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import CustomControl from './custom-control';
|
import CustomControl from './custom-control';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
import { onMount, type Snippet } from 'svelte';
|
import { onMount, type Snippet } from 'svelte';
|
||||||
|
import type { Map } from 'maplibre-gl';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
position = 'top-right',
|
position = 'top-right',
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
let control: CustomControl | null = null;
|
let control: CustomControl | null = null;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
map.onLoad((map: maplibregl.Map) => {
|
map.onLoad((map: Map) => {
|
||||||
if (position.includes('right')) container.classList.add('float-right');
|
if (position.includes('right')) container.classList.add('float-right');
|
||||||
else container.classList.add('float-left');
|
else container.classList.add('float-left');
|
||||||
container.classList.remove('hidden');
|
container.classList.remove('hidden');
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { MapPopup } from '$lib/components/map/map-popup';
|
import { MapPopup } from '$lib/components/map/map-popup';
|
||||||
|
import type { Map } from 'maplibre-gl';
|
||||||
|
|
||||||
export let waypointPopup: MapPopup | null = null;
|
export let waypointPopup: MapPopup | null = null;
|
||||||
export let trackpointPopup: MapPopup | null = null;
|
export let trackpointPopup: MapPopup | null = null;
|
||||||
|
|
||||||
export function createPopups(map: maplibregl.Map) {
|
export function createPopups(map: Map) {
|
||||||
removePopups();
|
removePopups();
|
||||||
waypointPopup = new MapPopup(map, {
|
waypointPopup = new MapPopup(map, {
|
||||||
closeButton: false,
|
closeButton: false,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { get, type Readable } from 'svelte/store';
|
import { get, type Readable } from 'svelte/store';
|
||||||
import maplibregl, {
|
import {
|
||||||
type GeoJSONSource,
|
type GeoJSONSource,
|
||||||
type FilterSpecification,
|
type FilterSpecification,
|
||||||
type MapLayerMouseEvent,
|
type MapLayerMouseEvent,
|
||||||
type MapLayerTouchEvent,
|
type MapLayerTouchEvent,
|
||||||
|
Point,
|
||||||
} from 'maplibre-gl';
|
} from 'maplibre-gl';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
import { waypointPopup, trackpointPopup } from './gpx-layer-popup';
|
import { waypointPopup, trackpointPopup } from './gpx-layer-popup';
|
||||||
@@ -61,7 +62,7 @@ export class GPXLayer {
|
|||||||
selected: boolean = false;
|
selected: boolean = false;
|
||||||
currentWaypointData: GeoJSON.FeatureCollection | null = null;
|
currentWaypointData: GeoJSON.FeatureCollection | null = null;
|
||||||
draggedWaypointIndex: number | null = null;
|
draggedWaypointIndex: number | null = null;
|
||||||
draggingStartingPosition: maplibregl.Point = new maplibregl.Point(0, 0);
|
draggingStartingPosition: Point = new Point(0, 0);
|
||||||
unsubscribe: Function[] = [];
|
unsubscribe: Function[] = [];
|
||||||
|
|
||||||
updateBinded: () => void = this.update.bind(this);
|
updateBinded: () => void = this.update.bind(this);
|
||||||
@@ -117,7 +118,11 @@ export class GPXLayer {
|
|||||||
return { type: 'file', fileId: this.fileId };
|
return { type: 'file', fileId: this.fileId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The node of a segment of the map: its route, which has no other, or the segment itself. */
|
||||||
segmentNode(properties: GeoJSON.GeoJsonProperties): FileTreeNode {
|
segmentNode(properties: GeoJSON.GeoJsonProperties): FileTreeNode {
|
||||||
|
if (properties!.routeId !== undefined) {
|
||||||
|
return { type: 'route', fileId: this.fileId, routeId: properties!.routeId };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
type: 'segment',
|
type: 'segment',
|
||||||
fileId: this.fileId,
|
fileId: this.fileId,
|
||||||
@@ -180,7 +185,11 @@ export class GPXLayer {
|
|||||||
const visibleSegmentIds = state.segments.features
|
const visibleSegmentIds = state.segments.features
|
||||||
.filter(
|
.filter(
|
||||||
({ properties }) =>
|
({ properties }) =>
|
||||||
!isSegmentHidden(state, properties.trackId, properties.segmentId)
|
!isSegmentHidden(
|
||||||
|
state,
|
||||||
|
properties.routeId ?? properties.trackId!,
|
||||||
|
properties.segmentId
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.map(({ properties }) => properties.segmentId);
|
.map(({ properties }) => properties.segmentId);
|
||||||
const segmentFilter: FilterSpecification = [
|
const segmentFilter: FilterSpecification = [
|
||||||
@@ -414,7 +423,7 @@ export class GPXLayer {
|
|||||||
|
|
||||||
layerOnClick(e: MapLayerMouseEvent) {
|
layerOnClick(e: MapLayerMouseEvent) {
|
||||||
const properties = e.features![0].properties!;
|
const properties = e.features![0].properties!;
|
||||||
const { trackId, segmentId } = properties;
|
const { routeId, trackId, segmentId } = properties;
|
||||||
const selectionType = this.selectionState.type;
|
const selectionType = this.selectionState.type;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -440,10 +449,15 @@ export class GPXLayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mode = e.originalEvent.ctrlKey || e.originalEvent.metaKey ? 'add' : 'replace';
|
const mode = e.originalEvent.ctrlKey || e.originalEvent.metaKey ? 'add' : 'replace';
|
||||||
const { tracks } = get(this.file).structure;
|
const { routes, tracks } = get(this.file).structure;
|
||||||
const segmentCount = tracks.reduce((count, track) => count + track.segments.length, 0);
|
const segmentCount =
|
||||||
|
routes.length + tracks.reduce((count, track) => count + track.segments.length, 0);
|
||||||
if (get(treeFileView) && segmentCount > 1) {
|
if (get(treeFileView) && segmentCount > 1) {
|
||||||
// Select inner item
|
// Select inner item
|
||||||
|
if (routeId !== undefined) {
|
||||||
|
engine.selectRoutes(this.fileId, [routeId], mode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const track = tracks.find((track) => track.id === trackId);
|
const track = tracks.find((track) => track.id === trackId);
|
||||||
if (track && track.segments.length > 1) {
|
if (track && track.segments.length > 1) {
|
||||||
engine.selectSegments(this.fileId, trackId, [segmentId], mode);
|
engine.selectSegments(this.fileId, trackId, [segmentId], mode);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { Layers } from '@lucide/svelte';
|
import { Layers } from '@lucide/svelte';
|
||||||
import { settings } from '$lib/state/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
|
import type { Map } from 'maplibre-gl';
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let overpassLayer: OverpassLayer;
|
let overpassLayer: OverpassLayer;
|
||||||
@@ -21,7 +22,7 @@
|
|||||||
selectedOverpassTree,
|
selectedOverpassTree,
|
||||||
} = settings;
|
} = settings;
|
||||||
|
|
||||||
map.onLoad((_map: maplibregl.Map) => {
|
map.onLoad((_map: Map) => {
|
||||||
if (overpassLayer) {
|
if (overpassLayer) {
|
||||||
overpassLayer.remove();
|
overpassLayer.remove();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
poi: PopupItem<any>;
|
poi: PopupItem<any>;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let tags: Record<string, string> = $derived(poi ? JSON.parse(poi.item.tags) : {});
|
let tags: Record<string, string> = $derived(
|
||||||
|
poi ? (typeof poi.item.tags == 'object' ? poi.item.tags : JSON.parse(poi.item.tags)) : {}
|
||||||
|
);
|
||||||
let name = $derived.by(() => {
|
let name = $derived.by(() => {
|
||||||
if (poi) {
|
if (poi) {
|
||||||
if (tags.name !== undefined && tags.name !== '') {
|
if (tags.name !== undefined && tags.name !== '') {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { get, writable } from 'svelte/store';
|
|||||||
import { overpassQueryData } from '$lib/data/layers';
|
import { overpassQueryData } from '$lib/data/layers';
|
||||||
import { MapPopup } from '$lib/components/map/map-popup';
|
import { MapPopup } from '$lib/components/map/map-popup';
|
||||||
import { settings } from '$lib/state/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import type { GeoJSONSource } from 'maplibre-gl';
|
import type { GeoJSONSource, Map as MapLibreMap } from 'maplibre-gl';
|
||||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||||
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
||||||
import { loadSVGIcon } from '$lib/utils';
|
import { loadSVGIcon } from '$lib/utils';
|
||||||
@@ -23,7 +23,7 @@ export class OverpassLayer {
|
|||||||
overpassUrl = 'https://overpass.gpx.studio/api/interpreter';
|
overpassUrl = 'https://overpass.gpx.studio/api/interpreter';
|
||||||
minZoom = 12;
|
minZoom = 12;
|
||||||
queryZoom = 12;
|
queryZoom = 12;
|
||||||
map: maplibregl.Map;
|
map: MapLibreMap;
|
||||||
layerEventManager: MapLayerEventManager;
|
layerEventManager: MapLayerEventManager;
|
||||||
popup: MapPopup;
|
popup: MapPopup;
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ export class OverpassLayer {
|
|||||||
updateBinded = this.update.bind(this);
|
updateBinded = this.update.bind(this);
|
||||||
onHoverBinded = this.onHover.bind(this);
|
onHoverBinded = this.onHover.bind(this);
|
||||||
|
|
||||||
constructor(map: maplibregl.Map, layerEventManager: MapLayerEventManager) {
|
constructor(map: MapLibreMap, layerEventManager: MapLayerEventManager) {
|
||||||
this.map = map;
|
this.map = map;
|
||||||
this.layerEventManager = layerEventManager;
|
this.layerEventManager = layerEventManager;
|
||||||
this.popup = new MapPopup(map, {
|
this.popup = new MapPopup(map, {
|
||||||
|
|||||||
Loaded 100 of 203 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user