mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2025-09-02 16:52:31 +00:00
merge tool
This commit is contained in:
@@ -3,6 +3,6 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="{$$props.class || ''} text-sm rounded border flex flex-row items-center p-2">
|
<div class="{$$props.class || ''} text-sm rounded border flex flex-row items-center p-2">
|
||||||
<CircleHelp size="16" class="w-10 mr-2" />
|
<CircleHelp size="16" class="w-4 mr-2 shrink-0 grow-0" />
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
@@ -120,7 +120,7 @@
|
|||||||
<div class="flex flex-row items-center justify-between gap-4">
|
<div class="flex flex-row items-center justify-between gap-4">
|
||||||
<Label>{$_('menu.color')}</Label>
|
<Label>{$_('menu.color')}</Label>
|
||||||
<Select.Root bind:selected={$selectedHeatmapColor} class="grow">
|
<Select.Root bind:selected={$selectedHeatmapColor} class="grow">
|
||||||
<Select.Trigger class="w-full">
|
<Select.Trigger class="w-full h-8">
|
||||||
<Select.Value placeholder="Theme" />
|
<Select.Value placeholder="Theme" />
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
|
@@ -40,7 +40,7 @@
|
|||||||
</ToolbarItem>
|
</ToolbarItem>
|
||||||
<ToolbarItem tool={Tool.MERGE}>
|
<ToolbarItem tool={Tool.MERGE}>
|
||||||
<Group slot="icon" size="18" />
|
<Group slot="icon" size="18" />
|
||||||
<span slot="tooltip">{$_('toolbar.merge_tooltip')}</span>
|
<span slot="tooltip">{$_('toolbar.merge.tooltip')}</span>
|
||||||
</ToolbarItem>
|
</ToolbarItem>
|
||||||
<ToolbarItem tool={Tool.EXTRACT}>
|
<ToolbarItem tool={Tool.EXTRACT}>
|
||||||
<Ungroup slot="icon" size="18" />
|
<Ungroup slot="icon" size="18" />
|
||||||
|
@@ -5,6 +5,7 @@
|
|||||||
import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte';
|
import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte';
|
||||||
import Scissors from '$lib/components/toolbar/tools/Scissors.svelte';
|
import Scissors from '$lib/components/toolbar/tools/Scissors.svelte';
|
||||||
import Waypoint from '$lib/components/toolbar/tools/Waypoint.svelte';
|
import Waypoint from '$lib/components/toolbar/tools/Waypoint.svelte';
|
||||||
|
import Merge from '$lib/components/toolbar/tools/Merge.svelte';
|
||||||
import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte';
|
import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import mapboxgl from 'mapbox-gl';
|
import mapboxgl from 'mapbox-gl';
|
||||||
@@ -36,6 +37,8 @@
|
|||||||
<Scissors />
|
<Scissors />
|
||||||
{:else if $currentTool === Tool.WAYPOINT}
|
{:else if $currentTool === Tool.WAYPOINT}
|
||||||
<Waypoint />
|
<Waypoint />
|
||||||
|
{:else if $currentTool === Tool.MERGE}
|
||||||
|
<Merge />
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
100
website/src/lib/components/toolbar/tools/Merge.svelte
Normal file
100
website/src/lib/components/toolbar/tools/Merge.svelte
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<script lang="ts" context="module">
|
||||||
|
enum MergeType {
|
||||||
|
TRACES = 'traces',
|
||||||
|
CONTENTS = 'contents'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { ListFileItem, ListTrackItem } from '$lib/components/file-list/FileList';
|
||||||
|
import Help from '$lib/components/Help.svelte';
|
||||||
|
import { selection } from '$lib/components/file-list/Selection';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
|
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import { dbUtils, fileObservers } from '$lib/db';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
let canMergeTraces = false;
|
||||||
|
let canMergeContents = false;
|
||||||
|
|
||||||
|
$: if ($selection.size > 1) {
|
||||||
|
canMergeTraces = true;
|
||||||
|
} else if ($selection.size === 1) {
|
||||||
|
let selected = $selection.getSelected()[0];
|
||||||
|
if (selected instanceof ListFileItem) {
|
||||||
|
let fileId = selected.getFileId();
|
||||||
|
let fileStore = $fileObservers.get(fileId);
|
||||||
|
if (fileStore) {
|
||||||
|
let file = get(fileStore)?.file;
|
||||||
|
if (file) {
|
||||||
|
canMergeTraces = file.getSegments().length > 1;
|
||||||
|
} else {
|
||||||
|
canMergeTraces = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
canMergeTraces = false;
|
||||||
|
}
|
||||||
|
} else if (selected instanceof ListTrackItem) {
|
||||||
|
let fileId = selected.getFileId();
|
||||||
|
let trackIndex = selected.getTrackIndex();
|
||||||
|
let fileStore = $fileObservers.get(fileId);
|
||||||
|
if (fileStore) {
|
||||||
|
let file = get(fileStore)?.file;
|
||||||
|
if (file) {
|
||||||
|
canMergeTraces = file.trk[trackIndex].getSegments().length > 1;
|
||||||
|
} else {
|
||||||
|
canMergeTraces = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
canMergeTraces = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
canMergeContents = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: canMergeContents =
|
||||||
|
$selection.size > 1 &&
|
||||||
|
$selection
|
||||||
|
.getSelected()
|
||||||
|
.some((item) => item instanceof ListFileItem || item instanceof ListTrackItem);
|
||||||
|
|
||||||
|
let mergeType = MergeType.TRACES;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 max-w-96">
|
||||||
|
<RadioGroup.Root bind:value={mergeType}>
|
||||||
|
<div class="flex flex-row items-center gap-2">
|
||||||
|
<RadioGroup.Item value={MergeType.TRACES} id={MergeType.TRACES} />
|
||||||
|
<Label for={MergeType.TRACES}>{$_('toolbar.merge.merge_traces')}</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row items-center gap-2">
|
||||||
|
<RadioGroup.Item value={MergeType.CONTENTS} id={MergeType.CONTENTS} />
|
||||||
|
<Label for={MergeType.CONTENTS}>{$_('toolbar.merge.merge_contents')}</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup.Root>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={(mergeType === MergeType.TRACES && !canMergeTraces) ||
|
||||||
|
(mergeType === MergeType.CONTENTS && !canMergeContents)}
|
||||||
|
class="w-full"
|
||||||
|
on:click={() => {
|
||||||
|
dbUtils.mergeSelection(mergeType === MergeType.TRACES);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{$_('toolbar.merge.merge_selection')}
|
||||||
|
</Button>
|
||||||
|
<Help>
|
||||||
|
{#if mergeType === MergeType.TRACES && canMergeTraces}
|
||||||
|
{$_('toolbar.merge.help_merge_traces')}
|
||||||
|
{:else if mergeType === MergeType.TRACES && !canMergeTraces}
|
||||||
|
{$_('toolbar.merge.help_cannot_merge_traces')}
|
||||||
|
{:else if mergeType === MergeType.CONTENTS && canMergeContents}
|
||||||
|
{$_('toolbar.merge.help_merge_contents')}
|
||||||
|
{:else if mergeType === MergeType.CONTENTS && !canMergeContents}
|
||||||
|
{$_('toolbar.merge.help_cannot_merge_contents')}
|
||||||
|
{/if}
|
||||||
|
</Help>
|
||||||
|
</div>
|
@@ -1,5 +1,5 @@
|
|||||||
import Dexie, { liveQuery } from 'dexie';
|
import Dexie, { liveQuery } from 'dexie';
|
||||||
import { GPXFile, GPXStatistics, Track, type AnyGPXTreeElement } from 'gpx';
|
import { GPXFile, GPXStatistics, Track, type AnyGPXTreeElement, TrackSegment, Waypoint, TrackPoint } from 'gpx';
|
||||||
import { enableMapSet, enablePatches, applyPatches, type Patch, type WritableDraft, castDraft, freeze, produceWithPatches, original, produce } from 'immer';
|
import { enableMapSet, enablePatches, applyPatches, type Patch, type WritableDraft, castDraft, freeze, produceWithPatches, original, produce } from 'immer';
|
||||||
import { writable, get, derived, type Readable, type Writable } from 'svelte/store';
|
import { writable, get, derived, type Readable, type Writable } from 'svelte/store';
|
||||||
import { initTargetMapBounds, updateTargetMapBounds } from './stores';
|
import { initTargetMapBounds, updateTargetMapBounds } from './stores';
|
||||||
@@ -187,31 +187,31 @@ function dexieGPXFileStore(id: string): Readable<GPXFileWithStatistics> & { dest
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add/update the files to the database
|
|
||||||
function updateDbFiles(files: (GPXFile | undefined)[]) {
|
|
||||||
let filteredFiles = files.filter(file => file !== undefined) as GPXFile[];
|
|
||||||
let fileIds = filteredFiles.map(file => file._data.id);
|
|
||||||
return db.transaction('rw', db.fileids, db.files, async () => {
|
|
||||||
await db.fileids.bulkPut(fileIds, fileIds);
|
|
||||||
await db.files.bulkPut(filteredFiles, fileIds);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the files with the given ids from the database
|
|
||||||
function deleteDbFiles(fileIds: string[]) {
|
|
||||||
return db.transaction('rw', db.fileids, db.files, async () => {
|
|
||||||
await db.fileids.bulkDelete(fileIds);
|
|
||||||
await db.files.bulkDelete(fileIds);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commit the changes to the file state to the database
|
// Commit the changes to the file state to the database
|
||||||
function commitFileStateChange(newFileState: ReadonlyMap<string, GPXFile>, patch: Patch[]) {
|
function commitFileStateChange(newFileState: ReadonlyMap<string, GPXFile>, patch: Patch[]) {
|
||||||
if (newFileState.size >= fileState.size) {
|
let changedFileIds = getChangedFileIds(patch);
|
||||||
return updateDbFiles(getChangedFileIds(patch).map((fileId) => newFileState.get(fileId)));
|
let updatedFileIds: string[] = [], deletedFileIds: string[] = [];
|
||||||
} else {
|
changedFileIds.forEach(id => {
|
||||||
return deleteDbFiles(getChangedFileIds(patch));
|
if (newFileState.has(id)) {
|
||||||
}
|
updatedFileIds.push(id);
|
||||||
|
} else {
|
||||||
|
deletedFileIds.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let updatedFiles = updatedFileIds.map(id => newFileState.get(id)).filter(file => file !== undefined) as GPXFile[];
|
||||||
|
updatedFileIds = updatedFiles.map(file => file._data.id);
|
||||||
|
|
||||||
|
return db.transaction('rw', db.fileids, db.files, async () => {
|
||||||
|
if (updatedFileIds.length > 0) {
|
||||||
|
await db.fileids.bulkPut(updatedFileIds, updatedFileIds);
|
||||||
|
await db.files.bulkPut(updatedFiles, updatedFileIds);
|
||||||
|
}
|
||||||
|
if (deletedFileIds.length > 0) {
|
||||||
|
await db.fileids.bulkDelete(deletedFileIds);
|
||||||
|
await db.files.bulkDelete(deletedFileIds);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fileObservers: Writable<Map<string, Readable<GPXFileWithStatistics | undefined> & { destroy: () => void }>> = writable(new Map());
|
export const fileObservers: Writable<Map<string, Readable<GPXFileWithStatistics | undefined> & { destroy: () => void }>> = writable(new Map());
|
||||||
@@ -487,6 +487,118 @@ export const dbUtils = {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
mergeSelection: (mergeTraces: boolean) => {
|
||||||
|
applyGlobal((draft) => {
|
||||||
|
let first = true;
|
||||||
|
let target: ListItem = new ListRootItem();
|
||||||
|
let targetFile: GPXFile | undefined = undefined;
|
||||||
|
let toMerge: {
|
||||||
|
trk: Track[],
|
||||||
|
trkseg: TrackSegment[],
|
||||||
|
wpt: Waypoint[]
|
||||||
|
} = {
|
||||||
|
trk: [],
|
||||||
|
trkseg: [],
|
||||||
|
wpt: []
|
||||||
|
};
|
||||||
|
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||||
|
let file = original(draft)?.get(fileId);
|
||||||
|
if (file) {
|
||||||
|
let newFile = file;
|
||||||
|
if (level === ListLevel.FILE) {
|
||||||
|
{
|
||||||
|
let [result, removed] = newFile.replaceTracks(0, newFile.trk.length - 1, []);
|
||||||
|
toMerge.trk.push(...removed);
|
||||||
|
newFile = result;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let [result, removed] = newFile.replaceWaypoints(0, newFile.wpt.length - 1, []);
|
||||||
|
toMerge.wpt.push(...removed);
|
||||||
|
newFile = result;
|
||||||
|
}
|
||||||
|
if (first) {
|
||||||
|
target = items[0];
|
||||||
|
targetFile = newFile;
|
||||||
|
} else {
|
||||||
|
draft.delete(fileId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (level === ListLevel.TRACK) {
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
let trackIndex = (item as ListTrackItem).getTrackIndex();
|
||||||
|
if (index === items.length - 1) { // Order is reversed, so the last track is the first one and the one to keep
|
||||||
|
let [result, removed] = newFile.replaceTrackSegments(trackIndex, 0, newFile.trk[trackIndex].trkseg.length - 1, []);
|
||||||
|
toMerge.trkseg.splice(0, 0, ...removed);
|
||||||
|
newFile = result;
|
||||||
|
target = item;
|
||||||
|
} else {
|
||||||
|
let [result, removed] = newFile.replaceTracks(trackIndex, trackIndex, []);
|
||||||
|
toMerge.trkseg.push(...removed[0].trkseg);
|
||||||
|
newFile = result;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (level === ListLevel.SEGMENT) {
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
let trackIndex = (item as ListTrackSegmentItem).getTrackIndex();
|
||||||
|
let segmentIndex = (item as ListTrackSegmentItem).getSegmentIndex();
|
||||||
|
if (index === items.length - 1) { // Order is reversed, so the last segment is the first one and the one to keep
|
||||||
|
target = item;
|
||||||
|
}
|
||||||
|
let [result, removed] = newFile.replaceTrackSegments(trackIndex, segmentIndex, segmentIndex, []);
|
||||||
|
toMerge.trkseg.splice(0, 0, ...removed);
|
||||||
|
newFile = result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (first) {
|
||||||
|
targetFile = newFile;
|
||||||
|
} else {
|
||||||
|
draft.set(fileId, freeze(newFile));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mergeTraces) {
|
||||||
|
if (toMerge.trk.length > 0) {
|
||||||
|
let trackPoints: TrackPoint[] = [];
|
||||||
|
toMerge.trk.forEach((track) => {
|
||||||
|
track.trkseg.forEach((segment) => {
|
||||||
|
trackPoints = trackPoints.concat(segment.trkpt.slice());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// TODO adapt timestamps of trackPoints
|
||||||
|
toMerge.trk[0] = toMerge.trk[0].replaceTrackPoints(0, 0, toMerge.trk[0].trkseg[0].trkpt.length - 1, trackPoints);
|
||||||
|
toMerge.trk[0] = toMerge.trk[0].replaceTrackSegments(1, toMerge.trk[0].trkseg.length - 1, [])[0];
|
||||||
|
toMerge.trk = toMerge.trk.slice(0, 1);
|
||||||
|
}
|
||||||
|
if (toMerge.trkseg.length > 0) {
|
||||||
|
let trackPoints: TrackPoint[] = [];
|
||||||
|
toMerge.trkseg.forEach((segment) => {
|
||||||
|
trackPoints = trackPoints.concat(segment.trkpt.slice());
|
||||||
|
});
|
||||||
|
// TODO adapt timestamps of trackPoints
|
||||||
|
toMerge.trkseg[0] = toMerge.trkseg[0].replaceTrackPoints(0, toMerge.trkseg[0].trkpt.length - 1, trackPoints);
|
||||||
|
toMerge.trkseg = toMerge.trkseg.slice(0, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetFile) {
|
||||||
|
if (target instanceof ListFileItem) {
|
||||||
|
targetFile = targetFile.replaceTracks(0, targetFile.trk.length - 1, toMerge.trk)[0];
|
||||||
|
targetFile = targetFile.replaceWaypoints(0, targetFile.wpt.length - 1, toMerge.wpt)[0];
|
||||||
|
} else if (target instanceof ListTrackItem) {
|
||||||
|
let trackIndex = target.getTrackIndex();
|
||||||
|
targetFile = targetFile.replaceTrackSegments(trackIndex, 0, -1, toMerge.trkseg)[0];
|
||||||
|
} else if (target instanceof ListTrackSegmentItem) {
|
||||||
|
let trackIndex = target.getTrackIndex();
|
||||||
|
let segmentIndex = target.getSegmentIndex();
|
||||||
|
targetFile = targetFile.replaceTrackSegments(trackIndex, segmentIndex, segmentIndex - 1, toMerge.trkseg)[0];
|
||||||
|
}
|
||||||
|
draft.set(targetFile._data.id, freeze(targetFile));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
deleteSelection: () => {
|
deleteSelection: () => {
|
||||||
if (get(selection).size === 0) {
|
if (get(selection).size === 0) {
|
||||||
return;
|
return;
|
||||||
|
@@ -105,7 +105,16 @@
|
|||||||
"tooltip": "Trim or split routes"
|
"tooltip": "Trim or split routes"
|
||||||
},
|
},
|
||||||
"time_tooltip": "Manage time and speed data",
|
"time_tooltip": "Manage time and speed data",
|
||||||
"merge_tooltip": "Merge file elements together",
|
"merge": {
|
||||||
|
"merge_traces": "Connect the traces",
|
||||||
|
"merge_contents": "Merge the contents and keep the traces disconnected",
|
||||||
|
"merge_selection": "Merge selection",
|
||||||
|
"tooltip": "Merge file elements together",
|
||||||
|
"help_merge_traces": "Connecting the selected traces will result in a single file containing a single continuous trace",
|
||||||
|
"help_cannot_merge_traces": "Your selection needs to contain several traces to connect them",
|
||||||
|
"help_merge_contents": "Merging the contents of the selected file elements will group all the contents inside the first file element",
|
||||||
|
"help_cannot_merge_contents": "Your selection needs to contain several file elements to merge their contents"
|
||||||
|
},
|
||||||
"extract_tooltip": "Extract inner tracks or segments",
|
"extract_tooltip": "Extract inner tracks or segments",
|
||||||
"waypoint_tooltip": "Create and edit points of interest",
|
"waypoint_tooltip": "Create and edit points of interest",
|
||||||
"reduce_tooltip": "Reduce the number of GPS points",
|
"reduce_tooltip": "Reduce the number of GPS points",
|
||||||
|
Reference in New Issue
Block a user