Merge branch 'dev' into elevation-tool

This commit is contained in:
vcoppe
2024-09-04 15:18:44 +02:00
609 changed files with 76321 additions and 6965 deletions

View File

@@ -4,7 +4,7 @@
import { flyAndScale } from '$lib/utils';
import * as Card from '$lib/components/ui/card';
import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte';
import Scissors from '$lib/components/toolbar/tools/Scissors.svelte';
import Scissors from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
import Waypoint from '$lib/components/toolbar/tools/Waypoint.svelte';
import Time from '$lib/components/toolbar/tools/Time.svelte';
import Merge from '$lib/components/toolbar/tools/Merge.svelte';

View File

@@ -178,7 +178,7 @@
<Trash2 size="16" class="mr-1" />
{$_('toolbar.clean.button')}
</Button>
<Help>
<Help link="./help/toolbar/clean">
{#if validSelection}
{$_('toolbar.clean.help')}
{:else}

View File

@@ -42,7 +42,7 @@
<Ungroup size="16" class="mr-1" />
{$_('toolbar.extract.button')}
</Button>
<Help>
<Help link="./help/toolbar/extract">
{#if validSelection}
{$_('toolbar.extract.help')}
{:else}

View File

@@ -74,7 +74,7 @@
<Group size="16" class="mr-1" />
{$_('toolbar.merge.merge_selection')}
</Button>
<Help>
<Help link="./help/toolbar/merge">
{#if mergeType === MergeType.TRACES && canMergeTraces}
{$_('toolbar.merge.help_merge_traces')}
{:else if mergeType === MergeType.TRACES && !canMergeTraces}

View File

@@ -164,7 +164,7 @@
{$_('toolbar.reduce.button')}
</Button>
<Help>
<Help link="./help/toolbar/minify">
{#if validSelection}
{$_('toolbar.reduce.help')}
{:else}

View File

@@ -1,342 +1,396 @@
<script lang="ts">
import DatePicker from '$lib/components/ui/date-picker/DatePicker.svelte';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label/index.js';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import TimePicker from '$lib/components/ui/time-picker/TimePicker.svelte';
import { dbUtils, settings } from '$lib/db';
import { gpxStatistics } from '$lib/stores';
import {
distancePerHourToSecondsPerDistance,
getConvertedVelocity,
kilometersToMiles
} from '$lib/units';
import { CalendarDate, type DateValue } from '@internationalized/date';
import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from 'lucide-svelte';
import { tick } from 'svelte';
import { _, locale } from 'svelte-i18n';
import { get } from 'svelte/store';
import { selection } from '$lib/components/file-list/Selection';
import {
ListFileItem,
ListRootItem,
ListTrackItem,
ListTrackSegmentItem
} from '$lib/components/file-list/FileList';
import Help from '$lib/components/Help.svelte';
import DatePicker from '$lib/components/ui/date-picker/DatePicker.svelte';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label/index.js';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import TimePicker from '$lib/components/ui/time-picker/TimePicker.svelte';
import { dbUtils, settings } from '$lib/db';
import { gpxStatistics } from '$lib/stores';
import {
distancePerHourToSecondsPerDistance,
getConvertedVelocity,
milesToKilometers,
nauticalMilesToKilometers
} from '$lib/units';
import { CalendarDate, type DateValue } from '@internationalized/date';
import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from 'lucide-svelte';
import { tick } from 'svelte';
import { _, locale } from 'svelte-i18n';
import { get } from 'svelte/store';
import { selection } from '$lib/components/file-list/Selection';
import {
ListFileItem,
ListRootItem,
ListTrackItem,
ListTrackSegmentItem
} from '$lib/components/file-list/FileList';
import Help from '$lib/components/Help.svelte';
let startDate: DateValue | undefined = undefined;
let startTime: string | undefined = undefined;
let endDate: DateValue | undefined = undefined;
let endTime: string | undefined = undefined;
let movingTime: number | undefined = undefined;
let speed: number | undefined = undefined;
let startDate: DateValue | undefined = undefined;
let startTime: string | undefined = undefined;
let endDate: DateValue | undefined = undefined;
let endTime: string | undefined = undefined;
let movingTime: number | undefined = undefined;
let speed: number | undefined = undefined;
let artificial = false;
function toCalendarDate(date: Date): CalendarDate {
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate());
}
function toCalendarDate(date: Date): CalendarDate {
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate());
}
const { velocityUnits, distanceUnits } = settings;
const { velocityUnits, distanceUnits } = settings;
function setSpeed(value: number) {
let speedValue = getConvertedVelocity(value);
if ($velocityUnits === 'speed') {
speedValue = parseFloat(speedValue.toFixed(2));
}
speed = speedValue;
}
function setSpeed(value: number) {
let speedValue = getConvertedVelocity(value);
if ($velocityUnits === 'speed') {
speedValue = parseFloat(speedValue.toFixed(2));
}
speed = speedValue;
}
function setGPXData() {
if ($gpxStatistics.global.time.start) {
startDate = toCalendarDate($gpxStatistics.global.time.start);
startTime = $gpxStatistics.global.time.start.toLocaleTimeString();
} else {
startDate = undefined;
startTime = undefined;
}
if ($gpxStatistics.global.time.end) {
endDate = toCalendarDate($gpxStatistics.global.time.end);
endTime = $gpxStatistics.global.time.end.toLocaleTimeString();
} else {
endDate = undefined;
endTime = undefined;
}
if ($gpxStatistics.global.time.moving) {
movingTime = $gpxStatistics.global.time.moving;
} else {
movingTime = undefined;
}
if ($gpxStatistics.global.speed.moving) {
setSpeed($gpxStatistics.global.speed.moving);
} else {
speed = undefined;
}
}
function setGPXData() {
if ($gpxStatistics.global.time.start) {
startDate = toCalendarDate($gpxStatistics.global.time.start);
startTime = $gpxStatistics.global.time.start.toLocaleTimeString();
} else {
startDate = undefined;
startTime = undefined;
}
if ($gpxStatistics.global.time.end) {
endDate = toCalendarDate($gpxStatistics.global.time.end);
endTime = $gpxStatistics.global.time.end.toLocaleTimeString();
} else {
endDate = undefined;
endTime = undefined;
}
if ($gpxStatistics.global.time.moving) {
movingTime = $gpxStatistics.global.time.moving;
} else {
movingTime = undefined;
}
if ($gpxStatistics.global.speed.moving) {
setSpeed($gpxStatistics.global.speed.moving);
} else {
speed = undefined;
}
}
$: if ($gpxStatistics && $velocityUnits && $distanceUnits) {
setGPXData();
}
$: if ($gpxStatistics && $velocityUnits && $distanceUnits) {
setGPXData();
}
function getDate(date: DateValue, time: string): Date {
if (date === undefined) {
return new Date();
}
let [hours, minutes, seconds] = time.split(':').map((x) => parseInt(x));
return new Date(date.year, date.month - 1, date.day, hours, minutes, seconds);
}
function getDate(date: DateValue, time: string): Date {
if (date === undefined) {
return new Date();
}
let [hours, minutes, seconds] = time.split(':').map((x) => parseInt(x));
return new Date(date.year, date.month - 1, date.day, hours, minutes, seconds);
}
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 end = new Date(start.getTime() + ratio * movingTime * 1000);
endDate = toCalendarDate(end);
endTime = end.toLocaleTimeString();
}
}
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 end = new Date(start.getTime() + ratio * movingTime * 1000);
endDate = toCalendarDate(end);
endTime = end.toLocaleTimeString();
}
}
function updateStart() {
if (endDate && movingTime !== undefined) {
if (endTime === undefined) {
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 start = new Date(end.getTime() - ratio * movingTime * 1000);
startDate = toCalendarDate(start);
startTime = start.toLocaleTimeString();
}
}
function updateStart() {
if (endDate && movingTime !== undefined) {
if (endTime === undefined) {
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 start = new Date(end.getTime() - ratio * movingTime * 1000);
startDate = toCalendarDate(start);
startTime = start.toLocaleTimeString();
}
}
function getSpeed() {
if (speed === undefined) {
return undefined;
}
function getSpeed() {
if (speed === undefined) {
return undefined;
}
let speedValue = speed;
if ($velocityUnits === 'pace') {
speedValue = distancePerHourToSecondsPerDistance(speed);
}
if ($distanceUnits === 'imperial') {
speedValue = kilometersToMiles(speedValue);
}
return speedValue;
}
let speedValue = speed;
if ($velocityUnits === 'pace') {
speedValue = distancePerHourToSecondsPerDistance(speed);
}
if ($distanceUnits === 'imperial') {
speedValue = milesToKilometers(speedValue);
} else if ($distanceUnits === 'nautical') {
speedValue = nauticalMilesToKilometers(speedValue);
}
return speedValue;
}
function updateDataFromSpeed() {
let speedValue = getSpeed();
if (speedValue === undefined) {
return;
}
function updateDataFromSpeed() {
let speedValue = getSpeed();
if (speedValue === undefined) {
return;
}
let distance =
$gpxStatistics.global.distance.moving > 0
? $gpxStatistics.global.distance.moving
: $gpxStatistics.global.distance.total;
movingTime = (distance / speedValue) * 3600;
let distance =
$gpxStatistics.global.distance.moving > 0
? $gpxStatistics.global.distance.moving
: $gpxStatistics.global.distance.total;
movingTime = (distance / speedValue) * 3600;
updateEnd();
}
updateEnd();
}
function updateDataFromTotalTime() {
if (movingTime === undefined) {
return;
}
setSpeed($gpxStatistics.global.distance.moving / (movingTime / 3600));
updateEnd();
}
function updateDataFromTotalTime() {
if (movingTime === undefined) {
return;
}
let distance =
$gpxStatistics.global.distance.moving > 0
? $gpxStatistics.global.distance.moving
: $gpxStatistics.global.distance.total;
setSpeed(distance / (movingTime / 3600));
updateEnd();
}
$: canUpdate =
$selection.size === 1 && $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
$: canUpdate =
$selection.size === 1 && $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
</script>
<div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
<fieldset class="flex flex-col gap-2">
<div class="flex flex-row gap-2 justify-center">
<div class="flex flex-col gap-2 grow">
<Label for="speed" class="flex flex-row">
<Zap size="16" class="mr-1" />
{#if $velocityUnits === 'speed'}
{$_('quantities.speed')}
{:else}
{$_('quantities.pace')}
{/if}
</Label>
<div class="flex flex-row gap-1 items-center">
{#if $velocityUnits === 'speed'}
<Input
id="speed"
type="number"
step={0.01}
min={0.01}
disabled={!canUpdate}
bind:value={speed}
on:change={updateDataFromSpeed}
/>
<span class="text-sm shrink-0">
{#if $distanceUnits === 'imperial'}
{$_('units.miles_per_hour')}
{:else}
{$_('units.kilometers_per_hour')}
{/if}
</span>
{:else}
<TimePicker
bind:value={speed}
showHours={false}
disabled={!canUpdate}
on:change={updateDataFromSpeed}
/>
<span class="text-sm shrink-0">
{#if $distanceUnits === 'imperial'}
{$_('units.minutes_per_mile')}
{:else}
{$_('units.minutes_per_kilometer')}
{/if}
</span>
{/if}
</div>
</div>
<div class="flex flex-col gap-2 grow">
<Label for="duration" class="flex flex-row">
<Timer size="16" class="mr-1" />
{$_('toolbar.time.total_time')}
</Label>
<TimePicker
bind:value={movingTime}
disabled={!canUpdate}
on:change={updateDataFromTotalTime}
/>
</div>
</div>
<Label class="flex flex-row">
<CirclePlay size="16" class="mr-1" />
{$_('toolbar.time.start')}
</Label>
<div class="flex flex-row gap-2">
<DatePicker
bind:value={startDate}
disabled={!canUpdate}
locale={get(locale) ?? 'en'}
placeholder={$_('toolbar.time.pick_date')}
class="w-fit grow"
onValueChange={async () => {
await tick();
updateEnd();
}}
/>
<Input
type="time"
step={1}
disabled={!canUpdate}
bind:value={startTime}
class="w-fit"
on:input={updateEnd}
/>
</div>
<Label class="flex flex-row">
<CircleStop size="16" class="mr-1" />
{$_('toolbar.time.end')}
</Label>
<div class="flex flex-row gap-2">
<DatePicker
bind:value={endDate}
disabled={!canUpdate}
locale={get(locale) ?? 'en'}
placeholder={$_('toolbar.time.pick_date')}
class="w-fit grow"
onValueChange={async () => {
await tick();
updateStart();
}}
/>
<Input
type="time"
step={1}
disabled={!canUpdate}
bind:value={endTime}
class="w-fit"
on:change={updateStart}
/>
</div>
{#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined}
<div class="mt-0.5 flex flex-row gap-1 items-center hidden">
<Checkbox id="artificial-time" disabled={!canUpdate} />
<Label for="artificial-time">
{$_('toolbar.time.artificial')}
</Label>
</div>
{/if}
</fieldset>
<div class="flex flex-row gap-2">
<Button
variant="outline"
disabled={!canUpdate}
class="grow"
on:click={() => {
let effectiveSpeed = getSpeed();
if (startDate === undefined || startTime === undefined || effectiveSpeed === undefined) {
return;
}
<fieldset class="flex flex-col gap-2">
<div class="flex flex-row gap-2 justify-center">
<div class="flex flex-col gap-2 grow">
<Label for="speed" class="flex flex-row">
<Zap size="16" class="mr-1" />
{#if $velocityUnits === 'speed'}
{$_('quantities.speed')}
{:else}
{$_('quantities.pace')}
{/if}
</Label>
<div class="flex flex-row gap-1 items-center">
{#if $velocityUnits === 'speed'}
<Input
id="speed"
type="number"
step={0.01}
min={0.01}
disabled={!canUpdate}
bind:value={speed}
on:change={updateDataFromSpeed}
/>
<span class="text-sm shrink-0">
{#if $distanceUnits === 'imperial'}
{$_('units.miles_per_hour')}
{:else if $distanceUnits === 'metric'}
{$_('units.kilometers_per_hour')}
{:else if $distanceUnits === 'nautical'}
{$_('units.knots')}
{/if}
</span>
{:else}
<TimePicker
bind:value={speed}
showHours={false}
disabled={!canUpdate}
onChange={updateDataFromSpeed}
/>
<span class="text-sm shrink-0">
{#if $distanceUnits === 'imperial'}
{$_('units.minutes_per_mile')}
{:else if $distanceUnits === 'metric'}
{$_('units.minutes_per_kilometer')}
{:else if $distanceUnits === 'nautical'}
{$_('units.minutes_per_nautical_mile')}
{/if}
</span>
{/if}
</div>
</div>
<div class="flex flex-col gap-2 grow">
<Label for="duration" class="flex flex-row">
<Timer size="16" class="mr-1" />
{$_('toolbar.time.total_time')}
</Label>
<TimePicker
bind:value={movingTime}
disabled={!canUpdate}
onChange={updateDataFromTotalTime}
/>
</div>
</div>
<Label class="flex flex-row">
<CirclePlay size="16" class="mr-1" />
{$_('toolbar.time.start')}
</Label>
<div class="flex flex-row gap-2">
<DatePicker
bind:value={startDate}
disabled={!canUpdate}
locale={get(locale) ?? 'en'}
placeholder={$_('toolbar.time.pick_date')}
class="w-fit grow"
onValueChange={async () => {
await tick();
updateEnd();
}}
/>
<input
type="time"
step={1}
disabled={!canUpdate}
bind:value={startTime}
class="w-fit"
on:change={updateEnd}
/>
</div>
<Label class="flex flex-row">
<CircleStop size="16" class="mr-1" />
{$_('toolbar.time.end')}
</Label>
<div class="flex flex-row gap-2">
<DatePicker
bind:value={endDate}
disabled={!canUpdate}
locale={get(locale) ?? 'en'}
placeholder={$_('toolbar.time.pick_date')}
class="w-fit grow"
onValueChange={async () => {
await tick();
updateStart();
}}
/>
<input
type="time"
step={1}
disabled={!canUpdate}
bind:value={endTime}
class="w-fit"
on:change={updateStart}
/>
</div>
{#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined}
<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">
{$_('toolbar.time.artificial')}
</Label>
</div>
{/if}
</fieldset>
<div class="flex flex-row gap-2">
<Button
variant="outline"
disabled={!canUpdate}
class="grow"
on:click={() => {
let effectiveSpeed = getSpeed();
if (
startDate === undefined ||
startTime === undefined ||
effectiveSpeed === undefined
) {
return;
}
if (Math.abs(effectiveSpeed - $gpxStatistics.global.speed.moving) < 0.01) {
effectiveSpeed = $gpxStatistics.global.speed.moving;
}
if (Math.abs(effectiveSpeed - $gpxStatistics.global.speed.moving) < 0.01) {
effectiveSpeed = $gpxStatistics.global.speed.moving;
}
let ratio = 1;
if (
$gpxStatistics.global.speed.moving > 0 &&
$gpxStatistics.global.speed.moving !== effectiveSpeed
) {
ratio = $gpxStatistics.global.speed.moving / effectiveSpeed;
}
let ratio = 1;
if (
$gpxStatistics.global.speed.moving > 0 &&
$gpxStatistics.global.speed.moving !== effectiveSpeed
) {
ratio = $gpxStatistics.global.speed.moving / effectiveSpeed;
}
let item = $selection.getSelected()[0];
let fileId = item.getFileId();
dbUtils.applyToFile(fileId, (file) => {
if (item instanceof ListFileItem) {
file.changeTimestamps(getDate(startDate, startTime), effectiveSpeed, ratio);
} else if (item instanceof ListTrackItem) {
file.changeTimestamps(
getDate(startDate, startTime),
effectiveSpeed,
ratio,
item.getTrackIndex()
);
} else if (item instanceof ListTrackSegmentItem) {
file.changeTimestamps(
getDate(startDate, startTime),
effectiveSpeed,
ratio,
item.getTrackIndex(),
item.getSegmentIndex()
);
}
});
}}
>
<CalendarClock size="16" class="mr-1" />
{$_('toolbar.time.update')}
</Button>
<Button variant="outline" on:click={setGPXData}>
<CircleX size="16" />
</Button>
</div>
<Help>
{#if canUpdate}
{$_('toolbar.time.help')}
{:else}
{$_('toolbar.time.help_invalid_selection')}
{/if}
</Help>
let item = $selection.getSelected()[0];
let fileId = item.getFileId();
dbUtils.applyToFile(fileId, (file) => {
if (item instanceof ListFileItem) {
if (artificial) {
file.createArtificialTimestamps(
getDate(startDate, startTime),
movingTime
);
} else {
file.changeTimestamps(
getDate(startDate, startTime),
effectiveSpeed,
ratio
);
}
} else if (item instanceof ListTrackItem) {
if (artificial) {
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) {
file.createArtificialTimestamps(
getDate(startDate, startTime),
movingTime,
item.getTrackIndex(),
item.getSegmentIndex()
);
} else {
file.changeTimestamps(
getDate(startDate, startTime),
effectiveSpeed,
ratio,
item.getTrackIndex(),
item.getSegmentIndex()
);
}
}
});
}}
>
<CalendarClock size="16" class="mr-1" />
{$_('toolbar.time.update')}
</Button>
<Button variant="outline" on:click={setGPXData}>
<CircleX size="16" />
</Button>
</div>
<Help link="./help/toolbar/time">
{#if canUpdate}
{$_('toolbar.time.help')}
{:else}
{$_('toolbar.time.help_invalid_selection')}
{/if}
</Help>
</div>
<style lang="postcss">
div :global(input[type='time']) {
/*
Style copy-pasted from shadcn-svelte Input.
Needed to use native time input to avoid a bug with 2-level bind:value.
*/
@apply flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
}
</style>

View File

@@ -9,9 +9,10 @@
import { Textarea } from '$lib/components/ui/textarea';
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 { selection } from '$lib/components/file-list/Selection';
import { Waypoint } from 'gpx';
import { _ } from 'svelte-i18n';
import { _, locale } from 'svelte-i18n';
import { ListWaypointItem } from '$lib/components/file-list/FileList';
import { dbUtils, fileObservers, getFile, settings, type GPXFileWithStatistics } from '$lib/db';
import { get } from 'svelte/store';
@@ -20,12 +21,19 @@
import { map } from '$lib/stores';
import { resetCursor, setCrosshairCursor } from '$lib/utils';
import { CirclePlus, CircleX, Save } from 'lucide-svelte';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
let name: string;
let description: string;
let link: string;
let longitude: number;
let latitude: number;
let selectedSymbol = {
value: '',
label: ''
};
const { verticalFileView } = settings;
$: canCreate = $selection.size > 0;
@@ -60,6 +68,20 @@
) {
description += '\n\n' + $selectedWaypoint[0].cmt;
}
link = $selectedWaypoint[0].link?.attributes?.href ?? '';
let symbol = $selectedWaypoint[0].sym ?? '';
let symbolKey = getSymbolKey(symbol);
if (symbolKey) {
selectedSymbol = {
value: symbol,
label: $_(`gpx.symbol.${symbolKey}`)
};
} else {
selectedSymbol = {
value: symbol,
label: ''
};
}
longitude = parseFloat($selectedWaypoint[0].getLongitude().toFixed(6));
latitude = parseFloat($selectedWaypoint[0].getLatitude().toFixed(6));
} else {
@@ -74,6 +96,11 @@
function resetWaypointData() {
name = '';
description = '';
link = '';
selectedSymbol = {
value: '',
label: ''
};
longitude = 0;
latitude = 0;
}
@@ -109,9 +136,11 @@
lat: latitude,
lon: longitude
},
name,
desc: description,
cmt: description
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: selectedSymbol.value.length > 0 ? selectedSymbol.value : undefined
},
$selectedWaypoint
? new ListWaypointItem($selectedWaypoint[1], $selectedWaypoint[0]._data.index)
@@ -127,6 +156,10 @@
longitude = e.lngLat.lng.toFixed(6);
}
$: sortedSymbols = Object.entries(symbols).sort((a, b) => {
return $_(`gpx.symbol.${a[0]}`).localeCompare($_(`gpx.symbol.${b[0]}`), $locale ?? 'en');
});
onMount(() => {
let m = get(map);
m?.on('click', setCoordinates);
@@ -151,6 +184,32 @@
<Input bind:value={name} id="name" class="font-semibold h-8" />
<Label for="description">{$_('menu.metadata.description')}</Label>
<Textarea bind:value={description} id="description" />
<Label for="symbol">{$_('toolbar.waypoint.icon')}</Label>
<Select.Root bind:selected={selectedSymbol}>
<Select.Trigger id="symbol" class="w-full h-8">
<Select.Value />
</Select.Trigger>
<Select.Content class="max-h-60 overflow-y-scroll">
{#each sortedSymbols as [key, symbol]}
<Select.Item value={symbol.value}>
<span>
{#if symbol.icon}
<svelte:component
this={symbol.icon}
size="14"
class="inline-block align-sub mr-0.5"
/>
{:else}
<span class="w-4 inline-block" />
{/if}
{$_(`gpx.symbol.${key}`)}
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Label for="link">{$_('toolbar.waypoint.link')}</Label>
<Input bind:value={link} id="link" class="h-8" />
<div class="flex flex-row gap-2">
<div>
<Label for="latitude">{$_('toolbar.waypoint.latitude')}</Label>
@@ -204,7 +263,7 @@
<CircleX size="16" />
</Button>
</div>
<Help>
<Help link="./help/toolbar/poi">
{#if $selectedWaypoint || canCreate}
{$_('toolbar.waypoint.help')}
{:else}

View File

@@ -236,11 +236,11 @@
</Tooltip>
</div>
<div class="w-full flex flex-row gap-2 items-end justify-between">
<Help>
<Help link="./help/toolbar/routing">
{#if !validSelection}
<div>{$_('toolbar.routing.help_no_file')}</div>
{$_('toolbar.routing.help_no_file')}
{:else}
<div>{$_('toolbar.routing.help')}</div>
{$_('toolbar.routing.help')}
{/if}
</Help>
<Button

View File

@@ -11,6 +11,7 @@ const { routing, routingProfile, privateRoads } = settings;
export const brouterProfiles: { [key: string]: string } = {
bike: 'Trekking-dry',
racing_bike: 'fastbike',
gravel_bike: 'gravel',
mountain_bike: 'MTB',
foot: 'Hiking-Alpine-SAC6',
motorcycle: 'Car-FastEco',
@@ -23,7 +24,7 @@ export const routingProfileSelectItem = writable({
});
derived([routingProfile, locale, isLoading], ([profile, l, i]) => [profile, l, i]).subscribe(([profile, l, i]) => {
if (!i && profile !== '' && profile !== get(routingProfileSelectItem).value && l !== null) {
if (!i && profile !== '' && (profile !== get(routingProfileSelectItem).value || get(_)(`toolbar.routing.activities.${profile}`) !== get(routingProfileSelectItem).label) && l !== null) {
routingProfileSelectItem.update((item) => {
item.value = profile;
item.label = get(_)(`toolbar.routing.activities.${profile}`);

View File

@@ -1,4 +1,4 @@
import { distance, type Coordinates, TrackPoint, TrackSegment, Track } from "gpx";
import { distance, type Coordinates, TrackPoint, TrackSegment, Track, projectedPoint } from "gpx";
import { get, writable, type Readable } from "svelte/store";
import mapboxgl from "mapbox-gl";
import { route } from "./Routing";
@@ -10,10 +10,14 @@ import { dbUtils, type GPXFileWithStatistics } from "$lib/db";
import { getOrderedSelection, selection } from "$lib/components/file-list/Selection";
import { ListFileItem, ListTrackItem, ListTrackSegmentItem } from "$lib/components/file-list/FileList";
import { currentTool, streetViewEnabled, Tool } from "$lib/stores";
import { resetCursor, setGrabbingCursor } from "$lib/utils";
import { getClosestLinePoint, getElevation, resetCursor, setGrabbingCursor } from "$lib/utils";
export const canChangeStart = writable(false);
function stopPropagation(e: any) {
e.stopPropagation();
}
export class RoutingControls {
active: boolean = false;
map: mapboxgl.Map;
@@ -24,6 +28,7 @@ export class RoutingControls {
popup: mapboxgl.Popup;
popupElement: HTMLElement;
temporaryAnchor: AnchorWithMarker;
lastDragEvent = 0;
fileUnsubscribe: () => void = () => { };
unsubscribes: Function[] = [];
@@ -76,10 +81,10 @@ export class RoutingControls {
add() {
this.active = true;
this.map.on('zoom', this.toggleAnchorsForZoomLevelAndBoundsBinded);
this.map.on('move', this.toggleAnchorsForZoomLevelAndBoundsBinded);
this.map.on('click', this.appendAnchorBinded);
this.map.on('mousemove', this.fileId, this.showTemporaryAnchorBinded);
this.map.on('click', this.fileId, stopPropagation);
this.fileUnsubscribe = this.file.subscribe(this.updateControls.bind(this));
}
@@ -123,10 +128,10 @@ export class RoutingControls {
for (let anchor of this.anchors) {
anchor.marker.remove();
}
this.map.off('zoom', this.toggleAnchorsForZoomLevelAndBoundsBinded);
this.map.off('move', this.toggleAnchorsForZoomLevelAndBoundsBinded);
this.map.off('click', this.appendAnchorBinded);
this.map.off('mousemove', this.fileId, this.showTemporaryAnchorBinded);
this.map.off('click', this.fileId, stopPropagation);
this.map.off('mousemove', this.updateTemporaryAnchorBinded);
this.temporaryAnchor.marker.remove();
@@ -139,7 +144,7 @@ export class RoutingControls {
createAnchor(point: TrackPoint, segment: TrackSegment, trackIndex: number, segmentIndex: number): AnchorWithMarker {
let element = document.createElement('div');
element.className = `h-3 w-3 rounded-full bg-white border-2 border-black cursor-pointer`;
element.className = `h-5 w-5 xs:h-4 xs:w-4 md:h-3 md:w-3 rounded-full bg-white border-2 border-black cursor-pointer`;
let marker = new mapboxgl.Marker({
draggable: true,
@@ -156,27 +161,37 @@ export class RoutingControls {
inZoom: false
};
let lastDragEvent = 0;
marker.on('dragstart', (e) => {
lastDragEvent = Date.now();
this.lastDragEvent = Date.now();
setGrabbingCursor();
element.classList.remove('cursor-pointer');
element.classList.add('cursor-grabbing');
});
marker.on('dragend', (e) => {
lastDragEvent = Date.now();
this.lastDragEvent = Date.now();
resetCursor();
element.classList.remove('cursor-grabbing');
element.classList.add('cursor-pointer');
this.moveAnchor(anchor);
});
marker.getElement().addEventListener('click', (e) => {
let handleAnchorClick = this.handleClickForAnchor(anchor, marker);
marker.getElement().addEventListener('click', handleAnchorClick);
marker.getElement().addEventListener('contextmenu', handleAnchorClick);
return anchor;
}
handleClickForAnchor(anchor: Anchor, marker: mapboxgl.Marker) {
return (e: any) => {
e.preventDefault();
e.stopPropagation();
if (marker === this.temporaryAnchor.marker) {
if (Date.now() - this.lastDragEvent < 100) { // Prevent click event during drag
return;
}
if (Date.now() - lastDragEvent < 100) { // Prevent click event during drag
if (marker === this.temporaryAnchor.marker) {
this.turnIntoPermanentAnchor();
return;
}
@@ -207,22 +222,21 @@ export class RoutingControls {
this.popupElement.removeEventListener('delete', deleteThisAnchor);
this.popupElement.removeEventListener('change-start', startLoopAtThisAnchor);
});
});
return anchor;
};
}
toggleAnchorsForZoomLevelAndBounds() { // Show markers only if they are in the current zoom level and bounds
this.shownAnchors.splice(0, this.shownAnchors.length);
let southWest = this.map.unproject([0, this.map.getCanvas().height]);
let northEast = this.map.unproject([this.map.getCanvas().width, 0]);
let bounds = new mapboxgl.LngLatBounds(southWest, northEast);
let center = this.map.getCenter();
let bottomLeft = this.map.unproject([0, this.map.getCanvas().height]);
let topRight = this.map.unproject([this.map.getCanvas().width, 0]);
let diagonal = bottomLeft.distanceTo(topRight);
let zoom = this.map.getZoom();
this.anchors.forEach((anchor) => {
anchor.inZoom = anchor.point._data.zoom <= zoom;
if (anchor.inZoom && bounds.contains(anchor.marker.getLngLat())) {
if (anchor.inZoom && center.distanceTo(anchor.marker.getLngLat()) < diagonal) {
anchor.marker.addTo(this.map);
this.shownAnchors.push(anchor);
} else {
@@ -232,6 +246,10 @@ export class RoutingControls {
}
showTemporaryAnchor(e: any) {
if (this.temporaryAnchor.marker.getElement().classList.contains('cursor-grabbing')) { // Do not not change the source point if it is already being dragged
return;
}
if (get(streetViewEnabled)) {
return;
}
@@ -318,28 +336,83 @@ export class RoutingControls {
let file = get(this.file)?.file;
// Find the point closest to the temporary anchor
let minDistance = Number.MAX_VALUE;
let minDetails: any = { distance: Number.MAX_VALUE };
let minAnchor = this.temporaryAnchor as Anchor;
file?.forEachSegment((segment, trackIndex, segmentIndex) => {
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
for (let point of segment.trkpt) {
let dist = distance(point.getCoordinates(), this.temporaryAnchor.point.getCoordinates());
if (dist < minDistance) {
minDistance = dist;
minAnchor = {
point,
segment,
trackIndex,
segmentIndex,
};
}
let details: any = {};
let closest = getClosestLinePoint(segment.trkpt, this.temporaryAnchor.point, details);
if (details.distance < minDetails.distance) {
minDetails = details;
minAnchor = {
point: closest,
segment,
trackIndex,
segmentIndex
};
}
}
});
if (minAnchor.point._data.anchor) {
minAnchor.point = minAnchor.point.clone();
if (minDetails.before) {
minAnchor.point._data.index = minAnchor.point._data.index + 0.5;
} else {
minAnchor.point._data.index = minAnchor.point._data.index - 0.5;
}
}
return minAnchor;
}
turnIntoPermanentAnchor() {
let file = get(this.file)?.file;
// Find the point closest to the temporary anchor
let minDetails: any = { distance: Number.MAX_VALUE };
let minInfo = {
point: this.temporaryAnchor.point,
trackIndex: -1,
segmentIndex: -1,
trkptIndex: -1
};
file?.forEachSegment((segment, trackIndex, segmentIndex) => {
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
let details: any = {};
getClosestLinePoint(segment.trkpt, this.temporaryAnchor.point, details);
if (details.distance < minDetails.distance) {
minDetails = details;
let before = details.before ? details.index : details.index - 1;
let projectedPt = projectedPoint(segment.trkpt[before], segment.trkpt[before + 1], this.temporaryAnchor.point);
let ratio = distance(segment.trkpt[before], projectedPt) / distance(segment.trkpt[before], segment.trkpt[before + 1]);
let point = segment.trkpt[before].clone();
point.setCoordinates(projectedPt);
point.ele = (1 - ratio) * (segment.trkpt[before].ele ?? 0) + ratio * (segment.trkpt[before + 1].ele ?? 0);
point.time = (segment.trkpt[before].time && segment.trkpt[before + 1].time) ? new Date((1 - ratio) * segment.trkpt[before].time.getTime() + ratio * segment.trkpt[before + 1].time.getTime()) : undefined;
point._data = {
anchor: true,
zoom: 0
};
minInfo = {
point,
trackIndex,
segmentIndex,
trkptIndex: before + 1
};
}
}
});
if (minInfo.trackIndex !== -1) {
dbUtils.applyToFile(this.fileId, (file) => file.replaceTrackPoints(minInfo.trackIndex, minInfo.segmentIndex, minInfo.trkptIndex, minInfo.trkptIndex - 1, [minInfo.point]));
}
}
getDeleteAnchor(anchor: Anchor) {
return () => this.deleteAnchor(anchor);
}
@@ -503,6 +576,9 @@ export class RoutingControls {
if (anchors[0].point._data.index === 0) { // First anchor is the first point of the segment
anchors[0].point = response[0]; // replace the first anchor
anchors[0].point._data.index = 0;
} else if (anchors[0].point._data.index === segment.trkpt.length - 1 && distance(anchors[0].point.getCoordinates(), response[0].getCoordinates()) < 1) { // First anchor is the last point of the segment, and the new point is close enough
anchors[0].point = response[0]; // replace the first anchor
anchors[0].point._data.index = segment.trkpt.length - 1;
} else {
anchors[0].point = anchors[0].point.clone(); // Clone the anchor to assign new properties
response.splice(0, 0, anchors[0].point); // Insert it in the response to keep it
@@ -519,16 +595,7 @@ export class RoutingControls {
for (let i = 1; i < anchors.length - 1; i++) {
// Find the closest point to the intermediate anchor
// and transfer the marker to that point
let minDistance = Number.MAX_VALUE;
let minIndex = 0;
for (let j = 1; j < response.length - 1; j++) {
let dist = distance(response[j].getCoordinates(), targetCoordinates[i]);
if (dist < minDistance) {
minDistance = dist;
minIndex = j;
}
}
anchors[i].point = response[minIndex];
anchors[i].point = getClosestLinePoint(response.slice(1, - 1), targetCoordinates[i]);
}
anchors.forEach((anchor) => {
@@ -553,6 +620,10 @@ export class RoutingControls {
let remainingTime = stats.global.time.moving - (stats.local.time.moving[anchors[anchors.length - 1].point._data.index] - stats.local.time.moving[anchors[0].point._data.index]);
let replacingTime = newTime - remainingTime;
if (replacingTime <= 0) { // Fallback to simple time difference
replacingTime = stats.local.time.total[anchors[anchors.length - 1].point._data.index] - stats.local.time.total[anchors[0].point._data.index];
}
speed = replacingDistance / replacingTime * 3600;
if (startTime === undefined) { // Replacing the first point

View File

@@ -10,7 +10,7 @@ export function getZoomLevelForDistance(latitude: number, distance?: number): nu
const rad = Math.PI / 180;
const lat = latitude * rad;
return Math.min(20, Math.max(0, Math.floor(Math.log2((earthRadius * Math.cos(lat)) / distance))));
return Math.min(22, Math.max(0, Math.log2((earthRadius * Math.cos(lat)) / distance)));
}
export function updateAnchorPoints(file: GPXFile) {
@@ -34,7 +34,7 @@ export function updateAnchorPoints(file: GPXFile) {
function computeAnchorPoints(segment: TrackSegment) {
let points = segment.trkpt;
let anchors = ramerDouglasPeucker(points);
let anchors = ramerDouglasPeucker(points, 1);
anchors.forEach((anchor) => {
let point = anchor.point;
point._data.anchor = true;

View File

@@ -15,21 +15,34 @@
import { Slider } from '$lib/components/ui/slider';
import * as Select from '$lib/components/ui/select';
import { Separator } from '$lib/components/ui/separator';
import { gpxStatistics, slicedGPXStatistics, splitAs } from '$lib/stores';
import { gpxStatistics, map, slicedGPXStatistics, splitAs } from '$lib/stores';
import { get } from 'svelte/store';
import { _ } from 'svelte-i18n';
import { onDestroy, tick } from 'svelte';
import { Crop } from 'lucide-svelte';
import { dbUtils } from '$lib/db';
import { SplitControls } from './SplitControls';
let splitControls: SplitControls | undefined = undefined;
let canCrop = false;
$: if ($map) {
if (splitControls) {
splitControls.destroy();
}
splitControls = new SplitControls($map);
}
$: validSelection =
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) &&
$gpxStatistics.local.points.length > 0;
let maxSliderValue = 100;
let sliderValues = [0, 100];
let maxSliderValue = 1;
let sliderValues = [0, 1];
$: canCrop = sliderValues[0] != 0 || sliderValues[1] != maxSliderValue;
function updateCanCrop() {
canCrop = sliderValues[0] != 0 || sliderValues[1] != maxSliderValue;
}
function updateSlicedGPXStatistics() {
if (validSelection && canCrop) {
@@ -53,7 +66,7 @@
if (validSelection && $gpxStatistics.local.points.length > 0) {
maxSliderValue = $gpxStatistics.local.points.length - 1;
} else {
maxSliderValue = 100;
maxSliderValue = 1;
}
await tick();
sliderValues = [0, maxSliderValue];
@@ -64,6 +77,7 @@
}
$: if (sliderValues) {
updateCanCrop();
updateSlicedGPXStatistics();
}
@@ -72,6 +86,7 @@
($slicedGPXStatistics[1] !== sliderValues[0] || $slicedGPXStatistics[2] !== sliderValues[1])
) {
updateSliderValues();
updateCanCrop();
}
const splitTypes = [
@@ -86,6 +101,10 @@
onDestroy(() => {
$slicedGPXStatistics = undefined;
if (splitControls) {
splitControls.destroy();
splitControls = undefined;
}
});
</script>
@@ -116,7 +135,7 @@
</Select.Content>
</Select.Root>
</Label>
<Help>
<Help link="./help/toolbar/scissors">
{#if validSelection}
{$_('toolbar.scissors.help')}
{:else}

View File

@@ -0,0 +1,163 @@
import { TrackPoint, TrackSegment } from "gpx";
import { get } from "svelte/store";
import mapboxgl from "mapbox-gl";
import { dbUtils, getFile } from "$lib/db";
import { applyToOrderedSelectedItemsFromFile, selection } from "$lib/components/file-list/Selection";
import { ListTrackSegmentItem } from "$lib/components/file-list/FileList";
import { currentTool, gpxStatistics, Tool } from "$lib/stores";
import { _ } from "svelte-i18n";
import { Scissors } from "lucide-static";
export class SplitControls {
active: boolean = false;
map: mapboxgl.Map;
controls: ControlWithMarker[] = [];
shownControls: ControlWithMarker[] = [];
unsubscribes: Function[] = [];
toggleControlsForZoomLevelAndBoundsBinded: () => void = this.toggleControlsForZoomLevelAndBounds.bind(this);
constructor(map: mapboxgl.Map) {
this.map = map;
this.unsubscribes.push(selection.subscribe(this.addIfNeeded.bind(this)));
this.unsubscribes.push(gpxStatistics.subscribe(this.addIfNeeded.bind(this)));
this.unsubscribes.push(currentTool.subscribe(this.addIfNeeded.bind(this)));
}
addIfNeeded() {
let scissors = get(currentTool) === Tool.SCISSORS;
if (!scissors) {
if (this.active) {
this.remove();
}
return;
}
if (this.active) {
this.updateControls();
} else {
this.add();
}
}
add() {
this.active = true;
this.map.on('zoom', this.toggleControlsForZoomLevelAndBoundsBinded);
this.map.on('move', this.toggleControlsForZoomLevelAndBoundsBinded);
}
updateControls() { // Update the markers when the files change
let controlIndex = 0;
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
let file = getFile(fileId);
if (file) {
file.forEachSegment((segment, trackIndex, segmentIndex) => {
if (get(selection).hasAnyParent(new ListTrackSegmentItem(fileId, trackIndex, segmentIndex))) {
for (let point of segment.trkpt.slice(1, -1)) { // Update the existing controls (could be improved by matching the existing controls with the new ones?)
if (point._data.anchor) {
if (controlIndex < this.controls.length) {
this.controls[controlIndex].point = point;
this.controls[controlIndex].segment = segment;
this.controls[controlIndex].trackIndex = trackIndex;
this.controls[controlIndex].segmentIndex = segmentIndex;
this.controls[controlIndex].marker.setLngLat(point.getCoordinates());
} else {
this.controls.push(this.createControl(point, segment, fileId, trackIndex, segmentIndex));
}
controlIndex++;
}
}
}
});
}
}, false);
while (controlIndex < this.controls.length) { // Remove the extra controls
this.controls.pop()?.marker.remove();
}
this.toggleControlsForZoomLevelAndBounds();
}
remove() {
this.active = false;
for (let control of this.controls) {
control.marker.remove();
}
this.map.off('zoom', this.toggleControlsForZoomLevelAndBoundsBinded);
this.map.off('move', this.toggleControlsForZoomLevelAndBoundsBinded);
}
toggleControlsForZoomLevelAndBounds() { // Show markers only if they are in the current zoom level and bounds
this.shownControls.splice(0, this.shownControls.length);
let southWest = this.map.unproject([0, this.map.getCanvas().height]);
let northEast = this.map.unproject([this.map.getCanvas().width, 0]);
let bounds = new mapboxgl.LngLatBounds(southWest, northEast);
let zoom = this.map.getZoom();
this.controls.forEach((control) => {
control.inZoom = control.point._data.zoom <= zoom;
if (control.inZoom && bounds.contains(control.marker.getLngLat())) {
control.marker.addTo(this.map);
this.shownControls.push(control);
} else {
control.marker.remove();
}
});
}
createControl(point: TrackPoint, segment: TrackSegment, fileId: string, trackIndex: number, segmentIndex: number): ControlWithMarker {
let element = document.createElement('div');
element.className = `h-6 w-6 p-0.5 rounded-full bg-white border-2 border-black cursor-pointer`;
element.innerHTML = Scissors.replace('width="24"', "").replace('height="24"', "").replace('stroke="currentColor"', 'stroke="black"');
let marker = new mapboxgl.Marker({
draggable: true,
className: 'z-10',
element
}).setLngLat(point.getCoordinates());
let control = {
point,
segment,
fileId,
trackIndex,
segmentIndex,
marker,
inZoom: false
};
marker.getElement().addEventListener('click', (e) => {
e.stopPropagation();
dbUtils.split(fileId, trackIndex, segmentIndex, point.getCoordinates(), point._data.index);
});
return control;
}
destroy() {
this.remove();
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
}
}
type Control = {
segment: TrackSegment;
fileId: string;
trackIndex: number;
segmentIndex: number;
point: TrackPoint;
};
type ControlWithMarker = Control & {
marker: mapboxgl.Marker;
inZoom: boolean;
};