mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2025-09-02 08:42:31 +00:00
enable routing tool without selection, and support multi-select
This commit is contained in:
@@ -14,7 +14,7 @@ export abstract class GPXTreeElement<T extends GPXTreeElement<any>> {
|
|||||||
_data: { [key: string]: any } = {};
|
_data: { [key: string]: any } = {};
|
||||||
|
|
||||||
abstract isLeaf(): boolean;
|
abstract isLeaf(): boolean;
|
||||||
abstract get children(): ReadonlyArray<T>;
|
abstract get children(): Array<T>;
|
||||||
|
|
||||||
abstract getNumberOfTrackPoints(): number;
|
abstract getNumberOfTrackPoints(): number;
|
||||||
abstract getStartTimestamp(): Date | undefined;
|
abstract getStartTimestamp(): Date | undefined;
|
||||||
@@ -74,16 +74,15 @@ abstract class GPXTreeNode<T extends GPXTreeElement<any>> extends GPXTreeElement
|
|||||||
newPreviousTimestamp = og.getStartTimestamp();
|
newPreviousTimestamp = og.getStartTimestamp();
|
||||||
}
|
}
|
||||||
|
|
||||||
let children = og.children.slice();
|
this.children.reverse();
|
||||||
children.reverse();
|
|
||||||
|
|
||||||
for (let i = 0; i < og.children.length; i++) {
|
for (let i = 0; i < og.children.length; i++) {
|
||||||
let originalStartTimestamp = og.children[og.children.length - i - 1].getStartTimestamp();
|
let originalStartTimestamp = og.children[og.children.length - i - 1].getStartTimestamp();
|
||||||
|
|
||||||
children[i]._reverse(originalNextTimestamp, newPreviousTimestamp);
|
this.children[i]._reverse(originalNextTimestamp, newPreviousTimestamp);
|
||||||
|
|
||||||
originalNextTimestamp = originalStartTimestamp;
|
originalNextTimestamp = originalStartTimestamp;
|
||||||
newPreviousTimestamp = children[i].getEndTimestamp();
|
newPreviousTimestamp = this.children[i].getEndTimestamp();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this instanceof GPXFile) {
|
if (this instanceof GPXFile) {
|
||||||
@@ -102,7 +101,7 @@ abstract class GPXTreeLeaf extends GPXTreeElement<GPXTreeLeaf> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
get children(): ReadonlyArray<GPXTreeLeaf> {
|
get children(): Array<GPXTreeLeaf> {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,7 +148,7 @@ export class GPXFile extends GPXTreeNode<Track>{
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get children(): ReadonlyArray<Track> {
|
get children(): Array<Track> {
|
||||||
return this.trk;
|
return this.trk;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,6 +245,20 @@ export class GPXFile extends GPXTreeNode<Track>{
|
|||||||
this.trk[trackIndex].reverseTrackSegment(segmentIndex);
|
this.trk[trackIndex].reverseTrackSegment(segmentIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
roundTrip() {
|
||||||
|
this.trk.forEach((track) => {
|
||||||
|
track.roundTrip();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
roundTripTrack(trackIndex: number) {
|
||||||
|
this.trk[trackIndex].roundTrip();
|
||||||
|
}
|
||||||
|
|
||||||
|
roundTripTrackSegment(trackIndex: number, segmentIndex: number) {
|
||||||
|
this.trk[trackIndex].roundTripTrackSegment(segmentIndex);
|
||||||
|
}
|
||||||
|
|
||||||
crop(start: number, end: number, trackIndices?: number[], segmentIndices?: number[]) {
|
crop(start: number, end: number, trackIndices?: number[], segmentIndices?: number[]) {
|
||||||
let i = 0;
|
let i = 0;
|
||||||
let trackIndex = 0;
|
let trackIndex = 0;
|
||||||
@@ -404,7 +417,7 @@ export class Track extends GPXTreeNode<TrackSegment> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get children(): ReadonlyArray<TrackSegment> {
|
get children(): Array<TrackSegment> {
|
||||||
return this.trkseg;
|
return this.trkseg;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,6 +483,16 @@ export class Track extends GPXTreeNode<TrackSegment> {
|
|||||||
this.trkseg[segmentIndex]._reverse(this.trkseg[segmentIndex].getEndTimestamp(), this.trkseg[segmentIndex].getStartTimestamp());
|
this.trkseg[segmentIndex]._reverse(this.trkseg[segmentIndex].getEndTimestamp(), this.trkseg[segmentIndex].getStartTimestamp());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
roundTrip() {
|
||||||
|
this.trkseg.forEach((segment) => {
|
||||||
|
segment.roundTrip();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
roundTripTrackSegment(segmentIndex: number) {
|
||||||
|
this.trkseg[segmentIndex].roundTrip();
|
||||||
|
}
|
||||||
|
|
||||||
crop(start: number, end: number, segmentIndices?: number[]) {
|
crop(start: number, end: number, segmentIndices?: number[]) {
|
||||||
let i = 0;
|
let i = 0;
|
||||||
let segmentIndex = 0;
|
let segmentIndex = 0;
|
||||||
@@ -826,6 +849,13 @@ export class TrackSegment extends GPXTreeLeaf {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
roundTrip() {
|
||||||
|
let og = getOriginal(this); // Read as much as possible from the original object because it is faster
|
||||||
|
let newSegment = og.clone();
|
||||||
|
newSegment._reverse(newSegment.getEndTimestamp(), newSegment.getEndTimestamp());
|
||||||
|
this.replaceTrackPoints(this.trkpt.length, this.trkpt.length, newSegment.trkpt);
|
||||||
|
}
|
||||||
|
|
||||||
crop(start: number, end: number) {
|
crop(start: number, end: number) {
|
||||||
this.trkpt = this.trkpt.slice(start, end + 1);
|
this.trkpt = this.trkpt.slice(start, end + 1);
|
||||||
}
|
}
|
||||||
|
@@ -102,10 +102,7 @@ export class SelectionTreeType {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSelected(selection?: ListItem[]): ListItem[] {
|
getSelected(selection: ListItem[] = []): ListItem[] {
|
||||||
if (selection === undefined) {
|
|
||||||
selection = [];
|
|
||||||
}
|
|
||||||
if (this.selected) {
|
if (this.selected) {
|
||||||
selection.push(this.item);
|
selection.push(this.item);
|
||||||
}
|
}
|
||||||
@@ -200,6 +197,14 @@ export function selectAll() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getOrderedSelection(reverse: boolean = false): ListItem[] {
|
||||||
|
let selected: ListItem[] = [];
|
||||||
|
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||||
|
selected.push(...items);
|
||||||
|
}, reverse);
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
export function applyToOrderedItemsFromFile(selectedItems: ListItem[], callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void, reverse: boolean = true) {
|
export function applyToOrderedItemsFromFile(selectedItems: ListItem[], callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void, reverse: boolean = true) {
|
||||||
get(settings.fileOrder).forEach((fileId) => {
|
get(settings.fileOrder).forEach((fileId) => {
|
||||||
let level: ListLevel | undefined = undefined;
|
let level: ListLevel | undefined = undefined;
|
||||||
|
@@ -21,7 +21,7 @@
|
|||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
|
|
||||||
import { map, newGPXFile, routingControls, selectFileWhenLoaded } from '$lib/stores';
|
import { map, newGPXFile, routingControls, selectFileWhenLoaded } from '$lib/stores';
|
||||||
import { dbUtils, getFileIds, settings } from '$lib/db';
|
import { dbUtils, getFile, getFileIds, settings } from '$lib/db';
|
||||||
import { brouterProfiles, routingProfileSelectItem } from './Routing';
|
import { brouterProfiles, routingProfileSelectItem } from './Routing';
|
||||||
|
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
@@ -30,9 +30,15 @@
|
|||||||
import mapboxgl from 'mapbox-gl';
|
import mapboxgl from 'mapbox-gl';
|
||||||
import { fileObservers } from '$lib/db';
|
import { fileObservers } from '$lib/db';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { selection } from '$lib/components/file-list/Selection';
|
import { getOrderedSelection, selection } from '$lib/components/file-list/Selection';
|
||||||
import { ListRootItem, type ListItem } from '$lib/components/file-list/FileList';
|
import {
|
||||||
import { flyAndScale } from '$lib/utils';
|
ListFileItem,
|
||||||
|
ListRootItem,
|
||||||
|
ListTrackItem,
|
||||||
|
ListTrackSegmentItem,
|
||||||
|
type ListItem
|
||||||
|
} from '$lib/components/file-list/FileList';
|
||||||
|
import { flyAndScale, resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { TrackPoint } from 'gpx';
|
import { TrackPoint } from 'gpx';
|
||||||
|
|
||||||
@@ -86,10 +92,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
setCrosshairCursor();
|
||||||
$map?.on('click', createFileWithPoint);
|
$map?.on('click', createFileWithPoint);
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
resetCursor();
|
||||||
$map?.off('click', createFileWithPoint);
|
$map?.off('click', createFileWithPoint);
|
||||||
|
|
||||||
routingControls.forEach((controls) => controls.destroy());
|
routingControls.forEach((controls) => controls.destroy());
|
||||||
@@ -178,10 +186,33 @@
|
|||||||
slot="data"
|
slot="data"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="flex flex-row gap-1 text-xs px-2"
|
class="flex flex-row gap-1 text-xs px-2"
|
||||||
disabled={$selection.size != 1 || !validSelection}
|
disabled={!validSelection}
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
const fileId = get(selection).getSelected()[0].getFileId();
|
const selected = getOrderedSelection();
|
||||||
routingControls.get(fileId)?.routeToStart();
|
if (selected.length > 0) {
|
||||||
|
const firstFileId = selected[0].getFileId();
|
||||||
|
const firstFile = getFile(firstFileId);
|
||||||
|
if (firstFile) {
|
||||||
|
let start = (() => {
|
||||||
|
if (selected[0] instanceof ListFileItem) {
|
||||||
|
return firstFile.trk[0]?.trkseg[0]?.trkpt[0];
|
||||||
|
} else if (selected[0] instanceof ListTrackItem) {
|
||||||
|
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[0]?.trkpt[0];
|
||||||
|
} else if (selected[0] instanceof ListTrackSegmentItem) {
|
||||||
|
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[
|
||||||
|
selected[0].getSegmentIndex()
|
||||||
|
]?.trkpt[0];
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (start !== undefined) {
|
||||||
|
const lastFileId = selected[selected.length - 1].getFileId();
|
||||||
|
routingControls
|
||||||
|
.get(lastFileId)
|
||||||
|
?.appendAnchorWithCoordinates(start.getCoordinates());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Home size="12" />{$_('toolbar.routing.route_back_to_start.button')}
|
<Home size="12" />{$_('toolbar.routing.route_back_to_start.button')}
|
||||||
@@ -193,11 +224,8 @@
|
|||||||
slot="data"
|
slot="data"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="flex flex-row gap-1 text-xs px-2"
|
class="flex flex-row gap-1 text-xs px-2"
|
||||||
disabled={$selection.size != 1 || !validSelection}
|
disabled={!validSelection}
|
||||||
on:click={() => {
|
on:click={dbUtils.createRoundTripForSelection}
|
||||||
const fileId = get(selection).getSelected()[0].getFileId();
|
|
||||||
routingControls.get(fileId)?.createRoundTrip();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Repeat size="12" />{$_('toolbar.routing.round_trip.button')}
|
<Repeat size="12" />{$_('toolbar.routing.round_trip.button')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -206,9 +234,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="w-full flex flex-row gap-2 items-end justify-between">
|
<div class="w-full flex flex-row gap-2 items-end justify-between">
|
||||||
<Help>
|
<Help>
|
||||||
{#if $selection.size > 1}
|
{#if !validSelection}
|
||||||
<div>{$_('toolbar.routing.help_multiple_files')}</div>
|
|
||||||
{:else if $selection.size == 0 || !validSelection}
|
|
||||||
<div>{$_('toolbar.routing.help_no_file')}</div>
|
<div>{$_('toolbar.routing.help_no_file')}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div>{$_('toolbar.routing.help')}</div>
|
<div>{$_('toolbar.routing.help')}</div>
|
||||||
|
@@ -7,10 +7,10 @@ import { toast } from "svelte-sonner";
|
|||||||
|
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
import { dbUtils, type GPXFileWithStatistics } from "$lib/db";
|
import { dbUtils, type GPXFileWithStatistics } from "$lib/db";
|
||||||
import { selection } from "$lib/components/file-list/Selection";
|
import { getOrderedSelection, selection } from "$lib/components/file-list/Selection";
|
||||||
import { ListFileItem, ListTrackItem, ListTrackSegmentItem } from "$lib/components/file-list/FileList";
|
import { ListFileItem, ListTrackItem, ListTrackSegmentItem } from "$lib/components/file-list/FileList";
|
||||||
import { currentTool, streetViewEnabled, Tool } from "$lib/stores";
|
import { currentTool, streetViewEnabled, Tool } from "$lib/stores";
|
||||||
import { resetCursor, setCrosshairCursor, setGrabbingCursor } from "$lib/utils";
|
import { resetCursor, setGrabbingCursor } from "$lib/utils";
|
||||||
|
|
||||||
export const canChangeStart = writable(false);
|
export const canChangeStart = writable(false);
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ export class RoutingControls {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let selected = get(selection).hasAnyChildren(new ListFileItem(this.fileId), true, ['waypoints']) && get(selection).size == 1;
|
let selected = get(selection).hasAnyChildren(new ListFileItem(this.fileId), true, ['waypoints']);
|
||||||
if (selected) {
|
if (selected) {
|
||||||
if (this.active) {
|
if (this.active) {
|
||||||
this.updateControls();
|
this.updateControls();
|
||||||
@@ -80,7 +80,6 @@ export class RoutingControls {
|
|||||||
this.map.on('move', this.toggleAnchorsForZoomLevelAndBoundsBinded);
|
this.map.on('move', this.toggleAnchorsForZoomLevelAndBoundsBinded);
|
||||||
this.map.on('click', this.appendAnchorBinded);
|
this.map.on('click', this.appendAnchorBinded);
|
||||||
this.map.on('mousemove', this.fileId, this.showTemporaryAnchorBinded);
|
this.map.on('mousemove', this.fileId, this.showTemporaryAnchorBinded);
|
||||||
setCrosshairCursor();
|
|
||||||
|
|
||||||
this.fileUnsubscribe = this.file.subscribe(this.updateControls.bind(this));
|
this.fileUnsubscribe = this.file.subscribe(this.updateControls.bind(this));
|
||||||
}
|
}
|
||||||
@@ -130,7 +129,6 @@ export class RoutingControls {
|
|||||||
this.map.off('mousemove', this.fileId, this.showTemporaryAnchorBinded);
|
this.map.off('mousemove', this.fileId, this.showTemporaryAnchorBinded);
|
||||||
this.map.off('mousemove', this.updateTemporaryAnchorBinded);
|
this.map.off('mousemove', this.updateTemporaryAnchorBinded);
|
||||||
this.temporaryAnchor.marker.remove();
|
this.temporaryAnchor.marker.remove();
|
||||||
resetCursor();
|
|
||||||
|
|
||||||
this.fileUnsubscribe();
|
this.fileUnsubscribe();
|
||||||
}
|
}
|
||||||
@@ -398,6 +396,12 @@ export class RoutingControls {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async appendAnchorWithCoordinates(coordinates: Coordinates) { // Add a new anchor to the end of the last segment
|
async appendAnchorWithCoordinates(coordinates: Coordinates) { // Add a new anchor to the end of the last segment
|
||||||
|
let selected = getOrderedSelection();
|
||||||
|
if (selected.length === 0 || selected[selected.length - 1].getFileId() !== this.fileId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let item = selected[selected.length - 1];
|
||||||
|
|
||||||
let lastAnchor = this.anchors[this.anchors.length - 1];
|
let lastAnchor = this.anchors[this.anchors.length - 1];
|
||||||
|
|
||||||
let newPoint = new TrackPoint({
|
let newPoint = new TrackPoint({
|
||||||
@@ -408,7 +412,6 @@ export class RoutingControls {
|
|||||||
|
|
||||||
if (!lastAnchor) {
|
if (!lastAnchor) {
|
||||||
dbUtils.applyToFile(this.fileId, (file) => {
|
dbUtils.applyToFile(this.fileId, (file) => {
|
||||||
let item = get(selection).getSelected()[0];
|
|
||||||
let trackIndex = file.trk.length > 0 ? file.trk.length - 1 : 0;
|
let trackIndex = file.trk.length > 0 ? file.trk.length - 1 : 0;
|
||||||
if (item instanceof ListTrackItem || item instanceof ListTrackSegmentItem) {
|
if (item instanceof ListTrackItem || item instanceof ListTrackSegmentItem) {
|
||||||
trackIndex = item.getTrackIndex();
|
trackIndex = item.getTrackIndex();
|
||||||
@@ -443,36 +446,6 @@ export class RoutingControls {
|
|||||||
await this.routeBetweenAnchors([lastAnchor, newAnchor], [lastAnchor.point.getCoordinates(), newAnchor.point.getCoordinates()]);
|
await this.routeBetweenAnchors([lastAnchor, newAnchor], [lastAnchor.point.getCoordinates(), newAnchor.point.getCoordinates()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
routeToStart() {
|
|
||||||
if (this.anchors.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastAnchor = this.anchors[this.anchors.length - 1];
|
|
||||||
let firstAnchor = this.anchors.find((anchor) => anchor.segment === lastAnchor.segment);
|
|
||||||
|
|
||||||
if (!firstAnchor) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.appendAnchorWithCoordinates(firstAnchor.point.getCoordinates());
|
|
||||||
}
|
|
||||||
|
|
||||||
createRoundTrip() {
|
|
||||||
if (this.anchors.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastAnchor = this.anchors[this.anchors.length - 1];
|
|
||||||
|
|
||||||
let segment = lastAnchor.segment;
|
|
||||||
dbUtils.applyToFile(this.fileId, (file) => {
|
|
||||||
let newSegment = segment.clone();
|
|
||||||
newSegment._reverse(segment.getEndTimestamp(), segment.getEndTimestamp());
|
|
||||||
file.replaceTrackPoints(lastAnchor.trackIndex, lastAnchor.segmentIndex, segment.trkpt.length, segment.trkpt.length, newSegment.trkpt.map((point) => point));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getNeighbouringAnchors(anchor: Anchor): [Anchor | null, Anchor | null] {
|
getNeighbouringAnchors(anchor: Anchor): [Anchor | null, Anchor | null] {
|
||||||
let previousAnchor: Anchor | null = null;
|
let previousAnchor: Anchor | null = null;
|
||||||
let nextAnchor: Anchor | null = null;
|
let nextAnchor: Anchor | null = null;
|
||||||
|
@@ -532,6 +532,32 @@ export const dbUtils = {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
createRoundTripForSelection() {
|
||||||
|
if (!get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyGlobal((draft) => {
|
||||||
|
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||||
|
let file = draft.get(fileId);
|
||||||
|
if (file) {
|
||||||
|
if (level === ListLevel.FILE) {
|
||||||
|
file.roundTrip();
|
||||||
|
} else if (level === ListLevel.TRACK) {
|
||||||
|
for (let item of items) {
|
||||||
|
let trackIndex = (item as ListTrackItem).getTrackIndex();
|
||||||
|
file.roundTripTrack(trackIndex);
|
||||||
|
}
|
||||||
|
} else if (level === ListLevel.SEGMENT) {
|
||||||
|
for (let item of items) {
|
||||||
|
let trackIndex = (item as ListTrackSegmentItem).getTrackIndex();
|
||||||
|
let segmentIndex = (item as ListTrackSegmentItem).getSegmentIndex();
|
||||||
|
file.roundTripTrackSegment(trackIndex, segmentIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
mergeSelection: (mergeTraces: boolean) => {
|
mergeSelection: (mergeTraces: boolean) => {
|
||||||
applyGlobal((draft) => {
|
applyGlobal((draft) => {
|
||||||
let first = true;
|
let first = true;
|
||||||
|
@@ -94,8 +94,7 @@
|
|||||||
"tooltip": "Return to the starting point by the same route"
|
"tooltip": "Return to the starting point by the same route"
|
||||||
},
|
},
|
||||||
"start_loop_here": "Start loop here",
|
"start_loop_here": "Start loop here",
|
||||||
"help_no_file": "Select a file item to use the routing tool, or create a new file from the menu.",
|
"help_no_file": "Select a file item to use the routing tool, or click on the map to start creating a new route.",
|
||||||
"help_multiple_files": "Select a single file item to use the routing tool.",
|
|
||||||
"help": "Click on the map to add a new anchor point, or drag existing ones to change the route.",
|
"help": "Click on the map to add a new anchor point, or drag existing ones to change the route.",
|
||||||
"activities": {
|
"activities": {
|
||||||
"bike": "Bike",
|
"bike": "Bike",
|
||||||
|
Reference in New Issue
Block a user