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;
|
||||
/// - 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.
|
||||
/// 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
|
||||
/// for the other selections, or for tracks without segments.
|
||||
/// The segments keep their ids, the new routes and tracks are selected (the files stay selected).
|
||||
/// There is nothing to do for the other selections, or for tracks without segments.
|
||||
#[derive(Debug)]
|
||||
pub struct Convert;
|
||||
|
||||
@@ -31,14 +33,7 @@ impl Apply for Convert {
|
||||
.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 tracks = to_tracks(converted);
|
||||
let trk_ids: HashSet<_> = tracks.iter().map(|trk| trk.id).collect();
|
||||
file.trk.splice(0..0, tracks);
|
||||
Selection::Track {
|
||||
@@ -57,14 +52,7 @@ impl Apply for Convert {
|
||||
.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)
|
||||
add_routes(&mut file.rte, *file_id, to_routes(converted))
|
||||
}
|
||||
Selection::TrackSegment {
|
||||
file_id,
|
||||
@@ -94,8 +82,32 @@ impl Apply for Convert {
|
||||
});
|
||||
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::File { .. }
|
||||
| Selection::Waypoints { .. }
|
||||
| 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.
|
||||
fn add_routes(
|
||||
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';
|
||||
import UnsavedDialog from '$lib/components/file-list/UnsavedDialog.svelte';
|
||||
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 { boundsManager } from '$lib/state/bounds';
|
||||
import { onMount } from 'svelte';
|
||||
@@ -334,6 +334,19 @@
|
||||
</Menubar.Item>
|
||||
{/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.Item onclick={() => engine.selectAll()} disabled={noFiles}>
|
||||
<FileStack size="16" />
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
||||
import { getSymbolKey, symbols } from '$lib/data/symbols';
|
||||
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 { isHidden } from '$lib/engine/file-visibility';
|
||||
import {
|
||||
@@ -271,7 +271,6 @@
|
||||
<Plus size="16" />
|
||||
{i18n._('menu.new_route')}
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
{:else if node.type === 'track'}
|
||||
<ContextMenu.Item
|
||||
disabled={!singleSelection}
|
||||
@@ -291,6 +290,21 @@
|
||||
<ContextMenu.Separator />
|
||||
{/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)}>
|
||||
{#if $allHidden}
|
||||
<Eye size="16" />
|
||||
|
||||
@@ -3,7 +3,7 @@ import { engine } from '$lib/engine';
|
||||
import { getConvertedDistanceToKilometers } from '$lib/units/units';
|
||||
import { get } from 'svelte/store';
|
||||
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 { 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 { get } from 'svelte/store';
|
||||
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 { loadSVGIcon } from '$lib/utils';
|
||||
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.
|
||||
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.
|
||||
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>
|
||||
import { Waypoints, MapPin } from '@lucide/svelte';
|
||||
import { Waypoints, Route, MapPin } from '@lucide/svelte';
|
||||
</script>
|
||||
|
||||
# { 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.
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
|
||||
**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">
|
||||
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';
|
||||
</script>
|
||||
|
||||
# { title }
|
||||
|
||||
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*.
|
||||
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>
|
||||
|
||||
### <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
|
||||
|
||||
Create a new segment in the selected track.
|
||||
@@ -49,6 +60,19 @@ Additionally, the selection must be a single track.
|
||||
|
||||
</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
|
||||
|
||||
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.
|
||||
|
||||
### <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...
|
||||
|
||||
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).
|
||||
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
|
||||
|
||||
|
||||
@@ -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" />
|
||||
</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.
|
||||
|
||||
<DocsNote>
|
||||
|
||||
@@ -10,11 +10,11 @@ title: Merge
|
||||
|
||||
# <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.
|
||||
- The second option can be used to create or manage files with multiple [tracks or segments](../gpx).
|
||||
Merging files (or tracks) will result in a single file (or track) containing all tracks (or segments) from the selection.
|
||||
- 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 and routes (or segments) from the selection.
|
||||
|
||||
<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.
|
||||
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." />
|
||||
@@ -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
|
||||
* 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() {
|
||||
return this.run((b) => b.convert());
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { VisibilityState } from '$lib/engine/file-visibility';
|
||||
import {
|
||||
coversSegments,
|
||||
elementId,
|
||||
fileConversion,
|
||||
hasSelectionWithin,
|
||||
isCovered,
|
||||
isInClipboard,
|
||||
@@ -274,4 +275,41 @@ describe('selection helpers', () => {
|
||||
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",
|
||||
"convert_to_route": "Convert to route",
|
||||
"convert_to_track": "Convert to track",
|
||||
"convert_to_routes": "Convert to route file",
|
||||
"convert_to_tracks": "Convert to track file",
|
||||
"new_segment": "New segment",
|
||||
"open": "Open...",
|
||||
"open_folder": "Open folder...",
|
||||
|
||||
Reference in new issue
Block a user