mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2025-09-05 09:52:54 +00:00
Merge branch 'dev' into elevation-tool
This commit is contained in:
@@ -40,6 +40,7 @@
|
||||
import { DateFormatter } from '@internationalized/date';
|
||||
import type { GPXStatistics } from 'gpx';
|
||||
import { settings } from '$lib/db';
|
||||
import { mode } from 'mode-watcher';
|
||||
|
||||
export let gpxStatistics: Writable<GPXStatistics>;
|
||||
export let slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
|
||||
@@ -72,6 +73,7 @@
|
||||
|
||||
let marker: mapboxgl.Marker | null = null;
|
||||
let dragging = false;
|
||||
let panning = false;
|
||||
|
||||
let options = {
|
||||
animation: false,
|
||||
@@ -102,7 +104,8 @@
|
||||
line: {
|
||||
pointRadius: 0,
|
||||
tension: 0.4,
|
||||
borderWidth: 2
|
||||
borderWidth: 2,
|
||||
cubicInterpolationMode: 'monotone'
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
@@ -118,7 +121,7 @@
|
||||
enabled: true
|
||||
},
|
||||
tooltip: {
|
||||
enabled: () => !dragging,
|
||||
enabled: () => !dragging && !panning,
|
||||
callbacks: {
|
||||
title: function () {
|
||||
return '';
|
||||
@@ -176,6 +179,48 @@
|
||||
return labels;
|
||||
}
|
||||
}
|
||||
},
|
||||
zoom: {
|
||||
pan: {
|
||||
enabled: true,
|
||||
mode: 'x',
|
||||
modifierKey: 'shift',
|
||||
onPanStart: function () {
|
||||
// hide tooltip
|
||||
panning = true;
|
||||
$slicedGPXStatistics = undefined;
|
||||
},
|
||||
onPanComplete: function () {
|
||||
panning = false;
|
||||
}
|
||||
},
|
||||
zoom: {
|
||||
wheel: {
|
||||
enabled: true
|
||||
},
|
||||
mode: 'x',
|
||||
onZoomStart: function ({ chart, event }: { chart: Chart; event: any }) {
|
||||
if (
|
||||
event.deltaY < 0 &&
|
||||
Math.abs(
|
||||
chart.getInitialScaleBounds().x.max / chart.options.plugins.zoom.limits.x.minRange -
|
||||
chart.getZoomLevel()
|
||||
) < 0.01
|
||||
) {
|
||||
// Disable wheel pan if zoomed in to the max, and zooming in
|
||||
return false;
|
||||
}
|
||||
|
||||
$slicedGPXStatistics = undefined;
|
||||
}
|
||||
},
|
||||
limits: {
|
||||
x: {
|
||||
min: 'original',
|
||||
max: 'original',
|
||||
minRange: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
stacked: false,
|
||||
@@ -248,7 +293,9 @@
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
onMount(async () => {
|
||||
Chart.register((await import('chartjs-plugin-zoom')).default); // dynamic import to avoid SSR and 'window is not defined' error
|
||||
|
||||
chart = new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
@@ -312,6 +359,10 @@
|
||||
|
||||
let dragStarted = false;
|
||||
function onMouseDown(evt) {
|
||||
if (evt.shiftKey) {
|
||||
// Panning interaction
|
||||
return;
|
||||
}
|
||||
dragStarted = true;
|
||||
canvas.style.cursor = 'col-resize';
|
||||
startIndex = getIndex(evt);
|
||||
@@ -525,7 +576,8 @@
|
||||
// Draw selection rectangle
|
||||
let selectionContext = overlay.getContext('2d');
|
||||
if (selectionContext) {
|
||||
selectionContext.globalAlpha = 0.1;
|
||||
selectionContext.fillStyle = $mode === 'dark' ? 'white' : 'black';
|
||||
selectionContext.globalAlpha = $mode === 'dark' ? 0.2 : 0.1;
|
||||
selectionContext.clearRect(0, 0, overlay.width, overlay.height);
|
||||
|
||||
let startPixel = chart.scales.x.getPixelForValue(
|
||||
@@ -550,7 +602,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
$: $slicedGPXStatistics, updateOverlay();
|
||||
$: $slicedGPXStatistics, $mode, updateOverlay();
|
||||
|
||||
onDestroy(() => {
|
||||
if (chart) {
|
||||
|
@@ -1,24 +1,75 @@
|
||||
<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
|
||||
exportState,
|
||||
gpxStatistics
|
||||
} from '$lib/stores';
|
||||
import { fileObservers } from '$lib/db';
|
||||
import { Download } from 'lucide-svelte';
|
||||
import {
|
||||
Download,
|
||||
Zap,
|
||||
BrickWall,
|
||||
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,
|
||||
surface: true,
|
||||
hr: true,
|
||||
cad: true,
|
||||
atemp: true,
|
||||
power: true
|
||||
};
|
||||
let hide: Record<string, boolean> = {
|
||||
time: false,
|
||||
surface: false,
|
||||
hr: false,
|
||||
cad: false,
|
||||
atemp: false,
|
||||
power: false
|
||||
};
|
||||
|
||||
$: 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());
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
$: exclude = Object.keys(exportOptions).filter((key) => !exportOptions[key]);
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
@@ -32,9 +83,11 @@
|
||||
<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-2 border bg-background p-3 shadow-lg rounded-md"
|
||||
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="flex flex-row items-center gap-4 border rounded-md p-2">
|
||||
<div
|
||||
class="w-full flex flex-row items-center justify-center gap-4 border rounded-md p-2 bg-accent"
|
||||
>
|
||||
<span>⚠️</span>
|
||||
<span class="max-w-96 text-sm">
|
||||
{$_('menu.support_message')}
|
||||
@@ -50,9 +103,9 @@
|
||||
class="grow"
|
||||
on:click={() => {
|
||||
if ($exportState === ExportState.SELECTION) {
|
||||
exportSelectedFiles();
|
||||
exportSelectedFiles(exclude);
|
||||
} else if ($exportState === ExportState.ALL) {
|
||||
exportAllFiles();
|
||||
exportAllFiles(exclude);
|
||||
}
|
||||
open = false;
|
||||
$exportState = ExportState.NONE;
|
||||
@@ -66,6 +119,63 @@
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="w-full max-w-xl flex flex-col items-center gap-2">
|
||||
<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">
|
||||
<Checkbox id="export-surface" bind:checked={exportOptions.surface} />
|
||||
<Label for="export-surface" class="flex flex-row items-center gap-1">
|
||||
<BrickWall size="16" />
|
||||
{$_('quantities.surface')}
|
||||
</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,60 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { page } from '$app/stores';
|
||||
import { languages } from '$lib/languages';
|
||||
import { _, isLoading } from 'svelte-i18n';
|
||||
|
||||
$: location = $page.route.id?.split('/')[2] ?? 'home';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{#if $isLoading}
|
||||
<title>gpx.studio — the online GPX file editor</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="View, edit and create GPX files online with advanced route planning capabilities and file processing tools, beautiful maps and detailed data visualizations."
|
||||
/>
|
||||
<meta property="og:title" content="gpx.studio — the online GPX file editor" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="View, edit and create GPX files online with advanced route planning capabilities and file processing tools, beautiful maps and detailed data visualizations."
|
||||
/>
|
||||
<meta name="twitter:title" content="gpx.studio — the online GPX file editor" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="View, edit and create GPX files online with advanced route planning capabilities and file processing tools, beautiful maps and detailed data visualizations."
|
||||
/>
|
||||
{:else}
|
||||
<title>gpx.studio — {$_(`metadata.${location}_title`)}</title>
|
||||
<meta name="description" content={$_('metadata.description')} />
|
||||
<meta property="og:title" content="gpx.studio — {$_(`metadata.${location}_title`)}" />
|
||||
<meta property="og:description" content={$_('metadata.description')} />
|
||||
<meta name="twitter:title" content="gpx.studio — {$_(`metadata.${location}_title`)}" />
|
||||
<meta name="twitter:description" content={$_('metadata.description')} />
|
||||
{/if}
|
||||
|
||||
<meta property="og:image" content="https://gpx.studio/og_logo.png" />
|
||||
<meta property="og:url" content="https://gpx.studio/" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="gpx.studio" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:image" content="https://gpx.studio/og_logo.png" />
|
||||
<meta name="twitter:url" content="https://gpx.studio/" />
|
||||
<meta name="twitter:site" content="@gpxstudio" />
|
||||
<meta name="twitter:creator" content="@gpxstudio" />
|
||||
|
||||
<link
|
||||
rel="alternate"
|
||||
hreflang="x-default"
|
||||
href="https://gpx.studio{base}/{location === 'home' ? '' : location}"
|
||||
/>
|
||||
{#each Object.keys(languages) as lang}
|
||||
<link
|
||||
rel="alternate"
|
||||
hreflang={lang}
|
||||
href="https://gpx.studio{base}/{lang === 'en' ? '' : lang + '/'}{location === 'home'
|
||||
? ''
|
||||
: location}"
|
||||
/>
|
||||
{/each}
|
||||
</svelte:head>
|
@@ -1,11 +1,22 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { CircleHelp } from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let link: string | undefined = undefined;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="{$$props.class ||
|
||||
''} text-sm bg-muted font-light rounded border flex flex-row items-center p-2"
|
||||
>
|
||||
<div class="text-sm bg-muted rounded border flex flex-row items-center p-2 {$$props.class || ''}">
|
||||
<CircleHelp size="16" class="w-4 mr-2 shrink-0 grow-0" />
|
||||
<slot />
|
||||
<div>
|
||||
<slot />
|
||||
{#if link}
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
class="text-sm text-blue-500 dark:text-blue-300 hover:underline"
|
||||
>
|
||||
{$_('menu.more')}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,4 +1,5 @@
|
||||
<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';
|
||||
@@ -25,18 +26,26 @@
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each Object.entries(languages) as [lang, label]}
|
||||
<a href={getURLForLanguage(lang)}>
|
||||
<Select.Item value={lang}>{label}</Select.Item>
|
||||
</a>
|
||||
{#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">
|
||||
{#each Object.entries(languages) as [lang, label]}
|
||||
<a href={getURLForLanguage(lang)}>
|
||||
{label}
|
||||
</a>
|
||||
{/each}
|
||||
{#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,318 +1,355 @@
|
||||
<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 { locale, _ } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||
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 fitBoundsOptions: mapboxgl.FitBoundsOptions = {
|
||||
maxZoom: 15,
|
||||
linear: true,
|
||||
easing: () => 1
|
||||
};
|
||||
let webgl2Supported = true;
|
||||
let fitBoundsOptions: mapboxgl.FitBoundsOptions = {
|
||||
maxZoom: 15,
|
||||
linear: true,
|
||||
easing: () => 1
|
||||
};
|
||||
|
||||
const { distanceUnits, elevationProfile, verticalFileView, bottomPanelSize, rightPanelSize } =
|
||||
settings;
|
||||
let scaleControl = new mapboxgl.ScaleControl({
|
||||
unit: $distanceUnits
|
||||
});
|
||||
const { distanceUnits, elevationProfile, verticalFileView, bottomPanelSize, rightPanelSize } =
|
||||
settings;
|
||||
let scaleControl = new mapboxgl.ScaleControl({
|
||||
unit: $distanceUnits
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
let gl = document.createElement('canvas').getContext('webgl2');
|
||||
if (!gl) {
|
||||
webgl2Supported = false;
|
||||
return;
|
||||
}
|
||||
onMount(() => {
|
||||
let gl = document.createElement('canvas').getContext('webgl2');
|
||||
if (!gl) {
|
||||
webgl2Supported = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let newMap = new mapboxgl.Map({
|
||||
container: 'map',
|
||||
style: { version: 8, sources: {}, layers: [] },
|
||||
zoom: 0,
|
||||
hash: hash,
|
||||
language: get(locale),
|
||||
attributionControl: false,
|
||||
logoPosition: 'bottom-right',
|
||||
boxZoom: false
|
||||
});
|
||||
newMap.on('load', () => {
|
||||
$map = newMap; // only set the store after the map has loaded
|
||||
scaleControl.setUnit($distanceUnits);
|
||||
});
|
||||
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';
|
||||
}
|
||||
|
||||
newMap.addControl(
|
||||
new mapboxgl.AttributionControl({
|
||||
compact: true
|
||||
})
|
||||
);
|
||||
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: []
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
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
|
||||
scaleControl.setUnit($distanceUnits);
|
||||
});
|
||||
|
||||
newMap.addControl(new mapboxgl.NavigationControl());
|
||||
newMap.addControl(
|
||||
new mapboxgl.AttributionControl({
|
||||
compact: true
|
||||
})
|
||||
);
|
||||
|
||||
if (geocoder) {
|
||||
newMap.addControl(
|
||||
new MapboxGeocoder({
|
||||
accessToken: mapboxgl.accessToken,
|
||||
mapboxgl: mapboxgl,
|
||||
collapsed: true,
|
||||
flyTo: fitBoundsOptions,
|
||||
language: get(locale)
|
||||
})
|
||||
);
|
||||
}
|
||||
newMap.addControl(
|
||||
new mapboxgl.NavigationControl({
|
||||
visualizePitch: true
|
||||
})
|
||||
);
|
||||
|
||||
if (geolocate) {
|
||||
newMap.addControl(
|
||||
new mapboxgl.GeolocateControl({
|
||||
positionOptions: {
|
||||
enableHighAccuracy: true
|
||||
},
|
||||
fitBoundsOptions,
|
||||
trackUserLocation: true,
|
||||
showUserHeading: true
|
||||
})
|
||||
);
|
||||
}
|
||||
if (geocoder) {
|
||||
newMap.addControl(
|
||||
new MapboxGeocoder({
|
||||
accessToken: mapboxgl.accessToken,
|
||||
mapboxgl: mapboxgl,
|
||||
collapsed: true,
|
||||
flyTo: fitBoundsOptions,
|
||||
language
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
newMap.addControl(scaleControl);
|
||||
if (geolocate) {
|
||||
newMap.addControl(
|
||||
new mapboxgl.GeolocateControl({
|
||||
positionOptions: {
|
||||
enableHighAccuracy: true
|
||||
},
|
||||
fitBoundsOptions,
|
||||
trackUserLocation: true,
|
||||
showUserHeading: true
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
newMap.on('style.load', () => {
|
||||
newMap.addSource('mapbox-dem', {
|
||||
type: 'raster-dem',
|
||||
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
|
||||
tileSize: 512,
|
||||
maxzoom: 14
|
||||
});
|
||||
newMap.setTerrain({
|
||||
source: 'mapbox-dem',
|
||||
exaggeration: newMap.getPitch() > 0 ? 1 : 0
|
||||
});
|
||||
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({
|
||||
source: 'mapbox-dem',
|
||||
exaggeration: 0
|
||||
});
|
||||
}
|
||||
});
|
||||
// add dummy layer to place the overlay layers below
|
||||
newMap.addLayer({
|
||||
id: 'overlays',
|
||||
type: 'background',
|
||||
paint: {
|
||||
'background-color': 'rgba(0, 0, 0, 0)'
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
newMap.addControl(scaleControl);
|
||||
|
||||
onDestroy(() => {
|
||||
if ($map) {
|
||||
$map.remove();
|
||||
$map = null;
|
||||
}
|
||||
});
|
||||
newMap.on('style.load', () => {
|
||||
newMap.addSource('mapbox-dem', {
|
||||
type: 'raster-dem',
|
||||
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
|
||||
tileSize: 512,
|
||||
maxzoom: 14
|
||||
});
|
||||
newMap.setTerrain({
|
||||
source: 'mapbox-dem',
|
||||
exaggeration: newMap.getPitch() > 0 ? 1 : 0
|
||||
});
|
||||
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({
|
||||
source: 'mapbox-dem',
|
||||
exaggeration: 0
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$: if (
|
||||
$map &&
|
||||
(!$verticalFileView || !$elevationProfile || $bottomPanelSize || $rightPanelSize)
|
||||
) {
|
||||
$map.resize();
|
||||
}
|
||||
onDestroy(() => {
|
||||
if ($map) {
|
||||
$map.remove();
|
||||
$map = null;
|
||||
}
|
||||
});
|
||||
|
||||
$: if (
|
||||
$map &&
|
||||
(!$verticalFileView || !$elevationProfile || $bottomPanelSize || $rightPanelSize)
|
||||
) {
|
||||
$map.resize();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div {...$$restProps}>
|
||||
<div id="map" class="h-full {webgl2Supported ? '' : 'hidden'}"></div>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center gap-3 h-full {webgl2Supported ? 'hidden' : ''}"
|
||||
>
|
||||
<p>{$_('webgl2_required')}</p>
|
||||
<Button href="https://get.webgl.org/webgl2/" target="_blank">
|
||||
{$_('enable_webgl2')}
|
||||
</Button>
|
||||
</div>
|
||||
<div id="map" class="h-full {webgl2Supported ? '' : 'hidden'}"></div>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center gap-3 h-full {webgl2Supported
|
||||
? 'hidden'
|
||||
: ''}"
|
||||
>
|
||||
<p>{$_('webgl2_required')}</p>
|
||||
<Button href="https://get.webgl.org/webgl2/" target="_blank">
|
||||
{$_('enable_webgl2')}
|
||||
</Button>
|
||||
</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-20;
|
||||
}
|
||||
div :global(.mapboxgl-popup) {
|
||||
@apply w-fit;
|
||||
@apply z-20;
|
||||
}
|
||||
|
||||
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>
|
||||
|
@@ -22,7 +22,6 @@
|
||||
Sun,
|
||||
Moon,
|
||||
Layers3,
|
||||
MountainSnow,
|
||||
GalleryVertical,
|
||||
Languages,
|
||||
Settings,
|
||||
@@ -41,7 +40,9 @@
|
||||
FolderOpen,
|
||||
FileStack,
|
||||
FileX,
|
||||
BookOpenText
|
||||
BookOpenText,
|
||||
ChartArea,
|
||||
Maximize
|
||||
} from 'lucide-svelte';
|
||||
|
||||
import {
|
||||
@@ -54,7 +55,8 @@
|
||||
editMetadata,
|
||||
editStyle,
|
||||
exportState,
|
||||
ExportState
|
||||
ExportState,
|
||||
centerMapOnSelection
|
||||
} from '$lib/stores';
|
||||
import {
|
||||
copied,
|
||||
@@ -247,6 +249,17 @@
|
||||
{$_('menu.select_all')}
|
||||
<Shortcut key="A" ctrl={true} />
|
||||
</Menubar.Item>
|
||||
<Menubar.Item
|
||||
on:click={() => {
|
||||
if ($selection.size > 0) {
|
||||
centerMapOnSelection();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Maximize size="16" class="mr-1" />
|
||||
{$_('menu.center')}
|
||||
<Shortcut key="⏎" ctrl={true} />
|
||||
</Menubar.Item>
|
||||
{#if $verticalFileView}
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item on:click={copySelection} disabled={$selection.size === 0}>
|
||||
@@ -286,7 +299,7 @@
|
||||
</Menubar.Trigger>
|
||||
<Menubar.Content class="border-none">
|
||||
<Menubar.CheckboxItem bind:checked={$elevationProfile}>
|
||||
<MountainSnow size="16" class="mr-1" />
|
||||
<ChartArea size="16" class="mr-1" />
|
||||
{$_('menu.elevation_profile')}
|
||||
<Shortcut key="P" ctrl={true} />
|
||||
</Menubar.CheckboxItem>
|
||||
@@ -333,6 +346,7 @@
|
||||
<Menubar.RadioGroup bind:value={$distanceUnits}>
|
||||
<Menubar.RadioItem value="metric">{$_('menu.metric')}</Menubar.RadioItem>
|
||||
<Menubar.RadioItem value="imperial">{$_('menu.imperial')}</Menubar.RadioItem>
|
||||
<Menubar.RadioItem value="nautical">{$_('menu.nautical')}</Menubar.RadioItem>
|
||||
</Menubar.RadioGroup>
|
||||
</Menubar.SubContent>
|
||||
</Menubar.Sub>
|
||||
@@ -367,7 +381,7 @@
|
||||
<Menubar.SubContent>
|
||||
<Menubar.RadioGroup bind:value={$locale}>
|
||||
{#each Object.entries(languages) as [lang, label]}
|
||||
<a href={getURLForLanguage(lang)}>
|
||||
<a href={getURLForLanguage(lang, '/app')}>
|
||||
<Menubar.RadioItem value={lang}>{label}</Menubar.RadioItem>
|
||||
</a>
|
||||
{/each}
|
||||
@@ -499,12 +513,14 @@
|
||||
dbUtils.undo();
|
||||
}
|
||||
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) {
|
||||
if (e.shiftKey) {
|
||||
dbUtils.deleteAllFiles();
|
||||
} else {
|
||||
dbUtils.deleteSelection();
|
||||
if (!targetInput) {
|
||||
if (e.shiftKey) {
|
||||
dbUtils.deleteAllFiles();
|
||||
} else {
|
||||
dbUtils.deleteSelection();
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
|
||||
if (!targetInput) {
|
||||
selectAll();
|
||||
@@ -533,6 +549,10 @@
|
||||
dbUtils.setHiddenToSelection(true);
|
||||
}
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
if ($selection.size > 0) {
|
||||
centerMapOnSelection();
|
||||
}
|
||||
} else if (e.key === 'F1') {
|
||||
switchBasemaps();
|
||||
e.preventDefault();
|
||||
|
@@ -8,14 +8,19 @@
|
||||
export let click: boolean = false;
|
||||
|
||||
let isMac = false;
|
||||
let isSafari = false;
|
||||
|
||||
onMount(() => {
|
||||
isMac = navigator.userAgent.toUpperCase().indexOf('MAC') >= 0;
|
||||
isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
});
|
||||
</script>
|
||||
|
||||
<span class="ml-auto pl-2 text-xs tracking-widest text-muted-foreground"
|
||||
>{shift ? '⇧' : ''}{ctrl ? (isMac ? '⌘' : $_('menu.ctrl') + '+') : ''}{key}{click
|
||||
? $_('menu.click')
|
||||
: ''}</span
|
||||
<div
|
||||
class="ml-auto pl-2 text-xs tracking-widest text-muted-foreground flex flex-row gap-0 items-baseline"
|
||||
>
|
||||
<span>{shift ? '⇧' : ''}</span>
|
||||
<span>{ctrl ? (isMac && !isSafari ? '⌘' : $_('menu.ctrl') + '+') : ''}</span>
|
||||
<span class={key === '+' ? 'font-medium text-sm/4' : ''}>{key}</span>
|
||||
<span>{click ? $_('menu.click') : ''}</span>
|
||||
</div>
|
||||
|
@@ -1,29 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { settings } from '$lib/db';
|
||||
|
||||
const { showWelcomeMessage } = settings;
|
||||
</script>
|
||||
|
||||
<AlertDialog.Root
|
||||
open={$showWelcomeMessage === true}
|
||||
closeOnEscape={false}
|
||||
closeOnOutsideClick={false}
|
||||
onOpenChange={() => ($showWelcomeMessage = false)}
|
||||
>
|
||||
<AlertDialog.Trigger class="hidden"></AlertDialog.Trigger>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>
|
||||
Welcome to the new version of <b>gpx.studio</b>!
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description class="space-y-1">
|
||||
<p>The website is still under development and may contain bugs.</p>
|
||||
<p>Please report any issues you find by email or on GitHub.</p>
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Action>Let's go!</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
@@ -2,9 +2,12 @@
|
||||
import { settings } from '$lib/db';
|
||||
import {
|
||||
celsiusToFahrenheit,
|
||||
distancePerHourToSecondsPerDistance,
|
||||
kilometersToMiles,
|
||||
metersToFeet,
|
||||
getConvertedDistance,
|
||||
getConvertedElevation,
|
||||
getConvertedVelocity,
|
||||
getDistanceUnits,
|
||||
getElevationUnits,
|
||||
getVelocityUnits,
|
||||
secondsToHHMMSS
|
||||
} from '$lib/units';
|
||||
|
||||
@@ -20,31 +23,18 @@
|
||||
|
||||
<span class={$$props.class}>
|
||||
{#if type === 'distance'}
|
||||
{#if $distanceUnits === 'metric'}
|
||||
{value.toFixed(decimals ?? 2)} {showUnits ? $_('units.kilometers') : ''}
|
||||
{:else}
|
||||
{kilometersToMiles(value).toFixed(decimals ?? 2)} {showUnits ? $_('units.miles') : ''}
|
||||
{/if}
|
||||
{getConvertedDistance(value, $distanceUnits).toFixed(decimals ?? 2)}
|
||||
{showUnits ? getDistanceUnits($distanceUnits) : ''}
|
||||
{:else if type === 'elevation'}
|
||||
{#if $distanceUnits === 'metric'}
|
||||
{value.toFixed(decimals ?? 0)} {showUnits ? $_('units.meters') : ''}
|
||||
{:else}
|
||||
{metersToFeet(value).toFixed(decimals ?? 0)} {showUnits ? $_('units.feet') : ''}
|
||||
{/if}
|
||||
{getConvertedElevation(value, $distanceUnits).toFixed(decimals ?? 0)}
|
||||
{showUnits ? getElevationUnits($distanceUnits) : ''}
|
||||
{:else if type === 'speed'}
|
||||
{#if $distanceUnits === 'metric'}
|
||||
{#if $velocityUnits === 'speed'}
|
||||
{value.toFixed(decimals ?? 2)} {showUnits ? $_('units.kilometers_per_hour') : ''}
|
||||
{:else}
|
||||
{secondsToHHMMSS(distancePerHourToSecondsPerDistance(value))}
|
||||
{showUnits ? $_('units.minutes_per_kilometer') : ''}
|
||||
{/if}
|
||||
{:else if $velocityUnits === 'speed'}
|
||||
{kilometersToMiles(value).toFixed(decimals ?? 2)}
|
||||
{showUnits ? $_('units.miles_per_hour') : ''}
|
||||
{#if $velocityUnits === 'speed'}
|
||||
{getConvertedVelocity(value, $velocityUnits, $distanceUnits).toFixed(decimals ?? 2)}
|
||||
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
|
||||
{:else}
|
||||
{secondsToHHMMSS(distancePerHourToSecondsPerDistance(kilometersToMiles(value)))}
|
||||
{showUnits ? $_('units.minutes_per_mile') : ''}
|
||||
{secondsToHHMMSS(getConvertedVelocity(value, $velocityUnits, $distanceUnits))}
|
||||
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
|
||||
{/if}
|
||||
{:else if type === 'temperature'}
|
||||
{#if $temperatureUnits === 'celsius'}
|
||||
|
@@ -1,106 +1,112 @@
|
||||
<script lang="ts">
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
|
||||
export let path: string;
|
||||
export let titleOnly: boolean = false;
|
||||
export let path: string;
|
||||
export let titleOnly: boolean = false;
|
||||
|
||||
let module = undefined;
|
||||
let metadata: Record<string, any> = {};
|
||||
let module = undefined;
|
||||
let metadata: Record<string, any> = {};
|
||||
|
||||
const modules = import.meta.glob('/src/lib/docs/**/*.mdx');
|
||||
const modules = import.meta.glob('/src/lib/docs/**/*.mdx');
|
||||
|
||||
function loadModule(path: string) {
|
||||
modules[path]().then((mod) => {
|
||||
module = mod.default;
|
||||
metadata = mod.metadata;
|
||||
});
|
||||
}
|
||||
function loadModule(path: string) {
|
||||
modules[path]?.().then((mod) => {
|
||||
module = mod.default;
|
||||
metadata = mod.metadata;
|
||||
});
|
||||
}
|
||||
|
||||
$: if ($locale) {
|
||||
loadModule(`/src/lib/docs/${$locale}/${path}`);
|
||||
}
|
||||
$: if ($locale) {
|
||||
if (modules.hasOwnProperty(`/src/lib/docs/${$locale}/${path}`)) {
|
||||
loadModule(`/src/lib/docs/${$locale}/${path}`);
|
||||
} else if (browser) {
|
||||
goto(`${base}/404`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if module !== undefined}
|
||||
{#if titleOnly}
|
||||
{metadata.title}
|
||||
{:else}
|
||||
<div class="markdown space-y-3">
|
||||
<svelte:component this={module} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if titleOnly}
|
||||
{metadata.title}
|
||||
{:else}
|
||||
<div class="markdown flex flex-col gap-3">
|
||||
<svelte:component this={module} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<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-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 mb-3;
|
||||
@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) {
|
||||
@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-blue-500;
|
||||
@apply hover:underline;
|
||||
}
|
||||
:global(.markdown > a) {
|
||||
@apply text-blue-500;
|
||||
@apply hover:underline;
|
||||
}
|
||||
|
||||
:global(.markdown p > a) {
|
||||
@apply text-blue-500;
|
||||
@apply hover:underline;
|
||||
}
|
||||
:global(.markdown p > a) {
|
||||
@apply text-blue-500;
|
||||
@apply hover:underline;
|
||||
}
|
||||
|
||||
:global(.markdown li > a) {
|
||||
@apply text-blue-500;
|
||||
@apply hover:underline;
|
||||
}
|
||||
:global(.markdown li > a) {
|
||||
@apply text-blue-500;
|
||||
@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>
|
||||
|
@@ -9,6 +9,7 @@ export const guides: Record<string, string[]> = {
|
||||
'map-controls': [],
|
||||
'gpx': [],
|
||||
'integration': [],
|
||||
'faq': [],
|
||||
};
|
||||
|
||||
export const guideIcons: Record<string, string | ComponentType<Icon>> = {
|
||||
@@ -31,6 +32,7 @@ export const guideIcons: Record<string, string | ComponentType<Icon>> = {
|
||||
"map-controls": "🗺",
|
||||
"gpx": "💾",
|
||||
"integration": "{ 👩💻 }",
|
||||
"faq": "🔮",
|
||||
};
|
||||
|
||||
export function getPreviousGuide(currentGuide: string): string | undefined {
|
||||
@@ -51,11 +53,15 @@ export function getPreviousGuide(currentGuide: string): string | undefined {
|
||||
return `${previousGuide}/${guides[previousGuide][guides[previousGuide].length - 1]}`;
|
||||
}
|
||||
} else {
|
||||
let subguideIndex = guides[subguides[0]].indexOf(subguides[1]);
|
||||
if (subguideIndex > 0) {
|
||||
return `${subguides[0]}/${guides[subguides[0]][subguideIndex - 1]}`;
|
||||
if (guides.hasOwnProperty(subguides[0])) {
|
||||
let subguideIndex = guides[subguides[0]].indexOf(subguides[1]);
|
||||
if (subguideIndex > 0) {
|
||||
return `${subguides[0]}/${guides[subguides[0]][subguideIndex - 1]}`;
|
||||
} else {
|
||||
return subguides[0];
|
||||
}
|
||||
} else {
|
||||
return subguides[0];
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,21 +70,29 @@ export function getNextGuide(currentGuide: string): string | undefined {
|
||||
let subguides = currentGuide.split('/');
|
||||
|
||||
if (subguides.length === 1) {
|
||||
if (guides[currentGuide].length === 0) {
|
||||
let keys = Object.keys(guides);
|
||||
let index = keys.indexOf(currentGuide);
|
||||
return keys[index + 1];
|
||||
if (guides.hasOwnProperty(currentGuide)) {
|
||||
if (guides[currentGuide].length === 0) {
|
||||
let keys = Object.keys(guides);
|
||||
let index = keys.indexOf(currentGuide);
|
||||
return keys[index + 1];
|
||||
} else {
|
||||
return `${currentGuide}/${guides[currentGuide][0]}`;
|
||||
}
|
||||
} else {
|
||||
return `${currentGuide}/${guides[currentGuide][0]}`;
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
let subguideIndex = guides[subguides[0]].indexOf(subguides[1]);
|
||||
if (subguideIndex < guides[subguides[0]].length - 1) {
|
||||
return `${subguides[0]}/${guides[subguides[0]][subguideIndex + 1]}`;
|
||||
if (guides.hasOwnProperty(subguides[0])) {
|
||||
let subguideIndex = guides[subguides[0]].indexOf(subguides[1]);
|
||||
if (subguideIndex < guides[subguides[0]].length - 1) {
|
||||
return `${subguides[0]}/${guides[subguides[0]][subguideIndex + 1]}`;
|
||||
} else {
|
||||
let keys = Object.keys(guides);
|
||||
let index = keys.indexOf(subguides[0]);
|
||||
return keys[index + 1];
|
||||
}
|
||||
} else {
|
||||
let keys = Object.keys(guides);
|
||||
let index = keys.indexOf(subguides[0]);
|
||||
return keys[index + 1];
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
@@ -6,7 +6,6 @@
|
||||
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,
|
||||
@@ -22,6 +21,7 @@
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import { ListFileItem } from '$lib/components/file-list/FileList';
|
||||
import { allowedEmbeddingBasemaps, type EmbeddingOptions } from './Embedding';
|
||||
import { mode, setMode } from 'mode-watcher';
|
||||
|
||||
$embedding = true;
|
||||
|
||||
@@ -44,7 +44,8 @@
|
||||
directionMarkers: false,
|
||||
distanceUnits: 'metric',
|
||||
velocityUnits: 'speed',
|
||||
temperatureUnits: 'celsius'
|
||||
temperatureUnits: 'celsius',
|
||||
theme: 'system'
|
||||
};
|
||||
|
||||
function applyOptions() {
|
||||
@@ -160,6 +161,10 @@
|
||||
if (options.temperatureUnits !== $temperatureUnits) {
|
||||
$temperatureUnits = options.temperatureUnits;
|
||||
}
|
||||
|
||||
if (options.theme !== $mode) {
|
||||
setMode(options.theme);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -168,6 +173,7 @@
|
||||
prevSettings.distanceUnits = $distanceUnits;
|
||||
prevSettings.velocityUnits = $velocityUnits;
|
||||
prevSettings.temperatureUnits = $temperatureUnits;
|
||||
prevSettings.theme = $mode ?? 'system';
|
||||
});
|
||||
|
||||
$: if (options) {
|
||||
@@ -199,6 +205,10 @@
|
||||
$temperatureUnits = prevSettings.temperatureUnits;
|
||||
}
|
||||
|
||||
if ($mode !== prevSettings.theme) {
|
||||
setMode(prevSettings.theme);
|
||||
}
|
||||
|
||||
$selection.clear();
|
||||
$fileObservers.clear();
|
||||
$fileOrder = $fileOrder.filter((id) => !id.includes('embed'));
|
||||
|
@@ -1,66 +1,141 @@
|
||||
import { basemaps } from "$lib/assets/layers";
|
||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||
import { basemaps } from '$lib/assets/layers';
|
||||
|
||||
export type EmbeddingOptions = {
|
||||
token: string;
|
||||
files: string[];
|
||||
basemap: string;
|
||||
elevation: {
|
||||
show: boolean;
|
||||
height: number,
|
||||
controls: boolean,
|
||||
fill: 'slope' | 'surface' | undefined,
|
||||
speed: boolean,
|
||||
hr: boolean,
|
||||
cad: boolean,
|
||||
temp: boolean,
|
||||
power: boolean,
|
||||
},
|
||||
distanceMarkers: boolean,
|
||||
directionMarkers: boolean,
|
||||
distanceUnits: 'metric' | 'imperial',
|
||||
velocityUnits: 'speed' | 'pace',
|
||||
temperatureUnits: 'celsius' | 'fahrenheit',
|
||||
token: string;
|
||||
files: string[];
|
||||
basemap: string;
|
||||
elevation: {
|
||||
show: boolean;
|
||||
height: number;
|
||||
controls: boolean;
|
||||
fill: 'slope' | 'surface' | undefined;
|
||||
speed: boolean;
|
||||
hr: boolean;
|
||||
cad: boolean;
|
||||
temp: boolean;
|
||||
power: boolean;
|
||||
};
|
||||
distanceMarkers: boolean;
|
||||
directionMarkers: boolean;
|
||||
distanceUnits: 'metric' | 'imperial' | 'nautical';
|
||||
velocityUnits: 'speed' | 'pace';
|
||||
temperatureUnits: 'celsius' | 'fahrenheit';
|
||||
theme: 'system' | 'light' | 'dark';
|
||||
};
|
||||
|
||||
export const defaultEmbeddingOptions = {
|
||||
token: '',
|
||||
files: [],
|
||||
basemap: 'mapboxOutdoors',
|
||||
elevation: {
|
||||
show: true,
|
||||
height: 170,
|
||||
controls: true,
|
||||
fill: undefined,
|
||||
speed: false,
|
||||
hr: false,
|
||||
cad: false,
|
||||
temp: false,
|
||||
power: false,
|
||||
},
|
||||
distanceMarkers: false,
|
||||
directionMarkers: false,
|
||||
distanceUnits: 'metric',
|
||||
velocityUnits: 'speed',
|
||||
temperatureUnits: 'celsius',
|
||||
token: '',
|
||||
files: [],
|
||||
basemap: 'mapboxOutdoors',
|
||||
elevation: {
|
||||
show: true,
|
||||
height: 170,
|
||||
controls: true,
|
||||
fill: undefined,
|
||||
speed: false,
|
||||
hr: false,
|
||||
cad: false,
|
||||
temp: false,
|
||||
power: false
|
||||
},
|
||||
distanceMarkers: false,
|
||||
directionMarkers: false,
|
||||
distanceUnits: 'metric',
|
||||
velocityUnits: 'speed',
|
||||
temperatureUnits: 'celsius',
|
||||
theme: 'system'
|
||||
};
|
||||
|
||||
export function getDefaultEmbeddingOptions(): EmbeddingOptions {
|
||||
return JSON.parse(JSON.stringify(defaultEmbeddingOptions));
|
||||
return JSON.parse(JSON.stringify(defaultEmbeddingOptions));
|
||||
}
|
||||
|
||||
export function getCleanedEmbeddingOptions(options: any, defaultOptions: any = defaultEmbeddingOptions): any {
|
||||
const cleanedOptions = JSON.parse(JSON.stringify(options));
|
||||
for (const key in cleanedOptions) {
|
||||
if (typeof cleanedOptions[key] === 'object' && cleanedOptions[key] !== null && !Array.isArray(cleanedOptions[key])) {
|
||||
cleanedOptions[key] = getCleanedEmbeddingOptions(cleanedOptions[key], defaultOptions[key]);
|
||||
if (Object.keys(cleanedOptions[key]).length === 0) {
|
||||
delete cleanedOptions[key];
|
||||
}
|
||||
} else if (JSON.stringify(cleanedOptions[key]) === JSON.stringify(defaultOptions[key])) {
|
||||
delete cleanedOptions[key];
|
||||
}
|
||||
}
|
||||
return cleanedOptions;
|
||||
export function getMergedEmbeddingOptions(
|
||||
options: any,
|
||||
defaultOptions: any = defaultEmbeddingOptions
|
||||
): 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])) {
|
||||
mergedOptions[key] = getMergedEmbeddingOptions(options[key], defaultOptions[key]);
|
||||
} else {
|
||||
mergedOptions[key] = options[key];
|
||||
}
|
||||
}
|
||||
return mergedOptions;
|
||||
}
|
||||
|
||||
export const allowedEmbeddingBasemaps = Object.keys(basemaps).filter(basemap => !['ordnanceSurvey'].includes(basemap));
|
||||
export function getCleanedEmbeddingOptions(
|
||||
options: any,
|
||||
defaultOptions: any = defaultEmbeddingOptions
|
||||
): any {
|
||||
const cleanedOptions = JSON.parse(JSON.stringify(options));
|
||||
for (const key in cleanedOptions) {
|
||||
if (
|
||||
typeof cleanedOptions[key] === 'object' &&
|
||||
cleanedOptions[key] !== null &&
|
||||
!Array.isArray(cleanedOptions[key])
|
||||
) {
|
||||
cleanedOptions[key] = getCleanedEmbeddingOptions(cleanedOptions[key], defaultOptions[key]);
|
||||
if (Object.keys(cleanedOptions[key]).length === 0) {
|
||||
delete cleanedOptions[key];
|
||||
}
|
||||
} else if (JSON.stringify(cleanedOptions[key]) === JSON.stringify(defaultOptions[key])) {
|
||||
delete cleanedOptions[key];
|
||||
}
|
||||
}
|
||||
return cleanedOptions;
|
||||
}
|
||||
|
||||
export const allowedEmbeddingBasemaps = Object.keys(basemaps).filter(
|
||||
(basemap) => !['ordnanceSurvey'].includes(basemap)
|
||||
);
|
||||
|
||||
export function getURLForGoogleDriveFile(fileId: string): string {
|
||||
return `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media&key=AIzaSyA2ZadQob_hXiT2VaYIkAyafPvz_4ZMssk`;
|
||||
}
|
||||
|
||||
export function convertOldEmbeddingOptions(options: URLSearchParams): any {
|
||||
let newOptions: any = {
|
||||
token: PUBLIC_MAPBOX_TOKEN,
|
||||
files: []
|
||||
};
|
||||
if (options.has('state')) {
|
||||
let state = JSON.parse(options.get('state')!);
|
||||
if (state.ids) {
|
||||
newOptions.files.push(...state.ids.map(getURLForGoogleDriveFile));
|
||||
}
|
||||
if (state.urls) {
|
||||
newOptions.files.push(...state.urls);
|
||||
}
|
||||
}
|
||||
if (options.has('source')) {
|
||||
let basemap = options.get('source')!;
|
||||
if (basemap === 'satellite') {
|
||||
newOptions.basemap = 'mapboxSatellite';
|
||||
} else if (basemap === 'otm') {
|
||||
newOptions.basemap = 'openTopoMap';
|
||||
} else if (basemap === 'ohm') {
|
||||
newOptions.basemap = 'openHikingMap';
|
||||
}
|
||||
}
|
||||
if (options.has('imperial')) {
|
||||
newOptions.distanceUnits = 'imperial';
|
||||
}
|
||||
if (options.has('running')) {
|
||||
newOptions.velocityUnits = 'pace';
|
||||
}
|
||||
if (options.has('distance')) {
|
||||
newOptions.distanceMarkers = true;
|
||||
}
|
||||
if (options.has('direction')) {
|
||||
newOptions.directionMarkers = true;
|
||||
}
|
||||
if (options.has('slope')) {
|
||||
newOptions.elevation = {
|
||||
fill: 'slope'
|
||||
};
|
||||
}
|
||||
return newOptions;
|
||||
}
|
||||
|
@@ -1,296 +1,330 @@
|
||||
<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,
|
||||
getURLForGoogleDriveFile
|
||||
} 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];
|
||||
$: if (files) {
|
||||
let urls = files.split(',');
|
||||
urls = urls.filter((url) => url.length > 0);
|
||||
if (JSON.stringify(urls) !== JSON.stringify(options.files)) {
|
||||
options.files = urls;
|
||||
}
|
||||
}
|
||||
let files = options.files[0];
|
||||
let driveIds = '';
|
||||
$: if (files || driveIds) {
|
||||
let urls = files.split(',');
|
||||
urls = urls.filter((url) => url.length > 0);
|
||||
let ids = driveIds.split(',');
|
||||
ids = ids.filter((id) => id.length > 0);
|
||||
urls.push(...ids.map(getURLForGoogleDriveFile));
|
||||
if (JSON.stringify(urls) !== JSON.stringify(options.files)) {
|
||||
options.files = urls;
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<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="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') {
|
||||
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="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" />
|
||||
{$_('chart.show_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" />
|
||||
{$_('chart.show_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" />
|
||||
{$_('chart.show_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" />
|
||||
{$_('chart.show_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" />
|
||||
{$_('chart.show_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>
|
||||
</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>
|
||||
<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') {
|
||||
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="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" />
|
||||
{$_('chart.show_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" />
|
||||
{$_('chart.show_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" />
|
||||
{$_('chart.show_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" />
|
||||
{$_('chart.show_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" />
|
||||
{$_('chart.show_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>
|
||||
|
@@ -15,6 +15,7 @@
|
||||
EyeOff,
|
||||
ClipboardCopy,
|
||||
ClipboardPaste,
|
||||
Maximize,
|
||||
Scissors,
|
||||
FileStack,
|
||||
FileX
|
||||
@@ -39,7 +40,15 @@
|
||||
} from './Selection';
|
||||
import { getContext } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { allHidden, editMetadata, editStyle, embedding, gpxLayers, map } from '$lib/stores';
|
||||
import {
|
||||
allHidden,
|
||||
editMetadata,
|
||||
editStyle,
|
||||
embedding,
|
||||
centerMapOnSelection,
|
||||
gpxLayers,
|
||||
map
|
||||
} from '$lib/stores';
|
||||
import {
|
||||
GPXTreeElement,
|
||||
Track,
|
||||
@@ -275,38 +284,41 @@
|
||||
{$_('menu.select_all')}
|
||||
<Shortcut key="A" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
{/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={dbUtils.duplicateSelection}>
|
||||
<Copy size="16" class="mr-1" />
|
||||
{$_('menu.duplicate')}
|
||||
<Shortcut key="D" ctrl={true} /></ContextMenu.Item
|
||||
<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}
|
||||
>
|
||||
{#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 />
|
||||
<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" />
|
||||
|
@@ -1,10 +1,9 @@
|
||||
|
||||
import { font } from "$lib/assets/layers";
|
||||
import { settings } from "$lib/db";
|
||||
import { gpxStatistics } from "$lib/stores";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
const { distanceMarkers, distanceUnits, currentBasemap } = settings;
|
||||
const { distanceMarkers, distanceUnits } = settings;
|
||||
|
||||
export class DistanceMarkers {
|
||||
map: mapboxgl.Map;
|
||||
@@ -17,7 +16,7 @@ export class DistanceMarkers {
|
||||
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(distanceMarkers.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(distanceUnits.subscribe(this.updateBinded));
|
||||
this.map.on('style.load', this.updateBinded);
|
||||
this.map.on('style.import.load', this.updateBinded);
|
||||
}
|
||||
|
||||
update() {
|
||||
@@ -40,7 +39,7 @@ export class DistanceMarkers {
|
||||
layout: {
|
||||
'text-field': ['get', 'distance'],
|
||||
'text-size': 14,
|
||||
'text-font': [font[get(currentBasemap)] ?? 'Open Sans Bold'],
|
||||
'text-font': ['Open Sans Bold'],
|
||||
'text-padding': 20,
|
||||
},
|
||||
paint: {
|
||||
|
@@ -6,10 +6,10 @@ import { currentPopupWaypoint, deleteWaypoint, waypointPopup } from "./WaypointP
|
||||
import { addSelectItem, selectItem, selection } from "$lib/components/file-list/Selection";
|
||||
import { ListTrackSegmentItem, ListWaypointItem, ListWaypointsItem, ListTrackItem, ListFileItem, ListRootItem } from "$lib/components/file-list/FileList";
|
||||
import type { Waypoint } from "gpx";
|
||||
import { getElevation, resetCursor, setCursor, setGrabbingCursor, setPointerCursor } from "$lib/utils";
|
||||
import { font } from "$lib/assets/layers";
|
||||
import { getElevation, resetCursor, setGrabbingCursor, setPointerCursor, setScissorsCursor } from "$lib/utils";
|
||||
import { selectedWaypoint } from "$lib/components/toolbar/tools/Waypoint.svelte";
|
||||
import { MapPin } from "lucide-static";
|
||||
import { MapPin, Square } from "lucide-static";
|
||||
import { getSymbolKey, symbols } from "$lib/assets/symbols";
|
||||
|
||||
const colors = [
|
||||
'#ff0000',
|
||||
@@ -43,7 +43,29 @@ function decrementColor(color: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const { directionMarkers, verticalFileView, currentBasemap, defaultOpacity, defaultWeight } = settings;
|
||||
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"')
|
||||
.replace('height="24"', 'height="10"')
|
||||
.replace('stroke="currentColor"', 'stroke="white"')
|
||||
.replace('stroke-width="2"', 'stroke-width="2.5" x="7" y="5"') ?? ''}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
const { directionMarkers, verticalFileView, defaultOpacity, defaultWeight } = settings;
|
||||
|
||||
export class GPXLayer {
|
||||
map: mapboxgl.Map;
|
||||
@@ -89,7 +111,7 @@ export class GPXLayer {
|
||||
}));
|
||||
this.draggable = get(currentTool) === Tool.WAYPOINT;
|
||||
|
||||
this.map.on('style.load', this.updateBinded);
|
||||
this.map.on('style.import.load', this.updateBinded);
|
||||
}
|
||||
|
||||
update() {
|
||||
@@ -98,9 +120,9 @@ 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;
|
||||
this.layerColor = `#${file._data.style.color}`;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -147,7 +169,7 @@ export class GPXLayer {
|
||||
'text-keep-upright': false,
|
||||
'text-max-angle': 361,
|
||||
'text-allow-overlap': true,
|
||||
'text-font': [font[get(currentBasemap)] ?? 'Open Sans Bold'],
|
||||
'text-font': ['Open Sans Bold'],
|
||||
'symbol-placement': 'line',
|
||||
'symbol-spacing': 20,
|
||||
},
|
||||
@@ -184,19 +206,15 @@ export class GPXLayer {
|
||||
|
||||
if (get(selection).hasAnyChildren(new ListFileItem(this.fileId))) {
|
||||
file.wpt.forEach((waypoint) => { // Update markers
|
||||
let symbolKey = getSymbolKey(waypoint.sym);
|
||||
if (markerIndex < this.markers.length) {
|
||||
this.markers[markerIndex].getElement().querySelector('circle')?.setAttribute('fill', 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 });
|
||||
} else {
|
||||
let element = document.createElement('div');
|
||||
element.classList.add('w-8', 'h-8', 'drop-shadow-xl');
|
||||
element.innerHTML = MapPin
|
||||
.replace('width="24"', '')
|
||||
.replace('height="24"', '')
|
||||
.replace('stroke="currentColor"', '')
|
||||
.replace('path', `path fill="#3fb1ce" stroke="SteelBlue" stroke-width="1"`)
|
||||
.replace('circle', `circle fill="${this.layerColor}" stroke="white" stroke-width="2.5"`);
|
||||
element.innerHTML = getMarkerForSymbol(symbolKey, this.layerColor);
|
||||
let marker = new mapboxgl.Marker({
|
||||
draggable: this.draggable,
|
||||
element,
|
||||
@@ -275,7 +293,7 @@ export class GPXLayer {
|
||||
|
||||
updateMap(map: mapboxgl.Map) {
|
||||
this.map = map;
|
||||
this.map.on('style.load', this.updateBinded);
|
||||
this.map.on('style.import.load', this.updateBinded);
|
||||
this.update();
|
||||
}
|
||||
|
||||
@@ -284,7 +302,7 @@ export class GPXLayer {
|
||||
this.map.off('click', this.fileId, this.layerOnClickBinded);
|
||||
this.map.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
|
||||
this.map.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
|
||||
this.map.off('style.load', this.updateBinded);
|
||||
this.map.off('style.import.load', this.updateBinded);
|
||||
|
||||
if (this.map.getLayer(this.fileId + '-direction')) {
|
||||
this.map.removeLayer(this.fileId + '-direction');
|
||||
@@ -320,7 +338,7 @@ export class GPXLayer {
|
||||
let segmentIndex = e.features[0].properties.segmentIndex;
|
||||
|
||||
if (get(currentTool) === Tool.SCISSORS && get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
|
||||
setCursor(`url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" version="1.1"><path d="M 3.200 3.200 C 0.441 5.959, 2.384 9.516, 7 10.154 C 10.466 10.634, 10.187 13.359, 6.607 13.990 C 2.934 14.637, 1.078 17.314, 2.612 19.750 C 4.899 23.380, 10 21.935, 10 17.657 C 10 16.445, 12.405 13.128, 15.693 9.805 C 18.824 6.641, 21.066 3.732, 20.674 3.341 C 20.283 2.950, 18.212 4.340, 16.072 6.430 C 12.019 10.388, 10 10.458, 10 6.641 C 10 2.602, 5.882 0.518, 3.200 3.200 M 4.446 5.087 C 3.416 6.755, 5.733 8.667, 7.113 7.287 C 8.267 6.133, 7.545 4, 6 4 C 5.515 4, 4.816 4.489, 4.446 5.087 M 14 14.813 C 14 16.187, 19.935 21.398, 20.667 20.667 C 21.045 20.289, 20.065 18.634, 18.490 16.990 C 15.661 14.036, 14 13.231, 14 14.813 M 4.446 17.087 C 3.416 18.755, 5.733 20.667, 7.113 19.287 C 8.267 18.133, 7.545 16, 6 16 C 5.515 16, 4.816 16.489, 4.446 17.087" stroke="black" stroke-width="1.2" fill="white" fill-rule="evenodd"/></svg>') 12 12, auto`);
|
||||
setScissorsCursor();
|
||||
} else {
|
||||
setPointerCursor();
|
||||
}
|
||||
|
@@ -4,10 +4,12 @@
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import { waypointPopup, currentPopupWaypoint, deleteWaypoint } from './WaypointPopup';
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
import { Dot, Trash2 } from 'lucide-svelte';
|
||||
import { Dot, ExternalLink, Trash2 } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { Tool, currentTool } from '$lib/stores';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
let popupElement: HTMLDivElement;
|
||||
|
||||
@@ -15,16 +17,55 @@
|
||||
waypointPopup.setDOMContent(popupElement);
|
||||
popupElement.classList.remove('hidden');
|
||||
});
|
||||
|
||||
$: symbolKey = $currentPopupWaypoint ? getSymbolKey($currentPopupWaypoint[0].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();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={popupElement} class="hidden">
|
||||
{#if $currentPopupWaypoint}
|
||||
<Card.Root class="border-none shadow-md text-base max-w-80 p-2">
|
||||
<Card.Header class="p-0">
|
||||
<Card.Title class="text-md">{$currentPopupWaypoint[0].name}</Card.Title>
|
||||
<Card.Title class="text-md">
|
||||
{#if $currentPopupWaypoint[0].link && $currentPopupWaypoint[0].link.attributes && $currentPopupWaypoint[0].link.attributes.href}
|
||||
<a href={$currentPopupWaypoint[0].link.attributes.href} target="_blank">
|
||||
{$currentPopupWaypoint[0].name ?? $currentPopupWaypoint[0].link.attributes.href}
|
||||
<ExternalLink size="12" class="inline-block mb-1.5" />
|
||||
</a>
|
||||
{:else}
|
||||
{$currentPopupWaypoint[0].name ?? $_('gpx.waypoint')}
|
||||
{/if}
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col p-0 text-sm">
|
||||
<div class="flex flex-row items-center text-muted-foreground">
|
||||
<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}
|
||||
{$currentPopupWaypoint[0].getLatitude().toFixed(6)}° {$currentPopupWaypoint[0]
|
||||
.getLongitude()
|
||||
.toFixed(6)}°
|
||||
@@ -34,10 +75,10 @@
|
||||
{/if}
|
||||
</div>
|
||||
{#if $currentPopupWaypoint[0].desc}
|
||||
<span class="whitespace-pre-wrap">{$currentPopupWaypoint[0].desc}</span>
|
||||
<span class="whitespace-pre-wrap">{@html sanitize($currentPopupWaypoint[0].desc)}</span>
|
||||
{/if}
|
||||
{#if $currentPopupWaypoint[0].cmt && $currentPopupWaypoint[0].cmt !== $currentPopupWaypoint[0].desc}
|
||||
<span class="whitespace-pre-wrap">{$currentPopupWaypoint[0].cmt}</span>
|
||||
<span class="whitespace-pre-wrap">{@html sanitize($currentPopupWaypoint[0].cmt)}</span>
|
||||
{/if}
|
||||
{#if $currentTool === Tool.WAYPOINT}
|
||||
<Button
|
||||
@@ -55,3 +96,15 @@
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
div :global(a) {
|
||||
@apply text-blue-500 dark:text-blue-300;
|
||||
@apply hover:underline;
|
||||
}
|
||||
|
||||
div :global(img) {
|
||||
@apply my-0;
|
||||
@apply rounded-md;
|
||||
}
|
||||
</style>
|
||||
|
@@ -1,282 +1,435 @@
|
||||
<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 * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import { CirclePlus, CircleX, Minus, Pencil, Plus, Save, Trash2 } from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { settings } from '$lib/db';
|
||||
import { defaultBasemap, extendBasemap, type CustomLayer } from '$lib/assets/layers';
|
||||
import { map } from '$lib/stores';
|
||||
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
|
||||
} = 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';
|
||||
|
||||
$: if (tileUrls[0].length > 0) {
|
||||
if (
|
||||
tileUrls[0].includes('.json') ||
|
||||
(tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles'))
|
||||
) {
|
||||
resourceType = 'vector';
|
||||
layerType = 'basemap';
|
||||
} else {
|
||||
resourceType = 'raster';
|
||||
}
|
||||
}
|
||||
let basemapContainer: HTMLElement;
|
||||
let overlayContainer: HTMLElement;
|
||||
|
||||
function createLayer() {
|
||||
if (typeof maxZoom === 'string') {
|
||||
maxZoom = parseInt(maxZoom);
|
||||
}
|
||||
let basemapSortable: Sortable;
|
||||
let overlaySortable: Sortable;
|
||||
|
||||
let layerId = selectedLayerId ?? getLayerId();
|
||||
let layer: CustomLayer = {
|
||||
id: layerId,
|
||||
name: name,
|
||||
tileUrls: tileUrls,
|
||||
maxZoom: maxZoom,
|
||||
layerType: layerType,
|
||||
resourceType: resourceType,
|
||||
value: ''
|
||||
};
|
||||
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'
|
||||
);
|
||||
}
|
||||
|
||||
if (resourceType === 'vector') {
|
||||
layer.value = tileUrls[0];
|
||||
} else {
|
||||
if (layerType === 'basemap') {
|
||||
layer.value = extendBasemap({
|
||||
version: 8,
|
||||
sources: {
|
||||
[layerId]: {
|
||||
type: 'raster',
|
||||
tiles: tileUrls,
|
||||
maxzoom: maxZoom
|
||||
}
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: layerId,
|
||||
type: 'raster',
|
||||
source: layerId
|
||||
}
|
||||
]
|
||||
});
|
||||
} else {
|
||||
layer.value = {
|
||||
type: 'raster',
|
||||
tiles: tileUrls,
|
||||
maxzoom: maxZoom
|
||||
};
|
||||
}
|
||||
}
|
||||
$customLayers[layerId] = layer;
|
||||
addLayer(layerId);
|
||||
selectedLayerId = undefined;
|
||||
setDataFromSelectedLayer();
|
||||
}
|
||||
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;
|
||||
}, {});
|
||||
}
|
||||
});
|
||||
|
||||
function getLayerId() {
|
||||
for (let id = 0; ; id++) {
|
||||
if (!$customLayers.hasOwnProperty(`custom-${id}`)) {
|
||||
return `custom-${id}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
basemapSortable.sort($customBasemapOrder);
|
||||
overlaySortable.sort($customOverlayOrder);
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
} else {
|
||||
selectedOverlayTree.update(($tree) => {
|
||||
if (!$tree.overlays.hasOwnProperty('custom')) {
|
||||
$tree.overlays['custom'] = {};
|
||||
}
|
||||
$tree.overlays['custom'][layerId] = true;
|
||||
return $tree;
|
||||
});
|
||||
}
|
||||
}
|
||||
onDestroy(() => {
|
||||
basemapSortable.destroy();
|
||||
overlaySortable.destroy();
|
||||
});
|
||||
|
||||
function tryDeleteLayer(node: any, id: string): any {
|
||||
if (node.hasOwnProperty(id)) {
|
||||
delete node[id];
|
||||
}
|
||||
return node;
|
||||
}
|
||||
$: 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 deleteLayer(layerId: string) {
|
||||
let layer = $customLayers[layerId];
|
||||
if (layer.layerType === 'basemap') {
|
||||
if (layerId === $currentBasemap) {
|
||||
$currentBasemap = defaultBasemap;
|
||||
}
|
||||
if (layerId === $previousBasemap) {
|
||||
$previousBasemap = defaultBasemap;
|
||||
}
|
||||
function createLayer() {
|
||||
if (selectedLayerId && $customLayers[selectedLayerId].layerType !== layerType) {
|
||||
deleteLayer(selectedLayerId);
|
||||
}
|
||||
|
||||
$selectedBasemapTree.basemaps['custom'] = tryDeleteLayer(
|
||||
$selectedBasemapTree.basemaps['custom'],
|
||||
layerId
|
||||
);
|
||||
if (Object.keys($selectedBasemapTree.basemaps['custom']).length === 0) {
|
||||
$selectedBasemapTree.basemaps = tryDeleteLayer($selectedBasemapTree.basemaps, 'custom');
|
||||
}
|
||||
} else {
|
||||
$currentOverlays = tryDeleteLayer($currentOverlays, layerId);
|
||||
$previousOverlays = tryDeleteLayer($previousOverlays, layerId);
|
||||
if (typeof maxZoom === 'string') {
|
||||
maxZoom = parseInt(maxZoom);
|
||||
}
|
||||
|
||||
$selectedOverlayTree.overlays['custom'] = tryDeleteLayer(
|
||||
$selectedOverlayTree.overlays['custom'],
|
||||
layerId
|
||||
);
|
||||
if (Object.keys($selectedOverlayTree.overlays['custom']).length === 0) {
|
||||
$selectedOverlayTree.overlays = tryDeleteLayer($selectedOverlayTree.overlays, 'custom');
|
||||
}
|
||||
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 ($map) {
|
||||
if ($map.getLayer(layerId)) {
|
||||
$map.removeLayer(layerId);
|
||||
}
|
||||
if ($map.getSource(layerId)) {
|
||||
$map.removeSource(layerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
$customLayers = tryDeleteLayer($customLayers, layerId);
|
||||
}
|
||||
if (resourceType === 'vector') {
|
||||
layer.value = layer.tileUrls[0];
|
||||
} else {
|
||||
layer.value = {
|
||||
version: 8,
|
||||
sources: {
|
||||
[layerId]: {
|
||||
type: 'raster',
|
||||
tiles: layer.tileUrls,
|
||||
tileSize: 256,
|
||||
maxzoom: maxZoom
|
||||
}
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: layerId,
|
||||
type: 'raster',
|
||||
source: layerId
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
$customLayers[layerId] = layer;
|
||||
addLayer(layerId);
|
||||
selectedLayerId = undefined;
|
||||
setDataFromSelectedLayer();
|
||||
}
|
||||
|
||||
let selectedLayerId: string | undefined = undefined;
|
||||
function getLayerId() {
|
||||
for (let id = 0; ; id++) {
|
||||
if (!$customLayers.hasOwnProperty(`custom-${id}`)) {
|
||||
return `custom-${id}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 addLayer(layerId: string) {
|
||||
if (layerType === 'basemap') {
|
||||
selectedBasemapTree.update(($tree) => {
|
||||
if (!$tree.basemaps.hasOwnProperty('custom')) {
|
||||
$tree.basemaps['custom'] = {};
|
||||
}
|
||||
$tree.basemaps['custom'][layerId] = true;
|
||||
return $tree;
|
||||
});
|
||||
|
||||
$: selectedLayerId, setDataFromSelectedLayer();
|
||||
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 (
|
||||
$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 (!$customOverlayOrder.includes(layerId)) {
|
||||
$customOverlayOrder = [...$customOverlayOrder, layerId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
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;
|
||||
|
||||
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();
|
||||
</script>
|
||||
|
||||
{#if Object.keys($customLayers).length > 0}
|
||||
<div class="flex flex-col gap-1 mb-3">
|
||||
{#each Object.entries($customLayers) as [id, layer] (id)}
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<span class="grow">{layer.name}</span>
|
||||
<Button variant="outline" on:click={() => (selectedLayerId = id)} class="p-1 h-8">
|
||||
<Pencil size="16" />
|
||||
</Button>
|
||||
<Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-8">
|
||||
<Trash2 size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<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>
|
||||
|
||||
<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" disabled={resourceType === 'vector'} />
|
||||
<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,198 +1,219 @@
|
||||
<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 { getLayers } from './utils';
|
||||
import { OverpassLayer } from './OverpassLayer';
|
||||
import OverpassPopup from './OverpassPopup.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 OverpassPopup from './OverpassPopup.svelte';
|
||||
|
||||
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;
|
||||
|
||||
$: if ($map) {
|
||||
// Set style depending on the current basemap
|
||||
let basemap = basemaps.hasOwnProperty($currentBasemap)
|
||||
? basemaps[$currentBasemap]
|
||||
: $customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap];
|
||||
$map.setStyle(basemap, {
|
||||
diff: false
|
||||
});
|
||||
}
|
||||
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 && $currentOverlays) {
|
||||
// Add or remove overlay layers depending on the current overlays
|
||||
let overlayLayers = getLayers($currentOverlays);
|
||||
Object.keys(overlayLayers).forEach((id) => {
|
||||
if (overlayLayers[id]) {
|
||||
if (!addOverlayLayer.hasOwnProperty(id)) {
|
||||
addOverlayLayer[id] = addOverlayLayerForId(id);
|
||||
}
|
||||
if (!$map.getLayer(id)) {
|
||||
addOverlayLayer[id]();
|
||||
$map.on('style.load', addOverlayLayer[id]);
|
||||
}
|
||||
} else if ($map.getLayer(id)) {
|
||||
$map.removeLayer(id);
|
||||
$map.off('style.load', addOverlayLayer[id]);
|
||||
}
|
||||
});
|
||||
}
|
||||
$: if ($map && ($currentBasemap || $customBasemapUpdate)) {
|
||||
setStyle();
|
||||
}
|
||||
|
||||
$: if ($map) {
|
||||
if (overpassLayer) {
|
||||
overpassLayer.remove();
|
||||
}
|
||||
overpassLayer = new OverpassLayer($map);
|
||||
overpassLayer.add();
|
||||
}
|
||||
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 {
|
||||
$map.addImport({
|
||||
id,
|
||||
data: overlay
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
selectedBasemap.set(value);
|
||||
});
|
||||
function updateOverlays() {
|
||||
if ($map && $currentOverlays) {
|
||||
let overlayLayers = getLayers($currentOverlays);
|
||||
try {
|
||||
let activeOverlays = $map
|
||||
.getStyle()
|
||||
.imports.filter((i) => i.id !== 'basemap' && i.id !== 'overlays');
|
||||
let toRemove = activeOverlays.filter((i) => !overlayLayers[i.id]);
|
||||
toRemove.forEach((i) => {
|
||||
$map.removeImport(i.id);
|
||||
});
|
||||
let toAdd = Object.entries(overlayLayers)
|
||||
.filter(
|
||||
([id, selected]) => selected && !activeOverlays.some((j) => j.id === 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let addOverlayLayer: { [key: string]: () => void } = {};
|
||||
function addOverlayLayerForId(id: string) {
|
||||
return () => {
|
||||
if ($map) {
|
||||
try {
|
||||
let overlay = $customLayers.hasOwnProperty(id) ? $customLayers[id].value : overlays[id];
|
||||
if (!$map.getSource(id)) {
|
||||
$map.addSource(id, overlay);
|
||||
}
|
||||
$map.addLayer(
|
||||
{
|
||||
id,
|
||||
type: overlay.type === 'raster' ? 'raster' : 'line',
|
||||
source: id,
|
||||
paint: {
|
||||
...(id in $opacities
|
||||
? overlay.type === 'raster'
|
||||
? { 'raster-opacity': $opacities[id] }
|
||||
: { 'line-opacity': $opacities[id] }
|
||||
: {})
|
||||
}
|
||||
},
|
||||
'overlays'
|
||||
);
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
$: if ($map && $currentOverlays) {
|
||||
updateOverlays();
|
||||
}
|
||||
|
||||
let open = false;
|
||||
function openLayerControl() {
|
||||
open = true;
|
||||
}
|
||||
function closeLayerControl() {
|
||||
open = false;
|
||||
}
|
||||
let cancelEvents = false;
|
||||
$: 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
|
||||
selectedBasemap.set(value);
|
||||
});
|
||||
|
||||
function removeOverlayLayer(id: string) {
|
||||
if ($map) {
|
||||
let overlay = $customLayers.hasOwnProperty(id) ? $customLayers[id].value : overlays[id];
|
||||
if (overlay.layers) {
|
||||
$map.removeImport(id);
|
||||
} else {
|
||||
$map.removeLayer(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<OverpassPopup />
|
||||
|
||||
<svelte:window
|
||||
on:click={(e) => {
|
||||
if (open && !cancelEvents && !container.contains(e.target)) {
|
||||
closeLayerControl();
|
||||
}
|
||||
}}
|
||||
on:click={(e) => {
|
||||
if (open && !cancelEvents && !container.contains(e.target)) {
|
||||
closeLayerControl();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
@@ -14,16 +14,14 @@
|
||||
import { settings } from '$lib/db';
|
||||
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { map, setStravaHeatmapURLs } from '$lib/stores';
|
||||
import { browser } from '$app/environment';
|
||||
import { writable } from 'svelte/store';
|
||||
import { map } from '$lib/stores';
|
||||
import CustomLayers from './CustomLayers.svelte';
|
||||
|
||||
const {
|
||||
selectedBasemapTree,
|
||||
selectedOverlayTree,
|
||||
selectedOverpassTree,
|
||||
stravaHeatmapColor,
|
||||
currentOverlays,
|
||||
customLayers,
|
||||
opacities
|
||||
@@ -51,63 +49,6 @@
|
||||
$: if ($selectedOverlay) {
|
||||
setOpacityFromSelection();
|
||||
}
|
||||
|
||||
const heatmapColors = [
|
||||
{ value: '', label: '' },
|
||||
{ value: 'blue', label: $_('layers.color.blue') },
|
||||
{ value: 'bluered', label: $_('layers.color.bluered') },
|
||||
{ value: 'gray', label: $_('layers.color.gray') },
|
||||
{ value: 'hot', label: $_('layers.color.hot') },
|
||||
{ value: 'orange', label: $_('layers.color.orange') },
|
||||
{ value: 'purple', label: $_('layers.color.purple') }
|
||||
];
|
||||
|
||||
let selectedHeatmapColor = writable(heatmapColors[0]);
|
||||
|
||||
$: if ($selectedHeatmapColor !== heatmapColors[0]) {
|
||||
stravaHeatmapColor.set($selectedHeatmapColor.value);
|
||||
|
||||
// remove and add the heatmap layers
|
||||
let m = get(map);
|
||||
if (m) {
|
||||
let currentStravaLayers = [];
|
||||
if (overlayTree.overlays.world.strava) {
|
||||
for (let layer of Object.keys(overlayTree.overlays.world.strava)) {
|
||||
if (m.getLayer(layer)) {
|
||||
m.removeLayer(layer);
|
||||
currentStravaLayers.push(layer);
|
||||
}
|
||||
if (m.getSource(layer)) {
|
||||
m.removeSource(layer);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentStravaLayers.length > 0) {
|
||||
currentOverlays.update(($currentOverlays) => {
|
||||
for (let layer of currentStravaLayers) {
|
||||
$currentOverlays.overlays.world.strava[layer] = false;
|
||||
}
|
||||
return $currentOverlays;
|
||||
});
|
||||
currentOverlays.update(($currentOverlays) => {
|
||||
for (let layer of currentStravaLayers) {
|
||||
$currentOverlays.overlays.world.strava[layer] = true;
|
||||
}
|
||||
return $currentOverlays;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($stravaHeatmapColor && browser) {
|
||||
setStravaHeatmapURLs();
|
||||
if ($stravaHeatmapColor !== get(selectedHeatmapColor).value) {
|
||||
let toSelect = heatmapColors.find(({ value }) => value === $stravaHeatmapColor);
|
||||
if (toSelect) {
|
||||
selectedHeatmapColor.set(toSelect);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Sheet.Root bind:open>
|
||||
@@ -209,26 +150,6 @@
|
||||
</ScrollArea>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="heatmap-color" class="hidden">
|
||||
<Accordion.Trigger>{$_('layers.heatmap')}</Accordion.Trigger>
|
||||
<Accordion.Content class="overflow-visible">
|
||||
<div class="flex flex-row items-center justify-between gap-6">
|
||||
<Label>
|
||||
{$_('menu.style.color')}
|
||||
</Label>
|
||||
<Select.Root bind:selected={$selectedHeatmapColor}>
|
||||
<Select.Trigger class="h-8 mr-1">
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each heatmapColors as { value, label }}
|
||||
<Select.Item {value}>{label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
</ScrollArea>
|
||||
</Sheet.Header>
|
||||
|
@@ -32,6 +32,7 @@ export class OverpassLayer {
|
||||
overpassUrl = 'https://overpass.private.coffee/api/interpreter';
|
||||
minZoom = 12;
|
||||
queryZoom = 12;
|
||||
expirationTime = 7 * 24 * 3600 * 1000;
|
||||
map: mapboxgl.Map;
|
||||
|
||||
currentQueries: Set<string> = new Set();
|
||||
@@ -49,7 +50,7 @@ export class OverpassLayer {
|
||||
|
||||
add() {
|
||||
this.map.on('moveend', this.queryIfNeededBinded);
|
||||
this.map.on('style.load', this.updateBinded);
|
||||
this.map.on('style.import.load', this.updateBinded);
|
||||
this.unsubscribes.push(data.subscribe(this.updateBinded));
|
||||
this.unsubscribes.push(currentOverpassQueries.subscribe(() => {
|
||||
this.updateBinded();
|
||||
@@ -107,20 +108,27 @@ export class OverpassLayer {
|
||||
|
||||
remove() {
|
||||
this.map.off('moveend', this.queryIfNeededBinded);
|
||||
this.map.off('style.load', this.updateBinded);
|
||||
this.map.off('style.import.load', this.updateBinded);
|
||||
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
|
||||
if (this.map.getLayer('overpass')) {
|
||||
this.map.removeLayer('overpass');
|
||||
}
|
||||
try {
|
||||
if (this.map.getLayer('overpass')) {
|
||||
this.map.removeLayer('overpass');
|
||||
}
|
||||
|
||||
if (this.map.getSource('overpass')) {
|
||||
this.map.removeSource('overpass');
|
||||
if (this.map.getSource('overpass')) {
|
||||
this.map.removeSource('overpass');
|
||||
}
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
}
|
||||
}
|
||||
|
||||
onHover(e: any) {
|
||||
overpassPopupPOI.set(e.features[0].properties);
|
||||
overpassPopupPOI.set({
|
||||
...e.features[0].properties,
|
||||
sym: overpassQueryData[e.features[0].properties.query].symbol ?? ''
|
||||
});
|
||||
overpassPopup.setLngLat(e.features[0].geometry.coordinates);
|
||||
overpassPopup.addTo(this.map);
|
||||
this.map.on('mousemove', this.maybeHidePopupBinded);
|
||||
@@ -147,6 +155,7 @@ export class OverpassLayer {
|
||||
}
|
||||
|
||||
let tileLimits = mercator.xyz(bbox, this.queryZoom);
|
||||
let time = Date.now();
|
||||
|
||||
for (let x = tileLimits.minX; x <= tileLimits.maxX; x++) {
|
||||
for (let y = tileLimits.minY; y <= tileLimits.maxY; y++) {
|
||||
@@ -154,8 +163,8 @@ export class OverpassLayer {
|
||||
continue;
|
||||
}
|
||||
|
||||
db.overpassquerytiles.where('[x+y]').equals([x, y]).toArray().then((querytiles) => {
|
||||
let missingQueries = queries.filter((query) => !querytiles.some((querytile) => querytile.query === query));
|
||||
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);
|
||||
}
|
||||
@@ -185,7 +194,8 @@ export class OverpassLayer {
|
||||
}
|
||||
|
||||
storeOverpassData(x: number, y: number, queries: string[], data: any) {
|
||||
let queryTiles = queries.map((query) => ({ x, y, query }));
|
||||
let time = Date.now();
|
||||
let queryTiles = queries.map((query) => ({ x, y, query, time }));
|
||||
let pois: { query: string, id: number, poi: GeoJSON.Feature }[] = [];
|
||||
|
||||
if (data.elements === undefined) {
|
||||
@@ -218,8 +228,8 @@ export class OverpassLayer {
|
||||
}
|
||||
}
|
||||
|
||||
db.transaction('rw', db.overpassquerytiles, db.overpassdata, async () => {
|
||||
await db.overpassquerytiles.bulkPut(queryTiles);
|
||||
db.transaction('rw', db.overpasstiles, db.overpassdata, async () => {
|
||||
await db.overpasstiles.bulkPut(queryTiles);
|
||||
await db.overpassdata.bulkPut(pois);
|
||||
});
|
||||
|
||||
|
@@ -7,7 +7,6 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { dbUtils } from '$lib/db';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
let popupElement: HTMLDivElement;
|
||||
|
||||
@@ -89,7 +88,8 @@
|
||||
},
|
||||
name: name,
|
||||
desc: desc,
|
||||
cmt: desc
|
||||
cmt: desc,
|
||||
sym: $overpassPopupPOI.sym
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import type { LayerTreeType } from "$lib/assets/layers";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export function anySelectedLayer(node: LayerTreeType) {
|
||||
return Object.keys(node).find((id) => {
|
||||
@@ -36,4 +37,6 @@ export function isSelected(node: LayerTreeType, id: string) {
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const customBasemapUpdate = writable(0);
|
@@ -4,7 +4,7 @@
|
||||
import { flyAndScale } from '$lib/utils';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte';
|
||||
import Scissors from '$lib/components/toolbar/tools/Scissors.svelte';
|
||||
import Scissors from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
|
||||
import Waypoint from '$lib/components/toolbar/tools/Waypoint.svelte';
|
||||
import Time from '$lib/components/toolbar/tools/Time.svelte';
|
||||
import Merge from '$lib/components/toolbar/tools/Merge.svelte';
|
||||
|
@@ -178,7 +178,7 @@
|
||||
<Trash2 size="16" class="mr-1" />
|
||||
{$_('toolbar.clean.button')}
|
||||
</Button>
|
||||
<Help>
|
||||
<Help link="./help/toolbar/clean">
|
||||
{#if validSelection}
|
||||
{$_('toolbar.clean.help')}
|
||||
{:else}
|
||||
|
@@ -42,7 +42,7 @@
|
||||
<Ungroup size="16" class="mr-1" />
|
||||
{$_('toolbar.extract.button')}
|
||||
</Button>
|
||||
<Help>
|
||||
<Help link="./help/toolbar/extract">
|
||||
{#if validSelection}
|
||||
{$_('toolbar.extract.help')}
|
||||
{:else}
|
||||
|
@@ -74,7 +74,7 @@
|
||||
<Group size="16" class="mr-1" />
|
||||
{$_('toolbar.merge.merge_selection')}
|
||||
</Button>
|
||||
<Help>
|
||||
<Help link="./help/toolbar/merge">
|
||||
{#if mergeType === MergeType.TRACES && canMergeTraces}
|
||||
{$_('toolbar.merge.help_merge_traces')}
|
||||
{:else if mergeType === MergeType.TRACES && !canMergeTraces}
|
||||
|
@@ -164,7 +164,7 @@
|
||||
{$_('toolbar.reduce.button')}
|
||||
</Button>
|
||||
|
||||
<Help>
|
||||
<Help link="./help/toolbar/minify">
|
||||
{#if validSelection}
|
||||
{$_('toolbar.reduce.help')}
|
||||
{:else}
|
||||
|
@@ -1,342 +1,396 @@
|
||||
<script lang="ts">
|
||||
import DatePicker from '$lib/components/ui/date-picker/DatePicker.svelte';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import TimePicker from '$lib/components/ui/time-picker/TimePicker.svelte';
|
||||
import { dbUtils, settings } from '$lib/db';
|
||||
import { gpxStatistics } from '$lib/stores';
|
||||
import {
|
||||
distancePerHourToSecondsPerDistance,
|
||||
getConvertedVelocity,
|
||||
kilometersToMiles
|
||||
} from '$lib/units';
|
||||
import { CalendarDate, type DateValue } from '@internationalized/date';
|
||||
import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from 'lucide-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListRootItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import DatePicker from '$lib/components/ui/date-picker/DatePicker.svelte';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import TimePicker from '$lib/components/ui/time-picker/TimePicker.svelte';
|
||||
import { dbUtils, settings } from '$lib/db';
|
||||
import { gpxStatistics } from '$lib/stores';
|
||||
import {
|
||||
distancePerHourToSecondsPerDistance,
|
||||
getConvertedVelocity,
|
||||
milesToKilometers,
|
||||
nauticalMilesToKilometers
|
||||
} from '$lib/units';
|
||||
import { CalendarDate, type DateValue } from '@internationalized/date';
|
||||
import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from 'lucide-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListRootItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
|
||||
let startDate: DateValue | undefined = undefined;
|
||||
let startTime: string | undefined = undefined;
|
||||
let endDate: DateValue | undefined = undefined;
|
||||
let endTime: string | undefined = undefined;
|
||||
let movingTime: number | undefined = undefined;
|
||||
let speed: number | undefined = undefined;
|
||||
let startDate: DateValue | undefined = undefined;
|
||||
let startTime: string | undefined = undefined;
|
||||
let endDate: DateValue | undefined = undefined;
|
||||
let endTime: string | undefined = undefined;
|
||||
let movingTime: number | undefined = undefined;
|
||||
let speed: number | undefined = undefined;
|
||||
let artificial = false;
|
||||
|
||||
function toCalendarDate(date: Date): CalendarDate {
|
||||
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate());
|
||||
}
|
||||
function toCalendarDate(date: Date): CalendarDate {
|
||||
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate());
|
||||
}
|
||||
|
||||
const { velocityUnits, distanceUnits } = settings;
|
||||
const { velocityUnits, distanceUnits } = settings;
|
||||
|
||||
function setSpeed(value: number) {
|
||||
let speedValue = getConvertedVelocity(value);
|
||||
if ($velocityUnits === 'speed') {
|
||||
speedValue = parseFloat(speedValue.toFixed(2));
|
||||
}
|
||||
speed = speedValue;
|
||||
}
|
||||
function setSpeed(value: number) {
|
||||
let speedValue = getConvertedVelocity(value);
|
||||
if ($velocityUnits === 'speed') {
|
||||
speedValue = parseFloat(speedValue.toFixed(2));
|
||||
}
|
||||
speed = speedValue;
|
||||
}
|
||||
|
||||
function setGPXData() {
|
||||
if ($gpxStatistics.global.time.start) {
|
||||
startDate = toCalendarDate($gpxStatistics.global.time.start);
|
||||
startTime = $gpxStatistics.global.time.start.toLocaleTimeString();
|
||||
} else {
|
||||
startDate = undefined;
|
||||
startTime = undefined;
|
||||
}
|
||||
if ($gpxStatistics.global.time.end) {
|
||||
endDate = toCalendarDate($gpxStatistics.global.time.end);
|
||||
endTime = $gpxStatistics.global.time.end.toLocaleTimeString();
|
||||
} else {
|
||||
endDate = undefined;
|
||||
endTime = undefined;
|
||||
}
|
||||
if ($gpxStatistics.global.time.moving) {
|
||||
movingTime = $gpxStatistics.global.time.moving;
|
||||
} else {
|
||||
movingTime = undefined;
|
||||
}
|
||||
if ($gpxStatistics.global.speed.moving) {
|
||||
setSpeed($gpxStatistics.global.speed.moving);
|
||||
} else {
|
||||
speed = undefined;
|
||||
}
|
||||
}
|
||||
function setGPXData() {
|
||||
if ($gpxStatistics.global.time.start) {
|
||||
startDate = toCalendarDate($gpxStatistics.global.time.start);
|
||||
startTime = $gpxStatistics.global.time.start.toLocaleTimeString();
|
||||
} else {
|
||||
startDate = undefined;
|
||||
startTime = undefined;
|
||||
}
|
||||
if ($gpxStatistics.global.time.end) {
|
||||
endDate = toCalendarDate($gpxStatistics.global.time.end);
|
||||
endTime = $gpxStatistics.global.time.end.toLocaleTimeString();
|
||||
} else {
|
||||
endDate = undefined;
|
||||
endTime = undefined;
|
||||
}
|
||||
if ($gpxStatistics.global.time.moving) {
|
||||
movingTime = $gpxStatistics.global.time.moving;
|
||||
} else {
|
||||
movingTime = undefined;
|
||||
}
|
||||
if ($gpxStatistics.global.speed.moving) {
|
||||
setSpeed($gpxStatistics.global.speed.moving);
|
||||
} else {
|
||||
speed = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($gpxStatistics && $velocityUnits && $distanceUnits) {
|
||||
setGPXData();
|
||||
}
|
||||
$: if ($gpxStatistics && $velocityUnits && $distanceUnits) {
|
||||
setGPXData();
|
||||
}
|
||||
|
||||
function getDate(date: DateValue, time: string): Date {
|
||||
if (date === undefined) {
|
||||
return new Date();
|
||||
}
|
||||
let [hours, minutes, seconds] = time.split(':').map((x) => parseInt(x));
|
||||
return new Date(date.year, date.month - 1, date.day, hours, minutes, seconds);
|
||||
}
|
||||
function getDate(date: DateValue, time: string): Date {
|
||||
if (date === undefined) {
|
||||
return new Date();
|
||||
}
|
||||
let [hours, minutes, seconds] = time.split(':').map((x) => parseInt(x));
|
||||
return new Date(date.year, date.month - 1, date.day, hours, minutes, seconds);
|
||||
}
|
||||
|
||||
function updateEnd() {
|
||||
if (startDate && movingTime !== undefined) {
|
||||
if (startTime === undefined) {
|
||||
startTime = '00:00:00';
|
||||
}
|
||||
let start = getDate(startDate, startTime);
|
||||
let ratio =
|
||||
$gpxStatistics.global.time.moving > 0
|
||||
? $gpxStatistics.global.time.total / $gpxStatistics.global.time.moving
|
||||
: 1;
|
||||
let end = new Date(start.getTime() + ratio * movingTime * 1000);
|
||||
endDate = toCalendarDate(end);
|
||||
endTime = end.toLocaleTimeString();
|
||||
}
|
||||
}
|
||||
function updateEnd() {
|
||||
if (startDate && movingTime !== undefined) {
|
||||
if (startTime === undefined) {
|
||||
startTime = '00:00:00';
|
||||
}
|
||||
let start = getDate(startDate, startTime);
|
||||
let ratio =
|
||||
$gpxStatistics.global.time.moving > 0
|
||||
? $gpxStatistics.global.time.total / $gpxStatistics.global.time.moving
|
||||
: 1;
|
||||
let end = new Date(start.getTime() + ratio * movingTime * 1000);
|
||||
endDate = toCalendarDate(end);
|
||||
endTime = end.toLocaleTimeString();
|
||||
}
|
||||
}
|
||||
|
||||
function updateStart() {
|
||||
if (endDate && movingTime !== undefined) {
|
||||
if (endTime === undefined) {
|
||||
endTime = '00:00:00';
|
||||
}
|
||||
let end = getDate(endDate, endTime);
|
||||
let ratio =
|
||||
$gpxStatistics.global.time.moving > 0
|
||||
? $gpxStatistics.global.time.total / $gpxStatistics.global.time.moving
|
||||
: 1;
|
||||
let start = new Date(end.getTime() - ratio * movingTime * 1000);
|
||||
startDate = toCalendarDate(start);
|
||||
startTime = start.toLocaleTimeString();
|
||||
}
|
||||
}
|
||||
function updateStart() {
|
||||
if (endDate && movingTime !== undefined) {
|
||||
if (endTime === undefined) {
|
||||
endTime = '00:00:00';
|
||||
}
|
||||
let end = getDate(endDate, endTime);
|
||||
let ratio =
|
||||
$gpxStatistics.global.time.moving > 0
|
||||
? $gpxStatistics.global.time.total / $gpxStatistics.global.time.moving
|
||||
: 1;
|
||||
let start = new Date(end.getTime() - ratio * movingTime * 1000);
|
||||
startDate = toCalendarDate(start);
|
||||
startTime = start.toLocaleTimeString();
|
||||
}
|
||||
}
|
||||
|
||||
function getSpeed() {
|
||||
if (speed === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
function getSpeed() {
|
||||
if (speed === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let speedValue = speed;
|
||||
if ($velocityUnits === 'pace') {
|
||||
speedValue = distancePerHourToSecondsPerDistance(speed);
|
||||
}
|
||||
if ($distanceUnits === 'imperial') {
|
||||
speedValue = kilometersToMiles(speedValue);
|
||||
}
|
||||
return speedValue;
|
||||
}
|
||||
let speedValue = speed;
|
||||
if ($velocityUnits === 'pace') {
|
||||
speedValue = distancePerHourToSecondsPerDistance(speed);
|
||||
}
|
||||
if ($distanceUnits === 'imperial') {
|
||||
speedValue = milesToKilometers(speedValue);
|
||||
} else if ($distanceUnits === 'nautical') {
|
||||
speedValue = nauticalMilesToKilometers(speedValue);
|
||||
}
|
||||
return speedValue;
|
||||
}
|
||||
|
||||
function updateDataFromSpeed() {
|
||||
let speedValue = getSpeed();
|
||||
if (speedValue === undefined) {
|
||||
return;
|
||||
}
|
||||
function updateDataFromSpeed() {
|
||||
let speedValue = getSpeed();
|
||||
if (speedValue === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
let distance =
|
||||
$gpxStatistics.global.distance.moving > 0
|
||||
? $gpxStatistics.global.distance.moving
|
||||
: $gpxStatistics.global.distance.total;
|
||||
movingTime = (distance / speedValue) * 3600;
|
||||
let distance =
|
||||
$gpxStatistics.global.distance.moving > 0
|
||||
? $gpxStatistics.global.distance.moving
|
||||
: $gpxStatistics.global.distance.total;
|
||||
movingTime = (distance / speedValue) * 3600;
|
||||
|
||||
updateEnd();
|
||||
}
|
||||
updateEnd();
|
||||
}
|
||||
|
||||
function updateDataFromTotalTime() {
|
||||
if (movingTime === undefined) {
|
||||
return;
|
||||
}
|
||||
setSpeed($gpxStatistics.global.distance.moving / (movingTime / 3600));
|
||||
updateEnd();
|
||||
}
|
||||
function updateDataFromTotalTime() {
|
||||
if (movingTime === undefined) {
|
||||
return;
|
||||
}
|
||||
let distance =
|
||||
$gpxStatistics.global.distance.moving > 0
|
||||
? $gpxStatistics.global.distance.moving
|
||||
: $gpxStatistics.global.distance.total;
|
||||
setSpeed(distance / (movingTime / 3600));
|
||||
updateEnd();
|
||||
}
|
||||
|
||||
$: canUpdate =
|
||||
$selection.size === 1 && $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
|
||||
$: canUpdate =
|
||||
$selection.size === 1 && $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<div class="flex flex-row gap-2 justify-center">
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<Label for="speed" class="flex flex-row">
|
||||
<Zap size="16" class="mr-1" />
|
||||
{#if $velocityUnits === 'speed'}
|
||||
{$_('quantities.speed')}
|
||||
{:else}
|
||||
{$_('quantities.pace')}
|
||||
{/if}
|
||||
</Label>
|
||||
<div class="flex flex-row gap-1 items-center">
|
||||
{#if $velocityUnits === 'speed'}
|
||||
<Input
|
||||
id="speed"
|
||||
type="number"
|
||||
step={0.01}
|
||||
min={0.01}
|
||||
disabled={!canUpdate}
|
||||
bind:value={speed}
|
||||
on:change={updateDataFromSpeed}
|
||||
/>
|
||||
<span class="text-sm shrink-0">
|
||||
{#if $distanceUnits === 'imperial'}
|
||||
{$_('units.miles_per_hour')}
|
||||
{:else}
|
||||
{$_('units.kilometers_per_hour')}
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<TimePicker
|
||||
bind:value={speed}
|
||||
showHours={false}
|
||||
disabled={!canUpdate}
|
||||
on:change={updateDataFromSpeed}
|
||||
/>
|
||||
<span class="text-sm shrink-0">
|
||||
{#if $distanceUnits === 'imperial'}
|
||||
{$_('units.minutes_per_mile')}
|
||||
{:else}
|
||||
{$_('units.minutes_per_kilometer')}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<Label for="duration" class="flex flex-row">
|
||||
<Timer size="16" class="mr-1" />
|
||||
{$_('toolbar.time.total_time')}
|
||||
</Label>
|
||||
<TimePicker
|
||||
bind:value={movingTime}
|
||||
disabled={!canUpdate}
|
||||
on:change={updateDataFromTotalTime}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Label class="flex flex-row">
|
||||
<CirclePlay size="16" class="mr-1" />
|
||||
{$_('toolbar.time.start')}
|
||||
</Label>
|
||||
<div class="flex flex-row gap-2">
|
||||
<DatePicker
|
||||
bind:value={startDate}
|
||||
disabled={!canUpdate}
|
||||
locale={get(locale) ?? 'en'}
|
||||
placeholder={$_('toolbar.time.pick_date')}
|
||||
class="w-fit grow"
|
||||
onValueChange={async () => {
|
||||
await tick();
|
||||
updateEnd();
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
type="time"
|
||||
step={1}
|
||||
disabled={!canUpdate}
|
||||
bind:value={startTime}
|
||||
class="w-fit"
|
||||
on:input={updateEnd}
|
||||
/>
|
||||
</div>
|
||||
<Label class="flex flex-row">
|
||||
<CircleStop size="16" class="mr-1" />
|
||||
{$_('toolbar.time.end')}
|
||||
</Label>
|
||||
<div class="flex flex-row gap-2">
|
||||
<DatePicker
|
||||
bind:value={endDate}
|
||||
disabled={!canUpdate}
|
||||
locale={get(locale) ?? 'en'}
|
||||
placeholder={$_('toolbar.time.pick_date')}
|
||||
class="w-fit grow"
|
||||
onValueChange={async () => {
|
||||
await tick();
|
||||
updateStart();
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
type="time"
|
||||
step={1}
|
||||
disabled={!canUpdate}
|
||||
bind:value={endTime}
|
||||
class="w-fit"
|
||||
on:change={updateStart}
|
||||
/>
|
||||
</div>
|
||||
{#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined}
|
||||
<div class="mt-0.5 flex flex-row gap-1 items-center hidden">
|
||||
<Checkbox id="artificial-time" disabled={!canUpdate} />
|
||||
<Label for="artificial-time">
|
||||
{$_('toolbar.time.artificial')}
|
||||
</Label>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canUpdate}
|
||||
class="grow"
|
||||
on:click={() => {
|
||||
let effectiveSpeed = getSpeed();
|
||||
if (startDate === undefined || startTime === undefined || effectiveSpeed === undefined) {
|
||||
return;
|
||||
}
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<div class="flex flex-row gap-2 justify-center">
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<Label for="speed" class="flex flex-row">
|
||||
<Zap size="16" class="mr-1" />
|
||||
{#if $velocityUnits === 'speed'}
|
||||
{$_('quantities.speed')}
|
||||
{:else}
|
||||
{$_('quantities.pace')}
|
||||
{/if}
|
||||
</Label>
|
||||
<div class="flex flex-row gap-1 items-center">
|
||||
{#if $velocityUnits === 'speed'}
|
||||
<Input
|
||||
id="speed"
|
||||
type="number"
|
||||
step={0.01}
|
||||
min={0.01}
|
||||
disabled={!canUpdate}
|
||||
bind:value={speed}
|
||||
on:change={updateDataFromSpeed}
|
||||
/>
|
||||
<span class="text-sm shrink-0">
|
||||
{#if $distanceUnits === 'imperial'}
|
||||
{$_('units.miles_per_hour')}
|
||||
{:else if $distanceUnits === 'metric'}
|
||||
{$_('units.kilometers_per_hour')}
|
||||
{:else if $distanceUnits === 'nautical'}
|
||||
{$_('units.knots')}
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<TimePicker
|
||||
bind:value={speed}
|
||||
showHours={false}
|
||||
disabled={!canUpdate}
|
||||
onChange={updateDataFromSpeed}
|
||||
/>
|
||||
<span class="text-sm shrink-0">
|
||||
{#if $distanceUnits === 'imperial'}
|
||||
{$_('units.minutes_per_mile')}
|
||||
{:else if $distanceUnits === 'metric'}
|
||||
{$_('units.minutes_per_kilometer')}
|
||||
{:else if $distanceUnits === 'nautical'}
|
||||
{$_('units.minutes_per_nautical_mile')}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<Label for="duration" class="flex flex-row">
|
||||
<Timer size="16" class="mr-1" />
|
||||
{$_('toolbar.time.total_time')}
|
||||
</Label>
|
||||
<TimePicker
|
||||
bind:value={movingTime}
|
||||
disabled={!canUpdate}
|
||||
onChange={updateDataFromTotalTime}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Label class="flex flex-row">
|
||||
<CirclePlay size="16" class="mr-1" />
|
||||
{$_('toolbar.time.start')}
|
||||
</Label>
|
||||
<div class="flex flex-row gap-2">
|
||||
<DatePicker
|
||||
bind:value={startDate}
|
||||
disabled={!canUpdate}
|
||||
locale={get(locale) ?? 'en'}
|
||||
placeholder={$_('toolbar.time.pick_date')}
|
||||
class="w-fit grow"
|
||||
onValueChange={async () => {
|
||||
await tick();
|
||||
updateEnd();
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
step={1}
|
||||
disabled={!canUpdate}
|
||||
bind:value={startTime}
|
||||
class="w-fit"
|
||||
on:change={updateEnd}
|
||||
/>
|
||||
</div>
|
||||
<Label class="flex flex-row">
|
||||
<CircleStop size="16" class="mr-1" />
|
||||
{$_('toolbar.time.end')}
|
||||
</Label>
|
||||
<div class="flex flex-row gap-2">
|
||||
<DatePicker
|
||||
bind:value={endDate}
|
||||
disabled={!canUpdate}
|
||||
locale={get(locale) ?? 'en'}
|
||||
placeholder={$_('toolbar.time.pick_date')}
|
||||
class="w-fit grow"
|
||||
onValueChange={async () => {
|
||||
await tick();
|
||||
updateStart();
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
step={1}
|
||||
disabled={!canUpdate}
|
||||
bind:value={endTime}
|
||||
class="w-fit"
|
||||
on:change={updateStart}
|
||||
/>
|
||||
</div>
|
||||
{#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined}
|
||||
<div class="mt-0.5 flex flex-row gap-1 items-center">
|
||||
<Checkbox id="artificial-time" bind:checked={artificial} disabled={!canUpdate} />
|
||||
<Label for="artificial-time">
|
||||
{$_('toolbar.time.artificial')}
|
||||
</Label>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canUpdate}
|
||||
class="grow"
|
||||
on:click={() => {
|
||||
let effectiveSpeed = getSpeed();
|
||||
if (
|
||||
startDate === undefined ||
|
||||
startTime === undefined ||
|
||||
effectiveSpeed === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Math.abs(effectiveSpeed - $gpxStatistics.global.speed.moving) < 0.01) {
|
||||
effectiveSpeed = $gpxStatistics.global.speed.moving;
|
||||
}
|
||||
if (Math.abs(effectiveSpeed - $gpxStatistics.global.speed.moving) < 0.01) {
|
||||
effectiveSpeed = $gpxStatistics.global.speed.moving;
|
||||
}
|
||||
|
||||
let ratio = 1;
|
||||
if (
|
||||
$gpxStatistics.global.speed.moving > 0 &&
|
||||
$gpxStatistics.global.speed.moving !== effectiveSpeed
|
||||
) {
|
||||
ratio = $gpxStatistics.global.speed.moving / effectiveSpeed;
|
||||
}
|
||||
let ratio = 1;
|
||||
if (
|
||||
$gpxStatistics.global.speed.moving > 0 &&
|
||||
$gpxStatistics.global.speed.moving !== effectiveSpeed
|
||||
) {
|
||||
ratio = $gpxStatistics.global.speed.moving / effectiveSpeed;
|
||||
}
|
||||
|
||||
let item = $selection.getSelected()[0];
|
||||
let fileId = item.getFileId();
|
||||
dbUtils.applyToFile(fileId, (file) => {
|
||||
if (item instanceof ListFileItem) {
|
||||
file.changeTimestamps(getDate(startDate, startTime), effectiveSpeed, ratio);
|
||||
} else if (item instanceof ListTrackItem) {
|
||||
file.changeTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
effectiveSpeed,
|
||||
ratio,
|
||||
item.getTrackIndex()
|
||||
);
|
||||
} else if (item instanceof ListTrackSegmentItem) {
|
||||
file.changeTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
effectiveSpeed,
|
||||
ratio,
|
||||
item.getTrackIndex(),
|
||||
item.getSegmentIndex()
|
||||
);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CalendarClock size="16" class="mr-1" />
|
||||
{$_('toolbar.time.update')}
|
||||
</Button>
|
||||
<Button variant="outline" on:click={setGPXData}>
|
||||
<CircleX size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
<Help>
|
||||
{#if canUpdate}
|
||||
{$_('toolbar.time.help')}
|
||||
{:else}
|
||||
{$_('toolbar.time.help_invalid_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
let item = $selection.getSelected()[0];
|
||||
let fileId = item.getFileId();
|
||||
dbUtils.applyToFile(fileId, (file) => {
|
||||
if (item instanceof ListFileItem) {
|
||||
if (artificial) {
|
||||
file.createArtificialTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
movingTime
|
||||
);
|
||||
} else {
|
||||
file.changeTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
effectiveSpeed,
|
||||
ratio
|
||||
);
|
||||
}
|
||||
} else if (item instanceof ListTrackItem) {
|
||||
if (artificial) {
|
||||
file.createArtificialTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
movingTime,
|
||||
item.getTrackIndex()
|
||||
);
|
||||
} else {
|
||||
file.changeTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
effectiveSpeed,
|
||||
ratio,
|
||||
item.getTrackIndex()
|
||||
);
|
||||
}
|
||||
} else if (item instanceof ListTrackSegmentItem) {
|
||||
if (artificial) {
|
||||
file.createArtificialTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
movingTime,
|
||||
item.getTrackIndex(),
|
||||
item.getSegmentIndex()
|
||||
);
|
||||
} else {
|
||||
file.changeTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
effectiveSpeed,
|
||||
ratio,
|
||||
item.getTrackIndex(),
|
||||
item.getSegmentIndex()
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CalendarClock size="16" class="mr-1" />
|
||||
{$_('toolbar.time.update')}
|
||||
</Button>
|
||||
<Button variant="outline" on:click={setGPXData}>
|
||||
<CircleX size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
<Help link="./help/toolbar/time">
|
||||
{#if canUpdate}
|
||||
{$_('toolbar.time.help')}
|
||||
{:else}
|
||||
{$_('toolbar.time.help_invalid_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
div :global(input[type='time']) {
|
||||
/*
|
||||
Style copy-pasted from shadcn-svelte Input.
|
||||
Needed to use native time input to avoid a bug with 2-level bind:value.
|
||||
*/
|
||||
@apply flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
</style>
|
||||
|
@@ -9,9 +9,10 @@
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import { Waypoint } from 'gpx';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { ListWaypointItem } from '$lib/components/file-list/FileList';
|
||||
import { dbUtils, fileObservers, getFile, settings, type GPXFileWithStatistics } from '$lib/db';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -20,12 +21,19 @@
|
||||
import { map } from '$lib/stores';
|
||||
import { resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||
import { CirclePlus, CircleX, Save } from 'lucide-svelte';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
|
||||
let name: string;
|
||||
let description: string;
|
||||
let link: string;
|
||||
let longitude: number;
|
||||
let latitude: number;
|
||||
|
||||
let selectedSymbol = {
|
||||
value: '',
|
||||
label: ''
|
||||
};
|
||||
|
||||
const { verticalFileView } = settings;
|
||||
|
||||
$: canCreate = $selection.size > 0;
|
||||
@@ -60,6 +68,20 @@
|
||||
) {
|
||||
description += '\n\n' + $selectedWaypoint[0].cmt;
|
||||
}
|
||||
link = $selectedWaypoint[0].link?.attributes?.href ?? '';
|
||||
let symbol = $selectedWaypoint[0].sym ?? '';
|
||||
let symbolKey = getSymbolKey(symbol);
|
||||
if (symbolKey) {
|
||||
selectedSymbol = {
|
||||
value: symbol,
|
||||
label: $_(`gpx.symbol.${symbolKey}`)
|
||||
};
|
||||
} else {
|
||||
selectedSymbol = {
|
||||
value: symbol,
|
||||
label: ''
|
||||
};
|
||||
}
|
||||
longitude = parseFloat($selectedWaypoint[0].getLongitude().toFixed(6));
|
||||
latitude = parseFloat($selectedWaypoint[0].getLatitude().toFixed(6));
|
||||
} else {
|
||||
@@ -74,6 +96,11 @@
|
||||
function resetWaypointData() {
|
||||
name = '';
|
||||
description = '';
|
||||
link = '';
|
||||
selectedSymbol = {
|
||||
value: '',
|
||||
label: ''
|
||||
};
|
||||
longitude = 0;
|
||||
latitude = 0;
|
||||
}
|
||||
@@ -109,9 +136,11 @@
|
||||
lat: latitude,
|
||||
lon: longitude
|
||||
},
|
||||
name,
|
||||
desc: description,
|
||||
cmt: description
|
||||
name: name.length > 0 ? name : undefined,
|
||||
desc: description.length > 0 ? description : undefined,
|
||||
cmt: description.length > 0 ? description : undefined,
|
||||
link: link.length > 0 ? { attributes: { href: link } } : undefined,
|
||||
sym: selectedSymbol.value.length > 0 ? selectedSymbol.value : undefined
|
||||
},
|
||||
$selectedWaypoint
|
||||
? new ListWaypointItem($selectedWaypoint[1], $selectedWaypoint[0]._data.index)
|
||||
@@ -127,6 +156,10 @@
|
||||
longitude = e.lngLat.lng.toFixed(6);
|
||||
}
|
||||
|
||||
$: sortedSymbols = Object.entries(symbols).sort((a, b) => {
|
||||
return $_(`gpx.symbol.${a[0]}`).localeCompare($_(`gpx.symbol.${b[0]}`), $locale ?? 'en');
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
let m = get(map);
|
||||
m?.on('click', setCoordinates);
|
||||
@@ -151,6 +184,32 @@
|
||||
<Input bind:value={name} id="name" class="font-semibold h-8" />
|
||||
<Label for="description">{$_('menu.metadata.description')}</Label>
|
||||
<Textarea bind:value={description} id="description" />
|
||||
<Label for="symbol">{$_('toolbar.waypoint.icon')}</Label>
|
||||
<Select.Root bind:selected={selectedSymbol}>
|
||||
<Select.Trigger id="symbol" class="w-full h-8">
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content class="max-h-60 overflow-y-scroll">
|
||||
{#each sortedSymbols as [key, symbol]}
|
||||
<Select.Item value={symbol.value}>
|
||||
<span>
|
||||
{#if symbol.icon}
|
||||
<svelte:component
|
||||
this={symbol.icon}
|
||||
size="14"
|
||||
class="inline-block align-sub mr-0.5"
|
||||
/>
|
||||
{:else}
|
||||
<span class="w-4 inline-block" />
|
||||
{/if}
|
||||
{$_(`gpx.symbol.${key}`)}
|
||||
</span>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Label for="link">{$_('toolbar.waypoint.link')}</Label>
|
||||
<Input bind:value={link} id="link" class="h-8" />
|
||||
<div class="flex flex-row gap-2">
|
||||
<div>
|
||||
<Label for="latitude">{$_('toolbar.waypoint.latitude')}</Label>
|
||||
@@ -204,7 +263,7 @@
|
||||
<CircleX size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
<Help>
|
||||
<Help link="./help/toolbar/poi">
|
||||
{#if $selectedWaypoint || canCreate}
|
||||
{$_('toolbar.waypoint.help')}
|
||||
{:else}
|
||||
|
@@ -236,11 +236,11 @@
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="w-full flex flex-row gap-2 items-end justify-between">
|
||||
<Help>
|
||||
<Help link="./help/toolbar/routing">
|
||||
{#if !validSelection}
|
||||
<div>{$_('toolbar.routing.help_no_file')}</div>
|
||||
{$_('toolbar.routing.help_no_file')}
|
||||
{:else}
|
||||
<div>{$_('toolbar.routing.help')}</div>
|
||||
{$_('toolbar.routing.help')}
|
||||
{/if}
|
||||
</Help>
|
||||
<Button
|
||||
|
@@ -11,6 +11,7 @@ const { routing, routingProfile, privateRoads } = settings;
|
||||
export const brouterProfiles: { [key: string]: string } = {
|
||||
bike: 'Trekking-dry',
|
||||
racing_bike: 'fastbike',
|
||||
gravel_bike: 'gravel',
|
||||
mountain_bike: 'MTB',
|
||||
foot: 'Hiking-Alpine-SAC6',
|
||||
motorcycle: 'Car-FastEco',
|
||||
@@ -23,7 +24,7 @@ export const routingProfileSelectItem = writable({
|
||||
});
|
||||
|
||||
derived([routingProfile, locale, isLoading], ([profile, l, i]) => [profile, l, i]).subscribe(([profile, l, i]) => {
|
||||
if (!i && profile !== '' && profile !== get(routingProfileSelectItem).value && l !== null) {
|
||||
if (!i && profile !== '' && (profile !== get(routingProfileSelectItem).value || get(_)(`toolbar.routing.activities.${profile}`) !== get(routingProfileSelectItem).label) && l !== null) {
|
||||
routingProfileSelectItem.update((item) => {
|
||||
item.value = profile;
|
||||
item.label = get(_)(`toolbar.routing.activities.${profile}`);
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { distance, type Coordinates, TrackPoint, TrackSegment, Track } from "gpx";
|
||||
import { distance, type Coordinates, TrackPoint, TrackSegment, Track, projectedPoint } from "gpx";
|
||||
import { get, writable, type Readable } from "svelte/store";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
import { route } from "./Routing";
|
||||
@@ -10,10 +10,14 @@ import { dbUtils, type GPXFileWithStatistics } from "$lib/db";
|
||||
import { getOrderedSelection, selection } from "$lib/components/file-list/Selection";
|
||||
import { ListFileItem, ListTrackItem, ListTrackSegmentItem } from "$lib/components/file-list/FileList";
|
||||
import { currentTool, streetViewEnabled, Tool } from "$lib/stores";
|
||||
import { resetCursor, setGrabbingCursor } from "$lib/utils";
|
||||
import { getClosestLinePoint, getElevation, resetCursor, setGrabbingCursor } from "$lib/utils";
|
||||
|
||||
export const canChangeStart = writable(false);
|
||||
|
||||
function stopPropagation(e: any) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
export class RoutingControls {
|
||||
active: boolean = false;
|
||||
map: mapboxgl.Map;
|
||||
@@ -24,6 +28,7 @@ export class RoutingControls {
|
||||
popup: mapboxgl.Popup;
|
||||
popupElement: HTMLElement;
|
||||
temporaryAnchor: AnchorWithMarker;
|
||||
lastDragEvent = 0;
|
||||
fileUnsubscribe: () => void = () => { };
|
||||
unsubscribes: Function[] = [];
|
||||
|
||||
@@ -76,10 +81,10 @@ export class RoutingControls {
|
||||
add() {
|
||||
this.active = true;
|
||||
|
||||
this.map.on('zoom', this.toggleAnchorsForZoomLevelAndBoundsBinded);
|
||||
this.map.on('move', this.toggleAnchorsForZoomLevelAndBoundsBinded);
|
||||
this.map.on('click', this.appendAnchorBinded);
|
||||
this.map.on('mousemove', this.fileId, this.showTemporaryAnchorBinded);
|
||||
this.map.on('click', this.fileId, stopPropagation);
|
||||
|
||||
this.fileUnsubscribe = this.file.subscribe(this.updateControls.bind(this));
|
||||
}
|
||||
@@ -123,10 +128,10 @@ export class RoutingControls {
|
||||
for (let anchor of this.anchors) {
|
||||
anchor.marker.remove();
|
||||
}
|
||||
this.map.off('zoom', this.toggleAnchorsForZoomLevelAndBoundsBinded);
|
||||
this.map.off('move', this.toggleAnchorsForZoomLevelAndBoundsBinded);
|
||||
this.map.off('click', this.appendAnchorBinded);
|
||||
this.map.off('mousemove', this.fileId, this.showTemporaryAnchorBinded);
|
||||
this.map.off('click', this.fileId, stopPropagation);
|
||||
this.map.off('mousemove', this.updateTemporaryAnchorBinded);
|
||||
this.temporaryAnchor.marker.remove();
|
||||
|
||||
@@ -139,7 +144,7 @@ export class RoutingControls {
|
||||
|
||||
createAnchor(point: TrackPoint, segment: TrackSegment, trackIndex: number, segmentIndex: number): AnchorWithMarker {
|
||||
let element = document.createElement('div');
|
||||
element.className = `h-3 w-3 rounded-full bg-white border-2 border-black cursor-pointer`;
|
||||
element.className = `h-5 w-5 xs:h-4 xs:w-4 md:h-3 md:w-3 rounded-full bg-white border-2 border-black cursor-pointer`;
|
||||
|
||||
let marker = new mapboxgl.Marker({
|
||||
draggable: true,
|
||||
@@ -156,27 +161,37 @@ export class RoutingControls {
|
||||
inZoom: false
|
||||
};
|
||||
|
||||
let lastDragEvent = 0;
|
||||
marker.on('dragstart', (e) => {
|
||||
lastDragEvent = Date.now();
|
||||
this.lastDragEvent = Date.now();
|
||||
setGrabbingCursor();
|
||||
element.classList.remove('cursor-pointer');
|
||||
element.classList.add('cursor-grabbing');
|
||||
});
|
||||
marker.on('dragend', (e) => {
|
||||
lastDragEvent = Date.now();
|
||||
this.lastDragEvent = Date.now();
|
||||
resetCursor();
|
||||
element.classList.remove('cursor-grabbing');
|
||||
element.classList.add('cursor-pointer');
|
||||
this.moveAnchor(anchor);
|
||||
});
|
||||
marker.getElement().addEventListener('click', (e) => {
|
||||
let handleAnchorClick = this.handleClickForAnchor(anchor, marker);
|
||||
marker.getElement().addEventListener('click', handleAnchorClick);
|
||||
marker.getElement().addEventListener('contextmenu', handleAnchorClick);
|
||||
|
||||
return anchor;
|
||||
}
|
||||
|
||||
handleClickForAnchor(anchor: Anchor, marker: mapboxgl.Marker) {
|
||||
return (e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (marker === this.temporaryAnchor.marker) {
|
||||
|
||||
if (Date.now() - this.lastDragEvent < 100) { // Prevent click event during drag
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() - lastDragEvent < 100) { // Prevent click event during drag
|
||||
if (marker === this.temporaryAnchor.marker) {
|
||||
this.turnIntoPermanentAnchor();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -207,22 +222,21 @@ export class RoutingControls {
|
||||
this.popupElement.removeEventListener('delete', deleteThisAnchor);
|
||||
this.popupElement.removeEventListener('change-start', startLoopAtThisAnchor);
|
||||
});
|
||||
});
|
||||
|
||||
return anchor;
|
||||
};
|
||||
}
|
||||
|
||||
toggleAnchorsForZoomLevelAndBounds() { // Show markers only if they are in the current zoom level and bounds
|
||||
this.shownAnchors.splice(0, this.shownAnchors.length);
|
||||
|
||||
let southWest = this.map.unproject([0, this.map.getCanvas().height]);
|
||||
let northEast = this.map.unproject([this.map.getCanvas().width, 0]);
|
||||
let bounds = new mapboxgl.LngLatBounds(southWest, northEast);
|
||||
let center = this.map.getCenter();
|
||||
let bottomLeft = this.map.unproject([0, this.map.getCanvas().height]);
|
||||
let topRight = this.map.unproject([this.map.getCanvas().width, 0]);
|
||||
let diagonal = bottomLeft.distanceTo(topRight);
|
||||
|
||||
let zoom = this.map.getZoom();
|
||||
this.anchors.forEach((anchor) => {
|
||||
anchor.inZoom = anchor.point._data.zoom <= zoom;
|
||||
if (anchor.inZoom && bounds.contains(anchor.marker.getLngLat())) {
|
||||
if (anchor.inZoom && center.distanceTo(anchor.marker.getLngLat()) < diagonal) {
|
||||
anchor.marker.addTo(this.map);
|
||||
this.shownAnchors.push(anchor);
|
||||
} else {
|
||||
@@ -232,6 +246,10 @@ export class RoutingControls {
|
||||
}
|
||||
|
||||
showTemporaryAnchor(e: any) {
|
||||
if (this.temporaryAnchor.marker.getElement().classList.contains('cursor-grabbing')) { // Do not not change the source point if it is already being dragged
|
||||
return;
|
||||
}
|
||||
|
||||
if (get(streetViewEnabled)) {
|
||||
return;
|
||||
}
|
||||
@@ -318,28 +336,83 @@ export class RoutingControls {
|
||||
let file = get(this.file)?.file;
|
||||
|
||||
// Find the point closest to the temporary anchor
|
||||
let minDistance = Number.MAX_VALUE;
|
||||
let minDetails: any = { distance: Number.MAX_VALUE };
|
||||
let minAnchor = this.temporaryAnchor as Anchor;
|
||||
file?.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
|
||||
for (let point of segment.trkpt) {
|
||||
let dist = distance(point.getCoordinates(), this.temporaryAnchor.point.getCoordinates());
|
||||
if (dist < minDistance) {
|
||||
minDistance = dist;
|
||||
minAnchor = {
|
||||
point,
|
||||
segment,
|
||||
trackIndex,
|
||||
segmentIndex,
|
||||
};
|
||||
}
|
||||
let details: any = {};
|
||||
let closest = getClosestLinePoint(segment.trkpt, this.temporaryAnchor.point, details);
|
||||
if (details.distance < minDetails.distance) {
|
||||
minDetails = details;
|
||||
minAnchor = {
|
||||
point: closest,
|
||||
segment,
|
||||
trackIndex,
|
||||
segmentIndex
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (minAnchor.point._data.anchor) {
|
||||
minAnchor.point = minAnchor.point.clone();
|
||||
if (minDetails.before) {
|
||||
minAnchor.point._data.index = minAnchor.point._data.index + 0.5;
|
||||
} else {
|
||||
minAnchor.point._data.index = minAnchor.point._data.index - 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
return minAnchor;
|
||||
}
|
||||
|
||||
turnIntoPermanentAnchor() {
|
||||
let file = get(this.file)?.file;
|
||||
|
||||
// Find the point closest to the temporary anchor
|
||||
let minDetails: any = { distance: Number.MAX_VALUE };
|
||||
let minInfo = {
|
||||
point: this.temporaryAnchor.point,
|
||||
trackIndex: -1,
|
||||
segmentIndex: -1,
|
||||
trkptIndex: -1
|
||||
};
|
||||
|
||||
file?.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
|
||||
let details: any = {};
|
||||
getClosestLinePoint(segment.trkpt, this.temporaryAnchor.point, details);
|
||||
if (details.distance < minDetails.distance) {
|
||||
minDetails = details;
|
||||
let before = details.before ? details.index : details.index - 1;
|
||||
|
||||
let projectedPt = projectedPoint(segment.trkpt[before], segment.trkpt[before + 1], this.temporaryAnchor.point);
|
||||
let ratio = distance(segment.trkpt[before], projectedPt) / distance(segment.trkpt[before], segment.trkpt[before + 1]);
|
||||
|
||||
let point = segment.trkpt[before].clone();
|
||||
point.setCoordinates(projectedPt);
|
||||
point.ele = (1 - ratio) * (segment.trkpt[before].ele ?? 0) + ratio * (segment.trkpt[before + 1].ele ?? 0);
|
||||
point.time = (segment.trkpt[before].time && segment.trkpt[before + 1].time) ? new Date((1 - ratio) * segment.trkpt[before].time.getTime() + ratio * segment.trkpt[before + 1].time.getTime()) : undefined;
|
||||
point._data = {
|
||||
anchor: true,
|
||||
zoom: 0
|
||||
};
|
||||
|
||||
minInfo = {
|
||||
point,
|
||||
trackIndex,
|
||||
segmentIndex,
|
||||
trkptIndex: before + 1
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (minInfo.trackIndex !== -1) {
|
||||
dbUtils.applyToFile(this.fileId, (file) => file.replaceTrackPoints(minInfo.trackIndex, minInfo.segmentIndex, minInfo.trkptIndex, minInfo.trkptIndex - 1, [minInfo.point]));
|
||||
}
|
||||
}
|
||||
|
||||
getDeleteAnchor(anchor: Anchor) {
|
||||
return () => this.deleteAnchor(anchor);
|
||||
}
|
||||
@@ -503,6 +576,9 @@ export class RoutingControls {
|
||||
if (anchors[0].point._data.index === 0) { // First anchor is the first point of the segment
|
||||
anchors[0].point = response[0]; // replace the first anchor
|
||||
anchors[0].point._data.index = 0;
|
||||
} else if (anchors[0].point._data.index === segment.trkpt.length - 1 && distance(anchors[0].point.getCoordinates(), response[0].getCoordinates()) < 1) { // First anchor is the last point of the segment, and the new point is close enough
|
||||
anchors[0].point = response[0]; // replace the first anchor
|
||||
anchors[0].point._data.index = segment.trkpt.length - 1;
|
||||
} else {
|
||||
anchors[0].point = anchors[0].point.clone(); // Clone the anchor to assign new properties
|
||||
response.splice(0, 0, anchors[0].point); // Insert it in the response to keep it
|
||||
@@ -519,16 +595,7 @@ export class RoutingControls {
|
||||
for (let i = 1; i < anchors.length - 1; i++) {
|
||||
// Find the closest point to the intermediate anchor
|
||||
// and transfer the marker to that point
|
||||
let minDistance = Number.MAX_VALUE;
|
||||
let minIndex = 0;
|
||||
for (let j = 1; j < response.length - 1; j++) {
|
||||
let dist = distance(response[j].getCoordinates(), targetCoordinates[i]);
|
||||
if (dist < minDistance) {
|
||||
minDistance = dist;
|
||||
minIndex = j;
|
||||
}
|
||||
}
|
||||
anchors[i].point = response[minIndex];
|
||||
anchors[i].point = getClosestLinePoint(response.slice(1, - 1), targetCoordinates[i]);
|
||||
}
|
||||
|
||||
anchors.forEach((anchor) => {
|
||||
@@ -553,6 +620,10 @@ export class RoutingControls {
|
||||
let remainingTime = stats.global.time.moving - (stats.local.time.moving[anchors[anchors.length - 1].point._data.index] - stats.local.time.moving[anchors[0].point._data.index]);
|
||||
let replacingTime = newTime - remainingTime;
|
||||
|
||||
if (replacingTime <= 0) { // Fallback to simple time difference
|
||||
replacingTime = stats.local.time.total[anchors[anchors.length - 1].point._data.index] - stats.local.time.total[anchors[0].point._data.index];
|
||||
}
|
||||
|
||||
speed = replacingDistance / replacingTime * 3600;
|
||||
|
||||
if (startTime === undefined) { // Replacing the first point
|
||||
|
@@ -10,7 +10,7 @@ export function getZoomLevelForDistance(latitude: number, distance?: number): nu
|
||||
const rad = Math.PI / 180;
|
||||
const lat = latitude * rad;
|
||||
|
||||
return Math.min(20, Math.max(0, Math.floor(Math.log2((earthRadius * Math.cos(lat)) / distance))));
|
||||
return Math.min(22, Math.max(0, Math.log2((earthRadius * Math.cos(lat)) / distance)));
|
||||
}
|
||||
|
||||
export function updateAnchorPoints(file: GPXFile) {
|
||||
@@ -34,7 +34,7 @@ export function updateAnchorPoints(file: GPXFile) {
|
||||
|
||||
function computeAnchorPoints(segment: TrackSegment) {
|
||||
let points = segment.trkpt;
|
||||
let anchors = ramerDouglasPeucker(points);
|
||||
let anchors = ramerDouglasPeucker(points, 1);
|
||||
anchors.forEach((anchor) => {
|
||||
let point = anchor.point;
|
||||
point._data.anchor = true;
|
||||
|
@@ -15,21 +15,34 @@
|
||||
import { Slider } from '$lib/components/ui/slider';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { gpxStatistics, slicedGPXStatistics, splitAs } from '$lib/stores';
|
||||
import { gpxStatistics, map, slicedGPXStatistics, splitAs } from '$lib/stores';
|
||||
import { get } from 'svelte/store';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
import { Crop } from 'lucide-svelte';
|
||||
import { dbUtils } from '$lib/db';
|
||||
import { SplitControls } from './SplitControls';
|
||||
|
||||
let splitControls: SplitControls | undefined = undefined;
|
||||
let canCrop = false;
|
||||
|
||||
$: if ($map) {
|
||||
if (splitControls) {
|
||||
splitControls.destroy();
|
||||
}
|
||||
splitControls = new SplitControls($map);
|
||||
}
|
||||
|
||||
$: validSelection =
|
||||
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) &&
|
||||
$gpxStatistics.local.points.length > 0;
|
||||
|
||||
let maxSliderValue = 100;
|
||||
let sliderValues = [0, 100];
|
||||
let maxSliderValue = 1;
|
||||
let sliderValues = [0, 1];
|
||||
|
||||
$: canCrop = sliderValues[0] != 0 || sliderValues[1] != maxSliderValue;
|
||||
function updateCanCrop() {
|
||||
canCrop = sliderValues[0] != 0 || sliderValues[1] != maxSliderValue;
|
||||
}
|
||||
|
||||
function updateSlicedGPXStatistics() {
|
||||
if (validSelection && canCrop) {
|
||||
@@ -53,7 +66,7 @@
|
||||
if (validSelection && $gpxStatistics.local.points.length > 0) {
|
||||
maxSliderValue = $gpxStatistics.local.points.length - 1;
|
||||
} else {
|
||||
maxSliderValue = 100;
|
||||
maxSliderValue = 1;
|
||||
}
|
||||
await tick();
|
||||
sliderValues = [0, maxSliderValue];
|
||||
@@ -64,6 +77,7 @@
|
||||
}
|
||||
|
||||
$: if (sliderValues) {
|
||||
updateCanCrop();
|
||||
updateSlicedGPXStatistics();
|
||||
}
|
||||
|
||||
@@ -72,6 +86,7 @@
|
||||
($slicedGPXStatistics[1] !== sliderValues[0] || $slicedGPXStatistics[2] !== sliderValues[1])
|
||||
) {
|
||||
updateSliderValues();
|
||||
updateCanCrop();
|
||||
}
|
||||
|
||||
const splitTypes = [
|
||||
@@ -86,6 +101,10 @@
|
||||
|
||||
onDestroy(() => {
|
||||
$slicedGPXStatistics = undefined;
|
||||
if (splitControls) {
|
||||
splitControls.destroy();
|
||||
splitControls = undefined;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -116,7 +135,7 @@
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Label>
|
||||
<Help>
|
||||
<Help link="./help/toolbar/scissors">
|
||||
{#if validSelection}
|
||||
{$_('toolbar.scissors.help')}
|
||||
{:else}
|
@@ -0,0 +1,163 @@
|
||||
import { TrackPoint, TrackSegment } from "gpx";
|
||||
import { get } from "svelte/store";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
import { dbUtils, getFile } from "$lib/db";
|
||||
import { applyToOrderedSelectedItemsFromFile, selection } from "$lib/components/file-list/Selection";
|
||||
import { ListTrackSegmentItem } from "$lib/components/file-list/FileList";
|
||||
import { currentTool, gpxStatistics, Tool } from "$lib/stores";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { Scissors } from "lucide-static";
|
||||
|
||||
export class SplitControls {
|
||||
active: boolean = false;
|
||||
map: mapboxgl.Map;
|
||||
controls: ControlWithMarker[] = [];
|
||||
shownControls: ControlWithMarker[] = [];
|
||||
unsubscribes: Function[] = [];
|
||||
|
||||
toggleControlsForZoomLevelAndBoundsBinded: () => void = this.toggleControlsForZoomLevelAndBounds.bind(this);
|
||||
|
||||
constructor(map: mapboxgl.Map) {
|
||||
this.map = map;
|
||||
|
||||
this.unsubscribes.push(selection.subscribe(this.addIfNeeded.bind(this)));
|
||||
this.unsubscribes.push(gpxStatistics.subscribe(this.addIfNeeded.bind(this)));
|
||||
this.unsubscribes.push(currentTool.subscribe(this.addIfNeeded.bind(this)));
|
||||
}
|
||||
|
||||
addIfNeeded() {
|
||||
let scissors = get(currentTool) === Tool.SCISSORS;
|
||||
if (!scissors) {
|
||||
if (this.active) {
|
||||
this.remove();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.active) {
|
||||
this.updateControls();
|
||||
} else {
|
||||
this.add();
|
||||
}
|
||||
}
|
||||
|
||||
add() {
|
||||
this.active = true;
|
||||
|
||||
this.map.on('zoom', this.toggleControlsForZoomLevelAndBoundsBinded);
|
||||
this.map.on('move', this.toggleControlsForZoomLevelAndBoundsBinded);
|
||||
}
|
||||
|
||||
updateControls() { // Update the markers when the files change
|
||||
|
||||
let controlIndex = 0;
|
||||
|
||||
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||
let file = getFile(fileId);
|
||||
|
||||
if (file) {
|
||||
file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||
if (get(selection).hasAnyParent(new ListTrackSegmentItem(fileId, trackIndex, segmentIndex))) {
|
||||
for (let point of segment.trkpt.slice(1, -1)) { // Update the existing controls (could be improved by matching the existing controls with the new ones?)
|
||||
if (point._data.anchor) {
|
||||
if (controlIndex < this.controls.length) {
|
||||
this.controls[controlIndex].point = point;
|
||||
this.controls[controlIndex].segment = segment;
|
||||
this.controls[controlIndex].trackIndex = trackIndex;
|
||||
this.controls[controlIndex].segmentIndex = segmentIndex;
|
||||
this.controls[controlIndex].marker.setLngLat(point.getCoordinates());
|
||||
} else {
|
||||
this.controls.push(this.createControl(point, segment, fileId, trackIndex, segmentIndex));
|
||||
}
|
||||
controlIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
}, false);
|
||||
|
||||
while (controlIndex < this.controls.length) { // Remove the extra controls
|
||||
this.controls.pop()?.marker.remove();
|
||||
}
|
||||
|
||||
this.toggleControlsForZoomLevelAndBounds();
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.active = false;
|
||||
|
||||
for (let control of this.controls) {
|
||||
control.marker.remove();
|
||||
}
|
||||
this.map.off('zoom', this.toggleControlsForZoomLevelAndBoundsBinded);
|
||||
this.map.off('move', this.toggleControlsForZoomLevelAndBoundsBinded);
|
||||
}
|
||||
|
||||
toggleControlsForZoomLevelAndBounds() { // Show markers only if they are in the current zoom level and bounds
|
||||
this.shownControls.splice(0, this.shownControls.length);
|
||||
|
||||
let southWest = this.map.unproject([0, this.map.getCanvas().height]);
|
||||
let northEast = this.map.unproject([this.map.getCanvas().width, 0]);
|
||||
let bounds = new mapboxgl.LngLatBounds(southWest, northEast);
|
||||
|
||||
let zoom = this.map.getZoom();
|
||||
this.controls.forEach((control) => {
|
||||
control.inZoom = control.point._data.zoom <= zoom;
|
||||
if (control.inZoom && bounds.contains(control.marker.getLngLat())) {
|
||||
control.marker.addTo(this.map);
|
||||
this.shownControls.push(control);
|
||||
} else {
|
||||
control.marker.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createControl(point: TrackPoint, segment: TrackSegment, fileId: string, trackIndex: number, segmentIndex: number): ControlWithMarker {
|
||||
let element = document.createElement('div');
|
||||
element.className = `h-6 w-6 p-0.5 rounded-full bg-white border-2 border-black cursor-pointer`;
|
||||
element.innerHTML = Scissors.replace('width="24"', "").replace('height="24"', "").replace('stroke="currentColor"', 'stroke="black"');
|
||||
|
||||
let marker = new mapboxgl.Marker({
|
||||
draggable: true,
|
||||
className: 'z-10',
|
||||
element
|
||||
}).setLngLat(point.getCoordinates());
|
||||
|
||||
let control = {
|
||||
point,
|
||||
segment,
|
||||
fileId,
|
||||
trackIndex,
|
||||
segmentIndex,
|
||||
marker,
|
||||
inZoom: false
|
||||
};
|
||||
|
||||
marker.getElement().addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
dbUtils.split(fileId, trackIndex, segmentIndex, point.getCoordinates(), point._data.index);
|
||||
});
|
||||
|
||||
return control;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.remove();
|
||||
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
}
|
||||
}
|
||||
|
||||
type Control = {
|
||||
segment: TrackSegment;
|
||||
fileId: string;
|
||||
trackIndex: number;
|
||||
segmentIndex: number;
|
||||
point: TrackPoint;
|
||||
};
|
||||
|
||||
type ControlWithMarker = Control & {
|
||||
marker: mapboxgl.Marker;
|
||||
inZoom: boolean;
|
||||
};
|
@@ -4,13 +4,14 @@
|
||||
export let showHours = true;
|
||||
export let value: number | undefined = undefined;
|
||||
export let disabled: boolean = false;
|
||||
export let onChange = () => {};
|
||||
|
||||
let hours: string | number = '--';
|
||||
let minutes: string | number = '--';
|
||||
let seconds: string | number = '--';
|
||||
|
||||
function maybeParseInt(value: string | number): number {
|
||||
if (value === '--') {
|
||||
if (value === '--' || value === '') {
|
||||
return 0;
|
||||
}
|
||||
return typeof value === 'string' ? parseInt(value) : value;
|
||||
@@ -84,12 +85,12 @@
|
||||
} else {
|
||||
hours = 0;
|
||||
}
|
||||
onChange();
|
||||
}}
|
||||
on:keypress={onKeyPress}
|
||||
on:focusin={() => {
|
||||
countKeyPress = 0;
|
||||
}}
|
||||
on:change
|
||||
/>
|
||||
<span class="text-sm">:</span>
|
||||
{/if}
|
||||
@@ -110,12 +111,12 @@
|
||||
minutes = 0;
|
||||
}
|
||||
minutes = minutes.toString().padStart(showHours ? 2 : 1, '0');
|
||||
onChange();
|
||||
}}
|
||||
on:keypress={onKeyPress}
|
||||
on:focusin={() => {
|
||||
countKeyPress = 0;
|
||||
}}
|
||||
on:change
|
||||
/>
|
||||
<span class="text-sm">:</span>
|
||||
<TimeComponentInput
|
||||
@@ -135,12 +136,12 @@
|
||||
seconds = 0;
|
||||
}
|
||||
seconds = seconds.toString().padStart(2, '0');
|
||||
onChange();
|
||||
}}
|
||||
on:keypress={onKeyPress}
|
||||
on:focusin={() => {
|
||||
countKeyPress = 0;
|
||||
}}
|
||||
on:change
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
Reference in New Issue
Block a user