routes continued

This commit is contained in:
vcoppe committed 2026-10-28 18:14:08 +01:00
1 parent 603b73b99d
commit 68eacc76d1
20 files changed
+307 -85

No files matched your search

+55 -20
View File
@@ -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>,
+40
View File
@@ -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)
);
}
+14 -1
View File
@@ -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';
+1 -1
View File
@@ -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.
+7 -1
View 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.
+26 -2
View File
@@ -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.
+4
View File
@@ -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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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>
+3 -3
View File
@@ -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>
+1 -1
View File
@@ -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." />
-49
View File
@@ -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();
};
});
+2 -1
View File
@@ -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
);
+2
View File
@@ -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...",