mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-27 02:27:18 +00:00
route full support
This commit is contained in:
1 parent
c842da8dc1
commit
b6c0f5190a
91 files changed
+2523
-847
No files matched your search
@@ -105,8 +105,10 @@
|
||||
let desktop = $derived($workspace !== undefined);
|
||||
let selectedIds = $derived(selectedFileIds($selection));
|
||||
let anyUnsaved = $derived(Object.values($workspace?.files ?? {}).some((file) => file.dirty));
|
||||
// metadata and style can be edited for files and tracks only
|
||||
let filesOrTracksSelected = $derived($selection.type === 'file' || $selection.type === 'track');
|
||||
// metadata and style can be edited for files, routes and tracks only
|
||||
let filesOrTracksSelected = $derived(
|
||||
$selection.type === 'file' || $selection.type === 'route' || $selection.type === 'track'
|
||||
);
|
||||
|
||||
function switchBasemaps() {
|
||||
[$currentBasemap, $previousBasemap] = [$previousBasemap, $currentBasemap];
|
||||
@@ -308,6 +310,13 @@
|
||||
<Plus size="16" />
|
||||
{i18n._('menu.new_track')}
|
||||
</Menubar.Item>
|
||||
<Menubar.Item
|
||||
onclick={() => engine.newRoute()}
|
||||
disabled={selectionCount !== 1}
|
||||
>
|
||||
<Plus size="16" />
|
||||
{i18n._('menu.new_route')}
|
||||
</Menubar.Item>
|
||||
{:else if $selection.type === 'track'}
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
Orbit,
|
||||
Thermometer,
|
||||
SquareActivity,
|
||||
Route,
|
||||
} from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { engine, type ExportOptions } from '$lib/engine';
|
||||
@@ -38,7 +37,6 @@
|
||||
atemp: true,
|
||||
power: true,
|
||||
osm: false,
|
||||
asRoute: false,
|
||||
});
|
||||
// the files that are exported
|
||||
let fileIds: string[] = $derived.by(() => {
|
||||
@@ -61,7 +59,6 @@
|
||||
atemp: false,
|
||||
power: false,
|
||||
osm: false,
|
||||
asRoute: false,
|
||||
});
|
||||
$effect(() => {
|
||||
const ids = fileIds;
|
||||
@@ -75,8 +72,6 @@
|
||||
atemp: !available.atemp,
|
||||
power: !available.power,
|
||||
osm: !available.osm,
|
||||
// always possible
|
||||
asRoute: false,
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -131,7 +126,6 @@
|
||||
atemp: exportOptions.atemp && !hide.atemp,
|
||||
power: exportOptions.power && !hide.power,
|
||||
osm: exportOptions.osm && !hide.osm,
|
||||
asRoute: exportOptions.asRoute,
|
||||
};
|
||||
if (exportState.current === ExportState.SELECTION) {
|
||||
exportSelectedFiles(options);
|
||||
@@ -211,13 +205,6 @@
|
||||
{i18n._('quantities.osm_extensions')}
|
||||
</Label>
|
||||
</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>
|
||||
</Dialog.Content>
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
switch (node.type) {
|
||||
case 'file':
|
||||
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': {
|
||||
const trackIndex = structure.tracks.findIndex((t) => t.id === node.trackId);
|
||||
return (
|
||||
@@ -75,7 +81,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if node.type === 'segment' || node.type === 'waypoint'}
|
||||
{#if node.type === 'route' || node.type === 'segment' || node.type === 'waypoint'}
|
||||
<FileListNodeLabel {fileState} {node} {label} />
|
||||
{:else if recursive}
|
||||
<CollapsibleTreeNode {id} bind:this={collapsible}>
|
||||
@@ -84,7 +90,11 @@
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<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>
|
||||
{/snippet}
|
||||
</CollapsibleTreeNode>
|
||||
|
||||
@@ -10,16 +10,21 @@
|
||||
let {
|
||||
fileState,
|
||||
node,
|
||||
waypointRoot = false,
|
||||
part = 'tracks',
|
||||
}: {
|
||||
/** State of the file of the node (not needed for the root). */
|
||||
fileState?: FileState;
|
||||
/** The node whose children are listed, `null` for the list of the files. */
|
||||
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();
|
||||
|
||||
let waypointRoot = part === 'waypoints';
|
||||
|
||||
const { files, order } = engine;
|
||||
|
||||
let container: HTMLElement;
|
||||
@@ -27,9 +32,11 @@
|
||||
node === null
|
||||
? 'file'
|
||||
: node.type === 'file'
|
||||
? waypointRoot
|
||||
? part === 'waypoints'
|
||||
? 'waypoints'
|
||||
: 'track'
|
||||
: part === 'routes'
|
||||
? 'route'
|
||||
: 'track'
|
||||
: node.type === 'waypoints'
|
||||
? 'waypoint'
|
||||
: 'segment';
|
||||
@@ -74,7 +81,7 @@
|
||||
{/each}
|
||||
{:else if fileState}
|
||||
{#if node.type === 'file'}
|
||||
{#if waypointRoot}
|
||||
{#if part === 'waypoints'}
|
||||
{#if fileState.structure.waypoints.length > 0}
|
||||
<div data-id="waypoints">
|
||||
<FileListNode
|
||||
@@ -83,6 +90,15 @@
|
||||
/>
|
||||
</div>
|
||||
{/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}
|
||||
{#each fileState.structure.tracks as track (track.id)}
|
||||
<div data-id={track.id}>
|
||||
@@ -120,8 +136,10 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if fileState && node?.type === 'file' && !waypointRoot}
|
||||
<FileListNodeContent {fileState} {node} waypointRoot={true} />
|
||||
{#if fileState && node?.type === 'file' && part === 'routes'}
|
||||
<FileListNodeContent {fileState} {node} part="tracks" />
|
||||
{:else if fileState && node?.type === 'file' && part === 'tracks'}
|
||||
<FileListNodeContent {fileState} {node} part="waypoints" />
|
||||
{/if}
|
||||
|
||||
<style lang="postcss">
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
Scissors,
|
||||
FileStack,
|
||||
FileX,
|
||||
Route,
|
||||
} from '@lucide/svelte';
|
||||
import { getContext, untrack } from 'svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
@@ -34,6 +35,7 @@
|
||||
elementId,
|
||||
isInClipboard,
|
||||
isSelected,
|
||||
pathInfo,
|
||||
selectedElementIds,
|
||||
selectedFileIds,
|
||||
selectionSize,
|
||||
@@ -62,20 +64,23 @@
|
||||
|
||||
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(() => {
|
||||
const { tracks } = fileState.structure;
|
||||
const { routes, tracks } = fileState.structure;
|
||||
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 = [
|
||||
...new Set(
|
||||
tracks.flatMap((track) =>
|
||||
track.color !== undefined ? [normalizeColor(track.color)] : []
|
||||
[...routes, ...tracks].flatMap((path) =>
|
||||
path.color !== undefined ? [normalizeColor(path.color)] : []
|
||||
)
|
||||
),
|
||||
];
|
||||
return colors.length > 0 ? colors : [fileState.color];
|
||||
} else if (node.type === 'track') {
|
||||
const color = tracks.find((track) => track.id === node.trackId)?.color;
|
||||
} else if (isPath) {
|
||||
const color = pathInfo(fileState.structure, node)?.color;
|
||||
return [color !== undefined ? normalizeColor(color) : fileState.color];
|
||||
}
|
||||
return [];
|
||||
@@ -141,11 +146,11 @@
|
||||
? 'w-full h-7'
|
||||
: '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} />
|
||||
<StyleDialog bind:open={openEditStyle} {fileState} {node} />
|
||||
{/if}
|
||||
{#if level === 'file' || level === 'track'}
|
||||
{#if level === 'file' || isPath}
|
||||
<div
|
||||
class="absolute {orientation === 'vertical'
|
||||
? 'top-0 bottom-0 right-0 w-1'
|
||||
@@ -197,7 +202,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" />
|
||||
{:else if level === 'waypoint'}
|
||||
{#if symbolKey && symbols[symbolKey].icon}
|
||||
@@ -238,7 +245,7 @@
|
||||
</Button>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content>
|
||||
{#if node.type === 'file' || node.type === 'track'}
|
||||
{#if node.type === 'file' || isPath}
|
||||
<ContextMenu.Item
|
||||
disabled={!singleSelection}
|
||||
onclick={() => (editMetadata.current = true)}
|
||||
@@ -269,6 +276,10 @@
|
||||
<Plus size="16" />
|
||||
{i18n._('menu.new_track')}
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item disabled={!singleSelection} onclick={() => engine.newRoute()}>
|
||||
<Plus size="16" />
|
||||
{i18n._('menu.new_route')}
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
{:else if node.type === 'track'}
|
||||
<ContextMenu.Item
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
||||
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';
|
||||
|
||||
let {
|
||||
@@ -21,16 +21,13 @@
|
||||
open: boolean;
|
||||
} = $props();
|
||||
|
||||
let track = $derived(
|
||||
node.type === 'track'
|
||||
? fileState.structure.tracks.find((track) => track.id === node.trackId)
|
||||
: undefined
|
||||
);
|
||||
// the route or the track
|
||||
let path = $derived(pathInfo(fileState.structure, node));
|
||||
let name: string = $derived(
|
||||
node.type === 'file' ? fileState.structure.name : (track?.name ?? '')
|
||||
node.type === 'file' ? fileState.structure.name : (path?.name ?? '')
|
||||
);
|
||||
let description: string = $derived(
|
||||
node.type === 'file' ? (fileState.structure.desc ?? '') : (track?.desc ?? '')
|
||||
node.type === 'file' ? (fileState.structure.desc ?? '') : (path?.desc ?? '')
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
|
||||
@@ -5,13 +5,22 @@ import { tick } from 'svelte';
|
||||
import { engine, type MoveTarget, type Selection } from '$lib/engine';
|
||||
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[]> = {
|
||||
file: ['file'],
|
||||
track: ['file', 'track'],
|
||||
segment: ['file', 'track', 'segment'],
|
||||
route: ['file', 'route', 'track'],
|
||||
track: ['file', 'route', 'track'],
|
||||
segment: ['file', 'route', 'track', 'segment'],
|
||||
waypoints: ['waypoints'],
|
||||
waypoint: ['waypoints', 'waypoint'],
|
||||
};
|
||||
@@ -103,6 +112,8 @@ export class SortableFileList {
|
||||
switch (this._sortableLevel) {
|
||||
case 'file':
|
||||
return { type: 'file', fileId: id };
|
||||
case 'route':
|
||||
return parent ? { type: 'route', fileId: parent.fileId, routeId: id } : undefined;
|
||||
case 'track':
|
||||
return parent ? { type: 'track', fileId: parent.fileId, trackId: id } : undefined;
|
||||
case 'segment':
|
||||
@@ -129,6 +140,8 @@ export class SortableFileList {
|
||||
switch (this._sortableLevel) {
|
||||
case 'file':
|
||||
return { type: 'file', fileIds: ids };
|
||||
case 'route':
|
||||
return parent ? { type: 'route', fileId: parent.fileId, routeIds: ids } : undefined;
|
||||
case 'track':
|
||||
return parent ? { type: 'track', fileId: parent.fileId, trackIds: ids } : undefined;
|
||||
case 'segment':
|
||||
@@ -155,6 +168,8 @@ export class SortableFileList {
|
||||
switch (this._sortableLevel) {
|
||||
case 'file':
|
||||
return { type: 'files', index };
|
||||
case 'route':
|
||||
return parent ? { type: 'routes', fileId: parent.fileId, index } : undefined;
|
||||
case 'track':
|
||||
return parent ? { type: 'tracks', fileId: parent.fileId, index } : undefined;
|
||||
case 'segment':
|
||||
@@ -275,6 +290,9 @@ export class SortableFileList {
|
||||
case 'file':
|
||||
request = engine.select(selected);
|
||||
break;
|
||||
case 'route':
|
||||
request = engine.selectRoutes(parent!.fileId, selected);
|
||||
break;
|
||||
case 'track':
|
||||
request = engine.selectTracks(parent!.fileId, selected);
|
||||
break;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { engine, type FileState } from '$lib/engine';
|
||||
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';
|
||||
|
||||
let {
|
||||
@@ -38,17 +38,15 @@
|
||||
width = $defaultWidth;
|
||||
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') {
|
||||
opacity = tracks.find((track) => track.opacity !== undefined)?.opacity ?? opacity;
|
||||
width = tracks.find((track) => track.width !== undefined)?.width ?? width;
|
||||
} else if (node.type === 'track') {
|
||||
const track = tracks.find((track) => track.id === node.trackId);
|
||||
if (track) {
|
||||
color = track.color !== undefined ? normalizeColor(track.color) : color;
|
||||
opacity = track.opacity ?? opacity;
|
||||
width = track.width ?? width;
|
||||
}
|
||||
opacity = paths.find((path) => path.opacity !== undefined)?.opacity ?? opacity;
|
||||
width = paths.find((path) => path.width !== undefined)?.width ?? width;
|
||||
} else if (path) {
|
||||
color = path.color !== undefined ? normalizeColor(path.color) : color;
|
||||
opacity = path.opacity ?? opacity;
|
||||
width = path.width ?? width;
|
||||
}
|
||||
|
||||
colorChanged = false;
|
||||
|
||||
@@ -118,7 +118,11 @@ export class GPXLayer {
|
||||
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 {
|
||||
if (properties!.routeId !== undefined) {
|
||||
return { type: 'route', fileId: this.fileId, routeId: properties!.routeId };
|
||||
}
|
||||
return {
|
||||
type: 'segment',
|
||||
fileId: this.fileId,
|
||||
@@ -181,7 +185,11 @@ export class GPXLayer {
|
||||
const visibleSegmentIds = state.segments.features
|
||||
.filter(
|
||||
({ properties }) =>
|
||||
!isSegmentHidden(state, properties.trackId, properties.segmentId)
|
||||
!isSegmentHidden(
|
||||
state,
|
||||
properties.routeId ?? properties.trackId!,
|
||||
properties.segmentId
|
||||
)
|
||||
)
|
||||
.map(({ properties }) => properties.segmentId);
|
||||
const segmentFilter: FilterSpecification = [
|
||||
@@ -415,7 +423,7 @@ export class GPXLayer {
|
||||
|
||||
layerOnClick(e: MapLayerMouseEvent) {
|
||||
const properties = e.features![0].properties!;
|
||||
const { trackId, segmentId } = properties;
|
||||
const { routeId, trackId, segmentId } = properties;
|
||||
const selectionType = this.selectionState.type;
|
||||
|
||||
if (
|
||||
@@ -441,10 +449,15 @@ export class GPXLayer {
|
||||
}
|
||||
|
||||
const mode = e.originalEvent.ctrlKey || e.originalEvent.metaKey ? 'add' : 'replace';
|
||||
const { tracks } = get(this.file).structure;
|
||||
const segmentCount = tracks.reduce((count, track) => count + track.segments.length, 0);
|
||||
const { routes, tracks } = get(this.file).structure;
|
||||
const segmentCount =
|
||||
routes.length + tracks.reduce((count, track) => count + track.segments.length, 0);
|
||||
if (get(treeFileView) && segmentCount > 1) {
|
||||
// Select inner item
|
||||
if (routeId !== undefined) {
|
||||
engine.selectRoutes(this.fileId, [routeId], mode);
|
||||
return;
|
||||
}
|
||||
const track = tracks.find((track) => track.id === trackId);
|
||||
if (track && track.segments.length > 1) {
|
||||
engine.selectSegments(this.fileId, trackId, [segmentId], mode);
|
||||
|
||||
@@ -15,13 +15,14 @@
|
||||
const files = engine.files;
|
||||
const statistics = engine.statistics;
|
||||
|
||||
/** The number of segments of a file or of one of its tracks. */
|
||||
/** The number of segments of a file (its routes have one each) or of one of its tracks. */
|
||||
function segmentCount(fileId: string, trackId?: string) {
|
||||
const file = $files.get(fileId);
|
||||
const tracks = file ? get(file).structure.tracks : [];
|
||||
return tracks
|
||||
const structure = file ? get(file).structure : undefined;
|
||||
const routes = trackId === undefined ? (structure?.routes.length ?? 0) : 0;
|
||||
return (structure?.tracks ?? [])
|
||||
.filter((track) => trackId === undefined || track.id === trackId)
|
||||
.reduce((count, track) => count + track.segments.length, 0);
|
||||
.reduce((count, track) => count + track.segments.length, routes);
|
||||
}
|
||||
|
||||
// files or tracks, which all have several segments
|
||||
|
||||
@@ -31,11 +31,13 @@
|
||||
onDestroy(() => request.release());
|
||||
let segmentCount = $derived($statistics.anchors?.segmentStarts.length ?? 0);
|
||||
|
||||
// several files, tracks or segments, or a single file or track with several segments
|
||||
// several files, routes, tracks or segments, or a single file or track with several segments
|
||||
let canMergeTraces = $derived.by(() => {
|
||||
switch ($selection.type) {
|
||||
case 'file':
|
||||
return $selection.fileIds.length > 1 || segmentCount > 1;
|
||||
case 'route':
|
||||
return $selection.routeIds.length > 1;
|
||||
case 'track':
|
||||
return $selection.trackIds.length > 1 || segmentCount > 1;
|
||||
case 'segment':
|
||||
@@ -45,7 +47,8 @@
|
||||
}
|
||||
});
|
||||
|
||||
// several files or tracks, that can be put together
|
||||
// several files or tracks, that can be put together (a route has a single segment: routes
|
||||
// cannot)
|
||||
let canMergeContents = $derived(
|
||||
($selection.type === 'file' && $selection.fileIds.length > 1) ||
|
||||
($selection.type === 'track' && $selection.trackIds.length > 1)
|
||||
|
||||
@@ -172,9 +172,10 @@
|
||||
updateEnd();
|
||||
}
|
||||
|
||||
// a single file, track or segment
|
||||
// a single file, route, track or segment
|
||||
let canUpdate = $derived(
|
||||
($selection.type === 'file' && $selection.fileIds.length === 1) ||
|
||||
($selection.type === 'route' && $selection.routeIds.length === 1) ||
|
||||
($selection.type === 'track' && $selection.trackIds.length === 1) ||
|
||||
($selection.type === 'segment' && $selection.segmentIds.length === 1)
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { onDestroy } from 'svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { engine } from '$lib/engine';
|
||||
import { coversSegments } from '$lib/engine/selection-helpers';
|
||||
import { minTolerance, ReducedLayer, tolerance } from './utils.svelte';
|
||||
|
||||
let props: { class?: string } = $props();
|
||||
@@ -23,9 +24,7 @@
|
||||
const request = engine.requestStatistics();
|
||||
request.set(['anchors']);
|
||||
|
||||
let validSelection = $derived(
|
||||
$selection.type === 'file' || $selection.type === 'track' || $selection.type === 'segment'
|
||||
);
|
||||
let validSelection = $derived(coversSegments($selection));
|
||||
|
||||
let reducedLayer = new ReducedLayer();
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { engine } from '$lib/engine';
|
||||
import { coversSegments } from '$lib/engine/selection-helpers';
|
||||
import { newFileName } from '$lib/actions/file-actions';
|
||||
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
|
||||
import { RoutingControls } from './routing-controls';
|
||||
@@ -54,10 +55,8 @@
|
||||
const selection = engine.selection;
|
||||
let routingControls: RoutingControls | undefined = undefined;
|
||||
|
||||
// the tool works on files, tracks and segments, not on waypoints
|
||||
let validSelection = $derived(
|
||||
$selection.type === 'file' || $selection.type === 'track' || $selection.type === 'segment'
|
||||
);
|
||||
// the tool works on files, routes, tracks and segments, not on waypoints
|
||||
let validSelection = $derived(coversSegments($selection));
|
||||
|
||||
function createFileWithPoint(e: any) {
|
||||
if ($selection.type === 'empty') {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { map } from '$lib/components/map/map';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { closestPointIndexIn } from '$lib/geo/closest-point';
|
||||
import { coversSegments } from '$lib/engine/selection-helpers';
|
||||
import {
|
||||
engine,
|
||||
type Selection,
|
||||
@@ -53,7 +54,7 @@ type AnchorFeature = GeoJSON.Feature<GeoJSON.Point, AnchorProperties>;
|
||||
|
||||
/** Whether the tool has to show controls for a selection: it covers segments, or can get some. */
|
||||
function isRoutable(selection: Selection) {
|
||||
return selection.type === 'file' || selection.type === 'track' || selection.type === 'segment';
|
||||
return coversSegments(selection);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { splitAs, SplitType } from '$lib/components/toolbar/tools/scissors/scissors';
|
||||
import { availableSplitTypes, splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
@@ -14,6 +14,7 @@
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { slicedStatistics } from '$lib/state/selection-statistics';
|
||||
import { engine } from '$lib/engine';
|
||||
import { coversSegments } from '$lib/engine/selection-helpers';
|
||||
|
||||
let props: {
|
||||
class?: string;
|
||||
@@ -24,13 +25,20 @@
|
||||
const selection = engine.selection;
|
||||
const statistics = engine.statistics;
|
||||
|
||||
// the tool works on files, tracks and segments, not on waypoints
|
||||
let validSelection = $derived(
|
||||
($selection.type === 'file' ||
|
||||
$selection.type === 'track' ||
|
||||
$selection.type === 'segment') &&
|
||||
$statistics.length > 0
|
||||
);
|
||||
// the ways the selection can be split, which follow its content
|
||||
let splitTypes = $derived.by(() => {
|
||||
// what is selected can change without the selection: the files are edited
|
||||
void $statistics;
|
||||
return availableSplitTypes($selection);
|
||||
});
|
||||
$effect(() => {
|
||||
if (!splitTypes.includes($splitAs)) {
|
||||
$splitAs = splitTypes[0];
|
||||
}
|
||||
});
|
||||
|
||||
// the tool works on files, routes, tracks and segments, not on waypoints
|
||||
let validSelection = $derived(coversSegments($selection) && $statistics.length > 0);
|
||||
// The slider does work for each of its steps every time it changes, which is too slow with
|
||||
// the thousands of trackpoints of a long track: it has a limited number of steps, which are
|
||||
// spread over the trackpoints.
|
||||
@@ -123,7 +131,7 @@
|
||||
{i18n._('gpx.' + $splitAs)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each Object.values(SplitType) as splitType}
|
||||
{#each splitTypes as splitType}
|
||||
<Select.Item value={splitType}>{i18n._('gpx.' + splitType)}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { get, writable, type Writable } from 'svelte/store';
|
||||
import { engine, type SplitType as EngineSplitType } from '$lib/engine';
|
||||
import { engine, type Selection, type SplitType as EngineSplitType } from '$lib/engine';
|
||||
import { selectedFileIds } from '$lib/engine/selection-helpers';
|
||||
import { closestPointIndexIn } from '$lib/geo/closest-point';
|
||||
|
||||
/** What a split cuts in two, the values being the ones of the engine. */
|
||||
export const SplitType = {
|
||||
FILES: 'files',
|
||||
ROUTES: 'routes',
|
||||
TRACKS: 'tracks',
|
||||
SEGMENTS: 'segments',
|
||||
} as const satisfies Record<string, EngineSplitType>;
|
||||
@@ -12,11 +14,62 @@ export type SplitType = (typeof SplitType)[keyof typeof SplitType];
|
||||
|
||||
export let splitAs: Writable<SplitType> = writable(SplitType.FILES);
|
||||
|
||||
/** The ids of the segments of the routes of the files of the selection. */
|
||||
export function routeSegmentIds(selection: Selection): Set<string> {
|
||||
const files = get(engine.files);
|
||||
return new Set(
|
||||
selectedFileIds(selection).flatMap((fileId) => {
|
||||
const file = files.get(fileId);
|
||||
return file ? get(file).structure.routes.map((route) => route.segment.id) : [];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits at the trackpoint of a selected segment that is the closest to `point`, as `splitAs` says.
|
||||
* The ways the selection can be split: as files always, as routes if it has routes, as tracks or
|
||||
* segments if it has tracks with segments (the engine does nothing for a trackpoint that is not in
|
||||
* what it is asked to cut).
|
||||
*/
|
||||
export function availableSplitTypes(selection: Selection): SplitType[] {
|
||||
let routes = false;
|
||||
let tracks = false;
|
||||
if (selection.type === 'route') {
|
||||
routes = true;
|
||||
} else if (selection.type === 'track' || selection.type === 'segment') {
|
||||
tracks = true;
|
||||
} else {
|
||||
const files = get(engine.files);
|
||||
for (const fileId of selectedFileIds(selection)) {
|
||||
const file = files.get(fileId);
|
||||
if (file) {
|
||||
const { structure } = get(file);
|
||||
routes ||= structure.routes.length > 0;
|
||||
tracks ||= structure.tracks.some((track) => track.segments.length > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [
|
||||
SplitType.FILES,
|
||||
...(routes ? [SplitType.ROUTES] : []),
|
||||
...(tracks ? [SplitType.TRACKS, SplitType.SEGMENTS] : []),
|
||||
];
|
||||
}
|
||||
|
||||
/** Whether splitting as `type` cuts something at a trackpoint of a segment, which is a route's or not. */
|
||||
export function cuts(type: SplitType, inRoute: boolean): boolean {
|
||||
return type === SplitType.FILES || (type === SplitType.ROUTES) === inRoute;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits at the trackpoint of a selected segment that is the closest to `point`, as `splitAs` says
|
||||
* (nothing happens if the segment cannot be split that way).
|
||||
* The statistics of the engine need to have the anchors (the scissors tool asks for them).
|
||||
*/
|
||||
export function splitAtPoint(segmentId: string, point: { lng: number; lat: number }) {
|
||||
const inRoute = routeSegmentIds(get(engine.selection)).has(segmentId);
|
||||
if (!cuts(get(splitAs), inRoute)) {
|
||||
return;
|
||||
}
|
||||
const statistics = get(engine.statistics);
|
||||
const anchors = statistics.anchors;
|
||||
const segment = anchors?.segmentIds.indexOf(segmentId) ?? -1;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||
import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
|
||||
import { cuts, routeSegmentIds, splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
|
||||
import { Scissors } from 'lucide-static';
|
||||
import { get } from 'svelte/store';
|
||||
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
|
||||
@@ -7,6 +7,7 @@ import type { GeoJSONSource, Map, MapLayerMouseEvent } from 'maplibre-gl';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
||||
import { loadSVGIcon } from '$lib/utils';
|
||||
import { coversSegments } from '$lib/engine/selection-helpers';
|
||||
import {
|
||||
engine,
|
||||
type Selection,
|
||||
@@ -15,9 +16,9 @@ import {
|
||||
} from '$lib/engine';
|
||||
|
||||
/**
|
||||
* The anchors inside the segments of the selection, where a click splits the file, the track or
|
||||
* the segment: a command of the engine, which finds them by the index of their trackpoint in the
|
||||
* selection.
|
||||
* The anchors inside the segments of the selection, where a click splits the file, the route, the
|
||||
* track or the segment: a command of the engine, which finds them by the index of their trackpoint
|
||||
* in the selection. Only the segments that can be split as `splitAs` says have them.
|
||||
*/
|
||||
export class SplitControls {
|
||||
map: Map;
|
||||
@@ -53,6 +54,7 @@ export class SplitControls {
|
||||
})
|
||||
);
|
||||
this.unsubscribes.push(currentTool.subscribe(this.addIfNeeded.bind(this)));
|
||||
this.unsubscribes.push(splitAs.subscribe(this.addIfNeeded.bind(this)));
|
||||
this.unsubscribes.push(
|
||||
engine.selection.subscribe((selection) => {
|
||||
this.selection = selection;
|
||||
@@ -62,12 +64,8 @@ export class SplitControls {
|
||||
}
|
||||
|
||||
addIfNeeded() {
|
||||
// the tool works on files, tracks and segments, not on waypoints
|
||||
const scissors =
|
||||
get(currentTool) === Tool.SCISSORS &&
|
||||
(this.selection.type === 'file' ||
|
||||
this.selection.type === 'track' ||
|
||||
this.selection.type === 'segment');
|
||||
// the tool works on files, routes, tracks and segments, not on waypoints
|
||||
const scissors = get(currentTool) === Tool.SCISSORS && coversSegments(this.selection);
|
||||
this.request.set(scissors ? ['anchors'] : []);
|
||||
if (!scissors) {
|
||||
this.remove();
|
||||
@@ -85,6 +83,8 @@ export class SplitControls {
|
||||
return [];
|
||||
}
|
||||
const starts = anchors.segmentStarts;
|
||||
const routeSegments = routeSegmentIds(this.selection);
|
||||
const type = get(splitAs);
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
let segment = 0;
|
||||
for (let i = 0; i < anchors.indices.length; i++) {
|
||||
@@ -94,7 +94,8 @@ export class SplitControls {
|
||||
segment++;
|
||||
}
|
||||
const end = segment + 1 < starts.length ? starts[segment + 1] : statistics.length;
|
||||
if (index > starts[segment] && index < end - 1) {
|
||||
const inRoute = routeSegments.has(anchors.segmentIds[segment]);
|
||||
if (index > starts[segment] && index < end - 1 && cuts(type, inRoute)) {
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
|
||||
@@ -38,6 +38,8 @@ export async function createTauriBackend(): Promise<Backend> {
|
||||
select: (fileIds, mode) => invoke('select', { fileIds, mode }),
|
||||
select_all: () => invoke('select_all'),
|
||||
arrow_select: (down, add) => invoke('arrow_select', { down, add }),
|
||||
select_routes: (fileId, routeIds, mode) =>
|
||||
invoke('select_routes', { fileId, routeIds, mode }),
|
||||
select_tracks: (fileId, trackIds, mode) =>
|
||||
invoke('select_tracks', { fileId, trackIds, mode }),
|
||||
select_segments: (fileId, trackId, segmentIds, mode) =>
|
||||
@@ -81,6 +83,7 @@ export async function createTauriBackend(): Promise<Backend> {
|
||||
}),
|
||||
metadata: (name, desc) => invoke('metadata', { name, desc }),
|
||||
style: (color, opacity, width) => invoke('style', { color, opacity, width }),
|
||||
new_route: () => invoke('new_route'),
|
||||
new_track: () => invoke('new_track'),
|
||||
new_track_segment: () => invoke('new_track_segment'),
|
||||
|
||||
@@ -156,7 +159,7 @@ export async function createTauriBackend(): Promise<Backend> {
|
||||
(await invoke('waypoint', { fileId, waypointId })) ?? undefined,
|
||||
trackpoint: async (fileId, segmentId, index) =>
|
||||
(await invoke('trackpoint', { fileId, segmentId, index })) ?? undefined,
|
||||
export_file: async (fileId, time, hr, cad, atemp, power, osm, asRoute) => {
|
||||
export_file: async (fileId, time, hr, cad, atemp, power, osm) => {
|
||||
const file = await optionalArray('export_file', Uint8Array, {
|
||||
fileId,
|
||||
time,
|
||||
@@ -165,7 +168,6 @@ export async function createTauriBackend(): Promise<Backend> {
|
||||
atemp,
|
||||
power,
|
||||
osm,
|
||||
asRoute,
|
||||
});
|
||||
return file as Uint8Array<ArrayBuffer> | undefined;
|
||||
},
|
||||
|
||||
@@ -146,6 +146,7 @@ export interface Backend {
|
||||
select(fileIds: string[], mode: SelectMode): Promise<Outcome>;
|
||||
select_all(): Promise<Outcome>;
|
||||
arrow_select(down: boolean, add: boolean): Promise<Outcome>;
|
||||
select_routes(fileId: string, routeIds: string[], mode: SelectMode): Promise<Outcome>;
|
||||
select_tracks(fileId: string, trackIds: string[], mode: SelectMode): Promise<Outcome>;
|
||||
select_segments(
|
||||
fileId: string,
|
||||
@@ -173,6 +174,7 @@ export interface Backend {
|
||||
move_elements(what: Selection, to: MoveTarget): Promise<Outcome>;
|
||||
metadata(name: string, desc: string): Promise<Outcome>;
|
||||
style(color?: string, opacity?: number, width?: number): Promise<Outcome>;
|
||||
new_route(): Promise<Outcome>;
|
||||
new_track(): Promise<Outcome>;
|
||||
new_track_segment(): Promise<Outcome>;
|
||||
|
||||
@@ -262,8 +264,7 @@ export interface Backend {
|
||||
cad: boolean,
|
||||
atemp: boolean,
|
||||
power: boolean,
|
||||
osm: boolean,
|
||||
asRoute: boolean
|
||||
osm: boolean
|
||||
): Promise<Uint8Array<ArrayBuffer> | undefined>;
|
||||
exportable_data(fileIds: string[]): Promise<number>;
|
||||
reduction_distances(): Promise<Float64Array>;
|
||||
|
||||
@@ -29,15 +29,19 @@ export async function createWasmBackend(): Promise<Backend> {
|
||||
const mode = (m: SelectMode) =>
|
||||
({ replace: w.SelectMode.Replace, add: w.SelectMode.Add, toggle: w.SelectMode.Toggle })[m];
|
||||
const splitType = (t: SplitType) =>
|
||||
({ files: w.SplitType.Files, tracks: w.SplitType.Tracks, segments: w.SplitType.Segments })[
|
||||
t
|
||||
];
|
||||
({
|
||||
files: w.SplitType.Files,
|
||||
routes: w.SplitType.Routes,
|
||||
tracks: w.SplitType.Tracks,
|
||||
segments: w.SplitType.Segments,
|
||||
})[t];
|
||||
const mergeType = (t: MergeType) => (t === 'connect' ? w.MergeType.Connect : w.MergeType.Group);
|
||||
|
||||
return {
|
||||
select: lift((ids, m) => w.select(idsToBytes(ids), mode(m))),
|
||||
select_all: lift(w.select_all),
|
||||
arrow_select: lift(w.arrow_select),
|
||||
select_routes: lift((file, ids, m) => w.select_routes(file, idsToBytes(ids), mode(m))),
|
||||
select_tracks: lift((file, ids, m) => w.select_tracks(file, idsToBytes(ids), mode(m))),
|
||||
select_segments: lift((file, track, ids, m) =>
|
||||
w.select_segments(file, track, idsToBytes(ids), mode(m))
|
||||
@@ -75,6 +79,7 @@ export async function createWasmBackend(): Promise<Backend> {
|
||||
move_elements: lift(w.move_elements),
|
||||
metadata: lift(w.metadata),
|
||||
style: lift(w.style),
|
||||
new_route: lift(w.new_route),
|
||||
new_track: lift(w.new_track),
|
||||
new_track_segment: lift(w.new_track_segment),
|
||||
|
||||
|
||||
@@ -175,6 +175,35 @@ describe('selection', () => {
|
||||
assert.ok(get(engine.statistics).length > trackLength);
|
||||
});
|
||||
|
||||
it('selects routes, whose segments are on the map', async () => {
|
||||
await load('with_routes');
|
||||
const file = fileStates()[0];
|
||||
const { id, routes, tracks } = file.structure;
|
||||
assert.equal(routes.length, 2);
|
||||
assert.equal(tracks.length, 0);
|
||||
// the segments of the map are the ones of the routes
|
||||
assert.deepEqual(
|
||||
file.segments.features.map((f) => f.properties.routeId),
|
||||
routes.map((route) => route.id)
|
||||
);
|
||||
assert.deepEqual(
|
||||
file.segments.features.map((f) => f.properties.segmentId),
|
||||
routes.map((route) => route.segment.id)
|
||||
);
|
||||
|
||||
await engine.selectRoutes(id, [routes[1].id]);
|
||||
assert.deepEqual(get(engine.selection), {
|
||||
type: 'route',
|
||||
fileId: id,
|
||||
routeIds: [routes[1].id],
|
||||
});
|
||||
assert.equal(get(engine.statistics).length, routes[1].segment.length);
|
||||
|
||||
await engine.select([id]);
|
||||
assert.equal(await engine.newRoute(), true);
|
||||
assert.equal(fileStates()[0].structure.routes.length, 3);
|
||||
});
|
||||
|
||||
it('ignores what does not exist', async () => {
|
||||
await load('simple');
|
||||
const before = get(engine.selection);
|
||||
@@ -415,42 +444,32 @@ describe('export', () => {
|
||||
await engine.exportFile(id, { ...ALL_EXPORT_OPTIONS, ...options })
|
||||
);
|
||||
|
||||
it('writes tracks, or routes when asked to', async () => {
|
||||
it('writes tracks as tracks', async () => {
|
||||
await load('with_tracks_and_segments');
|
||||
const file = fileStates()[0];
|
||||
const segments = file.structure.tracks.reduce(
|
||||
(sum, track) => sum + track.segments.length,
|
||||
0
|
||||
);
|
||||
const id = file.structure.id;
|
||||
|
||||
const tracks = await text(id);
|
||||
const tracks = await text(file.structure.id);
|
||||
assert.ok(tracks.includes('<trk>') && !tracks.includes('<rte>'));
|
||||
assert.equal(tracks.match(/<trkseg>/g)?.length, segments);
|
||||
|
||||
const routes = await text(id, { asRoute: true });
|
||||
assert.ok(routes.includes('<rte>') && !routes.includes('<trk>'));
|
||||
assert.equal(routes.match(/<rte>/g)?.length, segments);
|
||||
assert.ok(routes.includes('<rtept'));
|
||||
});
|
||||
|
||||
it('does not count the way a file is written among its data', async () => {
|
||||
await load('simple');
|
||||
const data = await engine.exportableData(get(engine.order));
|
||||
assert.equal(data.asRoute, false);
|
||||
});
|
||||
|
||||
it('can read back the routes it writes', async () => {
|
||||
await load('with_tracks_and_segments');
|
||||
const id = get(engine.order)[0];
|
||||
const before = fileStates()[0].segments.features.length;
|
||||
const routes = (await engine.exportFile(id, { ...ALL_EXPORT_OPTIONS, asRoute: true }))!;
|
||||
await load('with_routes');
|
||||
const [id] = get(engine.order);
|
||||
const before = fileStates()[0].structure.routes.length;
|
||||
const routes = (await engine.exportFile(id))!;
|
||||
const written = new TextDecoder().decode(routes);
|
||||
assert.equal(written.match(/<rte>/g)?.length, before);
|
||||
assert.ok(!written.includes('<trk>') && written.includes('<rtept'));
|
||||
|
||||
await engine.loadFiles([{ data: routes, name: 'routes' }]);
|
||||
const [, loaded] = get(engine.order);
|
||||
const state = get(get(engine.files).get(loaded)!);
|
||||
// one route per segment, each read as a track of one segment
|
||||
assert.equal(state.structure.tracks.length, before);
|
||||
assert.equal(state.segments.features.length, before);
|
||||
assert.equal(state.structure.routes.length, before);
|
||||
assert.equal(state.structure.tracks.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -182,6 +182,10 @@ class Engine {
|
||||
|
||||
// Selecting elements inside a file. Ids that do not exist are ignored.
|
||||
|
||||
selectRoutes(fileId: string, routeIds: string[], mode: SelectMode = 'replace') {
|
||||
return this.run((b) => b.select_routes(fileId, routeIds, mode));
|
||||
}
|
||||
|
||||
selectTracks(fileId: string, trackIds: string[], mode: SelectMode = 'replace') {
|
||||
return this.run((b) => b.select_tracks(fileId, trackIds, mode));
|
||||
}
|
||||
@@ -209,6 +213,8 @@ class Engine {
|
||||
switch (node.type) {
|
||||
case 'file':
|
||||
return this.select([node.fileId], mode);
|
||||
case 'route':
|
||||
return this.selectRoutes(node.fileId, [node.routeId], mode);
|
||||
case 'track':
|
||||
return this.selectTracks(node.fileId, [node.trackId], mode);
|
||||
case 'segment':
|
||||
@@ -359,6 +365,11 @@ class Engine {
|
||||
|
||||
// Edits of the selection
|
||||
|
||||
/** Adds an empty route to the selected files. */
|
||||
newRoute() {
|
||||
return this.run((b) => b.new_route());
|
||||
}
|
||||
|
||||
newTrack() {
|
||||
return this.run((b) => b.new_track());
|
||||
}
|
||||
@@ -868,8 +879,7 @@ class Engine {
|
||||
options.cad,
|
||||
options.atemp,
|
||||
options.power,
|
||||
options.osm,
|
||||
options.asRoute
|
||||
options.osm
|
||||
),
|
||||
undefined
|
||||
);
|
||||
@@ -885,8 +895,6 @@ class Engine {
|
||||
atemp: (bits & 8) !== 0,
|
||||
power: (bits & 16) !== 0,
|
||||
osm: (bits & 32) !== 0,
|
||||
// not a data
|
||||
asRoute: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,13 @@ import { normalizeColor, type FileColorAllocator } from '$lib/engine/file-colors
|
||||
import type { Visibility } from '$lib/engine/file-visibility';
|
||||
import type { Backend } from './backend';
|
||||
import { toPositions } from './convert';
|
||||
import { EMPTY_STATISTICS, type FileState, type SegmentProperties } from './types';
|
||||
import {
|
||||
EMPTY_STATISTICS,
|
||||
type FileState,
|
||||
type PathInfo,
|
||||
type SegmentNode,
|
||||
type SegmentProperties,
|
||||
} from './types';
|
||||
|
||||
/** Reads the state of a file, reusing from `previous` what did not change. */
|
||||
export async function readFileState(
|
||||
@@ -18,9 +24,10 @@ export async function readFileState(
|
||||
return null;
|
||||
}
|
||||
|
||||
const paths: PathInfo[] = [...structure.routes, ...structure.tracks];
|
||||
const color = colors.resolve(
|
||||
id,
|
||||
structure.tracks.map((track) => track.color)
|
||||
paths.map((path) => path.color)
|
||||
);
|
||||
|
||||
const previousSegments = new Map(
|
||||
@@ -31,38 +38,42 @@ export async function readFileState(
|
||||
| Feature<LineString, SegmentProperties>
|
||||
| Promise<Feature<LineString, SegmentProperties>>
|
||||
)[] = [];
|
||||
structure.tracks.forEach((track, trackIndex) => {
|
||||
track.segments.forEach((segment, segmentIndex) => {
|
||||
const properties: SegmentProperties = {
|
||||
fileId: id,
|
||||
trackId: track.id,
|
||||
segmentId: segment.id,
|
||||
trackIndex,
|
||||
segmentIndex,
|
||||
rev: segment.rev,
|
||||
color: track.color !== undefined ? normalizeColor(track.color) : color,
|
||||
opacity: track.opacity,
|
||||
width: track.width,
|
||||
};
|
||||
const old = previousSegments.get(segment.id);
|
||||
const unchanged =
|
||||
old !== undefined &&
|
||||
(Object.keys(properties) as (keyof SegmentProperties)[]).every(
|
||||
(key) => old.properties[key] === properties[key]
|
||||
);
|
||||
segments.push(
|
||||
unchanged
|
||||
? old
|
||||
: backend.segment_coordinates(segment.id).then((coordinates) => ({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: toPositions(coordinates),
|
||||
},
|
||||
properties,
|
||||
}))
|
||||
const addSegment = (
|
||||
segment: SegmentNode,
|
||||
path: PathInfo,
|
||||
parent: Pick<SegmentProperties, 'routeId' | 'trackId'>
|
||||
) => {
|
||||
const properties: SegmentProperties = {
|
||||
fileId: id,
|
||||
...parent,
|
||||
segmentId: segment.id,
|
||||
rev: segment.rev,
|
||||
color: path.color !== undefined ? normalizeColor(path.color) : color,
|
||||
opacity: path.opacity,
|
||||
width: path.width,
|
||||
};
|
||||
const old = previousSegments.get(segment.id);
|
||||
const unchanged =
|
||||
old !== undefined &&
|
||||
(Object.keys(properties) as (keyof SegmentProperties)[]).every(
|
||||
(key) => old.properties[key] === properties[key]
|
||||
);
|
||||
});
|
||||
segments.push(
|
||||
unchanged
|
||||
? old
|
||||
: backend.segment_coordinates(segment.id).then((coordinates) => ({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: toPositions(coordinates),
|
||||
},
|
||||
properties,
|
||||
}))
|
||||
);
|
||||
};
|
||||
structure.routes.forEach((route) => addSegment(route.segment, route, { routeId: route.id }));
|
||||
structure.tracks.forEach((track) => {
|
||||
track.segments.forEach((segment) => addSegment(segment, track, { trackId: track.id }));
|
||||
});
|
||||
const segmentFeatures = await Promise.all(segments);
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ const structure: FileStructure = {
|
||||
name: 'file',
|
||||
waypointsRev: 'wpt-rev',
|
||||
waypoints: [{ id: 'W1' }, { id: 'W2' }],
|
||||
routes: [],
|
||||
tracks: [
|
||||
{ id: 'T1', segments: [segment('S1'), segment('S2')] },
|
||||
{ id: 'T2', segments: [segment('S3')] },
|
||||
@@ -134,6 +135,29 @@ describe('file visibility', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('hides a route, which is a leaf like a waypoint', () => {
|
||||
const withRoute: FileStructure = {
|
||||
...structure,
|
||||
routes: [{ id: 'R', segment: segment('RS') }],
|
||||
};
|
||||
const state = (...actions: [ids: string[], hidden: boolean][]): VisibilityState => {
|
||||
let visibility: Visibility = new Map();
|
||||
for (const [ids, hidden] of actions) {
|
||||
visibility = setHidden(withRoute, visibility, ids, hidden);
|
||||
}
|
||||
return { structure: withRoute, visibility };
|
||||
};
|
||||
assert.equal(isHidden(state([['R'], true]), 'R'), true);
|
||||
assert.equal(isHidden(state([['R'], true]), 'F'), false);
|
||||
assert.equal(isSegmentHidden(state([['R'], true]), 'R', 'RS'), true);
|
||||
// with its file, and shown on its own below it
|
||||
assert.equal(isHidden(state([['F'], true]), 'R'), true);
|
||||
const shown = state([['F'], true], [['R'], false]);
|
||||
assert.equal(isHidden(shown, 'R'), false);
|
||||
assert.equal(isHidden(shown, 'F'), false);
|
||||
assert.equal(isHidden(shown, 'T1'), true);
|
||||
});
|
||||
|
||||
it('does not consider unknown ids hidden', () => {
|
||||
assert.equal(isHidden(apply([['F'], true]), 'unknown'), false);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FileStructure } from 'gpx-wasm';
|
||||
|
||||
/**
|
||||
* Visibility the user set explicitly, by element id (file, track, segment, waypoint, or the
|
||||
* Visibility the user set explicitly, by element id (file, route, track, segment, waypoint, or the
|
||||
* waypoints of the file as a whole, see `waypointsKey`): `false` hides, `true` shows. An element
|
||||
* without entry follows its parent, and everything is shown by default.
|
||||
*/
|
||||
@@ -15,7 +15,10 @@ export function waypointsKey(fileId: string): string {
|
||||
return `${fileId}:waypoints`;
|
||||
}
|
||||
|
||||
/** Ids from the file down to `id` in the file tree (file, tracks, segments / waypoints group). */
|
||||
/**
|
||||
* Ids from the file down to `id` in the file tree (file, routes, tracks, segments / waypoints
|
||||
* group).
|
||||
*/
|
||||
function pathTo(structure: FileStructure, id: string): string[] {
|
||||
const fileId = structure.id;
|
||||
if (id === fileId) {
|
||||
@@ -27,6 +30,9 @@ function pathTo(structure: FileStructure, id: string): string[] {
|
||||
if (structure.waypoints.some((waypoint) => waypoint.id === id)) {
|
||||
return [fileId, waypointsKey(fileId), id];
|
||||
}
|
||||
if (structure.routes.some((route) => route.id === id)) {
|
||||
return [fileId, id];
|
||||
}
|
||||
for (const track of structure.tracks) {
|
||||
if (track.id === id) {
|
||||
return [fileId, id];
|
||||
@@ -40,7 +46,11 @@ function pathTo(structure: FileStructure, id: string): string[] {
|
||||
|
||||
function children(structure: FileStructure, id: string): string[] {
|
||||
if (id === structure.id) {
|
||||
return [waypointsKey(id), ...structure.tracks.map((track) => track.id)];
|
||||
return [
|
||||
waypointsKey(id),
|
||||
...structure.routes.map((route) => route.id),
|
||||
...structure.tracks.map((track) => track.id),
|
||||
];
|
||||
}
|
||||
if (id === waypointsKey(structure.id)) {
|
||||
return structure.waypoints.map((waypoint) => waypoint.id);
|
||||
@@ -77,12 +87,13 @@ export function isHidden({ structure, visibility }: VisibilityState, id: string)
|
||||
|
||||
// Leaf elements have no descendants: no need to look at the structure.
|
||||
|
||||
/** Whether a segment is hidden: `parentId` is its route (which is a leaf), or its track. */
|
||||
export function isSegmentHidden(
|
||||
{ structure, visibility }: VisibilityState,
|
||||
trackId: string,
|
||||
parentId: string,
|
||||
segmentId: string
|
||||
): boolean {
|
||||
return followsHidden(visibility, [structure.id, trackId, segmentId]);
|
||||
return followsHidden(visibility, [structure.id, parentId, segmentId]);
|
||||
}
|
||||
|
||||
export function isWaypointHidden(
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import type { Clipboard, FileStructure, Selection } from 'gpx-wasm';
|
||||
import type { VisibilityState } from '$lib/engine/file-visibility';
|
||||
import {
|
||||
coversSegments,
|
||||
elementId,
|
||||
hasSelectionWithin,
|
||||
isCovered,
|
||||
@@ -16,10 +17,12 @@ import {
|
||||
type FileTreeNode,
|
||||
} from '$lib/engine/selection-helpers';
|
||||
|
||||
// Two files; F1 has the tracks T1 (segments S1, S2) and T2 (segment S3), and waypoints W1, W2.
|
||||
// Two files; F1 has the route R1, the tracks T1 (segments S1, S2) and T2 (segment S3), and
|
||||
// waypoints W1, W2.
|
||||
const nodes: Record<string, FileTreeNode> = {
|
||||
F1: { type: 'file', fileId: 'F1' },
|
||||
F2: { type: 'file', fileId: 'F2' },
|
||||
R1: { type: 'route', fileId: 'F1', routeId: 'R1' },
|
||||
T1: { type: 'track', fileId: 'F1', trackId: 'T1' },
|
||||
T2: { type: 'track', fileId: 'F1', trackId: 'T2' },
|
||||
S1: { type: 'segment', fileId: 'F1', trackId: 'T1', segmentId: 'S1' },
|
||||
@@ -34,6 +37,7 @@ const nodes: Record<string, FileTreeNode> = {
|
||||
|
||||
const empty: Selection = { type: 'empty' };
|
||||
const files = (...fileIds: string[]): Selection => ({ type: 'file', fileIds });
|
||||
const routes = (...routeIds: string[]): Selection => ({ type: 'route', fileId: 'F1', routeIds });
|
||||
const tracks = (...trackIds: string[]): Selection => ({ type: 'track', fileId: 'F1', trackIds });
|
||||
const segments = (trackId: string, ...segmentIds: string[]): Selection => ({
|
||||
type: 'segment',
|
||||
@@ -54,6 +58,15 @@ function matching(selection: Selection, predicate: typeof isSelected): string[]
|
||||
}
|
||||
|
||||
describe('selection helpers', () => {
|
||||
it('tells whether the selection is made of segments', () => {
|
||||
for (const selection of [files('F1'), routes('R1'), tracks('T1'), segments('T1', 'S1')]) {
|
||||
assert.equal(coversSegments(selection), true);
|
||||
}
|
||||
for (const selection of [empty, waypoints, waypoint('W1')]) {
|
||||
assert.equal(coversSegments(selection), false);
|
||||
}
|
||||
});
|
||||
|
||||
it('tells whether the selection is empty', () => {
|
||||
assert.equal(isEmpty(empty), true);
|
||||
assert.equal(isEmpty(files('F1')), false);
|
||||
@@ -72,7 +85,20 @@ describe('selection helpers', () => {
|
||||
it('gives the id of the element of a node', () => {
|
||||
assert.deepEqual(
|
||||
Object.keys(nodes).map((name) => elementId(nodes[name])),
|
||||
['F1', 'F2', 'T1', 'T2', 'S1', 'S2', 'S3', 'F1:waypoints', 'W1', 'W2', 'F2:waypoints']
|
||||
[
|
||||
'F1',
|
||||
'F2',
|
||||
'R1',
|
||||
'T1',
|
||||
'T2',
|
||||
'S1',
|
||||
'S2',
|
||||
'S3',
|
||||
'F1:waypoints',
|
||||
'W1',
|
||||
'W2',
|
||||
'F2:waypoints',
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
@@ -90,6 +116,7 @@ describe('selection helpers', () => {
|
||||
Object.keys(nodes).filter((name) => isInClipboard(c, nodes[name]));
|
||||
assert.deepEqual(inClipboard(clipboard('files', ['F2'])), ['F2']);
|
||||
assert.deepEqual(inClipboard(clipboard('tracks', ['T2', 'S1'])), ['T2']);
|
||||
assert.deepEqual(inClipboard(clipboard('routes', ['R1', 'T1'])), ['R1']);
|
||||
assert.deepEqual(inClipboard(clipboard('segments', ['S1', 'S3'])), ['S1', 'S3']);
|
||||
assert.deepEqual(inClipboard(clipboard('waypoints', ['W2'])), ['W2']);
|
||||
// not mixed up with the other kinds of elements
|
||||
@@ -99,6 +126,7 @@ describe('selection helpers', () => {
|
||||
it('counts the selected elements', () => {
|
||||
assert.equal(selectionSize(empty), 0);
|
||||
assert.equal(selectionSize(files('F1', 'F2')), 2);
|
||||
assert.equal(selectionSize(routes('R1')), 1);
|
||||
assert.equal(selectionSize(tracks('T1')), 1);
|
||||
assert.equal(selectionSize(segments('T1', 'S1', 'S2')), 2);
|
||||
assert.equal(selectionSize(waypoints), 1);
|
||||
@@ -111,6 +139,7 @@ describe('selection helpers', () => {
|
||||
{ fileId: 'F1', ids: ['F1'] },
|
||||
{ fileId: 'F2', ids: ['F2'] },
|
||||
]);
|
||||
assert.deepEqual(selectedElementIds(routes('R1')), [{ fileId: 'F1', ids: ['R1'] }]);
|
||||
assert.deepEqual(selectedElementIds(tracks('T1', 'T2')), [
|
||||
{ fileId: 'F1', ids: ['T1', 'T2'] },
|
||||
]);
|
||||
@@ -123,6 +152,7 @@ describe('selection helpers', () => {
|
||||
it('matches the selected nodes only', () => {
|
||||
assert.deepEqual(matching(empty, isSelected), []);
|
||||
assert.deepEqual(matching(files('F1', 'F2'), isSelected), ['F1', 'F2']);
|
||||
assert.deepEqual(matching(routes('R1'), isSelected), ['R1']);
|
||||
assert.deepEqual(matching(tracks('T1', 'T2'), isSelected), ['T1', 'T2']);
|
||||
assert.deepEqual(matching(segments('T1', 'S1'), isSelected), ['S1']);
|
||||
assert.deepEqual(matching(waypoints, isSelected), ['WPTS']);
|
||||
@@ -146,6 +176,7 @@ describe('selection helpers', () => {
|
||||
|
||||
it('covers the segments of a selected track, not its siblings', () => {
|
||||
assert.deepEqual(matching(tracks('T1'), isCovered), ['T1', 'S1', 'S2']);
|
||||
assert.deepEqual(matching(routes('R1'), isCovered), ['R1']);
|
||||
});
|
||||
|
||||
it('covers the waypoints below the waypoints node', () => {
|
||||
@@ -163,6 +194,7 @@ describe('selection helpers', () => {
|
||||
it('looks at the descendants of a file, whatever their kind', () => {
|
||||
for (const selection of [
|
||||
files('F1'),
|
||||
routes('R1'),
|
||||
tracks('T1'),
|
||||
segments('T1', 'S1'),
|
||||
waypoints,
|
||||
@@ -174,6 +206,10 @@ describe('selection helpers', () => {
|
||||
assert.equal(hasSelectionWithin(empty, nodes.F1), false);
|
||||
});
|
||||
|
||||
it('looks at a route, which has no node below it', () => {
|
||||
assert.deepEqual(matching(routes('R1'), hasSelectionWithin), ['F1', 'R1']);
|
||||
});
|
||||
|
||||
it('looks at the segments of a track', () => {
|
||||
assert.deepEqual(matching(tracks('T1'), hasSelectionWithin), ['F1', 'T1']);
|
||||
assert.deepEqual(matching(segments('T1', 'S2'), hasSelectionWithin), [
|
||||
@@ -201,6 +237,7 @@ describe('selection helpers', () => {
|
||||
name: 'file',
|
||||
waypointsRev: 'rev',
|
||||
waypoints: [{ id: 'W1' }, { id: 'W2' }],
|
||||
routes: [],
|
||||
tracks: [
|
||||
{ id: 'T1', segments: [segment('S1'), segment('S2')] },
|
||||
{ id: 'T2', segments: [segment('S3')] },
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import type { Clipboard, Selection } from 'gpx-wasm';
|
||||
import type { Clipboard, FileStructure, PathInfo, Selection } from 'gpx-wasm';
|
||||
import { isHidden, waypointsKey, type VisibilityState } from '$lib/engine/file-visibility';
|
||||
|
||||
/**
|
||||
* An element of the file tree, to ask questions about the selection:
|
||||
*
|
||||
* file ─┬─ track ─── segment
|
||||
* file ─┬─ route
|
||||
* ├─ track ─── segment
|
||||
* └─ waypoints ─── waypoint
|
||||
*
|
||||
* `waypoints` is the node standing for all the waypoints of a file.
|
||||
* A route is like a track of a single segment, which is not a node of its own. `waypoints` is the
|
||||
* node standing for all the waypoints of a file.
|
||||
*/
|
||||
export type FileTreeNode =
|
||||
| { type: 'file'; fileId: string }
|
||||
| { type: 'route'; fileId: string; routeId: string }
|
||||
| { type: 'track'; fileId: string; trackId: string }
|
||||
| { type: 'segment'; fileId: string; trackId: string; segmentId: string }
|
||||
| { type: 'waypoints'; fileId: string }
|
||||
@@ -24,6 +27,8 @@ export function elementId(node: FileTreeNode): string {
|
||||
switch (node.type) {
|
||||
case 'file':
|
||||
return node.fileId;
|
||||
case 'route':
|
||||
return node.routeId;
|
||||
case 'track':
|
||||
return node.trackId;
|
||||
case 'segment':
|
||||
@@ -35,6 +40,31 @@ export function elementId(node: FileTreeNode): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** The name, description and style of a route or track node, `undefined` for the other nodes. */
|
||||
export function pathInfo(structure: FileStructure, node: FileTreeNode): PathInfo | undefined {
|
||||
switch (node.type) {
|
||||
case 'route':
|
||||
return structure.routes.find((route) => route.id === node.routeId);
|
||||
case 'track':
|
||||
return structure.tracks.find((track) => track.id === node.trackId);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the selection is made of segments: files, routes, tracks or segments, not waypoints (the
|
||||
* selection the tools that work on trackpoints work on).
|
||||
*/
|
||||
export function coversSegments(selection: Selection): boolean {
|
||||
return (
|
||||
selection.type === 'file' ||
|
||||
selection.type === 'route' ||
|
||||
selection.type === 'track' ||
|
||||
selection.type === 'segment'
|
||||
);
|
||||
}
|
||||
|
||||
export function isEmpty(selection: Selection): boolean {
|
||||
return selection.type === 'empty';
|
||||
}
|
||||
@@ -59,6 +89,8 @@ export function isInClipboard(clipboard: Clipboard | undefined, node: FileTreeNo
|
||||
switch (node.type) {
|
||||
case 'file':
|
||||
return clipboard.type === 'files' && clipboard.ids.includes(node.fileId);
|
||||
case 'route':
|
||||
return clipboard.type === 'routes' && clipboard.ids.includes(node.routeId);
|
||||
case 'track':
|
||||
return clipboard.type === 'tracks' && clipboard.ids.includes(node.trackId);
|
||||
case 'segment':
|
||||
@@ -77,6 +109,8 @@ export function selectionSize(selection: Selection): number {
|
||||
return 0;
|
||||
case 'file':
|
||||
return selection.fileIds.length;
|
||||
case 'route':
|
||||
return selection.routeIds.length;
|
||||
case 'track':
|
||||
return selection.trackIds.length;
|
||||
case 'segment':
|
||||
@@ -89,8 +123,8 @@ export function selectionSize(selection: Selection): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* The ids of the selected elements, by file, as used by the visibility (a file, its tracks, its
|
||||
* segments, its waypoints, or `waypointsKey` for the waypoints node).
|
||||
* The ids of the selected elements, by file, as used by the visibility (a file, its routes, its
|
||||
* tracks, its segments, its waypoints, or `waypointsKey` for the waypoints node).
|
||||
*/
|
||||
export function selectedElementIds(selection: Selection): { fileId: string; ids: string[] }[] {
|
||||
switch (selection.type) {
|
||||
@@ -98,6 +132,8 @@ export function selectedElementIds(selection: Selection): { fileId: string; ids:
|
||||
return [];
|
||||
case 'file':
|
||||
return selection.fileIds.map((fileId) => ({ fileId, ids: [fileId] }));
|
||||
case 'route':
|
||||
return [{ fileId: selection.fileId, ids: selection.routeIds }];
|
||||
case 'track':
|
||||
return [{ fileId: selection.fileId, ids: selection.trackIds }];
|
||||
case 'segment':
|
||||
@@ -114,6 +150,12 @@ export function isSelected(selection: Selection, node: FileTreeNode): boolean {
|
||||
switch (node.type) {
|
||||
case 'file':
|
||||
return selection.type === 'file' && selection.fileIds.includes(node.fileId);
|
||||
case 'route':
|
||||
return (
|
||||
selection.type === 'route' &&
|
||||
selection.fileId === node.fileId &&
|
||||
selection.routeIds.includes(node.routeId)
|
||||
);
|
||||
case 'track':
|
||||
return (
|
||||
selection.type === 'track' &&
|
||||
@@ -143,6 +185,7 @@ function parent(node: FileTreeNode): FileTreeNode | undefined {
|
||||
switch (node.type) {
|
||||
case 'file':
|
||||
return undefined;
|
||||
case 'route':
|
||||
case 'track':
|
||||
case 'waypoints':
|
||||
return { type: 'file', fileId: node.fileId };
|
||||
@@ -180,6 +223,7 @@ export function hasSelectionWithin(selection: Selection, node: FileTreeNode): bo
|
||||
isSelected(selection, node) ||
|
||||
(selection.type === 'waypoint' && selection.fileId === node.fileId)
|
||||
);
|
||||
case 'route':
|
||||
case 'segment':
|
||||
case 'waypoint':
|
||||
return isSelected(selection, node);
|
||||
|
||||
@@ -17,6 +17,8 @@ export type {
|
||||
FilesUpdate,
|
||||
Outcome,
|
||||
Selection,
|
||||
PathInfo,
|
||||
RouteNode,
|
||||
TrackNode,
|
||||
SegmentNode,
|
||||
WaypointNode,
|
||||
@@ -28,8 +30,11 @@ export type {
|
||||
*/
|
||||
export type MergeType = 'connect' | 'group';
|
||||
|
||||
/** What a split cuts in two: the file, the track or the segment of the trackpoint. */
|
||||
export type SplitType = 'files' | 'tracks' | 'segments';
|
||||
/**
|
||||
* What a split cuts in two: the file, the route, the track or the segment of the trackpoint. Only
|
||||
* a trackpoint of a route cuts a route, and only one of a track cuts a track or a segment.
|
||||
*/
|
||||
export type SplitType = 'files' | 'routes' | 'tracks' | 'segments';
|
||||
|
||||
/**
|
||||
* How elements combine with the current selection: they `replace` it, are `add`ed to it, or are
|
||||
@@ -39,17 +44,17 @@ export type SplitType = 'files' | 'tracks' | 'segments';
|
||||
*/
|
||||
export type SelectMode = 'replace' | 'add' | 'toggle';
|
||||
|
||||
/** A segment of the map: the one of a route (`routeId`), or one of a track (`trackId`). */
|
||||
export type SegmentProperties = {
|
||||
fileId: string;
|
||||
trackId: string;
|
||||
routeId?: string;
|
||||
trackId?: string;
|
||||
segmentId: string;
|
||||
trackIndex: number;
|
||||
segmentIndex: number;
|
||||
/** Changes when the trackpoints of the segment change. */
|
||||
rev: string;
|
||||
/** Color of the track, or else the base color of the file. */
|
||||
/** Color of the route or track, or else the base color of the file. */
|
||||
color: string;
|
||||
/** Only when the track defines it. */
|
||||
/** Only when the route or track defines it. */
|
||||
opacity?: number;
|
||||
width?: number;
|
||||
};
|
||||
@@ -76,11 +81,14 @@ export type NewWaypoint = {
|
||||
/** Everything the UI knows about one file. */
|
||||
export type FileState = {
|
||||
structure: FileStructure;
|
||||
/** Color of the file: the one defined by its tracks if any, otherwise one from the palette. */
|
||||
/**
|
||||
* Color of the file: the one defined by its routes and tracks if any, otherwise one from the
|
||||
* palette.
|
||||
*/
|
||||
color: string;
|
||||
/** Global statistics of the file. */
|
||||
statistics: GlobalStatistics;
|
||||
/** One LineString feature per track segment, in file order. */
|
||||
/** One LineString feature per segment (of the routes, then of the tracks), in file order. */
|
||||
segments: FeatureCollection<LineString, SegmentProperties>;
|
||||
/** One Point feature per waypoint, in file order. */
|
||||
waypoints: FeatureCollection<Point, WaypointProperties>;
|
||||
@@ -120,11 +128,6 @@ export type ExportOptions = {
|
||||
power: boolean;
|
||||
/** The OpenStreetMap data: surface, highway, SAC scale and MTB scale. */
|
||||
osm: boolean;
|
||||
/**
|
||||
* Writes the segments as routes instead of tracks: the anchors are the route points, and the
|
||||
* trackpoints between them the points of their path (which only keep their position).
|
||||
*/
|
||||
asRoute: boolean;
|
||||
};
|
||||
|
||||
export const ALL_EXPORT_OPTIONS: ExportOptions = {
|
||||
@@ -134,7 +137,6 @@ export const ALL_EXPORT_OPTIONS: ExportOptions = {
|
||||
atemp: true,
|
||||
power: true,
|
||||
osm: true,
|
||||
asRoute: false,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"new": "New",
|
||||
"new_file": "New file",
|
||||
"new_track": "New track",
|
||||
"new_route": "New route",
|
||||
"new_segment": "New segment",
|
||||
"open": "Open...",
|
||||
"open_folder": "Open folder...",
|
||||
@@ -46,7 +47,6 @@
|
||||
"export": "Export...",
|
||||
"export_all": "Export all...",
|
||||
"export_options": "Export options",
|
||||
"export_as_route": "Export as route",
|
||||
"support_message": "The tool is free to use, but not free to run. Please consider supporting the website if you use it frequently. Thank you!",
|
||||
"support_button": "Help keep the website free",
|
||||
"download_file": "Download file",
|
||||
@@ -462,6 +462,8 @@
|
||||
"gpx": {
|
||||
"file": "File",
|
||||
"files": "Files",
|
||||
"route": "Route",
|
||||
"routes": "Routes",
|
||||
"track": "Track",
|
||||
"tracks": "Tracks",
|
||||
"segment": "Segment",
|
||||
|
||||
Reference in new issue
Block a user