mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-11-01 20:19:39 +00:00
Migration to new Rust-based engine (#401)
* wasm first steps * parsing start * parsing continued * progress * progress * progress * progress * progress * progress * progress * progress * fixes * reorganize * change type * fix * progress * refactoring * progress * progress * progress * keep style info * progress * progress * remove (copy) in name * progress * merge methods into 1 * splice * progress * progress * progress * cleaning * progress * progress * progress * visibility * progress * progress * progress * progress * progress * fixes * progress * fix markers * fix * progress * progress * progress * progress * progress * progress * small fix * use engine info * fix popups * small fix * progress * progress * progress * clean tool * handle self closing elements * reduce data exchanges across boundary * copy on wasm side to avoid any data corruption * speed fix * routing * refactoring * reverse * reverse * crop * split * merge * progress * time tool * fix bounds for waypoints * reduce * fix reactivity * handle corrupt time data * export * progress * remove gpx js package * remove todo * update readme * cleaning * progress * fix link fields * remove unused fields * refactoring and tests * import and export of routes, ref #192 * per-chunk categories storage * cross-tab sync * outdated state * refactoring * reorder methods * refactoring * fix * improve flow * renaming * progress * progress * fix * progress * per file history * keep original flag * use history variant * fix * progress * file explorer * progress * fix * sticky folders * improved sticky folders * fix sortable * fix sortable * fix * fix tab size * no file delete on desktop * fix routing on other file * fix sorting * remember language * open links in browser * commit missing changes * avoid selecting on resize * clarify filename * explorer actions * fix folder styling * drop anywhere in folder * cleaning * fixes * remove focus-ring * full screen * small fix * consistent highlight color * improved preview * performance improvements * sparser data transfers * sparser data transfers * show popup once data is loaded * fix routing control size * fix * sparse desktop build * remove custom ign styles * use new endpoint for openhikingmap * strip native menu * reorder context menu items * route full support * route - track conversion * create route file action * route export, filter out anchors at high zoom level * clarify name handling * move hide & center to view * routes continued * update docs * fix power format, closes #378 * lint fixes * fix display of additional data with gaps, closes #361 * fix linting issues * fix import modules * fix multidrag * change base to resolve * fix type errors * fix layer typings * fix last type error * each keys * improve typings * deploy to cloudflare --------- Co-authored-by: vcoppe <vcoppe@hexaly.com>
This commit is contained in:
1 parent
6a0d434371
commit
8fe7d8d64d
484 files changed
+53949
-41152
No files matched your search
@@ -13,7 +13,7 @@
|
||||
Scissors,
|
||||
MountainSnow,
|
||||
} from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
|
||||
let props: {
|
||||
class?: string;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import Reduce from '$lib/components/toolbar/tools/reduce/Reduce.svelte';
|
||||
import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte';
|
||||
import { Popup } from 'maplibre-gl';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { settings } from '$lib/state/settings';
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
|
||||
@@ -11,15 +11,14 @@
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { Trash2 } from '@lucide/svelte';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import type { GeoJSONSource, LngLat } from 'maplibre-gl';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import type { GeoJSONSource, LngLat, MapMouseEvent, MapTouchEvent } from 'maplibre-gl';
|
||||
import { engine } from '$lib/engine';
|
||||
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||
|
||||
let props: {
|
||||
@@ -82,18 +81,18 @@
|
||||
});
|
||||
|
||||
let mousedown = false;
|
||||
function onMouseDown(e: any) {
|
||||
function onMouseDown(e: MapMouseEvent | MapTouchEvent) {
|
||||
mousedown = true;
|
||||
rectangleCoordinates = [e.lngLat, e.lngLat];
|
||||
}
|
||||
|
||||
function onMouseMove(e: any) {
|
||||
function onMouseMove(e: MapMouseEvent | MapTouchEvent) {
|
||||
if (mousedown) {
|
||||
rectangleCoordinates[1] = e.lngLat;
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseUp(e: any) {
|
||||
function onMouseUp() {
|
||||
mousedown = false;
|
||||
}
|
||||
|
||||
@@ -130,7 +129,8 @@
|
||||
}
|
||||
});
|
||||
|
||||
let validSelection = $derived($selection.size > 0);
|
||||
const selection = engine.selection;
|
||||
let validSelection = $derived($selection.type !== 'empty');
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 w-full max-w-80 items-center {props.class ?? ''}">
|
||||
@@ -163,17 +163,13 @@
|
||||
class="w-full"
|
||||
disabled={!validSelection || rectangleCoordinates.length != 2}
|
||||
onclick={() => {
|
||||
fileActions.cleanSelection(
|
||||
[
|
||||
{
|
||||
lat: Math.min(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
|
||||
lon: Math.min(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng),
|
||||
},
|
||||
{
|
||||
lat: Math.max(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
|
||||
lon: Math.max(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng),
|
||||
},
|
||||
],
|
||||
engine.clean(
|
||||
{
|
||||
west: Math.min(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng),
|
||||
south: Math.min(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
|
||||
east: Math.max(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng),
|
||||
north: Math.max(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
|
||||
},
|
||||
cleanType === CleanType.INSIDE,
|
||||
deleteTrackpoints,
|
||||
deleteWaypoints
|
||||
|
||||
@@ -2,16 +2,29 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { MountainSnow } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { getElevation, getURLForLanguage } from '$lib/utils';
|
||||
import { engine } from '$lib/engine';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
let props: {
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
let validSelection = $derived($selection.size > 0);
|
||||
const statistics = engine.statistics;
|
||||
|
||||
let validSelection = $derived($statistics.length > 0);
|
||||
|
||||
async function addElevation() {
|
||||
const stats = get(statistics);
|
||||
const points = Array.from(stats.lng, (lng, i) => ({ lng, lat: stats.lat[i] }));
|
||||
const ele = await getElevation(points);
|
||||
|
||||
// the selection or the files may have changed while the elevation was loading
|
||||
if (get(statistics) === stats) {
|
||||
await engine.setElevation(Float64Array.from(ele));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 w-full max-w-80 {props.class ?? ''}">
|
||||
@@ -19,7 +32,7 @@
|
||||
variant="outline"
|
||||
class="whitespace-normal h-fit min-h-8 py-1"
|
||||
disabled={!validSelection}
|
||||
onclick={() => fileActions.addElevationToSelection()}
|
||||
onclick={() => addElevation()}
|
||||
>
|
||||
<MountainSnow size="16" class="shrink-0" />
|
||||
{i18n._('toolbar.elevation.button')}
|
||||
|
||||
@@ -1,51 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Ungroup } from '@lucide/svelte';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem,
|
||||
ListWaypointItem,
|
||||
ListWaypointsItem,
|
||||
} from '$lib/components/file-list/file-list';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { get } from 'svelte/store';
|
||||
import { engine } from '$lib/engine';
|
||||
|
||||
let props: {
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
let validSelection = $derived(
|
||||
$selection.size > 0 &&
|
||||
$selection.getSelected().every((item) => {
|
||||
if (
|
||||
item instanceof ListWaypointsItem ||
|
||||
item instanceof ListWaypointItem ||
|
||||
item instanceof ListTrackSegmentItem
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
let file = fileStateCollection.getFile(item.getFileId());
|
||||
if (file) {
|
||||
if (item instanceof ListFileItem) {
|
||||
return file.getSegments().length > 1;
|
||||
} else if (item instanceof ListTrackItem) {
|
||||
if (item.getTrackIndex() < file.trk.length) {
|
||||
return file.trk[item.getTrackIndex()].getSegments().length > 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const selection = engine.selection;
|
||||
const files = engine.files;
|
||||
const statistics = engine.statistics;
|
||||
|
||||
/** 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 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, routes);
|
||||
}
|
||||
|
||||
// files or tracks, which all have several segments
|
||||
let validSelection = $derived.by(() => {
|
||||
// what is selected can change without the selection: the files are edited
|
||||
void $statistics;
|
||||
switch ($selection.type) {
|
||||
case 'file':
|
||||
return (
|
||||
$selection.fileIds.length > 0 &&
|
||||
$selection.fileIds.every((fileId) => segmentCount(fileId) > 1)
|
||||
);
|
||||
case 'track': {
|
||||
const { fileId, trackIds } = $selection;
|
||||
return (
|
||||
trackIds.length > 0 &&
|
||||
trackIds.every((trackId) => segmentCount(fileId, trackId) > 1)
|
||||
);
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 w-full max-w-80 {props.class ?? ''}">
|
||||
<Button variant="outline" disabled={!validSelection} onclick={fileActions.extractSelection}>
|
||||
<Button variant="outline" disabled={!validSelection} onclick={() => engine.extract()}>
|
||||
<Ungroup size="16" />
|
||||
{i18n._('toolbar.extract.button')}
|
||||
</Button>
|
||||
|
||||
@@ -6,53 +6,55 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { ListFileItem, ListTrackItem } from '$lib/components/file-list/file-list';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { Group } from '@lucide/svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { gpxStatistics } from '$lib/logic/statistics';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { engine } from '$lib/engine';
|
||||
|
||||
let props: {
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
const selection = engine.selection;
|
||||
const statistics = engine.statistics;
|
||||
|
||||
// the number of segments of the selection that have trackpoints
|
||||
const request = engine.requestStatistics();
|
||||
request.set(['anchors']);
|
||||
onDestroy(() => request.release());
|
||||
let segmentCount = $derived($statistics.anchors?.segmentStarts.length ?? 0);
|
||||
|
||||
// several files, routes, tracks or segments, or a single file or track with several segments
|
||||
let canMergeTraces = $derived.by(() => {
|
||||
if ($selection.size > 1) {
|
||||
return true;
|
||||
} else if ($selection.size === 1) {
|
||||
let selected = $selection.getSelected()[0];
|
||||
if (selected instanceof ListFileItem) {
|
||||
let file = fileStateCollection.getFile(selected.getFileId());
|
||||
if (file) {
|
||||
return file.getSegments().length > 1;
|
||||
}
|
||||
} else if (selected instanceof ListTrackItem) {
|
||||
let trackIndex = selected.getTrackIndex();
|
||||
let file = fileStateCollection.getFile(selected.getFileId());
|
||||
if (file && trackIndex < file.trk.length) {
|
||||
return file.trk[trackIndex].getSegments().length > 1;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
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':
|
||||
return $selection.segmentIds.length > 1;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// several files or tracks, that can be put together (a route has a single segment: routes
|
||||
// cannot)
|
||||
let canMergeContents = $derived(
|
||||
$selection.size > 1 &&
|
||||
$selection
|
||||
.getSelected()
|
||||
.some((item) => item instanceof ListFileItem || item instanceof ListTrackItem)
|
||||
($selection.type === 'file' && $selection.fileIds.length > 1) ||
|
||||
($selection.type === 'track' && $selection.trackIds.length > 1)
|
||||
);
|
||||
|
||||
let hasTimes = $derived(($statistics.global.totalTime ?? 0) > 0);
|
||||
let removeGaps = $state(false);
|
||||
let mergeType = $state(MergeType.TRACES);
|
||||
</script>
|
||||
@@ -68,7 +70,7 @@
|
||||
{i18n._('toolbar.merge.merge_contents')}
|
||||
</Label>
|
||||
</RadioGroup.Root>
|
||||
{#if mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0}
|
||||
{#if mergeType === MergeType.TRACES && hasTimes}
|
||||
<div class="flex flex-row items-center gap-1.5">
|
||||
<Checkbox id="remove-gaps" bind:checked={removeGaps} />
|
||||
<Label for="remove-gaps">{i18n._('toolbar.merge.remove_gaps')}</Label>
|
||||
@@ -80,9 +82,9 @@
|
||||
disabled={(mergeType === MergeType.TRACES && !canMergeTraces) ||
|
||||
(mergeType === MergeType.CONTENTS && !canMergeContents)}
|
||||
onclick={() => {
|
||||
fileActions.mergeSelection(
|
||||
mergeType === MergeType.TRACES,
|
||||
mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0 && removeGaps
|
||||
engine.merge(
|
||||
mergeType === MergeType.TRACES ? 'connect' : 'group',
|
||||
mergeType === MergeType.TRACES && hasTimes && removeGaps
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -10,28 +10,24 @@
|
||||
getConvertedVelocity,
|
||||
milesToKilometers,
|
||||
nauticalMilesToKilometers,
|
||||
} from '$lib/units';
|
||||
} from '$lib/units/units';
|
||||
import { CalendarDate, type DateValue } from '@internationalized/date';
|
||||
import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from '@lucide/svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListRootItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem,
|
||||
} from '$lib/components/file-list/file-list';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { fileActionManager } from '$lib/logic/file-action-manager';
|
||||
import { gpxStatistics } from '$lib/logic/statistics';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { engine } from '$lib/engine';
|
||||
|
||||
let props: {
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
const selection = engine.selection;
|
||||
const statistics = engine.statistics;
|
||||
let global = $derived($statistics.global);
|
||||
|
||||
let startDate: DateValue | undefined = $state(undefined);
|
||||
let startTime: string | undefined = $state(undefined);
|
||||
let endDate: DateValue | undefined = $state(undefined);
|
||||
@@ -59,26 +55,28 @@
|
||||
}
|
||||
|
||||
function setGPXData() {
|
||||
if ($gpxStatistics.global.time.start) {
|
||||
startDate = toCalendarDate($gpxStatistics.global.time.start);
|
||||
startTime = toTimeString($gpxStatistics.global.time.start);
|
||||
if (global.startTime !== undefined) {
|
||||
const start = new Date(global.startTime);
|
||||
startDate = toCalendarDate(start);
|
||||
startTime = toTimeString(start);
|
||||
} else {
|
||||
startDate = undefined;
|
||||
startTime = undefined;
|
||||
}
|
||||
if ($gpxStatistics.global.time.end) {
|
||||
endDate = toCalendarDate($gpxStatistics.global.time.end);
|
||||
endTime = toTimeString($gpxStatistics.global.time.end);
|
||||
if (global.endTime !== undefined) {
|
||||
const end = new Date(global.endTime);
|
||||
endDate = toCalendarDate(end);
|
||||
endTime = toTimeString(end);
|
||||
} else {
|
||||
endDate = undefined;
|
||||
endTime = undefined;
|
||||
}
|
||||
if ($gpxStatistics.global.time.moving && $gpxStatistics.global.speed.moving) {
|
||||
movingTime = $gpxStatistics.global.time.moving;
|
||||
setSpeed($gpxStatistics.global.speed.moving);
|
||||
} else if ($gpxStatistics.global.time.total && $gpxStatistics.global.speed.total) {
|
||||
movingTime = $gpxStatistics.global.time.total;
|
||||
setSpeed($gpxStatistics.global.speed.total);
|
||||
if (global.movingTime && global.movingSpeed) {
|
||||
movingTime = global.movingTime;
|
||||
setSpeed(global.movingSpeed);
|
||||
} else if (global.totalTime && global.totalSpeed) {
|
||||
movingTime = global.totalTime;
|
||||
setSpeed(global.totalSpeed);
|
||||
} else {
|
||||
movingTime = undefined;
|
||||
speed = undefined;
|
||||
@@ -86,7 +84,7 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($gpxStatistics && $velocityUnits && $distanceUnits) {
|
||||
if ($statistics && $velocityUnits && $distanceUnits) {
|
||||
untrack(() => setGPXData());
|
||||
}
|
||||
});
|
||||
@@ -102,16 +100,23 @@
|
||||
return new Date(date.year, date.month - 1, date.day, hours, minutes, seconds);
|
||||
}
|
||||
|
||||
/** Total time over moving time. */
|
||||
function timeRatio() {
|
||||
return global.movingTime && global.totalTime ? global.totalTime / global.movingTime : 1;
|
||||
}
|
||||
|
||||
/** The distance to cover at the speed: the moving one if there is one. */
|
||||
function distance() {
|
||||
return global.movingDistance ? global.movingDistance : global.totalDistance;
|
||||
}
|
||||
|
||||
function updateEnd() {
|
||||
if (startDate && movingTime !== undefined) {
|
||||
if (startTime === undefined) {
|
||||
startTime = '00:00:00';
|
||||
}
|
||||
let start = getDate(startDate, startTime);
|
||||
let ratio =
|
||||
$gpxStatistics.global.time.moving > 0
|
||||
? $gpxStatistics.global.time.total / $gpxStatistics.global.time.moving
|
||||
: 1;
|
||||
let ratio = timeRatio();
|
||||
let end = new Date(start.getTime() + ratio * movingTime * 1000);
|
||||
endDate = toCalendarDate(end);
|
||||
endTime = toTimeString(end);
|
||||
@@ -124,10 +129,7 @@
|
||||
endTime = '00:00:00';
|
||||
}
|
||||
let end = getDate(endDate, endTime);
|
||||
let ratio =
|
||||
$gpxStatistics.global.time.moving > 0
|
||||
? $gpxStatistics.global.time.total / $gpxStatistics.global.time.moving
|
||||
: 1;
|
||||
let ratio = timeRatio();
|
||||
let start = new Date(end.getTime() - ratio * movingTime * 1000);
|
||||
startDate = toCalendarDate(start);
|
||||
startTime = toTimeString(start);
|
||||
@@ -157,11 +159,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
let distance =
|
||||
$gpxStatistics.global.distance.moving > 0
|
||||
? $gpxStatistics.global.distance.moving
|
||||
: $gpxStatistics.global.distance.total;
|
||||
movingTime = (distance / speedValue) * 3600;
|
||||
movingTime = (distance() / speedValue) * 3600;
|
||||
|
||||
updateEnd();
|
||||
}
|
||||
@@ -170,16 +168,16 @@
|
||||
if (movingTime === undefined) {
|
||||
return;
|
||||
}
|
||||
let distance =
|
||||
$gpxStatistics.global.distance.moving > 0
|
||||
? $gpxStatistics.global.distance.moving
|
||||
: $gpxStatistics.global.distance.total;
|
||||
setSpeed(distance / (movingTime / 3600));
|
||||
setSpeed(distance() / (movingTime / 3600));
|
||||
updateEnd();
|
||||
}
|
||||
|
||||
// a single file, route, track or segment
|
||||
let canUpdate = $derived(
|
||||
$selection.size === 1 && $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints'])
|
||||
($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)
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -309,7 +307,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined}
|
||||
{#if !global.movingTime}
|
||||
<div class="mt-0.5 flex flex-row gap-1 items-center">
|
||||
<Checkbox id="artificial-time" bind:checked={artificial} disabled={!canUpdate} />
|
||||
<Label for="artificial-time">
|
||||
@@ -324,78 +322,33 @@
|
||||
disabled={!canUpdate}
|
||||
class="grow shrink whitespace-normal h-fit min-h-8 py-1"
|
||||
onclick={() => {
|
||||
let effectiveSpeed = getSpeed();
|
||||
const speedValue = getSpeed();
|
||||
if (
|
||||
startDate === undefined ||
|
||||
startTime === undefined ||
|
||||
effectiveSpeed === undefined ||
|
||||
speedValue === undefined ||
|
||||
movingTime === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Math.abs(effectiveSpeed - $gpxStatistics.global.speed.moving) < 0.01) {
|
||||
effectiveSpeed = $gpxStatistics.global.speed.moving;
|
||||
let effectiveSpeed: number = speedValue;
|
||||
const movingSpeed = global.movingSpeed ?? 0;
|
||||
if (Math.abs(effectiveSpeed - movingSpeed) < 0.01) {
|
||||
effectiveSpeed = movingSpeed;
|
||||
}
|
||||
|
||||
let ratio = 1;
|
||||
if (
|
||||
$gpxStatistics.global.speed.moving > 0 &&
|
||||
$gpxStatistics.global.speed.moving !== effectiveSpeed
|
||||
) {
|
||||
ratio = $gpxStatistics.global.speed.moving / effectiveSpeed;
|
||||
if (movingSpeed > 0 && movingSpeed !== effectiveSpeed) {
|
||||
ratio = movingSpeed / effectiveSpeed;
|
||||
}
|
||||
|
||||
let item = $selection.getSelected()[0];
|
||||
let fileId = item.getFileId();
|
||||
fileActionManager.applyToFile(fileId, (file) => {
|
||||
if (item instanceof ListFileItem) {
|
||||
if (artificial && !$gpxStatistics.global.time.moving) {
|
||||
file.createArtificialTimestamps(
|
||||
getDate(startDate!, startTime!),
|
||||
movingTime!
|
||||
);
|
||||
} else {
|
||||
file.changeTimestamps(
|
||||
getDate(startDate!, startTime!),
|
||||
effectiveSpeed,
|
||||
ratio
|
||||
);
|
||||
}
|
||||
} else if (item instanceof ListTrackItem) {
|
||||
if (artificial && !$gpxStatistics.global.time.moving) {
|
||||
file.createArtificialTimestamps(
|
||||
getDate(startDate!, startTime!),
|
||||
movingTime!,
|
||||
item.getTrackIndex()
|
||||
);
|
||||
} else {
|
||||
file.changeTimestamps(
|
||||
getDate(startDate!, startTime!),
|
||||
effectiveSpeed,
|
||||
ratio,
|
||||
item.getTrackIndex()
|
||||
);
|
||||
}
|
||||
} else if (item instanceof ListTrackSegmentItem) {
|
||||
if (artificial && !$gpxStatistics.global.time.moving) {
|
||||
file.createArtificialTimestamps(
|
||||
getDate(startDate!, startTime!),
|
||||
movingTime!,
|
||||
item.getTrackIndex(),
|
||||
item.getSegmentIndex()
|
||||
);
|
||||
} else {
|
||||
file.changeTimestamps(
|
||||
getDate(startDate!, startTime!),
|
||||
effectiveSpeed,
|
||||
ratio,
|
||||
item.getTrackIndex(),
|
||||
item.getSegmentIndex()
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
const start = getDate(startDate, startTime);
|
||||
if (artificial && !global.movingTime) {
|
||||
engine.createArtificialTimestamps(start, movingTime);
|
||||
} else {
|
||||
engine.changeTimestamps(start, effectiveSpeed, ratio);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CalendarClock size="16" class="shrink-0" />
|
||||
|
||||
@@ -2,26 +2,46 @@
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Slider } from '$lib/components/ui/slider';
|
||||
import { ListRootItem } from '$lib/components/file-list/file-list';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { Funnel } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { minTolerance, ReducedGPXLayerCollection, tolerance } from './utils.svelte';
|
||||
import { engine } from '$lib/engine';
|
||||
import { coversSegments } from '$lib/engine/selection-helpers';
|
||||
import { minTolerance, ReducedLayer, tolerance } from './utils.svelte';
|
||||
|
||||
let props: { class?: string } = $props();
|
||||
|
||||
let sliderValue = $state(50);
|
||||
const maxTolerance = 10000;
|
||||
|
||||
let validSelection = $derived(
|
||||
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints'])
|
||||
);
|
||||
const selection = engine.selection;
|
||||
const statistics = engine.statistics;
|
||||
|
||||
let reducedLayers = new ReducedGPXLayerCollection();
|
||||
// the preview needs the start of each segment
|
||||
const request = engine.requestStatistics();
|
||||
request.set(['anchors']);
|
||||
|
||||
let validSelection = $derived(coversSegments($selection));
|
||||
|
||||
let reducedLayer = new ReducedLayer();
|
||||
|
||||
// read again only when the selection or the files change, not with the tolerance
|
||||
let distances = $state<Float64Array>(new Float64Array());
|
||||
$effect(() => {
|
||||
const current = $statistics;
|
||||
engine.reductionDistances().then((values) => {
|
||||
if (current === $statistics) {
|
||||
distances = values;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
reducedLayer.update($statistics, distances, $tolerance);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
tolerance.set(
|
||||
@@ -30,7 +50,8 @@
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
reducedLayers.destroy();
|
||||
request.release();
|
||||
reducedLayer.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -44,9 +65,9 @@
|
||||
</Label>
|
||||
<Label class="flex flex-row justify-between">
|
||||
<span>{i18n._('toolbar.reduce.number_of_points')}</span>
|
||||
<span class="font-normal">{reducedLayers.currentPoints}/{reducedLayers.maxPoints}</span>
|
||||
<span class="font-normal">{reducedLayer.currentPoints}/{reducedLayer.maxPoints}</span>
|
||||
</Label>
|
||||
<Button variant="outline" disabled={!validSelection} onclick={() => reducedLayers.reduce()}>
|
||||
<Button variant="outline" disabled={!validSelection} onclick={() => engine.reduce($tolerance)}>
|
||||
<Funnel size="16" />
|
||||
{i18n._('toolbar.reduce.button')}
|
||||
</Button>
|
||||
|
||||
@@ -1,205 +1,85 @@
|
||||
import { ListItem, ListTrackSegmentItem } from '$lib/components/file-list/file-list';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { GPXFileStateCollectionObserver, type GPXFileState } from '$lib/logic/file-state';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx';
|
||||
import type { SelectionStatistics } from '$lib/engine';
|
||||
import type { GeoJSONSource } from 'maplibre-gl';
|
||||
import { get, writable } from 'svelte/store';
|
||||
|
||||
/** Lowest tolerance (m) of the reduction, the one of the engine. */
|
||||
export const minTolerance = 0.1;
|
||||
|
||||
export class ReducedGPXLayer {
|
||||
private _fileState: GPXFileState;
|
||||
private _updateSimplified: (
|
||||
itemId: string,
|
||||
data: [ListItem, number, SimplifiedTrackPoint[]]
|
||||
) => void;
|
||||
private _unsubscribes: (() => void)[] = [];
|
||||
|
||||
constructor(
|
||||
fileState: GPXFileState,
|
||||
updateSimplified: (itemId: string, data: [ListItem, number, SimplifiedTrackPoint[]]) => void
|
||||
) {
|
||||
this._fileState = fileState;
|
||||
this._updateSimplified = updateSimplified;
|
||||
this._unsubscribes.push(this._fileState.subscribe(() => this.update()));
|
||||
}
|
||||
|
||||
update() {
|
||||
const file = this._fileState.file;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||
let segmentItem = new ListTrackSegmentItem(file._data.id, trackIndex, segmentIndex);
|
||||
this._updateSimplified(segmentItem.getFullId(), [
|
||||
segmentItem,
|
||||
segment.trkpt.length,
|
||||
ramerDouglasPeucker(segment.trkpt, minTolerance),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
}
|
||||
}
|
||||
|
||||
export const tolerance = writable<number>(0);
|
||||
|
||||
export class ReducedGPXLayerCollection {
|
||||
private _layers: Map<string, ReducedGPXLayer> = new Map();
|
||||
private _simplified: Map<string, [ListItem, number, SimplifiedTrackPoint[]]>;
|
||||
private _currentPoints = $state(0);
|
||||
private _maxPoints = $state(0);
|
||||
private _fileStateCollectionObserver: GPXFileStateCollectionObserver;
|
||||
private _updateSimplified = this.updateSimplified.bind(this);
|
||||
private _unsubscribes: (() => void)[] = [];
|
||||
const SOURCE = 'simplified';
|
||||
|
||||
constructor() {
|
||||
this._layers = new Map();
|
||||
this._simplified = new Map();
|
||||
this._fileStateCollectionObserver = new GPXFileStateCollectionObserver(
|
||||
(newFiles) => {
|
||||
newFiles.forEach((fileState, fileId) => {
|
||||
this._layers.set(
|
||||
fileId,
|
||||
new ReducedGPXLayer(fileState, this._updateSimplified)
|
||||
);
|
||||
});
|
||||
},
|
||||
(fileId) => {
|
||||
this._layers.get(fileId)?.destroy();
|
||||
this._layers.delete(fileId);
|
||||
},
|
||||
() => {
|
||||
this._layers.forEach((layer) => layer.destroy());
|
||||
this._layers.clear();
|
||||
/**
|
||||
* Draws on the map what the selection would be reduced to with a tolerance, from the distances of
|
||||
* the engine (see `Engine.reductionDistances`). It is the same as the result of `Engine.reduce`.
|
||||
*/
|
||||
export class ReducedLayer {
|
||||
/** The number of trackpoints kept by the tolerance. */
|
||||
currentPoints = $state(0);
|
||||
/** The number of trackpoints of the selection. */
|
||||
maxPoints = $state(0);
|
||||
|
||||
update(statistics: SelectionStatistics, distances: Float64Array, tolerance: number) {
|
||||
const { lng, lat, length } = statistics;
|
||||
const starts = statistics.anchors?.segmentStarts ?? new Uint32Array();
|
||||
|
||||
const data: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: [] };
|
||||
let current = 0;
|
||||
// distances are for the same selection as the statistics, unless they are about to change
|
||||
const count = Math.min(length, distances.length);
|
||||
for (let i = 0; i < starts.length; i++) {
|
||||
const end = Math.min(i + 1 < starts.length ? starts[i + 1] : length, count);
|
||||
const coordinates: [number, number][] = [];
|
||||
for (let j = starts[i]; j < end; j++) {
|
||||
if (distances[j] > tolerance) {
|
||||
coordinates.push([lng[j], lat[j]]);
|
||||
}
|
||||
}
|
||||
);
|
||||
this._unsubscribes.push(selection.subscribe(() => this.update()));
|
||||
this._unsubscribes.push(tolerance.subscribe(() => this.update()));
|
||||
}
|
||||
|
||||
updateSimplified(itemId: string, data: [ListItem, number, SimplifiedTrackPoint[]]) {
|
||||
this._simplified.set(itemId, data);
|
||||
if (get(selection).hasAnyParent(data[0])) {
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
|
||||
removeSimplified(itemId: string) {
|
||||
if (this._simplified.delete(itemId)) {
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
this._currentPoints = 0;
|
||||
this._maxPoints = 0;
|
||||
|
||||
let data: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
};
|
||||
|
||||
this._simplified.forEach(([item, maxPts, points], itemFullId) => {
|
||||
if (!get(selection).hasAnyParent(item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._maxPoints += maxPts;
|
||||
|
||||
let current = points.filter(
|
||||
(point) => point.distance === undefined || point.distance >= get(tolerance)
|
||||
);
|
||||
this._currentPoints += current.length;
|
||||
|
||||
current += coordinates.length;
|
||||
data.features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: current.map((point) => [
|
||||
point.point.getLongitude(),
|
||||
point.point.getLatitude(),
|
||||
]),
|
||||
},
|
||||
geometry: { type: 'LineString', coordinates },
|
||||
properties: {},
|
||||
});
|
||||
});
|
||||
}
|
||||
this.currentPoints = current;
|
||||
this.maxPoints = length;
|
||||
|
||||
const map_ = get(map);
|
||||
if (!map_) {
|
||||
return;
|
||||
}
|
||||
|
||||
let source: GeoJSONSource | undefined = map_.getSource('simplified');
|
||||
const source = map_.getSource<GeoJSONSource>(SOURCE);
|
||||
if (source) {
|
||||
source.setData(data);
|
||||
} else {
|
||||
map_.addSource('simplified', {
|
||||
type: 'geojson',
|
||||
data: data,
|
||||
});
|
||||
map_.addSource(SOURCE, { type: 'geojson', data });
|
||||
}
|
||||
if (!map_.getLayer('simplified')) {
|
||||
if (!map_.getLayer(SOURCE)) {
|
||||
map_.addLayer(
|
||||
{
|
||||
id: 'simplified',
|
||||
id: SOURCE,
|
||||
type: 'line',
|
||||
source: 'simplified',
|
||||
paint: {
|
||||
'line-color': 'white',
|
||||
'line-width': 3,
|
||||
},
|
||||
source: SOURCE,
|
||||
paint: { 'line-color': 'white', 'line-width': 3 },
|
||||
},
|
||||
ANCHOR_LAYER_KEY.interactions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
reduce() {
|
||||
let itemsAndPoints = new Map<ListItem, TrackPoint[]>();
|
||||
this._simplified.forEach(([item, maxPts, points], itemFullId) => {
|
||||
if (!get(selection).hasAnyParent(item)) {
|
||||
return;
|
||||
}
|
||||
itemsAndPoints.set(
|
||||
item,
|
||||
points
|
||||
.filter(
|
||||
(point) => point.distance === undefined || point.distance >= get(tolerance)
|
||||
)
|
||||
.map((point) => point.point)
|
||||
);
|
||||
});
|
||||
fileActions.reduce(itemsAndPoints);
|
||||
}
|
||||
|
||||
get currentPoints() {
|
||||
return this._currentPoints;
|
||||
}
|
||||
|
||||
get maxPoints() {
|
||||
return this._maxPoints;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._fileStateCollectionObserver.destroy();
|
||||
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
|
||||
const map_ = get(map);
|
||||
if (!map_) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (map_.getLayer('simplified')) {
|
||||
map_.removeLayer('simplified');
|
||||
if (map_.getLayer(SOURCE)) {
|
||||
map_.removeLayer(SOURCE);
|
||||
}
|
||||
if (map_.getSource('simplified')) {
|
||||
map_.removeSource('simplified');
|
||||
if (map_.getSource(SOURCE)) {
|
||||
map_.removeSource(SOURCE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { MapMouseEvent, Popup } from 'maplibre-gl';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
@@ -22,26 +23,18 @@
|
||||
SquareArrowOutDownRight,
|
||||
} from '@lucide/svelte';
|
||||
import { routingProfiles } from '$lib/components/toolbar/tools/routing/routing';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListRootItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem,
|
||||
type ListItem,
|
||||
} from '$lib/components/file-list/file-list';
|
||||
import { get } from 'svelte/store';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { TrackPoint } from 'gpx';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { fileStateCollection, GPXFileStateCollectionObserver } from '$lib/logic/file-state';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { fileActions, getFileIds, newGPXFile } from '$lib/logic/file-actions';
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import { RoutingControls, routingControls } from './routing-controls';
|
||||
import type { Popup } from 'maplibre-gl';
|
||||
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';
|
||||
|
||||
let {
|
||||
minimized = $bindable(false),
|
||||
@@ -59,52 +52,22 @@
|
||||
|
||||
const { privateRoads, routing, routingProfile } = settings;
|
||||
|
||||
let fileStateCollectionObserver: GPXFileStateCollectionObserver;
|
||||
const selection = engine.selection;
|
||||
let routingControls: RoutingControls | undefined = undefined;
|
||||
|
||||
let validSelection = $derived(
|
||||
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints'])
|
||||
);
|
||||
// the tool works on files, routes, tracks and segments, not on waypoints
|
||||
let validSelection = $derived(coversSegments($selection));
|
||||
|
||||
function createFileWithPoint(e: any) {
|
||||
if ($selection.size === 0) {
|
||||
let file = newGPXFile();
|
||||
file.replaceTrackPoints(0, 0, 0, 0, [
|
||||
new TrackPoint({
|
||||
attributes: {
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
file._data.id = getFileIds(1)[0];
|
||||
fileActions.add(file);
|
||||
selection.selectFileWhenLoaded(file._data.id);
|
||||
function createFileWithPoint(e: MapMouseEvent) {
|
||||
if ($selection.type === 'empty') {
|
||||
// the engine selects the new file, which starts with the trackpoint: one undo step
|
||||
engine.newFile(newFileName(), { lng: e.lngLat.lng, lat: e.lngLat.lat });
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if ($map && popup && popupElement) {
|
||||
fileStateCollectionObserver = new GPXFileStateCollectionObserver(
|
||||
(newFiles) => {
|
||||
newFiles.forEach((fileState, fileId) => {
|
||||
routingControls.set(
|
||||
fileId,
|
||||
new RoutingControls(fileId, fileState, popup, popupElement)
|
||||
);
|
||||
});
|
||||
},
|
||||
(fileId) => {
|
||||
const controls = routingControls.get(fileId);
|
||||
if (controls) {
|
||||
controls.destroy();
|
||||
routingControls.delete(fileId);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
routingControls.forEach((controls) => controls.destroy());
|
||||
routingControls.clear();
|
||||
}
|
||||
);
|
||||
routingControls = new RoutingControls(popup, popupElement);
|
||||
|
||||
mapCursor.notify(MapCursorState.TOOL_WITH_CROSSHAIR, true);
|
||||
$map.on('click', createFileWithPoint);
|
||||
@@ -113,9 +76,8 @@
|
||||
|
||||
onDestroy(() => {
|
||||
if ($map) {
|
||||
if (fileStateCollectionObserver) {
|
||||
fileStateCollectionObserver.destroy();
|
||||
}
|
||||
routingControls?.destroy();
|
||||
routingControls = undefined;
|
||||
|
||||
mapCursor.notify(MapCursorState.TOOL_WITH_CROSSHAIR, false);
|
||||
$map.off('click', createFileWithPoint);
|
||||
@@ -168,7 +130,7 @@
|
||||
{i18n._(`toolbar.routing.activities.${$routingProfile}`)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each Object.keys(routingProfiles) as profile}
|
||||
{#each Object.keys(routingProfiles) as profile (profile)}
|
||||
<Select.Item value={profile}
|
||||
>{i18n._(
|
||||
`toolbar.routing.activities.${profile}`
|
||||
@@ -194,7 +156,7 @@
|
||||
variant="outline"
|
||||
class="gap-1 text-xs px-1.5 py-1.5 h-fit"
|
||||
disabled={!validSelection}
|
||||
onclick={fileActions.reverseSelection}
|
||||
onclick={() => engine.reverse()}
|
||||
>
|
||||
<ArrowRightLeft class="size-3" />{i18n._('toolbar.routing.reverse.button')}
|
||||
</ButtonWithTooltip>
|
||||
@@ -204,31 +166,10 @@
|
||||
class="gap-1 text-xs px-1.5 py-1.5 h-fit"
|
||||
disabled={!validSelection}
|
||||
onclick={() => {
|
||||
const selected = selection.getOrderedSelection();
|
||||
if (selected.length > 0) {
|
||||
const firstFileId = selected[0].getFileId();
|
||||
const firstFile = fileStateCollection.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());
|
||||
}
|
||||
}
|
||||
// the first trackpoint of the selection
|
||||
const { length, lng, lat } = get(engine.statistics);
|
||||
if (length > 0) {
|
||||
routingControls?.appendAnchorWithCoordinates({ lng: lng[0], lat: lat[0] });
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -239,7 +180,7 @@
|
||||
variant="outline"
|
||||
class="gap-1 text-xs px-1.5 py-1.5 h-fit"
|
||||
disabled={!validSelection}
|
||||
onclick={fileActions.createRoundTripForSelection}
|
||||
onclick={() => engine.roundTrip()}
|
||||
>
|
||||
<Repeat class="size-3" />{i18n._('toolbar.routing.round_trip.button')}
|
||||
</ButtonWithTooltip>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { canChangeStart } from './routing-controls';
|
||||
import { CirclePlay, Trash2 } from '@lucide/svelte';
|
||||
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
|
||||
let {
|
||||
element = $bindable(),
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,282 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { distance } from '$lib/geo/coordinates';
|
||||
import {
|
||||
brouterError,
|
||||
getTags,
|
||||
graphHopperError,
|
||||
graphhopperBlockPrivateCustomModels,
|
||||
interpolatePoints,
|
||||
parseBRouterRoute,
|
||||
parseGraphHopperRoute,
|
||||
routingProfiles,
|
||||
} from './routing';
|
||||
|
||||
describe('routing profiles', () => {
|
||||
it('block the private roads of every profile of GraphHopper that has a model', () => {
|
||||
const profiles = Object.values(routingProfiles)
|
||||
.filter((profile) => profile.engine === 'graphhopper')
|
||||
.map((profile) => profile.profile);
|
||||
assert.ok(profiles.length > 0);
|
||||
for (const profile of profiles) {
|
||||
assert.ok(graphhopperBlockPrivateCustomModels[profile], profile);
|
||||
}
|
||||
// and the models are about profiles that exist
|
||||
for (const profile of Object.keys(graphhopperBlockPrivateCustomModels)) {
|
||||
assert.ok(profiles.includes(profile), profile);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GraphHopper', () => {
|
||||
const response = (details: Record<string, unknown[]>, coordinates: number[][]) => ({
|
||||
paths: [{ points: { coordinates }, details }],
|
||||
});
|
||||
const coordinates = [
|
||||
[4.0, 50.0, 100],
|
||||
[4.1, 50.1, 110],
|
||||
[4.2, 50.2, 120],
|
||||
[4.3, 50.3, 130],
|
||||
[4.4, 50.4],
|
||||
];
|
||||
|
||||
it('gives the points of the route, with the elevation of the previous one when missing', () => {
|
||||
const route = parseGraphHopperRoute(response({}, coordinates));
|
||||
assert.deepEqual(route.lng, [4.0, 4.1, 4.2, 4.3, 4.4]);
|
||||
assert.deepEqual(route.lat, [50.0, 50.1, 50.2, 50.3, 50.4]);
|
||||
assert.deepEqual(route.ele, [100, 110, 120, 130, 130]);
|
||||
assert.deepEqual(route.surface, new Array(5).fill(undefined));
|
||||
});
|
||||
|
||||
it('gives the details to the points of their ranges, the last range including the last point', () => {
|
||||
const route = parseGraphHopperRoute(
|
||||
response(
|
||||
{
|
||||
road_class: [
|
||||
[0, 2, 'cycleway'],
|
||||
[2, 4, 'path'],
|
||||
],
|
||||
surface: [
|
||||
[0, 3, 'asphalt'],
|
||||
[3, 4, 'gravel'],
|
||||
],
|
||||
hike_rating: [[0, 4, '2']],
|
||||
mtb_rating: [
|
||||
[0, 1, '1'],
|
||||
[1, 4, '5'],
|
||||
],
|
||||
},
|
||||
coordinates
|
||||
)
|
||||
);
|
||||
assert.deepEqual(route.highway, ['cycleway', 'cycleway', 'path', 'path', 'path']);
|
||||
assert.deepEqual(route.surface, ['asphalt', 'asphalt', 'asphalt', 'gravel', 'gravel']);
|
||||
// the ratings are converted to the scales
|
||||
assert.deepEqual(route.sacScale, new Array(5).fill('mountain_hiking'));
|
||||
assert.deepEqual(route.mtbScale, ['0', '4', '4', '4', '4']);
|
||||
});
|
||||
|
||||
it('ignores the details that are missing or unknown', () => {
|
||||
const route = parseGraphHopperRoute(
|
||||
response(
|
||||
{
|
||||
road_class: [
|
||||
[0, 2, 'missing'],
|
||||
[2, 4, 'primary'],
|
||||
],
|
||||
surface: [[0, 4, 'other']],
|
||||
hike_rating: [[0, 4, '9']],
|
||||
},
|
||||
coordinates
|
||||
)
|
||||
);
|
||||
assert.deepEqual(route.highway, [undefined, undefined, 'primary', 'primary', 'primary']);
|
||||
assert.deepEqual(route.surface, new Array(5).fill(undefined));
|
||||
assert.deepEqual(route.sacScale, new Array(5).fill(undefined));
|
||||
});
|
||||
|
||||
it('turns its errors into the messages of the interface', () => {
|
||||
const error = (message: string, details = '', count = 2) =>
|
||||
graphHopperError({ message, hints: [{ details }] }, count).message;
|
||||
assert.equal(error('Cannot find point 0: 1,2'), 'toolbar.routing.error.from');
|
||||
assert.equal(error('Cannot find point 1: 1,2', '', 2), 'toolbar.routing.error.to');
|
||||
assert.equal(error('Cannot find point 1: 1,2', '', 3), 'toolbar.routing.error.via');
|
||||
assert.equal(
|
||||
error('x', 'com.graphhopper.PointDistanceExceededException'),
|
||||
'toolbar.routing.error.distance'
|
||||
);
|
||||
assert.equal(
|
||||
error('x', 'ConnectionNotFoundException: no way'),
|
||||
'toolbar.routing.error.connection'
|
||||
);
|
||||
assert.equal(error('something else'), 'something else');
|
||||
// an error without hints is still an error
|
||||
assert.equal(graphHopperError({ message: 'no hints' }, 2).message, 'no hints');
|
||||
assert.equal(graphHopperError({}, 2).message, '');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BRouter', () => {
|
||||
const messages = [
|
||||
[
|
||||
'Longitude',
|
||||
'Latitude',
|
||||
'Elevation',
|
||||
'Distance',
|
||||
'CostPerKm',
|
||||
'ElevCost',
|
||||
'TurnCost',
|
||||
'NodeCost',
|
||||
'InitialCost',
|
||||
'WayTags',
|
||||
'NodeTags',
|
||||
],
|
||||
[
|
||||
'4100000',
|
||||
'50100000',
|
||||
'100',
|
||||
'10',
|
||||
'0',
|
||||
'0',
|
||||
'0',
|
||||
'0',
|
||||
'0',
|
||||
'highway=path surface=gravel sac_scale=hiking',
|
||||
'',
|
||||
],
|
||||
[
|
||||
'4300000',
|
||||
'50300000',
|
||||
'120',
|
||||
'10',
|
||||
'0',
|
||||
'0',
|
||||
'0',
|
||||
'0',
|
||||
'0',
|
||||
'highway=residential surface=asphalt mtb:scale=1',
|
||||
'',
|
||||
],
|
||||
];
|
||||
const geojson = (coordinates: number[][], messages_: string[][] = messages) => ({
|
||||
features: [{ geometry: { coordinates }, properties: { messages: messages_ } }],
|
||||
});
|
||||
|
||||
it('reads the tags of a way', () => {
|
||||
assert.deepEqual(getTags('highway=path surface=gravel'), {
|
||||
highway: 'path',
|
||||
surface: 'gravel',
|
||||
});
|
||||
assert.deepEqual(getTags('sac_scale=hiking mtb:scale=2'), {
|
||||
sac_scale: 'hiking',
|
||||
mtb_scale: '2',
|
||||
});
|
||||
assert.deepEqual(getTags('a:b:c=d'), { a_b_c: 'd' });
|
||||
assert.deepEqual(getTags(''), { '': undefined });
|
||||
});
|
||||
|
||||
it('gives each point the tags of its way', () => {
|
||||
const route = parseBRouterRoute(
|
||||
geojson([
|
||||
[4.0, 50.0, 90],
|
||||
[4.1, 50.1, 100],
|
||||
[4.2, 50.2, 110],
|
||||
[4.3, 50.3, 120],
|
||||
[4.4, 50.4, 130],
|
||||
])
|
||||
);
|
||||
assert.deepEqual(route.ele, [90, 100, 110, 120, 130]);
|
||||
// the first way is the one of the first message, whose last point is the start of the next
|
||||
assert.deepEqual(route.highway, [
|
||||
'path',
|
||||
'residential',
|
||||
'residential',
|
||||
undefined,
|
||||
undefined,
|
||||
]);
|
||||
assert.deepEqual(route.surface, ['gravel', 'asphalt', 'asphalt', undefined, undefined]);
|
||||
assert.deepEqual(route.sacScale, ['hiking', undefined, undefined, undefined, undefined]);
|
||||
assert.deepEqual(route.mtbScale, [undefined, '1', '1', undefined, undefined]);
|
||||
});
|
||||
|
||||
it('has no tags for a route without messages', () => {
|
||||
const route = parseBRouterRoute(
|
||||
geojson(
|
||||
[
|
||||
[4, 50, 1],
|
||||
[4.1, 50.1, 2],
|
||||
],
|
||||
[messages[0]]
|
||||
)
|
||||
);
|
||||
assert.deepEqual(route.lng, [4, 4.1]);
|
||||
assert.deepEqual(route.highway, [undefined, undefined]);
|
||||
});
|
||||
|
||||
it('turns its errors into the messages of the interface', () => {
|
||||
assert.equal(
|
||||
brouterError('from-position not mapped in existing datafile').message,
|
||||
'toolbar.routing.error.from'
|
||||
);
|
||||
assert.equal(
|
||||
brouterError('via1-position not mapped in existing datafile').message,
|
||||
'toolbar.routing.error.via'
|
||||
);
|
||||
assert.equal(
|
||||
brouterError('to-position not mapped in existing datafile').message,
|
||||
'toolbar.routing.error.to'
|
||||
);
|
||||
assert.equal(
|
||||
brouterError('operation killed by Time-out').message,
|
||||
'toolbar.routing.error.timeout'
|
||||
);
|
||||
assert.equal(brouterError('other').message, 'other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('straight routes', () => {
|
||||
it('have a point every 50 m and the last point', () => {
|
||||
const a = { lng: 4, lat: 50 };
|
||||
const b = { lng: 4.001, lat: 50 };
|
||||
const route = interpolatePoints([a, b]);
|
||||
const length = distance(a, b);
|
||||
assert.equal(route.lng.length, Math.ceil(length / 50) + 1);
|
||||
assert.deepEqual([route.lng[0], route.lat[0]], [4, 50]);
|
||||
assert.deepEqual([route.lng.at(-1), route.lat.at(-1)], [4.001, 50]);
|
||||
// the points are in order, and evenly spaced
|
||||
for (let i = 1; i < route.lng.length - 1; i++) {
|
||||
const step = distance(
|
||||
{ lng: route.lng[i - 1], lat: route.lat[i - 1] },
|
||||
{ lng: route.lng[i], lat: route.lat[i] }
|
||||
);
|
||||
assert.ok(Math.abs(step - 50) < 1, `${step}`);
|
||||
}
|
||||
assert.ok(route.ele.every((ele) => ele === 0));
|
||||
assert.equal(route.surface.length, route.lng.length);
|
||||
});
|
||||
|
||||
it('go through the intermediate points', () => {
|
||||
const route = interpolatePoints([
|
||||
{ lng: 4, lat: 50 },
|
||||
{ lng: 4.001, lat: 50 },
|
||||
{ lng: 4.001, lat: 50.001 },
|
||||
]);
|
||||
assert.ok(route.lng.includes(4.001) && route.lat.includes(50.001));
|
||||
const corner = route.lng.findIndex((lng, i) => lng === 4.001 && route.lat[i] === 50);
|
||||
assert.ok(corner > 0);
|
||||
});
|
||||
|
||||
it('are a point for one point, nothing for none', () => {
|
||||
const route = interpolatePoints([{ lng: 4, lat: 50 }]);
|
||||
assert.deepEqual(route.lng, [4]);
|
||||
assert.deepEqual(interpolatePoints([]).lng, []);
|
||||
// two points at the same place
|
||||
assert.deepEqual(
|
||||
interpolatePoints([
|
||||
{ lng: 4, lat: 50 },
|
||||
{ lng: 4, lat: 50 },
|
||||
]).lng,
|
||||
[4]
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Coordinates } from 'gpx';
|
||||
import { TrackPoint, distance } from 'gpx';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { distance, type Coordinates } from '$lib/geo/coordinates';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { getElevation } from '$lib/utils';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
@@ -22,7 +21,36 @@ export const routingProfiles: { [key: string]: RoutingProfile } = {
|
||||
railway: { engine: 'brouter', profile: 'rail' },
|
||||
};
|
||||
|
||||
export function route(points: Coordinates[]): Promise<TrackPoint[]> {
|
||||
/**
|
||||
* The trackpoints of a route, as arrays with an entry per trackpoint. The OSM attributes are
|
||||
* `undefined` where the router does not know them.
|
||||
*/
|
||||
export type RoutedPoints = {
|
||||
lng: number[];
|
||||
lat: number[];
|
||||
ele: number[];
|
||||
surface: (string | undefined)[];
|
||||
highway: (string | undefined)[];
|
||||
sacScale: (string | undefined)[];
|
||||
mtbScale: (string | undefined)[];
|
||||
};
|
||||
|
||||
export function emptyRoute(): RoutedPoints {
|
||||
return { lng: [], lat: [], ele: [], surface: [], highway: [], sacScale: [], mtbScale: [] };
|
||||
}
|
||||
|
||||
/** Adds a trackpoint, which has the elevation of the previous one if it has none. */
|
||||
export function addPoint(route: RoutedPoints, lng: number, lat: number, ele: number | undefined) {
|
||||
route.lng.push(lng);
|
||||
route.lat.push(lat);
|
||||
route.ele.push(ele ?? route.ele[route.ele.length - 1] ?? 0);
|
||||
route.surface.push(undefined);
|
||||
route.highway.push(undefined);
|
||||
route.sacScale.push(undefined);
|
||||
route.mtbScale.push(undefined);
|
||||
}
|
||||
|
||||
export function route(points: Coordinates[]): Promise<RoutedPoints> {
|
||||
if (get(routing)) {
|
||||
const profile = routingProfiles[get(routingProfile)];
|
||||
if (profile.engine === 'graphhopper') {
|
||||
@@ -54,7 +82,7 @@ const mtbRatingToScale: { [key: string]: string } = {
|
||||
'7': '6',
|
||||
};
|
||||
|
||||
const graphhopperBlockPrivateCustomModels: { [key: string]: any } = {
|
||||
export const graphhopperBlockPrivateCustomModels: { [key: string]: any } = {
|
||||
bike: {
|
||||
priority: [
|
||||
{
|
||||
@@ -95,7 +123,7 @@ const graphhopperBlockPrivateCustomModels: { [key: string]: any } = {
|
||||
},
|
||||
],
|
||||
},
|
||||
motorcycle: {
|
||||
motorbike: {
|
||||
priority: [
|
||||
{
|
||||
if: 'road_access == PRIVATE',
|
||||
@@ -108,14 +136,14 @@ async function getGraphHopperRoute(
|
||||
points: Coordinates[],
|
||||
graphHopperProfile: string,
|
||||
privateRoads: boolean
|
||||
): Promise<TrackPoint[]> {
|
||||
let response = await fetch('https://graphhopper.gpx.studio/route', {
|
||||
): Promise<RoutedPoints> {
|
||||
const response = await fetch('https://graphhopper.gpx.studio/route', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
points: points.map((point) => [point.lon, point.lat]),
|
||||
points: points.map((point) => [point.lng, point.lat]),
|
||||
profile: graphHopperProfile,
|
||||
elevation: true,
|
||||
points_encoded: false,
|
||||
@@ -127,62 +155,60 @@ async function getGraphHopperRoute(
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
if (error.message.includes('Cannot find point 0')) {
|
||||
throw new Error('toolbar.routing.error.from');
|
||||
} else if (error.message.includes('Cannot find point 1')) {
|
||||
if (points.length == 3) {
|
||||
throw new Error('toolbar.routing.error.via');
|
||||
} else {
|
||||
throw new Error('toolbar.routing.error.to');
|
||||
}
|
||||
} else if (error.hints[0].details.includes('PointDistanceExceededException')) {
|
||||
throw new Error('toolbar.routing.error.distance');
|
||||
} else if (error.hints[0].details.includes('ConnectionNotFoundException')) {
|
||||
throw new Error('toolbar.routing.error.connection');
|
||||
} else {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
throw graphHopperError(await response.json(), points.length);
|
||||
}
|
||||
|
||||
let json = await response.json();
|
||||
return parseGraphHopperRoute(await response.json());
|
||||
}
|
||||
|
||||
let route: TrackPoint[] = [];
|
||||
let coordinates = json.paths[0].points.coordinates;
|
||||
let details = json.paths[0].details;
|
||||
/** The error to throw for the error response of GraphHopper. Its message is a translation key. */
|
||||
export function graphHopperError(
|
||||
error: { message?: string; hints?: { details?: string }[] },
|
||||
pointCount: number
|
||||
): Error {
|
||||
const message = error.message ?? '';
|
||||
const details = error.hints?.[0]?.details ?? '';
|
||||
if (message.includes('Cannot find point 0')) {
|
||||
return new Error('toolbar.routing.error.from');
|
||||
} else if (message.includes('Cannot find point 1')) {
|
||||
return new Error(
|
||||
pointCount == 3 ? 'toolbar.routing.error.via' : 'toolbar.routing.error.to'
|
||||
);
|
||||
} else if (details.includes('PointDistanceExceededException')) {
|
||||
return new Error('toolbar.routing.error.distance');
|
||||
} else if (details.includes('ConnectionNotFoundException')) {
|
||||
return new Error('toolbar.routing.error.connection');
|
||||
}
|
||||
return new Error(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* The route of a response of GraphHopper. The details of the path (road class, surface...) are
|
||||
* given as ranges of points: `[from, to, value]`, `to` excluded except for the last range, which
|
||||
* includes the last point.
|
||||
*/
|
||||
export function parseGraphHopperRoute(json: any): RoutedPoints {
|
||||
const route = emptyRoute();
|
||||
const coordinates = json.paths[0].points.coordinates;
|
||||
const details = json.paths[0].details;
|
||||
|
||||
for (let i = 0; i < coordinates.length; i++) {
|
||||
route.push(
|
||||
new TrackPoint({
|
||||
attributes: {
|
||||
lat: coordinates[i][1],
|
||||
lon: coordinates[i][0],
|
||||
},
|
||||
ele: coordinates[i][2] ?? (i > 0 ? route[i - 1].ele : 0),
|
||||
extensions: {},
|
||||
})
|
||||
);
|
||||
addPoint(route, coordinates[i][0], coordinates[i][1], coordinates[i][2]);
|
||||
}
|
||||
|
||||
for (let key of graphhopperDetails) {
|
||||
let detail = details[key];
|
||||
for (const key of graphhopperDetails) {
|
||||
const detail = details[key] ?? [];
|
||||
for (let i = 0; i < detail.length; i++) {
|
||||
for (let j = detail[i][0]; j < detail[i][1] + (i == detail.length - 1); j++) {
|
||||
for (let j = detail[i][0]; j < detail[i][1] + (i == detail.length - 1 ? 1 : 0); j++) {
|
||||
if (detail[i][2] !== undefined && detail[i][2] !== 'missing') {
|
||||
if (key === 'road_class') {
|
||||
route[j].setExtension('highway', detail[i][2]);
|
||||
route.highway[j] = detail[i][2];
|
||||
} else if (key === 'hike_rating') {
|
||||
const sacScale = hikeRatingToSACScale[detail[i][2]];
|
||||
if (sacScale) {
|
||||
route[j].setExtension('sac_scale', sacScale);
|
||||
}
|
||||
route.sacScale[j] = hikeRatingToSACScale[detail[i][2]];
|
||||
} else if (key === 'mtb_rating') {
|
||||
const mtbScale = mtbRatingToScale[detail[i][2]];
|
||||
if (mtbScale) {
|
||||
route[j].setExtension('mtb_scale', mtbScale);
|
||||
}
|
||||
route.mtbScale[j] = mtbRatingToScale[detail[i][2]];
|
||||
} else if (key === 'surface' && detail[i][2] !== 'other') {
|
||||
route[j].setExtension('surface', detail[i][2]);
|
||||
route.surface[j] = detail[i][2];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,31 +221,41 @@ async function getGraphHopperRoute(
|
||||
async function getBRouterRoute(
|
||||
points: Coordinates[],
|
||||
brouterProfile: string
|
||||
): Promise<TrackPoint[]> {
|
||||
let url = `https://brouter.de/brouter?lonlats=${points.map((point) => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile}&format=geojson&alternativeidx=0`;
|
||||
): Promise<RoutedPoints> {
|
||||
const url = `https://brouter.de/brouter?lonlats=${points.map((point) => `${point.lng.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile}&format=geojson&alternativeidx=0`;
|
||||
|
||||
let response = await fetch(url);
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
if (error.includes('from-position not mapped in existing datafile')) {
|
||||
throw new Error('toolbar.routing.error.from');
|
||||
} else if (error.includes('via1-position not mapped in existing datafile')) {
|
||||
throw new Error('toolbar.routing.error.via');
|
||||
} else if (error.includes('to-position not mapped in existing datafile')) {
|
||||
throw new Error('toolbar.routing.error.to');
|
||||
} else if (error.includes('Time-out')) {
|
||||
throw new Error('toolbar.routing.error.timeout');
|
||||
} else {
|
||||
throw new Error(error);
|
||||
}
|
||||
throw brouterError(await response.text());
|
||||
}
|
||||
|
||||
let geojson = await response.json();
|
||||
return parseBRouterRoute(await response.json());
|
||||
}
|
||||
|
||||
let route: TrackPoint[] = [];
|
||||
let coordinates = geojson.features[0].geometry.coordinates;
|
||||
let messages = geojson.features[0].properties.messages;
|
||||
/** The error to throw for the error response of BRouter. Its message is a translation key. */
|
||||
export function brouterError(error: string): Error {
|
||||
if (error.includes('from-position not mapped in existing datafile')) {
|
||||
return new Error('toolbar.routing.error.from');
|
||||
} else if (error.includes('via1-position not mapped in existing datafile')) {
|
||||
return new Error('toolbar.routing.error.via');
|
||||
} else if (error.includes('to-position not mapped in existing datafile')) {
|
||||
return new Error('toolbar.routing.error.to');
|
||||
} else if (error.includes('Time-out')) {
|
||||
return new Error('toolbar.routing.error.timeout');
|
||||
}
|
||||
return new Error(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* The route of a response of BRouter (GeoJSON). The tags of the ways are in the `messages`, one
|
||||
* per way: a way starts at the point whose position the message gives, and applies up to the
|
||||
* next message.
|
||||
*/
|
||||
export function parseBRouterRoute(geojson: any): RoutedPoints {
|
||||
const route = emptyRoute();
|
||||
const coordinates = geojson.features[0].geometry.coordinates;
|
||||
const messages = geojson.features[0].properties.messages;
|
||||
|
||||
const lngIdx = messages[0].indexOf('Longitude');
|
||||
const latIdx = messages[0].indexOf('Latitude');
|
||||
@@ -228,15 +264,7 @@ async function getBRouterRoute(
|
||||
let tags = messageIdx < messages.length ? getTags(messages[messageIdx][tagIdx]) : {};
|
||||
|
||||
for (let i = 0; i < coordinates.length; i++) {
|
||||
route.push(
|
||||
new TrackPoint({
|
||||
attributes: {
|
||||
lat: coordinates[i][1],
|
||||
lon: coordinates[i][0],
|
||||
},
|
||||
ele: coordinates[i][2] ?? (i > 0 ? route[i - 1].ele : 0),
|
||||
})
|
||||
);
|
||||
addPoint(route, coordinates[i][0], coordinates[i][1], coordinates[i][2]);
|
||||
|
||||
if (
|
||||
messageIdx < messages.length &&
|
||||
@@ -249,57 +277,57 @@ async function getBRouterRoute(
|
||||
else tags = getTags(messages[messageIdx][tagIdx]);
|
||||
}
|
||||
|
||||
route[route.length - 1].setExtensions(tags);
|
||||
route.surface[i] = tags.surface;
|
||||
route.highway[i] = tags.highway;
|
||||
route.sacScale[i] = tags.sac_scale;
|
||||
route.mtbScale[i] = tags.mtb_scale;
|
||||
}
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
function getTags(message: string): { [key: string]: string } {
|
||||
/** The tags of a way, `key=value` separated by spaces, with the `:` of the keys as `_`. */
|
||||
export function getTags(message: string): { [key: string]: string } {
|
||||
const fields = message.split(' ');
|
||||
let tags: { [key: string]: string } = {};
|
||||
const tags: { [key: string]: string } = {};
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
let [key, value] = fields[i].split('=');
|
||||
key = key.replace(/:/g, '_');
|
||||
tags[key] = value;
|
||||
const [key, value] = fields[i].split('=');
|
||||
tags[key.replace(/:/g, '_')] = value;
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
function getIntermediatePoints(points: Coordinates[]): Promise<TrackPoint[]> {
|
||||
let route: TrackPoint[] = [];
|
||||
let step = 0.05;
|
||||
/**
|
||||
* The points of a straight route through `points`: one every 50 m, and the last one. The
|
||||
* elevations are 0.
|
||||
*/
|
||||
export function interpolatePoints(points: Coordinates[]): RoutedPoints {
|
||||
const route = emptyRoute();
|
||||
const step = 0.05;
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
// Add intermediate points between each pair of points
|
||||
let dist = distance(points[i], points[i + 1]) / 1000;
|
||||
const dist = distance(points[i], points[i + 1]) / 1000;
|
||||
for (let d = 0; d < dist; d += step) {
|
||||
let lat = points[i].lat + (d / dist) * (points[i + 1].lat - points[i].lat);
|
||||
let lon = points[i].lon + (d / dist) * (points[i + 1].lon - points[i].lon);
|
||||
route.push(
|
||||
new TrackPoint({
|
||||
attributes: {
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
},
|
||||
})
|
||||
);
|
||||
const lat = points[i].lat + (d / dist) * (points[i + 1].lat - points[i].lat);
|
||||
const lng = points[i].lng + (d / dist) * (points[i + 1].lng - points[i].lng);
|
||||
addPoint(route, lng, lat, 0);
|
||||
}
|
||||
}
|
||||
|
||||
route.push(
|
||||
new TrackPoint({
|
||||
attributes: {
|
||||
lat: points[points.length - 1].lat,
|
||||
lon: points[points.length - 1].lon,
|
||||
},
|
||||
})
|
||||
const last = points[points.length - 1];
|
||||
if (last) {
|
||||
addPoint(route, last.lng, last.lat, 0);
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
function getIntermediatePoints(points: Coordinates[]): Promise<RoutedPoints> {
|
||||
const route = interpolatePoints(points);
|
||||
return getElevation(route.lng.map((lng, i) => ({ lng, lat: route.lat[i] }))).then(
|
||||
(elevations) => {
|
||||
route.ele = elevations;
|
||||
return route;
|
||||
}
|
||||
);
|
||||
|
||||
return getElevation(route).then((elevations) => {
|
||||
route.forEach((point, i) => {
|
||||
point.ele = elevations[i];
|
||||
});
|
||||
return route;
|
||||
});
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { ramerDouglasPeucker, type GPXFile, type TrackSegment } from 'gpx';
|
||||
|
||||
const earthRadius = 6371008.8;
|
||||
|
||||
export const MIN_ANCHOR_ZOOM = 0;
|
||||
export const MAX_ANCHOR_ZOOM = 22;
|
||||
|
||||
export function getZoomLevelForDistance(latitude: number, distance?: number): number {
|
||||
if (distance === undefined) {
|
||||
return MIN_ANCHOR_ZOOM;
|
||||
}
|
||||
|
||||
const rad = Math.PI / 180;
|
||||
const lat = latitude * rad;
|
||||
|
||||
return Math.min(
|
||||
MAX_ANCHOR_ZOOM,
|
||||
Math.max(MIN_ANCHOR_ZOOM, Math.round(Math.log2((earthRadius * Math.cos(lat)) / distance)))
|
||||
);
|
||||
}
|
||||
|
||||
export function updateAnchorPoints(file: GPXFile) {
|
||||
let segments = file.getSegments();
|
||||
|
||||
for (let segment of segments) {
|
||||
if (!segment._data.anchors) {
|
||||
// New segment, compute anchor points for it
|
||||
computeAnchorPoints(segment);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (segment.trkpt.length > 0) {
|
||||
// Ensure first and last points are anchors and always visible
|
||||
segment.trkpt[0]._data.anchor = true;
|
||||
segment.trkpt[0]._data.zoom = 0;
|
||||
segment.trkpt[segment.trkpt.length - 1]._data.anchor = true;
|
||||
segment.trkpt[segment.trkpt.length - 1]._data.zoom = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function computeAnchorPoints(segment: TrackSegment) {
|
||||
let points = segment.trkpt;
|
||||
let anchors = ramerDouglasPeucker(points, 1);
|
||||
anchors.forEach((anchor) => {
|
||||
let point = anchor.point;
|
||||
point._data.anchor = true;
|
||||
point._data.zoom = getZoomLevelForDistance(point.getLatitude(), anchor.distance);
|
||||
});
|
||||
segment._data.anchors = true;
|
||||
}
|
||||
@@ -1,22 +1,20 @@
|
||||
<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 { ListRootItem } from '$lib/components/file-list/file-list';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Slider } from '$lib/components/ui/slider';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { get } from 'svelte/store';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { Crop } from '@lucide/svelte';
|
||||
import { SplitControls } from './split-controls';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics';
|
||||
import { slicedStatistics } from '$lib/state/selection-statistics';
|
||||
import { engine } from '$lib/engine';
|
||||
import { coversSegments } from '$lib/engine/selection-helpers';
|
||||
|
||||
let props: {
|
||||
class?: string;
|
||||
@@ -24,15 +22,42 @@
|
||||
|
||||
let splitControls: SplitControls | undefined = undefined;
|
||||
|
||||
let validSelection = $derived(
|
||||
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) &&
|
||||
$gpxStatistics.global.length > 0
|
||||
);
|
||||
let maxSliderValue = $derived(
|
||||
validSelection && $gpxStatistics.global.length > 0 ? $gpxStatistics.global.length - 1 : 1
|
||||
);
|
||||
let sliderValues = $derived([0, maxSliderValue]);
|
||||
let canCrop = $derived(sliderValues[0] != 0 || sliderValues[1] != maxSliderValue);
|
||||
const selection = engine.selection;
|
||||
const statistics = engine.statistics;
|
||||
|
||||
// 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.
|
||||
const MAX_STEPS = 500;
|
||||
let length = $derived($statistics.length);
|
||||
let steps = $derived(validSelection ? Math.min(length - 1, MAX_STEPS) : 1);
|
||||
let sliderValues = $state([0, 1]);
|
||||
|
||||
/** The index of the trackpoint of a step of the slider. */
|
||||
function toIndex(step: number) {
|
||||
return steps <= 0 || steps >= length - 1 ? step : Math.round((step * (length - 1)) / steps);
|
||||
}
|
||||
|
||||
/** The step of the slider that is the closest to a trackpoint. */
|
||||
function toStep(index: number) {
|
||||
return steps <= 0 || steps >= length - 1
|
||||
? index
|
||||
: Math.round((index * steps) / (length - 1));
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if ($map) {
|
||||
@@ -40,42 +65,37 @@
|
||||
}
|
||||
});
|
||||
|
||||
function updateSlicedGPXStatistics() {
|
||||
if (validSelection && canCrop) {
|
||||
$slicedGPXStatistics = [
|
||||
get(gpxStatistics).sliced(sliderValues[0], sliderValues[1]),
|
||||
sliderValues[0],
|
||||
sliderValues[1],
|
||||
];
|
||||
/** The user moved the slider: the part of the selection between its bounds is highlighted. */
|
||||
function updateSlicedStatistics([startStep, endStep]: number[]) {
|
||||
if (validSelection && (startStep !== 0 || endStep !== steps)) {
|
||||
const [start, end] = [toIndex(startStep), toIndex(endStep)];
|
||||
$statistics.slice(start, end).then((global) => {
|
||||
// the slider may have moved since
|
||||
if (validSelection) {
|
||||
slicedStatistics.set(global && { global, start, end });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
$slicedGPXStatistics = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function updateSliderValues() {
|
||||
if ($slicedGPXStatistics !== undefined) {
|
||||
sliderValues = [$slicedGPXStatistics[1], $slicedGPXStatistics[2]];
|
||||
slicedStatistics.set(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// The slider follows the highlighted part, which can also be chosen on the elevation profile or
|
||||
// reset (when the selection changes): it covers the whole selection when there is none.
|
||||
// It only goes this way, the slider tells about its own changes (`onValueChange`).
|
||||
$effect(() => {
|
||||
if (sliderValues) {
|
||||
untrack(() => updateSlicedGPXStatistics());
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (
|
||||
$slicedGPXStatistics !== undefined &&
|
||||
($slicedGPXStatistics[1] !== sliderValues[0] ||
|
||||
$slicedGPXStatistics[2] !== sliderValues[1])
|
||||
) {
|
||||
untrack(() => updateSliderValues());
|
||||
}
|
||||
const sliced = $slicedStatistics;
|
||||
const max = steps;
|
||||
untrack(() => {
|
||||
const bounds = sliced ? [toStep(sliced.start), toStep(sliced.end)] : [0, max];
|
||||
if (sliderValues[0] !== bounds[0] || sliderValues[1] !== bounds[1]) {
|
||||
sliderValues = bounds;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
$slicedGPXStatistics = undefined;
|
||||
slicedStatistics.set(undefined);
|
||||
if (splitControls) {
|
||||
splitControls.destroy();
|
||||
}
|
||||
@@ -86,7 +106,8 @@
|
||||
<div class="p-2">
|
||||
<Slider
|
||||
bind:value={sliderValues}
|
||||
max={maxSliderValue}
|
||||
onValueChange={updateSlicedStatistics}
|
||||
max={steps}
|
||||
step={1}
|
||||
type="multiple"
|
||||
disabled={!validSelection}
|
||||
@@ -94,8 +115,9 @@
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!validSelection || !canCrop}
|
||||
onclick={() => fileActions.cropSelection(sliderValues[0], sliderValues[1])}
|
||||
disabled={!validSelection || !$slicedStatistics}
|
||||
onclick={() =>
|
||||
$slicedStatistics && engine.crop($slicedStatistics.start, $slicedStatistics.end)}
|
||||
>
|
||||
<Crop size="16" />{i18n._('toolbar.scissors.crop')}
|
||||
</Button>
|
||||
@@ -109,7 +131,7 @@
|
||||
{i18n._('gpx.' + $splitAs)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each Object.values(SplitType) as splitType}
|
||||
{#each splitTypes as splitType (splitType)}
|
||||
<Select.Item value={splitType}>{i18n._('gpx.' + splitType)}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
|
||||
@@ -1,9 +1,85 @@
|
||||
import { writable, type Writable } from 'svelte/store';
|
||||
import { get, writable, type Writable } from 'svelte/store';
|
||||
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';
|
||||
|
||||
export enum SplitType {
|
||||
FILES = 'files',
|
||||
TRACKS = 'tracks',
|
||||
SEGMENTS = 'segments',
|
||||
/** 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>;
|
||||
export type SplitType = (typeof SplitType)[keyof typeof SplitType];
|
||||
|
||||
export const 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) : [];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export let splitAs: Writable<SplitType> = writable(SplitType.FILES);
|
||||
/**
|
||||
* 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;
|
||||
if (!anchors || segment < 0) {
|
||||
return;
|
||||
}
|
||||
const starts = anchors.segmentStarts;
|
||||
const end = segment + 1 < starts.length ? starts[segment + 1] : statistics.length;
|
||||
const index = closestPointIndexIn(statistics.lng, statistics.lat, starts[segment], end, point);
|
||||
if (index !== undefined) {
|
||||
engine.split(anchors.revision, index, get(splitAs));
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,37 @@
|
||||
import { ListTrackSegmentItem } from '$lib/components/file-list/file-list';
|
||||
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 { selection } from '$lib/logic/selection';
|
||||
import { gpxStatistics } from '$lib/logic/statistics';
|
||||
import { get } from 'svelte/store';
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import type { Map, GeoJSONSource, MapLayerMouseEvent } from 'maplibre-gl';
|
||||
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
|
||||
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,
|
||||
type SelectionStatistics,
|
||||
type StatisticsRequest,
|
||||
} from '$lib/engine';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
layerEventManager: MapLayerEventManager;
|
||||
unsubscribes: Function[] = [];
|
||||
unsubscribes: (() => void)[] = [];
|
||||
/** What the tool needs from the engine, while it shows the anchors. */
|
||||
request: StatisticsRequest = engine.requestStatistics();
|
||||
selection: Selection = { type: 'empty' };
|
||||
statistics: SelectionStatistics | undefined = undefined;
|
||||
|
||||
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
|
||||
layerOnMouseEnterBinded: () => void = this.layerOnMouseEnter.bind(this);
|
||||
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
|
||||
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
|
||||
layerOnClickBinded: (e: MapLayerMouseEvent) => void = this.layerOnClick.bind(this);
|
||||
|
||||
constructor(map: Map, layerEventManager: MapLayerEventManager) {
|
||||
this.map = map;
|
||||
@@ -36,13 +47,26 @@ export class SplitControls {
|
||||
</svg>`
|
||||
);
|
||||
|
||||
this.unsubscribes.push(gpxStatistics.subscribe(this.addIfNeeded.bind(this)));
|
||||
this.unsubscribes.push(
|
||||
engine.statistics.subscribe((statistics) => {
|
||||
this.statistics = statistics;
|
||||
this.addIfNeeded();
|
||||
})
|
||||
);
|
||||
this.unsubscribes.push(currentTool.subscribe(this.addIfNeeded.bind(this)));
|
||||
this.unsubscribes.push(selection.subscribe(this.addIfNeeded.bind(this)));
|
||||
this.unsubscribes.push(splitAs.subscribe(this.addIfNeeded.bind(this)));
|
||||
this.unsubscribes.push(
|
||||
engine.selection.subscribe((selection) => {
|
||||
this.selection = selection;
|
||||
this.addIfNeeded();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
addIfNeeded() {
|
||||
let scissors = get(currentTool) === Tool.SCISSORS;
|
||||
// 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();
|
||||
return;
|
||||
@@ -51,47 +75,48 @@ export class SplitControls {
|
||||
this.updateControls();
|
||||
}
|
||||
|
||||
updateControls() {
|
||||
let data: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
};
|
||||
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||
let file = fileStateCollection.getFile(fileId);
|
||||
|
||||
if (file) {
|
||||
file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||
if (
|
||||
get(selection).hasAnyParent(
|
||||
new ListTrackSegmentItem(fileId, trackIndex, segmentIndex)
|
||||
)
|
||||
) {
|
||||
for (let i = 1; i < segment.trkpt.length - 1; i++) {
|
||||
let point = segment.trkpt[i];
|
||||
if (point._data.anchor) {
|
||||
data.features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [point.getLongitude(), point.getLatitude()],
|
||||
},
|
||||
properties: {
|
||||
fileId: fileId,
|
||||
trackIndex: trackIndex,
|
||||
segmentIndex: segmentIndex,
|
||||
pointIndex: i,
|
||||
minZoom: point._data.zoom,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
/** The anchors that are inside a segment, which is not where it starts or ends. */
|
||||
inner(): GeoJSON.Feature[] {
|
||||
const statistics = this.statistics;
|
||||
const anchors = statistics?.anchors;
|
||||
if (!statistics || !anchors) {
|
||||
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++) {
|
||||
const index = anchors.indices[i];
|
||||
// the anchors are sorted: the segment can only be the same or a following one
|
||||
while (segment + 1 < starts.length && starts[segment + 1] <= index) {
|
||||
segment++;
|
||||
}
|
||||
const end = segment + 1 < starts.length ? starts[segment + 1] : statistics.length;
|
||||
const inRoute = routeSegments.has(anchors.segmentIds[segment]);
|
||||
if (index > starts[segment] && index < end - 1 && cuts(type, inRoute)) {
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [statistics.lng[index], statistics.lat[index]],
|
||||
},
|
||||
properties: { index, minZoom: anchors.zooms[i] },
|
||||
});
|
||||
}
|
||||
}, false);
|
||||
}
|
||||
return features;
|
||||
}
|
||||
|
||||
updateControls() {
|
||||
const data: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: this.inner(),
|
||||
};
|
||||
|
||||
try {
|
||||
let source = this.map.getSource('split-controls') as GeoJSONSource | undefined;
|
||||
const source = this.map.getSource('split-controls') as GeoJSONSource | undefined;
|
||||
if (source) {
|
||||
source.setData(data);
|
||||
} else {
|
||||
@@ -129,7 +154,7 @@ export class SplitControls {
|
||||
);
|
||||
this.layerEventManager.on('click', 'split-controls', this.layerOnClickBinded);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
}
|
||||
}
|
||||
@@ -147,12 +172,12 @@ export class SplitControls {
|
||||
if (this.map.getSource('split-controls')) {
|
||||
this.map.removeSource('split-controls');
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
}
|
||||
}
|
||||
|
||||
layerOnMouseEnter(e: any) {
|
||||
layerOnMouseEnter() {
|
||||
mapCursor.notify(MapCursorState.SPLIT_CONTROL, true);
|
||||
}
|
||||
|
||||
@@ -161,19 +186,15 @@ export class SplitControls {
|
||||
}
|
||||
|
||||
layerOnClick(e: MapLayerMouseEvent) {
|
||||
let coordinates = (e.features![0].geometry as GeoJSON.Point).coordinates;
|
||||
fileActions.split(
|
||||
get(splitAs),
|
||||
e.features![0].properties!.fileId,
|
||||
e.features![0].properties!.trackIndex,
|
||||
e.features![0].properties!.segmentIndex,
|
||||
{ lon: coordinates[0], lat: coordinates[1] },
|
||||
e.features![0].properties!.pointIndex
|
||||
);
|
||||
const revision = this.statistics?.anchors?.revision;
|
||||
if (revision !== undefined) {
|
||||
engine.split(revision, e.features![0].properties!.index, get(splitAs));
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.remove();
|
||||
this.request.release();
|
||||
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
}
|
||||
}
|
||||
@@ -4,19 +4,19 @@
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { ListWaypointItem } from '$lib/components/file-list/file-list';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { MapPin, CircleX, Save } from '@lucide/svelte';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { getSymbolKey, symbols } from '$lib/data/symbols';
|
||||
import { engine } from '$lib/engine';
|
||||
import { selectionSize } from '$lib/engine/selection-helpers';
|
||||
import { getElevation } from '$lib/utils';
|
||||
import { selectedWaypoint } from './waypoint';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import { Marker } from 'maplibre-gl';
|
||||
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
|
||||
import { Marker, type MapMouseEvent } from 'maplibre-gl';
|
||||
import { getSvgForSymbol } from '$lib/components/map/gpx-layer/gpx-layer';
|
||||
|
||||
let props: {
|
||||
@@ -31,7 +31,9 @@
|
||||
let latitude = $state(0);
|
||||
let symbolKey = $derived(getSymbolKey(sym));
|
||||
|
||||
let canCreate = $derived($selection.size > 0);
|
||||
const { selection } = engine;
|
||||
|
||||
let canCreate = $derived(selectionSize($selection) > 0);
|
||||
|
||||
let sortedSymbols = $derived(
|
||||
Object.entries(symbols).sort((a, b) => {
|
||||
@@ -58,24 +60,24 @@
|
||||
|
||||
$effect(() => {
|
||||
if ($selectedWaypoint) {
|
||||
const wpt = $selectedWaypoint[0];
|
||||
const wpt = $selectedWaypoint.waypoint;
|
||||
untrack(() => {
|
||||
name = wpt.name ?? '';
|
||||
description = wpt.desc ?? '';
|
||||
if (wpt.cmt !== undefined && wpt.cmt !== wpt.desc) {
|
||||
description += '\n\n' + wpt.cmt;
|
||||
}
|
||||
link = wpt.link?.attributes?.href ?? '';
|
||||
link = wpt.links?.[0]?.href ?? '';
|
||||
sym = wpt.sym ?? '';
|
||||
longitude = parseFloat(wpt.getLongitude().toFixed(6));
|
||||
latitude = parseFloat(wpt.getLatitude().toFixed(6));
|
||||
longitude = parseFloat(wpt.lng.toFixed(6));
|
||||
latitude = parseFloat(wpt.lat.toFixed(6));
|
||||
});
|
||||
} else {
|
||||
untrack(reset);
|
||||
}
|
||||
});
|
||||
|
||||
function createOrUpdateWaypoint() {
|
||||
async function createOrUpdateWaypoint() {
|
||||
if (typeof latitude === 'string') {
|
||||
latitude = parseFloat(latitude);
|
||||
}
|
||||
@@ -85,29 +87,38 @@
|
||||
latitude = parseFloat(latitude.toFixed(6));
|
||||
longitude = parseFloat(longitude.toFixed(6));
|
||||
|
||||
fileActions.addOrUpdateWaypoint(
|
||||
{
|
||||
attributes: {
|
||||
lat: latitude,
|
||||
lon: longitude,
|
||||
},
|
||||
name: name.length > 0 ? name : undefined,
|
||||
desc: description.length > 0 ? description : undefined,
|
||||
cmt: description.length > 0 ? description : undefined,
|
||||
link: link.length > 0 ? { attributes: { href: link } } : undefined,
|
||||
sym: sym.length > 0 ? sym : undefined,
|
||||
},
|
||||
selectedWaypoint.wpt && selectedWaypoint.fileId
|
||||
? new ListWaypointItem(selectedWaypoint.fileId, selectedWaypoint.wpt._data.index)
|
||||
: undefined
|
||||
);
|
||||
const target = $selectedWaypoint;
|
||||
const [ele] = await getElevation([{ lat: latitude, lng: longitude }]);
|
||||
const waypoint = {
|
||||
lng: longitude,
|
||||
lat: latitude,
|
||||
ele,
|
||||
name,
|
||||
desc: description,
|
||||
icon: sym,
|
||||
link,
|
||||
};
|
||||
if (target) {
|
||||
engine.updateWaypoint(target.fileId, target.id, waypoint);
|
||||
} else {
|
||||
// it goes in the selected files
|
||||
engine.newWaypoint(waypoint);
|
||||
}
|
||||
|
||||
reset();
|
||||
}
|
||||
|
||||
function setCoordinates(e: any) {
|
||||
latitude = e.lngLat.lat.toFixed(6);
|
||||
longitude = e.lngLat.lng.toFixed(6);
|
||||
function setCoordinates(e: MapMouseEvent) {
|
||||
if (
|
||||
$map?.queryRenderedFeatures(e.point, {
|
||||
layers: $map.getLayersOrder().filter((layerId) => layerId.endsWith('-waypoints')),
|
||||
}).length
|
||||
) {
|
||||
// Clicked on a waypoint, ignoring
|
||||
return;
|
||||
}
|
||||
latitude = parseFloat(e.lngLat.lat.toFixed(6));
|
||||
longitude = parseFloat(e.lngLat.lng.toFixed(6));
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -201,7 +212,7 @@
|
||||
</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content class="max-h-60">
|
||||
{#each sortedSymbols as [key, symbol]}
|
||||
{#each sortedSymbols as [key, symbol] (key)}
|
||||
<Select.Item value={symbol.value}>
|
||||
<span>
|
||||
{#if symbol.icon}
|
||||
|
||||
@@ -1,74 +1,107 @@
|
||||
import { ListWaypointItem } from '$lib/components/file-list/file-list';
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import type { Waypoint } from 'gpx';
|
||||
import { get, writable, type Writable } from 'svelte/store';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { engine, type WaypointDetails } from '$lib/engine';
|
||||
import { settings } from '$lib/state/settings';
|
||||
|
||||
export type SelectedWaypoint = {
|
||||
fileId: string;
|
||||
id: string;
|
||||
/** The data of the waypoint, kept up to date with the file. */
|
||||
waypoint: WaypointDetails;
|
||||
};
|
||||
|
||||
/**
|
||||
* The waypoint that the waypoint tool is about: the selected one when the file tree is shown and
|
||||
* a single waypoint is selected, or the one that was set explicitly (clicking a waypoint of the
|
||||
* map) until the selection changes to something else than its file or itself (that is what
|
||||
* clicking it selects, depending on the view, and the selection may arrive after it is set).
|
||||
*/
|
||||
export class WaypointSelection {
|
||||
private _selection: Writable<[Waypoint, string] | undefined>;
|
||||
private _selection = writable<SelectedWaypoint | undefined>(undefined);
|
||||
private _fileUnsubscribe: (() => void) | undefined;
|
||||
private _explicit: { fileId: string; id: string } | undefined;
|
||||
|
||||
constructor() {
|
||||
this._selection = writable(undefined);
|
||||
settings.treeFileView.subscribe(() => {
|
||||
this.update();
|
||||
});
|
||||
selection.subscribe(() => {
|
||||
this.update();
|
||||
});
|
||||
settings.treeFileView.subscribe(() => this.updateFromSelection());
|
||||
engine.selection.subscribe(() => this.updateFromSelection());
|
||||
}
|
||||
|
||||
subscribe(
|
||||
run: (value: [Waypoint, string] | undefined) => void,
|
||||
invalidate?: (value?: [Waypoint, string] | undefined) => void
|
||||
run: (value: SelectedWaypoint | undefined) => void,
|
||||
invalidate?: (value?: SelectedWaypoint | undefined) => void
|
||||
) {
|
||||
return this._selection.subscribe(run, invalidate);
|
||||
}
|
||||
|
||||
set(value: [Waypoint, string] | undefined) {
|
||||
this._selection.set(value);
|
||||
/** Sets the waypoint, whatever the selection is. */
|
||||
set(target: { fileId: string; id: string } | undefined) {
|
||||
this._explicit = target;
|
||||
this.track(target);
|
||||
}
|
||||
|
||||
update() {
|
||||
if (this._fileUnsubscribe) {
|
||||
this._fileUnsubscribe();
|
||||
this._fileUnsubscribe = undefined;
|
||||
updateFromSelection() {
|
||||
const selection = get(engine.selection);
|
||||
const explicit = this._explicit;
|
||||
if (
|
||||
explicit &&
|
||||
((selection.type === 'file' &&
|
||||
selection.fileIds.length === 1 &&
|
||||
selection.fileIds[0] === explicit.fileId) ||
|
||||
(selection.type === 'waypoint' &&
|
||||
selection.fileId === explicit.fileId &&
|
||||
selection.waypointIds.length === 1 &&
|
||||
selection.waypointIds[0] === explicit.id))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._explicit = undefined;
|
||||
if (
|
||||
get(settings.treeFileView) &&
|
||||
selection.type === 'waypoint' &&
|
||||
selection.waypointIds.length === 1
|
||||
) {
|
||||
this.track({ fileId: selection.fileId, id: selection.waypointIds[0] });
|
||||
} else {
|
||||
this.track(undefined);
|
||||
}
|
||||
this._selection.update(() => {
|
||||
if (get(settings.treeFileView) && get(selection).size === 1) {
|
||||
let item = get(selection).getSelected()[0];
|
||||
if (item instanceof ListWaypointItem) {
|
||||
let fileState = fileStateCollection.getFileState(item.getFileId());
|
||||
if (fileState) {
|
||||
let first = true;
|
||||
this._fileUnsubscribe = fileState.subscribe(() => {
|
||||
if (first) first = false;
|
||||
else this.update();
|
||||
});
|
||||
let waypoint = fileState.file?.wpt[item.getWaypointIndex()];
|
||||
if (waypoint) {
|
||||
return [waypoint, item.getFileId()];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
reset() {
|
||||
this._selection.set(undefined);
|
||||
this._explicit = undefined;
|
||||
this.track(undefined);
|
||||
}
|
||||
|
||||
get wpt(): Waypoint | undefined {
|
||||
const selection = get(this._selection);
|
||||
return selection ? selection[0] : undefined;
|
||||
get waypoint(): WaypointDetails | undefined {
|
||||
return get(this._selection)?.waypoint;
|
||||
}
|
||||
|
||||
get fileId(): string | undefined {
|
||||
const selection = get(this._selection);
|
||||
return selection ? selection[1] : undefined;
|
||||
return get(this._selection)?.fileId;
|
||||
}
|
||||
|
||||
get id(): string | undefined {
|
||||
return get(this._selection)?.id;
|
||||
}
|
||||
|
||||
private track(target: { fileId: string; id: string } | undefined) {
|
||||
this._fileUnsubscribe?.();
|
||||
this._fileUnsubscribe = undefined;
|
||||
|
||||
if (!target) {
|
||||
this._selection.set(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// read again each time the file changes
|
||||
const read = async () => {
|
||||
const waypoint = await engine.waypoint(target.fileId, target.id);
|
||||
this._selection.set(waypoint ? { ...target, waypoint } : undefined);
|
||||
};
|
||||
const file = get(engine.files).get(target.fileId);
|
||||
if (file) {
|
||||
this._fileUnsubscribe = file.subscribe(read);
|
||||
} else {
|
||||
read();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user