mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-28 18:47:18 +00:00
routes continued
This commit is contained in:
1 parent
603b73b99d
commit
68eacc76d1
20 files changed
+307
-85
No files matched your search
@@ -9,10 +9,12 @@ use crate::{Apply, CommandError, FileId, Route, Selection, State, Track};
|
|||||||
/// after the routes;
|
/// after the routes;
|
||||||
/// - a track becomes one route per segment, as a route has a single segment, and goes away. A
|
/// - 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
|
/// 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.
|
/// left. The routes have the information of their track, and go after the other routes;
|
||||||
|
/// - a file with routes only has all of them converted to tracks, and a file with tracks only
|
||||||
|
/// has all of them converted to routes. The files with both, or neither, stay as they are.
|
||||||
///
|
///
|
||||||
/// The segments keep their ids, the new routes and tracks are selected. There is nothing to do
|
/// The segments keep their ids, the new routes and tracks are selected (the files stay selected).
|
||||||
/// for the other selections, or for tracks without segments.
|
/// There is nothing to do for the other selections, or for tracks without segments.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Convert;
|
pub struct Convert;
|
||||||
|
|
||||||
@@ -31,14 +33,7 @@ impl Apply for Convert {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.partition::<Vec<_>, _>(|rte| rte_ids.contains(&rte.id));
|
.partition::<Vec<_>, _>(|rte| rte_ids.contains(&rte.id));
|
||||||
file.rte = kept;
|
file.rte = kept;
|
||||||
let tracks: Vec<Track> = converted
|
let tracks = to_tracks(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();
|
let trk_ids: HashSet<_> = tracks.iter().map(|trk| trk.id).collect();
|
||||||
file.trk.splice(0..0, tracks);
|
file.trk.splice(0..0, tracks);
|
||||||
Selection::Track {
|
Selection::Track {
|
||||||
@@ -57,14 +52,7 @@ impl Apply for Convert {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.partition::<Vec<_>, _>(|trk| trk_ids.contains(&trk.id));
|
.partition::<Vec<_>, _>(|trk| trk_ids.contains(&trk.id));
|
||||||
file.trk = kept;
|
file.trk = kept;
|
||||||
let routes = converted.into_iter().flat_map(|trk| {
|
add_routes(&mut file.rte, *file_id, to_routes(converted))
|
||||||
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 {
|
Selection::TrackSegment {
|
||||||
file_id,
|
file_id,
|
||||||
@@ -94,8 +82,32 @@ impl Apply for Convert {
|
|||||||
});
|
});
|
||||||
add_routes(&mut file.rte, *file_id, routes)
|
add_routes(&mut file.rte, *file_id, routes)
|
||||||
}
|
}
|
||||||
|
Selection::File { file_ids } => {
|
||||||
|
let mut converted = false;
|
||||||
|
for file_id in file_ids {
|
||||||
|
let Some(file) = state.files.get_mut(file_id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !file.rte.is_empty() && file.trk.is_empty() {
|
||||||
|
let file = Rc::make_mut(file);
|
||||||
|
file.trk = to_tracks(std::mem::take(&mut file.rte));
|
||||||
|
} else if file.rte.is_empty()
|
||||||
|
&& file.trk.iter().any(|trk| !trk.trkseg.is_empty())
|
||||||
|
{
|
||||||
|
let file = Rc::make_mut(file);
|
||||||
|
file.rte = to_routes(std::mem::take(&mut file.trk)).collect();
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
converted = true;
|
||||||
|
}
|
||||||
|
return if converted {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
};
|
||||||
|
}
|
||||||
Selection::Empty
|
Selection::Empty
|
||||||
| Selection::File { .. }
|
|
||||||
| Selection::Waypoints { .. }
|
| Selection::Waypoints { .. }
|
||||||
| Selection::Waypoint { .. } => return Err(CommandError::NothingToDo),
|
| Selection::Waypoint { .. } => return Err(CommandError::NothingToDo),
|
||||||
};
|
};
|
||||||
@@ -113,6 +125,29 @@ impl Apply for Convert {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The tracks of the routes, each with the segment of its route.
|
||||||
|
fn to_tracks(routes: Vec<Route>) -> Vec<Track> {
|
||||||
|
routes
|
||||||
|
.into_iter()
|
||||||
|
.map(|rte| Track {
|
||||||
|
id: Default::default(),
|
||||||
|
info: rte.info,
|
||||||
|
trkseg: vec![rte.seg],
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The routes of the tracks, one per segment, each with the information of its track.
|
||||||
|
fn to_routes(tracks: Vec<Track>) -> impl Iterator<Item = Route> {
|
||||||
|
tracks.into_iter().flat_map(|trk| {
|
||||||
|
trk.trkseg.into_iter().map(move |seg| Route {
|
||||||
|
id: Default::default(),
|
||||||
|
info: trk.info.clone(),
|
||||||
|
seg,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Adds the routes after the other routes, and gives the selection of them.
|
/// Adds the routes after the other routes, and gives the selection of them.
|
||||||
fn add_routes(
|
fn add_routes(
|
||||||
rte: &mut Vec<Route>,
|
rte: &mut Vec<Route>,
|
||||||
|
|||||||
@@ -682,3 +682,43 @@ fn test_convert_segments_to_routes_keeps_their_track() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_a_file_with_routes_or_tracks_only() {
|
||||||
|
let (mut fx, id) = fixture();
|
||||||
|
let before = file(&fx, id).clone();
|
||||||
|
let only_tracks = File {
|
||||||
|
trk: before.trk.clone(),
|
||||||
|
..File::default()
|
||||||
|
};
|
||||||
|
let only_tracks_id = only_tracks.id;
|
||||||
|
fx.files.insert(only_tracks_id, Rc::new(only_tracks));
|
||||||
|
fx.order.0.push(only_tracks_id);
|
||||||
|
|
||||||
|
// the file with both stays as it is, the other gets one route per segment
|
||||||
|
fx.selection = Selection::File {
|
||||||
|
file_ids: [id, only_tracks_id].into(),
|
||||||
|
};
|
||||||
|
Convert.apply(&mut fx.state()).unwrap();
|
||||||
|
assert_eq!(lengths(file(&fx, id)), lengths(&before));
|
||||||
|
let routes = file(&fx, only_tracks_id);
|
||||||
|
assert_eq!((routes.rte.len(), routes.trk.len()), (4, 0));
|
||||||
|
assert_eq!(routes.rte[0].seg.id, before.trk[0].trkseg[0].id);
|
||||||
|
assert_eq!(routes.rte[0].info, before.trk[0].info);
|
||||||
|
|
||||||
|
// and back: one track per route
|
||||||
|
fx.selection = Selection::File {
|
||||||
|
file_ids: [only_tracks_id].into(),
|
||||||
|
};
|
||||||
|
Convert.apply(&mut fx.state()).unwrap();
|
||||||
|
let tracks = file(&fx, only_tracks_id);
|
||||||
|
assert_eq!((tracks.rte.len(), tracks.trk.len()), (0, 4));
|
||||||
|
assert_eq!(tracks.trk[0].trkseg[0].id, before.trk[0].trkseg[0].id);
|
||||||
|
|
||||||
|
// nothing to do for a file with both
|
||||||
|
fx.selection = whole(id);
|
||||||
|
assert_eq!(
|
||||||
|
Convert.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
} from '$lib/actions/file-actions';
|
} from '$lib/actions/file-actions';
|
||||||
import UnsavedDialog from '$lib/components/file-list/UnsavedDialog.svelte';
|
import UnsavedDialog from '$lib/components/file-list/UnsavedDialog.svelte';
|
||||||
import { engine } from '$lib/engine';
|
import { engine } from '$lib/engine';
|
||||||
import { allHidden } from '$lib/engine/all-hidden';
|
import { allHidden, selectedFilesConversion } from '$lib/engine/selection-stores';
|
||||||
import { selectedFileIds, selectionSize } from '$lib/engine/selection-helpers';
|
import { selectedFileIds, selectionSize } from '$lib/engine/selection-helpers';
|
||||||
import { boundsManager } from '$lib/state/bounds';
|
import { boundsManager } from '$lib/state/bounds';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
@@ -334,6 +334,19 @@
|
|||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if $selectedFilesConversion}
|
||||||
|
{#if !$treeFileView}
|
||||||
|
<Menubar.Separator />
|
||||||
|
{/if}
|
||||||
|
<Menubar.Item onclick={() => engine.convert()}>
|
||||||
|
<ArrowRightLeft size="16" />
|
||||||
|
{i18n._(
|
||||||
|
$selectedFilesConversion === 'route'
|
||||||
|
? 'menu.convert_to_routes'
|
||||||
|
: 'menu.convert_to_tracks'
|
||||||
|
)}
|
||||||
|
</Menubar.Item>
|
||||||
|
{/if}
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item onclick={() => engine.selectAll()} disabled={noFiles}>
|
<Menubar.Item onclick={() => engine.selectAll()} disabled={noFiles}>
|
||||||
<FileStack size="16" />
|
<FileStack size="16" />
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
||||||
import { getSymbolKey, symbols } from '$lib/data/symbols';
|
import { getSymbolKey, symbols } from '$lib/data/symbols';
|
||||||
import { engine, type FileState } from '$lib/engine';
|
import { engine, type FileState } from '$lib/engine';
|
||||||
import { allHidden } from '$lib/engine/all-hidden';
|
import { allHidden, selectedFilesConversion } from '$lib/engine/selection-stores';
|
||||||
import { normalizeColor } from '$lib/engine/file-colors';
|
import { normalizeColor } from '$lib/engine/file-colors';
|
||||||
import { isHidden } from '$lib/engine/file-visibility';
|
import { isHidden } from '$lib/engine/file-visibility';
|
||||||
import {
|
import {
|
||||||
@@ -271,7 +271,6 @@
|
|||||||
<Plus size="16" />
|
<Plus size="16" />
|
||||||
{i18n._('menu.new_route')}
|
{i18n._('menu.new_route')}
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Separator />
|
|
||||||
{:else if node.type === 'track'}
|
{:else if node.type === 'track'}
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
disabled={!singleSelection}
|
disabled={!singleSelection}
|
||||||
@@ -291,6 +290,21 @@
|
|||||||
<ContextMenu.Separator />
|
<ContextMenu.Separator />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if node.type === 'file'}
|
||||||
|
{#if $selectedFilesConversion}
|
||||||
|
<ContextMenu.Item onclick={() => engine.convert()}>
|
||||||
|
<ArrowRightLeft size="16" />
|
||||||
|
{i18n._(
|
||||||
|
$selectedFilesConversion === 'route'
|
||||||
|
? 'menu.convert_to_routes'
|
||||||
|
: 'menu.convert_to_tracks'
|
||||||
|
)}
|
||||||
|
</ContextMenu.Item>
|
||||||
|
{/if}
|
||||||
|
{#if orientation === 'vertical' || $selectedFilesConversion}
|
||||||
|
<ContextMenu.Separator />
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
<ContextMenu.Item onclick={() => engine.setSelectionHidden(!$allHidden)}>
|
<ContextMenu.Item onclick={() => engine.setSelectionHidden(!$allHidden)}>
|
||||||
{#if $allHidden}
|
{#if $allHidden}
|
||||||
<Eye size="16" />
|
<Eye size="16" />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { engine } from '$lib/engine';
|
|||||||
import { getConvertedDistanceToKilometers } from '$lib/units/units';
|
import { getConvertedDistanceToKilometers } from '$lib/units/units';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
import { allHidden } from '$lib/engine/all-hidden';
|
import { allHidden } from '$lib/engine/selection-stores';
|
||||||
import type { GeoJSONSource } from 'maplibre-gl';
|
import type { GeoJSONSource } from 'maplibre-gl';
|
||||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
|||||||
import type { GeoJSONSource } from 'maplibre-gl';
|
import type { GeoJSONSource } from 'maplibre-gl';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
import { allHidden } from '$lib/engine/all-hidden';
|
import { allHidden } from '$lib/engine/selection-stores';
|
||||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||||
import { loadSVGIcon } from '$lib/utils';
|
import { loadSVGIcon } from '$lib/utils';
|
||||||
import { hoveredPoint, slicedStatistics } from '$lib/state/selection-statistics';
|
import { hoveredPoint, slicedStatistics } from '$lib/state/selection-statistics';
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ By right-clicking on a file tab, you can access the same actions as in the [edit
|
|||||||
|
|
||||||
As mentioned in the [view options section](./menu/view), you can switch to a tree layout for the files list.
|
As mentioned in the [view options section](./menu/view), you can switch to a tree layout for the files list.
|
||||||
This layout is ideal for managing a large number of open files, as it organizes them into a vertical list on the right side of the map.
|
This layout is ideal for managing a large number of open files, as it organizes them into a vertical list on the right side of the map.
|
||||||
In addition, the file tree view enables you to inspect the [tracks, segments, and points of interest](./gpx) contained inside the files through collapsible sections.
|
In addition, the file tree view enables you to inspect the [tracks, segments, routes, and points of interest](./gpx) contained inside the files through collapsible sections.
|
||||||
|
|
||||||
You can also apply [edit actions](./menu/edit) and [tools](./toolbar) to internal file items.
|
You can also apply [edit actions](./menu/edit) and [tools](./toolbar) to internal file items.
|
||||||
Furthermore, you can drag and drop the inner items to reorder them, or move them in the hierarchy or even to another file.
|
Furthermore, you can drag and drop the inner items to reorder them, or move them in the hierarchy or even to another file.
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ title: GPX file format
|
|||||||
---
|
---
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { Waypoints, MapPin } from '@lucide/svelte';
|
import { Waypoints, Route, MapPin } from '@lucide/svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
# { title }
|
# { title }
|
||||||
|
|
||||||
The <a href="https://www.topografix.com/gpx.asp" target="_blank">GPX file format</a> is an open standard for exchanging GPS data between applications and GPS devices.
|
The <a href="https://www.topografix.com/gpx.asp" target="_blank">GPX file format</a> is an open standard for exchanging GPS data between applications and GPS devices.
|
||||||
It essentially consists of a series of GPS points encoding one or multiple GPS traces, and, optionally, some points of interest.
|
It essentially consists of a series of GPS points encoding one or multiple GPS traces, and, optionally, some points of interest.
|
||||||
|
These traces can be stored either as tracks or as routes.
|
||||||
|
|
||||||
GPX files may also contain metadata, of which the **name** and **description** fields are the most useful for users.
|
GPX files may also contain metadata, of which the **name** and **description** fields are the most useful for users.
|
||||||
|
|
||||||
@@ -27,6 +28,11 @@ Some devices also store additional information such as heart rate, cadence, temp
|
|||||||
In most cases, GPX files contain a single track with a single segment.
|
In most cases, GPX files contain a single track with a single segment.
|
||||||
However, the hierarchy described above allows for more advanced use cases, such as planning multi-day trips with several variants for each day.
|
However, the hierarchy described above allows for more advanced use cases, such as planning multi-day trips with several variants for each day.
|
||||||
|
|
||||||
|
### <Route size="16" class="inline-block" style="margin-bottom: 2px" /> Routes
|
||||||
|
|
||||||
|
A **route** is a single continuous path described by a sequence of **route points**, which are the places to go through, like the anchor points of the [routing tool](./toolbar/routing).
|
||||||
|
Like a track, it can have a **name**, a **description**, and **appearance properties**.
|
||||||
|
|
||||||
### <MapPin size="16" class="inline-block" style="margin-bottom: 2px" /> Points of interest
|
### <MapPin size="16" class="inline-block" style="margin-bottom: 2px" /> Points of interest
|
||||||
|
|
||||||
**Points of interest** (technically called *waypoints*) represent locations of interest to show either on a GPS device or on a digital map.
|
**Points of interest** (technically called *waypoints*) represent locations of interest to show either on a GPS device or on a digital map.
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ title: Edit actions
|
|||||||
---
|
---
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Undo2, Redo2, Info, PaintBucket, FileStack, ClipboardCopy, Scissors, ClipboardPaste, Trash2, Plus } from '@lucide/svelte';
|
import { Undo2, Redo2, Info, PaintBucket, ArrowRightLeft, FileStack, ClipboardCopy, Scissors, ClipboardPaste, Trash2, Plus } from '@lucide/svelte';
|
||||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
# { title }
|
# { title }
|
||||||
|
|
||||||
Unlike the file actions, the edit actions can potentially modify the content of the currently selected files.
|
Unlike the file actions, the edit actions can potentially modify the content of the currently selected files.
|
||||||
Moreover, when the tree layout of the files list is enabled (see [Files and statistics](../files-and-stats)), they can also be applied to [tracks, segments, and points of interest](../gpx).
|
Moreover, when the tree layout of the files list is enabled (see [Files and statistics](../files-and-stats)), they can also be applied to [tracks, segments, routes, and points of interest](../gpx).
|
||||||
Therefore, we will refer to the elements that can be modified by these actions as *file items*.
|
Therefore, we will refer to the elements that can be modified by these actions as *file items*.
|
||||||
Note that except for the undo and redo actions, the edit actions are also accessible through the context menu (right-click) of the file items.
|
Note that except for the undo and redo actions, the edit actions are also accessible through the context menu (right-click) of the file items.
|
||||||
|
|
||||||
@@ -38,6 +38,17 @@ Additionally, the selection must be a single file.
|
|||||||
|
|
||||||
</DocsNote>
|
</DocsNote>
|
||||||
|
|
||||||
|
### <Plus size="16" class="inline-block" style="margin-bottom: 2px" /> New route
|
||||||
|
|
||||||
|
Create a new route in the selected file.
|
||||||
|
|
||||||
|
<DocsNote>
|
||||||
|
|
||||||
|
This action is only available when the tree layout of the files list is enabled.
|
||||||
|
Additionally, the selection must be a single file.
|
||||||
|
|
||||||
|
</DocsNote>
|
||||||
|
|
||||||
### <Plus size="16" class="inline-block" style="margin-bottom: 2px" /> New segment
|
### <Plus size="16" class="inline-block" style="margin-bottom: 2px" /> New segment
|
||||||
|
|
||||||
Create a new segment in the selected track.
|
Create a new segment in the selected track.
|
||||||
@@ -49,6 +60,19 @@ Additionally, the selection must be a single track.
|
|||||||
|
|
||||||
</DocsNote>
|
</DocsNote>
|
||||||
|
|
||||||
|
### <ArrowRightLeft size="16" class="inline-block" style="margin-bottom: 2px" /> Convert to route/track
|
||||||
|
|
||||||
|
Convert the selected routes to tracks, or the selected tracks or segments to routes.
|
||||||
|
Since a route has a single segment, a track with multiple segments is converted into one route per segment.
|
||||||
|
|
||||||
|
When the selected files contain only routes, or only tracks, you can also convert all of them at once.
|
||||||
|
|
||||||
|
<DocsNote>
|
||||||
|
|
||||||
|
Except for whole files, this action is only available when the tree layout of the files list is enabled.
|
||||||
|
|
||||||
|
</DocsNote>
|
||||||
|
|
||||||
### <FileStack size="16" class="inline-block" style="margin-bottom: 2px" /> Select all
|
### <FileStack size="16" class="inline-block" style="margin-bottom: 2px" /> Select all
|
||||||
|
|
||||||
Add all file items in the current hierarchy level to the selection.
|
Add all file items in the current hierarchy level to the selection.
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ The file actions menu contains a set of pretty self-explanatory file operations.
|
|||||||
|
|
||||||
Create a new empty file.
|
Create a new empty file.
|
||||||
|
|
||||||
|
### <Plus size="16" class="inline-block" style="margin-bottom: 2px" /> New route file
|
||||||
|
|
||||||
|
Create a new file containing an empty [route](../gpx).
|
||||||
|
|
||||||
### <FolderOpen size="16" class="inline-block" style="margin-bottom: 2px" /> Open...
|
### <FolderOpen size="16" class="inline-block" style="margin-bottom: 2px" /> Open...
|
||||||
|
|
||||||
Open files from your computer.
|
Open files from your computer.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Hide the elevation profile to make room for the map, or show it to inspect the c
|
|||||||
|
|
||||||
Toggle the tree layout for the [file list](../files-and-stats).
|
Toggle the tree layout for the [file list](../files-and-stats).
|
||||||
This layout is ideal for managing a large number of open files, as it organizes them into a vertical list on the right side of the map.
|
This layout is ideal for managing a large number of open files, as it organizes them into a vertical list on the right side of the map.
|
||||||
In addition, the file tree view enables you to inspect the [tracks, segments, and points of interest](../gpx) contained inside the files through collapsible sections.
|
In addition, the file tree view enables you to inspect the [tracks, segments, routes, and points of interest](../gpx) contained inside the files through collapsible sections.
|
||||||
|
|
||||||
### <Map size="16" class="inline-block" style="margin-bottom: 2px" /> Switch to previous basemap
|
### <Map size="16" class="inline-block" style="margin-bottom: 2px" /> Switch to previous basemap
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ This tool allows you to extract [tracks (or segments)](../gpx) from files (or tr
|
|||||||
<Extract class="text-foreground p-3 border rounded-md shadow-lg" />
|
<Extract class="text-foreground p-3 border rounded-md shadow-lg" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Applying the tool to a file containing multiple tracks will create a new file for each of the tracks it contains.
|
Applying the tool to a file containing multiple [tracks or routes](../gpx) will create a new file for each of the tracks and routes it contains.
|
||||||
Similarly, applying the tool to a track containing multiple segments will create (in the same file) a new track for each of the segments it contains.
|
Similarly, applying the tool to a track containing multiple segments will create (in the same file) a new track for each of the segments it contains.
|
||||||
|
|
||||||
<DocsNote>
|
<DocsNote>
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ title: Merge
|
|||||||
|
|
||||||
# <Group size="24" class="inline-block" style="margin-bottom: 5px" /> { title }
|
# <Group size="24" class="inline-block" style="margin-bottom: 5px" /> { title }
|
||||||
|
|
||||||
To use this tool, you need to [select](../files-and-stats) multiple files, [tracks, or segments](../gpx).
|
To use this tool, you need to [select](../files-and-stats) multiple files, [tracks, segments or routes](../gpx).
|
||||||
|
|
||||||
- If your goal is to create a single continuous trace from your selection, use the **Connect the traces** option and validate.
|
- If your goal is to create a single continuous trace from your selection, use the **Connect the traces** option and validate.
|
||||||
- The second option can be used to create or manage files with multiple [tracks or segments](../gpx).
|
- The second option can be used to create or manage files with multiple [tracks, segments or routes](../gpx).
|
||||||
Merging files (or tracks) will result in a single file (or track) containing all tracks (or segments) from the selection.
|
Merging files (or tracks) will result in a single file (or track) containing all tracks and routes (or segments) from the selection.
|
||||||
|
|
||||||
<DocsNote>
|
<DocsNote>
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,6 @@ To split the selected trace into two parts, click on one of the split markers di
|
|||||||
To split at a specific point of your choice, hover over the trace on the map.
|
To split at a specific point of your choice, hover over the trace on the map.
|
||||||
Scissors will appear at the cursor position, showing that you can split the trace at that point.
|
Scissors will appear at the cursor position, showing that you can split the trace at that point.
|
||||||
|
|
||||||
You can choose to split the trace into two GPX files, or to keep the split parts in the same file as [tracks or segments](../gpx).
|
You can choose to split the trace into two GPX files, or to keep the split parts in the same file as [tracks, segments or routes](../gpx).
|
||||||
|
|
||||||
<DocsImage src="tools/split" alt="Hovering over the selected trace turns your cursor into scissors." />
|
<DocsImage src="tools/split" alt="Hovering over the selected trace turns your cursor into scissors." />
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { readable } from 'svelte/store';
|
|
||||||
import { engine, FileStateCollectionObserver, type FileState, type Selection } from '$lib/engine';
|
|
||||||
import { isSelectionHidden } from '$lib/engine/selection-helpers';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether all the selected elements are hidden. It follows the selection and, through the
|
|
||||||
* observer of the files, the state of every file (hiding, editing).
|
|
||||||
*/
|
|
||||||
export const allHidden = readable(false, (set) => {
|
|
||||||
let selection: Selection = { type: 'empty' };
|
|
||||||
const states = new Map<string, FileState>();
|
|
||||||
const unsubscribes = new Map<string, () => void>();
|
|
||||||
|
|
||||||
const update = () => set(isSelectionHidden(selection, states));
|
|
||||||
|
|
||||||
const unsubscribeSelection = engine.selection.subscribe(($selection) => {
|
|
||||||
selection = $selection;
|
|
||||||
update();
|
|
||||||
});
|
|
||||||
const observer = new FileStateCollectionObserver(
|
|
||||||
(files) => {
|
|
||||||
files.forEach((store, fileId) => {
|
|
||||||
unsubscribes.set(
|
|
||||||
fileId,
|
|
||||||
store.subscribe(($file) => {
|
|
||||||
states.set(fileId, $file);
|
|
||||||
update();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
(fileId) => {
|
|
||||||
unsubscribes.get(fileId)?.();
|
|
||||||
unsubscribes.delete(fileId);
|
|
||||||
states.delete(fileId);
|
|
||||||
update();
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
|
||||||
unsubscribes.clear();
|
|
||||||
states.clear();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribeSelection();
|
|
||||||
observer.destroy();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -374,7 +374,8 @@ class Engine {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts the selected routes to tracks, or the selected tracks or segments to routes (one
|
* Converts the selected routes to tracks, or the selected tracks or segments to routes (one
|
||||||
* per segment), which are selected. Resolves to `false` when there is nothing to convert.
|
* per segment), which are selected. Selected files with routes only (or tracks only) have all
|
||||||
|
* of them converted. Resolves to `false` when there is nothing to convert.
|
||||||
*/
|
*/
|
||||||
convert() {
|
convert() {
|
||||||
return this.run((b) => b.convert());
|
return this.run((b) => b.convert());
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { VisibilityState } from '$lib/engine/file-visibility';
|
|||||||
import {
|
import {
|
||||||
coversSegments,
|
coversSegments,
|
||||||
elementId,
|
elementId,
|
||||||
|
fileConversion,
|
||||||
hasSelectionWithin,
|
hasSelectionWithin,
|
||||||
isCovered,
|
isCovered,
|
||||||
isInClipboard,
|
isInClipboard,
|
||||||
@@ -274,4 +275,41 @@ describe('selection helpers', () => {
|
|||||||
assert.equal(isSelectionHidden(files('F1', 'F2'), states('F1')), false);
|
assert.equal(isSelectionHidden(files('F1', 'F2'), states('F1')), false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('fileConversion', () => {
|
||||||
|
const segment = (id: string) => ({ id, rev: id, length: 2 });
|
||||||
|
const structure = (routes: string[], tracks: string[]): { structure: FileStructure } => ({
|
||||||
|
structure: {
|
||||||
|
id: 'F',
|
||||||
|
name: 'file',
|
||||||
|
waypointsRev: 'rev',
|
||||||
|
waypoints: [],
|
||||||
|
routes: routes.map((id) => ({ id, segment: segment(`${id}-S`) })),
|
||||||
|
tracks: tracks.map((id) => ({ id, segments: [segment(`${id}-S`)] })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const states = new Map([
|
||||||
|
['routes', structure(['R1', 'R2'], [])],
|
||||||
|
['tracks', structure([], ['T1'])],
|
||||||
|
['both', structure(['R1'], ['T1'])],
|
||||||
|
['none', structure([], [])],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('converts files with tracks only to routes, and routes only to tracks', () => {
|
||||||
|
assert.equal(fileConversion(files('tracks'), states), 'route');
|
||||||
|
assert.equal(fileConversion(files('routes'), states), 'track');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not convert files with both, neither, or a mix of the two', () => {
|
||||||
|
assert.equal(fileConversion(files('both'), states), undefined);
|
||||||
|
assert.equal(fileConversion(files('none'), states), undefined);
|
||||||
|
assert.equal(fileConversion(files('routes', 'tracks'), states), undefined);
|
||||||
|
assert.equal(fileConversion(files('unknown'), states), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only converts files', () => {
|
||||||
|
assert.equal(fileConversion(empty, states), undefined);
|
||||||
|
assert.equal(fileConversion(tracks('T1'), states), undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -247,3 +247,34 @@ export function isSelectionHidden(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the selected files convert to: `'route'` when they all have tracks only (with segments),
|
||||||
|
* `'track'` when they all have routes only, `undefined` otherwise (other selections, files with
|
||||||
|
* both or neither, or a mix of the two). `states` holds the files by id.
|
||||||
|
*/
|
||||||
|
export function fileConversion(
|
||||||
|
selection: Selection,
|
||||||
|
states: ReadonlyMap<string, { structure: FileStructure }>
|
||||||
|
): 'route' | 'track' | undefined {
|
||||||
|
if (selection.type !== 'file' || selection.fileIds.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const targets = new Set(
|
||||||
|
selection.fileIds.map((fileId) => {
|
||||||
|
const structure = states.get(fileId)?.structure;
|
||||||
|
if (structure === undefined) {
|
||||||
|
return undefined;
|
||||||
|
} else if (structure.routes.length > 0 && structure.tracks.length === 0) {
|
||||||
|
return 'track' as const;
|
||||||
|
} else if (
|
||||||
|
structure.routes.length === 0 &&
|
||||||
|
structure.tracks.some((track) => track.segments.length > 0)
|
||||||
|
) {
|
||||||
|
return 'route' as const;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return targets.size === 1 ? targets.values().next().value : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { readable, type Readable } from 'svelte/store';
|
||||||
|
import { engine, FileStateCollectionObserver, type FileState, type Selection } from '$lib/engine';
|
||||||
|
import { fileConversion, isSelectionHidden } from '$lib/engine/selection-helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A value computed from the selection and the states of the files. It follows the selection and,
|
||||||
|
* through the observer of the files, the state of every file (hiding, editing).
|
||||||
|
*/
|
||||||
|
function fromSelectionAndFiles<T>(
|
||||||
|
initial: T,
|
||||||
|
compute: (selection: Selection, states: ReadonlyMap<string, FileState>) => T
|
||||||
|
): Readable<T> {
|
||||||
|
return readable(initial, (set) => {
|
||||||
|
let selection: Selection = { type: 'empty' };
|
||||||
|
const states = new Map<string, FileState>();
|
||||||
|
const unsubscribes = new Map<string, () => void>();
|
||||||
|
|
||||||
|
const update = () => set(compute(selection, states));
|
||||||
|
|
||||||
|
const unsubscribeSelection = engine.selection.subscribe(($selection) => {
|
||||||
|
selection = $selection;
|
||||||
|
update();
|
||||||
|
});
|
||||||
|
const observer = new FileStateCollectionObserver(
|
||||||
|
(files) => {
|
||||||
|
files.forEach((store, fileId) => {
|
||||||
|
unsubscribes.set(
|
||||||
|
fileId,
|
||||||
|
store.subscribe(($file) => {
|
||||||
|
states.set(fileId, $file);
|
||||||
|
update();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(fileId) => {
|
||||||
|
unsubscribes.get(fileId)?.();
|
||||||
|
unsubscribes.delete(fileId);
|
||||||
|
states.delete(fileId);
|
||||||
|
update();
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||||
|
unsubscribes.clear();
|
||||||
|
states.clear();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribeSelection();
|
||||||
|
observer.destroy();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether all the selected elements are hidden. */
|
||||||
|
export const allHidden = fromSelectionAndFiles(false, isSelectionHidden);
|
||||||
|
|
||||||
|
/** What the selected files convert to, see `fileConversion`. */
|
||||||
|
export const selectedFilesConversion = fromSelectionAndFiles<'route' | 'track' | undefined>(
|
||||||
|
undefined,
|
||||||
|
fileConversion
|
||||||
|
);
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
"new_route": "New route",
|
"new_route": "New route",
|
||||||
"convert_to_route": "Convert to route",
|
"convert_to_route": "Convert to route",
|
||||||
"convert_to_track": "Convert to track",
|
"convert_to_track": "Convert to track",
|
||||||
|
"convert_to_routes": "Convert to route file",
|
||||||
|
"convert_to_tracks": "Convert to track file",
|
||||||
"new_segment": "New segment",
|
"new_segment": "New segment",
|
||||||
"open": "Open...",
|
"open": "Open...",
|
||||||
"open_folder": "Open folder...",
|
"open_folder": "Open folder...",
|
||||||
|
|||||||
Reference in new issue
Block a user