mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2025-12-28 05:59:59 +00:00
prettier config + format all, closes #175
This commit is contained in:
@@ -1,60 +1,60 @@
|
||||
<script lang="ts">
|
||||
import docsearch from '@docsearch/js';
|
||||
import '@docsearch/css';
|
||||
import { onMount } from 'svelte';
|
||||
import { _, locale, waitLocale } from 'svelte-i18n';
|
||||
import docsearch from '@docsearch/js';
|
||||
import '@docsearch/css';
|
||||
import { onMount } from 'svelte';
|
||||
import { _, locale, waitLocale } from 'svelte-i18n';
|
||||
|
||||
let mounted = false;
|
||||
let mounted = false;
|
||||
|
||||
function initDocsearch() {
|
||||
docsearch({
|
||||
appId: '21XLD94PE3',
|
||||
apiKey: 'd2c1ed6cb0ed12adb2bd84eb2a38494d',
|
||||
indexName: 'gpx',
|
||||
container: '#docsearch',
|
||||
searchParameters: {
|
||||
facetFilters: ['lang:' + ($locale ?? 'en')]
|
||||
},
|
||||
placeholder: $_('docs.search.search'),
|
||||
disableUserPersonalization: true,
|
||||
translations: {
|
||||
button: {
|
||||
buttonText: $_('docs.search.search'),
|
||||
buttonAriaLabel: $_('docs.search.search')
|
||||
},
|
||||
modal: {
|
||||
searchBox: {
|
||||
resetButtonTitle: $_('docs.search.clear'),
|
||||
resetButtonAriaLabel: $_('docs.search.clear'),
|
||||
cancelButtonText: $_('docs.search.cancel'),
|
||||
cancelButtonAriaLabel: $_('docs.search.cancel'),
|
||||
searchInputLabel: $_('docs.search.search')
|
||||
},
|
||||
footer: {
|
||||
selectText: $_('docs.search.to_select'),
|
||||
navigateText: $_('docs.search.to_navigate'),
|
||||
closeText: $_('docs.search.to_close')
|
||||
},
|
||||
noResultsScreen: {
|
||||
noResultsText: $_('docs.search.no_results'),
|
||||
suggestedQueryText: $_('docs.search.no_results_suggestion')
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
function initDocsearch() {
|
||||
docsearch({
|
||||
appId: '21XLD94PE3',
|
||||
apiKey: 'd2c1ed6cb0ed12adb2bd84eb2a38494d',
|
||||
indexName: 'gpx',
|
||||
container: '#docsearch',
|
||||
searchParameters: {
|
||||
facetFilters: ['lang:' + ($locale ?? 'en')],
|
||||
},
|
||||
placeholder: $_('docs.search.search'),
|
||||
disableUserPersonalization: true,
|
||||
translations: {
|
||||
button: {
|
||||
buttonText: $_('docs.search.search'),
|
||||
buttonAriaLabel: $_('docs.search.search'),
|
||||
},
|
||||
modal: {
|
||||
searchBox: {
|
||||
resetButtonTitle: $_('docs.search.clear'),
|
||||
resetButtonAriaLabel: $_('docs.search.clear'),
|
||||
cancelButtonText: $_('docs.search.cancel'),
|
||||
cancelButtonAriaLabel: $_('docs.search.cancel'),
|
||||
searchInputLabel: $_('docs.search.search'),
|
||||
},
|
||||
footer: {
|
||||
selectText: $_('docs.search.to_select'),
|
||||
navigateText: $_('docs.search.to_navigate'),
|
||||
closeText: $_('docs.search.to_close'),
|
||||
},
|
||||
noResultsScreen: {
|
||||
noResultsText: $_('docs.search.no_results'),
|
||||
suggestedQueryText: $_('docs.search.no_results_suggestion'),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
});
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
$: if (mounted && $locale) {
|
||||
waitLocale().then(initDocsearch);
|
||||
}
|
||||
$: if (mounted && $locale) {
|
||||
waitLocale().then(initDocsearch);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="preconnect" href="https://21XLD94PE3-dsn.algolia.net" crossorigin />
|
||||
<link rel="preconnect" href="https://21XLD94PE3-dsn.algolia.net" crossorigin />
|
||||
</svelte:head>
|
||||
|
||||
<div id="docsearch" {...$$restProps}></div>
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
import type { Builder } from 'bits-ui';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
import type { Builder } from 'bits-ui';
|
||||
|
||||
export let variant:
|
||||
| 'default'
|
||||
| 'secondary'
|
||||
| 'link'
|
||||
| 'destructive'
|
||||
| 'outline'
|
||||
| 'ghost'
|
||||
| undefined = 'default';
|
||||
export let label: string;
|
||||
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
|
||||
export let builders: Builder[] = [];
|
||||
export let variant:
|
||||
| 'default'
|
||||
| 'secondary'
|
||||
| 'link'
|
||||
| 'destructive'
|
||||
| 'outline'
|
||||
| 'ghost'
|
||||
| undefined = 'default';
|
||||
export let label: string;
|
||||
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
|
||||
export let builders: Builder[] = [];
|
||||
</script>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild let:builder>
|
||||
<Button builders={[...builders, builder]} {variant} {...$$restProps} on:click>
|
||||
<slot />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content {side}>
|
||||
<span>{label}</span>
|
||||
</Tooltip.Content>
|
||||
<Tooltip.Trigger asChild let:builder>
|
||||
<Button builders={[...builders, builder]} {variant} {...$$restProps} on:click>
|
||||
<slot />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content {side}>
|
||||
<span>{label}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { map } from '$lib/stores';
|
||||
import { trackpointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
|
||||
import { TrackPoint } from 'gpx';
|
||||
import { map } from '$lib/stores';
|
||||
import { trackpointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
|
||||
import { TrackPoint } from 'gpx';
|
||||
|
||||
$: if ($map) {
|
||||
$map.on('contextmenu', (e) => {
|
||||
trackpointPopup?.setItem({
|
||||
item: new TrackPoint({
|
||||
attributes: {
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng
|
||||
}
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
$: if ($map) {
|
||||
$map.on('contextmenu', (e) => {
|
||||
trackpointPopup?.setItem({
|
||||
item: new TrackPoint({
|
||||
attributes: {
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,186 +1,190 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { Dialog } from 'bits-ui';
|
||||
import {
|
||||
currentTool,
|
||||
exportAllFiles,
|
||||
exportSelectedFiles,
|
||||
ExportState,
|
||||
exportState,
|
||||
gpxStatistics
|
||||
} from '$lib/stores';
|
||||
import { fileObservers } from '$lib/db';
|
||||
import {
|
||||
Download,
|
||||
Zap,
|
||||
Earth,
|
||||
HeartPulse,
|
||||
Orbit,
|
||||
Thermometer,
|
||||
SquareActivity
|
||||
} from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { selection } from './file-list/Selection';
|
||||
import { get } from 'svelte/store';
|
||||
import { GPXStatistics } from 'gpx';
|
||||
import { ListRootItem } from './file-list/FileList';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { Dialog } from 'bits-ui';
|
||||
import {
|
||||
currentTool,
|
||||
exportAllFiles,
|
||||
exportSelectedFiles,
|
||||
ExportState,
|
||||
exportState,
|
||||
gpxStatistics,
|
||||
} from '$lib/stores';
|
||||
import { fileObservers } from '$lib/db';
|
||||
import {
|
||||
Download,
|
||||
Zap,
|
||||
Earth,
|
||||
HeartPulse,
|
||||
Orbit,
|
||||
Thermometer,
|
||||
SquareActivity,
|
||||
} from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { selection } from './file-list/Selection';
|
||||
import { get } from 'svelte/store';
|
||||
import { GPXStatistics } from 'gpx';
|
||||
import { ListRootItem } from './file-list/FileList';
|
||||
|
||||
let open = false;
|
||||
let exportOptions: Record<string, boolean> = {
|
||||
time: true,
|
||||
hr: true,
|
||||
cad: true,
|
||||
atemp: true,
|
||||
power: true,
|
||||
extensions: true
|
||||
};
|
||||
let hide: Record<string, boolean> = {
|
||||
time: false,
|
||||
hr: false,
|
||||
cad: false,
|
||||
atemp: false,
|
||||
power: false,
|
||||
extensions: false
|
||||
};
|
||||
let open = false;
|
||||
let exportOptions: Record<string, boolean> = {
|
||||
time: true,
|
||||
hr: true,
|
||||
cad: true,
|
||||
atemp: true,
|
||||
power: true,
|
||||
extensions: true,
|
||||
};
|
||||
let hide: Record<string, boolean> = {
|
||||
time: false,
|
||||
hr: false,
|
||||
cad: false,
|
||||
atemp: false,
|
||||
power: false,
|
||||
extensions: false,
|
||||
};
|
||||
|
||||
$: if ($exportState !== ExportState.NONE) {
|
||||
open = true;
|
||||
$currentTool = null;
|
||||
$: if ($exportState !== ExportState.NONE) {
|
||||
open = true;
|
||||
$currentTool = null;
|
||||
|
||||
let statistics = $gpxStatistics;
|
||||
if ($exportState === ExportState.ALL) {
|
||||
statistics = Array.from($fileObservers.values())
|
||||
.map((file) => get(file)?.statistics)
|
||||
.reduce((acc, cur) => {
|
||||
if (cur !== undefined) {
|
||||
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()));
|
||||
}
|
||||
return acc;
|
||||
}, new GPXStatistics());
|
||||
}
|
||||
let statistics = $gpxStatistics;
|
||||
if ($exportState === ExportState.ALL) {
|
||||
statistics = Array.from($fileObservers.values())
|
||||
.map((file) => get(file)?.statistics)
|
||||
.reduce((acc, cur) => {
|
||||
if (cur !== undefined) {
|
||||
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()));
|
||||
}
|
||||
return acc;
|
||||
}, new GPXStatistics());
|
||||
}
|
||||
|
||||
hide.time = statistics.global.time.total === 0;
|
||||
hide.hr = statistics.global.hr.count === 0;
|
||||
hide.cad = statistics.global.cad.count === 0;
|
||||
hide.atemp = statistics.global.atemp.count === 0;
|
||||
hide.power = statistics.global.power.count === 0;
|
||||
hide.extensions = Object.keys(statistics.global.extensions).length === 0;
|
||||
}
|
||||
hide.time = statistics.global.time.total === 0;
|
||||
hide.hr = statistics.global.hr.count === 0;
|
||||
hide.cad = statistics.global.cad.count === 0;
|
||||
hide.atemp = statistics.global.atemp.count === 0;
|
||||
hide.power = statistics.global.power.count === 0;
|
||||
hide.extensions = Object.keys(statistics.global.extensions).length === 0;
|
||||
}
|
||||
|
||||
$: exclude = Object.keys(exportOptions).filter((key) => !exportOptions[key]);
|
||||
$: exclude = Object.keys(exportOptions).filter((key) => !exportOptions[key]);
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
$exportState = ExportState.NONE;
|
||||
}
|
||||
}}
|
||||
bind:open
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
$exportState = ExportState.NONE;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Trigger class="hidden" />
|
||||
<Dialog.Portal>
|
||||
<Dialog.Content
|
||||
class="fixed left-[50%] top-[50%] z-50 w-fit max-w-full translate-x-[-50%] translate-y-[-50%] flex flex-col items-center gap-3 border bg-background p-3 shadow-lg rounded-md"
|
||||
>
|
||||
<div
|
||||
class="w-full flex flex-row items-center justify-center gap-4 border rounded-md p-2 bg-secondary"
|
||||
>
|
||||
<span>⚠️</span>
|
||||
<span class="max-w-[80%] text-sm">
|
||||
{$_('menu.support_message')}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full flex flex-row flex-wrap gap-2">
|
||||
<Button class="bg-support grow" href="https://ko-fi.com/gpxstudio" target="_blank">
|
||||
{$_('menu.support_button')}
|
||||
<span class="ml-2">🙏</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="grow"
|
||||
on:click={() => {
|
||||
if ($exportState === ExportState.SELECTION) {
|
||||
exportSelectedFiles(exclude);
|
||||
} else if ($exportState === ExportState.ALL) {
|
||||
exportAllFiles(exclude);
|
||||
}
|
||||
open = false;
|
||||
$exportState = ExportState.NONE;
|
||||
}}
|
||||
>
|
||||
<Download size="16" class="mr-1" />
|
||||
{#if $fileObservers.size === 1 || ($exportState === ExportState.SELECTION && $selection.size === 1)}
|
||||
{$_('menu.download_file')}
|
||||
{:else}
|
||||
{$_('menu.download_files')}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
class="w-full max-w-xl flex flex-col items-center gap-2 {Object.values(hide).some((v) => !v)
|
||||
? ''
|
||||
: 'hidden'}"
|
||||
>
|
||||
<div class="w-full flex flex-row items-center gap-3">
|
||||
<div class="grow">
|
||||
<Separator />
|
||||
</div>
|
||||
<Label class="shrink-0">
|
||||
{$_('menu.export_options')}
|
||||
</Label>
|
||||
<div class="grow">
|
||||
<Separator />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row flex-wrap justify-center gap-x-6 gap-y-2">
|
||||
<div class="flex flex-row items-center gap-1.5 {hide.time ? 'hidden' : ''}">
|
||||
<Checkbox id="export-time" bind:checked={exportOptions.time} />
|
||||
<Label for="export-time" class="flex flex-row items-center gap-1">
|
||||
<Zap size="16" />
|
||||
{$_('quantities.time')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-1.5 {hide.extensions ? 'hidden' : ''}">
|
||||
<Checkbox id="export-extensions" bind:checked={exportOptions.extensions} />
|
||||
<Label for="export-extensions" class="flex flex-row items-center gap-1">
|
||||
<Earth size="16" />
|
||||
{$_('quantities.osm_extensions')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-1.5 {hide.hr ? 'hidden' : ''}">
|
||||
<Checkbox id="export-heartrate" bind:checked={exportOptions.hr} />
|
||||
<Label for="export-heartrate" class="flex flex-row items-center gap-1">
|
||||
<HeartPulse size="16" />
|
||||
{$_('quantities.heartrate')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-1.5 {hide.cad ? 'hidden' : ''}">
|
||||
<Checkbox id="export-cadence" bind:checked={exportOptions.cad} />
|
||||
<Label for="export-cadence" class="flex flex-row items-center gap-1">
|
||||
<Orbit size="16" />
|
||||
{$_('quantities.cadence')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-1.5 {hide.atemp ? 'hidden' : ''}">
|
||||
<Checkbox id="export-temperature" bind:checked={exportOptions.atemp} />
|
||||
<Label for="export-temperature" class="flex flex-row items-center gap-1">
|
||||
<Thermometer size="16" />
|
||||
{$_('quantities.temperature')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-1.5 {hide.power ? 'hidden' : ''}">
|
||||
<Checkbox id="export-power" bind:checked={exportOptions.power} />
|
||||
<Label for="export-power" class="flex flex-row items-center gap-1">
|
||||
<SquareActivity size="16" />
|
||||
{$_('quantities.power')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
<Dialog.Trigger class="hidden" />
|
||||
<Dialog.Portal>
|
||||
<Dialog.Content
|
||||
class="fixed left-[50%] top-[50%] z-50 w-fit max-w-full translate-x-[-50%] translate-y-[-50%] flex flex-col items-center gap-3 border bg-background p-3 shadow-lg rounded-md"
|
||||
>
|
||||
<div
|
||||
class="w-full flex flex-row items-center justify-center gap-4 border rounded-md p-2 bg-secondary"
|
||||
>
|
||||
<span>⚠️</span>
|
||||
<span class="max-w-[80%] text-sm">
|
||||
{$_('menu.support_message')}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full flex flex-row flex-wrap gap-2">
|
||||
<Button class="bg-support grow" href="https://ko-fi.com/gpxstudio" target="_blank">
|
||||
{$_('menu.support_button')}
|
||||
<span class="ml-2">🙏</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="grow"
|
||||
on:click={() => {
|
||||
if ($exportState === ExportState.SELECTION) {
|
||||
exportSelectedFiles(exclude);
|
||||
} else if ($exportState === ExportState.ALL) {
|
||||
exportAllFiles(exclude);
|
||||
}
|
||||
open = false;
|
||||
$exportState = ExportState.NONE;
|
||||
}}
|
||||
>
|
||||
<Download size="16" class="mr-1" />
|
||||
{#if $fileObservers.size === 1 || ($exportState === ExportState.SELECTION && $selection.size === 1)}
|
||||
{$_('menu.download_file')}
|
||||
{:else}
|
||||
{$_('menu.download_files')}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
class="w-full max-w-xl flex flex-col items-center gap-2 {Object.values(hide).some(
|
||||
(v) => !v
|
||||
)
|
||||
? ''
|
||||
: 'hidden'}"
|
||||
>
|
||||
<div class="w-full flex flex-row items-center gap-3">
|
||||
<div class="grow">
|
||||
<Separator />
|
||||
</div>
|
||||
<Label class="shrink-0">
|
||||
{$_('menu.export_options')}
|
||||
</Label>
|
||||
<div class="grow">
|
||||
<Separator />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row flex-wrap justify-center gap-x-6 gap-y-2">
|
||||
<div class="flex flex-row items-center gap-1.5 {hide.time ? 'hidden' : ''}">
|
||||
<Checkbox id="export-time" bind:checked={exportOptions.time} />
|
||||
<Label for="export-time" class="flex flex-row items-center gap-1">
|
||||
<Zap size="16" />
|
||||
{$_('quantities.time')}
|
||||
</Label>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-row items-center gap-1.5 {hide.extensions ? 'hidden' : ''}"
|
||||
>
|
||||
<Checkbox id="export-extensions" bind:checked={exportOptions.extensions} />
|
||||
<Label for="export-extensions" class="flex flex-row items-center gap-1">
|
||||
<Earth size="16" />
|
||||
{$_('quantities.osm_extensions')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-1.5 {hide.hr ? 'hidden' : ''}">
|
||||
<Checkbox id="export-heartrate" bind:checked={exportOptions.hr} />
|
||||
<Label for="export-heartrate" class="flex flex-row items-center gap-1">
|
||||
<HeartPulse size="16" />
|
||||
{$_('quantities.heartrate')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-1.5 {hide.cad ? 'hidden' : ''}">
|
||||
<Checkbox id="export-cadence" bind:checked={exportOptions.cad} />
|
||||
<Label for="export-cadence" class="flex flex-row items-center gap-1">
|
||||
<Orbit size="16" />
|
||||
{$_('quantities.cadence')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-1.5 {hide.atemp ? 'hidden' : ''}">
|
||||
<Checkbox id="export-temperature" bind:checked={exportOptions.atemp} />
|
||||
<Label for="export-temperature" class="flex flex-row items-center gap-1">
|
||||
<Thermometer size="16" />
|
||||
{$_('quantities.temperature')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-1.5 {hide.power ? 'hidden' : ''}">
|
||||
<Checkbox id="export-power" bind:checked={exportOptions.power} />
|
||||
<Label for="export-power" class="flex flex-row items-center gap-1">
|
||||
<SquareActivity size="16" />
|
||||
{$_('quantities.power')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -1,125 +1,125 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import LanguageSelect from '$lib/components/LanguageSelect.svelte';
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
import { AtSign, BookOpenText, Heart, Home, Map } from 'lucide-svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import LanguageSelect from '$lib/components/LanguageSelect.svelte';
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
import { AtSign, BookOpenText, Heart, Home, Map } from 'lucide-svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
</script>
|
||||
|
||||
<footer class="w-full">
|
||||
<div class="mx-6 border-t">
|
||||
<div class="mx-12 py-10 flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
|
||||
<div class="grow flex flex-col items-start">
|
||||
<Logo class="h-8" width="153" />
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
>
|
||||
MIT © 2024 gpx.studio
|
||||
</Button>
|
||||
<LanguageSelect class="w-40 mt-3" />
|
||||
</div>
|
||||
<div class="grow max-w-2xl flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span class="font-semibold">{$_('homepage.website')}</span>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
href={getURLForLanguage($locale, '/')}
|
||||
>
|
||||
<Home size="16" class="mr-1" />
|
||||
{$_('homepage.home')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
href={getURLForLanguage($locale, '/app')}
|
||||
>
|
||||
<Map size="16" class="mr-1" />
|
||||
{$_('homepage.app')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
href={getURLForLanguage($locale, '/help')}
|
||||
>
|
||||
<BookOpenText size="16" class="mr-1" />
|
||||
{$_('menu.help')}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-1" id="contact">
|
||||
<span class="font-semibold">{$_('homepage.contact')}</span>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
href="https://www.reddit.com/r/gpxstudio/"
|
||||
target="_blank"
|
||||
>
|
||||
<Logo company="reddit" class="h-4 mr-1 fill-muted-foreground" />
|
||||
{$_('homepage.reddit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
href="https://facebook.com/gpx.studio"
|
||||
target="_blank"
|
||||
>
|
||||
<Logo company="facebook" class="h-4 mr-1 fill-muted-foreground" />
|
||||
{$_('homepage.facebook')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
href="https://x.com/gpxstudio"
|
||||
target="_blank"
|
||||
>
|
||||
<Logo company="x" class="h-4 mr-1 fill-muted-foreground" />
|
||||
{$_('homepage.x')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
href="mailto:hello@gpx.studio"
|
||||
target="_blank"
|
||||
>
|
||||
<AtSign size="16" class="mr-1" />
|
||||
{$_('homepage.email')}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span class="font-semibold">{$_('homepage.contribute')}</span>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
href="https://ko-fi.com/gpxstudio"
|
||||
target="_blank"
|
||||
>
|
||||
<Heart size="16" class="mr-1" />
|
||||
{$_('menu.donate')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
href="https://crowdin.com/project/gpxstudio"
|
||||
target="_blank"
|
||||
>
|
||||
<Logo company="crowdin" class="h-4 mr-1 fill-muted-foreground" />
|
||||
{$_('homepage.crowdin')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
href="https://github.com/gpxstudio/gpx.studio"
|
||||
target="_blank"
|
||||
>
|
||||
<Logo company="github" class="h-4 mr-1 fill-muted-foreground" />
|
||||
{$_('homepage.github')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-6 border-t">
|
||||
<div class="mx-12 py-10 flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
|
||||
<div class="grow flex flex-col items-start">
|
||||
<Logo class="h-8" width="153" />
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
>
|
||||
MIT © 2024 gpx.studio
|
||||
</Button>
|
||||
<LanguageSelect class="w-40 mt-3" />
|
||||
</div>
|
||||
<div class="grow max-w-2xl flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span class="font-semibold">{$_('homepage.website')}</span>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
href={getURLForLanguage($locale, '/')}
|
||||
>
|
||||
<Home size="16" class="mr-1" />
|
||||
{$_('homepage.home')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
href={getURLForLanguage($locale, '/app')}
|
||||
>
|
||||
<Map size="16" class="mr-1" />
|
||||
{$_('homepage.app')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
href={getURLForLanguage($locale, '/help')}
|
||||
>
|
||||
<BookOpenText size="16" class="mr-1" />
|
||||
{$_('menu.help')}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-1" id="contact">
|
||||
<span class="font-semibold">{$_('homepage.contact')}</span>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
href="https://www.reddit.com/r/gpxstudio/"
|
||||
target="_blank"
|
||||
>
|
||||
<Logo company="reddit" class="h-4 mr-1 fill-muted-foreground" />
|
||||
{$_('homepage.reddit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
href="https://facebook.com/gpx.studio"
|
||||
target="_blank"
|
||||
>
|
||||
<Logo company="facebook" class="h-4 mr-1 fill-muted-foreground" />
|
||||
{$_('homepage.facebook')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
href="https://x.com/gpxstudio"
|
||||
target="_blank"
|
||||
>
|
||||
<Logo company="x" class="h-4 mr-1 fill-muted-foreground" />
|
||||
{$_('homepage.x')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
href="mailto:hello@gpx.studio"
|
||||
target="_blank"
|
||||
>
|
||||
<AtSign size="16" class="mr-1" />
|
||||
{$_('homepage.email')}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span class="font-semibold">{$_('homepage.contribute')}</span>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
href="https://ko-fi.com/gpxstudio"
|
||||
target="_blank"
|
||||
>
|
||||
<Heart size="16" class="mr-1" />
|
||||
{$_('menu.donate')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
href="https://crowdin.com/project/gpxstudio"
|
||||
target="_blank"
|
||||
>
|
||||
<Logo company="crowdin" class="h-4 mr-1 fill-muted-foreground" />
|
||||
{$_('homepage.crowdin')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
href="https://github.com/gpxstudio/gpx.studio"
|
||||
target="_blank"
|
||||
>
|
||||
<Logo company="github" class="h-4 mr-1 fill-muted-foreground" />
|
||||
{$_('homepage.github')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,82 +1,88 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
|
||||
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from 'lucide-svelte';
|
||||
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from 'lucide-svelte';
|
||||
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { GPXStatistics } from 'gpx';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { settings } from '$lib/db';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { GPXStatistics } from 'gpx';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { settings } from '$lib/db';
|
||||
|
||||
export let gpxStatistics: Writable<GPXStatistics>;
|
||||
export let slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
|
||||
export let orientation: 'horizontal' | 'vertical';
|
||||
export let panelSize: number;
|
||||
export let gpxStatistics: Writable<GPXStatistics>;
|
||||
export let slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
|
||||
export let orientation: 'horizontal' | 'vertical';
|
||||
export let panelSize: number;
|
||||
|
||||
const { velocityUnits } = settings;
|
||||
const { velocityUnits } = settings;
|
||||
|
||||
let statistics: GPXStatistics;
|
||||
let statistics: GPXStatistics;
|
||||
|
||||
$: if ($slicedGPXStatistics !== undefined) {
|
||||
statistics = $slicedGPXStatistics[0];
|
||||
} else {
|
||||
statistics = $gpxStatistics;
|
||||
}
|
||||
$: if ($slicedGPXStatistics !== undefined) {
|
||||
statistics = $slicedGPXStatistics[0];
|
||||
} else {
|
||||
statistics = $gpxStatistics;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root
|
||||
class="h-full {orientation === 'vertical'
|
||||
? 'min-w-40 sm:min-w-44 text-sm sm:text-base'
|
||||
: 'w-full'} border-none shadow-none"
|
||||
class="h-full {orientation === 'vertical'
|
||||
? 'min-w-40 sm:min-w-44 text-sm sm:text-base'
|
||||
: 'w-full'} border-none shadow-none"
|
||||
>
|
||||
<Card.Content
|
||||
class="h-full flex {orientation === 'vertical'
|
||||
? 'flex-col justify-center'
|
||||
: 'flex-row w-full justify-between'} gap-4 p-0"
|
||||
>
|
||||
<Tooltip label={$_('quantities.distance')}>
|
||||
<span class="flex flex-row items-center">
|
||||
<Ruler size="16" class="mr-1" />
|
||||
<WithUnits value={statistics.global.distance.total} type="distance" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label={$_('quantities.elevation_gain_loss')}>
|
||||
<span class="flex flex-row items-center">
|
||||
<MoveUpRight size="16" class="mr-1" />
|
||||
<WithUnits value={statistics.global.elevation.gain} type="elevation" />
|
||||
<MoveDownRight size="16" class="mx-1" />
|
||||
<WithUnits value={statistics.global.elevation.loss} type="elevation" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
{#if panelSize > 120 || orientation === 'horizontal'}
|
||||
<Tooltip
|
||||
class={orientation === 'horizontal' ? 'hidden xs:block' : ''}
|
||||
label="{$velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')} ({$_(
|
||||
'quantities.moving'
|
||||
)} / {$_('quantities.total')})"
|
||||
>
|
||||
<span class="flex flex-row items-center">
|
||||
<Zap size="16" class="mr-1" />
|
||||
<WithUnits value={statistics.global.speed.moving} type="speed" showUnits={false} />
|
||||
<span class="mx-1">/</span>
|
||||
<WithUnits value={statistics.global.speed.total} type="speed" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{#if panelSize > 160 || orientation === 'horizontal'}
|
||||
<Tooltip
|
||||
class={orientation === 'horizontal' ? 'hidden md:block' : ''}
|
||||
label="{$_('quantities.time')} ({$_('quantities.moving')} / {$_('quantities.total')})"
|
||||
>
|
||||
<span class="flex flex-row items-center">
|
||||
<Timer size="16" class="mr-1" />
|
||||
<WithUnits value={statistics.global.time.moving} type="time" />
|
||||
<span class="mx-1">/</span>
|
||||
<WithUnits value={statistics.global.time.total} type="time" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
<Card.Content
|
||||
class="h-full flex {orientation === 'vertical'
|
||||
? 'flex-col justify-center'
|
||||
: 'flex-row w-full justify-between'} gap-4 p-0"
|
||||
>
|
||||
<Tooltip label={$_('quantities.distance')}>
|
||||
<span class="flex flex-row items-center">
|
||||
<Ruler size="16" class="mr-1" />
|
||||
<WithUnits value={statistics.global.distance.total} type="distance" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label={$_('quantities.elevation_gain_loss')}>
|
||||
<span class="flex flex-row items-center">
|
||||
<MoveUpRight size="16" class="mr-1" />
|
||||
<WithUnits value={statistics.global.elevation.gain} type="elevation" />
|
||||
<MoveDownRight size="16" class="mx-1" />
|
||||
<WithUnits value={statistics.global.elevation.loss} type="elevation" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
{#if panelSize > 120 || orientation === 'horizontal'}
|
||||
<Tooltip
|
||||
class={orientation === 'horizontal' ? 'hidden xs:block' : ''}
|
||||
label="{$velocityUnits === 'speed'
|
||||
? $_('quantities.speed')
|
||||
: $_('quantities.pace')} ({$_('quantities.moving')} / {$_('quantities.total')})"
|
||||
>
|
||||
<span class="flex flex-row items-center">
|
||||
<Zap size="16" class="mr-1" />
|
||||
<WithUnits
|
||||
value={statistics.global.speed.moving}
|
||||
type="speed"
|
||||
showUnits={false}
|
||||
/>
|
||||
<span class="mx-1">/</span>
|
||||
<WithUnits value={statistics.global.speed.total} type="speed" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{#if panelSize > 160 || orientation === 'horizontal'}
|
||||
<Tooltip
|
||||
class={orientation === 'horizontal' ? 'hidden md:block' : ''}
|
||||
label="{$_('quantities.time')} ({$_('quantities.moving')} / {$_(
|
||||
'quantities.total'
|
||||
)})"
|
||||
>
|
||||
<span class="flex flex-row items-center">
|
||||
<Timer size="16" class="mr-1" />
|
||||
<WithUnits value={statistics.global.time.moving} type="time" />
|
||||
<span class="mx-1">/</span>
|
||||
<WithUnits value={statistics.global.time.total} type="time" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { CircleHelp } from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { CircleHelp } from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let link: string | undefined = undefined;
|
||||
export let link: string | undefined = undefined;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {$$props.class || ''}"
|
||||
class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {$$props.class || ''}"
|
||||
>
|
||||
<CircleHelp size="16" class="w-4 mr-2 shrink-0 grow-0" />
|
||||
<div>
|
||||
<slot />
|
||||
{#if link}
|
||||
<a href={link} target="_blank" class="text-sm text-link hover:underline">
|
||||
{$_('menu.more')}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<CircleHelp size="16" class="w-4 mr-2 shrink-0 grow-0" />
|
||||
<div>
|
||||
<slot />
|
||||
{#if link}
|
||||
<a href={link} target="_blank" class="text-sm text-link hover:underline">
|
||||
{$_('menu.more')}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { languages } from '$lib/languages';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { Languages } from 'lucide-svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { page } from '$app/stores';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { languages } from '$lib/languages';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { Languages } from 'lucide-svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
|
||||
let selected = {
|
||||
value: '',
|
||||
label: ''
|
||||
};
|
||||
let selected = {
|
||||
value: '',
|
||||
label: '',
|
||||
};
|
||||
|
||||
$: if ($locale) {
|
||||
selected = {
|
||||
value: $locale,
|
||||
label: languages[$locale]
|
||||
};
|
||||
}
|
||||
$: if ($locale) {
|
||||
selected = {
|
||||
value: $locale,
|
||||
label: languages[$locale],
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<Select.Root bind:selected>
|
||||
<Select.Trigger class="w-[180px] {$$props.class ?? ''}" aria-label={$_('menu.language')}>
|
||||
<Languages size="16" />
|
||||
<Select.Value class="ml-2 mr-auto" />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each Object.entries(languages) as [lang, label]}
|
||||
{#if $page.url.pathname.includes('404')}
|
||||
<a href={getURLForLanguage(lang, '/')}>
|
||||
<Select.Item value={lang}>{label}</Select.Item>
|
||||
</a>
|
||||
{:else}
|
||||
<a href={getURLForLanguage(lang, $page.url.pathname)}>
|
||||
<Select.Item value={lang}>{label}</Select.Item>
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</Select.Content>
|
||||
<Select.Trigger class="w-[180px] {$$props.class ?? ''}" aria-label={$_('menu.language')}>
|
||||
<Languages size="16" />
|
||||
<Select.Value class="ml-2 mr-auto" />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each Object.entries(languages) as [lang, label]}
|
||||
{#if $page.url.pathname.includes('404')}
|
||||
<a href={getURLForLanguage(lang, '/')}>
|
||||
<Select.Item value={lang}>{label}</Select.Item>
|
||||
</a>
|
||||
{:else}
|
||||
<a href={getURLForLanguage(lang, $page.url.pathname)}>
|
||||
<Select.Item value={lang}>{label}</Select.Item>
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
|
||||
<!-- hidden links for svelte crawling -->
|
||||
<div class="hidden">
|
||||
{#if !$page.url.pathname.includes('404')}
|
||||
{#each Object.entries(languages) as [lang, label]}
|
||||
<a href={getURLForLanguage(lang, $page.url.pathname)}>
|
||||
{label}
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if !$page.url.pathname.includes('404')}
|
||||
{#each Object.entries(languages) as [lang, label]}
|
||||
<a href={getURLForLanguage(lang, $page.url.pathname)}>
|
||||
{label}
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,73 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { mode, systemPrefersMode } from 'mode-watcher';
|
||||
import { base } from '$app/paths';
|
||||
import { mode, systemPrefersMode } from 'mode-watcher';
|
||||
|
||||
export let iconOnly = false;
|
||||
export let company = 'gpx.studio';
|
||||
export let iconOnly = false;
|
||||
export let company = 'gpx.studio';
|
||||
|
||||
$: effectiveMode = $mode ?? $systemPrefersMode ?? 'light';
|
||||
$: effectiveMode = $mode ?? $systemPrefersMode ?? 'light';
|
||||
</script>
|
||||
|
||||
{#if company === 'gpx.studio'}
|
||||
<img
|
||||
src="{base}/{iconOnly ? 'icon' : 'logo'}{effectiveMode === 'dark' ? '-dark' : ''}.svg"
|
||||
alt="Logo of gpx.studio."
|
||||
{...$$restProps}
|
||||
/>
|
||||
<img
|
||||
src="{base}/{iconOnly ? 'icon' : 'logo'}{effectiveMode === 'dark' ? '-dark' : ''}.svg"
|
||||
alt="Logo of gpx.studio."
|
||||
{...$$restProps}
|
||||
/>
|
||||
{:else if company === 'mapbox'}
|
||||
<img
|
||||
src="{base}/mapbox-logo-{effectiveMode === 'dark' ? 'white' : 'black'}.svg"
|
||||
alt="Logo of Mapbox."
|
||||
{...$$restProps}
|
||||
/>
|
||||
<img
|
||||
src="{base}/mapbox-logo-{effectiveMode === 'dark' ? 'white' : 'black'}.svg"
|
||||
alt="Logo of Mapbox."
|
||||
{...$$restProps}
|
||||
/>
|
||||
{:else if company === 'github'}
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="fill-foreground {$$restProps.class ?? ''}"
|
||||
><title>GitHub</title><path
|
||||
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
|
||||
/></svg
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="fill-foreground {$$restProps.class ?? ''}"
|
||||
><title>GitHub</title><path
|
||||
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
|
||||
/></svg
|
||||
>
|
||||
{:else if company === 'crowdin'}
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="fill-foreground {$$restProps.class ?? ''}"
|
||||
><title>Crowdin</title><path
|
||||
d="M16.119 17.793a2.619 2.619 0 0 1-1.667-.562c-.546-.436-1.004-1.09-1.018-1.858-.008-.388.414-.388.414-.388l1.018-.008c.332.008.43.47.445.586.128 1.04.717 1.495 1.168 1.702.273.123.204.513-.362.528zm-5.695-5.287L8.5 12.252c-.867-.214-.844-.982-.807-1.247a5.119 5.119 0 0 1 .814-2.125c.545-.804 1.303-1.508 2.29-2.073 1.856-1.074 4.45-1.673 7.31-1.673 2.09 0 4.256.27 4.29.27.197.025.328.213.333.437a.377.377 0 0 1-.355.393l-.92-.01c-2.902 0-4.968.394-6.506 1.248-1.527.837-2.57 2.117-3.287 4.012-.076.163-.335 1.12-1.24 1.022zm2.533 7.823c-1.44 0-2.797-.622-3.825-1.746-.87-.96-1.397-1.931-1.493-3.164-.06-.813.3-1.094.788-1.044l1.988.218c.45.092.75.34.825.854.397 2.736 2.122 3.814 3.15 4.046.18.042.292.157.283.365a.412.412 0 0 1-.322.398c-.458.074-.936.073-1.394.073zm-4.101 2.418a14.216 14.216 0 0 1-2.307-.214c-1.202-.214-2.208-.582-3.072-1.13C1.41 20.095.163 17.786.014 15.048c-.037-.65-.11-1.89 1.427-1.797.638.033 1.653.343 2.368.548.887.247 1.314.933 1.314 1.608 0 3.858 3.494 6.408 5.02 6.408.654 0 .414.701.127.779-.502.136-1.15.153-1.413.153zM3.525 11.419c-.605-.109-1.194-.358-1.768-.5C-.018 10.479.284 8.688.45 8.196c1.617-4.757 6.746-6.35 10.887-6.773 3.898-.4 7.978-.092 11.778.967.31.083 1.269.327.718.891-.35.358-1.7-.016-2.073-.041-2.23-.167-4.434-.192-6.656.15-2.349.357-4.768 1.099-6.71 2.665-.938.758-1.76 1.723-2.313 2.866-.144.3-.256.6-.354.9-.11.327-.47 1.91-2.215 1.6zm9.94.917c.332-1.488 1.81-3.848 6.385-3.686 1.05.033.57.749.052.731-2.586-.09-3.815 1.578-4.457 3.27-.219.546-.68.626-1.271.53-.415-.074-.866-.123-.71-.846Z"
|
||||
/></svg
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="fill-foreground {$$restProps.class ?? ''}"
|
||||
><title>Crowdin</title><path
|
||||
d="M16.119 17.793a2.619 2.619 0 0 1-1.667-.562c-.546-.436-1.004-1.09-1.018-1.858-.008-.388.414-.388.414-.388l1.018-.008c.332.008.43.47.445.586.128 1.04.717 1.495 1.168 1.702.273.123.204.513-.362.528zm-5.695-5.287L8.5 12.252c-.867-.214-.844-.982-.807-1.247a5.119 5.119 0 0 1 .814-2.125c.545-.804 1.303-1.508 2.29-2.073 1.856-1.074 4.45-1.673 7.31-1.673 2.09 0 4.256.27 4.29.27.197.025.328.213.333.437a.377.377 0 0 1-.355.393l-.92-.01c-2.902 0-4.968.394-6.506 1.248-1.527.837-2.57 2.117-3.287 4.012-.076.163-.335 1.12-1.24 1.022zm2.533 7.823c-1.44 0-2.797-.622-3.825-1.746-.87-.96-1.397-1.931-1.493-3.164-.06-.813.3-1.094.788-1.044l1.988.218c.45.092.75.34.825.854.397 2.736 2.122 3.814 3.15 4.046.18.042.292.157.283.365a.412.412 0 0 1-.322.398c-.458.074-.936.073-1.394.073zm-4.101 2.418a14.216 14.216 0 0 1-2.307-.214c-1.202-.214-2.208-.582-3.072-1.13C1.41 20.095.163 17.786.014 15.048c-.037-.65-.11-1.89 1.427-1.797.638.033 1.653.343 2.368.548.887.247 1.314.933 1.314 1.608 0 3.858 3.494 6.408 5.02 6.408.654 0 .414.701.127.779-.502.136-1.15.153-1.413.153zM3.525 11.419c-.605-.109-1.194-.358-1.768-.5C-.018 10.479.284 8.688.45 8.196c1.617-4.757 6.746-6.35 10.887-6.773 3.898-.4 7.978-.092 11.778.967.31.083 1.269.327.718.891-.35.358-1.7-.016-2.073-.041-2.23-.167-4.434-.192-6.656.15-2.349.357-4.768 1.099-6.71 2.665-.938.758-1.76 1.723-2.313 2.866-.144.3-.256.6-.354.9-.11.327-.47 1.91-2.215 1.6zm9.94.917c.332-1.488 1.81-3.848 6.385-3.686 1.05.033.57.749.052.731-2.586-.09-3.815 1.578-4.457 3.27-.219.546-.68.626-1.271.53-.415-.074-.866-.123-.71-.846Z"
|
||||
/></svg
|
||||
>
|
||||
{:else if company === 'facebook'}
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="fill-foreground {$$restProps.class ?? ''}"
|
||||
><title>Facebook</title><path
|
||||
d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"
|
||||
/></svg
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="fill-foreground {$$restProps.class ?? ''}"
|
||||
><title>Facebook</title><path
|
||||
d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"
|
||||
/></svg
|
||||
>
|
||||
{:else if company === 'x'}
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="fill-foreground {$$restProps.class ?? ''}"
|
||||
><title>X</title><path
|
||||
d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"
|
||||
/></svg
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="fill-foreground {$$restProps.class ?? ''}"
|
||||
><title>X</title><path
|
||||
d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"
|
||||
/></svg
|
||||
>
|
||||
{:else if company === 'reddit'}
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="fill-foreground {$$restProps.class ?? ''}"
|
||||
><title>Reddit</title><path
|
||||
d="M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z"
|
||||
/></svg
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="fill-foreground {$$restProps.class ?? ''}"
|
||||
><title>Reddit</title><path
|
||||
d="M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z"
|
||||
/></svg
|
||||
>
|
||||
{/if}
|
||||
|
||||
@@ -1,392 +1,393 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
|
||||
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
|
||||
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
|
||||
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
|
||||
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
|
||||
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { map } from '$lib/stores';
|
||||
import { settings } from '$lib/db';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||
import { page } from '$app/stores';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { map } from '$lib/stores';
|
||||
import { settings } from '$lib/db';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
export let accessToken = PUBLIC_MAPBOX_TOKEN;
|
||||
export let geolocate = true;
|
||||
export let geocoder = true;
|
||||
export let hash = true;
|
||||
export let accessToken = PUBLIC_MAPBOX_TOKEN;
|
||||
export let geolocate = true;
|
||||
export let geocoder = true;
|
||||
export let hash = true;
|
||||
|
||||
mapboxgl.accessToken = accessToken;
|
||||
mapboxgl.accessToken = accessToken;
|
||||
|
||||
let webgl2Supported = true;
|
||||
let embeddedApp = false;
|
||||
let fitBoundsOptions: mapboxgl.FitBoundsOptions = {
|
||||
maxZoom: 15,
|
||||
linear: true,
|
||||
easing: () => 1
|
||||
};
|
||||
let webgl2Supported = true;
|
||||
let embeddedApp = false;
|
||||
let fitBoundsOptions: mapboxgl.FitBoundsOptions = {
|
||||
maxZoom: 15,
|
||||
linear: true,
|
||||
easing: () => 1,
|
||||
};
|
||||
|
||||
const { distanceUnits, elevationProfile, treeFileView, bottomPanelSize, rightPanelSize } =
|
||||
settings;
|
||||
let scaleControl = new mapboxgl.ScaleControl({
|
||||
unit: $distanceUnits
|
||||
});
|
||||
const { distanceUnits, elevationProfile, treeFileView, bottomPanelSize, rightPanelSize } =
|
||||
settings;
|
||||
let scaleControl = new mapboxgl.ScaleControl({
|
||||
unit: $distanceUnits,
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
let gl = document.createElement('canvas').getContext('webgl2');
|
||||
if (!gl) {
|
||||
webgl2Supported = false;
|
||||
return;
|
||||
}
|
||||
if (window.top !== window.self && !$page.route.id?.includes('embed')) {
|
||||
embeddedApp = true;
|
||||
return;
|
||||
}
|
||||
onMount(() => {
|
||||
let gl = document.createElement('canvas').getContext('webgl2');
|
||||
if (!gl) {
|
||||
webgl2Supported = false;
|
||||
return;
|
||||
}
|
||||
if (window.top !== window.self && !$page.route.id?.includes('embed')) {
|
||||
embeddedApp = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let language = $page.params.language;
|
||||
if (language === 'zh') {
|
||||
language = 'zh-Hans';
|
||||
} else if (language?.includes('-')) {
|
||||
language = language.split('-')[0];
|
||||
} else if (language === '' || language === undefined) {
|
||||
language = 'en';
|
||||
}
|
||||
let language = $page.params.language;
|
||||
if (language === 'zh') {
|
||||
language = 'zh-Hans';
|
||||
} else if (language?.includes('-')) {
|
||||
language = language.split('-')[0];
|
||||
} else if (language === '' || language === undefined) {
|
||||
language = 'en';
|
||||
}
|
||||
|
||||
let newMap = new mapboxgl.Map({
|
||||
container: 'map',
|
||||
style: {
|
||||
version: 8,
|
||||
sources: {},
|
||||
layers: [],
|
||||
imports: [
|
||||
{
|
||||
id: 'glyphs-and-sprite', // make Mapbox glyphs and sprite available to other styles
|
||||
url: '',
|
||||
data: {
|
||||
version: 8,
|
||||
sources: {},
|
||||
layers: [],
|
||||
glyphs: 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf',
|
||||
sprite: `https://api.mapbox.com/styles/v1/mapbox/outdoors-v12/sprite?access_token=${PUBLIC_MAPBOX_TOKEN}`
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'basemap',
|
||||
url: ''
|
||||
},
|
||||
{
|
||||
id: 'overlays',
|
||||
url: '',
|
||||
data: {
|
||||
version: 8,
|
||||
sources: {},
|
||||
layers: []
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
projection: 'globe',
|
||||
zoom: 0,
|
||||
hash: hash,
|
||||
language,
|
||||
attributionControl: false,
|
||||
logoPosition: 'bottom-right',
|
||||
boxZoom: false
|
||||
});
|
||||
newMap.on('load', () => {
|
||||
$map = newMap; // only set the store after the map has loaded
|
||||
window._map = newMap; // entry point for extensions
|
||||
scaleControl.setUnit($distanceUnits);
|
||||
});
|
||||
let newMap = new mapboxgl.Map({
|
||||
container: 'map',
|
||||
style: {
|
||||
version: 8,
|
||||
sources: {},
|
||||
layers: [],
|
||||
imports: [
|
||||
{
|
||||
id: 'glyphs-and-sprite', // make Mapbox glyphs and sprite available to other styles
|
||||
url: '',
|
||||
data: {
|
||||
version: 8,
|
||||
sources: {},
|
||||
layers: [],
|
||||
glyphs: 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf',
|
||||
sprite: `https://api.mapbox.com/styles/v1/mapbox/outdoors-v12/sprite?access_token=${PUBLIC_MAPBOX_TOKEN}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'basemap',
|
||||
url: '',
|
||||
},
|
||||
{
|
||||
id: 'overlays',
|
||||
url: '',
|
||||
data: {
|
||||
version: 8,
|
||||
sources: {},
|
||||
layers: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
projection: 'globe',
|
||||
zoom: 0,
|
||||
hash: hash,
|
||||
language,
|
||||
attributionControl: false,
|
||||
logoPosition: 'bottom-right',
|
||||
boxZoom: false,
|
||||
});
|
||||
newMap.on('load', () => {
|
||||
$map = newMap; // only set the store after the map has loaded
|
||||
window._map = newMap; // entry point for extensions
|
||||
scaleControl.setUnit($distanceUnits);
|
||||
});
|
||||
|
||||
newMap.addControl(
|
||||
new mapboxgl.AttributionControl({
|
||||
compact: true
|
||||
})
|
||||
);
|
||||
newMap.addControl(
|
||||
new mapboxgl.AttributionControl({
|
||||
compact: true,
|
||||
})
|
||||
);
|
||||
|
||||
newMap.addControl(
|
||||
new mapboxgl.NavigationControl({
|
||||
visualizePitch: true
|
||||
})
|
||||
);
|
||||
newMap.addControl(
|
||||
new mapboxgl.NavigationControl({
|
||||
visualizePitch: true,
|
||||
})
|
||||
);
|
||||
|
||||
if (geocoder) {
|
||||
let geocoder = new MapboxGeocoder({
|
||||
mapboxgl: mapboxgl,
|
||||
enableEventLogging: false,
|
||||
collapsed: true,
|
||||
flyTo: fitBoundsOptions,
|
||||
language,
|
||||
localGeocoder: () => [],
|
||||
localGeocoderOnly: true,
|
||||
externalGeocoder: (query: string) =>
|
||||
fetch(
|
||||
`https://nominatim.openstreetmap.org/search?format=json&q=${query}&limit=5&accept-language=${language}`
|
||||
)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
return data.map((result: any) => {
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [result.lon, result.lat]
|
||||
},
|
||||
place_name: result.display_name
|
||||
};
|
||||
});
|
||||
})
|
||||
});
|
||||
let onKeyDown = geocoder._onKeyDown;
|
||||
geocoder._onKeyDown = (e: KeyboardEvent) => {
|
||||
// Trigger search on Enter key only
|
||||
if (e.key === 'Enter') {
|
||||
onKeyDown.apply(geocoder, [{ target: geocoder._inputEl }]);
|
||||
} else if (geocoder._typeahead.data.length > 0) {
|
||||
geocoder._typeahead.clear();
|
||||
}
|
||||
};
|
||||
newMap.addControl(geocoder);
|
||||
}
|
||||
if (geocoder) {
|
||||
let geocoder = new MapboxGeocoder({
|
||||
mapboxgl: mapboxgl,
|
||||
enableEventLogging: false,
|
||||
collapsed: true,
|
||||
flyTo: fitBoundsOptions,
|
||||
language,
|
||||
localGeocoder: () => [],
|
||||
localGeocoderOnly: true,
|
||||
externalGeocoder: (query: string) =>
|
||||
fetch(
|
||||
`https://nominatim.openstreetmap.org/search?format=json&q=${query}&limit=5&accept-language=${language}`
|
||||
)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
return data.map((result: any) => {
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [result.lon, result.lat],
|
||||
},
|
||||
place_name: result.display_name,
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
let onKeyDown = geocoder._onKeyDown;
|
||||
geocoder._onKeyDown = (e: KeyboardEvent) => {
|
||||
// Trigger search on Enter key only
|
||||
if (e.key === 'Enter') {
|
||||
onKeyDown.apply(geocoder, [{ target: geocoder._inputEl }]);
|
||||
} else if (geocoder._typeahead.data.length > 0) {
|
||||
geocoder._typeahead.clear();
|
||||
}
|
||||
};
|
||||
newMap.addControl(geocoder);
|
||||
}
|
||||
|
||||
if (geolocate) {
|
||||
newMap.addControl(
|
||||
new mapboxgl.GeolocateControl({
|
||||
positionOptions: {
|
||||
enableHighAccuracy: true
|
||||
},
|
||||
fitBoundsOptions,
|
||||
trackUserLocation: true,
|
||||
showUserHeading: true
|
||||
})
|
||||
);
|
||||
}
|
||||
if (geolocate) {
|
||||
newMap.addControl(
|
||||
new mapboxgl.GeolocateControl({
|
||||
positionOptions: {
|
||||
enableHighAccuracy: true,
|
||||
},
|
||||
fitBoundsOptions,
|
||||
trackUserLocation: true,
|
||||
showUserHeading: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
newMap.addControl(scaleControl);
|
||||
newMap.addControl(scaleControl);
|
||||
|
||||
newMap.on('style.load', () => {
|
||||
newMap.addSource('mapbox-dem', {
|
||||
type: 'raster-dem',
|
||||
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
|
||||
tileSize: 512,
|
||||
maxzoom: 14
|
||||
});
|
||||
if (newMap.getPitch() > 0) {
|
||||
newMap.setTerrain({
|
||||
source: 'mapbox-dem',
|
||||
exaggeration: 1
|
||||
});
|
||||
}
|
||||
newMap.setFog({
|
||||
color: 'rgb(186, 210, 235)',
|
||||
'high-color': 'rgb(36, 92, 223)',
|
||||
'horizon-blend': 0.1,
|
||||
'space-color': 'rgb(156, 240, 255)'
|
||||
});
|
||||
newMap.on('pitch', () => {
|
||||
if (newMap.getPitch() > 0) {
|
||||
newMap.setTerrain({
|
||||
source: 'mapbox-dem',
|
||||
exaggeration: 1
|
||||
});
|
||||
} else {
|
||||
newMap.setTerrain(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
newMap.on('style.load', () => {
|
||||
newMap.addSource('mapbox-dem', {
|
||||
type: 'raster-dem',
|
||||
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
|
||||
tileSize: 512,
|
||||
maxzoom: 14,
|
||||
});
|
||||
if (newMap.getPitch() > 0) {
|
||||
newMap.setTerrain({
|
||||
source: 'mapbox-dem',
|
||||
exaggeration: 1,
|
||||
});
|
||||
}
|
||||
newMap.setFog({
|
||||
color: 'rgb(186, 210, 235)',
|
||||
'high-color': 'rgb(36, 92, 223)',
|
||||
'horizon-blend': 0.1,
|
||||
'space-color': 'rgb(156, 240, 255)',
|
||||
});
|
||||
newMap.on('pitch', () => {
|
||||
if (newMap.getPitch() > 0) {
|
||||
newMap.setTerrain({
|
||||
source: 'mapbox-dem',
|
||||
exaggeration: 1,
|
||||
});
|
||||
} else {
|
||||
newMap.setTerrain(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if ($map) {
|
||||
$map.remove();
|
||||
$map = null;
|
||||
}
|
||||
});
|
||||
onDestroy(() => {
|
||||
if ($map) {
|
||||
$map.remove();
|
||||
$map = null;
|
||||
}
|
||||
});
|
||||
|
||||
$: if ($map && (!$treeFileView || !$elevationProfile || $bottomPanelSize || $rightPanelSize)) {
|
||||
$map.resize();
|
||||
}
|
||||
$: if ($map && (!$treeFileView || !$elevationProfile || $bottomPanelSize || $rightPanelSize)) {
|
||||
$map.resize();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div {...$$restProps}>
|
||||
<div id="map" class="h-full {webgl2Supported && !embeddedApp ? '' : 'hidden'}"></div>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center gap-3 h-full {webgl2Supported && !embeddedApp
|
||||
? 'hidden'
|
||||
: ''} {embeddedApp ? 'z-30' : ''}"
|
||||
>
|
||||
{#if !webgl2Supported}
|
||||
<p>{$_('webgl2_required')}</p>
|
||||
<Button href="https://get.webgl.org/webgl2/" target="_blank">
|
||||
{$_('enable_webgl2')}
|
||||
</Button>
|
||||
{:else if embeddedApp}
|
||||
<p>The app cannot be embedded in an iframe.</p>
|
||||
<Button href="https://gpx.studio/help/integration" target="_blank">
|
||||
Learn how to create a map for your website
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<div id="map" class="h-full {webgl2Supported && !embeddedApp ? '' : 'hidden'}"></div>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center gap-3 h-full {webgl2Supported &&
|
||||
!embeddedApp
|
||||
? 'hidden'
|
||||
: ''} {embeddedApp ? 'z-30' : ''}"
|
||||
>
|
||||
{#if !webgl2Supported}
|
||||
<p>{$_('webgl2_required')}</p>
|
||||
<Button href="https://get.webgl.org/webgl2/" target="_blank">
|
||||
{$_('enable_webgl2')}
|
||||
</Button>
|
||||
{:else if embeddedApp}
|
||||
<p>The app cannot be embedded in an iframe.</p>
|
||||
<Button href="https://gpx.studio/help/integration" target="_blank">
|
||||
Learn how to create a map for your website
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
div :global(.mapboxgl-map) {
|
||||
@apply font-sans;
|
||||
}
|
||||
div :global(.mapboxgl-map) {
|
||||
@apply font-sans;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-top-right > .mapboxgl-ctrl) {
|
||||
@apply shadow-md;
|
||||
@apply bg-background;
|
||||
@apply text-foreground;
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-top-right > .mapboxgl-ctrl) {
|
||||
@apply shadow-md;
|
||||
@apply bg-background;
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-icon) {
|
||||
@apply dark:brightness-[4.7];
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-icon) {
|
||||
@apply dark:brightness-[4.7];
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder) {
|
||||
@apply flex;
|
||||
@apply flex-row;
|
||||
@apply w-fit;
|
||||
@apply min-w-fit;
|
||||
@apply items-center;
|
||||
@apply shadow-md;
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-geocoder) {
|
||||
@apply flex;
|
||||
@apply flex-row;
|
||||
@apply w-fit;
|
||||
@apply min-w-fit;
|
||||
@apply items-center;
|
||||
@apply shadow-md;
|
||||
}
|
||||
|
||||
div :global(.suggestions) {
|
||||
@apply shadow-md;
|
||||
@apply bg-background;
|
||||
@apply text-foreground;
|
||||
}
|
||||
div :global(.suggestions) {
|
||||
@apply shadow-md;
|
||||
@apply bg-background;
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder .suggestions > li > a) {
|
||||
@apply text-foreground;
|
||||
@apply hover:text-accent-foreground;
|
||||
@apply hover:bg-accent;
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-geocoder .suggestions > li > a) {
|
||||
@apply text-foreground;
|
||||
@apply hover:text-accent-foreground;
|
||||
@apply hover:bg-accent;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder .suggestions > .active > a) {
|
||||
@apply bg-background;
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-geocoder .suggestions > .active > a) {
|
||||
@apply bg-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder--button) {
|
||||
@apply bg-transparent;
|
||||
@apply hover:bg-transparent;
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-geocoder--button) {
|
||||
@apply bg-transparent;
|
||||
@apply hover:bg-transparent;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder--icon) {
|
||||
@apply fill-foreground;
|
||||
@apply hover:fill-accent-foreground;
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-geocoder--icon) {
|
||||
@apply fill-foreground;
|
||||
@apply hover:fill-accent-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder--icon-search) {
|
||||
@apply relative;
|
||||
@apply top-0;
|
||||
@apply left-0;
|
||||
@apply my-2;
|
||||
@apply w-[29px];
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-geocoder--icon-search) {
|
||||
@apply relative;
|
||||
@apply top-0;
|
||||
@apply left-0;
|
||||
@apply my-2;
|
||||
@apply w-[29px];
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder--input) {
|
||||
@apply relative;
|
||||
@apply w-64;
|
||||
@apply py-0;
|
||||
@apply pl-2;
|
||||
@apply focus:outline-none;
|
||||
@apply transition-[width];
|
||||
@apply duration-200;
|
||||
@apply text-foreground;
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-geocoder--input) {
|
||||
@apply relative;
|
||||
@apply w-64;
|
||||
@apply py-0;
|
||||
@apply pl-2;
|
||||
@apply focus:outline-none;
|
||||
@apply transition-[width];
|
||||
@apply duration-200;
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder--collapsed .mapboxgl-ctrl-geocoder--input) {
|
||||
@apply w-0;
|
||||
@apply p-0;
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-geocoder--collapsed .mapboxgl-ctrl-geocoder--input) {
|
||||
@apply w-0;
|
||||
@apply p-0;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-top-right) {
|
||||
@apply z-40;
|
||||
@apply flex;
|
||||
@apply flex-col;
|
||||
@apply items-end;
|
||||
@apply h-full;
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-top-right) {
|
||||
@apply z-40;
|
||||
@apply flex;
|
||||
@apply flex-col;
|
||||
@apply items-end;
|
||||
@apply h-full;
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
.horizontal :global(.mapboxgl-ctrl-bottom-left) {
|
||||
@apply bottom-[42px];
|
||||
}
|
||||
.horizontal :global(.mapboxgl-ctrl-bottom-left) {
|
||||
@apply bottom-[42px];
|
||||
}
|
||||
|
||||
.horizontal :global(.mapboxgl-ctrl-bottom-right) {
|
||||
@apply bottom-[42px];
|
||||
}
|
||||
.horizontal :global(.mapboxgl-ctrl-bottom-right) {
|
||||
@apply bottom-[42px];
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-attrib) {
|
||||
@apply dark:bg-transparent;
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-attrib) {
|
||||
@apply dark:bg-transparent;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-compact-show.mapboxgl-ctrl-attrib) {
|
||||
@apply dark:bg-background;
|
||||
}
|
||||
div :global(.mapboxgl-compact-show.mapboxgl-ctrl-attrib) {
|
||||
@apply dark:bg-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-attrib-button) {
|
||||
@apply dark:bg-foreground;
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-attrib-button) {
|
||||
@apply dark:bg-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button) {
|
||||
@apply dark:bg-foreground;
|
||||
}
|
||||
div :global(.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button) {
|
||||
@apply dark:bg-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-attrib a) {
|
||||
@apply text-foreground;
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-attrib a) {
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup) {
|
||||
@apply w-fit;
|
||||
@apply z-50;
|
||||
}
|
||||
div :global(.mapboxgl-popup) {
|
||||
@apply w-fit;
|
||||
@apply z-50;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-content) {
|
||||
@apply p-0;
|
||||
@apply bg-transparent;
|
||||
@apply shadow-none;
|
||||
}
|
||||
div :global(.mapboxgl-popup-content) {
|
||||
@apply p-0;
|
||||
@apply bg-transparent;
|
||||
@apply shadow-none;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-top .mapboxgl-popup-tip) {
|
||||
@apply border-b-background;
|
||||
}
|
||||
div :global(.mapboxgl-popup-anchor-top .mapboxgl-popup-tip) {
|
||||
@apply border-b-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip) {
|
||||
@apply border-b-background;
|
||||
}
|
||||
div :global(.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip) {
|
||||
@apply border-b-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip) {
|
||||
@apply border-b-background;
|
||||
}
|
||||
div :global(.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip) {
|
||||
@apply border-b-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip) {
|
||||
@apply border-t-background;
|
||||
@apply drop-shadow-md;
|
||||
}
|
||||
div :global(.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip) {
|
||||
@apply border-t-background;
|
||||
@apply drop-shadow-md;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip) {
|
||||
@apply border-t-background;
|
||||
@apply drop-shadow-md;
|
||||
}
|
||||
div :global(.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip) {
|
||||
@apply border-t-background;
|
||||
@apply drop-shadow-md;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip) {
|
||||
@apply border-t-background;
|
||||
@apply drop-shadow-md;
|
||||
}
|
||||
div :global(.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip) {
|
||||
@apply border-t-background;
|
||||
@apply drop-shadow-md;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-left .mapboxgl-popup-tip) {
|
||||
@apply border-r-background;
|
||||
}
|
||||
div :global(.mapboxgl-popup-anchor-left .mapboxgl-popup-tip) {
|
||||
@apply border-r-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-right .mapboxgl-popup-tip) {
|
||||
@apply border-l-background;
|
||||
}
|
||||
div :global(.mapboxgl-popup-anchor-right .mapboxgl-popup-tip) {
|
||||
@apply border-l-background;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
<svelte:options accessors />
|
||||
|
||||
<script lang="ts">
|
||||
import { TrackPoint, Waypoint } from 'gpx';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import WaypointPopup from '$lib/components/gpx-layer/WaypointPopup.svelte';
|
||||
import TrackpointPopup from '$lib/components/gpx-layer/TrackpointPopup.svelte';
|
||||
import OverpassPopup from '$lib/components/layer-control/OverpassPopup.svelte';
|
||||
import type { PopupItem } from './MapPopup';
|
||||
import { TrackPoint, Waypoint } from 'gpx';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import WaypointPopup from '$lib/components/gpx-layer/WaypointPopup.svelte';
|
||||
import TrackpointPopup from '$lib/components/gpx-layer/TrackpointPopup.svelte';
|
||||
import OverpassPopup from '$lib/components/layer-control/OverpassPopup.svelte';
|
||||
import type { PopupItem } from './MapPopup';
|
||||
|
||||
export let item: Writable<PopupItem | null>;
|
||||
export let container: HTMLDivElement | null = null;
|
||||
export let item: Writable<PopupItem | null>;
|
||||
export let container: HTMLDivElement | null = null;
|
||||
</script>
|
||||
|
||||
<div bind:this={container}>
|
||||
{#if $item}
|
||||
{#if $item.item instanceof Waypoint}
|
||||
<WaypointPopup waypoint={$item} />
|
||||
{:else if $item.item instanceof TrackPoint}
|
||||
<TrackpointPopup trackpoint={$item} />
|
||||
{:else}
|
||||
<OverpassPopup poi={$item} />
|
||||
{/if}
|
||||
{/if}
|
||||
{#if $item}
|
||||
{#if $item.item instanceof Waypoint}
|
||||
<WaypointPopup waypoint={$item} />
|
||||
{:else if $item.item instanceof TrackPoint}
|
||||
<TrackpointPopup trackpoint={$item} />
|
||||
{:else}
|
||||
<OverpassPopup poi={$item} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { TrackPoint, Waypoint } from "gpx";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
import { tick } from "svelte";
|
||||
import { get, writable, type Writable } from "svelte/store";
|
||||
import MapPopupComponent from "./MapPopup.svelte";
|
||||
import { TrackPoint, Waypoint } from 'gpx';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { tick } from 'svelte';
|
||||
import { get, writable, type Writable } from 'svelte/store';
|
||||
import MapPopupComponent from './MapPopup.svelte';
|
||||
|
||||
export type PopupItem<T = Waypoint | TrackPoint | any> = {
|
||||
item: T;
|
||||
@@ -23,16 +23,15 @@ export class MapPopup {
|
||||
let component = new MapPopupComponent({
|
||||
target: document.body,
|
||||
props: {
|
||||
item: this.item
|
||||
}
|
||||
item: this.item,
|
||||
},
|
||||
});
|
||||
|
||||
tick().then(() => this.popup.setDOMContent(component.container));
|
||||
}
|
||||
|
||||
setItem(item: PopupItem | null) {
|
||||
if (item)
|
||||
item.hide = () => this.hide();
|
||||
if (item) item.hide = () => this.hide();
|
||||
this.item.set(item);
|
||||
if (item === null) {
|
||||
this.hide();
|
||||
@@ -76,6 +75,8 @@ export class MapPopup {
|
||||
if (i === null) {
|
||||
return new mapboxgl.LngLat(0, 0);
|
||||
}
|
||||
return (i.item instanceof Waypoint || i.item instanceof TrackPoint) ? i.item.getCoordinates() : new mapboxgl.LngLat(i.item.lon, i.item.lat);
|
||||
return i.item instanceof Waypoint || i.item instanceof TrackPoint
|
||||
? i.item.getCoordinates()
|
||||
: new mapboxgl.LngLat(i.item.lon, i.item.lat);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Moon, Sun } from 'lucide-svelte';
|
||||
import { mode, setMode, systemPrefersMode } from 'mode-watcher';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Moon, Sun } from 'lucide-svelte';
|
||||
import { mode, setMode, systemPrefersMode } from 'mode-watcher';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let size = '20';
|
||||
export let size = '20';
|
||||
|
||||
$: selectedMode = $mode ?? $systemPrefersMode ?? 'light';
|
||||
$: selectedMode = $mode ?? $systemPrefersMode ?? 'light';
|
||||
</script>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="h-8 px-1.5 {$$props.class ?? ''}"
|
||||
on:click={() => {
|
||||
setMode(selectedMode === 'light' ? 'dark' : 'light');
|
||||
}}
|
||||
aria-label={$_('menu.mode')}
|
||||
variant="ghost"
|
||||
class="h-8 px-1.5 {$$props.class ?? ''}"
|
||||
on:click={() => {
|
||||
setMode(selectedMode === 'light' ? 'dark' : 'light');
|
||||
}}
|
||||
aria-label={$_('menu.mode')}
|
||||
>
|
||||
{#if selectedMode === 'light'}
|
||||
<Sun {size} />
|
||||
{:else}
|
||||
<Moon {size} />
|
||||
{/if}
|
||||
{#if selectedMode === 'light'}
|
||||
<Sun {size} />
|
||||
{:else}
|
||||
<Moon {size} />
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
<script lang="ts">
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import AlgoliaDocSearch from '$lib/components/AlgoliaDocSearch.svelte';
|
||||
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
|
||||
import { BookOpenText, Home, Map } from 'lucide-svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import AlgoliaDocSearch from '$lib/components/AlgoliaDocSearch.svelte';
|
||||
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
|
||||
import { BookOpenText, Home, Map } from 'lucide-svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
</script>
|
||||
|
||||
<nav class="w-full sticky top-0 bg-background z-50">
|
||||
<div class="mx-6 py-2 flex flex-row items-center border-b gap-4 sm:gap-8">
|
||||
<a href={getURLForLanguage($locale, '/')} class="shrink-0 translate-y-0.5">
|
||||
<Logo class="h-8 sm:hidden" iconOnly={true} width="26" />
|
||||
<Logo class="h-8 hidden sm:block" width="153" />
|
||||
</a>
|
||||
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/')}>
|
||||
<Home size="18" class="mr-1.5" />
|
||||
{$_('homepage.home')}
|
||||
</Button>
|
||||
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/app')}>
|
||||
<Map size="18" class="mr-1.5" />
|
||||
{$_('homepage.app')}
|
||||
</Button>
|
||||
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/help')}>
|
||||
<BookOpenText size="18" class="mr-1.5" />
|
||||
{$_('menu.help')}
|
||||
</Button>
|
||||
<AlgoliaDocSearch class="ml-auto" />
|
||||
<ModeSwitch class="hidden xs:block" />
|
||||
</div>
|
||||
<div class="mx-6 py-2 flex flex-row items-center border-b gap-4 sm:gap-8">
|
||||
<a href={getURLForLanguage($locale, '/')} class="shrink-0 translate-y-0.5">
|
||||
<Logo class="h-8 sm:hidden" iconOnly={true} width="26" />
|
||||
<Logo class="h-8 hidden sm:block" width="153" />
|
||||
</a>
|
||||
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/')}>
|
||||
<Home size="18" class="mr-1.5" />
|
||||
{$_('homepage.home')}
|
||||
</Button>
|
||||
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/app')}>
|
||||
<Map size="18" class="mr-1.5" />
|
||||
{$_('homepage.app')}
|
||||
</Button>
|
||||
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/help')}>
|
||||
<BookOpenText size="18" class="mr-1.5" />
|
||||
{$_('menu.help')}
|
||||
</Button>
|
||||
<AlgoliaDocSearch class="ml-auto" />
|
||||
<ModeSwitch class="hidden xs:block" />
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -1,41 +1,42 @@
|
||||
<script lang="ts">
|
||||
export let orientation: 'col' | 'row' = 'col';
|
||||
export let orientation: 'col' | 'row' = 'col';
|
||||
|
||||
export let after: number;
|
||||
export let minAfter: number = 0;
|
||||
export let maxAfter: number = Number.MAX_SAFE_INTEGER;
|
||||
export let after: number;
|
||||
export let minAfter: number = 0;
|
||||
export let maxAfter: number = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
function handleMouseDown(event: PointerEvent) {
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const startAfter = after;
|
||||
function handleMouseDown(event: PointerEvent) {
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const startAfter = after;
|
||||
|
||||
const handleMouseMove = (event: PointerEvent) => {
|
||||
const newAfter =
|
||||
startAfter + (orientation === 'col' ? startX - event.clientX : startY - event.clientY);
|
||||
if (newAfter >= minAfter && newAfter <= maxAfter) {
|
||||
after = newAfter;
|
||||
} else if (newAfter < minAfter && after !== minAfter) {
|
||||
after = minAfter;
|
||||
} else if (newAfter > maxAfter && after !== maxAfter) {
|
||||
after = maxAfter;
|
||||
}
|
||||
};
|
||||
const handleMouseMove = (event: PointerEvent) => {
|
||||
const newAfter =
|
||||
startAfter +
|
||||
(orientation === 'col' ? startX - event.clientX : startY - event.clientY);
|
||||
if (newAfter >= minAfter && newAfter <= maxAfter) {
|
||||
after = newAfter;
|
||||
} else if (newAfter < minAfter && after !== minAfter) {
|
||||
after = minAfter;
|
||||
} else if (newAfter > maxAfter && after !== maxAfter) {
|
||||
after = maxAfter;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
window.removeEventListener('pointermove', handleMouseMove);
|
||||
window.removeEventListener('pointerup', handleMouseUp);
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
window.removeEventListener('pointermove', handleMouseMove);
|
||||
window.removeEventListener('pointerup', handleMouseUp);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handleMouseMove);
|
||||
window.addEventListener('pointerup', handleMouseUp);
|
||||
}
|
||||
window.addEventListener('pointermove', handleMouseMove);
|
||||
window.addEventListener('pointerup', handleMouseUp);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="{orientation === 'col'
|
||||
? 'w-1 h-full cursor-col-resize border-l'
|
||||
: 'w-full h-1 cursor-row-resize border-t'} {orientation}"
|
||||
on:pointerdown={handleMouseDown}
|
||||
class="{orientation === 'col'
|
||||
? 'w-1 h-full cursor-col-resize border-l'
|
||||
: 'w-full h-1 cursor-row-resize border-t'} {orientation}"
|
||||
on:pointerdown={handleMouseDown}
|
||||
/>
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { isMac, isSafari } from '$lib/utils';
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { isMac, isSafari } from '$lib/utils';
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let key: string | undefined = undefined;
|
||||
export let shift: boolean = false;
|
||||
export let ctrl: boolean = false;
|
||||
export let click: boolean = false;
|
||||
export let key: string | undefined = undefined;
|
||||
export let shift: boolean = false;
|
||||
export let ctrl: boolean = false;
|
||||
export let click: boolean = false;
|
||||
|
||||
let mac = false;
|
||||
let safari = false;
|
||||
let mac = false;
|
||||
let safari = false;
|
||||
|
||||
onMount(() => {
|
||||
mac = isMac();
|
||||
safari = isSafari();
|
||||
});
|
||||
onMount(() => {
|
||||
mac = isMac();
|
||||
safari = isSafari();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="ml-auto pl-2 text-xs tracking-widest text-muted-foreground flex flex-row gap-0 items-baseline"
|
||||
{...$$props}
|
||||
class="ml-auto pl-2 text-xs tracking-widest text-muted-foreground flex flex-row gap-0 items-baseline"
|
||||
{...$$props}
|
||||
>
|
||||
{#if shift}
|
||||
<span>⇧</span>
|
||||
{/if}
|
||||
{#if ctrl}
|
||||
<span>{mac && !safari ? '⌘' : $_('menu.ctrl') + '+'}</span>
|
||||
{/if}
|
||||
{#if key}
|
||||
<span class={key === '+' ? 'font-medium text-sm/4' : ''}>{key}</span>
|
||||
{/if}
|
||||
{#if click}
|
||||
<span>{$_('menu.click')}</span>
|
||||
{/if}
|
||||
{#if shift}
|
||||
<span>⇧</span>
|
||||
{/if}
|
||||
{#if ctrl}
|
||||
<span>{mac && !safari ? '⌘' : $_('menu.ctrl') + '+'}</span>
|
||||
{/if}
|
||||
{#if key}
|
||||
<span class={key === '+' ? 'font-medium text-sm/4' : ''}>{key}</span>
|
||||
{/if}
|
||||
{#if click}
|
||||
<span>{$_('menu.click')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<script lang="ts">
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
|
||||
export let label: string;
|
||||
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
|
||||
export let label: string;
|
||||
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
|
||||
</script>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger {...$$restProps} aria-label={label}>
|
||||
<slot />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content {side}>
|
||||
<div class="flex flex-row items-center">
|
||||
<span>{label}</span>
|
||||
<slot name="extra" />
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
<Tooltip.Trigger {...$$restProps} aria-label={label}>
|
||||
<slot />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content {side}>
|
||||
<div class="flex flex-row items-center">
|
||||
<span>{label}</span>
|
||||
<slot name="extra" />
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { settings } from '$lib/db';
|
||||
import {
|
||||
celsiusToFahrenheit,
|
||||
getConvertedDistance,
|
||||
getConvertedElevation,
|
||||
getConvertedVelocity,
|
||||
getDistanceUnits,
|
||||
getElevationUnits,
|
||||
getVelocityUnits,
|
||||
secondsToHHMMSS
|
||||
} from '$lib/units';
|
||||
import { settings } from '$lib/db';
|
||||
import {
|
||||
celsiusToFahrenheit,
|
||||
getConvertedDistance,
|
||||
getConvertedElevation,
|
||||
getConvertedVelocity,
|
||||
getDistanceUnits,
|
||||
getElevationUnits,
|
||||
getVelocityUnits,
|
||||
secondsToHHMMSS,
|
||||
} from '$lib/units';
|
||||
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let value: number;
|
||||
export let type: 'distance' | 'elevation' | 'speed' | 'temperature' | 'time';
|
||||
export let showUnits: boolean = true;
|
||||
export let decimals: number | undefined = undefined;
|
||||
export let value: number;
|
||||
export let type: 'distance' | 'elevation' | 'speed' | 'temperature' | 'time';
|
||||
export let showUnits: boolean = true;
|
||||
export let decimals: number | undefined = undefined;
|
||||
|
||||
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
||||
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
||||
</script>
|
||||
|
||||
<span class={$$props.class}>
|
||||
{#if type === 'distance'}
|
||||
{getConvertedDistance(value, $distanceUnits).toFixed(decimals ?? 2)}
|
||||
{showUnits ? getDistanceUnits($distanceUnits) : ''}
|
||||
{:else if type === 'elevation'}
|
||||
{getConvertedElevation(value, $distanceUnits).toFixed(decimals ?? 0)}
|
||||
{showUnits ? getElevationUnits($distanceUnits) : ''}
|
||||
{:else if type === 'speed'}
|
||||
{#if $velocityUnits === 'speed'}
|
||||
{getConvertedVelocity(value, $velocityUnits, $distanceUnits).toFixed(decimals ?? 2)}
|
||||
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
|
||||
{:else}
|
||||
{secondsToHHMMSS(getConvertedVelocity(value, $velocityUnits, $distanceUnits))}
|
||||
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
|
||||
{/if}
|
||||
{:else if type === 'temperature'}
|
||||
{#if $temperatureUnits === 'celsius'}
|
||||
{value} {showUnits ? $_('units.celsius') : ''}
|
||||
{:else}
|
||||
{celsiusToFahrenheit(value)} {showUnits ? $_('units.fahrenheit') : ''}
|
||||
{/if}
|
||||
{:else if type === 'time'}
|
||||
{secondsToHHMMSS(value)}
|
||||
{/if}
|
||||
{#if type === 'distance'}
|
||||
{getConvertedDistance(value, $distanceUnits).toFixed(decimals ?? 2)}
|
||||
{showUnits ? getDistanceUnits($distanceUnits) : ''}
|
||||
{:else if type === 'elevation'}
|
||||
{getConvertedElevation(value, $distanceUnits).toFixed(decimals ?? 0)}
|
||||
{showUnits ? getElevationUnits($distanceUnits) : ''}
|
||||
{:else if type === 'speed'}
|
||||
{#if $velocityUnits === 'speed'}
|
||||
{getConvertedVelocity(value, $velocityUnits, $distanceUnits).toFixed(decimals ?? 2)}
|
||||
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
|
||||
{:else}
|
||||
{secondsToHHMMSS(getConvertedVelocity(value, $velocityUnits, $distanceUnits))}
|
||||
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
|
||||
{/if}
|
||||
{:else if type === 'temperature'}
|
||||
{#if $temperatureUnits === 'celsius'}
|
||||
{value} {showUnits ? $_('units.celsius') : ''}
|
||||
{:else}
|
||||
{celsiusToFahrenheit(value)} {showUnits ? $_('units.fahrenheit') : ''}
|
||||
{/if}
|
||||
{:else if type === 'time'}
|
||||
{secondsToHHMMSS(value)}
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { setContext } from 'svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
import { setContext } from 'svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export let defaultState: 'open' | 'closed' = 'open';
|
||||
export let side: 'left' | 'right' = 'right';
|
||||
export let nohover: boolean = false;
|
||||
export let slotInsideTrigger: boolean = true;
|
||||
export let defaultState: 'open' | 'closed' = 'open';
|
||||
export let side: 'left' | 'right' = 'right';
|
||||
export let nohover: boolean = false;
|
||||
export let slotInsideTrigger: boolean = true;
|
||||
|
||||
let open = writable<Record<string, boolean>>({});
|
||||
let open = writable<Record<string, boolean>>({});
|
||||
|
||||
setContext('collapsible-tree-default-state', defaultState);
|
||||
setContext('collapsible-tree-state', open);
|
||||
setContext('collapsible-tree-side', side);
|
||||
setContext('collapsible-tree-nohover', nohover);
|
||||
setContext('collapsible-tree-parent-id', 'root');
|
||||
setContext('collapsible-tree-slot-inside-trigger', slotInsideTrigger);
|
||||
setContext('collapsible-tree-default-state', defaultState);
|
||||
setContext('collapsible-tree-state', open);
|
||||
setContext('collapsible-tree-side', side);
|
||||
setContext('collapsible-tree-nohover', nohover);
|
||||
setContext('collapsible-tree-parent-id', 'root');
|
||||
setContext('collapsible-tree-slot-inside-trigger', slotInsideTrigger);
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
|
||||
@@ -1,97 +1,97 @@
|
||||
<script lang="ts">
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-svelte';
|
||||
import { getContext, onMount, setContext } from 'svelte';
|
||||
import { get, type Writable } from 'svelte/store';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-svelte';
|
||||
import { getContext, onMount, setContext } from 'svelte';
|
||||
import { get, type Writable } from 'svelte/store';
|
||||
|
||||
export let id: string | number;
|
||||
export let id: string | number;
|
||||
|
||||
let defaultState = getContext<'open' | 'closed'>('collapsible-tree-default-state');
|
||||
let open = getContext<Writable<Record<string, boolean>>>('collapsible-tree-state');
|
||||
let side = getContext<'left' | 'right'>('collapsible-tree-side');
|
||||
let nohover = getContext<boolean>('collapsible-tree-nohover');
|
||||
let slotInsideTrigger = getContext<boolean>('collapsible-tree-slot-inside-trigger');
|
||||
let parentId = getContext<string>('collapsible-tree-parent-id');
|
||||
let defaultState = getContext<'open' | 'closed'>('collapsible-tree-default-state');
|
||||
let open = getContext<Writable<Record<string, boolean>>>('collapsible-tree-state');
|
||||
let side = getContext<'left' | 'right'>('collapsible-tree-side');
|
||||
let nohover = getContext<boolean>('collapsible-tree-nohover');
|
||||
let slotInsideTrigger = getContext<boolean>('collapsible-tree-slot-inside-trigger');
|
||||
let parentId = getContext<string>('collapsible-tree-parent-id');
|
||||
|
||||
let fullId = `${parentId}.${id}`;
|
||||
setContext('collapsible-tree-parent-id', fullId);
|
||||
let fullId = `${parentId}.${id}`;
|
||||
setContext('collapsible-tree-parent-id', fullId);
|
||||
|
||||
onMount(() => {
|
||||
if (!get(open).hasOwnProperty(fullId)) {
|
||||
open.update((value) => {
|
||||
value[fullId] = defaultState === 'open';
|
||||
return value;
|
||||
});
|
||||
}
|
||||
});
|
||||
onMount(() => {
|
||||
if (!get(open).hasOwnProperty(fullId)) {
|
||||
open.update((value) => {
|
||||
value[fullId] = defaultState === 'open';
|
||||
return value;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export function openNode() {
|
||||
open.update((value) => {
|
||||
value[fullId] = true;
|
||||
return value;
|
||||
});
|
||||
}
|
||||
export function openNode() {
|
||||
open.update((value) => {
|
||||
value[fullId] = true;
|
||||
return value;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<Collapsible.Root bind:open={$open[fullId]} class={$$props.class ?? ''}>
|
||||
{#if slotInsideTrigger}
|
||||
<Collapsible.Trigger class="w-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full flex flex-row {side === 'right'
|
||||
? 'justify-between'
|
||||
: 'justify-start'} py-0 px-1 h-fit {nohover
|
||||
? 'hover:bg-background'
|
||||
: ''} pointer-events-none"
|
||||
>
|
||||
{#if side === 'left'}
|
||||
{#if $open[fullId]}
|
||||
<ChevronDown size="16" class="shrink-0" />
|
||||
{:else}
|
||||
<ChevronRight size="16" class="shrink-0" />
|
||||
{/if}
|
||||
{/if}
|
||||
<slot name="trigger" />
|
||||
{#if side === 'right'}
|
||||
{#if $open[fullId]}
|
||||
<ChevronDown size="16" class="shrink-0" />
|
||||
{:else}
|
||||
<ChevronLeft size="16" class="shrink-0" />
|
||||
{/if}
|
||||
{/if}
|
||||
</Button>
|
||||
</Collapsible.Trigger>
|
||||
{:else}
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full flex flex-row {side === 'right'
|
||||
? 'justify-between'
|
||||
: 'justify-start'} py-0 px-1 h-fit {nohover ? 'hover:bg-background' : ''}"
|
||||
>
|
||||
{#if side === 'left'}
|
||||
<Collapsible.Trigger>
|
||||
{#if $open[fullId]}
|
||||
<ChevronDown size="16" class="shrink-0" />
|
||||
{:else}
|
||||
<ChevronRight size="16" class="shrink-0" />
|
||||
{/if}
|
||||
</Collapsible.Trigger>
|
||||
{/if}
|
||||
<slot name="trigger" />
|
||||
{#if side === 'right'}
|
||||
<Collapsible.Trigger>
|
||||
{#if $open[fullId]}
|
||||
<ChevronDown size="16" class="shrink-0" />
|
||||
{:else}
|
||||
<ChevronLeft size="16" class="shrink-0" />
|
||||
{/if}
|
||||
</Collapsible.Trigger>
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if slotInsideTrigger}
|
||||
<Collapsible.Trigger class="w-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full flex flex-row {side === 'right'
|
||||
? 'justify-between'
|
||||
: 'justify-start'} py-0 px-1 h-fit {nohover
|
||||
? 'hover:bg-background'
|
||||
: ''} pointer-events-none"
|
||||
>
|
||||
{#if side === 'left'}
|
||||
{#if $open[fullId]}
|
||||
<ChevronDown size="16" class="shrink-0" />
|
||||
{:else}
|
||||
<ChevronRight size="16" class="shrink-0" />
|
||||
{/if}
|
||||
{/if}
|
||||
<slot name="trigger" />
|
||||
{#if side === 'right'}
|
||||
{#if $open[fullId]}
|
||||
<ChevronDown size="16" class="shrink-0" />
|
||||
{:else}
|
||||
<ChevronLeft size="16" class="shrink-0" />
|
||||
{/if}
|
||||
{/if}
|
||||
</Button>
|
||||
</Collapsible.Trigger>
|
||||
{:else}
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full flex flex-row {side === 'right'
|
||||
? 'justify-between'
|
||||
: 'justify-start'} py-0 px-1 h-fit {nohover ? 'hover:bg-background' : ''}"
|
||||
>
|
||||
{#if side === 'left'}
|
||||
<Collapsible.Trigger>
|
||||
{#if $open[fullId]}
|
||||
<ChevronDown size="16" class="shrink-0" />
|
||||
{:else}
|
||||
<ChevronRight size="16" class="shrink-0" />
|
||||
{/if}
|
||||
</Collapsible.Trigger>
|
||||
{/if}
|
||||
<slot name="trigger" />
|
||||
{#if side === 'right'}
|
||||
<Collapsible.Trigger>
|
||||
{#if $open[fullId]}
|
||||
<ChevronDown size="16" class="shrink-0" />
|
||||
{:else}
|
||||
<ChevronLeft size="16" class="shrink-0" />
|
||||
{/if}
|
||||
</Collapsible.Trigger>
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<Collapsible.Content class="ml-2">
|
||||
<slot name="content" />
|
||||
</Collapsible.Content>
|
||||
<Collapsible.Content class="ml-2">
|
||||
<slot name="content" />
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as CollapsibleTree } from './CollapsibleTree.svelte';
|
||||
export { default as CollapsibleTreeNode } from './CollapsibleTreeNode.svelte';
|
||||
export { default as CollapsibleTreeNode } from './CollapsibleTreeNode.svelte';
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
<script lang="ts">
|
||||
import CustomControl from './CustomControl';
|
||||
import { map } from '$lib/stores';
|
||||
import CustomControl from './CustomControl';
|
||||
import { map } from '$lib/stores';
|
||||
|
||||
export let position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' = 'top-right';
|
||||
export let position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' = 'top-right';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let control: CustomControl | undefined = undefined;
|
||||
let container: HTMLDivElement;
|
||||
let control: CustomControl | undefined = undefined;
|
||||
|
||||
$: if ($map && container) {
|
||||
if (position.includes('right')) container.classList.add('float-right');
|
||||
else container.classList.add('float-left');
|
||||
container.classList.remove('hidden');
|
||||
if (control === undefined) {
|
||||
control = new CustomControl(container);
|
||||
}
|
||||
$map.addControl(control, position);
|
||||
}
|
||||
$: if ($map && container) {
|
||||
if (position.includes('right')) container.classList.add('float-right');
|
||||
else container.classList.add('float-left');
|
||||
container.classList.remove('hidden');
|
||||
if (control === undefined) {
|
||||
control = new CustomControl(container);
|
||||
}
|
||||
$map.addControl(control, position);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={container}
|
||||
class="{$$props.class ||
|
||||
''} clear-both translate-0 m-[10px] mb-0 last:mb-[10px] pointer-events-auto bg-background rounded shadow-md hidden"
|
||||
bind:this={container}
|
||||
class="{$$props.class ||
|
||||
''} clear-both translate-0 m-[10px] mb-0 last:mb-[10px] pointer-events-auto bg-background rounded shadow-md hidden"
|
||||
>
|
||||
<slot />
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -17,4 +17,4 @@ export default class CustomControl implements IControl {
|
||||
this._container?.parentNode?.removeChild(this._container);
|
||||
this._map = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,82 +1,82 @@
|
||||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let module;
|
||||
export let module;
|
||||
</script>
|
||||
|
||||
<div class="markdown flex flex-col gap-3">
|
||||
<svelte:component this={module} />
|
||||
<svelte:component this={module} />
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
:global(.markdown) {
|
||||
@apply text-muted-foreground;
|
||||
}
|
||||
:global(.markdown) {
|
||||
@apply text-muted-foreground;
|
||||
}
|
||||
|
||||
:global(.markdown h1) {
|
||||
@apply text-foreground;
|
||||
@apply text-3xl;
|
||||
@apply font-semibold;
|
||||
@apply mb-3 pt-6;
|
||||
}
|
||||
:global(.markdown h1) {
|
||||
@apply text-foreground;
|
||||
@apply text-3xl;
|
||||
@apply font-semibold;
|
||||
@apply mb-3 pt-6;
|
||||
}
|
||||
|
||||
:global(.markdown h2) {
|
||||
@apply text-foreground;
|
||||
@apply text-2xl;
|
||||
@apply font-semibold;
|
||||
@apply pt-3;
|
||||
}
|
||||
:global(.markdown h2) {
|
||||
@apply text-foreground;
|
||||
@apply text-2xl;
|
||||
@apply font-semibold;
|
||||
@apply pt-3;
|
||||
}
|
||||
|
||||
:global(.markdown h3) {
|
||||
@apply text-foreground;
|
||||
@apply text-lg;
|
||||
@apply font-semibold;
|
||||
@apply pt-1.5;
|
||||
}
|
||||
:global(.markdown h3) {
|
||||
@apply text-foreground;
|
||||
@apply text-lg;
|
||||
@apply font-semibold;
|
||||
@apply pt-1.5;
|
||||
}
|
||||
|
||||
:global(.markdown p > button, .markdown li > button) {
|
||||
@apply border;
|
||||
@apply rounded-md;
|
||||
@apply px-1;
|
||||
}
|
||||
:global(.markdown p > button, .markdown li > button) {
|
||||
@apply border;
|
||||
@apply rounded-md;
|
||||
@apply px-1;
|
||||
}
|
||||
|
||||
:global(.markdown > a) {
|
||||
@apply text-link;
|
||||
@apply hover:underline;
|
||||
}
|
||||
:global(.markdown > a) {
|
||||
@apply text-link;
|
||||
@apply hover:underline;
|
||||
}
|
||||
|
||||
:global(.markdown p > a) {
|
||||
@apply text-link;
|
||||
@apply hover:underline;
|
||||
}
|
||||
:global(.markdown p > a) {
|
||||
@apply text-link;
|
||||
@apply hover:underline;
|
||||
}
|
||||
|
||||
:global(.markdown li > a) {
|
||||
@apply text-link;
|
||||
@apply hover:underline;
|
||||
}
|
||||
:global(.markdown li > a) {
|
||||
@apply text-link;
|
||||
@apply hover:underline;
|
||||
}
|
||||
|
||||
:global(.markdown kbd) {
|
||||
@apply p-1;
|
||||
@apply rounded-md;
|
||||
@apply border;
|
||||
}
|
||||
:global(.markdown kbd) {
|
||||
@apply p-1;
|
||||
@apply rounded-md;
|
||||
@apply border;
|
||||
}
|
||||
|
||||
:global(.markdown ul) {
|
||||
@apply list-disc;
|
||||
@apply pl-4;
|
||||
}
|
||||
:global(.markdown ul) {
|
||||
@apply list-disc;
|
||||
@apply pl-4;
|
||||
}
|
||||
|
||||
:global(.markdown ol) {
|
||||
@apply list-decimal;
|
||||
@apply pl-4;
|
||||
}
|
||||
:global(.markdown ol) {
|
||||
@apply list-decimal;
|
||||
@apply pl-4;
|
||||
}
|
||||
|
||||
:global(.markdown li) {
|
||||
@apply mt-1;
|
||||
@apply first:mt-0;
|
||||
}
|
||||
:global(.markdown li) {
|
||||
@apply mt-1;
|
||||
@apply first:mt-0;
|
||||
}
|
||||
|
||||
:global(.markdown hr) {
|
||||
@apply my-5;
|
||||
}
|
||||
:global(.markdown hr) {
|
||||
@apply my-5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
<script lang="ts">
|
||||
export let src: 'getting-started/interface' | 'tools/routing' | 'tools/split';
|
||||
export let alt: string;
|
||||
export let src: 'getting-started/interface' | 'tools/routing' | 'tools/split';
|
||||
export let alt: string;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center py-6 w-full">
|
||||
<div class="rounded-md overflow-hidden overflow-clip shadow-xl mx-auto">
|
||||
{#if src === 'getting-started/interface'}
|
||||
<enhanced:img
|
||||
src="/src/lib/assets/img/docs/getting-started/interface.png"
|
||||
{alt}
|
||||
class="w-full max-w-3xl"
|
||||
/>
|
||||
{:else if src === 'tools/routing'}
|
||||
<enhanced:img
|
||||
src="/src/lib/assets/img/docs/tools/routing.png"
|
||||
{alt}
|
||||
class="w-full max-w-3xl"
|
||||
/>
|
||||
{:else if src === 'tools/split'}
|
||||
<enhanced:img src="/src/lib/assets/img/docs/tools/split.png" {alt} class="w-full max-w-3xl" />
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-center text-sm text-muted-foreground mt-2">{alt}</p>
|
||||
<div class="rounded-md overflow-hidden overflow-clip shadow-xl mx-auto">
|
||||
{#if src === 'getting-started/interface'}
|
||||
<enhanced:img
|
||||
src="/src/lib/assets/img/docs/getting-started/interface.png"
|
||||
{alt}
|
||||
class="w-full max-w-3xl"
|
||||
/>
|
||||
{:else if src === 'tools/routing'}
|
||||
<enhanced:img
|
||||
src="/src/lib/assets/img/docs/tools/routing.png"
|
||||
{alt}
|
||||
class="w-full max-w-3xl"
|
||||
/>
|
||||
{:else if src === 'tools/split'}
|
||||
<enhanced:img
|
||||
src="/src/lib/assets/img/docs/tools/split.png"
|
||||
{alt}
|
||||
class="w-full max-w-3xl"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-center text-sm text-muted-foreground mt-2">{alt}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script lang="ts">
|
||||
import mapboxOutdoorsMap from '$lib/assets/img/home/mapbox-outdoors.png?enhanced';
|
||||
import waymarkedMap from '$lib/assets/img/home/waymarked.png?enhanced';
|
||||
import mapboxOutdoorsMap from '$lib/assets/img/home/mapbox-outdoors.png?enhanced';
|
||||
import waymarkedMap from '$lib/assets/img/home/waymarked.png?enhanced';
|
||||
</script>
|
||||
|
||||
<div class="relative h-80 aspect-square rounded-2xl shadow-xl overflow-clip">
|
||||
<enhanced:img src={mapboxOutdoorsMap} alt="Mapbox Outdoors map screenshot." class="absolute" />
|
||||
<enhanced:img
|
||||
src={waymarkedMap}
|
||||
alt="Waymarked Trails map screenshot."
|
||||
class="absolute opacity-0 hover:opacity-100 transition-opacity duration-200"
|
||||
/>
|
||||
<enhanced:img src={mapboxOutdoorsMap} alt="Mapbox Outdoors map screenshot." class="absolute" />
|
||||
<enhanced:img
|
||||
src={waymarkedMap}
|
||||
alt="Waymarked Trails map screenshot."
|
||||
class="absolute opacity-0 hover:opacity-100 transition-opacity duration-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<script lang="ts">
|
||||
export let type: 'note' | 'warning' = 'note';
|
||||
export let type: 'note' | 'warning' = 'note';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-secondary border-l-8 {type === 'note'
|
||||
? 'border-link'
|
||||
: 'border-destructive'} p-2 text-sm rounded-md"
|
||||
class="bg-secondary border-l-8 {type === 'note'
|
||||
? 'border-link'
|
||||
: 'border-destructive'} p-2 text-sm rounded-md"
|
||||
>
|
||||
<slot />
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
div :global(a) {
|
||||
@apply text-link;
|
||||
@apply hover:underline;
|
||||
}
|
||||
div :global(a) {
|
||||
@apply text-link;
|
||||
@apply hover:underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,39 +1,64 @@
|
||||
import { File, FilePen, View, type Icon, Settings, Pencil, MapPin, Scissors, CalendarClock, Group, Ungroup, Filter, SquareDashedMousePointer, MountainSnow } from "lucide-svelte";
|
||||
import type { ComponentType } from "svelte";
|
||||
import {
|
||||
File,
|
||||
FilePen,
|
||||
View,
|
||||
type Icon,
|
||||
Settings,
|
||||
Pencil,
|
||||
MapPin,
|
||||
Scissors,
|
||||
CalendarClock,
|
||||
Group,
|
||||
Ungroup,
|
||||
Filter,
|
||||
SquareDashedMousePointer,
|
||||
MountainSnow,
|
||||
} from 'lucide-svelte';
|
||||
import type { ComponentType } from 'svelte';
|
||||
|
||||
export const guides: Record<string, string[]> = {
|
||||
'getting-started': [],
|
||||
menu: ['file', 'edit', 'view', 'settings'],
|
||||
'files-and-stats': [],
|
||||
toolbar: ['routing', 'poi', 'scissors', 'time', 'merge', 'extract', 'elevation', 'minify', 'clean'],
|
||||
toolbar: [
|
||||
'routing',
|
||||
'poi',
|
||||
'scissors',
|
||||
'time',
|
||||
'merge',
|
||||
'extract',
|
||||
'elevation',
|
||||
'minify',
|
||||
'clean',
|
||||
],
|
||||
'map-controls': [],
|
||||
'gpx': [],
|
||||
'integration': [],
|
||||
'faq': [],
|
||||
gpx: [],
|
||||
integration: [],
|
||||
faq: [],
|
||||
};
|
||||
|
||||
export const guideIcons: Record<string, string | ComponentType<Icon>> = {
|
||||
"getting-started": "🚀",
|
||||
"menu": "📂 ⚙️",
|
||||
"file": File,
|
||||
"edit": FilePen,
|
||||
"view": View,
|
||||
"settings": Settings,
|
||||
"files-and-stats": "🗂 📈",
|
||||
"toolbar": "🧰",
|
||||
"routing": Pencil,
|
||||
"poi": MapPin,
|
||||
"scissors": Scissors,
|
||||
"time": CalendarClock,
|
||||
"merge": Group,
|
||||
"extract": Ungroup,
|
||||
"elevation": MountainSnow,
|
||||
"minify": Filter,
|
||||
"clean": SquareDashedMousePointer,
|
||||
"map-controls": "🗺",
|
||||
"gpx": "💾",
|
||||
"integration": "{ 👩💻 }",
|
||||
"faq": "🔮",
|
||||
'getting-started': '🚀',
|
||||
menu: '📂 ⚙️',
|
||||
file: File,
|
||||
edit: FilePen,
|
||||
view: View,
|
||||
settings: Settings,
|
||||
'files-and-stats': '🗂 📈',
|
||||
toolbar: '🧰',
|
||||
routing: Pencil,
|
||||
poi: MapPin,
|
||||
scissors: Scissors,
|
||||
time: CalendarClock,
|
||||
merge: Group,
|
||||
extract: Ungroup,
|
||||
elevation: MountainSnow,
|
||||
minify: Filter,
|
||||
clean: SquareDashedMousePointer,
|
||||
'map-controls': '🗺',
|
||||
gpx: '💾',
|
||||
integration: '{ 👩💻 }',
|
||||
faq: '🔮',
|
||||
};
|
||||
|
||||
export function getPreviousGuide(currentGuide: string): string | undefined {
|
||||
@@ -96,4 +121,4 @@ export function getNextGuide(currentGuide: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,267 +1,271 @@
|
||||
<script lang="ts">
|
||||
import GPXLayers from '$lib/components/gpx-layer/GPXLayers.svelte';
|
||||
import ElevationProfile from '$lib/components/ElevationProfile.svelte';
|
||||
import FileList from '$lib/components/file-list/FileList.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import Map from '$lib/components/Map.svelte';
|
||||
import LayerControl from '$lib/components/layer-control/LayerControl.svelte';
|
||||
import OpenIn from '$lib/components/embedding/OpenIn.svelte';
|
||||
import {
|
||||
gpxStatistics,
|
||||
slicedGPXStatistics,
|
||||
embedding,
|
||||
loadFile,
|
||||
map,
|
||||
updateGPXData
|
||||
} from '$lib/stores';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { fileObservers, settings, GPXStatisticsTree } from '$lib/db';
|
||||
import { readable } from 'svelte/store';
|
||||
import type { GPXFile } from 'gpx';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import { ListFileItem } from '$lib/components/file-list/FileList';
|
||||
import {
|
||||
allowedEmbeddingBasemaps,
|
||||
getFilesFromEmbeddingOptions,
|
||||
type EmbeddingOptions
|
||||
} from './Embedding';
|
||||
import { mode, setMode } from 'mode-watcher';
|
||||
import { browser } from '$app/environment';
|
||||
import GPXLayers from '$lib/components/gpx-layer/GPXLayers.svelte';
|
||||
import ElevationProfile from '$lib/components/ElevationProfile.svelte';
|
||||
import FileList from '$lib/components/file-list/FileList.svelte';
|
||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||
import Map from '$lib/components/Map.svelte';
|
||||
import LayerControl from '$lib/components/layer-control/LayerControl.svelte';
|
||||
import OpenIn from '$lib/components/embedding/OpenIn.svelte';
|
||||
import {
|
||||
gpxStatistics,
|
||||
slicedGPXStatistics,
|
||||
embedding,
|
||||
loadFile,
|
||||
map,
|
||||
updateGPXData,
|
||||
} from '$lib/stores';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { fileObservers, settings, GPXStatisticsTree } from '$lib/db';
|
||||
import { readable } from 'svelte/store';
|
||||
import type { GPXFile } from 'gpx';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import { ListFileItem } from '$lib/components/file-list/FileList';
|
||||
import {
|
||||
allowedEmbeddingBasemaps,
|
||||
getFilesFromEmbeddingOptions,
|
||||
type EmbeddingOptions,
|
||||
} from './Embedding';
|
||||
import { mode, setMode } from 'mode-watcher';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
$embedding = true;
|
||||
$embedding = true;
|
||||
|
||||
const {
|
||||
currentBasemap,
|
||||
distanceUnits,
|
||||
velocityUnits,
|
||||
temperatureUnits,
|
||||
fileOrder,
|
||||
distanceMarkers,
|
||||
directionMarkers
|
||||
} = settings;
|
||||
const {
|
||||
currentBasemap,
|
||||
distanceUnits,
|
||||
velocityUnits,
|
||||
temperatureUnits,
|
||||
fileOrder,
|
||||
distanceMarkers,
|
||||
directionMarkers,
|
||||
} = settings;
|
||||
|
||||
export let useHash = true;
|
||||
export let options: EmbeddingOptions;
|
||||
export let hash: string;
|
||||
export let useHash = true;
|
||||
export let options: EmbeddingOptions;
|
||||
export let hash: string;
|
||||
|
||||
let prevSettings = {
|
||||
distanceMarkers: false,
|
||||
directionMarkers: false,
|
||||
distanceUnits: 'metric',
|
||||
velocityUnits: 'speed',
|
||||
temperatureUnits: 'celsius',
|
||||
theme: 'system'
|
||||
};
|
||||
let prevSettings = {
|
||||
distanceMarkers: false,
|
||||
directionMarkers: false,
|
||||
distanceUnits: 'metric',
|
||||
velocityUnits: 'speed',
|
||||
temperatureUnits: 'celsius',
|
||||
theme: 'system',
|
||||
};
|
||||
|
||||
function applyOptions() {
|
||||
fileObservers.update(($fileObservers) => {
|
||||
$fileObservers.clear();
|
||||
return $fileObservers;
|
||||
});
|
||||
function applyOptions() {
|
||||
fileObservers.update(($fileObservers) => {
|
||||
$fileObservers.clear();
|
||||
return $fileObservers;
|
||||
});
|
||||
|
||||
let downloads: Promise<GPXFile | null>[] = [];
|
||||
getFilesFromEmbeddingOptions(options).forEach((url) => {
|
||||
downloads.push(
|
||||
fetch(url)
|
||||
.then((response) => response.blob())
|
||||
.then((blob) => new File([blob], url.split('/').pop() ?? url))
|
||||
.then(loadFile)
|
||||
);
|
||||
});
|
||||
let downloads: Promise<GPXFile | null>[] = [];
|
||||
getFilesFromEmbeddingOptions(options).forEach((url) => {
|
||||
downloads.push(
|
||||
fetch(url)
|
||||
.then((response) => response.blob())
|
||||
.then((blob) => new File([blob], url.split('/').pop() ?? url))
|
||||
.then(loadFile)
|
||||
);
|
||||
});
|
||||
|
||||
Promise.all(downloads).then((files) => {
|
||||
let ids: string[] = [];
|
||||
let bounds = {
|
||||
southWest: {
|
||||
lat: 90,
|
||||
lon: 180
|
||||
},
|
||||
northEast: {
|
||||
lat: -90,
|
||||
lon: -180
|
||||
}
|
||||
};
|
||||
Promise.all(downloads).then((files) => {
|
||||
let ids: string[] = [];
|
||||
let bounds = {
|
||||
southWest: {
|
||||
lat: 90,
|
||||
lon: 180,
|
||||
},
|
||||
northEast: {
|
||||
lat: -90,
|
||||
lon: -180,
|
||||
},
|
||||
};
|
||||
|
||||
fileObservers.update(($fileObservers) => {
|
||||
files.forEach((file, index) => {
|
||||
if (file === null) {
|
||||
return;
|
||||
}
|
||||
fileObservers.update(($fileObservers) => {
|
||||
files.forEach((file, index) => {
|
||||
if (file === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let id = `gpx-${index}-embed`;
|
||||
file._data.id = id;
|
||||
let statistics = new GPXStatisticsTree(file);
|
||||
let id = `gpx-${index}-embed`;
|
||||
file._data.id = id;
|
||||
let statistics = new GPXStatisticsTree(file);
|
||||
|
||||
$fileObservers.set(
|
||||
id,
|
||||
readable({
|
||||
file,
|
||||
statistics
|
||||
})
|
||||
);
|
||||
$fileObservers.set(
|
||||
id,
|
||||
readable({
|
||||
file,
|
||||
statistics,
|
||||
})
|
||||
);
|
||||
|
||||
ids.push(id);
|
||||
let fileBounds = statistics.getStatisticsFor(new ListFileItem(id)).global.bounds;
|
||||
ids.push(id);
|
||||
let fileBounds = statistics.getStatisticsFor(new ListFileItem(id)).global
|
||||
.bounds;
|
||||
|
||||
bounds.southWest.lat = Math.min(bounds.southWest.lat, fileBounds.southWest.lat);
|
||||
bounds.southWest.lon = Math.min(bounds.southWest.lon, fileBounds.southWest.lon);
|
||||
bounds.northEast.lat = Math.max(bounds.northEast.lat, fileBounds.northEast.lat);
|
||||
bounds.northEast.lon = Math.max(bounds.northEast.lon, fileBounds.northEast.lon);
|
||||
});
|
||||
bounds.southWest.lat = Math.min(bounds.southWest.lat, fileBounds.southWest.lat);
|
||||
bounds.southWest.lon = Math.min(bounds.southWest.lon, fileBounds.southWest.lon);
|
||||
bounds.northEast.lat = Math.max(bounds.northEast.lat, fileBounds.northEast.lat);
|
||||
bounds.northEast.lon = Math.max(bounds.northEast.lon, fileBounds.northEast.lon);
|
||||
});
|
||||
|
||||
return $fileObservers;
|
||||
});
|
||||
return $fileObservers;
|
||||
});
|
||||
|
||||
$fileOrder = [...$fileOrder.filter((id) => !id.includes('embed')), ...ids];
|
||||
$fileOrder = [...$fileOrder.filter((id) => !id.includes('embed')), ...ids];
|
||||
|
||||
selection.update(($selection) => {
|
||||
$selection.clear();
|
||||
ids.forEach((id) => {
|
||||
$selection.toggle(new ListFileItem(id));
|
||||
});
|
||||
return $selection;
|
||||
});
|
||||
selection.update(($selection) => {
|
||||
$selection.clear();
|
||||
ids.forEach((id) => {
|
||||
$selection.toggle(new ListFileItem(id));
|
||||
});
|
||||
return $selection;
|
||||
});
|
||||
|
||||
if (hash.length === 0) {
|
||||
map.subscribe(($map) => {
|
||||
if ($map) {
|
||||
$map.fitBounds(
|
||||
[
|
||||
bounds.southWest.lon,
|
||||
bounds.southWest.lat,
|
||||
bounds.northEast.lon,
|
||||
bounds.northEast.lat
|
||||
],
|
||||
{
|
||||
padding: 80,
|
||||
linear: true,
|
||||
easing: () => 1
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
if (hash.length === 0) {
|
||||
map.subscribe(($map) => {
|
||||
if ($map) {
|
||||
$map.fitBounds(
|
||||
[
|
||||
bounds.southWest.lon,
|
||||
bounds.southWest.lat,
|
||||
bounds.northEast.lon,
|
||||
bounds.northEast.lat,
|
||||
],
|
||||
{
|
||||
padding: 80,
|
||||
linear: true,
|
||||
easing: () => 1,
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (options.basemap !== $currentBasemap && allowedEmbeddingBasemaps.includes(options.basemap)) {
|
||||
$currentBasemap = options.basemap;
|
||||
}
|
||||
if (
|
||||
options.basemap !== $currentBasemap &&
|
||||
allowedEmbeddingBasemaps.includes(options.basemap)
|
||||
) {
|
||||
$currentBasemap = options.basemap;
|
||||
}
|
||||
|
||||
if (options.distanceMarkers !== $distanceMarkers) {
|
||||
$distanceMarkers = options.distanceMarkers;
|
||||
}
|
||||
if (options.distanceMarkers !== $distanceMarkers) {
|
||||
$distanceMarkers = options.distanceMarkers;
|
||||
}
|
||||
|
||||
if (options.directionMarkers !== $directionMarkers) {
|
||||
$directionMarkers = options.directionMarkers;
|
||||
}
|
||||
if (options.directionMarkers !== $directionMarkers) {
|
||||
$directionMarkers = options.directionMarkers;
|
||||
}
|
||||
|
||||
if (options.distanceUnits !== $distanceUnits) {
|
||||
$distanceUnits = options.distanceUnits;
|
||||
}
|
||||
if (options.distanceUnits !== $distanceUnits) {
|
||||
$distanceUnits = options.distanceUnits;
|
||||
}
|
||||
|
||||
if (options.velocityUnits !== $velocityUnits) {
|
||||
$velocityUnits = options.velocityUnits;
|
||||
}
|
||||
if (options.velocityUnits !== $velocityUnits) {
|
||||
$velocityUnits = options.velocityUnits;
|
||||
}
|
||||
|
||||
if (options.temperatureUnits !== $temperatureUnits) {
|
||||
$temperatureUnits = options.temperatureUnits;
|
||||
}
|
||||
if (options.temperatureUnits !== $temperatureUnits) {
|
||||
$temperatureUnits = options.temperatureUnits;
|
||||
}
|
||||
|
||||
if (options.theme !== $mode) {
|
||||
setMode(options.theme);
|
||||
}
|
||||
}
|
||||
if (options.theme !== $mode) {
|
||||
setMode(options.theme);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
prevSettings.distanceMarkers = $distanceMarkers;
|
||||
prevSettings.directionMarkers = $directionMarkers;
|
||||
prevSettings.distanceUnits = $distanceUnits;
|
||||
prevSettings.velocityUnits = $velocityUnits;
|
||||
prevSettings.temperatureUnits = $temperatureUnits;
|
||||
prevSettings.theme = $mode ?? 'system';
|
||||
});
|
||||
onMount(() => {
|
||||
prevSettings.distanceMarkers = $distanceMarkers;
|
||||
prevSettings.directionMarkers = $directionMarkers;
|
||||
prevSettings.distanceUnits = $distanceUnits;
|
||||
prevSettings.velocityUnits = $velocityUnits;
|
||||
prevSettings.temperatureUnits = $temperatureUnits;
|
||||
prevSettings.theme = $mode ?? 'system';
|
||||
});
|
||||
|
||||
$: if (browser && options) {
|
||||
applyOptions();
|
||||
}
|
||||
$: if (browser && options) {
|
||||
applyOptions();
|
||||
}
|
||||
|
||||
$: if ($fileOrder) {
|
||||
updateGPXData();
|
||||
}
|
||||
$: if ($fileOrder) {
|
||||
updateGPXData();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if ($distanceMarkers !== prevSettings.distanceMarkers) {
|
||||
$distanceMarkers = prevSettings.distanceMarkers;
|
||||
}
|
||||
onDestroy(() => {
|
||||
if ($distanceMarkers !== prevSettings.distanceMarkers) {
|
||||
$distanceMarkers = prevSettings.distanceMarkers;
|
||||
}
|
||||
|
||||
if ($directionMarkers !== prevSettings.directionMarkers) {
|
||||
$directionMarkers = prevSettings.directionMarkers;
|
||||
}
|
||||
if ($directionMarkers !== prevSettings.directionMarkers) {
|
||||
$directionMarkers = prevSettings.directionMarkers;
|
||||
}
|
||||
|
||||
if ($distanceUnits !== prevSettings.distanceUnits) {
|
||||
$distanceUnits = prevSettings.distanceUnits;
|
||||
}
|
||||
if ($distanceUnits !== prevSettings.distanceUnits) {
|
||||
$distanceUnits = prevSettings.distanceUnits;
|
||||
}
|
||||
|
||||
if ($velocityUnits !== prevSettings.velocityUnits) {
|
||||
$velocityUnits = prevSettings.velocityUnits;
|
||||
}
|
||||
if ($velocityUnits !== prevSettings.velocityUnits) {
|
||||
$velocityUnits = prevSettings.velocityUnits;
|
||||
}
|
||||
|
||||
if ($temperatureUnits !== prevSettings.temperatureUnits) {
|
||||
$temperatureUnits = prevSettings.temperatureUnits;
|
||||
}
|
||||
if ($temperatureUnits !== prevSettings.temperatureUnits) {
|
||||
$temperatureUnits = prevSettings.temperatureUnits;
|
||||
}
|
||||
|
||||
if ($mode !== prevSettings.theme) {
|
||||
setMode(prevSettings.theme);
|
||||
}
|
||||
if ($mode !== prevSettings.theme) {
|
||||
setMode(prevSettings.theme);
|
||||
}
|
||||
|
||||
$selection.clear();
|
||||
$fileObservers.clear();
|
||||
$fileOrder = $fileOrder.filter((id) => !id.includes('embed'));
|
||||
});
|
||||
$selection.clear();
|
||||
$fileObservers.clear();
|
||||
$fileOrder = $fileOrder.filter((id) => !id.includes('embed'));
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="absolute flex flex-col h-full w-full border rounded-xl overflow-clip">
|
||||
<div class="grow relative">
|
||||
<Map
|
||||
class="h-full {$fileObservers.size > 1 ? 'horizontal' : ''}"
|
||||
accessToken={options.token}
|
||||
geocoder={false}
|
||||
geolocate={false}
|
||||
hash={useHash}
|
||||
/>
|
||||
<OpenIn bind:files={options.files} bind:ids={options.ids} />
|
||||
<LayerControl />
|
||||
<GPXLayers />
|
||||
{#if $fileObservers.size > 1}
|
||||
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
|
||||
<FileList orientation="horizontal" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="{options.elevation.show ? '' : 'h-10'} flex flex-row gap-2 px-2 sm:px-4"
|
||||
style={options.elevation.show ? `height: ${options.elevation.height}px` : ''}
|
||||
>
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
panelSize={options.elevation.height}
|
||||
orientation={options.elevation.show ? 'vertical' : 'horizontal'}
|
||||
/>
|
||||
{#if options.elevation.show}
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
additionalDatasets={[
|
||||
options.elevation.speed ? 'speed' : null,
|
||||
options.elevation.hr ? 'hr' : null,
|
||||
options.elevation.cad ? 'cad' : null,
|
||||
options.elevation.temp ? 'temp' : null,
|
||||
options.elevation.power ? 'power' : null
|
||||
].filter((dataset) => dataset !== null)}
|
||||
elevationFill={options.elevation.fill}
|
||||
showControls={options.elevation.controls}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grow relative">
|
||||
<Map
|
||||
class="h-full {$fileObservers.size > 1 ? 'horizontal' : ''}"
|
||||
accessToken={options.token}
|
||||
geocoder={false}
|
||||
geolocate={false}
|
||||
hash={useHash}
|
||||
/>
|
||||
<OpenIn bind:files={options.files} bind:ids={options.ids} />
|
||||
<LayerControl />
|
||||
<GPXLayers />
|
||||
{#if $fileObservers.size > 1}
|
||||
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
|
||||
<FileList orientation="horizontal" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="{options.elevation.show ? '' : 'h-10'} flex flex-row gap-2 px-2 sm:px-4"
|
||||
style={options.elevation.show ? `height: ${options.elevation.height}px` : ''}
|
||||
>
|
||||
<GPXStatistics
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
panelSize={options.elevation.height}
|
||||
orientation={options.elevation.show ? 'vertical' : 'horizontal'}
|
||||
/>
|
||||
{#if options.elevation.show}
|
||||
<ElevationProfile
|
||||
{gpxStatistics}
|
||||
{slicedGPXStatistics}
|
||||
additionalDatasets={[
|
||||
options.elevation.speed ? 'speed' : null,
|
||||
options.elevation.hr ? 'hr' : null,
|
||||
options.elevation.cad ? 'cad' : null,
|
||||
options.elevation.temp ? 'temp' : null,
|
||||
options.elevation.power ? 'power' : null,
|
||||
].filter((dataset) => dataset !== null)}
|
||||
elevationFill={options.elevation.fill}
|
||||
showControls={options.elevation.controls}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,14 +39,14 @@ export const defaultEmbeddingOptions = {
|
||||
hr: false,
|
||||
cad: false,
|
||||
temp: false,
|
||||
power: false
|
||||
power: false,
|
||||
},
|
||||
distanceMarkers: false,
|
||||
directionMarkers: false,
|
||||
distanceUnits: 'metric',
|
||||
velocityUnits: 'speed',
|
||||
temperatureUnits: 'celsius',
|
||||
theme: 'system'
|
||||
theme: 'system',
|
||||
};
|
||||
|
||||
export function getDefaultEmbeddingOptions(): EmbeddingOptions {
|
||||
@@ -59,7 +59,11 @@ export function getMergedEmbeddingOptions(
|
||||
): EmbeddingOptions {
|
||||
const mergedOptions = JSON.parse(JSON.stringify(defaultOptions));
|
||||
for (const key in options) {
|
||||
if (typeof options[key] === 'object' && options[key] !== null && !Array.isArray(options[key])) {
|
||||
if (
|
||||
typeof options[key] === 'object' &&
|
||||
options[key] !== null &&
|
||||
!Array.isArray(options[key])
|
||||
) {
|
||||
mergedOptions[key] = getMergedEmbeddingOptions(options[key], defaultOptions[key]);
|
||||
} else {
|
||||
mergedOptions[key] = options[key];
|
||||
@@ -79,7 +83,10 @@ export function getCleanedEmbeddingOptions(
|
||||
cleanedOptions[key] !== null &&
|
||||
!Array.isArray(cleanedOptions[key])
|
||||
) {
|
||||
cleanedOptions[key] = getCleanedEmbeddingOptions(cleanedOptions[key], defaultOptions[key]);
|
||||
cleanedOptions[key] = getCleanedEmbeddingOptions(
|
||||
cleanedOptions[key],
|
||||
defaultOptions[key]
|
||||
);
|
||||
if (Object.keys(cleanedOptions[key]).length === 0) {
|
||||
delete cleanedOptions[key];
|
||||
}
|
||||
@@ -141,7 +148,7 @@ export function convertOldEmbeddingOptions(options: URLSearchParams): any {
|
||||
}
|
||||
if (options.has('slope')) {
|
||||
newOptions.elevation = {
|
||||
fill: 'slope'
|
||||
fill: 'slope',
|
||||
};
|
||||
}
|
||||
return newOptions;
|
||||
|
||||
@@ -1,328 +1,339 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import {
|
||||
Zap,
|
||||
HeartPulse,
|
||||
Orbit,
|
||||
Thermometer,
|
||||
SquareActivity,
|
||||
Coins,
|
||||
Milestone,
|
||||
Video
|
||||
} from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import {
|
||||
allowedEmbeddingBasemaps,
|
||||
getCleanedEmbeddingOptions,
|
||||
getDefaultEmbeddingOptions
|
||||
} from './Embedding';
|
||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||
import Embedding from './Embedding.svelte';
|
||||
import { map } from '$lib/stores';
|
||||
import { tick } from 'svelte';
|
||||
import { base } from '$app/paths';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import {
|
||||
Zap,
|
||||
HeartPulse,
|
||||
Orbit,
|
||||
Thermometer,
|
||||
SquareActivity,
|
||||
Coins,
|
||||
Milestone,
|
||||
Video,
|
||||
} from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import {
|
||||
allowedEmbeddingBasemaps,
|
||||
getCleanedEmbeddingOptions,
|
||||
getDefaultEmbeddingOptions,
|
||||
} from './Embedding';
|
||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||
import Embedding from './Embedding.svelte';
|
||||
import { map } from '$lib/stores';
|
||||
import { tick } from 'svelte';
|
||||
import { base } from '$app/paths';
|
||||
|
||||
let options = getDefaultEmbeddingOptions();
|
||||
options.token = 'YOUR_MAPBOX_TOKEN';
|
||||
options.files = [
|
||||
'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx'
|
||||
];
|
||||
let options = getDefaultEmbeddingOptions();
|
||||
options.token = 'YOUR_MAPBOX_TOKEN';
|
||||
options.files = [
|
||||
'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx',
|
||||
];
|
||||
|
||||
let files = options.files[0];
|
||||
$: {
|
||||
let urls = files.split(',');
|
||||
urls = urls.filter((url) => url.length > 0);
|
||||
if (JSON.stringify(urls) !== JSON.stringify(options.files)) {
|
||||
options.files = urls;
|
||||
}
|
||||
}
|
||||
let driveIds = '';
|
||||
$: {
|
||||
let ids = driveIds.split(',');
|
||||
ids = ids.filter((id) => id.length > 0);
|
||||
if (JSON.stringify(ids) !== JSON.stringify(options.ids)) {
|
||||
options.ids = ids;
|
||||
}
|
||||
}
|
||||
let files = options.files[0];
|
||||
$: {
|
||||
let urls = files.split(',');
|
||||
urls = urls.filter((url) => url.length > 0);
|
||||
if (JSON.stringify(urls) !== JSON.stringify(options.files)) {
|
||||
options.files = urls;
|
||||
}
|
||||
}
|
||||
let driveIds = '';
|
||||
$: {
|
||||
let ids = driveIds.split(',');
|
||||
ids = ids.filter((id) => id.length > 0);
|
||||
if (JSON.stringify(ids) !== JSON.stringify(options.ids)) {
|
||||
options.ids = ids;
|
||||
}
|
||||
}
|
||||
|
||||
let manualCamera = false;
|
||||
let manualCamera = false;
|
||||
|
||||
let zoom = '0';
|
||||
let lat = '0';
|
||||
let lon = '0';
|
||||
let bearing = '0';
|
||||
let pitch = '0';
|
||||
let zoom = '0';
|
||||
let lat = '0';
|
||||
let lon = '0';
|
||||
let bearing = '0';
|
||||
let pitch = '0';
|
||||
|
||||
$: hash = manualCamera ? `#${zoom}/${lat}/${lon}/${bearing}/${pitch}` : '';
|
||||
$: hash = manualCamera ? `#${zoom}/${lat}/${lon}/${bearing}/${pitch}` : '';
|
||||
|
||||
$: iframeOptions =
|
||||
options.token.length === 0 || options.token === 'YOUR_MAPBOX_TOKEN'
|
||||
? Object.assign({}, options, { token: PUBLIC_MAPBOX_TOKEN })
|
||||
: options;
|
||||
$: iframeOptions =
|
||||
options.token.length === 0 || options.token === 'YOUR_MAPBOX_TOKEN'
|
||||
? Object.assign({}, options, { token: PUBLIC_MAPBOX_TOKEN })
|
||||
: options;
|
||||
|
||||
async function resizeMap() {
|
||||
if ($map) {
|
||||
await tick();
|
||||
$map.resize();
|
||||
}
|
||||
}
|
||||
async function resizeMap() {
|
||||
if ($map) {
|
||||
await tick();
|
||||
$map.resize();
|
||||
}
|
||||
}
|
||||
|
||||
$: if (options.elevation.height || options.elevation.show) {
|
||||
resizeMap();
|
||||
}
|
||||
$: if (options.elevation.height || options.elevation.show) {
|
||||
resizeMap();
|
||||
}
|
||||
|
||||
function updateCamera() {
|
||||
if ($map) {
|
||||
let center = $map.getCenter();
|
||||
lat = center.lat.toFixed(4);
|
||||
lon = center.lng.toFixed(4);
|
||||
zoom = $map.getZoom().toFixed(2);
|
||||
bearing = $map.getBearing().toFixed(1);
|
||||
pitch = $map.getPitch().toFixed(0);
|
||||
}
|
||||
}
|
||||
function updateCamera() {
|
||||
if ($map) {
|
||||
let center = $map.getCenter();
|
||||
lat = center.lat.toFixed(4);
|
||||
lon = center.lng.toFixed(4);
|
||||
zoom = $map.getZoom().toFixed(2);
|
||||
bearing = $map.getBearing().toFixed(1);
|
||||
pitch = $map.getPitch().toFixed(0);
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($map) {
|
||||
$map.on('moveend', updateCamera);
|
||||
}
|
||||
$: if ($map) {
|
||||
$map.on('moveend', updateCamera);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root id="embedding-playground">
|
||||
<Card.Header>
|
||||
<Card.Title>{$_('embedding.title')}</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<fieldset class="flex flex-col gap-3">
|
||||
<Label for="token">{$_('embedding.mapbox_token')}</Label>
|
||||
<Input id="token" type="text" class="h-8" bind:value={options.token} />
|
||||
<Label for="file_urls">{$_('embedding.file_urls')}</Label>
|
||||
<Input id="file_urls" type="text" class="h-8" bind:value={files} />
|
||||
<Label for="drive_ids">{$_('embedding.drive_ids')}</Label>
|
||||
<Input id="drive_ids" type="text" class="h-8" bind:value={driveIds} />
|
||||
<Label for="basemap">{$_('embedding.basemap')}</Label>
|
||||
<Select.Root
|
||||
selected={{ value: options.basemap, label: $_(`layers.label.${options.basemap}`) }}
|
||||
onSelectedChange={(selected) => {
|
||||
if (selected?.value) {
|
||||
options.basemap = selected?.value;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Select.Trigger id="basemap" class="w-full h-8">
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content class="max-h-60 overflow-y-scroll">
|
||||
{#each allowedEmbeddingBasemaps as basemap}
|
||||
<Select.Item value={basemap}>{$_(`layers.label.${basemap}`)}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Label for="profile">{$_('menu.elevation_profile')}</Label>
|
||||
<Checkbox id="profile" bind:checked={options.elevation.show} />
|
||||
</div>
|
||||
{#if options.elevation.show}
|
||||
<div class="grid grid-cols-2 gap-x-6 gap-y-3 rounded-md border p-3 mt-1">
|
||||
<Label class="flex flex-row items-center gap-2">
|
||||
{$_('embedding.height')}
|
||||
<Input type="number" bind:value={options.elevation.height} class="h-8 w-20" />
|
||||
</Label>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<span class="shrink-0">
|
||||
{$_('embedding.fill_by')}
|
||||
</span>
|
||||
<Select.Root
|
||||
selected={{ value: 'none', label: $_('embedding.none') }}
|
||||
onSelectedChange={(selected) => {
|
||||
let value = selected?.value;
|
||||
if (value === 'none') {
|
||||
options.elevation.fill = undefined;
|
||||
} else if (value === 'slope' || value === 'surface' || value === 'highway') {
|
||||
options.elevation.fill = value;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Select.Trigger class="grow h-8">
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="slope">{$_('quantities.slope')}</Select.Item>
|
||||
<Select.Item value="surface">{$_('quantities.surface')}</Select.Item>
|
||||
<Select.Item value="highway">{$_('quantities.highway')}</Select.Item>
|
||||
<Select.Item value="none">{$_('embedding.none')}</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="controls" bind:checked={options.elevation.controls} />
|
||||
<Label for="controls">{$_('embedding.show_controls')}</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="show-speed" bind:checked={options.elevation.speed} />
|
||||
<Label for="show-speed" class="flex flex-row items-center gap-1">
|
||||
<Zap size="16" />
|
||||
{$_('quantities.speed')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="show-hr" bind:checked={options.elevation.hr} />
|
||||
<Label for="show-hr" class="flex flex-row items-center gap-1">
|
||||
<HeartPulse size="16" />
|
||||
{$_('quantities.heartrate')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="show-cad" bind:checked={options.elevation.cad} />
|
||||
<Label for="show-cad" class="flex flex-row items-center gap-1">
|
||||
<Orbit size="16" />
|
||||
{$_('quantities.cadence')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="show-temp" bind:checked={options.elevation.temp} />
|
||||
<Label for="show-temp" class="flex flex-row items-center gap-1">
|
||||
<Thermometer size="16" />
|
||||
{$_('quantities.temperature')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="show-power" bind:checked={options.elevation.power} />
|
||||
<Label for="show-power" class="flex flex-row items-center gap-1">
|
||||
<SquareActivity size="16" />
|
||||
{$_('quantities.power')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="distance-markers" bind:checked={options.distanceMarkers} />
|
||||
<Label for="distance-markers" class="flex flex-row items-center gap-1">
|
||||
<Coins size="16" />
|
||||
{$_('menu.distance_markers')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="direction-markers" bind:checked={options.directionMarkers} />
|
||||
<Label for="direction-markers" class="flex flex-row items-center gap-1">
|
||||
<Milestone size="16" />
|
||||
{$_('menu.direction_markers')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row flex-wrap justify-between gap-3">
|
||||
<Label class="flex flex-col items-start gap-2">
|
||||
{$_('menu.distance_units')}
|
||||
<RadioGroup.Root bind:value={options.distanceUnits}>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="metric" id="metric" />
|
||||
<Label for="metric">{$_('menu.metric')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="imperial" id="imperial" />
|
||||
<Label for="imperial">{$_('menu.imperial')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="nautical" id="nautical" />
|
||||
<Label for="nautical">{$_('menu.nautical')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</Label>
|
||||
<Label class="flex flex-col items-start gap-2">
|
||||
{$_('menu.velocity_units')}
|
||||
<RadioGroup.Root bind:value={options.velocityUnits}>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="speed" id="speed" />
|
||||
<Label for="speed">{$_('quantities.speed')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="pace" id="pace" />
|
||||
<Label for="pace">{$_('quantities.pace')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</Label>
|
||||
<Label class="flex flex-col items-start gap-2">
|
||||
{$_('menu.temperature_units')}
|
||||
<RadioGroup.Root bind:value={options.temperatureUnits}>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="celsius" id="celsius" />
|
||||
<Label for="celsius">{$_('menu.celsius')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="fahrenheit" id="fahrenheit" />
|
||||
<Label for="fahrenheit">{$_('menu.fahrenheit')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</Label>
|
||||
</div>
|
||||
<Label class="flex flex-col items-start gap-2">
|
||||
{$_('menu.mode')}
|
||||
<RadioGroup.Root bind:value={options.theme} class="flex flex-row">
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="system" id="system" />
|
||||
<Label for="system">{$_('menu.system')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="light" id="light" />
|
||||
<Label for="light">{$_('menu.light')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="dark" id="dark" />
|
||||
<Label for="dark">{$_('menu.dark')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</Label>
|
||||
<div class="flex flex-col gap-3 p-3 border rounded-md">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="manual-camera" bind:checked={manualCamera} />
|
||||
<Label for="manual-camera" class="flex flex-row items-center gap-1">
|
||||
<Video size="16" />
|
||||
{$_('embedding.manual_camera')}
|
||||
</Label>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{$_('embedding.manual_camera_description')}
|
||||
</p>
|
||||
<div class="flex flex-row flex-wrap items-center gap-6">
|
||||
<Label class="flex flex-col gap-1">
|
||||
<span>{$_('embedding.latitude')}</span>
|
||||
<span>{lat}</span>
|
||||
</Label>
|
||||
<Label class="flex flex-col gap-1">
|
||||
<span>{$_('embedding.longitude')}</span>
|
||||
<span>{lon}</span>
|
||||
</Label>
|
||||
<Label class="flex flex-col gap-1">
|
||||
<span>{$_('embedding.zoom')}</span>
|
||||
<span>{zoom}</span>
|
||||
</Label>
|
||||
<Label class="flex flex-col gap-1">
|
||||
<span>{$_('embedding.bearing')}</span>
|
||||
<span>{bearing}</span>
|
||||
</Label>
|
||||
<Label class="flex flex-col gap-1">
|
||||
<span>{$_('embedding.pitch')}</span>
|
||||
<span>{pitch}</span>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<Label>
|
||||
{$_('embedding.preview')}
|
||||
</Label>
|
||||
<div class="relative h-[600px]">
|
||||
<Embedding bind:options={iframeOptions} bind:hash useHash={false} />
|
||||
</div>
|
||||
<Label>
|
||||
{$_('embedding.code')}
|
||||
</Label>
|
||||
<pre class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">
|
||||
<Card.Header>
|
||||
<Card.Title>{$_('embedding.title')}</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<fieldset class="flex flex-col gap-3">
|
||||
<Label for="token">{$_('embedding.mapbox_token')}</Label>
|
||||
<Input id="token" type="text" class="h-8" bind:value={options.token} />
|
||||
<Label for="file_urls">{$_('embedding.file_urls')}</Label>
|
||||
<Input id="file_urls" type="text" class="h-8" bind:value={files} />
|
||||
<Label for="drive_ids">{$_('embedding.drive_ids')}</Label>
|
||||
<Input id="drive_ids" type="text" class="h-8" bind:value={driveIds} />
|
||||
<Label for="basemap">{$_('embedding.basemap')}</Label>
|
||||
<Select.Root
|
||||
selected={{ value: options.basemap, label: $_(`layers.label.${options.basemap}`) }}
|
||||
onSelectedChange={(selected) => {
|
||||
if (selected?.value) {
|
||||
options.basemap = selected?.value;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Select.Trigger id="basemap" class="w-full h-8">
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content class="max-h-60 overflow-y-scroll">
|
||||
{#each allowedEmbeddingBasemaps as basemap}
|
||||
<Select.Item value={basemap}>{$_(`layers.label.${basemap}`)}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Label for="profile">{$_('menu.elevation_profile')}</Label>
|
||||
<Checkbox id="profile" bind:checked={options.elevation.show} />
|
||||
</div>
|
||||
{#if options.elevation.show}
|
||||
<div class="grid grid-cols-2 gap-x-6 gap-y-3 rounded-md border p-3 mt-1">
|
||||
<Label class="flex flex-row items-center gap-2">
|
||||
{$_('embedding.height')}
|
||||
<Input
|
||||
type="number"
|
||||
bind:value={options.elevation.height}
|
||||
class="h-8 w-20"
|
||||
/>
|
||||
</Label>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<span class="shrink-0">
|
||||
{$_('embedding.fill_by')}
|
||||
</span>
|
||||
<Select.Root
|
||||
selected={{ value: 'none', label: $_('embedding.none') }}
|
||||
onSelectedChange={(selected) => {
|
||||
let value = selected?.value;
|
||||
if (value === 'none') {
|
||||
options.elevation.fill = undefined;
|
||||
} else if (
|
||||
value === 'slope' ||
|
||||
value === 'surface' ||
|
||||
value === 'highway'
|
||||
) {
|
||||
options.elevation.fill = value;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Select.Trigger class="grow h-8">
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="slope">{$_('quantities.slope')}</Select.Item>
|
||||
<Select.Item value="surface">{$_('quantities.surface')}</Select.Item
|
||||
>
|
||||
<Select.Item value="highway">{$_('quantities.highway')}</Select.Item
|
||||
>
|
||||
<Select.Item value="none">{$_('embedding.none')}</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="controls" bind:checked={options.elevation.controls} />
|
||||
<Label for="controls">{$_('embedding.show_controls')}</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="show-speed" bind:checked={options.elevation.speed} />
|
||||
<Label for="show-speed" class="flex flex-row items-center gap-1">
|
||||
<Zap size="16" />
|
||||
{$_('quantities.speed')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="show-hr" bind:checked={options.elevation.hr} />
|
||||
<Label for="show-hr" class="flex flex-row items-center gap-1">
|
||||
<HeartPulse size="16" />
|
||||
{$_('quantities.heartrate')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="show-cad" bind:checked={options.elevation.cad} />
|
||||
<Label for="show-cad" class="flex flex-row items-center gap-1">
|
||||
<Orbit size="16" />
|
||||
{$_('quantities.cadence')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="show-temp" bind:checked={options.elevation.temp} />
|
||||
<Label for="show-temp" class="flex flex-row items-center gap-1">
|
||||
<Thermometer size="16" />
|
||||
{$_('quantities.temperature')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="show-power" bind:checked={options.elevation.power} />
|
||||
<Label for="show-power" class="flex flex-row items-center gap-1">
|
||||
<SquareActivity size="16" />
|
||||
{$_('quantities.power')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="distance-markers" bind:checked={options.distanceMarkers} />
|
||||
<Label for="distance-markers" class="flex flex-row items-center gap-1">
|
||||
<Coins size="16" />
|
||||
{$_('menu.distance_markers')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="direction-markers" bind:checked={options.directionMarkers} />
|
||||
<Label for="direction-markers" class="flex flex-row items-center gap-1">
|
||||
<Milestone size="16" />
|
||||
{$_('menu.direction_markers')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row flex-wrap justify-between gap-3">
|
||||
<Label class="flex flex-col items-start gap-2">
|
||||
{$_('menu.distance_units')}
|
||||
<RadioGroup.Root bind:value={options.distanceUnits}>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="metric" id="metric" />
|
||||
<Label for="metric">{$_('menu.metric')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="imperial" id="imperial" />
|
||||
<Label for="imperial">{$_('menu.imperial')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="nautical" id="nautical" />
|
||||
<Label for="nautical">{$_('menu.nautical')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</Label>
|
||||
<Label class="flex flex-col items-start gap-2">
|
||||
{$_('menu.velocity_units')}
|
||||
<RadioGroup.Root bind:value={options.velocityUnits}>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="speed" id="speed" />
|
||||
<Label for="speed">{$_('quantities.speed')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="pace" id="pace" />
|
||||
<Label for="pace">{$_('quantities.pace')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</Label>
|
||||
<Label class="flex flex-col items-start gap-2">
|
||||
{$_('menu.temperature_units')}
|
||||
<RadioGroup.Root bind:value={options.temperatureUnits}>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="celsius" id="celsius" />
|
||||
<Label for="celsius">{$_('menu.celsius')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="fahrenheit" id="fahrenheit" />
|
||||
<Label for="fahrenheit">{$_('menu.fahrenheit')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</Label>
|
||||
</div>
|
||||
<Label class="flex flex-col items-start gap-2">
|
||||
{$_('menu.mode')}
|
||||
<RadioGroup.Root bind:value={options.theme} class="flex flex-row">
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="system" id="system" />
|
||||
<Label for="system">{$_('menu.system')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="light" id="light" />
|
||||
<Label for="light">{$_('menu.light')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="dark" id="dark" />
|
||||
<Label for="dark">{$_('menu.dark')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</Label>
|
||||
<div class="flex flex-col gap-3 p-3 border rounded-md">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="manual-camera" bind:checked={manualCamera} />
|
||||
<Label for="manual-camera" class="flex flex-row items-center gap-1">
|
||||
<Video size="16" />
|
||||
{$_('embedding.manual_camera')}
|
||||
</Label>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{$_('embedding.manual_camera_description')}
|
||||
</p>
|
||||
<div class="flex flex-row flex-wrap items-center gap-6">
|
||||
<Label class="flex flex-col gap-1">
|
||||
<span>{$_('embedding.latitude')}</span>
|
||||
<span>{lat}</span>
|
||||
</Label>
|
||||
<Label class="flex flex-col gap-1">
|
||||
<span>{$_('embedding.longitude')}</span>
|
||||
<span>{lon}</span>
|
||||
</Label>
|
||||
<Label class="flex flex-col gap-1">
|
||||
<span>{$_('embedding.zoom')}</span>
|
||||
<span>{zoom}</span>
|
||||
</Label>
|
||||
<Label class="flex flex-col gap-1">
|
||||
<span>{$_('embedding.bearing')}</span>
|
||||
<span>{bearing}</span>
|
||||
</Label>
|
||||
<Label class="flex flex-col gap-1">
|
||||
<span>{$_('embedding.pitch')}</span>
|
||||
<span>{pitch}</span>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<Label>
|
||||
{$_('embedding.preview')}
|
||||
</Label>
|
||||
<div class="relative h-[600px]">
|
||||
<Embedding bind:options={iframeOptions} bind:hash useHash={false} />
|
||||
</div>
|
||||
<Label>
|
||||
{$_('embedding.code')}
|
||||
</Label>
|
||||
<pre
|
||||
class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">
|
||||
<code class="language-html">
|
||||
{`<iframe src="https://gpx.studio${base}/embed?options=${encodeURIComponent(JSON.stringify(getCleanedEmbeddingOptions(options)))}${hash}" width="100%" height="600px" frameborder="0" style="outline: none;"/>`}
|
||||
</code>
|
||||
</pre>
|
||||
</fieldset>
|
||||
</Card.Content>
|
||||
</fieldset>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -1,89 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
|
||||
import * as ContextMenu from '$lib/components/ui/context-menu';
|
||||
import FileListNode from './FileListNode.svelte';
|
||||
import { fileObservers, settings } from '$lib/db';
|
||||
import { setContext } from 'svelte';
|
||||
import { ListFileItem, ListLevel, ListRootItem, allowedPastes } from './FileList';
|
||||
import { copied, pasteSelection, selectAll, selection } from './Selection';
|
||||
import { ClipboardPaste, FileStack, Plus } from 'lucide-svelte';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { createFile } from '$lib/stores';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
|
||||
import * as ContextMenu from '$lib/components/ui/context-menu';
|
||||
import FileListNode from './FileListNode.svelte';
|
||||
import { fileObservers, settings } from '$lib/db';
|
||||
import { setContext } from 'svelte';
|
||||
import { ListFileItem, ListLevel, ListRootItem, allowedPastes } from './FileList';
|
||||
import { copied, pasteSelection, selectAll, selection } from './Selection';
|
||||
import { ClipboardPaste, FileStack, Plus } from 'lucide-svelte';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { createFile } from '$lib/stores';
|
||||
|
||||
export let orientation: 'vertical' | 'horizontal';
|
||||
export let recursive = false;
|
||||
export let orientation: 'vertical' | 'horizontal';
|
||||
export let recursive = false;
|
||||
|
||||
setContext('orientation', orientation);
|
||||
setContext('recursive', recursive);
|
||||
setContext('orientation', orientation);
|
||||
setContext('recursive', recursive);
|
||||
|
||||
const { treeFileView } = settings;
|
||||
const { treeFileView } = settings;
|
||||
|
||||
treeFileView.subscribe(($vertical) => {
|
||||
if ($vertical) {
|
||||
selection.update(($selection) => {
|
||||
$selection.forEach((item) => {
|
||||
if ($selection.hasAnyChildren(item, false)) {
|
||||
$selection.toggle(item);
|
||||
}
|
||||
});
|
||||
return $selection;
|
||||
});
|
||||
} else {
|
||||
selection.update(($selection) => {
|
||||
$selection.forEach((item) => {
|
||||
if (!(item instanceof ListFileItem)) {
|
||||
$selection.toggle(item);
|
||||
$selection.set(new ListFileItem(item.getFileId()), true);
|
||||
}
|
||||
});
|
||||
return $selection;
|
||||
});
|
||||
}
|
||||
});
|
||||
treeFileView.subscribe(($vertical) => {
|
||||
if ($vertical) {
|
||||
selection.update(($selection) => {
|
||||
$selection.forEach((item) => {
|
||||
if ($selection.hasAnyChildren(item, false)) {
|
||||
$selection.toggle(item);
|
||||
}
|
||||
});
|
||||
return $selection;
|
||||
});
|
||||
} else {
|
||||
selection.update(($selection) => {
|
||||
$selection.forEach((item) => {
|
||||
if (!(item instanceof ListFileItem)) {
|
||||
$selection.toggle(item);
|
||||
$selection.set(new ListFileItem(item.getFileId()), true);
|
||||
}
|
||||
});
|
||||
return $selection;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<ScrollArea
|
||||
class="shrink-0 {orientation === 'vertical' ? 'p-0 pr-3' : 'h-10 px-1'}"
|
||||
{orientation}
|
||||
scrollbarXClasses={orientation === 'vertical' ? '' : 'mt-1 h-2'}
|
||||
scrollbarYClasses={orientation === 'vertical' ? '' : ''}
|
||||
class="shrink-0 {orientation === 'vertical' ? 'p-0 pr-3' : 'h-10 px-1'}"
|
||||
{orientation}
|
||||
scrollbarXClasses={orientation === 'vertical' ? '' : 'mt-1 h-2'}
|
||||
scrollbarYClasses={orientation === 'vertical' ? '' : ''}
|
||||
>
|
||||
<div
|
||||
class="flex {orientation === 'vertical'
|
||||
? 'flex-col py-1 pl-1 min-h-screen'
|
||||
: 'flex-row'} {$$props.class ?? ''}"
|
||||
{...$$restProps}
|
||||
>
|
||||
<FileListNode bind:node={$fileObservers} item={new ListRootItem()} />
|
||||
{#if orientation === 'vertical'}
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger class="grow" />
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item on:click={createFile}>
|
||||
<Plus size="16" class="mr-1" />
|
||||
{$_('menu.new_file')}
|
||||
<Shortcut key="+" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item on:click={selectAll} disabled={$fileObservers.size === 0}>
|
||||
<FileStack size="16" class="mr-1" />
|
||||
{$_('menu.select_all')}
|
||||
<Shortcut key="A" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item
|
||||
disabled={$copied === undefined ||
|
||||
$copied.length === 0 ||
|
||||
!allowedPastes[$copied[0].level].includes(ListLevel.ROOT)}
|
||||
on:click={pasteSelection}
|
||||
>
|
||||
<ClipboardPaste size="16" class="mr-1" />
|
||||
{$_('menu.paste')}
|
||||
<Shortcut key="V" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="flex {orientation === 'vertical'
|
||||
? 'flex-col py-1 pl-1 min-h-screen'
|
||||
: 'flex-row'} {$$props.class ?? ''}"
|
||||
{...$$restProps}
|
||||
>
|
||||
<FileListNode bind:node={$fileObservers} item={new ListRootItem()} />
|
||||
{#if orientation === 'vertical'}
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger class="grow" />
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item on:click={createFile}>
|
||||
<Plus size="16" class="mr-1" />
|
||||
{$_('menu.new_file')}
|
||||
<Shortcut key="+" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item on:click={selectAll} disabled={$fileObservers.size === 0}>
|
||||
<FileStack size="16" class="mr-1" />
|
||||
{$_('menu.select_all')}
|
||||
<Shortcut key="A" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item
|
||||
disabled={$copied === undefined ||
|
||||
$copied.length === 0 ||
|
||||
!allowedPastes[$copied[0].level].includes(ListLevel.ROOT)}
|
||||
on:click={pasteSelection}
|
||||
>
|
||||
<ClipboardPaste size="16" class="mr-1" />
|
||||
{$_('menu.paste')}
|
||||
<Shortcut key="V" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { dbUtils, getFile } from "$lib/db";
|
||||
import { freeze } from "immer";
|
||||
import { GPXFile, Track, TrackSegment, Waypoint } from "gpx";
|
||||
import { selection } from "./Selection";
|
||||
import { newGPXFile } from "$lib/stores";
|
||||
import { dbUtils, getFile } from '$lib/db';
|
||||
import { freeze } from 'immer';
|
||||
import { GPXFile, Track, TrackSegment, Waypoint } from 'gpx';
|
||||
import { selection } from './Selection';
|
||||
import { newGPXFile } from '$lib/stores';
|
||||
|
||||
export enum ListLevel {
|
||||
ROOT,
|
||||
@@ -10,7 +10,7 @@ export enum ListLevel {
|
||||
TRACK,
|
||||
SEGMENT,
|
||||
WAYPOINTS,
|
||||
WAYPOINT
|
||||
WAYPOINT,
|
||||
}
|
||||
|
||||
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
|
||||
@@ -19,7 +19,7 @@ export const allowedMoves: Record<ListLevel, ListLevel[]> = {
|
||||
[ListLevel.TRACK]: [ListLevel.FILE, ListLevel.TRACK],
|
||||
[ListLevel.SEGMENT]: [ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
|
||||
[ListLevel.WAYPOINTS]: [ListLevel.WAYPOINTS],
|
||||
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT]
|
||||
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
|
||||
};
|
||||
|
||||
export const allowedPastes: Record<ListLevel, ListLevel[]> = {
|
||||
@@ -28,7 +28,7 @@ export const allowedPastes: Record<ListLevel, ListLevel[]> = {
|
||||
[ListLevel.TRACK]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK],
|
||||
[ListLevel.SEGMENT]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
|
||||
[ListLevel.WAYPOINTS]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
|
||||
[ListLevel.WAYPOINT]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT]
|
||||
[ListLevel.WAYPOINT]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
|
||||
};
|
||||
|
||||
export abstract class ListItem {
|
||||
@@ -322,7 +322,13 @@ export function sortItems(items: ListItem[], reverse: boolean = false) {
|
||||
}
|
||||
}
|
||||
|
||||
export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: ListItem[], toItems: ListItem[], remove: boolean = true) {
|
||||
export function moveItems(
|
||||
fromParent: ListItem,
|
||||
toParent: ListItem,
|
||||
fromItems: ListItem[],
|
||||
toItems: ListItem[],
|
||||
remove: boolean = true
|
||||
) {
|
||||
if (fromItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -338,11 +344,18 @@ export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: L
|
||||
context.push(file.clone());
|
||||
} else if (item instanceof ListTrackItem && item.getTrackIndex() < file.trk.length) {
|
||||
context.push(file.trk[item.getTrackIndex()].clone());
|
||||
} else if (item instanceof ListTrackSegmentItem && item.getTrackIndex() < file.trk.length && item.getSegmentIndex() < file.trk[item.getTrackIndex()].trkseg.length) {
|
||||
} else if (
|
||||
item instanceof ListTrackSegmentItem &&
|
||||
item.getTrackIndex() < file.trk.length &&
|
||||
item.getSegmentIndex() < file.trk[item.getTrackIndex()].trkseg.length
|
||||
) {
|
||||
context.push(file.trk[item.getTrackIndex()].trkseg[item.getSegmentIndex()].clone());
|
||||
} else if (item instanceof ListWaypointsItem) {
|
||||
context.push(file.wpt.map((wpt) => wpt.clone()));
|
||||
} else if (item instanceof ListWaypointItem && item.getWaypointIndex() < file.wpt.length) {
|
||||
} else if (
|
||||
item instanceof ListWaypointItem &&
|
||||
item.getWaypointIndex() < file.wpt.length
|
||||
) {
|
||||
context.push(file.wpt[item.getWaypointIndex()].clone());
|
||||
}
|
||||
}
|
||||
@@ -359,7 +372,12 @@ export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: L
|
||||
if (item instanceof ListTrackItem) {
|
||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex(), []);
|
||||
} else if (item instanceof ListTrackSegmentItem) {
|
||||
file.replaceTrackSegments(item.getTrackIndex(), item.getSegmentIndex(), item.getSegmentIndex(), []);
|
||||
file.replaceTrackSegments(
|
||||
item.getTrackIndex(),
|
||||
item.getSegmentIndex(),
|
||||
item.getSegmentIndex(),
|
||||
[]
|
||||
);
|
||||
} else if (item instanceof ListWaypointsItem) {
|
||||
file.replaceWaypoints(0, file.wpt.length - 1, []);
|
||||
} else if (item instanceof ListWaypointItem) {
|
||||
@@ -371,25 +389,43 @@ export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: L
|
||||
toItems.forEach((item, i) => {
|
||||
if (item instanceof ListTrackItem) {
|
||||
if (context[i] instanceof Track) {
|
||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [context[i]]);
|
||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [
|
||||
context[i],
|
||||
]);
|
||||
} else if (context[i] instanceof TrackSegment) {
|
||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [new Track({
|
||||
trkseg: [context[i]]
|
||||
})]);
|
||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [
|
||||
new Track({
|
||||
trkseg: [context[i]],
|
||||
}),
|
||||
]);
|
||||
}
|
||||
} else if (item instanceof ListTrackSegmentItem && context[i] instanceof TrackSegment) {
|
||||
file.replaceTrackSegments(item.getTrackIndex(), item.getSegmentIndex(), item.getSegmentIndex() - 1, [context[i]]);
|
||||
} else if (
|
||||
item instanceof ListTrackSegmentItem &&
|
||||
context[i] instanceof TrackSegment
|
||||
) {
|
||||
file.replaceTrackSegments(
|
||||
item.getTrackIndex(),
|
||||
item.getSegmentIndex(),
|
||||
item.getSegmentIndex() - 1,
|
||||
[context[i]]
|
||||
);
|
||||
} else if (item instanceof ListWaypointsItem) {
|
||||
if (Array.isArray(context[i]) && context[i].length > 0 && context[i][0] instanceof Waypoint) {
|
||||
if (
|
||||
Array.isArray(context[i]) &&
|
||||
context[i].length > 0 &&
|
||||
context[i][0] instanceof Waypoint
|
||||
) {
|
||||
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, context[i]);
|
||||
} else if (context[i] instanceof Waypoint) {
|
||||
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, [context[i]]);
|
||||
}
|
||||
} else if (item instanceof ListWaypointItem && context[i] instanceof Waypoint) {
|
||||
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex() - 1, [context[i]]);
|
||||
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex() - 1, [
|
||||
context[i],
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (fromParent instanceof ListRootItem) {
|
||||
@@ -400,35 +436,42 @@ export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: L
|
||||
callbacks.splice(0, 1);
|
||||
}
|
||||
|
||||
dbUtils.applyEachToFilesAndGlobal(files, callbacks, (files, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
|
||||
toItems.forEach((item, i) => {
|
||||
if (item instanceof ListFileItem) {
|
||||
if (context[i] instanceof GPXFile) {
|
||||
let newFile = context[i];
|
||||
if (remove) {
|
||||
files.delete(newFile._data.id);
|
||||
dbUtils.applyEachToFilesAndGlobal(
|
||||
files,
|
||||
callbacks,
|
||||
(files, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
|
||||
toItems.forEach((item, i) => {
|
||||
if (item instanceof ListFileItem) {
|
||||
if (context[i] instanceof GPXFile) {
|
||||
let newFile = context[i];
|
||||
if (remove) {
|
||||
files.delete(newFile._data.id);
|
||||
}
|
||||
newFile._data.id = item.getFileId();
|
||||
files.set(item.getFileId(), freeze(newFile));
|
||||
} else if (context[i] instanceof Track) {
|
||||
let newFile = newGPXFile();
|
||||
newFile._data.id = item.getFileId();
|
||||
if (context[i].name) {
|
||||
newFile.metadata.name = context[i].name;
|
||||
}
|
||||
newFile.replaceTracks(0, 0, [context[i]]);
|
||||
files.set(item.getFileId(), freeze(newFile));
|
||||
} else if (context[i] instanceof TrackSegment) {
|
||||
let newFile = newGPXFile();
|
||||
newFile._data.id = item.getFileId();
|
||||
newFile.replaceTracks(0, 0, [
|
||||
new Track({
|
||||
trkseg: [context[i]],
|
||||
}),
|
||||
]);
|
||||
files.set(item.getFileId(), freeze(newFile));
|
||||
}
|
||||
newFile._data.id = item.getFileId();
|
||||
files.set(item.getFileId(), freeze(newFile));
|
||||
} else if (context[i] instanceof Track) {
|
||||
let newFile = newGPXFile();
|
||||
newFile._data.id = item.getFileId();
|
||||
if (context[i].name) {
|
||||
newFile.metadata.name = context[i].name;
|
||||
}
|
||||
newFile.replaceTracks(0, 0, [context[i]]);
|
||||
files.set(item.getFileId(), freeze(newFile));
|
||||
} else if (context[i] instanceof TrackSegment) {
|
||||
let newFile = newGPXFile();
|
||||
newFile._data.id = item.getFileId();
|
||||
newFile.replaceTracks(0, 0, [new Track({
|
||||
trkseg: [context[i]]
|
||||
})]);
|
||||
files.set(item.getFileId(), freeze(newFile));
|
||||
}
|
||||
}
|
||||
});
|
||||
}, context);
|
||||
});
|
||||
},
|
||||
context
|
||||
);
|
||||
|
||||
selection.update(($selection) => {
|
||||
$selection.clear();
|
||||
|
||||
@@ -1,83 +1,84 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
GPXFile,
|
||||
Track,
|
||||
TrackSegment,
|
||||
Waypoint,
|
||||
type AnyGPXTreeElement,
|
||||
type GPXTreeElement
|
||||
} from 'gpx';
|
||||
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
|
||||
import { settings, type GPXFileWithStatistics } from '$lib/db';
|
||||
import { get, type Readable } from 'svelte/store';
|
||||
import FileListNodeContent from './FileListNodeContent.svelte';
|
||||
import FileListNodeLabel from './FileListNodeLabel.svelte';
|
||||
import { afterUpdate, getContext } from 'svelte';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListTrackSegmentItem,
|
||||
ListWaypointItem,
|
||||
ListWaypointsItem,
|
||||
type ListItem,
|
||||
type ListTrackItem
|
||||
} from './FileList';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { selection } from './Selection';
|
||||
import {
|
||||
GPXFile,
|
||||
Track,
|
||||
TrackSegment,
|
||||
Waypoint,
|
||||
type AnyGPXTreeElement,
|
||||
type GPXTreeElement,
|
||||
} from 'gpx';
|
||||
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
|
||||
import { settings, type GPXFileWithStatistics } from '$lib/db';
|
||||
import { get, type Readable } from 'svelte/store';
|
||||
import FileListNodeContent from './FileListNodeContent.svelte';
|
||||
import FileListNodeLabel from './FileListNodeLabel.svelte';
|
||||
import { afterUpdate, getContext } from 'svelte';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListTrackSegmentItem,
|
||||
ListWaypointItem,
|
||||
ListWaypointsItem,
|
||||
type ListItem,
|
||||
type ListTrackItem,
|
||||
} from './FileList';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { selection } from './Selection';
|
||||
|
||||
export let node:
|
||||
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
||||
| GPXTreeElement<AnyGPXTreeElement>
|
||||
| Waypoint[]
|
||||
| Waypoint;
|
||||
export let item: ListItem;
|
||||
export let node:
|
||||
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
||||
| GPXTreeElement<AnyGPXTreeElement>
|
||||
| Waypoint[]
|
||||
| Waypoint;
|
||||
export let item: ListItem;
|
||||
|
||||
let recursive = getContext<boolean>('recursive');
|
||||
let recursive = getContext<boolean>('recursive');
|
||||
|
||||
let collapsible: CollapsibleTreeNode;
|
||||
let collapsible: CollapsibleTreeNode;
|
||||
|
||||
$: label =
|
||||
node instanceof GPXFile && item instanceof ListFileItem
|
||||
? node.metadata.name
|
||||
: node instanceof Track
|
||||
? (node.name ?? `${$_('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`)
|
||||
: node instanceof TrackSegment
|
||||
? `${$_('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}`
|
||||
: node instanceof Waypoint
|
||||
? (node.name ?? `${$_('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`)
|
||||
: node instanceof GPXFile && item instanceof ListWaypointsItem
|
||||
? $_('gpx.waypoints')
|
||||
: '';
|
||||
$: label =
|
||||
node instanceof GPXFile && item instanceof ListFileItem
|
||||
? node.metadata.name
|
||||
: node instanceof Track
|
||||
? (node.name ?? `${$_('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`)
|
||||
: node instanceof TrackSegment
|
||||
? `${$_('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}`
|
||||
: node instanceof Waypoint
|
||||
? (node.name ??
|
||||
`${$_('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`)
|
||||
: node instanceof GPXFile && item instanceof ListWaypointsItem
|
||||
? $_('gpx.waypoints')
|
||||
: '';
|
||||
|
||||
const { treeFileView } = settings;
|
||||
const { treeFileView } = settings;
|
||||
|
||||
function openIfSelectedChild() {
|
||||
if (collapsible && get(treeFileView) && $selection.hasAnyChildren(item, false)) {
|
||||
collapsible.openNode();
|
||||
}
|
||||
}
|
||||
function openIfSelectedChild() {
|
||||
if (collapsible && get(treeFileView) && $selection.hasAnyChildren(item, false)) {
|
||||
collapsible.openNode();
|
||||
}
|
||||
}
|
||||
|
||||
if ($selection) {
|
||||
openIfSelectedChild();
|
||||
}
|
||||
if ($selection) {
|
||||
openIfSelectedChild();
|
||||
}
|
||||
|
||||
afterUpdate(openIfSelectedChild);
|
||||
afterUpdate(openIfSelectedChild);
|
||||
</script>
|
||||
|
||||
{#if node instanceof Map}
|
||||
<FileListNodeContent {node} {item} />
|
||||
<FileListNodeContent {node} {item} />
|
||||
{:else if node instanceof TrackSegment}
|
||||
<FileListNodeLabel {node} {item} {label} />
|
||||
<FileListNodeLabel {node} {item} {label} />
|
||||
{:else if node instanceof Waypoint}
|
||||
<FileListNodeLabel {node} {item} {label} />
|
||||
<FileListNodeLabel {node} {item} {label} />
|
||||
{:else if recursive}
|
||||
<CollapsibleTreeNode id={item.getId()} bind:this={collapsible}>
|
||||
<FileListNodeLabel {node} {item} {label} slot="trigger" />
|
||||
<div slot="content" class="ml-2">
|
||||
{#key node}
|
||||
<FileListNodeContent {node} {item} />
|
||||
{/key}
|
||||
</div>
|
||||
</CollapsibleTreeNode>
|
||||
<CollapsibleTreeNode id={item.getId()} bind:this={collapsible}>
|
||||
<FileListNodeLabel {node} {item} {label} slot="trigger" />
|
||||
<div slot="content" class="ml-2">
|
||||
{#key node}
|
||||
<FileListNodeContent {node} {item} />
|
||||
{/key}
|
||||
</div>
|
||||
</CollapsibleTreeNode>
|
||||
{:else}
|
||||
<FileListNodeLabel {node} {item} {label} />
|
||||
<FileListNodeLabel {node} {item} {label} />
|
||||
{/if}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
ListWaypointsItem,
|
||||
allowedMoves,
|
||||
moveItems,
|
||||
type ListItem
|
||||
type ListItem,
|
||||
} from './FileList';
|
||||
import { selection } from './Selection';
|
||||
import { isMac } from '$lib/utils';
|
||||
@@ -113,7 +113,7 @@
|
||||
Sortable.utils.select(element);
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest'
|
||||
block: 'nearest',
|
||||
});
|
||||
} else {
|
||||
Sortable.utils.deselect(element);
|
||||
@@ -155,7 +155,7 @@
|
||||
group: {
|
||||
name: sortableLevel,
|
||||
pull: allowedMoves[sortableLevel],
|
||||
put: true
|
||||
put: true,
|
||||
},
|
||||
direction: orientation,
|
||||
forceAutoScrollFallback: true,
|
||||
@@ -233,16 +233,16 @@
|
||||
|
||||
moveItems(fromItem, toItem, fromItems, toItems);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
Object.defineProperty(sortable, '_item', {
|
||||
value: item,
|
||||
writable: true
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(sortable, '_waypointRoot', {
|
||||
value: waypointRoot,
|
||||
writable: true
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,322 +1,335 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as ContextMenu from '$lib/components/ui/context-menu';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import { dbUtils, getFile } from '$lib/db';
|
||||
import {
|
||||
Copy,
|
||||
Info,
|
||||
MapPin,
|
||||
PaintBucket,
|
||||
Plus,
|
||||
Trash2,
|
||||
Waypoints,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ClipboardCopy,
|
||||
ClipboardPaste,
|
||||
Maximize,
|
||||
Scissors,
|
||||
FileStack,
|
||||
FileX
|
||||
} from 'lucide-svelte';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListLevel,
|
||||
ListTrackItem,
|
||||
ListWaypointItem,
|
||||
allowedPastes,
|
||||
type ListItem
|
||||
} from './FileList';
|
||||
import {
|
||||
copied,
|
||||
copySelection,
|
||||
cut,
|
||||
cutSelection,
|
||||
pasteSelection,
|
||||
selectAll,
|
||||
selectItem,
|
||||
selection
|
||||
} from './Selection';
|
||||
import { getContext } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import {
|
||||
allHidden,
|
||||
editMetadata,
|
||||
editStyle,
|
||||
embedding,
|
||||
centerMapOnSelection,
|
||||
gpxLayers,
|
||||
map
|
||||
} from '$lib/stores';
|
||||
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import MetadataDialog from './MetadataDialog.svelte';
|
||||
import StyleDialog from './StyleDialog.svelte';
|
||||
import { waypointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as ContextMenu from '$lib/components/ui/context-menu';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import { dbUtils, getFile } from '$lib/db';
|
||||
import {
|
||||
Copy,
|
||||
Info,
|
||||
MapPin,
|
||||
PaintBucket,
|
||||
Plus,
|
||||
Trash2,
|
||||
Waypoints,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ClipboardCopy,
|
||||
ClipboardPaste,
|
||||
Maximize,
|
||||
Scissors,
|
||||
FileStack,
|
||||
FileX,
|
||||
} from 'lucide-svelte';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListLevel,
|
||||
ListTrackItem,
|
||||
ListWaypointItem,
|
||||
allowedPastes,
|
||||
type ListItem,
|
||||
} from './FileList';
|
||||
import {
|
||||
copied,
|
||||
copySelection,
|
||||
cut,
|
||||
cutSelection,
|
||||
pasteSelection,
|
||||
selectAll,
|
||||
selectItem,
|
||||
selection,
|
||||
} from './Selection';
|
||||
import { getContext } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import {
|
||||
allHidden,
|
||||
editMetadata,
|
||||
editStyle,
|
||||
embedding,
|
||||
centerMapOnSelection,
|
||||
gpxLayers,
|
||||
map,
|
||||
} from '$lib/stores';
|
||||
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import MetadataDialog from './MetadataDialog.svelte';
|
||||
import StyleDialog from './StyleDialog.svelte';
|
||||
import { waypointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
|
||||
export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
||||
export let item: ListItem;
|
||||
export let label: string | undefined;
|
||||
export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
||||
export let item: ListItem;
|
||||
export let label: string | undefined;
|
||||
|
||||
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
||||
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
||||
|
||||
$: singleSelection = $selection.size === 1;
|
||||
$: singleSelection = $selection.size === 1;
|
||||
|
||||
let nodeColors: string[] = [];
|
||||
let nodeColors: string[] = [];
|
||||
|
||||
$: if (node && $map) {
|
||||
nodeColors = [];
|
||||
$: if (node && $map) {
|
||||
nodeColors = [];
|
||||
|
||||
if (node instanceof GPXFile) {
|
||||
let defaultColor = undefined;
|
||||
if (node instanceof GPXFile) {
|
||||
let defaultColor = undefined;
|
||||
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
if (layer) {
|
||||
defaultColor = layer.layerColor;
|
||||
}
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
if (layer) {
|
||||
defaultColor = layer.layerColor;
|
||||
}
|
||||
|
||||
let style = node.getStyle(defaultColor);
|
||||
style.color.forEach((c) => {
|
||||
if (!nodeColors.includes(c)) {
|
||||
nodeColors.push(c);
|
||||
}
|
||||
});
|
||||
} else if (node instanceof Track) {
|
||||
let style = node.getStyle();
|
||||
if (style) {
|
||||
if (style['gpx_style:color'] && !nodeColors.includes(style['gpx_style:color'])) {
|
||||
nodeColors.push(style['gpx_style:color']);
|
||||
}
|
||||
}
|
||||
if (nodeColors.length === 0) {
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
if (layer) {
|
||||
nodeColors.push(layer.layerColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let style = node.getStyle(defaultColor);
|
||||
style.color.forEach((c) => {
|
||||
if (!nodeColors.includes(c)) {
|
||||
nodeColors.push(c);
|
||||
}
|
||||
});
|
||||
} else if (node instanceof Track) {
|
||||
let style = node.getStyle();
|
||||
if (style) {
|
||||
if (style['gpx_style:color'] && !nodeColors.includes(style['gpx_style:color'])) {
|
||||
nodeColors.push(style['gpx_style:color']);
|
||||
}
|
||||
}
|
||||
if (nodeColors.length === 0) {
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
if (layer) {
|
||||
nodeColors.push(layer.layerColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: symbolKey = node instanceof Waypoint ? getSymbolKey(node.sym) : undefined;
|
||||
$: symbolKey = node instanceof Waypoint ? getSymbolKey(node.sym) : undefined;
|
||||
|
||||
let openEditMetadata: boolean = false;
|
||||
let openEditStyle: boolean = false;
|
||||
let openEditMetadata: boolean = false;
|
||||
let openEditStyle: boolean = false;
|
||||
|
||||
$: openEditMetadata = $editMetadata && singleSelection && $selection.has(item);
|
||||
$: openEditStyle =
|
||||
$editStyle &&
|
||||
$selection.has(item) &&
|
||||
$selection.getSelected().findIndex((i) => i.getFullId() === item.getFullId()) === 0;
|
||||
$: hidden = item.level === ListLevel.WAYPOINTS ? node._data.hiddenWpt : node._data.hidden;
|
||||
$: openEditMetadata = $editMetadata && singleSelection && $selection.has(item);
|
||||
$: openEditStyle =
|
||||
$editStyle &&
|
||||
$selection.has(item) &&
|
||||
$selection.getSelected().findIndex((i) => i.getFullId() === item.getFullId()) === 0;
|
||||
$: hidden = item.level === ListLevel.WAYPOINTS ? node._data.hiddenWpt : node._data.hidden;
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<ContextMenu.Root
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
if (!get(selection).has(item)) {
|
||||
selectItem(item);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
if (!get(selection).has(item)) {
|
||||
selectItem(item);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ContextMenu.Trigger class="grow truncate">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="relative w-full p-0 px-1 border-none overflow-hidden focus-visible:ring-0 focus-visible:ring-offset-0 {orientation ===
|
||||
'vertical'
|
||||
? 'h-fit'
|
||||
: 'h-9 px-1.5 shadow-md'} pointer-events-auto"
|
||||
>
|
||||
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
|
||||
<MetadataDialog bind:open={openEditMetadata} {node} {item} />
|
||||
<StyleDialog bind:open={openEditStyle} {item} />
|
||||
{/if}
|
||||
{#if item.level === ListLevel.FILE || item.level === ListLevel.TRACK}
|
||||
<div
|
||||
class="absolute {orientation === 'vertical'
|
||||
? 'top-0 bottom-0 right-1 w-1'
|
||||
: 'top-0 h-1 left-0 right-0'}"
|
||||
style="background:linear-gradient(to {orientation === 'vertical'
|
||||
? 'bottom'
|
||||
: 'right'},{nodeColors
|
||||
.map(
|
||||
(c, i) =>
|
||||
`${c} ${Math.floor((100 * i) / nodeColors.length)}% ${Math.floor((100 * (i + 1)) / nodeColors.length)}%`
|
||||
)
|
||||
.join(',')})"
|
||||
/>
|
||||
{/if}
|
||||
<span
|
||||
class="w-full text-left truncate py-1 flex flex-row items-center {hidden
|
||||
? 'text-muted-foreground'
|
||||
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId())
|
||||
? 'text-muted-foreground'
|
||||
: ''}"
|
||||
on:contextmenu={(e) => {
|
||||
if ($embedding) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (e.ctrlKey) {
|
||||
// Add to selection instead of opening context menu
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
$selection.toggle(item);
|
||||
$selection = $selection;
|
||||
}
|
||||
}}
|
||||
on:mouseenter={() => {
|
||||
if (item instanceof ListWaypointItem) {
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
let file = getFile(item.getFileId());
|
||||
if (layer && file) {
|
||||
let waypoint = file.wpt[item.getWaypointIndex()];
|
||||
if (waypoint) {
|
||||
waypointPopup?.setItem({ item: waypoint, fileId: item.getFileId() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
if (item instanceof ListWaypointItem) {
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
if (layer) {
|
||||
waypointPopup?.setItem(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if item.level === ListLevel.SEGMENT}
|
||||
<Waypoints size="16" class="mr-1 shrink-0" />
|
||||
{:else if item.level === ListLevel.WAYPOINT}
|
||||
{#if symbolKey && symbols[symbolKey].icon}
|
||||
<svelte:component this={symbols[symbolKey].icon} size="16" class="mr-1 shrink-0" />
|
||||
{:else}
|
||||
<MapPin size="16" class="mr-1 shrink-0" />
|
||||
{/if}
|
||||
{/if}
|
||||
<span class="grow select-none truncate {orientation === 'vertical' ? 'last:mr-2' : ''}">
|
||||
{label}
|
||||
</span>
|
||||
{#if hidden}
|
||||
<EyeOff
|
||||
size="12"
|
||||
class="shrink-0 mt-1 ml-1 {orientation === 'vertical' ? 'mr-2' : ''} {item.level ===
|
||||
ListLevel.SEGMENT || item.level === ListLevel.WAYPOINT
|
||||
? 'mr-3'
|
||||
: ''}"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
</Button>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content>
|
||||
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
|
||||
<ContextMenu.Item disabled={!singleSelection} on:click={() => ($editMetadata = true)}>
|
||||
<Info size="16" class="mr-1" />
|
||||
{$_('menu.metadata.button')}
|
||||
<Shortcut key="I" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item on:click={() => ($editStyle = true)}>
|
||||
<PaintBucket size="16" class="mr-1" />
|
||||
{$_('menu.style.button')}
|
||||
</ContextMenu.Item>
|
||||
{/if}
|
||||
<ContextMenu.Item
|
||||
on:click={() => {
|
||||
if ($allHidden) {
|
||||
dbUtils.setHiddenToSelection(false);
|
||||
} else {
|
||||
dbUtils.setHiddenToSelection(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if $allHidden}
|
||||
<Eye size="16" class="mr-1" />
|
||||
{$_('menu.unhide')}
|
||||
{:else}
|
||||
<EyeOff size="16" class="mr-1" />
|
||||
{$_('menu.hide')}
|
||||
{/if}
|
||||
<Shortcut key="H" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
{#if orientation === 'vertical'}
|
||||
{#if item instanceof ListFileItem}
|
||||
<ContextMenu.Item
|
||||
disabled={!singleSelection}
|
||||
on:click={() => dbUtils.addNewTrack(item.getFileId())}
|
||||
>
|
||||
<Plus size="16" class="mr-1" />
|
||||
{$_('menu.new_track')}
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
{:else if item instanceof ListTrackItem}
|
||||
<ContextMenu.Item
|
||||
disabled={!singleSelection}
|
||||
on:click={() => dbUtils.addNewSegment(item.getFileId(), item.getTrackIndex())}
|
||||
>
|
||||
<Plus size="16" class="mr-1" />
|
||||
{$_('menu.new_segment')}
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
{/if}
|
||||
{/if}
|
||||
{#if item.level !== ListLevel.WAYPOINTS}
|
||||
<ContextMenu.Item on:click={selectAll}>
|
||||
<FileStack size="16" class="mr-1" />
|
||||
{$_('menu.select_all')}
|
||||
<Shortcut key="A" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
{/if}
|
||||
<ContextMenu.Item on:click={centerMapOnSelection}>
|
||||
<Maximize size="16" class="mr-1" />
|
||||
{$_('menu.center')}
|
||||
<Shortcut key="⏎" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item on:click={dbUtils.duplicateSelection}>
|
||||
<Copy size="16" class="mr-1" />
|
||||
{$_('menu.duplicate')}
|
||||
<Shortcut key="D" ctrl={true} /></ContextMenu.Item
|
||||
>
|
||||
{#if orientation === 'vertical'}
|
||||
<ContextMenu.Item on:click={copySelection}>
|
||||
<ClipboardCopy size="16" class="mr-1" />
|
||||
{$_('menu.copy')}
|
||||
<Shortcut key="C" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item on:click={cutSelection}>
|
||||
<Scissors size="16" class="mr-1" />
|
||||
{$_('menu.cut')}
|
||||
<Shortcut key="X" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
disabled={$copied === undefined ||
|
||||
$copied.length === 0 ||
|
||||
!allowedPastes[$copied[0].level].includes(item.level)}
|
||||
on:click={pasteSelection}
|
||||
>
|
||||
<ClipboardPaste size="16" class="mr-1" />
|
||||
{$_('menu.paste')}
|
||||
<Shortcut key="V" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
{/if}
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item on:click={dbUtils.deleteSelection}>
|
||||
{#if item instanceof ListFileItem}
|
||||
<FileX size="16" class="mr-1" />
|
||||
{$_('menu.close')}
|
||||
{:else}
|
||||
<Trash2 size="16" class="mr-1" />
|
||||
{$_('menu.delete')}
|
||||
{/if}
|
||||
<Shortcut key="⌫" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
<ContextMenu.Trigger class="grow truncate">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="relative w-full p-0 px-1 border-none overflow-hidden focus-visible:ring-0 focus-visible:ring-offset-0 {orientation ===
|
||||
'vertical'
|
||||
? 'h-fit'
|
||||
: 'h-9 px-1.5 shadow-md'} pointer-events-auto"
|
||||
>
|
||||
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
|
||||
<MetadataDialog bind:open={openEditMetadata} {node} {item} />
|
||||
<StyleDialog bind:open={openEditStyle} {item} />
|
||||
{/if}
|
||||
{#if item.level === ListLevel.FILE || item.level === ListLevel.TRACK}
|
||||
<div
|
||||
class="absolute {orientation === 'vertical'
|
||||
? 'top-0 bottom-0 right-1 w-1'
|
||||
: 'top-0 h-1 left-0 right-0'}"
|
||||
style="background:linear-gradient(to {orientation === 'vertical'
|
||||
? 'bottom'
|
||||
: 'right'},{nodeColors
|
||||
.map(
|
||||
(c, i) =>
|
||||
`${c} ${Math.floor((100 * i) / nodeColors.length)}% ${Math.floor((100 * (i + 1)) / nodeColors.length)}%`
|
||||
)
|
||||
.join(',')})"
|
||||
/>
|
||||
{/if}
|
||||
<span
|
||||
class="w-full text-left truncate py-1 flex flex-row items-center {hidden
|
||||
? 'text-muted-foreground'
|
||||
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId())
|
||||
? 'text-muted-foreground'
|
||||
: ''}"
|
||||
on:contextmenu={(e) => {
|
||||
if ($embedding) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (e.ctrlKey) {
|
||||
// Add to selection instead of opening context menu
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
$selection.toggle(item);
|
||||
$selection = $selection;
|
||||
}
|
||||
}}
|
||||
on:mouseenter={() => {
|
||||
if (item instanceof ListWaypointItem) {
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
let file = getFile(item.getFileId());
|
||||
if (layer && file) {
|
||||
let waypoint = file.wpt[item.getWaypointIndex()];
|
||||
if (waypoint) {
|
||||
waypointPopup?.setItem({
|
||||
item: waypoint,
|
||||
fileId: item.getFileId(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
if (item instanceof ListWaypointItem) {
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
if (layer) {
|
||||
waypointPopup?.setItem(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if item.level === ListLevel.SEGMENT}
|
||||
<Waypoints size="16" class="mr-1 shrink-0" />
|
||||
{:else if item.level === ListLevel.WAYPOINT}
|
||||
{#if symbolKey && symbols[symbolKey].icon}
|
||||
<svelte:component
|
||||
this={symbols[symbolKey].icon}
|
||||
size="16"
|
||||
class="mr-1 shrink-0"
|
||||
/>
|
||||
{:else}
|
||||
<MapPin size="16" class="mr-1 shrink-0" />
|
||||
{/if}
|
||||
{/if}
|
||||
<span
|
||||
class="grow select-none truncate {orientation === 'vertical'
|
||||
? 'last:mr-2'
|
||||
: ''}"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{#if hidden}
|
||||
<EyeOff
|
||||
size="12"
|
||||
class="shrink-0 mt-1 ml-1 {orientation === 'vertical'
|
||||
? 'mr-2'
|
||||
: ''} {item.level === ListLevel.SEGMENT ||
|
||||
item.level === ListLevel.WAYPOINT
|
||||
? 'mr-3'
|
||||
: ''}"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
</Button>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content>
|
||||
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
|
||||
<ContextMenu.Item disabled={!singleSelection} on:click={() => ($editMetadata = true)}>
|
||||
<Info size="16" class="mr-1" />
|
||||
{$_('menu.metadata.button')}
|
||||
<Shortcut key="I" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item on:click={() => ($editStyle = true)}>
|
||||
<PaintBucket size="16" class="mr-1" />
|
||||
{$_('menu.style.button')}
|
||||
</ContextMenu.Item>
|
||||
{/if}
|
||||
<ContextMenu.Item
|
||||
on:click={() => {
|
||||
if ($allHidden) {
|
||||
dbUtils.setHiddenToSelection(false);
|
||||
} else {
|
||||
dbUtils.setHiddenToSelection(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if $allHidden}
|
||||
<Eye size="16" class="mr-1" />
|
||||
{$_('menu.unhide')}
|
||||
{:else}
|
||||
<EyeOff size="16" class="mr-1" />
|
||||
{$_('menu.hide')}
|
||||
{/if}
|
||||
<Shortcut key="H" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
{#if orientation === 'vertical'}
|
||||
{#if item instanceof ListFileItem}
|
||||
<ContextMenu.Item
|
||||
disabled={!singleSelection}
|
||||
on:click={() => dbUtils.addNewTrack(item.getFileId())}
|
||||
>
|
||||
<Plus size="16" class="mr-1" />
|
||||
{$_('menu.new_track')}
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
{:else if item instanceof ListTrackItem}
|
||||
<ContextMenu.Item
|
||||
disabled={!singleSelection}
|
||||
on:click={() => dbUtils.addNewSegment(item.getFileId(), item.getTrackIndex())}
|
||||
>
|
||||
<Plus size="16" class="mr-1" />
|
||||
{$_('menu.new_segment')}
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
{/if}
|
||||
{/if}
|
||||
{#if item.level !== ListLevel.WAYPOINTS}
|
||||
<ContextMenu.Item on:click={selectAll}>
|
||||
<FileStack size="16" class="mr-1" />
|
||||
{$_('menu.select_all')}
|
||||
<Shortcut key="A" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
{/if}
|
||||
<ContextMenu.Item on:click={centerMapOnSelection}>
|
||||
<Maximize size="16" class="mr-1" />
|
||||
{$_('menu.center')}
|
||||
<Shortcut key="⏎" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item on:click={dbUtils.duplicateSelection}>
|
||||
<Copy size="16" class="mr-1" />
|
||||
{$_('menu.duplicate')}
|
||||
<Shortcut key="D" ctrl={true} /></ContextMenu.Item
|
||||
>
|
||||
{#if orientation === 'vertical'}
|
||||
<ContextMenu.Item on:click={copySelection}>
|
||||
<ClipboardCopy size="16" class="mr-1" />
|
||||
{$_('menu.copy')}
|
||||
<Shortcut key="C" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item on:click={cutSelection}>
|
||||
<Scissors size="16" class="mr-1" />
|
||||
{$_('menu.cut')}
|
||||
<Shortcut key="X" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
disabled={$copied === undefined ||
|
||||
$copied.length === 0 ||
|
||||
!allowedPastes[$copied[0].level].includes(item.level)}
|
||||
on:click={pasteSelection}
|
||||
>
|
||||
<ClipboardPaste size="16" class="mr-1" />
|
||||
{$_('menu.paste')}
|
||||
<Shortcut key="V" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
{/if}
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item on:click={dbUtils.deleteSelection}>
|
||||
{#if item instanceof ListFileItem}
|
||||
<FileX size="16" class="mr-1" />
|
||||
{$_('menu.close')}
|
||||
{:else}
|
||||
<Trash2 size="16" class="mr-1" />
|
||||
{$_('menu.delete')}
|
||||
{/if}
|
||||
<Shortcut key="⌫" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
<script lang="ts">
|
||||
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
|
||||
import FileListNode from '$lib/components/file-list/FileListNode.svelte';
|
||||
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
|
||||
import FileListNode from '$lib/components/file-list/FileListNode.svelte';
|
||||
|
||||
import type { GPXFileWithStatistics } from '$lib/db';
|
||||
import { getContext } from 'svelte';
|
||||
import type { Readable } from 'svelte/store';
|
||||
import { ListFileItem } from './FileList';
|
||||
import type { GPXFileWithStatistics } from '$lib/db';
|
||||
import { getContext } from 'svelte';
|
||||
import type { Readable } from 'svelte/store';
|
||||
import { ListFileItem } from './FileList';
|
||||
|
||||
export let file: Readable<GPXFileWithStatistics | undefined>;
|
||||
export let file: Readable<GPXFileWithStatistics | undefined>;
|
||||
|
||||
let recursive = getContext<boolean>('recursive');
|
||||
let recursive = getContext<boolean>('recursive');
|
||||
</script>
|
||||
|
||||
{#if $file}
|
||||
{#if recursive}
|
||||
<CollapsibleTree side="left" defaultState="closed" slotInsideTrigger={false}>
|
||||
<FileListNode node={$file.file} item={new ListFileItem($file.file._data.id)} />
|
||||
</CollapsibleTree>
|
||||
{:else}
|
||||
<FileListNode node={$file.file} item={new ListFileItem($file.file._data.id)} />
|
||||
{/if}
|
||||
{#if recursive}
|
||||
<CollapsibleTree side="left" defaultState="closed" slotInsideTrigger={false}>
|
||||
<FileListNode node={$file.file} item={new ListFileItem($file.file._data.id)} />
|
||||
</CollapsibleTree>
|
||||
{:else}
|
||||
<FileListNode node={$file.file} item={new ListFileItem($file.file._data.id)} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -17,15 +17,15 @@
|
||||
|
||||
let name: string =
|
||||
node instanceof GPXFile
|
||||
? node.metadata.name ?? ''
|
||||
? (node.metadata.name ?? '')
|
||||
: node instanceof Track
|
||||
? node.name ?? ''
|
||||
? (node.name ?? '')
|
||||
: '';
|
||||
let description: string =
|
||||
node instanceof GPXFile
|
||||
? node.metadata.desc ?? ''
|
||||
? (node.metadata.desc ?? '')
|
||||
: node instanceof Track
|
||||
? node.desc ?? ''
|
||||
? (node.desc ?? '')
|
||||
: '';
|
||||
|
||||
$: if (!open) {
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { get, writable } from "svelte/store";
|
||||
import { ListFileItem, ListItem, ListRootItem, ListTrackItem, ListTrackSegmentItem, ListWaypointItem, ListLevel, sortItems, ListWaypointsItem, moveItems } from "./FileList";
|
||||
import { fileObservers, getFile, getFileIds, settings } from "$lib/db";
|
||||
import { get, writable } from 'svelte/store';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListItem,
|
||||
ListRootItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem,
|
||||
ListWaypointItem,
|
||||
ListLevel,
|
||||
sortItems,
|
||||
ListWaypointsItem,
|
||||
moveItems,
|
||||
} from './FileList';
|
||||
import { fileObservers, getFile, getFileIds, settings } from '$lib/db';
|
||||
|
||||
export class SelectionTreeType {
|
||||
item: ListItem;
|
||||
selected: boolean;
|
||||
children: {
|
||||
[key: string | number]: SelectionTreeType
|
||||
[key: string | number]: SelectionTreeType;
|
||||
};
|
||||
size: number = 0;
|
||||
|
||||
@@ -67,7 +78,11 @@ export class SelectionTreeType {
|
||||
}
|
||||
|
||||
hasAnyParent(item: ListItem, self: boolean = true): boolean {
|
||||
if (this.selected && this.item.level <= item.level && (self || this.item.level < item.level)) {
|
||||
if (
|
||||
this.selected &&
|
||||
this.item.level <= item.level &&
|
||||
(self || this.item.level < item.level)
|
||||
) {
|
||||
return this.selected;
|
||||
}
|
||||
let id = item.getIdAtLevel(this.item.level);
|
||||
@@ -80,7 +95,11 @@ export class SelectionTreeType {
|
||||
}
|
||||
|
||||
hasAnyChildren(item: ListItem, self: boolean = true, ignoreIds?: (string | number)[]): boolean {
|
||||
if (this.selected && this.item.level >= item.level && (self || this.item.level > item.level)) {
|
||||
if (
|
||||
this.selected &&
|
||||
this.item.level >= item.level &&
|
||||
(self || this.item.level > item.level)
|
||||
) {
|
||||
return this.selected;
|
||||
}
|
||||
let id = item.getIdAtLevel(this.item.level);
|
||||
@@ -131,7 +150,7 @@ export class SelectionTreeType {
|
||||
delete this.children[id];
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const selection = writable<SelectionTreeType>(new SelectionTreeType(new ListRootItem()));
|
||||
|
||||
@@ -181,7 +200,10 @@ export function selectAll() {
|
||||
let file = getFile(item.getFileId());
|
||||
if (file) {
|
||||
file.trk[item.getTrackIndex()].trkseg.forEach((_segment, segmentId) => {
|
||||
$selection.set(new ListTrackSegmentItem(item.getFileId(), item.getTrackIndex(), segmentId), true);
|
||||
$selection.set(
|
||||
new ListTrackSegmentItem(item.getFileId(), item.getTrackIndex(), segmentId),
|
||||
true
|
||||
);
|
||||
});
|
||||
}
|
||||
} else if (item instanceof ListWaypointItem) {
|
||||
@@ -205,14 +227,24 @@ export function getOrderedSelection(reverse: boolean = false): ListItem[] {
|
||||
return selected;
|
||||
}
|
||||
|
||||
export function applyToOrderedItemsFromFile(selectedItems: ListItem[], callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void, reverse: boolean = true) {
|
||||
export function applyToOrderedItemsFromFile(
|
||||
selectedItems: ListItem[],
|
||||
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
|
||||
reverse: boolean = true
|
||||
) {
|
||||
get(settings.fileOrder).forEach((fileId) => {
|
||||
let level: ListLevel | undefined = undefined;
|
||||
let items: ListItem[] = [];
|
||||
selectedItems.forEach((item) => {
|
||||
if (item.getFileId() === fileId) {
|
||||
level = item.level;
|
||||
if (item instanceof ListFileItem || item instanceof ListTrackItem || item instanceof ListTrackSegmentItem || item instanceof ListWaypointsItem || item instanceof ListWaypointItem) {
|
||||
if (
|
||||
item instanceof ListFileItem ||
|
||||
item instanceof ListTrackItem ||
|
||||
item instanceof ListTrackSegmentItem ||
|
||||
item instanceof ListWaypointsItem ||
|
||||
item instanceof ListWaypointItem
|
||||
) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
@@ -225,7 +257,10 @@ export function applyToOrderedItemsFromFile(selectedItems: ListItem[], callback:
|
||||
});
|
||||
}
|
||||
|
||||
export function applyToOrderedSelectedItemsFromFile(callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void, reverse: boolean = true) {
|
||||
export function applyToOrderedSelectedItemsFromFile(
|
||||
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
|
||||
reverse: boolean = true
|
||||
) {
|
||||
applyToOrderedItemsFromFile(get(selection).getSelected(), callback, reverse);
|
||||
}
|
||||
|
||||
@@ -270,7 +305,11 @@ export function pasteSelection() {
|
||||
let startIndex: number | undefined = undefined;
|
||||
|
||||
if (fromItems[0].level === toParent.level) {
|
||||
if (toParent instanceof ListTrackItem || toParent instanceof ListTrackSegmentItem || toParent instanceof ListWaypointItem) {
|
||||
if (
|
||||
toParent instanceof ListTrackItem ||
|
||||
toParent instanceof ListTrackSegmentItem ||
|
||||
toParent instanceof ListWaypointItem
|
||||
) {
|
||||
startIndex = toParent.getId() + 1;
|
||||
}
|
||||
toParent = toParent.getParent();
|
||||
@@ -288,20 +327,41 @@ export function pasteSelection() {
|
||||
fromItems.forEach((item, index) => {
|
||||
if (toParent instanceof ListFileItem) {
|
||||
if (item instanceof ListTrackItem || item instanceof ListTrackSegmentItem) {
|
||||
toItems.push(new ListTrackItem(toParent.getFileId(), (startIndex ?? toFile.trk.length) + index));
|
||||
toItems.push(
|
||||
new ListTrackItem(
|
||||
toParent.getFileId(),
|
||||
(startIndex ?? toFile.trk.length) + index
|
||||
)
|
||||
);
|
||||
} else if (item instanceof ListWaypointsItem) {
|
||||
toItems.push(new ListWaypointsItem(toParent.getFileId()));
|
||||
} else if (item instanceof ListWaypointItem) {
|
||||
toItems.push(new ListWaypointItem(toParent.getFileId(), (startIndex ?? toFile.wpt.length) + index));
|
||||
toItems.push(
|
||||
new ListWaypointItem(
|
||||
toParent.getFileId(),
|
||||
(startIndex ?? toFile.wpt.length) + index
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (toParent instanceof ListTrackItem) {
|
||||
if (item instanceof ListTrackSegmentItem) {
|
||||
let toTrackIndex = toParent.getTrackIndex();
|
||||
toItems.push(new ListTrackSegmentItem(toParent.getFileId(), toTrackIndex, (startIndex ?? toFile.trk[toTrackIndex].trkseg.length) + index));
|
||||
toItems.push(
|
||||
new ListTrackSegmentItem(
|
||||
toParent.getFileId(),
|
||||
toTrackIndex,
|
||||
(startIndex ?? toFile.trk[toTrackIndex].trkseg.length) + index
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (toParent instanceof ListWaypointsItem) {
|
||||
if (item instanceof ListWaypointItem) {
|
||||
toItems.push(new ListWaypointItem(toParent.getFileId(), (startIndex ?? toFile.wpt.length) + index));
|
||||
toItems.push(
|
||||
new ListWaypointItem(
|
||||
toParent.getFileId(),
|
||||
(startIndex ?? toFile.wpt.length) + index
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -312,4 +372,4 @@ export function pasteSelection() {
|
||||
moveItems(fromParent, toParent, fromItems, toItems, get(cut));
|
||||
resetCopied();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,167 +1,173 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Slider } from '$lib/components/ui/slider';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { dbUtils, getFile, settings } from '$lib/db';
|
||||
import { Save } from 'lucide-svelte';
|
||||
import { ListFileItem, ListTrackItem, type ListItem } from './FileList';
|
||||
import { selection } from './Selection';
|
||||
import { editStyle, gpxLayers } from '$lib/stores';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Slider } from '$lib/components/ui/slider';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { dbUtils, getFile, settings } from '$lib/db';
|
||||
import { Save } from 'lucide-svelte';
|
||||
import { ListFileItem, ListTrackItem, type ListItem } from './FileList';
|
||||
import { selection } from './Selection';
|
||||
import { editStyle, gpxLayers } from '$lib/stores';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let item: ListItem;
|
||||
export let open = false;
|
||||
export let item: ListItem;
|
||||
export let open = false;
|
||||
|
||||
const { defaultOpacity, defaultWidth } = settings;
|
||||
const { defaultOpacity, defaultWidth } = settings;
|
||||
|
||||
let colors: string[] = [];
|
||||
let color: string | undefined = undefined;
|
||||
let opacity: number[] = [];
|
||||
let width: number[] = [];
|
||||
let colorChanged = false;
|
||||
let opacityChanged = false;
|
||||
let widthChanged = false;
|
||||
let colors: string[] = [];
|
||||
let color: string | undefined = undefined;
|
||||
let opacity: number[] = [];
|
||||
let width: number[] = [];
|
||||
let colorChanged = false;
|
||||
let opacityChanged = false;
|
||||
let widthChanged = false;
|
||||
|
||||
function setStyleInputs() {
|
||||
colors = [];
|
||||
opacity = [];
|
||||
width = [];
|
||||
function setStyleInputs() {
|
||||
colors = [];
|
||||
opacity = [];
|
||||
width = [];
|
||||
|
||||
$selection.forEach((item) => {
|
||||
if (item instanceof ListFileItem) {
|
||||
let file = getFile(item.getFileId());
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
if (file && layer) {
|
||||
let style = file.getStyle();
|
||||
style.color.push(layer.layerColor);
|
||||
$selection.forEach((item) => {
|
||||
if (item instanceof ListFileItem) {
|
||||
let file = getFile(item.getFileId());
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
if (file && layer) {
|
||||
let style = file.getStyle();
|
||||
style.color.push(layer.layerColor);
|
||||
|
||||
style.color.forEach((c) => {
|
||||
if (!colors.includes(c)) {
|
||||
colors.push(c);
|
||||
}
|
||||
});
|
||||
style.opacity.forEach((o) => {
|
||||
if (!opacity.includes(o)) {
|
||||
opacity.push(o);
|
||||
}
|
||||
});
|
||||
style.width.forEach((w) => {
|
||||
if (!width.includes(w)) {
|
||||
width.push(w);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (item instanceof ListTrackItem) {
|
||||
let file = getFile(item.getFileId());
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
if (file && layer) {
|
||||
let track = file.trk[item.getTrackIndex()];
|
||||
let style = track.getStyle();
|
||||
if (style) {
|
||||
if (style['gpx_style:color'] && !colors.includes(style['gpx_style:color'])) {
|
||||
colors.push(style['gpx_style:color']);
|
||||
}
|
||||
if (style['gpx_style:opacity'] && !opacity.includes(style['gpx_style:opacity'])) {
|
||||
opacity.push(style['gpx_style:opacity']);
|
||||
}
|
||||
if (style['gpx_style:width'] && !width.includes(style['gpx_style:width'])) {
|
||||
width.push(style['gpx_style:width']);
|
||||
}
|
||||
}
|
||||
if (!colors.includes(layer.layerColor)) {
|
||||
colors.push(layer.layerColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
style.color.forEach((c) => {
|
||||
if (!colors.includes(c)) {
|
||||
colors.push(c);
|
||||
}
|
||||
});
|
||||
style.opacity.forEach((o) => {
|
||||
if (!opacity.includes(o)) {
|
||||
opacity.push(o);
|
||||
}
|
||||
});
|
||||
style.width.forEach((w) => {
|
||||
if (!width.includes(w)) {
|
||||
width.push(w);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (item instanceof ListTrackItem) {
|
||||
let file = getFile(item.getFileId());
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
if (file && layer) {
|
||||
let track = file.trk[item.getTrackIndex()];
|
||||
let style = track.getStyle();
|
||||
if (style) {
|
||||
if (
|
||||
style['gpx_style:color'] &&
|
||||
!colors.includes(style['gpx_style:color'])
|
||||
) {
|
||||
colors.push(style['gpx_style:color']);
|
||||
}
|
||||
if (
|
||||
style['gpx_style:opacity'] &&
|
||||
!opacity.includes(style['gpx_style:opacity'])
|
||||
) {
|
||||
opacity.push(style['gpx_style:opacity']);
|
||||
}
|
||||
if (style['gpx_style:width'] && !width.includes(style['gpx_style:width'])) {
|
||||
width.push(style['gpx_style:width']);
|
||||
}
|
||||
}
|
||||
if (!colors.includes(layer.layerColor)) {
|
||||
colors.push(layer.layerColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
color = colors[0];
|
||||
opacity = [opacity[0] ?? $defaultOpacity];
|
||||
width = [width[0] ?? $defaultWidth];
|
||||
color = colors[0];
|
||||
opacity = [opacity[0] ?? $defaultOpacity];
|
||||
width = [width[0] ?? $defaultWidth];
|
||||
|
||||
colorChanged = false;
|
||||
opacityChanged = false;
|
||||
widthChanged = false;
|
||||
}
|
||||
colorChanged = false;
|
||||
opacityChanged = false;
|
||||
widthChanged = false;
|
||||
}
|
||||
|
||||
$: if ($selection && open) {
|
||||
setStyleInputs();
|
||||
}
|
||||
$: if ($selection && open) {
|
||||
setStyleInputs();
|
||||
}
|
||||
|
||||
$: if (!open) {
|
||||
$editStyle = false;
|
||||
}
|
||||
$: if (!open) {
|
||||
$editStyle = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger />
|
||||
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
|
||||
<Label class="flex flex-row gap-2 items-center justify-between">
|
||||
{$_('menu.style.color')}
|
||||
<Input
|
||||
bind:value={color}
|
||||
type="color"
|
||||
class="p-0 h-6 w-40"
|
||||
on:change={() => (colorChanged = true)}
|
||||
/>
|
||||
</Label>
|
||||
<Label class="flex flex-row gap-2 items-center justify-between">
|
||||
{$_('menu.style.opacity')}
|
||||
<div class="w-40 p-2">
|
||||
<Slider
|
||||
bind:value={opacity}
|
||||
min={0.3}
|
||||
max={1}
|
||||
step={0.1}
|
||||
onValueChange={() => (opacityChanged = true)}
|
||||
/>
|
||||
</div>
|
||||
</Label>
|
||||
<Label class="flex flex-row gap-2 items-center justify-between">
|
||||
{$_('menu.style.width')}
|
||||
<div class="w-40 p-2">
|
||||
<Slider
|
||||
bind:value={width}
|
||||
id="width"
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
onValueChange={() => (widthChanged = true)}
|
||||
/>
|
||||
</div>
|
||||
</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!colorChanged && !opacityChanged && !widthChanged}
|
||||
on:click={() => {
|
||||
let style = {};
|
||||
if (colorChanged) {
|
||||
style['gpx_style:color'] = color;
|
||||
}
|
||||
if (opacityChanged) {
|
||||
style['gpx_style:opacity'] = opacity[0];
|
||||
}
|
||||
if (widthChanged) {
|
||||
style['gpx_style:width'] = width[0];
|
||||
}
|
||||
dbUtils.setStyleToSelection(style);
|
||||
<Popover.Trigger />
|
||||
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
|
||||
<Label class="flex flex-row gap-2 items-center justify-between">
|
||||
{$_('menu.style.color')}
|
||||
<Input
|
||||
bind:value={color}
|
||||
type="color"
|
||||
class="p-0 h-6 w-40"
|
||||
on:change={() => (colorChanged = true)}
|
||||
/>
|
||||
</Label>
|
||||
<Label class="flex flex-row gap-2 items-center justify-between">
|
||||
{$_('menu.style.opacity')}
|
||||
<div class="w-40 p-2">
|
||||
<Slider
|
||||
bind:value={opacity}
|
||||
min={0.3}
|
||||
max={1}
|
||||
step={0.1}
|
||||
onValueChange={() => (opacityChanged = true)}
|
||||
/>
|
||||
</div>
|
||||
</Label>
|
||||
<Label class="flex flex-row gap-2 items-center justify-between">
|
||||
{$_('menu.style.width')}
|
||||
<div class="w-40 p-2">
|
||||
<Slider
|
||||
bind:value={width}
|
||||
id="width"
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
onValueChange={() => (widthChanged = true)}
|
||||
/>
|
||||
</div>
|
||||
</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!colorChanged && !opacityChanged && !widthChanged}
|
||||
on:click={() => {
|
||||
let style = {};
|
||||
if (colorChanged) {
|
||||
style['gpx_style:color'] = color;
|
||||
}
|
||||
if (opacityChanged) {
|
||||
style['gpx_style:opacity'] = opacity[0];
|
||||
}
|
||||
if (widthChanged) {
|
||||
style['gpx_style:width'] = width[0];
|
||||
}
|
||||
dbUtils.setStyleToSelection(style);
|
||||
|
||||
if (item instanceof ListFileItem && $selection.size === gpxLayers.size) {
|
||||
if (style['gpx_style:opacity']) {
|
||||
$defaultOpacity = style['gpx_style:opacity'];
|
||||
}
|
||||
if (style['gpx_style:width']) {
|
||||
$defaultWidth = style['gpx_style:width'];
|
||||
}
|
||||
}
|
||||
if (item instanceof ListFileItem && $selection.size === gpxLayers.size) {
|
||||
if (style['gpx_style:opacity']) {
|
||||
$defaultOpacity = style['gpx_style:opacity'];
|
||||
}
|
||||
if (style['gpx_style:width']) {
|
||||
$defaultWidth = style['gpx_style:width'];
|
||||
}
|
||||
}
|
||||
|
||||
open = false;
|
||||
}}
|
||||
>
|
||||
<Save size="16" class="mr-1" />
|
||||
{$_('menu.metadata.save')}
|
||||
</Button>
|
||||
</Popover.Content>
|
||||
open = false;
|
||||
}}
|
||||
>
|
||||
<Save size="16" class="mr-1" />
|
||||
{$_('menu.metadata.save')}
|
||||
</Button>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
|
||||
import { settings } from "$lib/db";
|
||||
import { gpxStatistics } from "$lib/stores";
|
||||
import { get } from "svelte/store";
|
||||
import { settings } from '$lib/db';
|
||||
import { gpxStatistics } from '$lib/stores';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
const { distanceMarkers, distanceUnits } = settings;
|
||||
|
||||
const stops = [[100, 0], [50, 7], [25, 8, 10], [10, 10], [5, 11], [1, 13]];
|
||||
const stops = [
|
||||
[100, 0],
|
||||
[50, 7],
|
||||
[25, 8, 10],
|
||||
[10, 10],
|
||||
[5, 11],
|
||||
[1, 13],
|
||||
];
|
||||
|
||||
export class DistanceMarkers {
|
||||
map: mapboxgl.Map;
|
||||
@@ -30,7 +36,7 @@ export class DistanceMarkers {
|
||||
} else {
|
||||
this.map.addSource('distance-markers', {
|
||||
type: 'geojson',
|
||||
data: this.getDistanceMarkersGeoJSON()
|
||||
data: this.getDistanceMarkersGeoJSON(),
|
||||
});
|
||||
}
|
||||
stops.forEach(([d, minzoom, maxzoom]) => {
|
||||
@@ -39,7 +45,14 @@ export class DistanceMarkers {
|
||||
id: `distance-markers-${d}`,
|
||||
type: 'symbol',
|
||||
source: 'distance-markers',
|
||||
filter: d === 5 ? ['any', ['==', ['get', 'level'], 5], ['==', ['get', 'level'], 25]] : ['==', ['get', 'level'], d],
|
||||
filter:
|
||||
d === 5
|
||||
? [
|
||||
'any',
|
||||
['==', ['get', 'level'], 5],
|
||||
['==', ['get', 'level'], 25],
|
||||
]
|
||||
: ['==', ['get', 'level'], d],
|
||||
minzoom: minzoom,
|
||||
maxzoom: maxzoom ?? 24,
|
||||
layout: {
|
||||
@@ -51,7 +64,7 @@ export class DistanceMarkers {
|
||||
'text-color': 'black',
|
||||
'text-halo-width': 2,
|
||||
'text-halo-color': 'white',
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.map.moveLayer(`distance-markers-${d}`);
|
||||
@@ -64,13 +77,14 @@ export class DistanceMarkers {
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) { // No reliable way to check if the map is ready to add sources and layers
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.unsubscribes.forEach(unsubscribe => unsubscribe());
|
||||
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
}
|
||||
|
||||
getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection {
|
||||
@@ -79,20 +93,28 @@ export class DistanceMarkers {
|
||||
let features = [];
|
||||
let currentTargetDistance = 1;
|
||||
for (let i = 0; i < statistics.local.distance.total.length; i++) {
|
||||
if (statistics.local.distance.total[i] >= currentTargetDistance * (get(distanceUnits) === 'metric' ? 1 : 1.60934)) {
|
||||
if (
|
||||
statistics.local.distance.total[i] >=
|
||||
currentTargetDistance * (get(distanceUnits) === 'metric' ? 1 : 1.60934)
|
||||
) {
|
||||
let distance = currentTargetDistance.toFixed(0);
|
||||
let [level, minzoom] = stops.find(([d]) => currentTargetDistance % d === 0) ?? [0, 0];
|
||||
let [level, minzoom] = stops.find(([d]) => currentTargetDistance % d === 0) ?? [
|
||||
0, 0,
|
||||
];
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [statistics.local.points[i].getLongitude(), statistics.local.points[i].getLatitude()]
|
||||
coordinates: [
|
||||
statistics.local.points[i].getLongitude(),
|
||||
statistics.local.points[i].getLatitude(),
|
||||
],
|
||||
},
|
||||
properties: {
|
||||
distance,
|
||||
level,
|
||||
minzoom,
|
||||
}
|
||||
},
|
||||
} as GeoJSON.Feature);
|
||||
currentTargetDistance += 1;
|
||||
}
|
||||
@@ -100,7 +122,7 @@ export class DistanceMarkers {
|
||||
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features
|
||||
features,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import { currentTool, map, Tool } from "$lib/stores";
|
||||
import { settings, type GPXFileWithStatistics, dbUtils } from "$lib/db";
|
||||
import { get, type Readable } from "svelte/store";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
import { waypointPopup, deleteWaypoint, trackpointPopup } from "./GPXLayerPopup";
|
||||
import { addSelectItem, selectItem, selection } from "$lib/components/file-list/Selection";
|
||||
import { ListTrackSegmentItem, ListWaypointItem, ListWaypointsItem, ListTrackItem, ListFileItem, ListRootItem } from "$lib/components/file-list/FileList";
|
||||
import { getClosestLinePoint, getElevation, resetCursor, setGrabbingCursor, setPointerCursor, setScissorsCursor } from "$lib/utils";
|
||||
import { selectedWaypoint } from "$lib/components/toolbar/tools/Waypoint.svelte";
|
||||
import { MapPin, Square } from "lucide-static";
|
||||
import { getSymbolKey, symbols } from "$lib/assets/symbols";
|
||||
import { currentTool, map, Tool } from '$lib/stores';
|
||||
import { settings, type GPXFileWithStatistics, dbUtils } from '$lib/db';
|
||||
import { get, type Readable } from 'svelte/store';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { waypointPopup, deleteWaypoint, trackpointPopup } from './GPXLayerPopup';
|
||||
import { addSelectItem, selectItem, selection } from '$lib/components/file-list/Selection';
|
||||
import {
|
||||
ListTrackSegmentItem,
|
||||
ListWaypointItem,
|
||||
ListWaypointsItem,
|
||||
ListTrackItem,
|
||||
ListFileItem,
|
||||
ListRootItem,
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import {
|
||||
getClosestLinePoint,
|
||||
getElevation,
|
||||
resetCursor,
|
||||
setGrabbingCursor,
|
||||
setPointerCursor,
|
||||
setScissorsCursor,
|
||||
} from '$lib/utils';
|
||||
import { selectedWaypoint } from '$lib/components/toolbar/tools/Waypoint.svelte';
|
||||
import { MapPin, Square } from 'lucide-static';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
|
||||
const colors = [
|
||||
'#ff0000',
|
||||
@@ -21,7 +35,7 @@ const colors = [
|
||||
'#288228',
|
||||
'#9933ff',
|
||||
'#50f0be',
|
||||
'#8c645a'
|
||||
'#8c645a',
|
||||
];
|
||||
|
||||
const colorCount: { [key: string]: number } = {};
|
||||
@@ -56,12 +70,12 @@ class KeyDown {
|
||||
if (e.key === this.key) {
|
||||
this.down = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === this.key) {
|
||||
this.down = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
isDown() {
|
||||
return this.down;
|
||||
}
|
||||
@@ -70,22 +84,26 @@ class KeyDown {
|
||||
function getMarkerForSymbol(symbol: string | undefined, layerColor: string) {
|
||||
let symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
${Square
|
||||
.replace('width="24"', 'width="12"')
|
||||
.replace('height="24"', 'height="12"')
|
||||
.replace('stroke="currentColor"', 'stroke="SteelBlue"')
|
||||
.replace('stroke-width="2"', 'stroke-width="1.5" x="9.6" y="0.4"')
|
||||
.replace('fill="none"', `fill="${layerColor}"`)}
|
||||
${MapPin
|
||||
.replace('width="24"', '')
|
||||
.replace('height="24"', '')
|
||||
.replace('stroke="currentColor"', '')
|
||||
.replace('path', `path fill="#3fb1ce" stroke="SteelBlue" stroke-width="1"`)
|
||||
.replace('circle', `circle fill="${symbolSvg ? 'none' : 'white'}" stroke="${symbolSvg ? 'none' : 'white'}" stroke-width="2"`)}
|
||||
${symbolSvg?.replace('width="24"', 'width="10"')
|
||||
${Square.replace('width="24"', 'width="12"')
|
||||
.replace('height="24"', 'height="12"')
|
||||
.replace('stroke="currentColor"', 'stroke="SteelBlue"')
|
||||
.replace('stroke-width="2"', 'stroke-width="1.5" x="9.6" y="0.4"')
|
||||
.replace('fill="none"', `fill="${layerColor}"`)}
|
||||
${MapPin.replace('width="24"', '')
|
||||
.replace('height="24"', '')
|
||||
.replace('stroke="currentColor"', '')
|
||||
.replace('path', `path fill="#3fb1ce" stroke="SteelBlue" stroke-width="1"`)
|
||||
.replace(
|
||||
'circle',
|
||||
`circle fill="${symbolSvg ? 'none' : 'white'}" stroke="${symbolSvg ? 'none' : 'white'}" stroke-width="2"`
|
||||
)}
|
||||
${
|
||||
symbolSvg
|
||||
?.replace('width="24"', 'width="10"')
|
||||
.replace('height="24"', 'height="10"')
|
||||
.replace('stroke="currentColor"', 'stroke="white"')
|
||||
.replace('stroke-width="2"', 'stroke-width="2.5" x="7" y="5"') ?? ''}
|
||||
.replace('stroke-width="2"', 'stroke-width="2.5" x="7" y="5"') ?? ''
|
||||
}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
@@ -108,32 +126,40 @@ export class GPXLayer {
|
||||
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
|
||||
layerOnContextMenuBinded: (e: any) => void = this.layerOnContextMenu.bind(this);
|
||||
|
||||
constructor(map: mapboxgl.Map, fileId: string, file: Readable<GPXFileWithStatistics | undefined>) {
|
||||
constructor(
|
||||
map: mapboxgl.Map,
|
||||
fileId: string,
|
||||
file: Readable<GPXFileWithStatistics | undefined>
|
||||
) {
|
||||
this.map = map;
|
||||
this.fileId = fileId;
|
||||
this.file = file;
|
||||
this.layerColor = getColor();
|
||||
this.unsubscribe.push(file.subscribe(this.updateBinded));
|
||||
this.unsubscribe.push(selection.subscribe($selection => {
|
||||
let newSelected = $selection.hasAnyChildren(new ListFileItem(this.fileId));
|
||||
if (this.selected || newSelected) {
|
||||
this.selected = newSelected;
|
||||
this.update();
|
||||
}
|
||||
if (newSelected) {
|
||||
this.moveToFront();
|
||||
}
|
||||
}));
|
||||
this.unsubscribe.push(
|
||||
selection.subscribe(($selection) => {
|
||||
let newSelected = $selection.hasAnyChildren(new ListFileItem(this.fileId));
|
||||
if (this.selected || newSelected) {
|
||||
this.selected = newSelected;
|
||||
this.update();
|
||||
}
|
||||
if (newSelected) {
|
||||
this.moveToFront();
|
||||
}
|
||||
})
|
||||
);
|
||||
this.unsubscribe.push(directionMarkers.subscribe(this.updateBinded));
|
||||
this.unsubscribe.push(currentTool.subscribe(tool => {
|
||||
if (tool === Tool.WAYPOINT && !this.draggable) {
|
||||
this.draggable = true;
|
||||
this.markers.forEach(marker => marker.setDraggable(true));
|
||||
} else if (tool !== Tool.WAYPOINT && this.draggable) {
|
||||
this.draggable = false;
|
||||
this.markers.forEach(marker => marker.setDraggable(false));
|
||||
}
|
||||
}));
|
||||
this.unsubscribe.push(
|
||||
currentTool.subscribe((tool) => {
|
||||
if (tool === Tool.WAYPOINT && !this.draggable) {
|
||||
this.draggable = true;
|
||||
this.markers.forEach((marker) => marker.setDraggable(true));
|
||||
} else if (tool !== Tool.WAYPOINT && this.draggable) {
|
||||
this.draggable = false;
|
||||
this.markers.forEach((marker) => marker.setDraggable(false));
|
||||
}
|
||||
})
|
||||
);
|
||||
this.draggable = get(currentTool) === Tool.WAYPOINT;
|
||||
|
||||
this.map.on('style.import.load', this.updateBinded);
|
||||
@@ -149,7 +175,11 @@ export class GPXLayer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (file._data.style && file._data.style.color && this.layerColor !== `#${file._data.style.color}`) {
|
||||
if (
|
||||
file._data.style &&
|
||||
file._data.style.color &&
|
||||
this.layerColor !== `#${file._data.style.color}`
|
||||
) {
|
||||
decrementColor(this.layerColor);
|
||||
this.layerColor = `#${file._data.style.color}`;
|
||||
}
|
||||
@@ -161,7 +191,7 @@ export class GPXLayer {
|
||||
} else {
|
||||
this.map.addSource(this.fileId, {
|
||||
type: 'geojson',
|
||||
data: this.getGeoJSON()
|
||||
data: this.getGeoJSON(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -172,13 +202,13 @@ export class GPXLayer {
|
||||
source: this.fileId,
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round'
|
||||
'line-cap': 'round',
|
||||
},
|
||||
paint: {
|
||||
'line-color': ['get', 'color'],
|
||||
'line-width': ['get', 'width'],
|
||||
'line-opacity': ['get', 'opacity']
|
||||
}
|
||||
'line-opacity': ['get', 'opacity'],
|
||||
},
|
||||
});
|
||||
|
||||
this.map.on('click', this.fileId, this.layerOnClickBinded);
|
||||
@@ -190,27 +220,30 @@ export class GPXLayer {
|
||||
|
||||
if (get(directionMarkers)) {
|
||||
if (!this.map.getLayer(this.fileId + '-direction')) {
|
||||
this.map.addLayer({
|
||||
id: this.fileId + '-direction',
|
||||
type: 'symbol',
|
||||
source: this.fileId,
|
||||
layout: {
|
||||
'text-field': '»',
|
||||
'text-offset': [0, -0.1],
|
||||
'text-keep-upright': false,
|
||||
'text-max-angle': 361,
|
||||
'text-allow-overlap': true,
|
||||
'text-font': ['Open Sans Bold'],
|
||||
'symbol-placement': 'line',
|
||||
'symbol-spacing': 20,
|
||||
this.map.addLayer(
|
||||
{
|
||||
id: this.fileId + '-direction',
|
||||
type: 'symbol',
|
||||
source: this.fileId,
|
||||
layout: {
|
||||
'text-field': '»',
|
||||
'text-offset': [0, -0.1],
|
||||
'text-keep-upright': false,
|
||||
'text-max-angle': 361,
|
||||
'text-allow-overlap': true,
|
||||
'text-font': ['Open Sans Bold'],
|
||||
'symbol-placement': 'line',
|
||||
'symbol-spacing': 20,
|
||||
},
|
||||
paint: {
|
||||
'text-color': 'white',
|
||||
'text-opacity': 0.7,
|
||||
'text-halo-width': 0.2,
|
||||
'text-halo-color': 'white',
|
||||
},
|
||||
},
|
||||
paint: {
|
||||
'text-color': 'white',
|
||||
'text-opacity': 0.7,
|
||||
'text-halo-width': 0.2,
|
||||
'text-halo-color': 'white'
|
||||
}
|
||||
}, this.map.getLayer('distance-markers') ? 'distance-markers' : undefined);
|
||||
this.map.getLayer('distance-markers') ? 'distance-markers' : undefined
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (this.map.getLayer(this.fileId + '-direction')) {
|
||||
@@ -225,23 +258,53 @@ export class GPXLayer {
|
||||
}
|
||||
});
|
||||
|
||||
this.map.setFilter(this.fileId, ['any', ...visibleItems.map(([trackIndex, segmentIndex]) => ['all', ['==', 'trackIndex', trackIndex], ['==', 'segmentIndex', segmentIndex]])], { validate: false });
|
||||
this.map.setFilter(
|
||||
this.fileId,
|
||||
[
|
||||
'any',
|
||||
...visibleItems.map(([trackIndex, segmentIndex]) => [
|
||||
'all',
|
||||
['==', 'trackIndex', trackIndex],
|
||||
['==', 'segmentIndex', segmentIndex],
|
||||
]),
|
||||
],
|
||||
{ validate: false }
|
||||
);
|
||||
if (this.map.getLayer(this.fileId + '-direction')) {
|
||||
this.map.setFilter(this.fileId + '-direction', ['any', ...visibleItems.map(([trackIndex, segmentIndex]) => ['all', ['==', 'trackIndex', trackIndex], ['==', 'segmentIndex', segmentIndex]])], { validate: false });
|
||||
this.map.setFilter(
|
||||
this.fileId + '-direction',
|
||||
[
|
||||
'any',
|
||||
...visibleItems.map(([trackIndex, segmentIndex]) => [
|
||||
'all',
|
||||
['==', 'trackIndex', trackIndex],
|
||||
['==', 'segmentIndex', segmentIndex],
|
||||
]),
|
||||
],
|
||||
{ validate: false }
|
||||
);
|
||||
}
|
||||
} catch (e) { // No reliable way to check if the map is ready to add sources and layers
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
return;
|
||||
}
|
||||
|
||||
let markerIndex = 0;
|
||||
|
||||
if (get(selection).hasAnyChildren(new ListFileItem(this.fileId))) {
|
||||
file.wpt.forEach((waypoint) => { // Update markers
|
||||
file.wpt.forEach((waypoint) => {
|
||||
// Update markers
|
||||
let symbolKey = getSymbolKey(waypoint.sym);
|
||||
if (markerIndex < this.markers.length) {
|
||||
this.markers[markerIndex].getElement().innerHTML = getMarkerForSymbol(symbolKey, this.layerColor);
|
||||
this.markers[markerIndex].getElement().innerHTML = getMarkerForSymbol(
|
||||
symbolKey,
|
||||
this.layerColor
|
||||
);
|
||||
this.markers[markerIndex].setLngLat(waypoint.getCoordinates());
|
||||
Object.defineProperty(this.markers[markerIndex], '_waypoint', { value: waypoint, writable: true });
|
||||
Object.defineProperty(this.markers[markerIndex], '_waypoint', {
|
||||
value: waypoint,
|
||||
writable: true,
|
||||
});
|
||||
} else {
|
||||
let element = document.createElement('div');
|
||||
element.classList.add('w-8', 'h-8', 'drop-shadow-xl');
|
||||
@@ -249,7 +312,7 @@ export class GPXLayer {
|
||||
let marker = new mapboxgl.Marker({
|
||||
draggable: this.draggable,
|
||||
element,
|
||||
anchor: 'bottom'
|
||||
anchor: 'bottom',
|
||||
}).setLngLat(waypoint.getCoordinates());
|
||||
Object.defineProperty(marker, '_waypoint', { value: waypoint, writable: true });
|
||||
let dragEndTimestamp = 0;
|
||||
@@ -272,10 +335,20 @@ export class GPXLayer {
|
||||
}
|
||||
|
||||
if (get(treeFileView)) {
|
||||
if ((e.ctrlKey || e.metaKey) && get(selection).hasAnyChildren(new ListWaypointsItem(this.fileId), false)) {
|
||||
addSelectItem(new ListWaypointItem(this.fileId, marker._waypoint._data.index));
|
||||
if (
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
get(selection).hasAnyChildren(
|
||||
new ListWaypointsItem(this.fileId),
|
||||
false
|
||||
)
|
||||
) {
|
||||
addSelectItem(
|
||||
new ListWaypointItem(this.fileId, marker._waypoint._data.index)
|
||||
);
|
||||
} else {
|
||||
selectItem(new ListWaypointItem(this.fileId, marker._waypoint._data.index));
|
||||
selectItem(
|
||||
new ListWaypointItem(this.fileId, marker._waypoint._data.index)
|
||||
);
|
||||
}
|
||||
} else if (get(currentTool) === Tool.WAYPOINT) {
|
||||
selectedWaypoint.set([marker._waypoint, this.fileId]);
|
||||
@@ -298,12 +371,12 @@ export class GPXLayer {
|
||||
let wpt = file.wpt[marker._waypoint._data.index];
|
||||
wpt.setCoordinates({
|
||||
lat: latLng.lat,
|
||||
lon: latLng.lng
|
||||
lon: latLng.lng,
|
||||
});
|
||||
wpt.ele = ele[0];
|
||||
});
|
||||
});
|
||||
dragEndTimestamp = Date.now()
|
||||
dragEndTimestamp = Date.now();
|
||||
});
|
||||
this.markers.push(marker);
|
||||
}
|
||||
@@ -311,7 +384,8 @@ export class GPXLayer {
|
||||
});
|
||||
}
|
||||
|
||||
while (markerIndex < this.markers.length) { // Remove extra markers
|
||||
while (markerIndex < this.markers.length) {
|
||||
// Remove extra markers
|
||||
this.markers.pop()?.remove();
|
||||
}
|
||||
|
||||
@@ -364,7 +438,10 @@ export class GPXLayer {
|
||||
this.map.moveLayer(this.fileId);
|
||||
}
|
||||
if (this.map.getLayer(this.fileId + '-direction')) {
|
||||
this.map.moveLayer(this.fileId + '-direction', this.map.getLayer('distance-markers') ? 'distance-markers' : undefined);
|
||||
this.map.moveLayer(
|
||||
this.fileId + '-direction',
|
||||
this.map.getLayer('distance-markers') ? 'distance-markers' : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,7 +449,12 @@ export class GPXLayer {
|
||||
let trackIndex = e.features[0].properties.trackIndex;
|
||||
let segmentIndex = e.features[0].properties.segmentIndex;
|
||||
|
||||
if (get(currentTool) === Tool.SCISSORS && get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
|
||||
if (
|
||||
get(currentTool) === Tool.SCISSORS &&
|
||||
get(selection).hasAnyParent(
|
||||
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
)
|
||||
) {
|
||||
setScissorsCursor();
|
||||
} else {
|
||||
setPointerCursor();
|
||||
@@ -390,22 +472,36 @@ export class GPXLayer {
|
||||
|
||||
const file = get(this.file)?.file;
|
||||
if (file) {
|
||||
const closest = getClosestLinePoint(file.trk[trackIndex].trkseg[segmentIndex].trkpt, { lat: e.lngLat.lat, lon: e.lngLat.lng });
|
||||
const closest = getClosestLinePoint(
|
||||
file.trk[trackIndex].trkseg[segmentIndex].trkpt,
|
||||
{ lat: e.lngLat.lat, lon: e.lngLat.lng }
|
||||
);
|
||||
trackpointPopup?.setItem({ item: closest, fileId: this.fileId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
layerOnClick(e: any) {
|
||||
if (get(currentTool) === Tool.ROUTING && get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])) {
|
||||
if (
|
||||
get(currentTool) === Tool.ROUTING &&
|
||||
get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let trackIndex = e.features[0].properties.trackIndex;
|
||||
let segmentIndex = e.features[0].properties.segmentIndex;
|
||||
|
||||
if (get(currentTool) === Tool.SCISSORS && get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
|
||||
dbUtils.split(this.fileId, trackIndex, segmentIndex, { lat: e.lngLat.lat, lon: e.lngLat.lng });
|
||||
if (
|
||||
get(currentTool) === Tool.SCISSORS &&
|
||||
get(selection).hasAnyParent(
|
||||
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
)
|
||||
) {
|
||||
dbUtils.split(this.fileId, trackIndex, segmentIndex, {
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -415,8 +511,12 @@ export class GPXLayer {
|
||||
}
|
||||
|
||||
let item = undefined;
|
||||
if (get(treeFileView) && file.getSegments().length > 1) { // Select inner item
|
||||
item = file.children[trackIndex].children.length > 1 ? new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex) : new ListTrackItem(this.fileId, trackIndex);
|
||||
if (get(treeFileView) && file.getSegments().length > 1) {
|
||||
// Select inner item
|
||||
item =
|
||||
file.children[trackIndex].children.length > 1
|
||||
? new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
: new ListTrackItem(this.fileId, trackIndex);
|
||||
} else {
|
||||
item = new ListFileItem(this.fileId);
|
||||
}
|
||||
@@ -439,13 +539,14 @@ export class GPXLayer {
|
||||
if (!file) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
features: [],
|
||||
};
|
||||
}
|
||||
|
||||
let data = file.toGeoJSON();
|
||||
|
||||
let trackIndex = 0, segmentIndex = 0;
|
||||
let trackIndex = 0,
|
||||
segmentIndex = 0;
|
||||
for (let feature of data.features) {
|
||||
if (!feature.properties) {
|
||||
feature.properties = {};
|
||||
@@ -459,7 +560,12 @@ export class GPXLayer {
|
||||
if (!feature.properties.width) {
|
||||
feature.properties.width = get(defaultWidth);
|
||||
}
|
||||
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)) || get(selection).hasAnyChildren(new ListWaypointsItem(this.fileId), true)) {
|
||||
if (
|
||||
get(selection).hasAnyParent(
|
||||
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
) ||
|
||||
get(selection).hasAnyChildren(new ListWaypointsItem(this.fileId), true)
|
||||
) {
|
||||
feature.properties.width = feature.properties.width + 2;
|
||||
feature.properties.opacity = Math.min(1, feature.properties.opacity + 0.1);
|
||||
}
|
||||
@@ -474,4 +580,4 @@ export class GPXLayer {
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { dbUtils } from "$lib/db";
|
||||
import { MapPopup } from "$lib/components/MapPopup";
|
||||
import { dbUtils } from '$lib/db';
|
||||
import { MapPopup } from '$lib/components/MapPopup';
|
||||
|
||||
export let waypointPopup: MapPopup | null = null;
|
||||
export let trackpointPopup: MapPopup | null = null;
|
||||
@@ -11,14 +11,14 @@ export function createPopups(map: mapboxgl.Map) {
|
||||
focusAfterOpen: false,
|
||||
maxWidth: undefined,
|
||||
offset: {
|
||||
'top': [0, 0],
|
||||
top: [0, 0],
|
||||
'top-left': [0, 0],
|
||||
'top-right': [0, 0],
|
||||
'bottom': [0, -30],
|
||||
bottom: [0, -30],
|
||||
'bottom-left': [0, -30],
|
||||
'bottom-right': [0, -30],
|
||||
'left': [10, -15],
|
||||
'right': [-10, -15],
|
||||
left: [10, -15],
|
||||
right: [-10, -15],
|
||||
},
|
||||
});
|
||||
trackpointPopup = new MapPopup(map, {
|
||||
@@ -41,4 +41,4 @@ export function removePopups() {
|
||||
|
||||
export function deleteWaypoint(fileId: string, waypointIndex: number) {
|
||||
dbUtils.applyToFile(fileId, (file) => file.replaceWaypoints(waypointIndex, waypointIndex, []));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { map, gpxLayers } from '$lib/stores';
|
||||
import { GPXLayer } from './GPXLayer';
|
||||
import { fileObservers } from '$lib/db';
|
||||
import { DistanceMarkers } from './DistanceMarkers';
|
||||
import { StartEndMarkers } from './StartEndMarkers';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { createPopups, removePopups } from './GPXLayerPopup';
|
||||
import { map, gpxLayers } from '$lib/stores';
|
||||
import { GPXLayer } from './GPXLayer';
|
||||
import { fileObservers } from '$lib/db';
|
||||
import { DistanceMarkers } from './DistanceMarkers';
|
||||
import { StartEndMarkers } from './StartEndMarkers';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { createPopups, removePopups } from './GPXLayerPopup';
|
||||
|
||||
let distanceMarkers: DistanceMarkers | undefined = undefined;
|
||||
let startEndMarkers: StartEndMarkers | undefined = undefined;
|
||||
let distanceMarkers: DistanceMarkers | undefined = undefined;
|
||||
let startEndMarkers: StartEndMarkers | undefined = undefined;
|
||||
|
||||
$: if ($map && $fileObservers) {
|
||||
// remove layers for deleted files
|
||||
gpxLayers.forEach((layer, fileId) => {
|
||||
if (!$fileObservers.has(fileId)) {
|
||||
layer.remove();
|
||||
gpxLayers.delete(fileId);
|
||||
} else if ($map !== layer.map) {
|
||||
layer.updateMap($map);
|
||||
}
|
||||
});
|
||||
// add layers for new files
|
||||
$fileObservers.forEach((file, fileId) => {
|
||||
if (!gpxLayers.has(fileId)) {
|
||||
gpxLayers.set(fileId, new GPXLayer($map, fileId, file));
|
||||
}
|
||||
});
|
||||
}
|
||||
$: if ($map && $fileObservers) {
|
||||
// remove layers for deleted files
|
||||
gpxLayers.forEach((layer, fileId) => {
|
||||
if (!$fileObservers.has(fileId)) {
|
||||
layer.remove();
|
||||
gpxLayers.delete(fileId);
|
||||
} else if ($map !== layer.map) {
|
||||
layer.updateMap($map);
|
||||
}
|
||||
});
|
||||
// add layers for new files
|
||||
$fileObservers.forEach((file, fileId) => {
|
||||
if (!gpxLayers.has(fileId)) {
|
||||
gpxLayers.set(fileId, new GPXLayer($map, fileId, file));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$: if ($map) {
|
||||
if (distanceMarkers) {
|
||||
distanceMarkers.remove();
|
||||
}
|
||||
if (startEndMarkers) {
|
||||
startEndMarkers.remove();
|
||||
}
|
||||
createPopups($map);
|
||||
distanceMarkers = new DistanceMarkers($map);
|
||||
startEndMarkers = new StartEndMarkers($map);
|
||||
}
|
||||
$: if ($map) {
|
||||
if (distanceMarkers) {
|
||||
distanceMarkers.remove();
|
||||
}
|
||||
if (startEndMarkers) {
|
||||
startEndMarkers.remove();
|
||||
}
|
||||
createPopups($map);
|
||||
distanceMarkers = new DistanceMarkers($map);
|
||||
startEndMarkers = new StartEndMarkers($map);
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
gpxLayers.forEach((layer) => layer.remove());
|
||||
gpxLayers.clear();
|
||||
removePopups();
|
||||
if (distanceMarkers) {
|
||||
distanceMarkers.remove();
|
||||
distanceMarkers = undefined;
|
||||
}
|
||||
if (startEndMarkers) {
|
||||
startEndMarkers.remove();
|
||||
startEndMarkers = undefined;
|
||||
}
|
||||
});
|
||||
onDestroy(() => {
|
||||
gpxLayers.forEach((layer) => layer.remove());
|
||||
gpxLayers.clear();
|
||||
removePopups();
|
||||
if (distanceMarkers) {
|
||||
distanceMarkers.remove();
|
||||
distanceMarkers = undefined;
|
||||
}
|
||||
if (startEndMarkers) {
|
||||
startEndMarkers.remove();
|
||||
startEndMarkers = undefined;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { gpxStatistics, slicedGPXStatistics, currentTool, Tool } from "$lib/stores";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
import { get } from "svelte/store";
|
||||
import { gpxStatistics, slicedGPXStatistics, currentTool, Tool } from '$lib/stores';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export class StartEndMarkers {
|
||||
map: mapboxgl.Map;
|
||||
@@ -16,7 +16,8 @@ export class StartEndMarkers {
|
||||
let endElement = document.createElement('div');
|
||||
startElement.className = `h-4 w-4 rounded-full bg-green-500 border-2 border-white`;
|
||||
endElement.className = `h-4 w-4 rounded-full border-2 border-white`;
|
||||
endElement.style.background = 'repeating-conic-gradient(#fff 0 90deg, #000 0 180deg) 0 0/8px 8px round';
|
||||
endElement.style.background =
|
||||
'repeating-conic-gradient(#fff 0 90deg, #000 0 180deg) 0 0/8px 8px round';
|
||||
|
||||
this.start = new mapboxgl.Marker({ element: startElement });
|
||||
this.end = new mapboxgl.Marker({ element: endElement });
|
||||
@@ -31,7 +32,11 @@ export class StartEndMarkers {
|
||||
let statistics = get(slicedGPXStatistics)?.[0] ?? get(gpxStatistics);
|
||||
if (statistics.local.points.length > 0 && tool !== Tool.ROUTING) {
|
||||
this.start.setLngLat(statistics.local.points[0].getCoordinates()).addTo(this.map);
|
||||
this.end.setLngLat(statistics.local.points[statistics.local.points.length - 1].getCoordinates()).addTo(this.map);
|
||||
this.end
|
||||
.setLngLat(
|
||||
statistics.local.points[statistics.local.points.length - 1].getCoordinates()
|
||||
)
|
||||
.addTo(this.map);
|
||||
} else {
|
||||
this.start.remove();
|
||||
this.end.remove();
|
||||
@@ -39,9 +44,9 @@ export class StartEndMarkers {
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.unsubscribes.forEach(unsubscribe => unsubscribe());
|
||||
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
|
||||
this.start.remove();
|
||||
this.end.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
<script lang="ts">
|
||||
import type { TrackPoint } from 'gpx';
|
||||
import type { PopupItem } from '$lib/components/MapPopup';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
import { Compass, Mountain, Timer, ClipboardCopy } from 'lucide-svelte';
|
||||
import { df } from '$lib/utils';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { TrackPoint } from 'gpx';
|
||||
import type { PopupItem } from '$lib/components/MapPopup';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
import { Compass, Mountain, Timer, ClipboardCopy } from 'lucide-svelte';
|
||||
import { df } from '$lib/utils';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let trackpoint: PopupItem<TrackPoint>;
|
||||
export let trackpoint: PopupItem<TrackPoint>;
|
||||
</script>
|
||||
|
||||
<Card.Root class="border-none shadow-md text-base p-2">
|
||||
<Card.Header class="p-0">
|
||||
<Card.Title class="text-md"></Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col p-0 text-xs gap-1">
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<Compass size="14" />
|
||||
{trackpoint.item.getLatitude().toFixed(6)}° {trackpoint.item
|
||||
.getLongitude()
|
||||
.toFixed(6)}°
|
||||
</div>
|
||||
{#if trackpoint.item.ele !== undefined}
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<Mountain size="14" />
|
||||
<WithUnits value={trackpoint.item.ele} type="elevation" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if trackpoint.item.time}
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<Timer size="14" />
|
||||
{df.format(trackpoint.item.time)}
|
||||
</div>
|
||||
{/if}
|
||||
<Button
|
||||
class="w-full px-2 py-1 h-6 justify-start mt-0.5"
|
||||
variant="secondary"
|
||||
on:click={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${trackpoint.item.getLatitude().toFixed(6)}, ${trackpoint.item.getLongitude().toFixed(6)}`
|
||||
);
|
||||
trackpoint.hide?.();
|
||||
}}
|
||||
>
|
||||
<ClipboardCopy size="16" class="mr-1" />
|
||||
{$_('menu.copy_coordinates')}
|
||||
</Button>
|
||||
</Card.Content>
|
||||
<Card.Header class="p-0">
|
||||
<Card.Title class="text-md"></Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col p-0 text-xs gap-1">
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<Compass size="14" />
|
||||
{trackpoint.item.getLatitude().toFixed(6)}° {trackpoint.item
|
||||
.getLongitude()
|
||||
.toFixed(6)}°
|
||||
</div>
|
||||
{#if trackpoint.item.ele !== undefined}
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<Mountain size="14" />
|
||||
<WithUnits value={trackpoint.item.ele} type="elevation" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if trackpoint.item.time}
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<Timer size="14" />
|
||||
{df.format(trackpoint.item.time)}
|
||||
</div>
|
||||
{/if}
|
||||
<Button
|
||||
class="w-full px-2 py-1 h-6 justify-start mt-0.5"
|
||||
variant="secondary"
|
||||
on:click={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${trackpoint.item.getLatitude().toFixed(6)}, ${trackpoint.item.getLongitude().toFixed(6)}`
|
||||
);
|
||||
trackpoint.hide?.();
|
||||
}}
|
||||
>
|
||||
<ClipboardCopy size="16" class="mr-1" />
|
||||
{$_('menu.copy_coordinates')}
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -1,102 +1,104 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import { deleteWaypoint } from './GPXLayerPopup';
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
import { Dot, ExternalLink, Trash2 } from 'lucide-svelte';
|
||||
import { Tool, currentTool } from '$lib/stores';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import type { Waypoint } from 'gpx';
|
||||
import type { PopupItem } from '$lib/components/MapPopup';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import { deleteWaypoint } from './GPXLayerPopup';
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
import { Dot, ExternalLink, Trash2 } from 'lucide-svelte';
|
||||
import { Tool, currentTool } from '$lib/stores';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import type { Waypoint } from 'gpx';
|
||||
import type { PopupItem } from '$lib/components/MapPopup';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
|
||||
export let waypoint: PopupItem<Waypoint>;
|
||||
export let waypoint: PopupItem<Waypoint>;
|
||||
|
||||
$: symbolKey = waypoint ? getSymbolKey(waypoint.item.sym) : undefined;
|
||||
$: symbolKey = waypoint ? getSymbolKey(waypoint.item.sym) : undefined;
|
||||
|
||||
function sanitize(text: string | undefined): string {
|
||||
if (text === undefined) {
|
||||
return '';
|
||||
}
|
||||
return sanitizeHtml(text, {
|
||||
allowedTags: ['a', 'br', 'img'],
|
||||
allowedAttributes: {
|
||||
a: ['href', 'target'],
|
||||
img: ['src']
|
||||
}
|
||||
}).trim();
|
||||
}
|
||||
function sanitize(text: string | undefined): string {
|
||||
if (text === undefined) {
|
||||
return '';
|
||||
}
|
||||
return sanitizeHtml(text, {
|
||||
allowedTags: ['a', 'br', 'img'],
|
||||
allowedAttributes: {
|
||||
a: ['href', 'target'],
|
||||
img: ['src'],
|
||||
},
|
||||
}).trim();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw]">
|
||||
<Card.Header class="p-0">
|
||||
<Card.Title class="text-md">
|
||||
{#if waypoint.item.link && waypoint.item.link.attributes && waypoint.item.link.attributes.href}
|
||||
<a href={waypoint.item.link.attributes.href} target="_blank">
|
||||
{waypoint.item.name ?? waypoint.item.link.attributes.href}
|
||||
<ExternalLink size="12" class="inline-block mb-1.5" />
|
||||
</a>
|
||||
{:else}
|
||||
{waypoint.item.name ?? $_('gpx.waypoint')}
|
||||
{/if}
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col text-sm p-0">
|
||||
<div class="flex flex-row items-center text-muted-foreground text-xs whitespace-nowrap">
|
||||
{#if symbolKey}
|
||||
<span>
|
||||
{#if symbols[symbolKey].icon}
|
||||
<svelte:component
|
||||
this={symbols[symbolKey].icon}
|
||||
size="12"
|
||||
class="inline-block mb-0.5"
|
||||
/>
|
||||
{:else}
|
||||
<span class="w-4 inline-block" />
|
||||
{/if}
|
||||
{$_(`gpx.symbol.${symbolKey}`)}
|
||||
</span>
|
||||
<Dot size="16" />
|
||||
{/if}
|
||||
{waypoint.item.getLatitude().toFixed(6)}° {waypoint.item.getLongitude().toFixed(6)}°
|
||||
{#if waypoint.item.ele !== undefined}
|
||||
<Dot size="16" />
|
||||
<WithUnits value={waypoint.item.ele} type="elevation" />
|
||||
{/if}
|
||||
</div>
|
||||
<ScrollArea class="flex flex-col" viewportClasses="max-h-[30dvh]">
|
||||
{#if waypoint.item.desc}
|
||||
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.desc)}</span>
|
||||
{/if}
|
||||
{#if waypoint.item.cmt && waypoint.item.cmt !== waypoint.item.desc}
|
||||
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.cmt)}</span>
|
||||
{/if}
|
||||
</ScrollArea>
|
||||
{#if $currentTool === Tool.WAYPOINT}
|
||||
<Button
|
||||
class="mt-2 w-full px-2 py-1 h-8 justify-start"
|
||||
variant="outline"
|
||||
on:click={() => deleteWaypoint(waypoint.fileId, waypoint.item._data.index)}
|
||||
>
|
||||
<Trash2 size="16" class="mr-1" />
|
||||
{$_('menu.delete')}
|
||||
<Shortcut shift={true} click={true} />
|
||||
</Button>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
<Card.Header class="p-0">
|
||||
<Card.Title class="text-md">
|
||||
{#if waypoint.item.link && waypoint.item.link.attributes && waypoint.item.link.attributes.href}
|
||||
<a href={waypoint.item.link.attributes.href} target="_blank">
|
||||
{waypoint.item.name ?? waypoint.item.link.attributes.href}
|
||||
<ExternalLink size="12" class="inline-block mb-1.5" />
|
||||
</a>
|
||||
{:else}
|
||||
{waypoint.item.name ?? $_('gpx.waypoint')}
|
||||
{/if}
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col text-sm p-0">
|
||||
<div class="flex flex-row items-center text-muted-foreground text-xs whitespace-nowrap">
|
||||
{#if symbolKey}
|
||||
<span>
|
||||
{#if symbols[symbolKey].icon}
|
||||
<svelte:component
|
||||
this={symbols[symbolKey].icon}
|
||||
size="12"
|
||||
class="inline-block mb-0.5"
|
||||
/>
|
||||
{:else}
|
||||
<span class="w-4 inline-block" />
|
||||
{/if}
|
||||
{$_(`gpx.symbol.${symbolKey}`)}
|
||||
</span>
|
||||
<Dot size="16" />
|
||||
{/if}
|
||||
{waypoint.item.getLatitude().toFixed(6)}° {waypoint.item
|
||||
.getLongitude()
|
||||
.toFixed(6)}°
|
||||
{#if waypoint.item.ele !== undefined}
|
||||
<Dot size="16" />
|
||||
<WithUnits value={waypoint.item.ele} type="elevation" />
|
||||
{/if}
|
||||
</div>
|
||||
<ScrollArea class="flex flex-col" viewportClasses="max-h-[30dvh]">
|
||||
{#if waypoint.item.desc}
|
||||
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.desc)}</span>
|
||||
{/if}
|
||||
{#if waypoint.item.cmt && waypoint.item.cmt !== waypoint.item.desc}
|
||||
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.cmt)}</span>
|
||||
{/if}
|
||||
</ScrollArea>
|
||||
{#if $currentTool === Tool.WAYPOINT}
|
||||
<Button
|
||||
class="mt-2 w-full px-2 py-1 h-8 justify-start"
|
||||
variant="outline"
|
||||
on:click={() => deleteWaypoint(waypoint.fileId, waypoint.item._data.index)}
|
||||
>
|
||||
<Trash2 size="16" class="mr-1" />
|
||||
{$_('menu.delete')}
|
||||
<Shortcut shift={true} click={true} />
|
||||
</Button>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<style lang="postcss">
|
||||
div :global(a) {
|
||||
@apply text-link;
|
||||
@apply hover:underline;
|
||||
}
|
||||
div :global(a) {
|
||||
@apply text-link;
|
||||
@apply hover:underline;
|
||||
}
|
||||
|
||||
div :global(img) {
|
||||
@apply my-0;
|
||||
@apply rounded-md;
|
||||
}
|
||||
div :global(img) {
|
||||
@apply my-0;
|
||||
@apply rounded-md;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,422 +1,436 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import {
|
||||
CirclePlus,
|
||||
CircleX,
|
||||
Minus,
|
||||
Pencil,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
Move,
|
||||
Map,
|
||||
Layers2
|
||||
} from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { settings } from '$lib/db';
|
||||
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
|
||||
import { map } from '$lib/stores';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import Sortable from 'sortablejs/Sortable';
|
||||
import { customBasemapUpdate } from './utils';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import {
|
||||
CirclePlus,
|
||||
CircleX,
|
||||
Minus,
|
||||
Pencil,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
Move,
|
||||
Map,
|
||||
Layers2,
|
||||
} from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { settings } from '$lib/db';
|
||||
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
|
||||
import { map } from '$lib/stores';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import Sortable from 'sortablejs/Sortable';
|
||||
import { customBasemapUpdate } from './utils';
|
||||
|
||||
const {
|
||||
customLayers,
|
||||
selectedBasemapTree,
|
||||
selectedOverlayTree,
|
||||
currentBasemap,
|
||||
previousBasemap,
|
||||
currentOverlays,
|
||||
previousOverlays,
|
||||
customBasemapOrder,
|
||||
customOverlayOrder
|
||||
} = settings;
|
||||
const {
|
||||
customLayers,
|
||||
selectedBasemapTree,
|
||||
selectedOverlayTree,
|
||||
currentBasemap,
|
||||
previousBasemap,
|
||||
currentOverlays,
|
||||
previousOverlays,
|
||||
customBasemapOrder,
|
||||
customOverlayOrder,
|
||||
} = settings;
|
||||
|
||||
let name: string = '';
|
||||
let tileUrls: string[] = [''];
|
||||
let maxZoom: number = 20;
|
||||
let layerType: 'basemap' | 'overlay' = 'basemap';
|
||||
let resourceType: 'raster' | 'vector' = 'raster';
|
||||
let name: string = '';
|
||||
let tileUrls: string[] = [''];
|
||||
let maxZoom: number = 20;
|
||||
let layerType: 'basemap' | 'overlay' = 'basemap';
|
||||
let resourceType: 'raster' | 'vector' = 'raster';
|
||||
|
||||
let basemapContainer: HTMLElement;
|
||||
let overlayContainer: HTMLElement;
|
||||
let basemapContainer: HTMLElement;
|
||||
let overlayContainer: HTMLElement;
|
||||
|
||||
let basemapSortable: Sortable;
|
||||
let overlaySortable: Sortable;
|
||||
let basemapSortable: Sortable;
|
||||
let overlaySortable: Sortable;
|
||||
|
||||
onMount(() => {
|
||||
if ($customBasemapOrder.length === 0) {
|
||||
$customBasemapOrder = Object.keys($customLayers).filter(
|
||||
(id) => $customLayers[id].layerType === 'basemap'
|
||||
);
|
||||
}
|
||||
if ($customOverlayOrder.length === 0) {
|
||||
$customOverlayOrder = Object.keys($customLayers).filter(
|
||||
(id) => $customLayers[id].layerType === 'overlay'
|
||||
);
|
||||
}
|
||||
onMount(() => {
|
||||
if ($customBasemapOrder.length === 0) {
|
||||
$customBasemapOrder = Object.keys($customLayers).filter(
|
||||
(id) => $customLayers[id].layerType === 'basemap'
|
||||
);
|
||||
}
|
||||
if ($customOverlayOrder.length === 0) {
|
||||
$customOverlayOrder = Object.keys($customLayers).filter(
|
||||
(id) => $customLayers[id].layerType === 'overlay'
|
||||
);
|
||||
}
|
||||
|
||||
basemapSortable = Sortable.create(basemapContainer, {
|
||||
onSort: (e) => {
|
||||
$customBasemapOrder = basemapSortable.toArray();
|
||||
$selectedBasemapTree.basemaps['custom'] = $customBasemapOrder.reduce((acc, id) => {
|
||||
acc[id] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
});
|
||||
overlaySortable = Sortable.create(overlayContainer, {
|
||||
onSort: (e) => {
|
||||
$customOverlayOrder = overlaySortable.toArray();
|
||||
$selectedOverlayTree.overlays['custom'] = $customOverlayOrder.reduce((acc, id) => {
|
||||
acc[id] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
});
|
||||
basemapSortable = Sortable.create(basemapContainer, {
|
||||
onSort: (e) => {
|
||||
$customBasemapOrder = basemapSortable.toArray();
|
||||
$selectedBasemapTree.basemaps['custom'] = $customBasemapOrder.reduce((acc, id) => {
|
||||
acc[id] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
},
|
||||
});
|
||||
overlaySortable = Sortable.create(overlayContainer, {
|
||||
onSort: (e) => {
|
||||
$customOverlayOrder = overlaySortable.toArray();
|
||||
$selectedOverlayTree.overlays['custom'] = $customOverlayOrder.reduce((acc, id) => {
|
||||
acc[id] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
},
|
||||
});
|
||||
|
||||
basemapSortable.sort($customBasemapOrder);
|
||||
overlaySortable.sort($customOverlayOrder);
|
||||
});
|
||||
basemapSortable.sort($customBasemapOrder);
|
||||
overlaySortable.sort($customOverlayOrder);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
basemapSortable.destroy();
|
||||
overlaySortable.destroy();
|
||||
});
|
||||
onDestroy(() => {
|
||||
basemapSortable.destroy();
|
||||
overlaySortable.destroy();
|
||||
});
|
||||
|
||||
$: if (tileUrls[0].length > 0) {
|
||||
if (
|
||||
tileUrls[0].includes('.json') ||
|
||||
(tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles'))
|
||||
) {
|
||||
resourceType = 'vector';
|
||||
} else {
|
||||
resourceType = 'raster';
|
||||
}
|
||||
}
|
||||
$: if (tileUrls[0].length > 0) {
|
||||
if (
|
||||
tileUrls[0].includes('.json') ||
|
||||
(tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles'))
|
||||
) {
|
||||
resourceType = 'vector';
|
||||
} else {
|
||||
resourceType = 'raster';
|
||||
}
|
||||
}
|
||||
|
||||
function createLayer() {
|
||||
if (selectedLayerId && $customLayers[selectedLayerId].layerType !== layerType) {
|
||||
deleteLayer(selectedLayerId);
|
||||
}
|
||||
function createLayer() {
|
||||
if (selectedLayerId && $customLayers[selectedLayerId].layerType !== layerType) {
|
||||
deleteLayer(selectedLayerId);
|
||||
}
|
||||
|
||||
if (typeof maxZoom === 'string') {
|
||||
maxZoom = parseInt(maxZoom);
|
||||
}
|
||||
let is512 = tileUrls.some((url) => url.includes('512'));
|
||||
if (typeof maxZoom === 'string') {
|
||||
maxZoom = parseInt(maxZoom);
|
||||
}
|
||||
let is512 = tileUrls.some((url) => url.includes('512'));
|
||||
|
||||
let layerId = selectedLayerId ?? getLayerId();
|
||||
let layer: CustomLayer = {
|
||||
id: layerId,
|
||||
name: name,
|
||||
tileUrls: tileUrls.map((url) => decodeURI(url.trim())),
|
||||
maxZoom: maxZoom,
|
||||
layerType: layerType,
|
||||
resourceType: resourceType,
|
||||
value: ''
|
||||
};
|
||||
let layerId = selectedLayerId ?? getLayerId();
|
||||
let layer: CustomLayer = {
|
||||
id: layerId,
|
||||
name: name,
|
||||
tileUrls: tileUrls.map((url) => decodeURI(url.trim())),
|
||||
maxZoom: maxZoom,
|
||||
layerType: layerType,
|
||||
resourceType: resourceType,
|
||||
value: '',
|
||||
};
|
||||
|
||||
if (resourceType === 'vector') {
|
||||
layer.value = layer.tileUrls[0];
|
||||
} else {
|
||||
layer.value = {
|
||||
version: 8,
|
||||
sources: {
|
||||
[layerId]: {
|
||||
type: 'raster',
|
||||
tiles: layer.tileUrls,
|
||||
tileSize: is512 ? 512 : 256,
|
||||
maxzoom: maxZoom
|
||||
}
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: layerId,
|
||||
type: 'raster',
|
||||
source: layerId
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
$customLayers[layerId] = layer;
|
||||
addLayer(layerId);
|
||||
selectedLayerId = undefined;
|
||||
setDataFromSelectedLayer();
|
||||
}
|
||||
if (resourceType === 'vector') {
|
||||
layer.value = layer.tileUrls[0];
|
||||
} else {
|
||||
layer.value = {
|
||||
version: 8,
|
||||
sources: {
|
||||
[layerId]: {
|
||||
type: 'raster',
|
||||
tiles: layer.tileUrls,
|
||||
tileSize: is512 ? 512 : 256,
|
||||
maxzoom: maxZoom,
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: layerId,
|
||||
type: 'raster',
|
||||
source: layerId,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
$customLayers[layerId] = layer;
|
||||
addLayer(layerId);
|
||||
selectedLayerId = undefined;
|
||||
setDataFromSelectedLayer();
|
||||
}
|
||||
|
||||
function getLayerId() {
|
||||
for (let id = 0; ; id++) {
|
||||
if (!$customLayers.hasOwnProperty(`custom-${id}`)) {
|
||||
return `custom-${id}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
function getLayerId() {
|
||||
for (let id = 0; ; id++) {
|
||||
if (!$customLayers.hasOwnProperty(`custom-${id}`)) {
|
||||
return `custom-${id}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addLayer(layerId: string) {
|
||||
if (layerType === 'basemap') {
|
||||
selectedBasemapTree.update(($tree) => {
|
||||
if (!$tree.basemaps.hasOwnProperty('custom')) {
|
||||
$tree.basemaps['custom'] = {};
|
||||
}
|
||||
$tree.basemaps['custom'][layerId] = true;
|
||||
return $tree;
|
||||
});
|
||||
function addLayer(layerId: string) {
|
||||
if (layerType === 'basemap') {
|
||||
selectedBasemapTree.update(($tree) => {
|
||||
if (!$tree.basemaps.hasOwnProperty('custom')) {
|
||||
$tree.basemaps['custom'] = {};
|
||||
}
|
||||
$tree.basemaps['custom'][layerId] = true;
|
||||
return $tree;
|
||||
});
|
||||
|
||||
if ($currentBasemap === layerId) {
|
||||
$customBasemapUpdate++;
|
||||
} else {
|
||||
$currentBasemap = layerId;
|
||||
}
|
||||
if ($currentBasemap === layerId) {
|
||||
$customBasemapUpdate++;
|
||||
} else {
|
||||
$currentBasemap = layerId;
|
||||
}
|
||||
|
||||
if (!$customBasemapOrder.includes(layerId)) {
|
||||
$customBasemapOrder = [...$customBasemapOrder, layerId];
|
||||
}
|
||||
} else {
|
||||
selectedOverlayTree.update(($tree) => {
|
||||
if (!$tree.overlays.hasOwnProperty('custom')) {
|
||||
$tree.overlays['custom'] = {};
|
||||
}
|
||||
$tree.overlays['custom'][layerId] = true;
|
||||
return $tree;
|
||||
});
|
||||
if (!$customBasemapOrder.includes(layerId)) {
|
||||
$customBasemapOrder = [...$customBasemapOrder, layerId];
|
||||
}
|
||||
} else {
|
||||
selectedOverlayTree.update(($tree) => {
|
||||
if (!$tree.overlays.hasOwnProperty('custom')) {
|
||||
$tree.overlays['custom'] = {};
|
||||
}
|
||||
$tree.overlays['custom'][layerId] = true;
|
||||
return $tree;
|
||||
});
|
||||
|
||||
if (
|
||||
$currentOverlays.overlays['custom'] &&
|
||||
$currentOverlays.overlays['custom'][layerId] &&
|
||||
$map
|
||||
) {
|
||||
try {
|
||||
$map.removeImport(layerId);
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
}
|
||||
}
|
||||
if (
|
||||
$currentOverlays.overlays['custom'] &&
|
||||
$currentOverlays.overlays['custom'][layerId] &&
|
||||
$map
|
||||
) {
|
||||
try {
|
||||
$map.removeImport(layerId);
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
}
|
||||
}
|
||||
|
||||
if (!$currentOverlays.overlays.hasOwnProperty('custom')) {
|
||||
$currentOverlays.overlays['custom'] = {};
|
||||
}
|
||||
$currentOverlays.overlays['custom'][layerId] = true;
|
||||
if (!$currentOverlays.overlays.hasOwnProperty('custom')) {
|
||||
$currentOverlays.overlays['custom'] = {};
|
||||
}
|
||||
$currentOverlays.overlays['custom'][layerId] = true;
|
||||
|
||||
if (!$customOverlayOrder.includes(layerId)) {
|
||||
$customOverlayOrder = [...$customOverlayOrder, layerId];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$customOverlayOrder.includes(layerId)) {
|
||||
$customOverlayOrder = [...$customOverlayOrder, layerId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tryDeleteLayer(node: any, id: string): any {
|
||||
if (node.hasOwnProperty(id)) {
|
||||
delete node[id];
|
||||
}
|
||||
return node;
|
||||
}
|
||||
function tryDeleteLayer(node: any, id: string): any {
|
||||
if (node.hasOwnProperty(id)) {
|
||||
delete node[id];
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function deleteLayer(layerId: string) {
|
||||
let layer = $customLayers[layerId];
|
||||
if (layer.layerType === 'basemap') {
|
||||
if (layerId === $currentBasemap) {
|
||||
$currentBasemap = defaultBasemap;
|
||||
}
|
||||
if (layerId === $previousBasemap) {
|
||||
$previousBasemap = defaultBasemap;
|
||||
}
|
||||
function deleteLayer(layerId: string) {
|
||||
let layer = $customLayers[layerId];
|
||||
if (layer.layerType === 'basemap') {
|
||||
if (layerId === $currentBasemap) {
|
||||
$currentBasemap = defaultBasemap;
|
||||
}
|
||||
if (layerId === $previousBasemap) {
|
||||
$previousBasemap = defaultBasemap;
|
||||
}
|
||||
|
||||
$selectedBasemapTree.basemaps['custom'] = tryDeleteLayer(
|
||||
$selectedBasemapTree.basemaps['custom'],
|
||||
layerId
|
||||
);
|
||||
if (Object.keys($selectedBasemapTree.basemaps['custom']).length === 0) {
|
||||
$selectedBasemapTree.basemaps = tryDeleteLayer($selectedBasemapTree.basemaps, 'custom');
|
||||
}
|
||||
$customBasemapOrder = $customBasemapOrder.filter((id) => id !== layerId);
|
||||
} else {
|
||||
$currentOverlays.overlays['custom'][layerId] = false;
|
||||
if ($previousOverlays.overlays['custom']) {
|
||||
$previousOverlays.overlays['custom'] = tryDeleteLayer(
|
||||
$previousOverlays.overlays['custom'],
|
||||
layerId
|
||||
);
|
||||
}
|
||||
$selectedBasemapTree.basemaps['custom'] = tryDeleteLayer(
|
||||
$selectedBasemapTree.basemaps['custom'],
|
||||
layerId
|
||||
);
|
||||
if (Object.keys($selectedBasemapTree.basemaps['custom']).length === 0) {
|
||||
$selectedBasemapTree.basemaps = tryDeleteLayer(
|
||||
$selectedBasemapTree.basemaps,
|
||||
'custom'
|
||||
);
|
||||
}
|
||||
$customBasemapOrder = $customBasemapOrder.filter((id) => id !== layerId);
|
||||
} else {
|
||||
$currentOverlays.overlays['custom'][layerId] = false;
|
||||
if ($previousOverlays.overlays['custom']) {
|
||||
$previousOverlays.overlays['custom'] = tryDeleteLayer(
|
||||
$previousOverlays.overlays['custom'],
|
||||
layerId
|
||||
);
|
||||
}
|
||||
|
||||
$selectedOverlayTree.overlays['custom'] = tryDeleteLayer(
|
||||
$selectedOverlayTree.overlays['custom'],
|
||||
layerId
|
||||
);
|
||||
if (Object.keys($selectedOverlayTree.overlays['custom']).length === 0) {
|
||||
$selectedOverlayTree.overlays = tryDeleteLayer($selectedOverlayTree.overlays, 'custom');
|
||||
}
|
||||
$customOverlayOrder = $customOverlayOrder.filter((id) => id !== layerId);
|
||||
$selectedOverlayTree.overlays['custom'] = tryDeleteLayer(
|
||||
$selectedOverlayTree.overlays['custom'],
|
||||
layerId
|
||||
);
|
||||
if (Object.keys($selectedOverlayTree.overlays['custom']).length === 0) {
|
||||
$selectedOverlayTree.overlays = tryDeleteLayer(
|
||||
$selectedOverlayTree.overlays,
|
||||
'custom'
|
||||
);
|
||||
}
|
||||
$customOverlayOrder = $customOverlayOrder.filter((id) => id !== layerId);
|
||||
|
||||
if (
|
||||
$currentOverlays.overlays['custom'] &&
|
||||
$currentOverlays.overlays['custom'][layerId] &&
|
||||
$map
|
||||
) {
|
||||
try {
|
||||
$map.removeImport(layerId);
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
}
|
||||
}
|
||||
}
|
||||
$customLayers = tryDeleteLayer($customLayers, layerId);
|
||||
}
|
||||
if (
|
||||
$currentOverlays.overlays['custom'] &&
|
||||
$currentOverlays.overlays['custom'][layerId] &&
|
||||
$map
|
||||
) {
|
||||
try {
|
||||
$map.removeImport(layerId);
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
}
|
||||
}
|
||||
}
|
||||
$customLayers = tryDeleteLayer($customLayers, layerId);
|
||||
}
|
||||
|
||||
let selectedLayerId: string | undefined = undefined;
|
||||
let selectedLayerId: string | undefined = undefined;
|
||||
|
||||
function setDataFromSelectedLayer() {
|
||||
if (selectedLayerId) {
|
||||
const layer = $customLayers[selectedLayerId];
|
||||
name = layer.name;
|
||||
tileUrls = layer.tileUrls;
|
||||
maxZoom = layer.maxZoom;
|
||||
layerType = layer.layerType;
|
||||
resourceType = layer.resourceType;
|
||||
} else {
|
||||
name = '';
|
||||
tileUrls = [''];
|
||||
maxZoom = 20;
|
||||
layerType = 'basemap';
|
||||
resourceType = 'raster';
|
||||
}
|
||||
}
|
||||
function setDataFromSelectedLayer() {
|
||||
if (selectedLayerId) {
|
||||
const layer = $customLayers[selectedLayerId];
|
||||
name = layer.name;
|
||||
tileUrls = layer.tileUrls;
|
||||
maxZoom = layer.maxZoom;
|
||||
layerType = layer.layerType;
|
||||
resourceType = layer.resourceType;
|
||||
} else {
|
||||
name = '';
|
||||
tileUrls = [''];
|
||||
maxZoom = 20;
|
||||
layerType = 'basemap';
|
||||
resourceType = 'raster';
|
||||
}
|
||||
}
|
||||
|
||||
$: selectedLayerId, setDataFromSelectedLayer();
|
||||
$: selectedLayerId, setDataFromSelectedLayer();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col">
|
||||
{#if $customBasemapOrder.length > 0}
|
||||
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
|
||||
<Map size="16" />
|
||||
{$_('layers.label.basemaps')}
|
||||
<div class="grow">
|
||||
<Separator />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
bind:this={basemapContainer}
|
||||
class="ml-1.5 flex flex-col gap-1 {$customBasemapOrder.length > 0 ? 'mb-2' : ''}"
|
||||
>
|
||||
{#each $customBasemapOrder as id (id)}
|
||||
<div class="flex flex-row items-center gap-2" data-id={id}>
|
||||
<Move size="12" />
|
||||
<span class="grow">{$customLayers[id].name}</span>
|
||||
<Button variant="outline" on:click={() => (selectedLayerId = id)} class="p-1 h-7">
|
||||
<Pencil size="16" />
|
||||
</Button>
|
||||
<Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-7">
|
||||
<Trash2 size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if $customOverlayOrder.length > 0}
|
||||
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
|
||||
<Layers2 size="16" />
|
||||
{$_('layers.label.overlays')}
|
||||
<div class="grow">
|
||||
<Separator />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
bind:this={overlayContainer}
|
||||
class="ml-1.5 flex flex-col gap-1 {$customOverlayOrder.length > 0 ? 'mb-2' : ''}"
|
||||
>
|
||||
{#each $customOverlayOrder as id (id)}
|
||||
<div class="flex flex-row items-center gap-2" data-id={id}>
|
||||
<Move size="12" />
|
||||
<span class="grow">{$customLayers[id].name}</span>
|
||||
<Button variant="outline" on:click={() => (selectedLayerId = id)} class="p-1 h-7">
|
||||
<Pencil size="16" />
|
||||
</Button>
|
||||
<Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-7">
|
||||
<Trash2 size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if $customBasemapOrder.length > 0}
|
||||
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
|
||||
<Map size="16" />
|
||||
{$_('layers.label.basemaps')}
|
||||
<div class="grow">
|
||||
<Separator />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
bind:this={basemapContainer}
|
||||
class="ml-1.5 flex flex-col gap-1 {$customBasemapOrder.length > 0 ? 'mb-2' : ''}"
|
||||
>
|
||||
{#each $customBasemapOrder as id (id)}
|
||||
<div class="flex flex-row items-center gap-2" data-id={id}>
|
||||
<Move size="12" />
|
||||
<span class="grow">{$customLayers[id].name}</span>
|
||||
<Button variant="outline" on:click={() => (selectedLayerId = id)} class="p-1 h-7">
|
||||
<Pencil size="16" />
|
||||
</Button>
|
||||
<Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-7">
|
||||
<Trash2 size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if $customOverlayOrder.length > 0}
|
||||
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
|
||||
<Layers2 size="16" />
|
||||
{$_('layers.label.overlays')}
|
||||
<div class="grow">
|
||||
<Separator />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
bind:this={overlayContainer}
|
||||
class="ml-1.5 flex flex-col gap-1 {$customOverlayOrder.length > 0 ? 'mb-2' : ''}"
|
||||
>
|
||||
{#each $customOverlayOrder as id (id)}
|
||||
<div class="flex flex-row items-center gap-2" data-id={id}>
|
||||
<Move size="12" />
|
||||
<span class="grow">{$customLayers[id].name}</span>
|
||||
<Button variant="outline" on:click={() => (selectedLayerId = id)} class="p-1 h-7">
|
||||
<Pencil size="16" />
|
||||
</Button>
|
||||
<Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-7">
|
||||
<Trash2 size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header class="p-3">
|
||||
<Card.Title class="text-base">
|
||||
{#if selectedLayerId}
|
||||
{$_('layers.custom_layers.edit')}
|
||||
{:else}
|
||||
{$_('layers.custom_layers.new')}
|
||||
{/if}
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="p-3 pt-0">
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<Label for="name">{$_('menu.metadata.name')}</Label>
|
||||
<Input bind:value={name} id="name" class="h-8" />
|
||||
<Label for="url">{$_('layers.custom_layers.urls')}</Label>
|
||||
{#each tileUrls as url, i}
|
||||
<div class="flex flex-row gap-2">
|
||||
<Input
|
||||
bind:value={tileUrls[i]}
|
||||
id="url"
|
||||
class="h-8"
|
||||
placeholder={$_('layers.custom_layers.url_placeholder')}
|
||||
/>
|
||||
{#if tileUrls.length > 1}
|
||||
<Button
|
||||
on:click={() => (tileUrls = tileUrls.filter((_, index) => index !== i))}
|
||||
variant="outline"
|
||||
class="p-1 h-8"
|
||||
>
|
||||
<Minus size="16" />
|
||||
</Button>
|
||||
{/if}
|
||||
{#if i === tileUrls.length - 1}
|
||||
<Button
|
||||
on:click={() => (tileUrls = [...tileUrls, ''])}
|
||||
variant="outline"
|
||||
class="p-1 h-8"
|
||||
>
|
||||
<Plus size="16" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if resourceType === 'raster'}
|
||||
<Label for="maxZoom">{$_('layers.custom_layers.max_zoom')}</Label>
|
||||
<Input type="number" bind:value={maxZoom} id="maxZoom" min={0} max={22} class="h-8" />
|
||||
{/if}
|
||||
<Label>{$_('layers.custom_layers.layer_type')}</Label>
|
||||
<RadioGroup.Root bind:value={layerType} class="flex flex-row">
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="basemap" id="basemap" />
|
||||
<Label for="basemap">{$_('layers.custom_layers.basemap')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="overlay" id="overlay" />
|
||||
<Label for="overlay">{$_('layers.custom_layers.overlay')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
{#if selectedLayerId}
|
||||
<div class="mt-2 flex flex-row gap-2">
|
||||
<Button variant="outline" on:click={createLayer} class="grow">
|
||||
<Save size="16" class="mr-1" />
|
||||
{$_('layers.custom_layers.update')}
|
||||
</Button>
|
||||
<Button variant="outline" on:click={() => (selectedLayerId = undefined)}>
|
||||
<CircleX size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<Button variant="outline" class="mt-2" on:click={createLayer}>
|
||||
<CirclePlus size="16" class="mr-1" />
|
||||
{$_('layers.custom_layers.create')}
|
||||
</Button>
|
||||
{/if}
|
||||
</fieldset>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<Card.Root>
|
||||
<Card.Header class="p-3">
|
||||
<Card.Title class="text-base">
|
||||
{#if selectedLayerId}
|
||||
{$_('layers.custom_layers.edit')}
|
||||
{:else}
|
||||
{$_('layers.custom_layers.new')}
|
||||
{/if}
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="p-3 pt-0">
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<Label for="name">{$_('menu.metadata.name')}</Label>
|
||||
<Input bind:value={name} id="name" class="h-8" />
|
||||
<Label for="url">{$_('layers.custom_layers.urls')}</Label>
|
||||
{#each tileUrls as url, i}
|
||||
<div class="flex flex-row gap-2">
|
||||
<Input
|
||||
bind:value={tileUrls[i]}
|
||||
id="url"
|
||||
class="h-8"
|
||||
placeholder={$_('layers.custom_layers.url_placeholder')}
|
||||
/>
|
||||
{#if tileUrls.length > 1}
|
||||
<Button
|
||||
on:click={() =>
|
||||
(tileUrls = tileUrls.filter((_, index) => index !== i))}
|
||||
variant="outline"
|
||||
class="p-1 h-8"
|
||||
>
|
||||
<Minus size="16" />
|
||||
</Button>
|
||||
{/if}
|
||||
{#if i === tileUrls.length - 1}
|
||||
<Button
|
||||
on:click={() => (tileUrls = [...tileUrls, ''])}
|
||||
variant="outline"
|
||||
class="p-1 h-8"
|
||||
>
|
||||
<Plus size="16" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if resourceType === 'raster'}
|
||||
<Label for="maxZoom">{$_('layers.custom_layers.max_zoom')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
bind:value={maxZoom}
|
||||
id="maxZoom"
|
||||
min={0}
|
||||
max={22}
|
||||
class="h-8"
|
||||
/>
|
||||
{/if}
|
||||
<Label>{$_('layers.custom_layers.layer_type')}</Label>
|
||||
<RadioGroup.Root bind:value={layerType} class="flex flex-row">
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="basemap" id="basemap" />
|
||||
<Label for="basemap">{$_('layers.custom_layers.basemap')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="overlay" id="overlay" />
|
||||
<Label for="overlay">{$_('layers.custom_layers.overlay')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
{#if selectedLayerId}
|
||||
<div class="mt-2 flex flex-row gap-2">
|
||||
<Button variant="outline" on:click={createLayer} class="grow">
|
||||
<Save size="16" class="mr-1" />
|
||||
{$_('layers.custom_layers.update')}
|
||||
</Button>
|
||||
<Button variant="outline" on:click={() => (selectedLayerId = undefined)}>
|
||||
<CircleX size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<Button variant="outline" class="mt-2" on:click={createLayer}>
|
||||
<CirclePlus size="16" class="mr-1" />
|
||||
{$_('layers.custom_layers.create')}
|
||||
</Button>
|
||||
{/if}
|
||||
</fieldset>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
@@ -1,222 +1,222 @@
|
||||
<script lang="ts">
|
||||
import CustomControl from '$lib/components/custom-control/CustomControl.svelte';
|
||||
import LayerTree from './LayerTree.svelte';
|
||||
import CustomControl from '$lib/components/custom-control/CustomControl.svelte';
|
||||
import LayerTree from './LayerTree.svelte';
|
||||
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
|
||||
import { Layers } from 'lucide-svelte';
|
||||
import { Layers } from 'lucide-svelte';
|
||||
|
||||
import { basemaps, defaultBasemap, overlays } from '$lib/assets/layers';
|
||||
import { settings } from '$lib/db';
|
||||
import { map } from '$lib/stores';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { customBasemapUpdate, getLayers } from './utils';
|
||||
import { OverpassLayer } from './OverpassLayer';
|
||||
import { basemaps, defaultBasemap, overlays } from '$lib/assets/layers';
|
||||
import { settings } from '$lib/db';
|
||||
import { map } from '$lib/stores';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { customBasemapUpdate, getLayers } from './utils';
|
||||
import { OverpassLayer } from './OverpassLayer';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let overpassLayer: OverpassLayer;
|
||||
let container: HTMLDivElement;
|
||||
let overpassLayer: OverpassLayer;
|
||||
|
||||
const {
|
||||
currentBasemap,
|
||||
previousBasemap,
|
||||
currentOverlays,
|
||||
currentOverpassQueries,
|
||||
selectedBasemapTree,
|
||||
selectedOverlayTree,
|
||||
selectedOverpassTree,
|
||||
customLayers,
|
||||
opacities
|
||||
} = settings;
|
||||
const {
|
||||
currentBasemap,
|
||||
previousBasemap,
|
||||
currentOverlays,
|
||||
currentOverpassQueries,
|
||||
selectedBasemapTree,
|
||||
selectedOverlayTree,
|
||||
selectedOverpassTree,
|
||||
customLayers,
|
||||
opacities,
|
||||
} = settings;
|
||||
|
||||
function setStyle() {
|
||||
if ($map) {
|
||||
let basemap = basemaps.hasOwnProperty($currentBasemap)
|
||||
? basemaps[$currentBasemap]
|
||||
: ($customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap]);
|
||||
$map.removeImport('basemap');
|
||||
if (typeof basemap === 'string') {
|
||||
$map.addImport({ id: 'basemap', url: basemap }, 'overlays');
|
||||
} else {
|
||||
$map.addImport(
|
||||
{
|
||||
id: 'basemap',
|
||||
data: basemap
|
||||
},
|
||||
'overlays'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
function setStyle() {
|
||||
if ($map) {
|
||||
let basemap = basemaps.hasOwnProperty($currentBasemap)
|
||||
? basemaps[$currentBasemap]
|
||||
: ($customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap]);
|
||||
$map.removeImport('basemap');
|
||||
if (typeof basemap === 'string') {
|
||||
$map.addImport({ id: 'basemap', url: basemap }, 'overlays');
|
||||
} else {
|
||||
$map.addImport(
|
||||
{
|
||||
id: 'basemap',
|
||||
data: basemap,
|
||||
},
|
||||
'overlays'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($map && ($currentBasemap || $customBasemapUpdate)) {
|
||||
setStyle();
|
||||
}
|
||||
$: if ($map && ($currentBasemap || $customBasemapUpdate)) {
|
||||
setStyle();
|
||||
}
|
||||
|
||||
function addOverlay(id: string) {
|
||||
try {
|
||||
let overlay = $customLayers.hasOwnProperty(id) ? $customLayers[id].value : overlays[id];
|
||||
if (typeof overlay === 'string') {
|
||||
$map.addImport({ id, url: overlay });
|
||||
} else {
|
||||
if ($opacities.hasOwnProperty(id)) {
|
||||
overlay = {
|
||||
...overlay,
|
||||
layers: overlay.layers.map((layer) => {
|
||||
if (layer.type === 'raster') {
|
||||
if (!layer.paint) {
|
||||
layer.paint = {};
|
||||
}
|
||||
layer.paint['raster-opacity'] = $opacities[id];
|
||||
}
|
||||
return layer;
|
||||
})
|
||||
};
|
||||
}
|
||||
$map.addImport({
|
||||
id,
|
||||
data: overlay
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
}
|
||||
}
|
||||
function addOverlay(id: string) {
|
||||
try {
|
||||
let overlay = $customLayers.hasOwnProperty(id) ? $customLayers[id].value : overlays[id];
|
||||
if (typeof overlay === 'string') {
|
||||
$map.addImport({ id, url: overlay });
|
||||
} else {
|
||||
if ($opacities.hasOwnProperty(id)) {
|
||||
overlay = {
|
||||
...overlay,
|
||||
layers: overlay.layers.map((layer) => {
|
||||
if (layer.type === 'raster') {
|
||||
if (!layer.paint) {
|
||||
layer.paint = {};
|
||||
}
|
||||
layer.paint['raster-opacity'] = $opacities[id];
|
||||
}
|
||||
return layer;
|
||||
}),
|
||||
};
|
||||
}
|
||||
$map.addImport({
|
||||
id,
|
||||
data: overlay,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
}
|
||||
}
|
||||
|
||||
function updateOverlays() {
|
||||
if ($map && $currentOverlays && $opacities) {
|
||||
let overlayLayers = getLayers($currentOverlays);
|
||||
try {
|
||||
let activeOverlays = $map.getStyle().imports.reduce((acc, i) => {
|
||||
if (!['basemap', 'overlays', 'glyphs-and-sprite'].includes(i.id)) {
|
||||
acc[i.id] = i;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
let toRemove = Object.keys(activeOverlays).filter((id) => !overlayLayers[id]);
|
||||
toRemove.forEach((id) => {
|
||||
$map.removeImport(id);
|
||||
});
|
||||
let toAdd = Object.entries(overlayLayers)
|
||||
.filter(([id, selected]) => selected && !activeOverlays.hasOwnProperty(id))
|
||||
.map(([id]) => id);
|
||||
toAdd.forEach((id) => {
|
||||
addOverlay(id);
|
||||
});
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
}
|
||||
}
|
||||
}
|
||||
function updateOverlays() {
|
||||
if ($map && $currentOverlays && $opacities) {
|
||||
let overlayLayers = getLayers($currentOverlays);
|
||||
try {
|
||||
let activeOverlays = $map.getStyle().imports.reduce((acc, i) => {
|
||||
if (!['basemap', 'overlays', 'glyphs-and-sprite'].includes(i.id)) {
|
||||
acc[i.id] = i;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
let toRemove = Object.keys(activeOverlays).filter((id) => !overlayLayers[id]);
|
||||
toRemove.forEach((id) => {
|
||||
$map.removeImport(id);
|
||||
});
|
||||
let toAdd = Object.entries(overlayLayers)
|
||||
.filter(([id, selected]) => selected && !activeOverlays.hasOwnProperty(id))
|
||||
.map(([id]) => id);
|
||||
toAdd.forEach((id) => {
|
||||
addOverlay(id);
|
||||
});
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($map && $currentOverlays && $opacities) {
|
||||
updateOverlays();
|
||||
}
|
||||
$: if ($map && $currentOverlays && $opacities) {
|
||||
updateOverlays();
|
||||
}
|
||||
|
||||
$: if ($map) {
|
||||
if (overpassLayer) {
|
||||
overpassLayer.remove();
|
||||
}
|
||||
overpassLayer = new OverpassLayer($map);
|
||||
overpassLayer.add();
|
||||
$map.on('style.import.load', updateOverlays);
|
||||
}
|
||||
$: if ($map) {
|
||||
if (overpassLayer) {
|
||||
overpassLayer.remove();
|
||||
}
|
||||
overpassLayer = new OverpassLayer($map);
|
||||
overpassLayer.add();
|
||||
$map.on('style.import.load', updateOverlays);
|
||||
}
|
||||
|
||||
let selectedBasemap = writable(get(currentBasemap));
|
||||
selectedBasemap.subscribe((value) => {
|
||||
// Updates coming from radio buttons
|
||||
if (value !== get(currentBasemap)) {
|
||||
previousBasemap.set(get(currentBasemap));
|
||||
currentBasemap.set(value);
|
||||
}
|
||||
});
|
||||
currentBasemap.subscribe((value) => {
|
||||
// Updates coming from the database, or from the user swapping basemaps
|
||||
if (value !== get(selectedBasemap)) {
|
||||
selectedBasemap.set(value);
|
||||
}
|
||||
});
|
||||
let selectedBasemap = writable(get(currentBasemap));
|
||||
selectedBasemap.subscribe((value) => {
|
||||
// Updates coming from radio buttons
|
||||
if (value !== get(currentBasemap)) {
|
||||
previousBasemap.set(get(currentBasemap));
|
||||
currentBasemap.set(value);
|
||||
}
|
||||
});
|
||||
currentBasemap.subscribe((value) => {
|
||||
// Updates coming from the database, or from the user swapping basemaps
|
||||
if (value !== get(selectedBasemap)) {
|
||||
selectedBasemap.set(value);
|
||||
}
|
||||
});
|
||||
|
||||
let open = false;
|
||||
function openLayerControl() {
|
||||
open = true;
|
||||
}
|
||||
function closeLayerControl() {
|
||||
open = false;
|
||||
}
|
||||
let cancelEvents = false;
|
||||
let open = false;
|
||||
function openLayerControl() {
|
||||
open = true;
|
||||
}
|
||||
function closeLayerControl() {
|
||||
open = false;
|
||||
}
|
||||
let cancelEvents = false;
|
||||
</script>
|
||||
|
||||
<CustomControl class="group min-w-[29px] min-h-[29px] overflow-hidden">
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
bind:this={container}
|
||||
class="h-full w-full"
|
||||
on:mouseenter={openLayerControl}
|
||||
on:mouseleave={closeLayerControl}
|
||||
on:pointerenter={() => {
|
||||
if (!open) {
|
||||
cancelEvents = true;
|
||||
openLayerControl();
|
||||
setTimeout(() => {
|
||||
cancelEvents = false;
|
||||
}, 500);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="flex flex-row justify-center items-center delay-100 transition-[opacity] duration-0 {open
|
||||
? 'opacity-0 w-0 h-0 delay-0'
|
||||
: 'w-[29px] h-[29px]'}"
|
||||
>
|
||||
<Layers size="20" />
|
||||
</div>
|
||||
<div
|
||||
class="transition-[grid-template-rows grid-template-cols] grid grid-rows-[0fr] grid-cols-[0fr] duration-150 h-full {open
|
||||
? 'grid-rows-[1fr] grid-cols-[1fr]'
|
||||
: ''} {cancelEvents ? 'pointer-events-none' : ''}"
|
||||
>
|
||||
<ScrollArea>
|
||||
<div class="h-fit">
|
||||
<div class="p-2">
|
||||
<LayerTree
|
||||
layerTree={$selectedBasemapTree}
|
||||
name="basemaps"
|
||||
bind:selected={$selectedBasemap}
|
||||
/>
|
||||
</div>
|
||||
<Separator class="w-full" />
|
||||
<div class="p-2">
|
||||
{#if $currentOverlays}
|
||||
<LayerTree
|
||||
layerTree={$selectedOverlayTree}
|
||||
name="overlays"
|
||||
multiple={true}
|
||||
bind:checked={$currentOverlays}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<Separator class="w-full" />
|
||||
<div class="p-2">
|
||||
{#if $currentOverpassQueries}
|
||||
<LayerTree
|
||||
layerTree={$selectedOverpassTree}
|
||||
name="overpass"
|
||||
multiple={true}
|
||||
bind:checked={$currentOverpassQueries}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
bind:this={container}
|
||||
class="h-full w-full"
|
||||
on:mouseenter={openLayerControl}
|
||||
on:mouseleave={closeLayerControl}
|
||||
on:pointerenter={() => {
|
||||
if (!open) {
|
||||
cancelEvents = true;
|
||||
openLayerControl();
|
||||
setTimeout(() => {
|
||||
cancelEvents = false;
|
||||
}, 500);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="flex flex-row justify-center items-center delay-100 transition-[opacity] duration-0 {open
|
||||
? 'opacity-0 w-0 h-0 delay-0'
|
||||
: 'w-[29px] h-[29px]'}"
|
||||
>
|
||||
<Layers size="20" />
|
||||
</div>
|
||||
<div
|
||||
class="transition-[grid-template-rows grid-template-cols] grid grid-rows-[0fr] grid-cols-[0fr] duration-150 h-full {open
|
||||
? 'grid-rows-[1fr] grid-cols-[1fr]'
|
||||
: ''} {cancelEvents ? 'pointer-events-none' : ''}"
|
||||
>
|
||||
<ScrollArea>
|
||||
<div class="h-fit">
|
||||
<div class="p-2">
|
||||
<LayerTree
|
||||
layerTree={$selectedBasemapTree}
|
||||
name="basemaps"
|
||||
bind:selected={$selectedBasemap}
|
||||
/>
|
||||
</div>
|
||||
<Separator class="w-full" />
|
||||
<div class="p-2">
|
||||
{#if $currentOverlays}
|
||||
<LayerTree
|
||||
layerTree={$selectedOverlayTree}
|
||||
name="overlays"
|
||||
multiple={true}
|
||||
bind:checked={$currentOverlays}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<Separator class="w-full" />
|
||||
<div class="p-2">
|
||||
{#if $currentOverpassQueries}
|
||||
<LayerTree
|
||||
layerTree={$selectedOverpassTree}
|
||||
name="overpass"
|
||||
multiple={true}
|
||||
bind:checked={$currentOverpassQueries}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</CustomControl>
|
||||
|
||||
<svelte:window
|
||||
on:click={(e) => {
|
||||
if (open && !cancelEvents && !container.contains(e.target)) {
|
||||
closeLayerControl();
|
||||
}
|
||||
}}
|
||||
on:click={(e) => {
|
||||
if (open && !cancelEvents && !container.contains(e.target)) {
|
||||
closeLayerControl();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,189 +1,197 @@
|
||||
<script lang="ts">
|
||||
import LayerTree from './LayerTree.svelte';
|
||||
import LayerTree from './LayerTree.svelte';
|
||||
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import * as Sheet from '$lib/components/ui/sheet';
|
||||
import * as Accordion from '$lib/components/ui/accordion';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Slider } from '$lib/components/ui/slider';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import * as Sheet from '$lib/components/ui/sheet';
|
||||
import * as Accordion from '$lib/components/ui/accordion';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Slider } from '$lib/components/ui/slider';
|
||||
|
||||
import {
|
||||
basemapTree,
|
||||
defaultBasemap,
|
||||
overlays,
|
||||
overlayTree,
|
||||
overpassTree
|
||||
} from '$lib/assets/layers';
|
||||
import { getLayers, isSelected, toggle } from '$lib/components/layer-control/utils';
|
||||
import { settings } from '$lib/db';
|
||||
import {
|
||||
basemapTree,
|
||||
defaultBasemap,
|
||||
overlays,
|
||||
overlayTree,
|
||||
overpassTree,
|
||||
} from '$lib/assets/layers';
|
||||
import { getLayers, isSelected, toggle } from '$lib/components/layer-control/utils';
|
||||
import { settings } from '$lib/db';
|
||||
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { writable } from 'svelte/store';
|
||||
import { map } from '$lib/stores';
|
||||
import CustomLayers from './CustomLayers.svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { writable } from 'svelte/store';
|
||||
import { map } from '$lib/stores';
|
||||
import CustomLayers from './CustomLayers.svelte';
|
||||
|
||||
const {
|
||||
selectedBasemapTree,
|
||||
selectedOverlayTree,
|
||||
selectedOverpassTree,
|
||||
currentBasemap,
|
||||
currentOverlays,
|
||||
customLayers,
|
||||
opacities
|
||||
} = settings;
|
||||
const {
|
||||
selectedBasemapTree,
|
||||
selectedOverlayTree,
|
||||
selectedOverpassTree,
|
||||
currentBasemap,
|
||||
currentOverlays,
|
||||
customLayers,
|
||||
opacities,
|
||||
} = settings;
|
||||
|
||||
export let open: boolean;
|
||||
let accordionValue: string | string[] | undefined = undefined;
|
||||
export let open: boolean;
|
||||
let accordionValue: string | string[] | undefined = undefined;
|
||||
|
||||
let selectedOverlay = writable(undefined);
|
||||
let overlayOpacity = writable([1]);
|
||||
let selectedOverlay = writable(undefined);
|
||||
let overlayOpacity = writable([1]);
|
||||
|
||||
function setOpacityFromSelection() {
|
||||
if ($selectedOverlay) {
|
||||
let overlayId = $selectedOverlay.value;
|
||||
if ($opacities.hasOwnProperty(overlayId)) {
|
||||
$overlayOpacity = [$opacities[overlayId]];
|
||||
} else {
|
||||
$overlayOpacity = [1];
|
||||
}
|
||||
} else {
|
||||
$overlayOpacity = [1];
|
||||
}
|
||||
}
|
||||
function setOpacityFromSelection() {
|
||||
if ($selectedOverlay) {
|
||||
let overlayId = $selectedOverlay.value;
|
||||
if ($opacities.hasOwnProperty(overlayId)) {
|
||||
$overlayOpacity = [$opacities[overlayId]];
|
||||
} else {
|
||||
$overlayOpacity = [1];
|
||||
}
|
||||
} else {
|
||||
$overlayOpacity = [1];
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($selectedBasemapTree && $currentBasemap) {
|
||||
if (!isSelected($selectedBasemapTree, $currentBasemap)) {
|
||||
if (!isSelected($selectedBasemapTree, defaultBasemap)) {
|
||||
$selectedBasemapTree = toggle($selectedBasemapTree, defaultBasemap);
|
||||
}
|
||||
$currentBasemap = defaultBasemap;
|
||||
}
|
||||
}
|
||||
$: if ($selectedBasemapTree && $currentBasemap) {
|
||||
if (!isSelected($selectedBasemapTree, $currentBasemap)) {
|
||||
if (!isSelected($selectedBasemapTree, defaultBasemap)) {
|
||||
$selectedBasemapTree = toggle($selectedBasemapTree, defaultBasemap);
|
||||
}
|
||||
$currentBasemap = defaultBasemap;
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($selectedOverlayTree && $currentOverlays) {
|
||||
let overlayLayers = getLayers($currentOverlays);
|
||||
let toRemove = Object.entries(overlayLayers).filter(
|
||||
([id, checked]) => checked && !isSelected($selectedOverlayTree, id)
|
||||
);
|
||||
if (toRemove.length > 0) {
|
||||
currentOverlays.update((tree) => {
|
||||
toRemove.forEach(([id]) => {
|
||||
toggle(tree, id);
|
||||
});
|
||||
return tree;
|
||||
});
|
||||
}
|
||||
}
|
||||
$: if ($selectedOverlayTree && $currentOverlays) {
|
||||
let overlayLayers = getLayers($currentOverlays);
|
||||
let toRemove = Object.entries(overlayLayers).filter(
|
||||
([id, checked]) => checked && !isSelected($selectedOverlayTree, id)
|
||||
);
|
||||
if (toRemove.length > 0) {
|
||||
currentOverlays.update((tree) => {
|
||||
toRemove.forEach(([id]) => {
|
||||
toggle(tree, id);
|
||||
});
|
||||
return tree;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($selectedOverlay) {
|
||||
setOpacityFromSelection();
|
||||
}
|
||||
$: if ($selectedOverlay) {
|
||||
setOpacityFromSelection();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Sheet.Root bind:open>
|
||||
<Sheet.Trigger class="hidden" />
|
||||
<Sheet.Content>
|
||||
<Sheet.Header class="h-full">
|
||||
<Sheet.Title>{$_('layers.settings')}</Sheet.Title>
|
||||
<ScrollArea class="w-[105%] min-h-full pr-4">
|
||||
<Sheet.Description>
|
||||
{$_('layers.settings_help')}
|
||||
</Sheet.Description>
|
||||
<Accordion.Root class="flex flex-col" bind:value={accordionValue}>
|
||||
<Accordion.Item value="layer-selection" class="flex flex-col">
|
||||
<Accordion.Trigger>{$_('layers.selection')}</Accordion.Trigger>
|
||||
<Accordion.Content class="grow flex flex-col border rounded">
|
||||
<div class="py-2 pl-1 pr-2">
|
||||
<LayerTree
|
||||
layerTree={basemapTree}
|
||||
name="basemapSettings"
|
||||
multiple={true}
|
||||
bind:checked={$selectedBasemapTree}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div class="py-2 pl-1 pr-2">
|
||||
<LayerTree
|
||||
layerTree={overlayTree}
|
||||
name="overlaySettings"
|
||||
multiple={true}
|
||||
bind:checked={$selectedOverlayTree}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div class="py-2 pl-1 pr-2">
|
||||
<LayerTree
|
||||
layerTree={overpassTree}
|
||||
name="overpassSettings"
|
||||
multiple={true}
|
||||
bind:checked={$selectedOverpassTree}
|
||||
/>
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="overlay-opacity">
|
||||
<Accordion.Trigger>{$_('layers.opacity')}</Accordion.Trigger>
|
||||
<Accordion.Content class="flex flex-col gap-3 overflow-visible">
|
||||
<div class="flex flex-row gap-6 items-center">
|
||||
<Label>
|
||||
{$_('layers.custom_layers.overlay')}
|
||||
</Label>
|
||||
<Select.Root bind:selected={$selectedOverlay}>
|
||||
<Select.Trigger class="h-8 mr-1">
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
|
||||
{#each Object.keys(overlays) as id}
|
||||
{#if isSelected($selectedOverlayTree, id)}
|
||||
<Select.Item value={id}>{$_(`layers.label.${id}`)}</Select.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each Object.entries($customLayers) as [id, layer]}
|
||||
{#if layer.layerType === 'overlay'}
|
||||
<Select.Item value={id}>{layer.name}</Select.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<Label class="flex flex-row gap-6 items-center">
|
||||
{$_('menu.style.opacity')}
|
||||
<div class="p-2 pr-3 grow">
|
||||
<Slider
|
||||
bind:value={$overlayOpacity}
|
||||
min={0.1}
|
||||
max={1}
|
||||
step={0.1}
|
||||
disabled={$selectedOverlay === undefined}
|
||||
onValueChange={(value) => {
|
||||
if ($selectedOverlay) {
|
||||
if ($map && isSelected($currentOverlays, $selectedOverlay.value)) {
|
||||
try {
|
||||
$map.removeImport($selectedOverlay.value);
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
}
|
||||
}
|
||||
$opacities[$selectedOverlay.value] = value[0];
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Label>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="custom-layers">
|
||||
<Accordion.Trigger>{$_('layers.custom_layers.title')}</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
<ScrollArea>
|
||||
<CustomLayers />
|
||||
</ScrollArea>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
</ScrollArea>
|
||||
</Sheet.Header>
|
||||
</Sheet.Content>
|
||||
<Sheet.Trigger class="hidden" />
|
||||
<Sheet.Content>
|
||||
<Sheet.Header class="h-full">
|
||||
<Sheet.Title>{$_('layers.settings')}</Sheet.Title>
|
||||
<ScrollArea class="w-[105%] min-h-full pr-4">
|
||||
<Sheet.Description>
|
||||
{$_('layers.settings_help')}
|
||||
</Sheet.Description>
|
||||
<Accordion.Root class="flex flex-col" bind:value={accordionValue}>
|
||||
<Accordion.Item value="layer-selection" class="flex flex-col">
|
||||
<Accordion.Trigger>{$_('layers.selection')}</Accordion.Trigger>
|
||||
<Accordion.Content class="grow flex flex-col border rounded">
|
||||
<div class="py-2 pl-1 pr-2">
|
||||
<LayerTree
|
||||
layerTree={basemapTree}
|
||||
name="basemapSettings"
|
||||
multiple={true}
|
||||
bind:checked={$selectedBasemapTree}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div class="py-2 pl-1 pr-2">
|
||||
<LayerTree
|
||||
layerTree={overlayTree}
|
||||
name="overlaySettings"
|
||||
multiple={true}
|
||||
bind:checked={$selectedOverlayTree}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div class="py-2 pl-1 pr-2">
|
||||
<LayerTree
|
||||
layerTree={overpassTree}
|
||||
name="overpassSettings"
|
||||
multiple={true}
|
||||
bind:checked={$selectedOverpassTree}
|
||||
/>
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="overlay-opacity">
|
||||
<Accordion.Trigger>{$_('layers.opacity')}</Accordion.Trigger>
|
||||
<Accordion.Content class="flex flex-col gap-3 overflow-visible">
|
||||
<div class="flex flex-row gap-6 items-center">
|
||||
<Label>
|
||||
{$_('layers.custom_layers.overlay')}
|
||||
</Label>
|
||||
<Select.Root bind:selected={$selectedOverlay}>
|
||||
<Select.Trigger class="h-8 mr-1">
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
|
||||
{#each Object.keys(overlays) as id}
|
||||
{#if isSelected($selectedOverlayTree, id)}
|
||||
<Select.Item value={id}
|
||||
>{$_(`layers.label.${id}`)}</Select.Item
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each Object.entries($customLayers) as [id, layer]}
|
||||
{#if layer.layerType === 'overlay'}
|
||||
<Select.Item value={id}>{layer.name}</Select.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<Label class="flex flex-row gap-6 items-center">
|
||||
{$_('menu.style.opacity')}
|
||||
<div class="p-2 pr-3 grow">
|
||||
<Slider
|
||||
bind:value={$overlayOpacity}
|
||||
min={0.1}
|
||||
max={1}
|
||||
step={0.1}
|
||||
disabled={$selectedOverlay === undefined}
|
||||
onValueChange={(value) => {
|
||||
if ($selectedOverlay) {
|
||||
if (
|
||||
$map &&
|
||||
isSelected(
|
||||
$currentOverlays,
|
||||
$selectedOverlay.value
|
||||
)
|
||||
) {
|
||||
try {
|
||||
$map.removeImport($selectedOverlay.value);
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
}
|
||||
}
|
||||
$opacities[$selectedOverlay.value] = value[0];
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Label>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="custom-layers">
|
||||
<Accordion.Trigger>{$_('layers.custom_layers.title')}</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
<ScrollArea>
|
||||
<CustomLayers />
|
||||
</ScrollArea>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
</ScrollArea>
|
||||
</Sheet.Header>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import LayerTreeNode from './LayerTreeNode.svelte';
|
||||
import { type LayerTreeType } from '$lib/assets/layers';
|
||||
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
|
||||
import LayerTreeNode from './LayerTreeNode.svelte';
|
||||
import { type LayerTreeType } from '$lib/assets/layers';
|
||||
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
|
||||
|
||||
export let layerTree: LayerTreeType;
|
||||
export let name: string;
|
||||
export let selected: string | undefined = undefined;
|
||||
export let multiple: boolean = false;
|
||||
export let layerTree: LayerTreeType;
|
||||
export let name: string;
|
||||
export let selected: string | undefined = undefined;
|
||||
export let multiple: boolean = false;
|
||||
|
||||
export let checked: LayerTreeType = {};
|
||||
export let checked: LayerTreeType = {};
|
||||
</script>
|
||||
|
||||
<form>
|
||||
<fieldset class="min-w-64 mb-1">
|
||||
<CollapsibleTree nohover={true}>
|
||||
<LayerTreeNode {name} node={layerTree} bind:selected {multiple} bind:checked />
|
||||
</CollapsibleTree>
|
||||
</fieldset>
|
||||
<fieldset class="min-w-64 mb-1">
|
||||
<CollapsibleTree nohover={true}>
|
||||
<LayerTreeNode {name} node={layerTree} bind:selected {multiple} bind:checked />
|
||||
</CollapsibleTree>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -1,86 +1,98 @@
|
||||
<script lang="ts">
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import CollapsibleTreeNode from '../collapsible-tree/CollapsibleTreeNode.svelte';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import CollapsibleTreeNode from '../collapsible-tree/CollapsibleTreeNode.svelte';
|
||||
|
||||
import { type LayerTreeType } from '$lib/assets/layers';
|
||||
import { anySelectedLayer } from './utils';
|
||||
import { type LayerTreeType } from '$lib/assets/layers';
|
||||
import { anySelectedLayer } from './utils';
|
||||
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { settings } from '$lib/db';
|
||||
import { beforeUpdate } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { settings } from '$lib/db';
|
||||
import { beforeUpdate } from 'svelte';
|
||||
|
||||
export let name: string;
|
||||
export let node: LayerTreeType;
|
||||
export let selected: string | undefined = undefined;
|
||||
export let multiple: boolean = false;
|
||||
export let name: string;
|
||||
export let node: LayerTreeType;
|
||||
export let selected: string | undefined = undefined;
|
||||
export let multiple: boolean = false;
|
||||
|
||||
export let checked: LayerTreeType;
|
||||
export let checked: LayerTreeType;
|
||||
|
||||
const { customLayers } = settings;
|
||||
const { customLayers } = settings;
|
||||
|
||||
beforeUpdate(() => {
|
||||
if (checked !== undefined) {
|
||||
Object.keys(node).forEach((id) => {
|
||||
if (!checked.hasOwnProperty(id)) {
|
||||
if (typeof node[id] == 'boolean') {
|
||||
checked[id] = false;
|
||||
} else {
|
||||
checked[id] = {};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
beforeUpdate(() => {
|
||||
if (checked !== undefined) {
|
||||
Object.keys(node).forEach((id) => {
|
||||
if (!checked.hasOwnProperty(id)) {
|
||||
if (typeof node[id] == 'boolean') {
|
||||
checked[id] = false;
|
||||
} else {
|
||||
checked[id] = {};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-[3px]">
|
||||
{#each Object.keys(node) as id}
|
||||
{#if typeof node[id] == 'boolean'}
|
||||
{#if node[id]}
|
||||
<div class="flex flex-row items-center gap-2 first:mt-0.5 h-4">
|
||||
{#if multiple}
|
||||
<Checkbox
|
||||
id="{name}-{id}"
|
||||
{name}
|
||||
value={id}
|
||||
bind:checked={checked[id]}
|
||||
class="scale-90"
|
||||
aria-label={$_(`layers.label.${id}`)}
|
||||
/>
|
||||
{:else}
|
||||
<input id="{name}-{id}" type="radio" {name} value={id} bind:group={selected} />
|
||||
{/if}
|
||||
<Label for="{name}-{id}" class="flex flex-row items-center gap-1">
|
||||
{#if $customLayers.hasOwnProperty(id)}
|
||||
{$customLayers[id].name}
|
||||
{:else}
|
||||
{$_(`layers.label.${id}`)}
|
||||
{/if}
|
||||
</Label>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if anySelectedLayer(node[id])}
|
||||
<CollapsibleTreeNode {id}>
|
||||
<span slot="trigger">{$_(`layers.label.${id}`)}</span>
|
||||
<div slot="content">
|
||||
<svelte:self node={node[id]} {name} bind:selected {multiple} bind:checked={checked[id]} />
|
||||
</div>
|
||||
</CollapsibleTreeNode>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each Object.keys(node) as id}
|
||||
{#if typeof node[id] == 'boolean'}
|
||||
{#if node[id]}
|
||||
<div class="flex flex-row items-center gap-2 first:mt-0.5 h-4">
|
||||
{#if multiple}
|
||||
<Checkbox
|
||||
id="{name}-{id}"
|
||||
{name}
|
||||
value={id}
|
||||
bind:checked={checked[id]}
|
||||
class="scale-90"
|
||||
aria-label={$_(`layers.label.${id}`)}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
id="{name}-{id}"
|
||||
type="radio"
|
||||
{name}
|
||||
value={id}
|
||||
bind:group={selected}
|
||||
/>
|
||||
{/if}
|
||||
<Label for="{name}-{id}" class="flex flex-row items-center gap-1">
|
||||
{#if $customLayers.hasOwnProperty(id)}
|
||||
{$customLayers[id].name}
|
||||
{:else}
|
||||
{$_(`layers.label.${id}`)}
|
||||
{/if}
|
||||
</Label>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if anySelectedLayer(node[id])}
|
||||
<CollapsibleTreeNode {id}>
|
||||
<span slot="trigger">{$_(`layers.label.${id}`)}</span>
|
||||
<div slot="content">
|
||||
<svelte:self
|
||||
node={node[id]}
|
||||
{name}
|
||||
bind:selected
|
||||
{multiple}
|
||||
bind:checked={checked[id]}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTreeNode>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
div :global(input[type='radio']) {
|
||||
@apply appearance-none;
|
||||
@apply w-4 h-4;
|
||||
@apply border-[1.5px] border-primary;
|
||||
@apply rounded-full;
|
||||
@apply ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2;
|
||||
@apply cursor-pointer;
|
||||
@apply checked:bg-primary;
|
||||
@apply checked:bg-clip-content;
|
||||
@apply checked:p-0.5;
|
||||
}
|
||||
div :global(input[type='radio']) {
|
||||
@apply appearance-none;
|
||||
@apply w-4 h-4;
|
||||
@apply border-[1.5px] border-primary;
|
||||
@apply rounded-full;
|
||||
@apply ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2;
|
||||
@apply cursor-pointer;
|
||||
@apply checked:bg-primary;
|
||||
@apply checked:bg-clip-content;
|
||||
@apply checked:p-0.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import SphericalMercator from "@mapbox/sphericalmercator";
|
||||
import { getLayers } from "./utils";
|
||||
import { get, writable } from "svelte/store";
|
||||
import { liveQuery } from "dexie";
|
||||
import { db, settings } from "$lib/db";
|
||||
import { overpassQueryData } from "$lib/assets/layers";
|
||||
import { MapPopup } from "$lib/components/MapPopup";
|
||||
import SphericalMercator from '@mapbox/sphericalmercator';
|
||||
import { getLayers } from './utils';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db, settings } from '$lib/db';
|
||||
import { overpassQueryData } from '$lib/assets/layers';
|
||||
import { MapPopup } from '$lib/components/MapPopup';
|
||||
|
||||
const {
|
||||
currentOverpassQueries
|
||||
} = settings;
|
||||
const { currentOverpassQueries } = settings;
|
||||
|
||||
const mercator = new SphericalMercator({
|
||||
size: 256,
|
||||
@@ -29,7 +27,7 @@ export class OverpassLayer {
|
||||
popup: MapPopup;
|
||||
|
||||
currentQueries: Set<string> = new Set();
|
||||
nextQueries: Map<string, { x: number, y: number, queries: string[] }> = new Map();
|
||||
nextQueries: Map<string, { x: number; y: number; queries: string[] }> = new Map();
|
||||
|
||||
unsubscribes: (() => void)[] = [];
|
||||
queryIfNeededBinded = this.queryIfNeeded.bind(this);
|
||||
@@ -50,10 +48,12 @@ export class OverpassLayer {
|
||||
this.map.on('moveend', this.queryIfNeededBinded);
|
||||
this.map.on('style.import.load', this.updateBinded);
|
||||
this.unsubscribes.push(data.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(currentOverpassQueries.subscribe(() => {
|
||||
this.updateBinded();
|
||||
this.queryIfNeededBinded();
|
||||
}));
|
||||
this.unsubscribes.push(
|
||||
currentOverpassQueries.subscribe(() => {
|
||||
this.updateBinded();
|
||||
this.queryIfNeededBinded();
|
||||
})
|
||||
);
|
||||
|
||||
this.update();
|
||||
}
|
||||
@@ -126,8 +126,8 @@ export class OverpassLayer {
|
||||
this.popup.setItem({
|
||||
item: {
|
||||
...e.features[0].properties,
|
||||
sym: overpassQueryData[e.features[0].properties.query].symbol ?? ''
|
||||
}
|
||||
sym: overpassQueryData[e.features[0].properties.query].symbol ?? '',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -146,12 +146,23 @@ export class OverpassLayer {
|
||||
continue;
|
||||
}
|
||||
|
||||
db.overpasstiles.where('[x+y]').equals([x, y]).toArray().then((querytiles) => {
|
||||
let missingQueries = queries.filter((query) => !querytiles.some((querytile) => querytile.query === query && time - querytile.time < this.expirationTime));
|
||||
if (missingQueries.length > 0) {
|
||||
this.queryTile(x, y, missingQueries);
|
||||
}
|
||||
});
|
||||
db.overpasstiles
|
||||
.where('[x+y]')
|
||||
.equals([x, y])
|
||||
.toArray()
|
||||
.then((querytiles) => {
|
||||
let missingQueries = queries.filter(
|
||||
(query) =>
|
||||
!querytiles.some(
|
||||
(querytile) =>
|
||||
querytile.query === query &&
|
||||
time - querytile.time < this.expirationTime
|
||||
)
|
||||
);
|
||||
if (missingQueries.length > 0) {
|
||||
this.queryTile(x, y, missingQueries);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,13 +176,16 @@ export class OverpassLayer {
|
||||
|
||||
const bounds = mercator.bbox(x, y, this.queryZoom);
|
||||
fetch(`${this.overpassUrl}?data=${getQueryForBounds(bounds, queries)}`)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
this.currentQueries.delete(`${x},${y}`);
|
||||
return Promise.reject();
|
||||
}, () => (this.currentQueries.delete(`${x},${y}`)))
|
||||
.then(
|
||||
(response) => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
this.currentQueries.delete(`${x},${y}`);
|
||||
return Promise.reject();
|
||||
},
|
||||
() => this.currentQueries.delete(`${x},${y}`)
|
||||
)
|
||||
.then((data) => this.storeOverpassData(x, y, queries, data))
|
||||
.catch(() => this.currentQueries.delete(`${x},${y}`));
|
||||
}
|
||||
@@ -179,7 +193,7 @@ export class OverpassLayer {
|
||||
storeOverpassData(x: number, y: number, queries: string[], data: any) {
|
||||
let time = Date.now();
|
||||
let queryTiles = queries.map((query) => ({ x, y, query, time }));
|
||||
let pois: { query: string, id: number, poi: GeoJSON.Feature }[] = [];
|
||||
let pois: { query: string; id: number; poi: GeoJSON.Feature }[] = [];
|
||||
|
||||
if (data.elements === undefined) {
|
||||
return;
|
||||
@@ -195,7 +209,9 @@ export class OverpassLayer {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: element.center ? [element.center.lon, element.center.lat] : [element.lon, element.lat],
|
||||
coordinates: element.center
|
||||
? [element.center.lon, element.center.lat]
|
||||
: [element.lon, element.lat],
|
||||
},
|
||||
properties: {
|
||||
id: element.id,
|
||||
@@ -203,9 +219,9 @@ export class OverpassLayer {
|
||||
lon: element.center ? element.center.lon : element.lon,
|
||||
query: query,
|
||||
icon: `overpass-${query}`,
|
||||
tags: element.tags
|
||||
tags: element.tags,
|
||||
},
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -228,11 +244,13 @@ export class OverpassLayer {
|
||||
if (!this.map.hasImage(`overpass-${query}`)) {
|
||||
this.map.addImage(`overpass-${query}`, icon);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Lucide icons are SVG files with a 24x24 viewBox
|
||||
// Create a new SVG with a 32x32 viewBox and center the icon in a circle
|
||||
icon.src = 'data:image/svg+xml,' + encodeURIComponent(`
|
||||
icon.src =
|
||||
'data:image/svg+xml,' +
|
||||
encodeURIComponent(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
|
||||
<circle cx="20" cy="20" r="20" fill="${overpassQueryData[query].icon.color}" />
|
||||
<g transform="translate(8 8)">
|
||||
@@ -264,9 +282,14 @@ function getQuery(query: string) {
|
||||
function getQueryItem(tags: Record<string, string | boolean | string[]>) {
|
||||
let arrayEntry = Object.entries(tags).find(([_, value]) => Array.isArray(value));
|
||||
if (arrayEntry !== undefined) {
|
||||
return arrayEntry[1].map((val) => `nwr${Object.entries(tags)
|
||||
.map(([tag, value]) => `[${tag}=${tag === arrayEntry[0] ? val : value}]`)
|
||||
.join('')};`).join('');
|
||||
return arrayEntry[1]
|
||||
.map(
|
||||
(val) =>
|
||||
`nwr${Object.entries(tags)
|
||||
.map(([tag, value]) => `[${tag}=${tag === arrayEntry[0] ? val : value}]`)
|
||||
.join('')};`
|
||||
)
|
||||
.join('');
|
||||
} else {
|
||||
return `nwr${Object.entries(tags)
|
||||
.map(([tag, value]) => `[${tag}=${value}]`)
|
||||
@@ -283,8 +306,9 @@ function belongsToQuery(element: any, query: string) {
|
||||
}
|
||||
|
||||
function belongsToQueryItem(element: any, tags: Record<string, string | boolean | string[]>) {
|
||||
return Object.entries(tags)
|
||||
.every(([tag, value]) => Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value);
|
||||
return Object.entries(tags).every(([tag, value]) =>
|
||||
Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value
|
||||
);
|
||||
}
|
||||
|
||||
function getCurrentQueries() {
|
||||
@@ -293,5 +317,7 @@ function getCurrentQueries() {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(getLayers(currentQueries)).filter(([_, selected]) => selected).map(([query, _]) => query);
|
||||
}
|
||||
return Object.entries(getLayers(currentQueries))
|
||||
.filter(([_, selected]) => selected)
|
||||
.map(([query, _]) => query);
|
||||
}
|
||||
|
||||
@@ -1,95 +1,95 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import { PencilLine, MapPin } from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { dbUtils } from '$lib/db';
|
||||
import type { PopupItem } from '$lib/components/MapPopup';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import { PencilLine, MapPin } from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { dbUtils } from '$lib/db';
|
||||
import type { PopupItem } from '$lib/components/MapPopup';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
|
||||
export let poi: PopupItem<any>;
|
||||
export let poi: PopupItem<any>;
|
||||
|
||||
let tags = {};
|
||||
let name = '';
|
||||
$: if (poi) {
|
||||
tags = JSON.parse(poi.item.tags);
|
||||
if (tags.name !== undefined && tags.name !== '') {
|
||||
name = tags.name;
|
||||
} else {
|
||||
name = $_(`layers.label.${poi.item.query}`);
|
||||
}
|
||||
}
|
||||
let tags = {};
|
||||
let name = '';
|
||||
$: if (poi) {
|
||||
tags = JSON.parse(poi.item.tags);
|
||||
if (tags.name !== undefined && tags.name !== '') {
|
||||
name = tags.name;
|
||||
} else {
|
||||
name = $_(`layers.label.${poi.item.query}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw]">
|
||||
<Card.Header class="p-0">
|
||||
<Card.Title class="text-md">
|
||||
<div class="flex flex-row gap-3">
|
||||
<div class="flex flex-col">
|
||||
{name}
|
||||
<div class="text-muted-foreground text-sm font-normal">
|
||||
{poi.item.lat.toFixed(6)}° {poi.item.lon.toFixed(6)}°
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
class="ml-auto p-1.5 h-8"
|
||||
variant="outline"
|
||||
href="https://www.openstreetmap.org/edit?editor=id&node={poi.item.id}"
|
||||
target="_blank"
|
||||
>
|
||||
<PencilLine size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col p-0 text-sm mt-1 whitespace-normal break-all">
|
||||
<ScrollArea class="flex flex-col" viewportClasses="max-h-[30dvh]">
|
||||
{#if tags.image || tags['image:0']}
|
||||
<div class="w-full rounded-md overflow-clip my-2 max-w-96 mx-auto">
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<img src={tags.image ?? tags['image:0']} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-[auto_auto] gap-x-3">
|
||||
{#each Object.entries(tags) as [key, value]}
|
||||
{#if key !== 'name' && !key.includes('image')}
|
||||
<span class="font-mono">{key}</span>
|
||||
{#if key === 'website' || key.startsWith('website:') || key === 'contact:website' || key === 'contact:facebook' || key === 'contact:instagram' || key === 'contact:twitter'}
|
||||
<a href={value} target="_blank" class="text-link underline">{value}</a>
|
||||
{:else if key === 'phone' || key === 'contact:phone'}
|
||||
<a href={'tel:' + value} class="text-link underline">{value}</a>
|
||||
{:else if key === 'email' || key === 'contact:email'}
|
||||
<a href={'mailto:' + value} class="text-link underline">{value}</a>
|
||||
{:else}
|
||||
<span>{value}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<Button
|
||||
class="mt-2"
|
||||
variant="outline"
|
||||
disabled={$selection.size === 0}
|
||||
on:click={() => {
|
||||
let desc = Object.entries(tags)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\n');
|
||||
dbUtils.addOrUpdateWaypoint({
|
||||
attributes: {
|
||||
lat: poi.item.lat,
|
||||
lon: poi.item.lon
|
||||
},
|
||||
name: name,
|
||||
desc: desc,
|
||||
cmt: desc,
|
||||
sym: poi.item.sym
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MapPin size="16" class="mr-1" />
|
||||
{$_('toolbar.waypoint.add')}
|
||||
</Button>
|
||||
</Card.Content>
|
||||
<Card.Header class="p-0">
|
||||
<Card.Title class="text-md">
|
||||
<div class="flex flex-row gap-3">
|
||||
<div class="flex flex-col">
|
||||
{name}
|
||||
<div class="text-muted-foreground text-sm font-normal">
|
||||
{poi.item.lat.toFixed(6)}° {poi.item.lon.toFixed(6)}°
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
class="ml-auto p-1.5 h-8"
|
||||
variant="outline"
|
||||
href="https://www.openstreetmap.org/edit?editor=id&node={poi.item.id}"
|
||||
target="_blank"
|
||||
>
|
||||
<PencilLine size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col p-0 text-sm mt-1 whitespace-normal break-all">
|
||||
<ScrollArea class="flex flex-col" viewportClasses="max-h-[30dvh]">
|
||||
{#if tags.image || tags['image:0']}
|
||||
<div class="w-full rounded-md overflow-clip my-2 max-w-96 mx-auto">
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<img src={tags.image ?? tags['image:0']} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-[auto_auto] gap-x-3">
|
||||
{#each Object.entries(tags) as [key, value]}
|
||||
{#if key !== 'name' && !key.includes('image')}
|
||||
<span class="font-mono">{key}</span>
|
||||
{#if key === 'website' || key.startsWith('website:') || key === 'contact:website' || key === 'contact:facebook' || key === 'contact:instagram' || key === 'contact:twitter'}
|
||||
<a href={value} target="_blank" class="text-link underline">{value}</a>
|
||||
{:else if key === 'phone' || key === 'contact:phone'}
|
||||
<a href={'tel:' + value} class="text-link underline">{value}</a>
|
||||
{:else if key === 'email' || key === 'contact:email'}
|
||||
<a href={'mailto:' + value} class="text-link underline">{value}</a>
|
||||
{:else}
|
||||
<span>{value}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<Button
|
||||
class="mt-2"
|
||||
variant="outline"
|
||||
disabled={$selection.size === 0}
|
||||
on:click={() => {
|
||||
let desc = Object.entries(tags)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\n');
|
||||
dbUtils.addOrUpdateWaypoint({
|
||||
attributes: {
|
||||
lat: poi.item.lat,
|
||||
lon: poi.item.lon,
|
||||
},
|
||||
name: name,
|
||||
desc: desc,
|
||||
cmt: desc,
|
||||
sym: poi.item.sym,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MapPin size="16" class="mr-1" />
|
||||
{$_('toolbar.waypoint.add')}
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
import type { LayerTreeType } from "$lib/assets/layers";
|
||||
import { writable } from "svelte/store";
|
||||
import type { LayerTreeType } from '$lib/assets/layers';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export function anySelectedLayer(node: LayerTreeType) {
|
||||
return Object.keys(node).find((id) => {
|
||||
if (typeof node[id] == "boolean") {
|
||||
if (node[id]) {
|
||||
return true;
|
||||
return (
|
||||
Object.keys(node).find((id) => {
|
||||
if (typeof node[id] == 'boolean') {
|
||||
if (node[id]) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
if (anySelectedLayer(node[id])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (anySelectedLayer(node[id])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}) !== undefined;
|
||||
return false;
|
||||
}) !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function getLayers(node: LayerTreeType, layers: { [key: string]: boolean } = {}): { [key: string]: boolean } {
|
||||
export function getLayers(
|
||||
node: LayerTreeType,
|
||||
layers: { [key: string]: boolean } = {}
|
||||
): { [key: string]: boolean } {
|
||||
Object.keys(node).forEach((id) => {
|
||||
if (typeof node[id] == "boolean") {
|
||||
if (typeof node[id] == 'boolean') {
|
||||
layers[id] = node[id];
|
||||
} else {
|
||||
getLayers(node[id], layers);
|
||||
@@ -32,7 +37,7 @@ export function isSelected(node: LayerTreeType, id: string) {
|
||||
if (key === id) {
|
||||
return node[key];
|
||||
}
|
||||
if (typeof node[key] !== "boolean" && isSelected(node[key], id)) {
|
||||
if (typeof node[key] !== 'boolean' && isSelected(node[key], id)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -43,11 +48,11 @@ export function toggle(node: LayerTreeType, id: string) {
|
||||
Object.keys(node).forEach((key) => {
|
||||
if (key === id) {
|
||||
node[key] = !node[key];
|
||||
} else if (typeof node[key] !== "boolean") {
|
||||
} else if (typeof node[key] !== 'boolean') {
|
||||
toggle(node[key], id);
|
||||
}
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
export const customBasemapUpdate = writable(0);
|
||||
export const customBasemapUpdate = writable(0);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { resetCursor, setCrosshairCursor } from "$lib/utils";
|
||||
import type mapboxgl from "mapbox-gl";
|
||||
import { resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||
import type mapboxgl from 'mapbox-gl';
|
||||
|
||||
export class GoogleRedirect {
|
||||
map: mapboxgl.Map;
|
||||
@@ -30,4 +30,4 @@ export class GoogleRedirect {
|
||||
`https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${e.lngLat.lat},${e.lngLat.lng}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import mapboxgl, { type LayerSpecification, type VectorSourceSpecification } from "mapbox-gl";
|
||||
import mapboxgl, { type LayerSpecification, type VectorSourceSpecification } from 'mapbox-gl';
|
||||
import { Viewer, type ViewerBearingEvent } from 'mapillary-js/dist/mapillary.module';
|
||||
import 'mapillary-js/dist/mapillary.css';
|
||||
import { resetCursor, setPointerCursor } from "$lib/utils";
|
||||
import type { Writable } from "svelte/store";
|
||||
import { resetCursor, setPointerCursor } from '$lib/utils';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
const mapillarySource: VectorSourceSpecification = {
|
||||
type: 'vector',
|
||||
tiles: ['https://tiles.mapillary.com/maps/vtp/mly1_computed_public/2/{z}/{x}/{y}?access_token=MLY|4381405525255083|3204871ec181638c3c31320490f03011'],
|
||||
tiles: [
|
||||
'https://tiles.mapillary.com/maps/vtp/mly1_computed_public/2/{z}/{x}/{y}?access_token=MLY|4381405525255083|3204871ec181638c3c31320490f03011',
|
||||
],
|
||||
minzoom: 6,
|
||||
maxzoom: 14,
|
||||
};
|
||||
@@ -70,7 +72,7 @@ export class MapillaryLayer {
|
||||
|
||||
this.marker = new mapboxgl.Marker({
|
||||
rotationAlignment: 'map',
|
||||
element
|
||||
element,
|
||||
});
|
||||
|
||||
this.viewer.on('position', async () => {
|
||||
@@ -145,4 +147,4 @@ export class MapillaryLayer {
|
||||
onMouseLeave() {
|
||||
resetCursor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +1,74 @@
|
||||
<script lang="ts">
|
||||
import CustomControl from '$lib/components/custom-control/CustomControl.svelte';
|
||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||
import { Toggle } from '$lib/components/ui/toggle';
|
||||
import { PersonStanding, X } from 'lucide-svelte';
|
||||
import { MapillaryLayer } from './Mapillary';
|
||||
import { GoogleRedirect } from './Google';
|
||||
import { map, streetViewEnabled } from '$lib/stores';
|
||||
import { settings } from '$lib/db';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { writable } from 'svelte/store';
|
||||
import CustomControl from '$lib/components/custom-control/CustomControl.svelte';
|
||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||
import { Toggle } from '$lib/components/ui/toggle';
|
||||
import { PersonStanding, X } from 'lucide-svelte';
|
||||
import { MapillaryLayer } from './Mapillary';
|
||||
import { GoogleRedirect } from './Google';
|
||||
import { map, streetViewEnabled } from '$lib/stores';
|
||||
import { settings } from '$lib/db';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
const { streetViewSource } = settings;
|
||||
const { streetViewSource } = settings;
|
||||
|
||||
let googleRedirect: GoogleRedirect;
|
||||
let mapillaryLayer: MapillaryLayer;
|
||||
let mapillaryOpen = writable(false);
|
||||
let container: HTMLElement;
|
||||
let googleRedirect: GoogleRedirect;
|
||||
let mapillaryLayer: MapillaryLayer;
|
||||
let mapillaryOpen = writable(false);
|
||||
let container: HTMLElement;
|
||||
|
||||
$: if ($map) {
|
||||
googleRedirect = new GoogleRedirect($map);
|
||||
mapillaryLayer = new MapillaryLayer($map, container, mapillaryOpen);
|
||||
}
|
||||
$: if ($map) {
|
||||
googleRedirect = new GoogleRedirect($map);
|
||||
mapillaryLayer = new MapillaryLayer($map, container, mapillaryOpen);
|
||||
}
|
||||
|
||||
$: if (mapillaryLayer) {
|
||||
if ($streetViewSource === 'mapillary') {
|
||||
googleRedirect.remove();
|
||||
if ($streetViewEnabled) {
|
||||
mapillaryLayer.add();
|
||||
} else {
|
||||
mapillaryLayer.remove();
|
||||
}
|
||||
} else {
|
||||
mapillaryLayer.remove();
|
||||
if ($streetViewEnabled) {
|
||||
googleRedirect.add();
|
||||
} else {
|
||||
googleRedirect.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
$: if (mapillaryLayer) {
|
||||
if ($streetViewSource === 'mapillary') {
|
||||
googleRedirect.remove();
|
||||
if ($streetViewEnabled) {
|
||||
mapillaryLayer.add();
|
||||
} else {
|
||||
mapillaryLayer.remove();
|
||||
}
|
||||
} else {
|
||||
mapillaryLayer.remove();
|
||||
if ($streetViewEnabled) {
|
||||
googleRedirect.add();
|
||||
} else {
|
||||
googleRedirect.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<CustomControl class="w-[29px] h-[29px] shrink-0">
|
||||
<Tooltip class="w-full h-full" side="left" label={$_('menu.toggle_street_view')}>
|
||||
<Toggle
|
||||
bind:pressed={$streetViewEnabled}
|
||||
class="w-full h-full rounded p-0"
|
||||
aria-label={$_('menu.toggle_street_view')}
|
||||
>
|
||||
<PersonStanding size="22" />
|
||||
</Toggle>
|
||||
</Tooltip>
|
||||
<Tooltip class="w-full h-full" side="left" label={$_('menu.toggle_street_view')}>
|
||||
<Toggle
|
||||
bind:pressed={$streetViewEnabled}
|
||||
class="w-full h-full rounded p-0"
|
||||
aria-label={$_('menu.toggle_street_view')}
|
||||
>
|
||||
<PersonStanding size="22" />
|
||||
</Toggle>
|
||||
</Tooltip>
|
||||
</CustomControl>
|
||||
|
||||
<div
|
||||
bind:this={container}
|
||||
class="{$mapillaryOpen
|
||||
? ''
|
||||
: 'hidden'} !absolute bottom-[44px] right-2.5 z-10 w-[40%] h-[40%] bg-background rounded-md overflow-hidden border-background border-2"
|
||||
bind:this={container}
|
||||
class="{$mapillaryOpen
|
||||
? ''
|
||||
: 'hidden'} !absolute bottom-[44px] right-2.5 z-10 w-[40%] h-[40%] bg-background rounded-md overflow-hidden border-background border-2"
|
||||
>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="absolute top-0 right-0 z-10 bg-background p-1 rounded-bl-md cursor-pointer"
|
||||
on:click={() => {
|
||||
if (mapillaryLayer) {
|
||||
mapillaryLayer.closePopup();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X size="16" />
|
||||
</div>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="absolute top-0 right-0 z-10 bg-background p-1 rounded-bl-md cursor-pointer"
|
||||
on:click={() => {
|
||||
if (mapillaryLayer) {
|
||||
mapillaryLayer.closePopup();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X size="16" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { Tool } from '$lib/stores';
|
||||
import ToolbarItem from './ToolbarItem.svelte';
|
||||
import {
|
||||
Group,
|
||||
CalendarClock,
|
||||
Pencil,
|
||||
SquareDashedMousePointer,
|
||||
Ungroup,
|
||||
MapPin,
|
||||
Filter,
|
||||
Scissors,
|
||||
MountainSnow
|
||||
} from 'lucide-svelte';
|
||||
import { Tool } from '$lib/stores';
|
||||
import ToolbarItem from './ToolbarItem.svelte';
|
||||
import {
|
||||
Group,
|
||||
CalendarClock,
|
||||
Pencil,
|
||||
SquareDashedMousePointer,
|
||||
Ungroup,
|
||||
MapPin,
|
||||
Filter,
|
||||
Scissors,
|
||||
MountainSnow,
|
||||
} from 'lucide-svelte';
|
||||
|
||||
import { _ } from 'svelte-i18n';
|
||||
import ToolbarItemMenu from './ToolbarItemMenu.svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import ToolbarItemMenu from './ToolbarItemMenu.svelte';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-row w-full items-center pr-12">
|
||||
<div
|
||||
class="h-fit flex flex-col p-1 gap-1.5 bg-background rounded-r-md pointer-events-auto shadow-md {$$props.class ??
|
||||
''}"
|
||||
>
|
||||
<ToolbarItem tool={Tool.ROUTING} label={$_('toolbar.routing.tooltip')}>
|
||||
<Pencil slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.WAYPOINT} label={$_('toolbar.waypoint.tooltip')}>
|
||||
<MapPin slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.SCISSORS} label={$_('toolbar.scissors.tooltip')}>
|
||||
<Scissors slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.TIME} label={$_('toolbar.time.tooltip')}>
|
||||
<CalendarClock slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.MERGE} label={$_('toolbar.merge.tooltip')}>
|
||||
<Group slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.EXTRACT} label={$_('toolbar.extract.tooltip')}>
|
||||
<Ungroup slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.ELEVATION} label={$_('toolbar.elevation.button')}>
|
||||
<MountainSnow slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.REDUCE} label={$_('toolbar.reduce.tooltip')}>
|
||||
<Filter slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.CLEAN} label={$_('toolbar.clean.tooltip')}>
|
||||
<SquareDashedMousePointer slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
</div>
|
||||
<ToolbarItemMenu class={$$props.class ?? ''} />
|
||||
<div
|
||||
class="h-fit flex flex-col p-1 gap-1.5 bg-background rounded-r-md pointer-events-auto shadow-md {$$props.class ??
|
||||
''}"
|
||||
>
|
||||
<ToolbarItem tool={Tool.ROUTING} label={$_('toolbar.routing.tooltip')}>
|
||||
<Pencil slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.WAYPOINT} label={$_('toolbar.waypoint.tooltip')}>
|
||||
<MapPin slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.SCISSORS} label={$_('toolbar.scissors.tooltip')}>
|
||||
<Scissors slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.TIME} label={$_('toolbar.time.tooltip')}>
|
||||
<CalendarClock slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.MERGE} label={$_('toolbar.merge.tooltip')}>
|
||||
<Group slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.EXTRACT} label={$_('toolbar.extract.tooltip')}>
|
||||
<Ungroup slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.ELEVATION} label={$_('toolbar.elevation.button')}>
|
||||
<MountainSnow slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.REDUCE} label={$_('toolbar.reduce.tooltip')}>
|
||||
<Filter slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.CLEAN} label={$_('toolbar.clean.tooltip')}>
|
||||
<SquareDashedMousePointer slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
</div>
|
||||
<ToolbarItemMenu class={$$props.class ?? ''} />
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
import { currentTool, type Tool } from '$lib/stores';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
import { currentTool, type Tool } from '$lib/stores';
|
||||
|
||||
export let tool: Tool;
|
||||
export let label: string;
|
||||
export let tool: Tool;
|
||||
export let label: string;
|
||||
|
||||
function toggleTool() {
|
||||
currentTool.update((current) => (current === tool ? null : tool));
|
||||
}
|
||||
function toggleTool() {
|
||||
currentTool.update((current) => (current === tool ? null : tool));
|
||||
}
|
||||
</script>
|
||||
|
||||
<Tooltip.Root openDelay={300}>
|
||||
<Tooltip.Trigger asChild let:builder>
|
||||
<Button
|
||||
builders={[builder]}
|
||||
variant="ghost"
|
||||
class="h-[26px] px-1 py-1.5 {$currentTool === tool ? 'bg-accent' : ''}"
|
||||
on:click={toggleTool}
|
||||
aria-label={label}
|
||||
>
|
||||
<slot name="icon" />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="right">
|
||||
<span>{label}</span>
|
||||
</Tooltip.Content>
|
||||
<Tooltip.Trigger asChild let:builder>
|
||||
<Button
|
||||
builders={[builder]}
|
||||
variant="ghost"
|
||||
class="h-[26px] px-1 py-1.5 {$currentTool === tool ? 'bg-accent' : ''}"
|
||||
on:click={toggleTool}
|
||||
aria-label={label}
|
||||
>
|
||||
<slot name="icon" />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="right">
|
||||
<span>{label}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
@@ -1,75 +1,75 @@
|
||||
<script lang="ts">
|
||||
import { Tool, currentTool } from '$lib/stores';
|
||||
import { settings } from '$lib/db';
|
||||
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/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';
|
||||
import Extract from '$lib/components/toolbar/tools/Extract.svelte';
|
||||
import Elevation from '$lib/components/toolbar/tools/Elevation.svelte';
|
||||
import Clean from '$lib/components/toolbar/tools/Clean.svelte';
|
||||
import Reduce from '$lib/components/toolbar/tools/Reduce.svelte';
|
||||
import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { Tool, currentTool } from '$lib/stores';
|
||||
import { settings } from '$lib/db';
|
||||
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/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';
|
||||
import Extract from '$lib/components/toolbar/tools/Extract.svelte';
|
||||
import Elevation from '$lib/components/toolbar/tools/Elevation.svelte';
|
||||
import Clean from '$lib/components/toolbar/tools/Clean.svelte';
|
||||
import Reduce from '$lib/components/toolbar/tools/Reduce.svelte';
|
||||
import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
|
||||
const { minimizeRoutingMenu } = settings;
|
||||
const { minimizeRoutingMenu } = settings;
|
||||
|
||||
let popupElement: HTMLElement;
|
||||
let popup: mapboxgl.Popup;
|
||||
let popupElement: HTMLElement;
|
||||
let popup: mapboxgl.Popup;
|
||||
|
||||
onMount(() => {
|
||||
popup = new mapboxgl.Popup({
|
||||
closeButton: false,
|
||||
maxWidth: undefined
|
||||
});
|
||||
popup.setDOMContent(popupElement);
|
||||
popupElement.classList.remove('hidden');
|
||||
});
|
||||
onMount(() => {
|
||||
popup = new mapboxgl.Popup({
|
||||
closeButton: false,
|
||||
maxWidth: undefined,
|
||||
});
|
||||
popup.setDOMContent(popupElement);
|
||||
popupElement.classList.remove('hidden');
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $currentTool !== null}
|
||||
<div
|
||||
in:flyAndScale={{ x: -2, y: 0, duration: 100 }}
|
||||
class="translate-x-1 h-full {$$props.class ?? ''}"
|
||||
>
|
||||
<div class="rounded-md shadow-md pointer-events-auto">
|
||||
<Card.Root class="rounded-md border-none">
|
||||
<Card.Content class="p-2.5">
|
||||
{#if $currentTool === Tool.ROUTING}
|
||||
<Routing {popup} {popupElement} bind:minimized={$minimizeRoutingMenu} />
|
||||
{:else if $currentTool === Tool.SCISSORS}
|
||||
<Scissors />
|
||||
{:else if $currentTool === Tool.WAYPOINT}
|
||||
<Waypoint />
|
||||
{:else if $currentTool === Tool.TIME}
|
||||
<Time />
|
||||
{:else if $currentTool === Tool.MERGE}
|
||||
<Merge />
|
||||
{:else if $currentTool === Tool.ELEVATION}
|
||||
<Elevation />
|
||||
{:else if $currentTool === Tool.EXTRACT}
|
||||
<Extract />
|
||||
{:else if $currentTool === Tool.CLEAN}
|
||||
<Clean />
|
||||
{:else if $currentTool === Tool.REDUCE}
|
||||
<Reduce />
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
in:flyAndScale={{ x: -2, y: 0, duration: 100 }}
|
||||
class="translate-x-1 h-full {$$props.class ?? ''}"
|
||||
>
|
||||
<div class="rounded-md shadow-md pointer-events-auto">
|
||||
<Card.Root class="rounded-md border-none">
|
||||
<Card.Content class="p-2.5">
|
||||
{#if $currentTool === Tool.ROUTING}
|
||||
<Routing {popup} {popupElement} bind:minimized={$minimizeRoutingMenu} />
|
||||
{:else if $currentTool === Tool.SCISSORS}
|
||||
<Scissors />
|
||||
{:else if $currentTool === Tool.WAYPOINT}
|
||||
<Waypoint />
|
||||
{:else if $currentTool === Tool.TIME}
|
||||
<Time />
|
||||
{:else if $currentTool === Tool.MERGE}
|
||||
<Merge />
|
||||
{:else if $currentTool === Tool.ELEVATION}
|
||||
<Elevation />
|
||||
{:else if $currentTool === Tool.EXTRACT}
|
||||
<Extract />
|
||||
{:else if $currentTool === Tool.CLEAN}
|
||||
<Clean />
|
||||
{:else if $currentTool === Tool.REDUCE}
|
||||
<Reduce />
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<svelte:window
|
||||
on:keydown={(e) => {
|
||||
if ($currentTool !== null && e.key === 'Escape') {
|
||||
currentTool.set(null);
|
||||
}
|
||||
}}
|
||||
on:keydown={(e) => {
|
||||
if ($currentTool !== null && e.key === 'Escape') {
|
||||
currentTool.set(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<RoutingControlPopup bind:element={popupElement} />
|
||||
|
||||
@@ -1,188 +1,188 @@
|
||||
<script lang="ts" context="module">
|
||||
enum CleanType {
|
||||
INSIDE = 'inside',
|
||||
OUTSIDE = 'outside'
|
||||
}
|
||||
enum CleanType {
|
||||
INSIDE = 'inside',
|
||||
OUTSIDE = 'outside',
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
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 { Button } from '$lib/components/ui/button';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||
import { Trash2 } from 'lucide-svelte';
|
||||
import { map } from '$lib/stores';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import { dbUtils } from '$lib/db';
|
||||
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 { Button } from '$lib/components/ui/button';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||
import { Trash2 } from 'lucide-svelte';
|
||||
import { map } from '$lib/stores';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import { dbUtils } from '$lib/db';
|
||||
|
||||
let cleanType = CleanType.INSIDE;
|
||||
let deleteTrackpoints = true;
|
||||
let deleteWaypoints = true;
|
||||
let rectangleCoordinates: mapboxgl.LngLat[] = [];
|
||||
let cleanType = CleanType.INSIDE;
|
||||
let deleteTrackpoints = true;
|
||||
let deleteWaypoints = true;
|
||||
let rectangleCoordinates: mapboxgl.LngLat[] = [];
|
||||
|
||||
function updateRectangle() {
|
||||
if ($map) {
|
||||
if (rectangleCoordinates.length != 2) {
|
||||
if ($map.getLayer('rectangle')) {
|
||||
$map.removeLayer('rectangle');
|
||||
}
|
||||
} else {
|
||||
let data = {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[rectangleCoordinates[0].lng, rectangleCoordinates[0].lat],
|
||||
[rectangleCoordinates[1].lng, rectangleCoordinates[0].lat],
|
||||
[rectangleCoordinates[1].lng, rectangleCoordinates[1].lat],
|
||||
[rectangleCoordinates[0].lng, rectangleCoordinates[1].lat],
|
||||
[rectangleCoordinates[0].lng, rectangleCoordinates[0].lat]
|
||||
]
|
||||
]
|
||||
}
|
||||
};
|
||||
let source = $map.getSource('rectangle');
|
||||
if (source) {
|
||||
source.setData(data);
|
||||
} else {
|
||||
$map.addSource('rectangle', {
|
||||
type: 'geojson',
|
||||
data: data
|
||||
});
|
||||
}
|
||||
if (!$map.getLayer('rectangle')) {
|
||||
$map.addLayer({
|
||||
id: 'rectangle',
|
||||
type: 'fill',
|
||||
source: 'rectangle',
|
||||
paint: {
|
||||
'fill-color': 'SteelBlue',
|
||||
'fill-opacity': 0.5
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function updateRectangle() {
|
||||
if ($map) {
|
||||
if (rectangleCoordinates.length != 2) {
|
||||
if ($map.getLayer('rectangle')) {
|
||||
$map.removeLayer('rectangle');
|
||||
}
|
||||
} else {
|
||||
let data = {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[rectangleCoordinates[0].lng, rectangleCoordinates[0].lat],
|
||||
[rectangleCoordinates[1].lng, rectangleCoordinates[0].lat],
|
||||
[rectangleCoordinates[1].lng, rectangleCoordinates[1].lat],
|
||||
[rectangleCoordinates[0].lng, rectangleCoordinates[1].lat],
|
||||
[rectangleCoordinates[0].lng, rectangleCoordinates[0].lat],
|
||||
],
|
||||
],
|
||||
},
|
||||
};
|
||||
let source = $map.getSource('rectangle');
|
||||
if (source) {
|
||||
source.setData(data);
|
||||
} else {
|
||||
$map.addSource('rectangle', {
|
||||
type: 'geojson',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
if (!$map.getLayer('rectangle')) {
|
||||
$map.addLayer({
|
||||
id: 'rectangle',
|
||||
type: 'fill',
|
||||
source: 'rectangle',
|
||||
paint: {
|
||||
'fill-color': 'SteelBlue',
|
||||
'fill-opacity': 0.5,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: if (rectangleCoordinates) {
|
||||
updateRectangle();
|
||||
}
|
||||
$: if (rectangleCoordinates) {
|
||||
updateRectangle();
|
||||
}
|
||||
|
||||
let mousedown = false;
|
||||
function onMouseDown(e: any) {
|
||||
mousedown = true;
|
||||
rectangleCoordinates = [e.lngLat, e.lngLat];
|
||||
}
|
||||
let mousedown = false;
|
||||
function onMouseDown(e: any) {
|
||||
mousedown = true;
|
||||
rectangleCoordinates = [e.lngLat, e.lngLat];
|
||||
}
|
||||
|
||||
function onMouseMove(e: any) {
|
||||
if (mousedown) {
|
||||
rectangleCoordinates[1] = e.lngLat;
|
||||
}
|
||||
}
|
||||
function onMouseMove(e: any) {
|
||||
if (mousedown) {
|
||||
rectangleCoordinates[1] = e.lngLat;
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseUp(e: any) {
|
||||
mousedown = false;
|
||||
}
|
||||
function onMouseUp(e: any) {
|
||||
mousedown = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
setCrosshairCursor();
|
||||
});
|
||||
onMount(() => {
|
||||
setCrosshairCursor();
|
||||
});
|
||||
|
||||
$: if ($map) {
|
||||
$map.on('mousedown', onMouseDown);
|
||||
$map.on('mousemove', onMouseMove);
|
||||
$map.on('mouseup', onMouseUp);
|
||||
$map.on('touchstart', onMouseDown);
|
||||
$map.on('touchmove', onMouseMove);
|
||||
$map.on('touchend', onMouseUp);
|
||||
$map.dragPan.disable();
|
||||
}
|
||||
$: if ($map) {
|
||||
$map.on('mousedown', onMouseDown);
|
||||
$map.on('mousemove', onMouseMove);
|
||||
$map.on('mouseup', onMouseUp);
|
||||
$map.on('touchstart', onMouseDown);
|
||||
$map.on('touchmove', onMouseMove);
|
||||
$map.on('touchend', onMouseUp);
|
||||
$map.dragPan.disable();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
resetCursor();
|
||||
if ($map) {
|
||||
$map.off('mousedown', onMouseDown);
|
||||
$map.off('mousemove', onMouseMove);
|
||||
$map.off('mouseup', onMouseUp);
|
||||
$map.off('touchstart', onMouseDown);
|
||||
$map.off('touchmove', onMouseMove);
|
||||
$map.off('touchend', onMouseUp);
|
||||
$map.dragPan.enable();
|
||||
onDestroy(() => {
|
||||
resetCursor();
|
||||
if ($map) {
|
||||
$map.off('mousedown', onMouseDown);
|
||||
$map.off('mousemove', onMouseMove);
|
||||
$map.off('mouseup', onMouseUp);
|
||||
$map.off('touchstart', onMouseDown);
|
||||
$map.off('touchmove', onMouseMove);
|
||||
$map.off('touchend', onMouseUp);
|
||||
$map.dragPan.enable();
|
||||
|
||||
if ($map.getLayer('rectangle')) {
|
||||
$map.removeLayer('rectangle');
|
||||
}
|
||||
if ($map.getSource('rectangle')) {
|
||||
$map.removeSource('rectangle');
|
||||
}
|
||||
}
|
||||
});
|
||||
if ($map.getLayer('rectangle')) {
|
||||
$map.removeLayer('rectangle');
|
||||
}
|
||||
if ($map.getSource('rectangle')) {
|
||||
$map.removeSource('rectangle');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$: validSelection = $selection.size > 0;
|
||||
$: validSelection = $selection.size > 0;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 w-full max-w-80 items-center {$$props.class ?? ''}">
|
||||
<fieldset class="flex flex-col gap-3">
|
||||
<div class="flex flex-row items-center gap-[6.4px] h-3">
|
||||
<Checkbox id="delete-trkpt" bind:checked={deleteTrackpoints} class="scale-90" />
|
||||
<Label for="delete-trkpt">
|
||||
{$_('toolbar.clean.delete_trackpoints')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-[6.4px] h-3">
|
||||
<Checkbox id="delete-wpt" bind:checked={deleteWaypoints} class="scale-90" />
|
||||
<Label for="delete-wpt">
|
||||
{$_('toolbar.clean.delete_waypoints')}
|
||||
</Label>
|
||||
</div>
|
||||
<RadioGroup.Root bind:value={cleanType}>
|
||||
<Label class="flex flex-row items-center gap-2">
|
||||
<RadioGroup.Item value={CleanType.INSIDE} />
|
||||
{$_('toolbar.clean.delete_inside')}
|
||||
</Label>
|
||||
<Label class="flex flex-row items-center gap-2">
|
||||
<RadioGroup.Item value={CleanType.OUTSIDE} />
|
||||
{$_('toolbar.clean.delete_outside')}
|
||||
</Label>
|
||||
</RadioGroup.Root>
|
||||
</fieldset>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
disabled={!validSelection || rectangleCoordinates.length != 2}
|
||||
on:click={() => {
|
||||
dbUtils.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)
|
||||
}
|
||||
],
|
||||
cleanType === CleanType.INSIDE,
|
||||
deleteTrackpoints,
|
||||
deleteWaypoints
|
||||
);
|
||||
rectangleCoordinates = [];
|
||||
}}
|
||||
>
|
||||
<Trash2 size="16" class="mr-1" />
|
||||
{$_('toolbar.clean.button')}
|
||||
</Button>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/clean')}>
|
||||
{#if validSelection}
|
||||
{$_('toolbar.clean.help')}
|
||||
{:else}
|
||||
{$_('toolbar.clean.help_no_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
<fieldset class="flex flex-col gap-3">
|
||||
<div class="flex flex-row items-center gap-[6.4px] h-3">
|
||||
<Checkbox id="delete-trkpt" bind:checked={deleteTrackpoints} class="scale-90" />
|
||||
<Label for="delete-trkpt">
|
||||
{$_('toolbar.clean.delete_trackpoints')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-[6.4px] h-3">
|
||||
<Checkbox id="delete-wpt" bind:checked={deleteWaypoints} class="scale-90" />
|
||||
<Label for="delete-wpt">
|
||||
{$_('toolbar.clean.delete_waypoints')}
|
||||
</Label>
|
||||
</div>
|
||||
<RadioGroup.Root bind:value={cleanType}>
|
||||
<Label class="flex flex-row items-center gap-2">
|
||||
<RadioGroup.Item value={CleanType.INSIDE} />
|
||||
{$_('toolbar.clean.delete_inside')}
|
||||
</Label>
|
||||
<Label class="flex flex-row items-center gap-2">
|
||||
<RadioGroup.Item value={CleanType.OUTSIDE} />
|
||||
{$_('toolbar.clean.delete_outside')}
|
||||
</Label>
|
||||
</RadioGroup.Root>
|
||||
</fieldset>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
disabled={!validSelection || rectangleCoordinates.length != 2}
|
||||
on:click={() => {
|
||||
dbUtils.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),
|
||||
},
|
||||
],
|
||||
cleanType === CleanType.INSIDE,
|
||||
deleteTrackpoints,
|
||||
deleteWaypoints
|
||||
);
|
||||
rectangleCoordinates = [];
|
||||
}}
|
||||
>
|
||||
<Trash2 size="16" class="mr-1" />
|
||||
{$_('toolbar.clean.button')}
|
||||
</Button>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/clean')}>
|
||||
{#if validSelection}
|
||||
{$_('toolbar.clean.help')}
|
||||
{:else}
|
||||
{$_('toolbar.clean.help_no_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
</div>
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { MountainSnow } from 'lucide-svelte';
|
||||
import { dbUtils } from '$lib/db';
|
||||
import { map } from '$lib/stores';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { MountainSnow } from 'lucide-svelte';
|
||||
import { dbUtils } from '$lib/db';
|
||||
import { map } from '$lib/stores';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
|
||||
$: validSelection = $selection.size > 0;
|
||||
$: validSelection = $selection.size > 0;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="whitespace-normal h-fit"
|
||||
disabled={!validSelection}
|
||||
on:click={async () => {
|
||||
if ($map) {
|
||||
dbUtils.addElevationToSelection($map);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MountainSnow size="16" class="mr-1 shrink-0" />
|
||||
{$_('toolbar.elevation.button')}
|
||||
</Button>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/elevation')}>
|
||||
{#if validSelection}
|
||||
{$_('toolbar.elevation.help')}
|
||||
{:else}
|
||||
{$_('toolbar.elevation.help_no_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="whitespace-normal h-fit"
|
||||
disabled={!validSelection}
|
||||
on:click={async () => {
|
||||
if ($map) {
|
||||
dbUtils.addElevationToSelection($map);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MountainSnow size="16" class="mr-1 shrink-0" />
|
||||
{$_('toolbar.elevation.button')}
|
||||
</Button>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/elevation')}>
|
||||
{#if validSelection}
|
||||
{$_('toolbar.elevation.help')}
|
||||
{:else}
|
||||
{$_('toolbar.elevation.help_no_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
</div>
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Ungroup } from 'lucide-svelte';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem,
|
||||
ListWaypointItem,
|
||||
ListWaypointsItem
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { dbUtils, getFile } from '$lib/db';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Ungroup } from 'lucide-svelte';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem,
|
||||
ListWaypointItem,
|
||||
ListWaypointsItem,
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { dbUtils, getFile } from '$lib/db';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
|
||||
$: validSelection =
|
||||
$selection.size > 0 &&
|
||||
$selection.getSelected().every((item) => {
|
||||
if (
|
||||
item instanceof ListWaypointsItem ||
|
||||
item instanceof ListWaypointItem ||
|
||||
item instanceof ListTrackSegmentItem
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
let file = 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
$: validSelection =
|
||||
$selection.size > 0 &&
|
||||
$selection.getSelected().every((item) => {
|
||||
if (
|
||||
item instanceof ListWaypointsItem ||
|
||||
item instanceof ListWaypointItem ||
|
||||
item instanceof ListTrackSegmentItem
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
let file = 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
|
||||
<Button variant="outline" disabled={!validSelection} on:click={dbUtils.extractSelection}>
|
||||
<Ungroup size="16" class="mr-1" />
|
||||
{$_('toolbar.extract.button')}
|
||||
</Button>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/extract')}>
|
||||
{#if validSelection}
|
||||
{$_('toolbar.extract.help')}
|
||||
{:else}
|
||||
{$_('toolbar.extract.help_invalid_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
<Button variant="outline" disabled={!validSelection} on:click={dbUtils.extractSelection}>
|
||||
<Ungroup size="16" class="mr-1" />
|
||||
{$_('toolbar.extract.button')}
|
||||
</Button>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/extract')}>
|
||||
{#if validSelection}
|
||||
{$_('toolbar.extract.help')}
|
||||
{:else}
|
||||
{$_('toolbar.extract.help_invalid_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
</div>
|
||||
|
||||
@@ -1,117 +1,117 @@
|
||||
<script lang="ts" context="module">
|
||||
enum MergeType {
|
||||
TRACES = 'traces',
|
||||
CONTENTS = 'contents'
|
||||
}
|
||||
enum MergeType {
|
||||
TRACES = 'traces',
|
||||
CONTENTS = 'contents',
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { ListFileItem, ListTrackItem } from '$lib/components/file-list/FileList';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { dbUtils, getFile } from '$lib/db';
|
||||
import { Group } from 'lucide-svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import { gpxStatistics } from '$lib/stores';
|
||||
import { ListFileItem, ListTrackItem } from '$lib/components/file-list/FileList';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { dbUtils, getFile } from '$lib/db';
|
||||
import { Group } from 'lucide-svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import { gpxStatistics } from '$lib/stores';
|
||||
|
||||
let canMergeTraces = false;
|
||||
let canMergeContents = false;
|
||||
let removeGaps = false;
|
||||
let canMergeTraces = false;
|
||||
let canMergeContents = false;
|
||||
let removeGaps = false;
|
||||
|
||||
$: if ($selection.size > 1) {
|
||||
canMergeTraces = true;
|
||||
} else if ($selection.size === 1) {
|
||||
let selected = $selection.getSelected()[0];
|
||||
if (selected instanceof ListFileItem) {
|
||||
let file = getFile(selected.getFileId());
|
||||
if (file) {
|
||||
canMergeTraces = file.getSegments().length > 1;
|
||||
} else {
|
||||
canMergeTraces = false;
|
||||
}
|
||||
} else if (selected instanceof ListTrackItem) {
|
||||
let trackIndex = selected.getTrackIndex();
|
||||
let file = getFile(selected.getFileId());
|
||||
if (file && trackIndex < file.trk.length) {
|
||||
canMergeTraces = file.trk[trackIndex].getSegments().length > 1;
|
||||
} else {
|
||||
canMergeTraces = false;
|
||||
}
|
||||
} else {
|
||||
canMergeContents = false;
|
||||
}
|
||||
}
|
||||
$: if ($selection.size > 1) {
|
||||
canMergeTraces = true;
|
||||
} else if ($selection.size === 1) {
|
||||
let selected = $selection.getSelected()[0];
|
||||
if (selected instanceof ListFileItem) {
|
||||
let file = getFile(selected.getFileId());
|
||||
if (file) {
|
||||
canMergeTraces = file.getSegments().length > 1;
|
||||
} else {
|
||||
canMergeTraces = false;
|
||||
}
|
||||
} else if (selected instanceof ListTrackItem) {
|
||||
let trackIndex = selected.getTrackIndex();
|
||||
let file = getFile(selected.getFileId());
|
||||
if (file && trackIndex < file.trk.length) {
|
||||
canMergeTraces = file.trk[trackIndex].getSegments().length > 1;
|
||||
} else {
|
||||
canMergeTraces = false;
|
||||
}
|
||||
} else {
|
||||
canMergeContents = false;
|
||||
}
|
||||
}
|
||||
|
||||
$: canMergeContents =
|
||||
$selection.size > 1 &&
|
||||
$selection
|
||||
.getSelected()
|
||||
.some((item) => item instanceof ListFileItem || item instanceof ListTrackItem);
|
||||
$: canMergeContents =
|
||||
$selection.size > 1 &&
|
||||
$selection
|
||||
.getSelected()
|
||||
.some((item) => item instanceof ListFileItem || item instanceof ListTrackItem);
|
||||
|
||||
let mergeType = MergeType.TRACES;
|
||||
let mergeType = MergeType.TRACES;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
|
||||
<RadioGroup.Root bind:value={mergeType}>
|
||||
<Label class="flex flex-row items-center gap-1.5 leading-5">
|
||||
<RadioGroup.Item value={MergeType.TRACES} />
|
||||
{$_('toolbar.merge.merge_traces')}
|
||||
</Label>
|
||||
<Label class="flex flex-row items-center gap-1.5 leading-5">
|
||||
<RadioGroup.Item value={MergeType.CONTENTS} />
|
||||
{$_('toolbar.merge.merge_contents')}
|
||||
</Label>
|
||||
</RadioGroup.Root>
|
||||
{#if mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0}
|
||||
<div class="flex flex-row items-center gap-1.5">
|
||||
<Checkbox id="remove-gaps" bind:checked={removeGaps} />
|
||||
<Label for="remove-gaps">{$_('toolbar.merge.remove_gaps')}</Label>
|
||||
</div>
|
||||
{/if}
|
||||
<Button
|
||||
variant="outline"
|
||||
class="whitespace-normal h-fit"
|
||||
disabled={(mergeType === MergeType.TRACES && !canMergeTraces) ||
|
||||
(mergeType === MergeType.CONTENTS && !canMergeContents)}
|
||||
on:click={() => {
|
||||
dbUtils.mergeSelection(
|
||||
mergeType === MergeType.TRACES,
|
||||
mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0 && removeGaps
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Group size="16" class="mr-1 shrink-0" />
|
||||
{$_('toolbar.merge.merge_selection')}
|
||||
</Button>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/merge')}>
|
||||
{#if mergeType === MergeType.TRACES && canMergeTraces}
|
||||
{$_('toolbar.merge.help_merge_traces')}
|
||||
{:else if mergeType === MergeType.TRACES && !canMergeTraces}
|
||||
{$_('toolbar.merge.help_cannot_merge_traces')}
|
||||
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[0]}
|
||||
<Shortcut
|
||||
ctrl={true}
|
||||
click={true}
|
||||
class="inline-flex text-muted-foreground text-xs border rounded p-0.5 gap-0"
|
||||
/>
|
||||
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[1]}
|
||||
{:else if mergeType === MergeType.CONTENTS && canMergeContents}
|
||||
{$_('toolbar.merge.help_merge_contents')}
|
||||
{:else if mergeType === MergeType.CONTENTS && !canMergeContents}
|
||||
{$_('toolbar.merge.help_cannot_merge_contents')}
|
||||
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[0]}
|
||||
<Shortcut
|
||||
ctrl={true}
|
||||
click={true}
|
||||
class="inline-flex text-muted-foreground text-xs border rounded p-0.5 gap-0"
|
||||
/>
|
||||
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[1]}
|
||||
{/if}
|
||||
</Help>
|
||||
<RadioGroup.Root bind:value={mergeType}>
|
||||
<Label class="flex flex-row items-center gap-1.5 leading-5">
|
||||
<RadioGroup.Item value={MergeType.TRACES} />
|
||||
{$_('toolbar.merge.merge_traces')}
|
||||
</Label>
|
||||
<Label class="flex flex-row items-center gap-1.5 leading-5">
|
||||
<RadioGroup.Item value={MergeType.CONTENTS} />
|
||||
{$_('toolbar.merge.merge_contents')}
|
||||
</Label>
|
||||
</RadioGroup.Root>
|
||||
{#if mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0}
|
||||
<div class="flex flex-row items-center gap-1.5">
|
||||
<Checkbox id="remove-gaps" bind:checked={removeGaps} />
|
||||
<Label for="remove-gaps">{$_('toolbar.merge.remove_gaps')}</Label>
|
||||
</div>
|
||||
{/if}
|
||||
<Button
|
||||
variant="outline"
|
||||
class="whitespace-normal h-fit"
|
||||
disabled={(mergeType === MergeType.TRACES && !canMergeTraces) ||
|
||||
(mergeType === MergeType.CONTENTS && !canMergeContents)}
|
||||
on:click={() => {
|
||||
dbUtils.mergeSelection(
|
||||
mergeType === MergeType.TRACES,
|
||||
mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0 && removeGaps
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Group size="16" class="mr-1 shrink-0" />
|
||||
{$_('toolbar.merge.merge_selection')}
|
||||
</Button>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/merge')}>
|
||||
{#if mergeType === MergeType.TRACES && canMergeTraces}
|
||||
{$_('toolbar.merge.help_merge_traces')}
|
||||
{:else if mergeType === MergeType.TRACES && !canMergeTraces}
|
||||
{$_('toolbar.merge.help_cannot_merge_traces')}
|
||||
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[0]}
|
||||
<Shortcut
|
||||
ctrl={true}
|
||||
click={true}
|
||||
class="inline-flex text-muted-foreground text-xs border rounded p-0.5 gap-0"
|
||||
/>
|
||||
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[1]}
|
||||
{:else if mergeType === MergeType.CONTENTS && canMergeContents}
|
||||
{$_('toolbar.merge.help_merge_contents')}
|
||||
{:else if mergeType === MergeType.CONTENTS && !canMergeContents}
|
||||
{$_('toolbar.merge.help_cannot_merge_contents')}
|
||||
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[0]}
|
||||
<Shortcut
|
||||
ctrl={true}
|
||||
click={true}
|
||||
class="inline-flex text-muted-foreground text-xs border rounded p-0.5 gap-0"
|
||||
/>
|
||||
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[1]}
|
||||
{/if}
|
||||
</Help>
|
||||
</div>
|
||||
|
||||
@@ -1,178 +1,187 @@
|
||||
<script lang="ts">
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Slider } from '$lib/components/ui/slider';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import { ListItem, ListRootItem, ListTrackSegmentItem } from '$lib/components/file-list/FileList';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { Filter } from 'lucide-svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
import { dbUtils, fileObservers } from '$lib/db';
|
||||
import { map } from '$lib/stores';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx';
|
||||
import { derived } from 'svelte/store';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Slider } from '$lib/components/ui/slider';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import {
|
||||
ListItem,
|
||||
ListRootItem,
|
||||
ListTrackSegmentItem,
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { Filter } from 'lucide-svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
import { dbUtils, fileObservers } from '$lib/db';
|
||||
import { map } from '$lib/stores';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx';
|
||||
import { derived } from 'svelte/store';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
|
||||
let sliderValue = [50];
|
||||
let maxPoints = 0;
|
||||
let currentPoints = 0;
|
||||
const minTolerance = 0.1;
|
||||
const maxTolerance = 10000;
|
||||
let sliderValue = [50];
|
||||
let maxPoints = 0;
|
||||
let currentPoints = 0;
|
||||
const minTolerance = 0.1;
|
||||
const maxTolerance = 10000;
|
||||
|
||||
$: validSelection = $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
|
||||
$: validSelection = $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
|
||||
|
||||
$: tolerance =
|
||||
minTolerance * 2 ** (sliderValue[0] / (100 / Math.log2(maxTolerance / minTolerance)));
|
||||
$: tolerance =
|
||||
minTolerance * 2 ** (sliderValue[0] / (100 / Math.log2(maxTolerance / minTolerance)));
|
||||
|
||||
let simplified = new Map<string, [ListItem, number, SimplifiedTrackPoint[]]>();
|
||||
let unsubscribes = new Map<string, () => void>();
|
||||
let simplified = new Map<string, [ListItem, number, SimplifiedTrackPoint[]]>();
|
||||
let unsubscribes = new Map<string, () => void>();
|
||||
|
||||
function update() {
|
||||
maxPoints = 0;
|
||||
currentPoints = 0;
|
||||
function update() {
|
||||
maxPoints = 0;
|
||||
currentPoints = 0;
|
||||
|
||||
let data: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
};
|
||||
let data: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
};
|
||||
|
||||
simplified.forEach(([item, maxPts, points], itemFullId) => {
|
||||
maxPoints += maxPts;
|
||||
simplified.forEach(([item, maxPts, points], itemFullId) => {
|
||||
maxPoints += maxPts;
|
||||
|
||||
let current = points.filter(
|
||||
(point) => point.distance === undefined || point.distance >= tolerance
|
||||
);
|
||||
currentPoints += current.length;
|
||||
let current = points.filter(
|
||||
(point) => point.distance === undefined || point.distance >= tolerance
|
||||
);
|
||||
currentPoints += current.length;
|
||||
|
||||
data.features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: current.map((point) => [
|
||||
point.point.getLongitude(),
|
||||
point.point.getLatitude()
|
||||
])
|
||||
},
|
||||
properties: {}
|
||||
});
|
||||
});
|
||||
data.features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: current.map((point) => [
|
||||
point.point.getLongitude(),
|
||||
point.point.getLatitude(),
|
||||
]),
|
||||
},
|
||||
properties: {},
|
||||
});
|
||||
});
|
||||
|
||||
if ($map) {
|
||||
let source = $map.getSource('simplified');
|
||||
if (source) {
|
||||
source.setData(data);
|
||||
} else {
|
||||
$map.addSource('simplified', {
|
||||
type: 'geojson',
|
||||
data: data
|
||||
});
|
||||
}
|
||||
if (!$map.getLayer('simplified')) {
|
||||
$map.addLayer({
|
||||
id: 'simplified',
|
||||
type: 'line',
|
||||
source: 'simplified',
|
||||
paint: {
|
||||
'line-color': 'white',
|
||||
'line-width': 3
|
||||
}
|
||||
});
|
||||
} else {
|
||||
$map.moveLayer('simplified');
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($map) {
|
||||
let source = $map.getSource('simplified');
|
||||
if (source) {
|
||||
source.setData(data);
|
||||
} else {
|
||||
$map.addSource('simplified', {
|
||||
type: 'geojson',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
if (!$map.getLayer('simplified')) {
|
||||
$map.addLayer({
|
||||
id: 'simplified',
|
||||
type: 'line',
|
||||
source: 'simplified',
|
||||
paint: {
|
||||
'line-color': 'white',
|
||||
'line-width': 3,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
$map.moveLayer('simplified');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($fileObservers) {
|
||||
unsubscribes.forEach((unsubscribe, fileId) => {
|
||||
if (!$fileObservers.has(fileId)) {
|
||||
unsubscribe();
|
||||
unsubscribes.delete(fileId);
|
||||
}
|
||||
});
|
||||
$fileObservers.forEach((fileStore, fileId) => {
|
||||
if (!unsubscribes.has(fileId)) {
|
||||
let unsubscribe = derived([fileStore, selection], ([fs, sel]) => [fs, sel]).subscribe(
|
||||
([fs, sel]) => {
|
||||
if (fs) {
|
||||
fs.file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||
let segmentItem = new ListTrackSegmentItem(fileId, trackIndex, segmentIndex);
|
||||
if (sel.hasAnyParent(segmentItem)) {
|
||||
let statistics = fs.statistics.getStatisticsFor(segmentItem);
|
||||
simplified.set(segmentItem.getFullId(), [
|
||||
segmentItem,
|
||||
statistics.local.points.length,
|
||||
ramerDouglasPeucker(statistics.local.points, minTolerance)
|
||||
]);
|
||||
update();
|
||||
} else if (simplified.has(segmentItem.getFullId())) {
|
||||
simplified.delete(segmentItem.getFullId());
|
||||
update();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
unsubscribes.set(fileId, unsubscribe);
|
||||
}
|
||||
});
|
||||
}
|
||||
$: if ($fileObservers) {
|
||||
unsubscribes.forEach((unsubscribe, fileId) => {
|
||||
if (!$fileObservers.has(fileId)) {
|
||||
unsubscribe();
|
||||
unsubscribes.delete(fileId);
|
||||
}
|
||||
});
|
||||
$fileObservers.forEach((fileStore, fileId) => {
|
||||
if (!unsubscribes.has(fileId)) {
|
||||
let unsubscribe = derived([fileStore, selection], ([fs, sel]) => [
|
||||
fs,
|
||||
sel,
|
||||
]).subscribe(([fs, sel]) => {
|
||||
if (fs) {
|
||||
fs.file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||
let segmentItem = new ListTrackSegmentItem(
|
||||
fileId,
|
||||
trackIndex,
|
||||
segmentIndex
|
||||
);
|
||||
if (sel.hasAnyParent(segmentItem)) {
|
||||
let statistics = fs.statistics.getStatisticsFor(segmentItem);
|
||||
simplified.set(segmentItem.getFullId(), [
|
||||
segmentItem,
|
||||
statistics.local.points.length,
|
||||
ramerDouglasPeucker(statistics.local.points, minTolerance),
|
||||
]);
|
||||
update();
|
||||
} else if (simplified.has(segmentItem.getFullId())) {
|
||||
simplified.delete(segmentItem.getFullId());
|
||||
update();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
unsubscribes.set(fileId, unsubscribe);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$: if (tolerance) {
|
||||
update();
|
||||
}
|
||||
$: if (tolerance) {
|
||||
update();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if ($map) {
|
||||
if ($map.getLayer('simplified')) {
|
||||
$map.removeLayer('simplified');
|
||||
}
|
||||
if ($map.getSource('simplified')) {
|
||||
$map.removeSource('simplified');
|
||||
}
|
||||
}
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
simplified.clear();
|
||||
});
|
||||
onDestroy(() => {
|
||||
if ($map) {
|
||||
if ($map.getLayer('simplified')) {
|
||||
$map.removeLayer('simplified');
|
||||
}
|
||||
if ($map.getSource('simplified')) {
|
||||
$map.removeSource('simplified');
|
||||
}
|
||||
}
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
simplified.clear();
|
||||
});
|
||||
|
||||
function reduce() {
|
||||
let itemsAndPoints = new Map<ListItem, TrackPoint[]>();
|
||||
simplified.forEach(([item, maxPts, points], itemFullId) => {
|
||||
itemsAndPoints.set(
|
||||
item,
|
||||
points
|
||||
.filter((point) => point.distance === undefined || point.distance >= tolerance)
|
||||
.map((point) => point.point)
|
||||
);
|
||||
});
|
||||
dbUtils.reduce(itemsAndPoints);
|
||||
}
|
||||
function reduce() {
|
||||
let itemsAndPoints = new Map<ListItem, TrackPoint[]>();
|
||||
simplified.forEach(([item, maxPts, points], itemFullId) => {
|
||||
itemsAndPoints.set(
|
||||
item,
|
||||
points
|
||||
.filter((point) => point.distance === undefined || point.distance >= tolerance)
|
||||
.map((point) => point.point)
|
||||
);
|
||||
});
|
||||
dbUtils.reduce(itemsAndPoints);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
|
||||
<div class="p-2">
|
||||
<Slider bind:value={sliderValue} min={0} max={100} step={1} />
|
||||
</div>
|
||||
<Label class="flex flex-row justify-between">
|
||||
<span>{$_('toolbar.reduce.tolerance')}</span>
|
||||
<WithUnits value={tolerance / 1000} type="distance" decimals={4} class="font-normal" />
|
||||
</Label>
|
||||
<Label class="flex flex-row justify-between">
|
||||
<span>{$_('toolbar.reduce.number_of_points')}</span>
|
||||
<span class="font-normal">{currentPoints}/{maxPoints}</span>
|
||||
</Label>
|
||||
<Button variant="outline" disabled={!validSelection} on:click={reduce}>
|
||||
<Filter size="16" class="mr-1" />
|
||||
{$_('toolbar.reduce.button')}
|
||||
</Button>
|
||||
<div class="p-2">
|
||||
<Slider bind:value={sliderValue} min={0} max={100} step={1} />
|
||||
</div>
|
||||
<Label class="flex flex-row justify-between">
|
||||
<span>{$_('toolbar.reduce.tolerance')}</span>
|
||||
<WithUnits value={tolerance / 1000} type="distance" decimals={4} class="font-normal" />
|
||||
</Label>
|
||||
<Label class="flex flex-row justify-between">
|
||||
<span>{$_('toolbar.reduce.number_of_points')}</span>
|
||||
<span class="font-normal">{currentPoints}/{maxPoints}</span>
|
||||
</Label>
|
||||
<Button variant="outline" disabled={!validSelection} on:click={reduce}>
|
||||
<Filter size="16" class="mr-1" />
|
||||
{$_('toolbar.reduce.button')}
|
||||
</Button>
|
||||
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/minify')}>
|
||||
{#if validSelection}
|
||||
{$_('toolbar.reduce.help')}
|
||||
{:else}
|
||||
{$_('toolbar.reduce.help_no_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/minify')}>
|
||||
{#if validSelection}
|
||||
{$_('toolbar.reduce.help')}
|
||||
{:else}
|
||||
{$_('toolbar.reduce.help_no_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
</div>
|
||||
|
||||
@@ -1,393 +1,404 @@
|
||||
<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,
|
||||
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';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
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';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
|
||||
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;
|
||||
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());
|
||||
}
|
||||
|
||||
function toTimeString(date: Date): string {
|
||||
return date.toTimeString().split(' ')[0];
|
||||
}
|
||||
function toTimeString(date: Date): string {
|
||||
return date.toTimeString().split(' ')[0];
|
||||
}
|
||||
|
||||
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 = toTimeString($gpxStatistics.global.time.start);
|
||||
} else {
|
||||
startDate = undefined;
|
||||
startTime = undefined;
|
||||
}
|
||||
if ($gpxStatistics.global.time.end) {
|
||||
endDate = toCalendarDate($gpxStatistics.global.time.end);
|
||||
endTime = toTimeString($gpxStatistics.global.time.end);
|
||||
} 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 = toTimeString($gpxStatistics.global.time.start);
|
||||
} else {
|
||||
startDate = undefined;
|
||||
startTime = undefined;
|
||||
}
|
||||
if ($gpxStatistics.global.time.end) {
|
||||
endDate = toCalendarDate($gpxStatistics.global.time.end);
|
||||
endTime = toTimeString($gpxStatistics.global.time.end);
|
||||
} 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));
|
||||
if (seconds === undefined) {
|
||||
seconds = 0;
|
||||
}
|
||||
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));
|
||||
if (seconds === undefined) {
|
||||
seconds = 0;
|
||||
}
|
||||
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 = toTimeString(end);
|
||||
}
|
||||
}
|
||||
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 = toTimeString(end);
|
||||
}
|
||||
}
|
||||
|
||||
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 = toTimeString(start);
|
||||
}
|
||||
}
|
||||
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 = toTimeString(start);
|
||||
}
|
||||
}
|
||||
|
||||
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 = milesToKilometers(speedValue);
|
||||
} else if ($distanceUnits === 'nautical') {
|
||||
speedValue = nauticalMilesToKilometers(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;
|
||||
}
|
||||
let distance =
|
||||
$gpxStatistics.global.distance.moving > 0
|
||||
? $gpxStatistics.global.distance.moving
|
||||
: $gpxStatistics.global.distance.total;
|
||||
setSpeed(distance / (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 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 items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canUpdate}
|
||||
class="grow whitespace-normal h-fit"
|
||||
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 items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canUpdate}
|
||||
class="grow whitespace-normal h-fit"
|
||||
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) {
|
||||
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 shrink-0" />
|
||||
{$_('toolbar.time.update')}
|
||||
</Button>
|
||||
<Button variant="outline" on:click={setGPXData}>
|
||||
<CircleX size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/time')}>
|
||||
{#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 shrink-0" />
|
||||
{$_('toolbar.time.update')}
|
||||
</Button>
|
||||
<Button variant="outline" on:click={setGPXData}>
|
||||
<CircleX size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
<Help link={getURLForLanguage($locale, '/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']) {
|
||||
/*
|
||||
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;
|
||||
}
|
||||
@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>
|
||||
|
||||
@@ -1,283 +1,292 @@
|
||||
<script lang="ts" context="module">
|
||||
import { writable } from 'svelte/store';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const selectedWaypoint = writable<[Waypoint, string] | undefined>(undefined);
|
||||
export const selectedWaypoint = writable<[Waypoint, string] | undefined>(undefined);
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
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 { _, 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';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { map } from '$lib/stores';
|
||||
import { getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||
import { MapPin, CircleX, Save } from 'lucide-svelte';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
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 { _, 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';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { map } from '$lib/stores';
|
||||
import { getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||
import { MapPin, 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 name: string;
|
||||
let description: string;
|
||||
let link: string;
|
||||
let longitude: number;
|
||||
let latitude: number;
|
||||
|
||||
let selectedSymbol = {
|
||||
value: '',
|
||||
label: ''
|
||||
};
|
||||
let selectedSymbol = {
|
||||
value: '',
|
||||
label: '',
|
||||
};
|
||||
|
||||
const { treeFileView } = settings;
|
||||
const { treeFileView } = settings;
|
||||
|
||||
$: canCreate = $selection.size > 0;
|
||||
$: canCreate = $selection.size > 0;
|
||||
|
||||
$: if ($treeFileView && $selection) {
|
||||
selectedWaypoint.update(() => {
|
||||
if ($selection.size === 1) {
|
||||
let item = $selection.getSelected()[0];
|
||||
if (item instanceof ListWaypointItem) {
|
||||
let file = getFile(item.getFileId());
|
||||
let waypoint = file?.wpt[item.getWaypointIndex()];
|
||||
if (waypoint) {
|
||||
return [waypoint, item.getFileId()];
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
$: if ($treeFileView && $selection) {
|
||||
selectedWaypoint.update(() => {
|
||||
if ($selection.size === 1) {
|
||||
let item = $selection.getSelected()[0];
|
||||
if (item instanceof ListWaypointItem) {
|
||||
let file = getFile(item.getFileId());
|
||||
let waypoint = file?.wpt[item.getWaypointIndex()];
|
||||
if (waypoint) {
|
||||
return [waypoint, item.getFileId()];
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
let unsubscribe: (() => void) | undefined = undefined;
|
||||
function updateWaypointData(fileStore: GPXFileWithStatistics | undefined) {
|
||||
if ($selectedWaypoint) {
|
||||
if (fileStore) {
|
||||
if ($selectedWaypoint[0]._data.index < fileStore.file.wpt.length) {
|
||||
$selectedWaypoint[0] = fileStore.file.wpt[$selectedWaypoint[0]._data.index];
|
||||
name = $selectedWaypoint[0].name ?? '';
|
||||
description = $selectedWaypoint[0].desc ?? '';
|
||||
if (
|
||||
$selectedWaypoint[0].cmt !== undefined &&
|
||||
$selectedWaypoint[0].cmt !== $selectedWaypoint[0].desc
|
||||
) {
|
||||
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 {
|
||||
selectedWaypoint.set(undefined);
|
||||
}
|
||||
} else {
|
||||
selectedWaypoint.set(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
let unsubscribe: (() => void) | undefined = undefined;
|
||||
function updateWaypointData(fileStore: GPXFileWithStatistics | undefined) {
|
||||
if ($selectedWaypoint) {
|
||||
if (fileStore) {
|
||||
if ($selectedWaypoint[0]._data.index < fileStore.file.wpt.length) {
|
||||
$selectedWaypoint[0] = fileStore.file.wpt[$selectedWaypoint[0]._data.index];
|
||||
name = $selectedWaypoint[0].name ?? '';
|
||||
description = $selectedWaypoint[0].desc ?? '';
|
||||
if (
|
||||
$selectedWaypoint[0].cmt !== undefined &&
|
||||
$selectedWaypoint[0].cmt !== $selectedWaypoint[0].desc
|
||||
) {
|
||||
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 {
|
||||
selectedWaypoint.set(undefined);
|
||||
}
|
||||
} else {
|
||||
selectedWaypoint.set(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetWaypointData() {
|
||||
name = '';
|
||||
description = '';
|
||||
link = '';
|
||||
selectedSymbol = {
|
||||
value: '',
|
||||
label: ''
|
||||
};
|
||||
longitude = 0;
|
||||
latitude = 0;
|
||||
}
|
||||
function resetWaypointData() {
|
||||
name = '';
|
||||
description = '';
|
||||
link = '';
|
||||
selectedSymbol = {
|
||||
value: '',
|
||||
label: '',
|
||||
};
|
||||
longitude = 0;
|
||||
latitude = 0;
|
||||
}
|
||||
|
||||
$: {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribe = undefined;
|
||||
}
|
||||
if ($selectedWaypoint) {
|
||||
let fileStore = get(fileObservers).get($selectedWaypoint[1]);
|
||||
if (fileStore) {
|
||||
unsubscribe = fileStore.subscribe(updateWaypointData);
|
||||
}
|
||||
} else {
|
||||
resetWaypointData();
|
||||
}
|
||||
}
|
||||
$: {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribe = undefined;
|
||||
}
|
||||
if ($selectedWaypoint) {
|
||||
let fileStore = get(fileObservers).get($selectedWaypoint[1]);
|
||||
if (fileStore) {
|
||||
unsubscribe = fileStore.subscribe(updateWaypointData);
|
||||
}
|
||||
} else {
|
||||
resetWaypointData();
|
||||
}
|
||||
}
|
||||
|
||||
function createOrUpdateWaypoint() {
|
||||
if (typeof latitude === 'string') {
|
||||
latitude = parseFloat(latitude);
|
||||
}
|
||||
if (typeof longitude === 'string') {
|
||||
longitude = parseFloat(longitude);
|
||||
}
|
||||
latitude = parseFloat(latitude.toFixed(6));
|
||||
longitude = parseFloat(longitude.toFixed(6));
|
||||
function createOrUpdateWaypoint() {
|
||||
if (typeof latitude === 'string') {
|
||||
latitude = parseFloat(latitude);
|
||||
}
|
||||
if (typeof longitude === 'string') {
|
||||
longitude = parseFloat(longitude);
|
||||
}
|
||||
latitude = parseFloat(latitude.toFixed(6));
|
||||
longitude = parseFloat(longitude.toFixed(6));
|
||||
|
||||
dbUtils.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: selectedSymbol.value.length > 0 ? selectedSymbol.value : undefined
|
||||
},
|
||||
$selectedWaypoint
|
||||
? new ListWaypointItem($selectedWaypoint[1], $selectedWaypoint[0]._data.index)
|
||||
: undefined
|
||||
);
|
||||
dbUtils.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: selectedSymbol.value.length > 0 ? selectedSymbol.value : undefined,
|
||||
},
|
||||
$selectedWaypoint
|
||||
? new ListWaypointItem($selectedWaypoint[1], $selectedWaypoint[0]._data.index)
|
||||
: undefined
|
||||
);
|
||||
|
||||
selectedWaypoint.set(undefined);
|
||||
resetWaypointData();
|
||||
}
|
||||
selectedWaypoint.set(undefined);
|
||||
resetWaypointData();
|
||||
}
|
||||
|
||||
function setCoordinates(e: any) {
|
||||
latitude = e.lngLat.lat.toFixed(6);
|
||||
longitude = e.lngLat.lng.toFixed(6);
|
||||
}
|
||||
function setCoordinates(e: any) {
|
||||
latitude = e.lngLat.lat.toFixed(6);
|
||||
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');
|
||||
});
|
||||
$: 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);
|
||||
setCrosshairCursor();
|
||||
});
|
||||
onMount(() => {
|
||||
let m = get(map);
|
||||
m?.on('click', setCoordinates);
|
||||
setCrosshairCursor();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
let m = get(map);
|
||||
m?.off('click', setCoordinates);
|
||||
resetCursor();
|
||||
onDestroy(() => {
|
||||
let m = get(map);
|
||||
m?.off('click', setCoordinates);
|
||||
resetCursor();
|
||||
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribe = undefined;
|
||||
}
|
||||
});
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribe = undefined;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 w-full max-w-96 {$$props.class ?? ''}">
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<Label for="name">{$_('menu.metadata.name')}</Label>
|
||||
<Input
|
||||
bind:value={name}
|
||||
id="name"
|
||||
class="font-semibold h-8"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
/>
|
||||
<Label for="description">{$_('menu.metadata.description')}</Label>
|
||||
<Textarea
|
||||
bind:value={description}
|
||||
id="description"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
/>
|
||||
<Label for="symbol">{$_('toolbar.waypoint.icon')}</Label>
|
||||
<Select.Root bind:selected={selectedSymbol}>
|
||||
<Select.Trigger id="symbol" class="w-full h-8" disabled={!canCreate && !$selectedWaypoint}>
|
||||
<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" disabled={!canCreate && !$selectedWaypoint} />
|
||||
<div class="flex flex-row gap-2">
|
||||
<div class="grow">
|
||||
<Label for="latitude">{$_('toolbar.waypoint.latitude')}</Label>
|
||||
<Input
|
||||
bind:value={latitude}
|
||||
type="number"
|
||||
id="latitude"
|
||||
step={1e-6}
|
||||
min={-90}
|
||||
max={90}
|
||||
class="text-xs h-8"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
/>
|
||||
</div>
|
||||
<div class="grow">
|
||||
<Label for="longitude">{$_('toolbar.waypoint.longitude')}</Label>
|
||||
<Input
|
||||
bind:value={longitude}
|
||||
type="number"
|
||||
id="longitude"
|
||||
step={1e-6}
|
||||
min={-180}
|
||||
max={180}
|
||||
class="text-xs h-8"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
class="grow whitespace-normal h-fit"
|
||||
on:click={createOrUpdateWaypoint}
|
||||
>
|
||||
{#if $selectedWaypoint}
|
||||
<Save size="16" class="mr-1 shrink-0" />
|
||||
{$_('menu.metadata.save')}
|
||||
{:else}
|
||||
<MapPin size="16" class="mr-1 shrink-0" />
|
||||
{$_('toolbar.waypoint.create')}
|
||||
{/if}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
on:click={() => {
|
||||
selectedWaypoint.set(undefined);
|
||||
resetWaypointData();
|
||||
}}
|
||||
>
|
||||
<CircleX size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/poi')}>
|
||||
{#if $selectedWaypoint || canCreate}
|
||||
{$_('toolbar.waypoint.help')}
|
||||
{:else}
|
||||
{$_('toolbar.waypoint.help_no_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<Label for="name">{$_('menu.metadata.name')}</Label>
|
||||
<Input
|
||||
bind:value={name}
|
||||
id="name"
|
||||
class="font-semibold h-8"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
/>
|
||||
<Label for="description">{$_('menu.metadata.description')}</Label>
|
||||
<Textarea
|
||||
bind:value={description}
|
||||
id="description"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
/>
|
||||
<Label for="symbol">{$_('toolbar.waypoint.icon')}</Label>
|
||||
<Select.Root bind:selected={selectedSymbol}>
|
||||
<Select.Trigger
|
||||
id="symbol"
|
||||
class="w-full h-8"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
>
|
||||
<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"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
/>
|
||||
<div class="flex flex-row gap-2">
|
||||
<div class="grow">
|
||||
<Label for="latitude">{$_('toolbar.waypoint.latitude')}</Label>
|
||||
<Input
|
||||
bind:value={latitude}
|
||||
type="number"
|
||||
id="latitude"
|
||||
step={1e-6}
|
||||
min={-90}
|
||||
max={90}
|
||||
class="text-xs h-8"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
/>
|
||||
</div>
|
||||
<div class="grow">
|
||||
<Label for="longitude">{$_('toolbar.waypoint.longitude')}</Label>
|
||||
<Input
|
||||
bind:value={longitude}
|
||||
type="number"
|
||||
id="longitude"
|
||||
step={1e-6}
|
||||
min={-180}
|
||||
max={180}
|
||||
class="text-xs h-8"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
class="grow whitespace-normal h-fit"
|
||||
on:click={createOrUpdateWaypoint}
|
||||
>
|
||||
{#if $selectedWaypoint}
|
||||
<Save size="16" class="mr-1 shrink-0" />
|
||||
{$_('menu.metadata.save')}
|
||||
{:else}
|
||||
<MapPin size="16" class="mr-1 shrink-0" />
|
||||
{$_('toolbar.waypoint.create')}
|
||||
{/if}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
on:click={() => {
|
||||
selectedWaypoint.set(undefined);
|
||||
resetWaypointData();
|
||||
}}
|
||||
>
|
||||
<CircleX size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/poi')}>
|
||||
{#if $selectedWaypoint || canCreate}
|
||||
{$_('toolbar.waypoint.help')}
|
||||
{:else}
|
||||
{$_('toolbar.waypoint.help_no_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
</div>
|
||||
|
||||
@@ -1,249 +1,253 @@
|
||||
<script lang="ts">
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import ButtonWithTooltip from '$lib/components/ButtonWithTooltip.svelte';
|
||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import {
|
||||
Bike,
|
||||
Footprints,
|
||||
Waves,
|
||||
TrainFront,
|
||||
Route,
|
||||
TriangleAlert,
|
||||
ArrowRightLeft,
|
||||
Home,
|
||||
RouteOff,
|
||||
Repeat,
|
||||
SquareArrowUpLeft,
|
||||
SquareArrowOutDownRight
|
||||
} from 'lucide-svelte';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import ButtonWithTooltip from '$lib/components/ButtonWithTooltip.svelte';
|
||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import {
|
||||
Bike,
|
||||
Footprints,
|
||||
Waves,
|
||||
TrainFront,
|
||||
Route,
|
||||
TriangleAlert,
|
||||
ArrowRightLeft,
|
||||
Home,
|
||||
RouteOff,
|
||||
Repeat,
|
||||
SquareArrowUpLeft,
|
||||
SquareArrowOutDownRight,
|
||||
} from 'lucide-svelte';
|
||||
|
||||
import { map, newGPXFile, routingControls, selectFileWhenLoaded } from '$lib/stores';
|
||||
import { dbUtils, getFile, getFileIds, settings } from '$lib/db';
|
||||
import { brouterProfiles, routingProfileSelectItem } from './Routing';
|
||||
import { map, newGPXFile, routingControls, selectFileWhenLoaded } from '$lib/stores';
|
||||
import { dbUtils, getFile, getFileIds, settings } from '$lib/db';
|
||||
import { brouterProfiles, routingProfileSelectItem } from './Routing';
|
||||
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { RoutingControls } from './RoutingControls';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { fileObservers } from '$lib/db';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { getOrderedSelection, selection } from '$lib/components/file-list/Selection';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListRootItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem,
|
||||
type ListItem
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import { flyAndScale, getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { TrackPoint } from 'gpx';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { RoutingControls } from './RoutingControls';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { fileObservers } from '$lib/db';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { getOrderedSelection, selection } from '$lib/components/file-list/Selection';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListRootItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem,
|
||||
type ListItem,
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import { flyAndScale, getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { TrackPoint } from 'gpx';
|
||||
|
||||
export let minimized = false;
|
||||
export let minimizable = true;
|
||||
export let popup: mapboxgl.Popup | undefined = undefined;
|
||||
export let popupElement: HTMLElement | undefined = undefined;
|
||||
let selectedItem: ListItem | null = null;
|
||||
export let minimized = false;
|
||||
export let minimizable = true;
|
||||
export let popup: mapboxgl.Popup | undefined = undefined;
|
||||
export let popupElement: HTMLElement | undefined = undefined;
|
||||
let selectedItem: ListItem | null = null;
|
||||
|
||||
const { privateRoads, routing } = settings;
|
||||
const { privateRoads, routing } = settings;
|
||||
|
||||
$: if ($map && popup && popupElement) {
|
||||
// remove controls for deleted files
|
||||
routingControls.forEach((controls, fileId) => {
|
||||
if (!$fileObservers.has(fileId)) {
|
||||
controls.destroy();
|
||||
routingControls.delete(fileId);
|
||||
$: if ($map && popup && popupElement) {
|
||||
// remove controls for deleted files
|
||||
routingControls.forEach((controls, fileId) => {
|
||||
if (!$fileObservers.has(fileId)) {
|
||||
controls.destroy();
|
||||
routingControls.delete(fileId);
|
||||
|
||||
if (selectedItem && selectedItem.getFileId() === fileId) {
|
||||
selectedItem = null;
|
||||
}
|
||||
} else if ($map !== controls.map) {
|
||||
controls.updateMap($map);
|
||||
}
|
||||
});
|
||||
// add controls for new files
|
||||
$fileObservers.forEach((file, fileId) => {
|
||||
if (!routingControls.has(fileId)) {
|
||||
routingControls.set(fileId, new RoutingControls($map, fileId, file, popup, popupElement));
|
||||
}
|
||||
});
|
||||
}
|
||||
if (selectedItem && selectedItem.getFileId() === fileId) {
|
||||
selectedItem = null;
|
||||
}
|
||||
} else if ($map !== controls.map) {
|
||||
controls.updateMap($map);
|
||||
}
|
||||
});
|
||||
// add controls for new files
|
||||
$fileObservers.forEach((file, fileId) => {
|
||||
if (!routingControls.has(fileId)) {
|
||||
routingControls.set(
|
||||
fileId,
|
||||
new RoutingControls($map, fileId, file, popup, popupElement)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$: validSelection = $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
|
||||
$: validSelection = $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
|
||||
|
||||
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];
|
||||
dbUtils.add(file);
|
||||
selectFileWhenLoaded(file._data.id);
|
||||
}
|
||||
}
|
||||
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];
|
||||
dbUtils.add(file);
|
||||
selectFileWhenLoaded(file._data.id);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
setCrosshairCursor();
|
||||
$map?.on('click', createFileWithPoint);
|
||||
});
|
||||
onMount(() => {
|
||||
setCrosshairCursor();
|
||||
$map?.on('click', createFileWithPoint);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
resetCursor();
|
||||
$map?.off('click', createFileWithPoint);
|
||||
onDestroy(() => {
|
||||
resetCursor();
|
||||
$map?.off('click', createFileWithPoint);
|
||||
|
||||
routingControls.forEach((controls) => controls.destroy());
|
||||
routingControls.clear();
|
||||
});
|
||||
routingControls.forEach((controls) => controls.destroy());
|
||||
routingControls.clear();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if minimizable && minimized}
|
||||
<div class="-m-1.5 -mb-2">
|
||||
<Button variant="ghost" class="px-1 h-[26px]" on:click={() => (minimized = false)}>
|
||||
<SquareArrowOutDownRight size="18" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="-m-1.5 -mb-2">
|
||||
<Button variant="ghost" class="px-1 h-[26px]" on:click={() => (minimized = false)}>
|
||||
<SquareArrowOutDownRight size="18" />
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}"
|
||||
in:flyAndScale={{ x: -2, y: 0, duration: 50 }}
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<Label class="flex flex-row justify-between items-center gap-2">
|
||||
<span class="flex flex-row items-center gap-1">
|
||||
{#if $routing}
|
||||
<Route size="16" />
|
||||
{:else}
|
||||
<RouteOff size="16" />
|
||||
{/if}
|
||||
{$_('toolbar.routing.use_routing')}
|
||||
</span>
|
||||
<Tooltip label={$_('toolbar.routing.use_routing_tooltip')}>
|
||||
<Switch class="scale-90" bind:checked={$routing} />
|
||||
<Shortcut slot="extra" key="F5" />
|
||||
</Tooltip>
|
||||
</Label>
|
||||
{#if $routing}
|
||||
<div class="flex flex-col gap-3" in:slide>
|
||||
<Label class="flex flex-row justify-between items-center gap-2">
|
||||
<span class="shrink-0 flex flex-row items-center gap-1">
|
||||
{#if $routingProfileSelectItem.value.includes('bike') || $routingProfileSelectItem.value.includes('motorcycle')}
|
||||
<Bike size="16" />
|
||||
{:else if $routingProfileSelectItem.value.includes('foot')}
|
||||
<Footprints size="16" />
|
||||
{:else if $routingProfileSelectItem.value.includes('water')}
|
||||
<Waves size="16" />
|
||||
{:else if $routingProfileSelectItem.value.includes('railway')}
|
||||
<TrainFront size="16" />
|
||||
{/if}
|
||||
{$_('toolbar.routing.activity')}
|
||||
</span>
|
||||
<Select.Root bind:selected={$routingProfileSelectItem}>
|
||||
<Select.Trigger class="h-8 grow">
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each Object.keys(brouterProfiles) as profile}
|
||||
<Select.Item value={profile}
|
||||
>{$_(`toolbar.routing.activities.${profile}`)}</Select.Item
|
||||
>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Label>
|
||||
<Label class="flex flex-row justify-between items-center gap-2">
|
||||
<span class="flex flex-row gap-1">
|
||||
<TriangleAlert size="16" />
|
||||
{$_('toolbar.routing.allow_private')}
|
||||
</span>
|
||||
<Switch class="scale-90" bind:checked={$privateRoads} />
|
||||
</Label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-row flex-wrap justify-center gap-1">
|
||||
<ButtonWithTooltip
|
||||
label={$_('toolbar.routing.reverse.tooltip')}
|
||||
variant="outline"
|
||||
class="flex flex-row gap-1 text-xs px-2"
|
||||
disabled={!validSelection}
|
||||
on:click={dbUtils.reverseSelection}
|
||||
>
|
||||
<ArrowRightLeft size="12" />{$_('toolbar.routing.reverse.button')}
|
||||
</ButtonWithTooltip>
|
||||
<ButtonWithTooltip
|
||||
label={$_('toolbar.routing.route_back_to_start.tooltip')}
|
||||
variant="outline"
|
||||
class="flex flex-row gap-1 text-xs px-2"
|
||||
disabled={!validSelection}
|
||||
on:click={() => {
|
||||
const selected = getOrderedSelection();
|
||||
if (selected.length > 0) {
|
||||
const firstFileId = selected[0].getFileId();
|
||||
const firstFile = getFile(firstFileId);
|
||||
if (firstFile) {
|
||||
let start = (() => {
|
||||
if (selected[0] instanceof ListFileItem) {
|
||||
return firstFile.trk[0]?.trkseg[0]?.trkpt[0];
|
||||
} else if (selected[0] instanceof ListTrackItem) {
|
||||
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[0]?.trkpt[0];
|
||||
} else if (selected[0] instanceof ListTrackSegmentItem) {
|
||||
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[
|
||||
selected[0].getSegmentIndex()
|
||||
]?.trkpt[0];
|
||||
}
|
||||
})();
|
||||
<div
|
||||
class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}"
|
||||
in:flyAndScale={{ x: -2, y: 0, duration: 50 }}
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<Label class="flex flex-row justify-between items-center gap-2">
|
||||
<span class="flex flex-row items-center gap-1">
|
||||
{#if $routing}
|
||||
<Route size="16" />
|
||||
{:else}
|
||||
<RouteOff size="16" />
|
||||
{/if}
|
||||
{$_('toolbar.routing.use_routing')}
|
||||
</span>
|
||||
<Tooltip label={$_('toolbar.routing.use_routing_tooltip')}>
|
||||
<Switch class="scale-90" bind:checked={$routing} />
|
||||
<Shortcut slot="extra" key="F5" />
|
||||
</Tooltip>
|
||||
</Label>
|
||||
{#if $routing}
|
||||
<div class="flex flex-col gap-3" in:slide>
|
||||
<Label class="flex flex-row justify-between items-center gap-2">
|
||||
<span class="shrink-0 flex flex-row items-center gap-1">
|
||||
{#if $routingProfileSelectItem.value.includes('bike') || $routingProfileSelectItem.value.includes('motorcycle')}
|
||||
<Bike size="16" />
|
||||
{:else if $routingProfileSelectItem.value.includes('foot')}
|
||||
<Footprints size="16" />
|
||||
{:else if $routingProfileSelectItem.value.includes('water')}
|
||||
<Waves size="16" />
|
||||
{:else if $routingProfileSelectItem.value.includes('railway')}
|
||||
<TrainFront size="16" />
|
||||
{/if}
|
||||
{$_('toolbar.routing.activity')}
|
||||
</span>
|
||||
<Select.Root bind:selected={$routingProfileSelectItem}>
|
||||
<Select.Trigger class="h-8 grow">
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each Object.keys(brouterProfiles) as profile}
|
||||
<Select.Item value={profile}
|
||||
>{$_(`toolbar.routing.activities.${profile}`)}</Select.Item
|
||||
>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Label>
|
||||
<Label class="flex flex-row justify-between items-center gap-2">
|
||||
<span class="flex flex-row gap-1">
|
||||
<TriangleAlert size="16" />
|
||||
{$_('toolbar.routing.allow_private')}
|
||||
</span>
|
||||
<Switch class="scale-90" bind:checked={$privateRoads} />
|
||||
</Label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-row flex-wrap justify-center gap-1">
|
||||
<ButtonWithTooltip
|
||||
label={$_('toolbar.routing.reverse.tooltip')}
|
||||
variant="outline"
|
||||
class="flex flex-row gap-1 text-xs px-2"
|
||||
disabled={!validSelection}
|
||||
on:click={dbUtils.reverseSelection}
|
||||
>
|
||||
<ArrowRightLeft size="12" />{$_('toolbar.routing.reverse.button')}
|
||||
</ButtonWithTooltip>
|
||||
<ButtonWithTooltip
|
||||
label={$_('toolbar.routing.route_back_to_start.tooltip')}
|
||||
variant="outline"
|
||||
class="flex flex-row gap-1 text-xs px-2"
|
||||
disabled={!validSelection}
|
||||
on:click={() => {
|
||||
const selected = getOrderedSelection();
|
||||
if (selected.length > 0) {
|
||||
const firstFileId = selected[0].getFileId();
|
||||
const firstFile = getFile(firstFileId);
|
||||
if (firstFile) {
|
||||
let start = (() => {
|
||||
if (selected[0] instanceof ListFileItem) {
|
||||
return firstFile.trk[0]?.trkseg[0]?.trkpt[0];
|
||||
} else if (selected[0] instanceof ListTrackItem) {
|
||||
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[0]
|
||||
?.trkpt[0];
|
||||
} else if (selected[0] instanceof ListTrackSegmentItem) {
|
||||
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[
|
||||
selected[0].getSegmentIndex()
|
||||
]?.trkpt[0];
|
||||
}
|
||||
})();
|
||||
|
||||
if (start !== undefined) {
|
||||
const lastFileId = selected[selected.length - 1].getFileId();
|
||||
routingControls
|
||||
.get(lastFileId)
|
||||
?.appendAnchorWithCoordinates(start.getCoordinates());
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Home size="12" />{$_('toolbar.routing.route_back_to_start.button')}
|
||||
</ButtonWithTooltip>
|
||||
<ButtonWithTooltip
|
||||
label={$_('toolbar.routing.round_trip.tooltip')}
|
||||
variant="outline"
|
||||
class="flex flex-row gap-1 text-xs px-2"
|
||||
disabled={!validSelection}
|
||||
on:click={dbUtils.createRoundTripForSelection}
|
||||
>
|
||||
<Repeat size="12" />{$_('toolbar.routing.round_trip.button')}
|
||||
</ButtonWithTooltip>
|
||||
</div>
|
||||
<div class="w-full flex flex-row gap-2 items-end justify-between">
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/routing')}>
|
||||
{#if !validSelection}
|
||||
{$_('toolbar.routing.help_no_file')}
|
||||
{:else}
|
||||
{$_('toolbar.routing.help')}
|
||||
{/if}
|
||||
</Help>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="px-1 h-6"
|
||||
on:click={() => {
|
||||
if (minimizable) {
|
||||
minimized = true;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SquareArrowUpLeft size="18" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
if (start !== undefined) {
|
||||
const lastFileId = selected[selected.length - 1].getFileId();
|
||||
routingControls
|
||||
.get(lastFileId)
|
||||
?.appendAnchorWithCoordinates(start.getCoordinates());
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Home size="12" />{$_('toolbar.routing.route_back_to_start.button')}
|
||||
</ButtonWithTooltip>
|
||||
<ButtonWithTooltip
|
||||
label={$_('toolbar.routing.round_trip.tooltip')}
|
||||
variant="outline"
|
||||
class="flex flex-row gap-1 text-xs px-2"
|
||||
disabled={!validSelection}
|
||||
on:click={dbUtils.createRoundTripForSelection}
|
||||
>
|
||||
<Repeat size="12" />{$_('toolbar.routing.round_trip.button')}
|
||||
</ButtonWithTooltip>
|
||||
</div>
|
||||
<div class="w-full flex flex-row gap-2 items-end justify-between">
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/routing')}>
|
||||
{#if !validSelection}
|
||||
{$_('toolbar.routing.help_no_file')}
|
||||
{:else}
|
||||
{$_('toolbar.routing.help')}
|
||||
{/if}
|
||||
</Help>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="px-1 h-6"
|
||||
on:click={() => {
|
||||
if (minimizable) {
|
||||
minimized = true;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SquareArrowUpLeft size="18" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Coordinates } from "gpx";
|
||||
import { TrackPoint, distance } from "gpx";
|
||||
import { derived, get, writable } from "svelte/store";
|
||||
import { settings } from "$lib/db";
|
||||
import { _, isLoading, locale } from "svelte-i18n";
|
||||
import { getElevation } from "$lib/utils";
|
||||
import type { Coordinates } from 'gpx';
|
||||
import { TrackPoint, distance } from 'gpx';
|
||||
import { derived, get, writable } from 'svelte/store';
|
||||
import { settings } from '$lib/db';
|
||||
import { _, isLoading, locale } from 'svelte-i18n';
|
||||
import { getElevation } from '$lib/utils';
|
||||
|
||||
const { routing, routingProfile, privateRoads } = settings;
|
||||
|
||||
@@ -15,22 +15,31 @@ export const brouterProfiles: { [key: string]: string } = {
|
||||
foot: 'Hiking-Alpine-SAC6',
|
||||
motorcycle: 'Car-FastEco',
|
||||
water: 'river',
|
||||
railway: 'rail'
|
||||
railway: 'rail',
|
||||
};
|
||||
export const routingProfileSelectItem = writable({
|
||||
value: '',
|
||||
label: ''
|
||||
label: '',
|
||||
});
|
||||
|
||||
derived([routingProfile, locale, isLoading], ([profile, l, i]) => [profile, l, i]).subscribe(([profile, l, i]) => {
|
||||
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}`);
|
||||
return item;
|
||||
});
|
||||
derived([routingProfile, locale, isLoading], ([profile, l, i]) => [profile, l, i]).subscribe(
|
||||
([profile, l, i]) => {
|
||||
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}`);
|
||||
return item;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
routingProfileSelectItem.subscribe((item) => {
|
||||
if (item.value !== '' && item.value !== get(routingProfile)) {
|
||||
routingProfile.set(item.value);
|
||||
@@ -45,8 +54,12 @@ export function route(points: Coordinates[]): Promise<TrackPoint[]> {
|
||||
}
|
||||
}
|
||||
|
||||
async function getRoute(points: Coordinates[], brouterProfile: string, privateRoads: boolean): Promise<TrackPoint[]> {
|
||||
let url = `https://routing.gpx.studio?lonlats=${points.map(point => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile + (privateRoads ? '-private' : '')}&format=geojson&alternativeidx=0`;
|
||||
async function getRoute(
|
||||
points: Coordinates[],
|
||||
brouterProfile: string,
|
||||
privateRoads: boolean
|
||||
): Promise<TrackPoint[]> {
|
||||
let url = `https://routing.gpx.studio?lonlats=${points.map((point) => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile + (privateRoads ? '-private' : '')}&format=geojson&alternativeidx=0`;
|
||||
|
||||
let response = await fetch(url);
|
||||
|
||||
@@ -61,25 +74,29 @@ async function getRoute(points: Coordinates[], brouterProfile: string, privateRo
|
||||
let coordinates = geojson.features[0].geometry.coordinates;
|
||||
let messages = geojson.features[0].properties.messages;
|
||||
|
||||
const lngIdx = messages[0].indexOf("Longitude");
|
||||
const latIdx = messages[0].indexOf("Latitude");
|
||||
const tagIdx = messages[0].indexOf("WayTags");
|
||||
const lngIdx = messages[0].indexOf('Longitude');
|
||||
const latIdx = messages[0].indexOf('Latitude');
|
||||
const tagIdx = messages[0].indexOf('WayTags');
|
||||
let messageIdx = 1;
|
||||
let tags = messageIdx < messages.length ? getTags(messages[messageIdx][tagIdx]) : {};
|
||||
|
||||
for (let i = 0; i < coordinates.length; i++) {
|
||||
let coord = coordinates[i];
|
||||
route.push(new TrackPoint({
|
||||
attributes: {
|
||||
lat: coord[1],
|
||||
lon: coord[0]
|
||||
},
|
||||
ele: coord[2] ?? (i > 0 ? route[i - 1].ele : 0)
|
||||
}));
|
||||
route.push(
|
||||
new TrackPoint({
|
||||
attributes: {
|
||||
lat: coord[1],
|
||||
lon: coord[0],
|
||||
},
|
||||
ele: coord[2] ?? (i > 0 ? route[i - 1].ele : 0),
|
||||
})
|
||||
);
|
||||
|
||||
if (messageIdx < messages.length &&
|
||||
if (
|
||||
messageIdx < messages.length &&
|
||||
coordinates[i][0] == Number(messages[messageIdx][lngIdx]) / 1000000 &&
|
||||
coordinates[i][1] == Number(messages[messageIdx][latIdx]) / 1000000) {
|
||||
coordinates[i][1] == Number(messages[messageIdx][latIdx]) / 1000000
|
||||
) {
|
||||
messageIdx++;
|
||||
|
||||
if (messageIdx == messages.length) tags = {};
|
||||
@@ -93,10 +110,10 @@ async function getRoute(points: Coordinates[], brouterProfile: string, privateRo
|
||||
}
|
||||
|
||||
function getTags(message: string): { [key: string]: string } {
|
||||
const fields = message.split(" ");
|
||||
const fields = message.split(' ');
|
||||
let tags: { [key: string]: string } = {};
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
let [key, value] = fields[i].split("=");
|
||||
let [key, value] = fields[i].split('=');
|
||||
key = key.replace(/:/g, '_');
|
||||
tags[key] = value;
|
||||
}
|
||||
@@ -107,26 +124,31 @@ function getIntermediatePoints(points: Coordinates[]): Promise<TrackPoint[]> {
|
||||
let route: TrackPoint[] = [];
|
||||
let step = 0.05;
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) { // Add intermediate points between each pair of points
|
||||
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;
|
||||
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
|
||||
}
|
||||
}));
|
||||
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,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
route.push(new TrackPoint({
|
||||
attributes: {
|
||||
lat: points[points.length - 1].lat,
|
||||
lon: points[points.length - 1].lon
|
||||
}
|
||||
}));
|
||||
route.push(
|
||||
new TrackPoint({
|
||||
attributes: {
|
||||
lat: points[points.length - 1].lat,
|
||||
lon: points[points.length - 1].lon,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return getElevation(route).then((elevations) => {
|
||||
route.forEach((point, i) => {
|
||||
@@ -134,4 +156,4 @@ function getIntermediatePoints(points: Coordinates[]): Promise<TrackPoint[]> {
|
||||
});
|
||||
return route;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import { canChangeStart } from './RoutingControls';
|
||||
import { CirclePlay, Trash2 } from 'lucide-svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import { canChangeStart } from './RoutingControls';
|
||||
import { CirclePlay, Trash2 } from 'lucide-svelte';
|
||||
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let element: HTMLElement;
|
||||
export let element: HTMLElement;
|
||||
</script>
|
||||
|
||||
<div bind:this={element} class="hidden">
|
||||
<Card.Root class="border-none shadow-md text-base">
|
||||
<Card.Content class="flex flex-col p-1">
|
||||
{#if $canChangeStart}
|
||||
<Button
|
||||
class="w-full px-2 py-1 h-6 justify-start"
|
||||
variant="ghost"
|
||||
on:click={() => element.dispatchEvent(new CustomEvent('change-start'))}
|
||||
>
|
||||
<CirclePlay size="16" class="mr-1" />
|
||||
{$_('toolbar.routing.start_loop_here')}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
class="w-full px-2 py-1 h-6 justify-start"
|
||||
variant="ghost"
|
||||
on:click={() => element.dispatchEvent(new CustomEvent('delete'))}
|
||||
>
|
||||
<Trash2 size="16" class="mr-1" />
|
||||
{$_('menu.delete')}
|
||||
<Shortcut shift={true} click={true} />
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<Card.Root class="border-none shadow-md text-base">
|
||||
<Card.Content class="flex flex-col p-1">
|
||||
{#if $canChangeStart}
|
||||
<Button
|
||||
class="w-full px-2 py-1 h-6 justify-start"
|
||||
variant="ghost"
|
||||
on:click={() => element.dispatchEvent(new CustomEvent('change-start'))}
|
||||
>
|
||||
<CirclePlay size="16" class="mr-1" />
|
||||
{$_('toolbar.routing.start_loop_here')}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
class="w-full px-2 py-1 h-6 justify-start"
|
||||
variant="ghost"
|
||||
on:click={() => element.dispatchEvent(new CustomEvent('delete'))}
|
||||
>
|
||||
<Trash2 size="16" class="mr-1" />
|
||||
{$_('menu.delete')}
|
||||
<Shortcut shift={true} click={true} />
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
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";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { dbUtils, settings, 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 { getClosestLinePoint, resetCursor, setGrabbingCursor } from "$lib/utils";
|
||||
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';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { dbUtils, settings, 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 { getClosestLinePoint, resetCursor, setGrabbingCursor } from '$lib/utils';
|
||||
|
||||
const { streetViewSource } = settings;
|
||||
export const canChangeStart = writable(false);
|
||||
@@ -28,15 +32,22 @@ export class RoutingControls {
|
||||
popupElement: HTMLElement;
|
||||
temporaryAnchor: AnchorWithMarker;
|
||||
lastDragEvent = 0;
|
||||
fileUnsubscribe: () => void = () => { };
|
||||
fileUnsubscribe: () => void = () => {};
|
||||
unsubscribes: Function[] = [];
|
||||
|
||||
toggleAnchorsForZoomLevelAndBoundsBinded: () => void = this.toggleAnchorsForZoomLevelAndBounds.bind(this);
|
||||
toggleAnchorsForZoomLevelAndBoundsBinded: () => void =
|
||||
this.toggleAnchorsForZoomLevelAndBounds.bind(this);
|
||||
showTemporaryAnchorBinded: (e: any) => void = this.showTemporaryAnchor.bind(this);
|
||||
updateTemporaryAnchorBinded: (e: any) => void = this.updateTemporaryAnchor.bind(this);
|
||||
appendAnchorBinded: (e: mapboxgl.MapMouseEvent) => void = this.appendAnchor.bind(this);
|
||||
|
||||
constructor(map: mapboxgl.Map, fileId: string, file: Readable<GPXFileWithStatistics | undefined>, popup: mapboxgl.Popup, popupElement: HTMLElement) {
|
||||
constructor(
|
||||
map: mapboxgl.Map,
|
||||
fileId: string,
|
||||
file: Readable<GPXFileWithStatistics | undefined>,
|
||||
popup: mapboxgl.Popup,
|
||||
popupElement: HTMLElement
|
||||
) {
|
||||
this.map = map;
|
||||
this.fileId = fileId;
|
||||
this.file = file;
|
||||
@@ -46,8 +57,8 @@ export class RoutingControls {
|
||||
let point = new TrackPoint({
|
||||
attributes: {
|
||||
lat: 0,
|
||||
lon: 0
|
||||
}
|
||||
lon: 0,
|
||||
},
|
||||
});
|
||||
this.temporaryAnchor = this.createAnchor(point, new TrackSegment(), 0, 0);
|
||||
this.temporaryAnchor.marker.getElement().classList.remove('z-10'); // Show below the other markers
|
||||
@@ -65,7 +76,9 @@ export class RoutingControls {
|
||||
return;
|
||||
}
|
||||
|
||||
let selected = get(selection).hasAnyChildren(new ListFileItem(this.fileId), true, ['waypoints']);
|
||||
let selected = get(selection).hasAnyChildren(new ListFileItem(this.fileId), true, [
|
||||
'waypoints',
|
||||
]);
|
||||
if (selected) {
|
||||
if (this.active) {
|
||||
this.updateControls();
|
||||
@@ -88,7 +101,8 @@ export class RoutingControls {
|
||||
this.fileUnsubscribe = this.file.subscribe(this.updateControls.bind(this));
|
||||
}
|
||||
|
||||
updateControls() { // Update the markers when the file changes
|
||||
updateControls() {
|
||||
// Update the markers when the file changes
|
||||
let file = get(this.file)?.file;
|
||||
if (!file) {
|
||||
return;
|
||||
@@ -96,8 +110,13 @@ export class RoutingControls {
|
||||
|
||||
let anchorIndex = 0;
|
||||
file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
|
||||
for (let point of segment.trkpt) { // Update the existing anchors (could be improved by matching the existing anchors with the new ones?)
|
||||
if (
|
||||
get(selection).hasAnyParent(
|
||||
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
)
|
||||
) {
|
||||
for (let point of segment.trkpt) {
|
||||
// Update the existing anchors (could be improved by matching the existing anchors with the new ones?)
|
||||
if (point._data.anchor) {
|
||||
if (anchorIndex < this.anchors.length) {
|
||||
this.anchors[anchorIndex].point = point;
|
||||
@@ -106,7 +125,9 @@ export class RoutingControls {
|
||||
this.anchors[anchorIndex].segmentIndex = segmentIndex;
|
||||
this.anchors[anchorIndex].marker.setLngLat(point.getCoordinates());
|
||||
} else {
|
||||
this.anchors.push(this.createAnchor(point, segment, trackIndex, segmentIndex));
|
||||
this.anchors.push(
|
||||
this.createAnchor(point, segment, trackIndex, segmentIndex)
|
||||
);
|
||||
}
|
||||
anchorIndex++;
|
||||
}
|
||||
@@ -114,7 +135,8 @@ export class RoutingControls {
|
||||
}
|
||||
});
|
||||
|
||||
while (anchorIndex < this.anchors.length) { // Remove the extra anchors
|
||||
while (anchorIndex < this.anchors.length) {
|
||||
// Remove the extra anchors
|
||||
this.anchors.pop()?.marker.remove();
|
||||
}
|
||||
|
||||
@@ -141,14 +163,19 @@ export class RoutingControls {
|
||||
this.map = map;
|
||||
}
|
||||
|
||||
createAnchor(point: TrackPoint, segment: TrackSegment, trackIndex: number, segmentIndex: number): AnchorWithMarker {
|
||||
createAnchor(
|
||||
point: TrackPoint,
|
||||
segment: TrackSegment,
|
||||
trackIndex: number,
|
||||
segmentIndex: number
|
||||
): AnchorWithMarker {
|
||||
let element = document.createElement('div');
|
||||
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,
|
||||
className: 'z-10',
|
||||
element
|
||||
element,
|
||||
}).setLngLat(point.getCoordinates());
|
||||
|
||||
let anchor = {
|
||||
@@ -157,7 +184,7 @@ export class RoutingControls {
|
||||
trackIndex,
|
||||
segmentIndex,
|
||||
marker,
|
||||
inZoom: false
|
||||
inZoom: false,
|
||||
};
|
||||
|
||||
marker.on('dragstart', (e) => {
|
||||
@@ -185,7 +212,8 @@ export class RoutingControls {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (Date.now() - this.lastDragEvent < 100) { // Prevent click event during drag
|
||||
if (Date.now() - this.lastDragEvent < 100) {
|
||||
// Prevent click event during drag
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -204,7 +232,12 @@ export class RoutingControls {
|
||||
return false;
|
||||
}
|
||||
let segment = anchor.segment;
|
||||
if (distance(segment.trkpt[0].getCoordinates(), segment.trkpt[segment.trkpt.length - 1].getCoordinates()) > 1000) {
|
||||
if (
|
||||
distance(
|
||||
segment.trkpt[0].getCoordinates(),
|
||||
segment.trkpt[segment.trkpt.length - 1].getCoordinates()
|
||||
) > 1000
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -224,7 +257,8 @@ export class RoutingControls {
|
||||
};
|
||||
}
|
||||
|
||||
toggleAnchorsForZoomLevelAndBounds() { // Show markers only if they are in the current zoom level and bounds
|
||||
toggleAnchorsForZoomLevelAndBounds() {
|
||||
// Show markers only if they are in the current zoom level and bounds
|
||||
this.shownAnchors.splice(0, this.shownAnchors.length);
|
||||
|
||||
let center = this.map.getCenter();
|
||||
@@ -245,7 +279,8 @@ 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
|
||||
if (this.temporaryAnchor.marker.getElement().classList.contains('cursor-grabbing')) {
|
||||
// Do not not change the source point if it is already being dragged
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -253,7 +288,15 @@ export class RoutingControls {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, e.features[0].properties.trackIndex, e.features[0].properties.segmentIndex))) {
|
||||
if (
|
||||
!get(selection).hasAnyParent(
|
||||
new ListTrackSegmentItem(
|
||||
this.fileId,
|
||||
e.features[0].properties.trackIndex,
|
||||
e.features[0].properties.segmentIndex
|
||||
)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -263,7 +306,7 @@ export class RoutingControls {
|
||||
|
||||
this.temporaryAnchor.point.setCoordinates({
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng
|
||||
lon: e.lngLat.lng,
|
||||
});
|
||||
this.temporaryAnchor.marker.setLngLat(e.lngLat).addTo(this.map);
|
||||
|
||||
@@ -271,12 +314,17 @@ export class RoutingControls {
|
||||
}
|
||||
|
||||
updateTemporaryAnchor(e: any) {
|
||||
if (this.temporaryAnchor.marker.getElement().classList.contains('cursor-grabbing')) { // Do not hide if it is being dragged, and stop listening for mousemove
|
||||
if (this.temporaryAnchor.marker.getElement().classList.contains('cursor-grabbing')) {
|
||||
// Do not hide if it is being dragged, and stop listening for mousemove
|
||||
this.map.off('mousemove', this.updateTemporaryAnchorBinded);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.point.dist(this.map.project(this.temporaryAnchor.point.getCoordinates())) > 20 || this.temporaryAnchorCloseToOtherAnchor(e)) { // Hide if too far from the layer
|
||||
if (
|
||||
e.point.dist(this.map.project(this.temporaryAnchor.point.getCoordinates())) > 20 ||
|
||||
this.temporaryAnchorCloseToOtherAnchor(e)
|
||||
) {
|
||||
// Hide if too far from the layer
|
||||
this.temporaryAnchor.marker.remove();
|
||||
this.map.off('mousemove', this.updateTemporaryAnchorBinded);
|
||||
return;
|
||||
@@ -294,14 +342,16 @@ export class RoutingControls {
|
||||
return false;
|
||||
}
|
||||
|
||||
async moveAnchor(anchorWithMarker: AnchorWithMarker) { // Move the anchor and update the route from and to the neighbouring anchors
|
||||
async moveAnchor(anchorWithMarker: AnchorWithMarker) {
|
||||
// Move the anchor and update the route from and to the neighbouring anchors
|
||||
let coordinates = {
|
||||
lat: anchorWithMarker.marker.getLngLat().lat,
|
||||
lon: anchorWithMarker.marker.getLngLat().lng
|
||||
lon: anchorWithMarker.marker.getLngLat().lng,
|
||||
};
|
||||
|
||||
let anchor = anchorWithMarker as Anchor;
|
||||
if (anchorWithMarker === this.temporaryAnchor) { // Temporary anchor, need to find the closest point of the segment and create an anchor for it
|
||||
if (anchorWithMarker === this.temporaryAnchor) {
|
||||
// Temporary anchor, need to find the closest point of the segment and create an anchor for it
|
||||
this.temporaryAnchor.marker.remove();
|
||||
anchor = this.getPermanentAnchor();
|
||||
}
|
||||
@@ -326,7 +376,8 @@ export class RoutingControls {
|
||||
|
||||
let success = await this.routeBetweenAnchors(anchors, targetCoordinates);
|
||||
|
||||
if (!success) { // Route failed, revert the anchor to the previous position
|
||||
if (!success) {
|
||||
// Route failed, revert the anchor to the previous position
|
||||
anchorWithMarker.marker.setLngLat(anchorWithMarker.point.getCoordinates());
|
||||
}
|
||||
}
|
||||
@@ -338,16 +389,24 @@ export class RoutingControls {
|
||||
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))) {
|
||||
if (
|
||||
get(selection).hasAnyParent(
|
||||
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
)
|
||||
) {
|
||||
let details: any = {};
|
||||
let closest = getClosestLinePoint(segment.trkpt, this.temporaryAnchor.point, details);
|
||||
let closest = getClosestLinePoint(
|
||||
segment.trkpt,
|
||||
this.temporaryAnchor.point,
|
||||
details
|
||||
);
|
||||
if (details.distance < minDetails.distance) {
|
||||
minDetails = details;
|
||||
minAnchor = {
|
||||
point: closest,
|
||||
segment,
|
||||
trackIndex,
|
||||
segmentIndex
|
||||
segmentIndex,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -374,41 +433,67 @@ export class RoutingControls {
|
||||
point: this.temporaryAnchor.point,
|
||||
trackIndex: -1,
|
||||
segmentIndex: -1,
|
||||
trkptIndex: -1
|
||||
trkptIndex: -1,
|
||||
};
|
||||
|
||||
file?.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, 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 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.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
|
||||
zoom: 0,
|
||||
};
|
||||
|
||||
minInfo = {
|
||||
point,
|
||||
trackIndex,
|
||||
segmentIndex,
|
||||
trkptIndex: before + 1
|
||||
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]));
|
||||
dbUtils.applyToFile(this.fileId, (file) =>
|
||||
file.replaceTrackPoints(
|
||||
minInfo.trackIndex,
|
||||
minInfo.segmentIndex,
|
||||
minInfo.trkptIndex,
|
||||
minInfo.trkptIndex - 1,
|
||||
[minInfo.point]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,22 +501,46 @@ export class RoutingControls {
|
||||
return () => this.deleteAnchor(anchor);
|
||||
}
|
||||
|
||||
async deleteAnchor(anchor: Anchor) { // Remove the anchor and route between the neighbouring anchors if they exist
|
||||
async deleteAnchor(anchor: Anchor) {
|
||||
// Remove the anchor and route between the neighbouring anchors if they exist
|
||||
this.popup.remove();
|
||||
|
||||
let [previousAnchor, nextAnchor] = this.getNeighbouringAnchors(anchor);
|
||||
|
||||
if (previousAnchor === null && nextAnchor === null) { // Only one point, remove it
|
||||
dbUtils.applyToFile(this.fileId, (file) => file.replaceTrackPoints(anchor.trackIndex, anchor.segmentIndex, 0, 0, []));
|
||||
} else if (previousAnchor === null) { // First point, remove trackpoints until nextAnchor
|
||||
dbUtils.applyToFile(this.fileId, (file) => file.replaceTrackPoints(anchor.trackIndex, anchor.segmentIndex, 0, nextAnchor.point._data.index - 1, []));
|
||||
} else if (nextAnchor === null) { // Last point, remove trackpoints from previousAnchor
|
||||
if (previousAnchor === null && nextAnchor === null) {
|
||||
// Only one point, remove it
|
||||
dbUtils.applyToFile(this.fileId, (file) =>
|
||||
file.replaceTrackPoints(anchor.trackIndex, anchor.segmentIndex, 0, 0, [])
|
||||
);
|
||||
} else if (previousAnchor === null) {
|
||||
// First point, remove trackpoints until nextAnchor
|
||||
dbUtils.applyToFile(this.fileId, (file) =>
|
||||
file.replaceTrackPoints(
|
||||
anchor.trackIndex,
|
||||
anchor.segmentIndex,
|
||||
0,
|
||||
nextAnchor.point._data.index - 1,
|
||||
[]
|
||||
)
|
||||
);
|
||||
} else if (nextAnchor === null) {
|
||||
// Last point, remove trackpoints from previousAnchor
|
||||
dbUtils.applyToFile(this.fileId, (file) => {
|
||||
let segment = file.getSegment(anchor.trackIndex, anchor.segmentIndex);
|
||||
file.replaceTrackPoints(anchor.trackIndex, anchor.segmentIndex, previousAnchor.point._data.index + 1, segment.trkpt.length - 1, []);
|
||||
file.replaceTrackPoints(
|
||||
anchor.trackIndex,
|
||||
anchor.segmentIndex,
|
||||
previousAnchor.point._data.index + 1,
|
||||
segment.trkpt.length - 1,
|
||||
[]
|
||||
);
|
||||
});
|
||||
} else { // Route between previousAnchor and nextAnchor
|
||||
this.routeBetweenAnchors([previousAnchor, nextAnchor], [previousAnchor.point.getCoordinates(), nextAnchor.point.getCoordinates()]);
|
||||
} else {
|
||||
// Route between previousAnchor and nextAnchor
|
||||
this.routeBetweenAnchors(
|
||||
[previousAnchor, nextAnchor],
|
||||
[previousAnchor.point.getCoordinates(), nextAnchor.point.getCoordinates()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,27 +556,43 @@ export class RoutingControls {
|
||||
return;
|
||||
}
|
||||
|
||||
let speed = fileWithStats.statistics.getStatisticsFor(new ListTrackSegmentItem(this.fileId, anchor.trackIndex, anchor.segmentIndex)).global.speed.moving;
|
||||
let speed = fileWithStats.statistics.getStatisticsFor(
|
||||
new ListTrackSegmentItem(this.fileId, anchor.trackIndex, anchor.segmentIndex)
|
||||
).global.speed.moving;
|
||||
|
||||
let segment = anchor.segment;
|
||||
dbUtils.applyToFile(this.fileId, (file) => {
|
||||
file.replaceTrackPoints(anchor.trackIndex, anchor.segmentIndex, segment.trkpt.length, segment.trkpt.length - 1, segment.trkpt.slice(0, anchor.point._data.index), speed > 0 ? speed : undefined);
|
||||
file.crop(anchor.point._data.index, anchor.point._data.index + segment.trkpt.length - 1, [anchor.trackIndex], [anchor.segmentIndex]);
|
||||
file.replaceTrackPoints(
|
||||
anchor.trackIndex,
|
||||
anchor.segmentIndex,
|
||||
segment.trkpt.length,
|
||||
segment.trkpt.length - 1,
|
||||
segment.trkpt.slice(0, anchor.point._data.index),
|
||||
speed > 0 ? speed : undefined
|
||||
);
|
||||
file.crop(
|
||||
anchor.point._data.index,
|
||||
anchor.point._data.index + segment.trkpt.length - 1,
|
||||
[anchor.trackIndex],
|
||||
[anchor.segmentIndex]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async appendAnchor(e: mapboxgl.MapMouseEvent) { // Add a new anchor to the end of the last segment
|
||||
async appendAnchor(e: mapboxgl.MapMouseEvent) {
|
||||
// Add a new anchor to the end of the last segment
|
||||
if (get(streetViewEnabled) && get(streetViewSource) === 'google') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.appendAnchorWithCoordinates({
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng
|
||||
lon: e.lngLat.lng,
|
||||
});
|
||||
}
|
||||
|
||||
async appendAnchorWithCoordinates(coordinates: Coordinates) { // Add a new anchor to the end of the last segment
|
||||
async appendAnchorWithCoordinates(coordinates: Coordinates) {
|
||||
// Add a new anchor to the end of the last segment
|
||||
let selected = getOrderedSelection();
|
||||
if (selected.length === 0 || selected[selected.length - 1].getFileId() !== this.fileId) {
|
||||
return;
|
||||
@@ -477,7 +602,7 @@ export class RoutingControls {
|
||||
let lastAnchor = this.anchors[this.anchors.length - 1];
|
||||
|
||||
let newPoint = new TrackPoint({
|
||||
attributes: coordinates
|
||||
attributes: coordinates,
|
||||
});
|
||||
newPoint._data.anchor = true;
|
||||
newPoint._data.zoom = 0;
|
||||
@@ -488,7 +613,10 @@ export class RoutingControls {
|
||||
if (item instanceof ListTrackItem || item instanceof ListTrackSegmentItem) {
|
||||
trackIndex = item.getTrackIndex();
|
||||
}
|
||||
let segmentIndex = (file.trk.length > 0 && file.trk[trackIndex].trkseg.length > 0) ? file.trk[trackIndex].trkseg.length - 1 : 0;
|
||||
let segmentIndex =
|
||||
file.trk.length > 0 && file.trk[trackIndex].trkseg.length > 0
|
||||
? file.trk[trackIndex].trkseg.length - 1
|
||||
: 0;
|
||||
if (item instanceof ListTrackSegmentItem) {
|
||||
segmentIndex = item.getSegmentIndex();
|
||||
}
|
||||
@@ -512,10 +640,13 @@ export class RoutingControls {
|
||||
point: newPoint,
|
||||
segment: lastAnchor.segment,
|
||||
trackIndex: lastAnchor.trackIndex,
|
||||
segmentIndex: lastAnchor.segmentIndex
|
||||
segmentIndex: lastAnchor.segmentIndex,
|
||||
};
|
||||
|
||||
await this.routeBetweenAnchors([lastAnchor, newAnchor], [lastAnchor.point.getCoordinates(), newAnchor.point.getCoordinates()]);
|
||||
await this.routeBetweenAnchors(
|
||||
[lastAnchor, newAnchor],
|
||||
[lastAnchor.point.getCoordinates(), newAnchor.point.getCoordinates()]
|
||||
);
|
||||
}
|
||||
|
||||
getNeighbouringAnchors(anchor: Anchor): [Anchor | null, Anchor | null] {
|
||||
@@ -525,11 +656,17 @@ export class RoutingControls {
|
||||
for (let i = 0; i < this.anchors.length; i++) {
|
||||
if (this.anchors[i].segment === anchor.segment && this.anchors[i].inZoom) {
|
||||
if (this.anchors[i].point._data.index < anchor.point._data.index) {
|
||||
if (!previousAnchor || this.anchors[i].point._data.index > previousAnchor.point._data.index) {
|
||||
if (
|
||||
!previousAnchor ||
|
||||
this.anchors[i].point._data.index > previousAnchor.point._data.index
|
||||
) {
|
||||
previousAnchor = this.anchors[i];
|
||||
}
|
||||
} else if (this.anchors[i].point._data.index > anchor.point._data.index) {
|
||||
if (!nextAnchor || this.anchors[i].point._data.index < nextAnchor.point._data.index) {
|
||||
if (
|
||||
!nextAnchor ||
|
||||
this.anchors[i].point._data.index < nextAnchor.point._data.index
|
||||
) {
|
||||
nextAnchor = this.anchors[i];
|
||||
}
|
||||
}
|
||||
@@ -539,7 +676,10 @@ export class RoutingControls {
|
||||
return [previousAnchor, nextAnchor];
|
||||
}
|
||||
|
||||
async routeBetweenAnchors(anchors: Anchor[], targetCoordinates: Coordinates[]): Promise<boolean> {
|
||||
async routeBetweenAnchors(
|
||||
anchors: Anchor[],
|
||||
targetCoordinates: Coordinates[]
|
||||
): Promise<boolean> {
|
||||
let segment = anchors[0].segment;
|
||||
|
||||
let fileWithStats = get(this.file);
|
||||
@@ -547,10 +687,15 @@ export class RoutingControls {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (anchors.length === 1) { // Only one anchor, update the point in the segment
|
||||
dbUtils.applyToFile(this.fileId, (file) => file.replaceTrackPoints(anchors[0].trackIndex, anchors[0].segmentIndex, 0, 0, [new TrackPoint({
|
||||
attributes: targetCoordinates[0],
|
||||
})]));
|
||||
if (anchors.length === 1) {
|
||||
// Only one anchor, update the point in the segment
|
||||
dbUtils.applyToFile(this.fileId, (file) =>
|
||||
file.replaceTrackPoints(anchors[0].trackIndex, anchors[0].segmentIndex, 0, 0, [
|
||||
new TrackPoint({
|
||||
attributes: targetCoordinates[0],
|
||||
}),
|
||||
])
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -559,23 +704,28 @@ export class RoutingControls {
|
||||
response = await route(targetCoordinates);
|
||||
} catch (e: any) {
|
||||
if (e.message.includes('from-position not mapped in existing datafile')) {
|
||||
toast.error(get(_)("toolbar.routing.error.from"));
|
||||
toast.error(get(_)('toolbar.routing.error.from'));
|
||||
} else if (e.message.includes('via1-position not mapped in existing datafile')) {
|
||||
toast.error(get(_)("toolbar.routing.error.via"));
|
||||
toast.error(get(_)('toolbar.routing.error.via'));
|
||||
} else if (e.message.includes('to-position not mapped in existing datafile')) {
|
||||
toast.error(get(_)("toolbar.routing.error.to"));
|
||||
toast.error(get(_)('toolbar.routing.error.to'));
|
||||
} else if (e.message.includes('Time-out')) {
|
||||
toast.error(get(_)("toolbar.routing.error.timeout"));
|
||||
toast.error(get(_)('toolbar.routing.error.timeout'));
|
||||
} else {
|
||||
toast.error(e.message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (anchors[0].point._data.index === 0) { // First anchor is the first point of the segment
|
||||
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
|
||||
} 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 {
|
||||
@@ -583,7 +733,8 @@ export class RoutingControls {
|
||||
response.splice(0, 0, anchors[0].point); // Insert it in the response to keep it
|
||||
}
|
||||
|
||||
if (anchors[anchors.length - 1].point._data.index === segment.trkpt.length - 1) { // Last anchor is the last point of the segment
|
||||
if (anchors[anchors.length - 1].point._data.index === segment.trkpt.length - 1) {
|
||||
// Last anchor is the last point of the segment
|
||||
anchors[anchors.length - 1].point = response[response.length - 1]; // replace the last anchor
|
||||
anchors[anchors.length - 1].point._data.index = segment.trkpt.length - 1;
|
||||
} else {
|
||||
@@ -594,7 +745,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
|
||||
anchors[i].point = getClosestLinePoint(response.slice(1, - 1), targetCoordinates[i]);
|
||||
anchors[i].point = getClosestLinePoint(response.slice(1, -1), targetCoordinates[i]);
|
||||
}
|
||||
|
||||
anchors.forEach((anchor) => {
|
||||
@@ -602,36 +753,64 @@ export class RoutingControls {
|
||||
anchor.point._data.zoom = 0; // Make these anchors permanent
|
||||
});
|
||||
|
||||
let stats = fileWithStats.statistics.getStatisticsFor(new ListTrackSegmentItem(this.fileId, anchors[0].trackIndex, anchors[0].segmentIndex));
|
||||
let stats = fileWithStats.statistics.getStatisticsFor(
|
||||
new ListTrackSegmentItem(this.fileId, anchors[0].trackIndex, anchors[0].segmentIndex)
|
||||
);
|
||||
let speed: number | undefined = undefined;
|
||||
let startTime = anchors[0].point.time;
|
||||
|
||||
if (stats.global.speed.moving > 0) {
|
||||
let replacingDistance = 0;
|
||||
for (let i = 1; i < response.length; i++) {
|
||||
replacingDistance += distance(response[i - 1].getCoordinates(), response[i].getCoordinates()) / 1000;
|
||||
replacingDistance +=
|
||||
distance(response[i - 1].getCoordinates(), response[i].getCoordinates()) / 1000;
|
||||
}
|
||||
let replacedDistance = stats.local.distance.moving[anchors[anchors.length - 1].point._data.index] - stats.local.distance.moving[anchors[0].point._data.index];
|
||||
let replacedDistance =
|
||||
stats.local.distance.moving[anchors[anchors.length - 1].point._data.index] -
|
||||
stats.local.distance.moving[anchors[0].point._data.index];
|
||||
|
||||
let newDistance = stats.global.distance.moving + replacingDistance - replacedDistance;
|
||||
let newTime = newDistance / stats.global.speed.moving * 3600;
|
||||
let newTime = (newDistance / stats.global.speed.moving) * 3600;
|
||||
|
||||
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 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];
|
||||
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;
|
||||
speed = (replacingDistance / replacingTime) * 3600;
|
||||
|
||||
if (startTime === undefined) { // Replacing the first point
|
||||
if (startTime === undefined) {
|
||||
// Replacing the first point
|
||||
let endIndex = anchors[anchors.length - 1].point._data.index;
|
||||
startTime = new Date((segment.trkpt[endIndex].time?.getTime() ?? 0) - (replacingTime + stats.local.time.total[endIndex] - stats.local.time.moving[endIndex]) * 1000);
|
||||
startTime = new Date(
|
||||
(segment.trkpt[endIndex].time?.getTime() ?? 0) -
|
||||
(replacingTime +
|
||||
stats.local.time.total[endIndex] -
|
||||
stats.local.time.moving[endIndex]) *
|
||||
1000
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
dbUtils.applyToFile(this.fileId, (file) => file.replaceTrackPoints(anchors[0].trackIndex, anchors[0].segmentIndex, anchors[0].point._data.index, anchors[anchors.length - 1].point._data.index, response, speed, startTime));
|
||||
dbUtils.applyToFile(this.fileId, (file) =>
|
||||
file.replaceTrackPoints(
|
||||
anchors[0].trackIndex,
|
||||
anchors[0].segmentIndex,
|
||||
anchors[0].point._data.index,
|
||||
anchors[anchors.length - 1].point._data.index,
|
||||
response,
|
||||
speed,
|
||||
startTime
|
||||
)
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ramerDouglasPeucker, type GPXFile, type TrackSegment } from "gpx";
|
||||
import { ramerDouglasPeucker, type GPXFile, type TrackSegment } from 'gpx';
|
||||
|
||||
const earthRadius = 6371008.8;
|
||||
|
||||
@@ -17,7 +17,8 @@ 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
|
||||
if (!segment._data.anchors) {
|
||||
// New segment, compute anchor points for it
|
||||
computeAnchorPoints(segment);
|
||||
continue;
|
||||
}
|
||||
@@ -42,4 +43,3 @@ function computeAnchorPoints(segment: TrackSegment) {
|
||||
});
|
||||
segment._data.anchors = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,146 +1,151 @@
|
||||
<script lang="ts" context="module">
|
||||
export enum SplitType {
|
||||
FILES = 'files',
|
||||
TRACKS = 'tracks',
|
||||
SEGMENTS = 'segments'
|
||||
}
|
||||
export enum SplitType {
|
||||
FILES = 'files',
|
||||
TRACKS = 'tracks',
|
||||
SEGMENTS = 'segments',
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { ListRootItem } from '$lib/components/file-list/FileList';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
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 { gpxStatistics, map, slicedGPXStatistics, splitAs } from '$lib/stores';
|
||||
import { get } from 'svelte/store';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
import { Crop } from 'lucide-svelte';
|
||||
import { dbUtils } from '$lib/db';
|
||||
import { SplitControls } from './SplitControls';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { ListRootItem } from '$lib/components/file-list/FileList';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
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 { gpxStatistics, map, slicedGPXStatistics, splitAs } from '$lib/stores';
|
||||
import { get } from 'svelte/store';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
import { Crop } from 'lucide-svelte';
|
||||
import { dbUtils } from '$lib/db';
|
||||
import { SplitControls } from './SplitControls';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
|
||||
let splitControls: SplitControls | undefined = undefined;
|
||||
let canCrop = false;
|
||||
let splitControls: SplitControls | undefined = undefined;
|
||||
let canCrop = false;
|
||||
|
||||
$: if ($map) {
|
||||
if (splitControls) {
|
||||
splitControls.destroy();
|
||||
}
|
||||
splitControls = new SplitControls($map);
|
||||
}
|
||||
$: if ($map) {
|
||||
if (splitControls) {
|
||||
splitControls.destroy();
|
||||
}
|
||||
splitControls = new SplitControls($map);
|
||||
}
|
||||
|
||||
$: validSelection =
|
||||
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) &&
|
||||
$gpxStatistics.local.points.length > 0;
|
||||
$: validSelection =
|
||||
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) &&
|
||||
$gpxStatistics.local.points.length > 0;
|
||||
|
||||
let maxSliderValue = 1;
|
||||
let sliderValues = [0, 1];
|
||||
let maxSliderValue = 1;
|
||||
let sliderValues = [0, 1];
|
||||
|
||||
function updateCanCrop() {
|
||||
canCrop = sliderValues[0] != 0 || sliderValues[1] != maxSliderValue;
|
||||
}
|
||||
function updateCanCrop() {
|
||||
canCrop = sliderValues[0] != 0 || sliderValues[1] != maxSliderValue;
|
||||
}
|
||||
|
||||
function updateSlicedGPXStatistics() {
|
||||
if (validSelection && canCrop) {
|
||||
$slicedGPXStatistics = [
|
||||
get(gpxStatistics).slice(sliderValues[0], sliderValues[1]),
|
||||
sliderValues[0],
|
||||
sliderValues[1]
|
||||
];
|
||||
} else {
|
||||
$slicedGPXStatistics = undefined;
|
||||
}
|
||||
}
|
||||
function updateSlicedGPXStatistics() {
|
||||
if (validSelection && canCrop) {
|
||||
$slicedGPXStatistics = [
|
||||
get(gpxStatistics).slice(sliderValues[0], sliderValues[1]),
|
||||
sliderValues[0],
|
||||
sliderValues[1],
|
||||
];
|
||||
} else {
|
||||
$slicedGPXStatistics = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function updateSliderValues() {
|
||||
if ($slicedGPXStatistics !== undefined) {
|
||||
sliderValues = [$slicedGPXStatistics[1], $slicedGPXStatistics[2]];
|
||||
}
|
||||
}
|
||||
function updateSliderValues() {
|
||||
if ($slicedGPXStatistics !== undefined) {
|
||||
sliderValues = [$slicedGPXStatistics[1], $slicedGPXStatistics[2]];
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSliderLimits() {
|
||||
if (validSelection && $gpxStatistics.local.points.length > 0) {
|
||||
maxSliderValue = $gpxStatistics.local.points.length - 1;
|
||||
} else {
|
||||
maxSliderValue = 1;
|
||||
}
|
||||
await tick();
|
||||
sliderValues = [0, maxSliderValue];
|
||||
}
|
||||
async function updateSliderLimits() {
|
||||
if (validSelection && $gpxStatistics.local.points.length > 0) {
|
||||
maxSliderValue = $gpxStatistics.local.points.length - 1;
|
||||
} else {
|
||||
maxSliderValue = 1;
|
||||
}
|
||||
await tick();
|
||||
sliderValues = [0, maxSliderValue];
|
||||
}
|
||||
|
||||
$: if ($gpxStatistics.local.points.length - 1 != maxSliderValue) {
|
||||
updateSliderLimits();
|
||||
}
|
||||
$: if ($gpxStatistics.local.points.length - 1 != maxSliderValue) {
|
||||
updateSliderLimits();
|
||||
}
|
||||
|
||||
$: if (sliderValues) {
|
||||
updateCanCrop();
|
||||
updateSlicedGPXStatistics();
|
||||
}
|
||||
$: if (sliderValues) {
|
||||
updateCanCrop();
|
||||
updateSlicedGPXStatistics();
|
||||
}
|
||||
|
||||
$: if (
|
||||
$slicedGPXStatistics !== undefined &&
|
||||
($slicedGPXStatistics[1] !== sliderValues[0] || $slicedGPXStatistics[2] !== sliderValues[1])
|
||||
) {
|
||||
updateSliderValues();
|
||||
updateCanCrop();
|
||||
}
|
||||
$: if (
|
||||
$slicedGPXStatistics !== undefined &&
|
||||
($slicedGPXStatistics[1] !== sliderValues[0] || $slicedGPXStatistics[2] !== sliderValues[1])
|
||||
) {
|
||||
updateSliderValues();
|
||||
updateCanCrop();
|
||||
}
|
||||
|
||||
const splitTypes = [
|
||||
{ value: SplitType.FILES, label: $_('gpx.files') },
|
||||
{ value: SplitType.TRACKS, label: $_('gpx.tracks') },
|
||||
{ value: SplitType.SEGMENTS, label: $_('gpx.segments') }
|
||||
];
|
||||
const splitTypes = [
|
||||
{ value: SplitType.FILES, label: $_('gpx.files') },
|
||||
{ value: SplitType.TRACKS, label: $_('gpx.tracks') },
|
||||
{ value: SplitType.SEGMENTS, label: $_('gpx.segments') },
|
||||
];
|
||||
|
||||
let splitType = splitTypes.find((type) => type.value === $splitAs) ?? splitTypes[0];
|
||||
let splitType = splitTypes.find((type) => type.value === $splitAs) ?? splitTypes[0];
|
||||
|
||||
$: splitAs.set(splitType.value);
|
||||
$: splitAs.set(splitType.value);
|
||||
|
||||
onDestroy(() => {
|
||||
$slicedGPXStatistics = undefined;
|
||||
if (splitControls) {
|
||||
splitControls.destroy();
|
||||
splitControls = undefined;
|
||||
}
|
||||
});
|
||||
onDestroy(() => {
|
||||
$slicedGPXStatistics = undefined;
|
||||
if (splitControls) {
|
||||
splitControls.destroy();
|
||||
splitControls = undefined;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
|
||||
<div class="p-2">
|
||||
<Slider bind:value={sliderValues} max={maxSliderValue} step={1} disabled={!validSelection} />
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!validSelection || !canCrop}
|
||||
on:click={() => dbUtils.cropSelection(sliderValues[0], sliderValues[1])}
|
||||
>
|
||||
<Crop size="16" class="mr-1" />{$_('toolbar.scissors.crop')}
|
||||
</Button>
|
||||
<Separator />
|
||||
<Label class="flex flex-row flex-wrap gap-3 items-center">
|
||||
<span class="shrink-0">
|
||||
{$_('toolbar.scissors.split_as')}
|
||||
</span>
|
||||
<Select.Root bind:selected={splitType}>
|
||||
<Select.Trigger class="h-8 w-fit grow">
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each splitTypes as { value, label }}
|
||||
<Select.Item {value}>{label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Label>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/scissors')}>
|
||||
{#if validSelection}
|
||||
{$_('toolbar.scissors.help')}
|
||||
{:else}
|
||||
{$_('toolbar.scissors.help_invalid_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
<div class="p-2">
|
||||
<Slider
|
||||
bind:value={sliderValues}
|
||||
max={maxSliderValue}
|
||||
step={1}
|
||||
disabled={!validSelection}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!validSelection || !canCrop}
|
||||
on:click={() => dbUtils.cropSelection(sliderValues[0], sliderValues[1])}
|
||||
>
|
||||
<Crop size="16" class="mr-1" />{$_('toolbar.scissors.crop')}
|
||||
</Button>
|
||||
<Separator />
|
||||
<Label class="flex flex-row flex-wrap gap-3 items-center">
|
||||
<span class="shrink-0">
|
||||
{$_('toolbar.scissors.split_as')}
|
||||
</span>
|
||||
<Select.Root bind:selected={splitType}>
|
||||
<Select.Trigger class="h-8 w-fit grow">
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each splitTypes as { value, label }}
|
||||
<Select.Item {value}>{label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Label>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/scissors')}>
|
||||
{#if validSelection}
|
||||
{$_('toolbar.scissors.help')}
|
||||
{:else}
|
||||
{$_('toolbar.scissors.help_invalid_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
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";
|
||||
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;
|
||||
@@ -15,7 +18,8 @@ export class SplitControls {
|
||||
shownControls: ControlWithMarker[] = [];
|
||||
unsubscribes: Function[] = [];
|
||||
|
||||
toggleControlsForZoomLevelAndBoundsBinded: () => void = this.toggleControlsForZoomLevelAndBounds.bind(this);
|
||||
toggleControlsForZoomLevelAndBoundsBinded: () => void =
|
||||
this.toggleControlsForZoomLevelAndBounds.bind(this);
|
||||
|
||||
constructor(map: mapboxgl.Map) {
|
||||
this.map = map;
|
||||
@@ -48,15 +52,21 @@ export class SplitControls {
|
||||
this.map.on('move', this.toggleControlsForZoomLevelAndBoundsBinded);
|
||||
}
|
||||
|
||||
updateControls() { // Update the markers when the files change
|
||||
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 (
|
||||
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].fileId = fileId;
|
||||
@@ -64,20 +74,30 @@ export class SplitControls {
|
||||
this.controls[controlIndex].segment = segment;
|
||||
this.controls[controlIndex].trackIndex = trackIndex;
|
||||
this.controls[controlIndex].segmentIndex = segmentIndex;
|
||||
this.controls[controlIndex].marker.setLngLat(point.getCoordinates());
|
||||
this.controls[controlIndex].marker.setLngLat(
|
||||
point.getCoordinates()
|
||||
);
|
||||
} else {
|
||||
this.controls.push(this.createControl(point, segment, fileId, trackIndex, segmentIndex));
|
||||
this.controls.push(
|
||||
this.createControl(
|
||||
point,
|
||||
segment,
|
||||
fileId,
|
||||
trackIndex,
|
||||
segmentIndex
|
||||
)
|
||||
);
|
||||
}
|
||||
controlIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
}, false);
|
||||
|
||||
while (controlIndex < this.controls.length) { // Remove the extra controls
|
||||
while (controlIndex < this.controls.length) {
|
||||
// Remove the extra controls
|
||||
this.controls.pop()?.marker.remove();
|
||||
}
|
||||
|
||||
@@ -94,7 +114,8 @@ export class SplitControls {
|
||||
this.map.off('move', this.toggleControlsForZoomLevelAndBoundsBinded);
|
||||
}
|
||||
|
||||
toggleControlsForZoomLevelAndBounds() { // Show markers only if they are in the current zoom level and bounds
|
||||
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]);
|
||||
@@ -113,15 +134,23 @@ export class SplitControls {
|
||||
});
|
||||
}
|
||||
|
||||
createControl(point: TrackPoint, segment: TrackSegment, fileId: string, trackIndex: number, segmentIndex: number): ControlWithMarker {
|
||||
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"');
|
||||
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
|
||||
element,
|
||||
}).setLngLat(point.getCoordinates());
|
||||
|
||||
let control = {
|
||||
@@ -131,12 +160,18 @@ export class SplitControls {
|
||||
trackIndex,
|
||||
segmentIndex,
|
||||
marker,
|
||||
inZoom: false
|
||||
inZoom: false,
|
||||
};
|
||||
|
||||
marker.getElement().addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
dbUtils.split(control.fileId, control.trackIndex, control.segmentIndex, control.point.getCoordinates(), control.point._data.index);
|
||||
dbUtils.split(
|
||||
control.fileId,
|
||||
control.trackIndex,
|
||||
control.segmentIndex,
|
||||
control.point.getCoordinates(),
|
||||
control.point._data.index
|
||||
);
|
||||
});
|
||||
|
||||
return control;
|
||||
|
||||
Reference in New Issue
Block a user