prettier config + format all, closes #175

This commit is contained in:
vcoppe
2025-02-02 11:17:22 +01:00
parent 01cfd448f0
commit 0b457f9a1e
157 changed files with 17194 additions and 29365 deletions

View File

@@ -1,28 +1,28 @@
export const surfaceColors: { [key: string]: string } = {
"missing": "#d1d1d1",
"paved": "#8c8c8c",
"unpaved": "#6b443a",
"asphalt": "#8c8c8c",
"concrete": "#8c8c8c",
"cobblestone": "#ffd991",
"paving_stones": "#8c8c8c",
"sett": "#ffd991",
"metal": "#8c8c8c",
"wood": "#6b443a",
"compacted": "#ffffa8",
"fine_gravel": "#ffffa8",
"gravel": "#ffffa8",
"pebblestone": "#ffffa8",
"rock": "#ffd991",
"dirt": "#ffffa8",
"ground": "#6b443a",
"earth": "#6b443a",
"mud": "#6b443a",
"sand": "#ffffc4",
"grass": "#61b55c",
"grass_paver": "#61b55c",
"clay": "#6b443a",
"stone": "#ffd991",
missing: '#d1d1d1',
paved: '#8c8c8c',
unpaved: '#6b443a',
asphalt: '#8c8c8c',
concrete: '#8c8c8c',
cobblestone: '#ffd991',
paving_stones: '#8c8c8c',
sett: '#ffd991',
metal: '#8c8c8c',
wood: '#6b443a',
compacted: '#ffffa8',
fine_gravel: '#ffffa8',
gravel: '#ffffa8',
pebblestone: '#ffffa8',
rock: '#ffd991',
dirt: '#ffffa8',
ground: '#6b443a',
earth: '#6b443a',
mud: '#6b443a',
sand: '#ffffc4',
grass: '#61b55c',
grass_paver: '#61b55c',
clay: '#6b443a',
stone: '#ffd991',
};
export function getSurfaceColor(surface: string): string {
@@ -30,66 +30,72 @@ export function getSurfaceColor(surface: string): string {
}
export const highwayColors: { [key: string]: string } = {
"missing": "#d1d1d1",
"motorway": "#ff4d33",
"motorway_link": "#ff4d33",
"trunk": "#ff5e4d",
"trunk_link": "#ff947f",
"primary": "#ff6e5c",
"primary_link": "#ff6e5c",
"secondary": "#ff8d7b",
"secondary_link": "#ff8d7b",
"tertiary": "#ffd75f",
"tertiary_link": "#ffd75f",
"unclassified": "#f1f2a5",
"road": "#f1f2a5",
"residential": "#73b2ff",
"living_street": "#73b2ff",
"service": "#9c9cd9",
"track": "#a8e381",
"footway": "#a8e381",
"path": "#a8e381",
"pedestrian": "#a8e381",
"cycleway": "#9de2ff",
"construction": "#e09a4a",
"bridleway": "#946f43",
"raceway": "#ff0000",
"rest_area": "#9c9cd9",
"services": "#9c9cd9",
"corridor": "#474747",
"elevator": "#474747",
"steps": "#474747",
"bus_stop": "#8545a3",
"busway": "#8545a3",
"via_ferrata": "#474747"
missing: '#d1d1d1',
motorway: '#ff4d33',
motorway_link: '#ff4d33',
trunk: '#ff5e4d',
trunk_link: '#ff947f',
primary: '#ff6e5c',
primary_link: '#ff6e5c',
secondary: '#ff8d7b',
secondary_link: '#ff8d7b',
tertiary: '#ffd75f',
tertiary_link: '#ffd75f',
unclassified: '#f1f2a5',
road: '#f1f2a5',
residential: '#73b2ff',
living_street: '#73b2ff',
service: '#9c9cd9',
track: '#a8e381',
footway: '#a8e381',
path: '#a8e381',
pedestrian: '#a8e381',
cycleway: '#9de2ff',
construction: '#e09a4a',
bridleway: '#946f43',
raceway: '#ff0000',
rest_area: '#9c9cd9',
services: '#9c9cd9',
corridor: '#474747',
elevator: '#474747',
steps: '#474747',
bus_stop: '#8545a3',
busway: '#8545a3',
via_ferrata: '#474747',
};
export const sacScaleColors: { [key: string]: string } = {
"hiking": "#007700",
"mountain_hiking": "#1843ad",
"demanding_mountain_hiking": "#ffff00",
"alpine_hiking": "#ff9233",
"demanding_alpine_hiking": "#ff0000",
"difficult_alpine_hiking": "#000000",
hiking: '#007700',
mountain_hiking: '#1843ad',
demanding_mountain_hiking: '#ffff00',
alpine_hiking: '#ff9233',
demanding_alpine_hiking: '#ff0000',
difficult_alpine_hiking: '#000000',
};
export const mtbScaleColors: { [key: string]: string } = {
"0-": "#007700",
"0": "#007700",
"0+": "#007700",
"1-": "#1843ad",
"1": "#1843ad",
"1+": "#1843ad",
"2-": "#ffff00",
"2": "#ffff00",
"2+": "#ffff00",
"3": "#ff0000",
"4": "#00ff00",
"5": "#000000",
"6": "#b105eb",
'0-': '#007700',
'0': '#007700',
'0+': '#007700',
'1-': '#1843ad',
'1': '#1843ad',
'1+': '#1843ad',
'2-': '#ffff00',
'2': '#ffff00',
'2+': '#ffff00',
'3': '#ff0000',
'4': '#00ff00',
'5': '#000000',
'6': '#b105eb',
};
function createPattern(backgroundColor: string, sacScaleColor: string | undefined, mtbScaleColor: string | undefined, size: number = 16, lineWidth: number = 4) {
function createPattern(
backgroundColor: string,
sacScaleColor: string | undefined,
mtbScaleColor: string | undefined,
size: number = 16,
lineWidth: number = 4
) {
let canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
@@ -104,11 +110,11 @@ function createPattern(backgroundColor: string, sacScaleColor: string | undefine
if (sacScaleColor) {
ctx.strokeStyle = sacScaleColor;
ctx.beginPath();
ctx.moveTo(halfSize - halfLineWidth, - halfLineWidth);
ctx.moveTo(halfSize - halfLineWidth, -halfLineWidth);
ctx.lineTo(size + halfLineWidth, halfSize + halfLineWidth);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(- halfLineWidth, halfSize - halfLineWidth);
ctx.moveTo(-halfLineWidth, halfSize - halfLineWidth);
ctx.lineTo(halfSize + halfLineWidth, size + halfLineWidth);
ctx.stroke();
}
@@ -119,8 +125,8 @@ function createPattern(backgroundColor: string, sacScaleColor: string | undefine
ctx.lineTo(size + halfLineWidth, halfSize - halfLineWidth);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(- halfLineWidth, halfSize + halfLineWidth);
ctx.lineTo(halfSize + halfLineWidth, - halfLineWidth);
ctx.moveTo(-halfLineWidth, halfSize + halfLineWidth);
ctx.lineTo(halfSize + halfLineWidth, -halfLineWidth);
ctx.stroke();
}
}
@@ -128,12 +134,16 @@ function createPattern(backgroundColor: string, sacScaleColor: string | undefine
}
const patterns: Record<string, string | CanvasPattern> = {};
export function getHighwayColor(highway: string, sacScale: string | undefined, mtbScale: string | undefined) {
export function getHighwayColor(
highway: string,
sacScale: string | undefined,
mtbScale: string | undefined
) {
let backgroundColor = highwayColors[highway] ? highwayColors[highway] : highwayColors.missing;
let sacScaleColor = sacScale ? sacScaleColors[sacScale] : undefined;
let mtbScaleColor = mtbScale ? mtbScaleColors[mtbScale] : undefined;
if (sacScale || mtbScale) {
let patternId = `${backgroundColor}-${[sacScale, mtbScale].filter(x => x).join('-')}`;
let patternId = `${backgroundColor}-${[sacScale, mtbScale].filter((x) => x).join('-')}`;
if (!patterns[patternId]) {
patterns[patternId] = createPattern(backgroundColor, sacScaleColor, mtbScaleColor);
}
@@ -158,4 +168,4 @@ export function getSlopeColor(slope: number): string {
let lightness = 90 - Math.abs(v) * 70;
return `hsl(${hue},70%,${lightness}%)`;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,67 @@
import { Landmark, Icon, Shell, Bike, Building, Tent, Car, Wrench, ShoppingBasket, Droplet, DoorOpen, Trees, Fuel, Home, Info, TreeDeciduous, CircleParking, Cross, Utensils, Construction, BrickWall, ShowerHead, Mountain, Phone, TrainFront, Bed, Binoculars, TriangleAlert, Anchor, Toilet } from "lucide-svelte";
import { Landmark as LandmarkSvg, Shell as ShellSvg, Bike as BikeSvg, Building as BuildingSvg, Tent as TentSvg, Car as CarSvg, Wrench as WrenchSvg, ShoppingBasket as ShoppingBasketSvg, Droplet as DropletSvg, DoorOpen as DoorOpenSvg, Trees as TreesSvg, Fuel as FuelSvg, Home as HomeSvg, Info as InfoSvg, TreeDeciduous as TreeDeciduousSvg, CircleParking as CircleParkingSvg, Cross as CrossSvg, Utensils as UtensilsSvg, Construction as ConstructionSvg, BrickWall as BrickWallSvg, ShowerHead as ShowerHeadSvg, Mountain as MountainSvg, Phone as PhoneSvg, TrainFront as TrainFrontSvg, Bed as BedSvg, Binoculars as BinocularsSvg, TriangleAlert as TriangleAlertSvg, Anchor as AnchorSvg, Toilet as ToiletSvg } from "lucide-static";
import type { ComponentType } from "svelte";
import {
Landmark,
Icon,
Shell,
Bike,
Building,
Tent,
Car,
Wrench,
ShoppingBasket,
Droplet,
DoorOpen,
Trees,
Fuel,
Home,
Info,
TreeDeciduous,
CircleParking,
Cross,
Utensils,
Construction,
BrickWall,
ShowerHead,
Mountain,
Phone,
TrainFront,
Bed,
Binoculars,
TriangleAlert,
Anchor,
Toilet,
} from 'lucide-svelte';
import {
Landmark as LandmarkSvg,
Shell as ShellSvg,
Bike as BikeSvg,
Building as BuildingSvg,
Tent as TentSvg,
Car as CarSvg,
Wrench as WrenchSvg,
ShoppingBasket as ShoppingBasketSvg,
Droplet as DropletSvg,
DoorOpen as DoorOpenSvg,
Trees as TreesSvg,
Fuel as FuelSvg,
Home as HomeSvg,
Info as InfoSvg,
TreeDeciduous as TreeDeciduousSvg,
CircleParking as CircleParkingSvg,
Cross as CrossSvg,
Utensils as UtensilsSvg,
Construction as ConstructionSvg,
BrickWall as BrickWallSvg,
ShowerHead as ShowerHeadSvg,
Mountain as MountainSvg,
Phone as PhoneSvg,
TrainFront as TrainFrontSvg,
Bed as BedSvg,
Binoculars as BinocularsSvg,
TriangleAlert as TriangleAlertSvg,
Anchor as AnchorSvg,
Toilet as ToiletSvg,
} from 'lucide-static';
import type { ComponentType } from 'svelte';
export type Symbol = {
value: string;
@@ -20,16 +81,28 @@ export const symbols: { [key: string]: Symbol } = {
campground: { value: 'Campground', icon: Tent, iconSvg: TentSvg },
car: { value: 'Car', icon: Car, iconSvg: CarSvg },
car_repair: { value: 'Car Repair', icon: Wrench, iconSvg: WrenchSvg },
convenience_store: { value: 'Convenience Store', icon: ShoppingBasket, iconSvg: ShoppingBasketSvg },
convenience_store: {
value: 'Convenience Store',
icon: ShoppingBasket,
iconSvg: ShoppingBasketSvg,
},
crossing: { value: 'Crossing' },
department_store: { value: 'Department Store', icon: ShoppingBasket, iconSvg: ShoppingBasketSvg },
department_store: {
value: 'Department Store',
icon: ShoppingBasket,
iconSvg: ShoppingBasketSvg,
},
drinking_water: { value: 'Drinking Water', icon: Droplet, iconSvg: DropletSvg },
exit: { value: 'Exit', icon: DoorOpen, iconSvg: DoorOpenSvg },
lodge: { value: 'Lodge', icon: Home, iconSvg: HomeSvg },
lodging: { value: 'Lodging', icon: Bed, iconSvg: BedSvg },
forest: { value: 'Forest', icon: Trees, iconSvg: TreesSvg },
gas_station: { value: 'Gas Station', icon: Fuel, iconSvg: FuelSvg },
ground_transportation: { value: 'Ground Transportation', icon: TrainFront, iconSvg: TrainFrontSvg },
ground_transportation: {
value: 'Ground Transportation',
icon: TrainFront,
iconSvg: TrainFrontSvg,
},
hotel: { value: 'Hotel', icon: Bed, iconSvg: BedSvg },
house: { value: 'House', icon: Home, iconSvg: HomeSvg },
information: { value: 'Information', icon: Info, iconSvg: InfoSvg },
@@ -55,6 +128,6 @@ export function getSymbolKey(value: string | undefined): string | undefined {
if (value === undefined) {
return undefined;
} else {
return Object.keys(symbols).find(key => symbols[key].value === value);
return Object.keys(symbols).find((key) => symbols[key].value === value);
}
}
}

View File

@@ -1,60 +1,60 @@
<script lang="ts">
import docsearch from '@docsearch/js';
import '@docsearch/css';
import { onMount } from 'svelte';
import { _, locale, waitLocale } from 'svelte-i18n';
import docsearch from '@docsearch/js';
import '@docsearch/css';
import { onMount } from 'svelte';
import { _, locale, waitLocale } from 'svelte-i18n';
let mounted = false;
let mounted = false;
function initDocsearch() {
docsearch({
appId: '21XLD94PE3',
apiKey: 'd2c1ed6cb0ed12adb2bd84eb2a38494d',
indexName: 'gpx',
container: '#docsearch',
searchParameters: {
facetFilters: ['lang:' + ($locale ?? 'en')]
},
placeholder: $_('docs.search.search'),
disableUserPersonalization: true,
translations: {
button: {
buttonText: $_('docs.search.search'),
buttonAriaLabel: $_('docs.search.search')
},
modal: {
searchBox: {
resetButtonTitle: $_('docs.search.clear'),
resetButtonAriaLabel: $_('docs.search.clear'),
cancelButtonText: $_('docs.search.cancel'),
cancelButtonAriaLabel: $_('docs.search.cancel'),
searchInputLabel: $_('docs.search.search')
},
footer: {
selectText: $_('docs.search.to_select'),
navigateText: $_('docs.search.to_navigate'),
closeText: $_('docs.search.to_close')
},
noResultsScreen: {
noResultsText: $_('docs.search.no_results'),
suggestedQueryText: $_('docs.search.no_results_suggestion')
}
}
}
});
}
function initDocsearch() {
docsearch({
appId: '21XLD94PE3',
apiKey: 'd2c1ed6cb0ed12adb2bd84eb2a38494d',
indexName: 'gpx',
container: '#docsearch',
searchParameters: {
facetFilters: ['lang:' + ($locale ?? 'en')],
},
placeholder: $_('docs.search.search'),
disableUserPersonalization: true,
translations: {
button: {
buttonText: $_('docs.search.search'),
buttonAriaLabel: $_('docs.search.search'),
},
modal: {
searchBox: {
resetButtonTitle: $_('docs.search.clear'),
resetButtonAriaLabel: $_('docs.search.clear'),
cancelButtonText: $_('docs.search.cancel'),
cancelButtonAriaLabel: $_('docs.search.cancel'),
searchInputLabel: $_('docs.search.search'),
},
footer: {
selectText: $_('docs.search.to_select'),
navigateText: $_('docs.search.to_navigate'),
closeText: $_('docs.search.to_close'),
},
noResultsScreen: {
noResultsText: $_('docs.search.no_results'),
suggestedQueryText: $_('docs.search.no_results_suggestion'),
},
},
},
});
}
onMount(() => {
mounted = true;
});
onMount(() => {
mounted = true;
});
$: if (mounted && $locale) {
waitLocale().then(initDocsearch);
}
$: if (mounted && $locale) {
waitLocale().then(initDocsearch);
}
</script>
<svelte:head>
<link rel="preconnect" href="https://21XLD94PE3-dsn.algolia.net" crossorigin />
<link rel="preconnect" href="https://21XLD94PE3-dsn.algolia.net" crossorigin />
</svelte:head>
<div id="docsearch" {...$$restProps}></div>

View File

@@ -1,28 +1,28 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import type { Builder } from 'bits-ui';
import { Button } from '$lib/components/ui/button/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import type { Builder } from 'bits-ui';
export let variant:
| 'default'
| 'secondary'
| 'link'
| 'destructive'
| 'outline'
| 'ghost'
| undefined = 'default';
export let label: string;
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
export let builders: Builder[] = [];
export let variant:
| 'default'
| 'secondary'
| 'link'
| 'destructive'
| 'outline'
| 'ghost'
| undefined = 'default';
export let label: string;
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
export let builders: Builder[] = [];
</script>
<Tooltip.Root>
<Tooltip.Trigger asChild let:builder>
<Button builders={[...builders, builder]} {variant} {...$$restProps} on:click>
<slot />
</Button>
</Tooltip.Trigger>
<Tooltip.Content {side}>
<span>{label}</span>
</Tooltip.Content>
<Tooltip.Trigger asChild let:builder>
<Button builders={[...builders, builder]} {variant} {...$$restProps} on:click>
<slot />
</Button>
</Tooltip.Trigger>
<Tooltip.Content {side}>
<span>{label}</span>
</Tooltip.Content>
</Tooltip.Root>

View File

@@ -1,18 +1,18 @@
<script lang="ts">
import { map } from '$lib/stores';
import { trackpointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
import { TrackPoint } from 'gpx';
import { map } from '$lib/stores';
import { trackpointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
import { TrackPoint } from 'gpx';
$: if ($map) {
$map.on('contextmenu', (e) => {
trackpointPopup?.setItem({
item: new TrackPoint({
attributes: {
lat: e.lngLat.lat,
lon: e.lngLat.lng
}
})
});
});
}
$: if ($map) {
$map.on('contextmenu', (e) => {
trackpointPopup?.setItem({
item: new TrackPoint({
attributes: {
lat: e.lngLat.lat,
lon: e.lngLat.lng,
},
}),
});
});
}
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -1,186 +1,190 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Label } from '$lib/components/ui/label';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Separator } from '$lib/components/ui/separator';
import { Dialog } from 'bits-ui';
import {
currentTool,
exportAllFiles,
exportSelectedFiles,
ExportState,
exportState,
gpxStatistics
} from '$lib/stores';
import { fileObservers } from '$lib/db';
import {
Download,
Zap,
Earth,
HeartPulse,
Orbit,
Thermometer,
SquareActivity
} from 'lucide-svelte';
import { _ } from 'svelte-i18n';
import { selection } from './file-list/Selection';
import { get } from 'svelte/store';
import { GPXStatistics } from 'gpx';
import { ListRootItem } from './file-list/FileList';
import { Button } from '$lib/components/ui/button';
import { Label } from '$lib/components/ui/label';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Separator } from '$lib/components/ui/separator';
import { Dialog } from 'bits-ui';
import {
currentTool,
exportAllFiles,
exportSelectedFiles,
ExportState,
exportState,
gpxStatistics,
} from '$lib/stores';
import { fileObservers } from '$lib/db';
import {
Download,
Zap,
Earth,
HeartPulse,
Orbit,
Thermometer,
SquareActivity,
} from 'lucide-svelte';
import { _ } from 'svelte-i18n';
import { selection } from './file-list/Selection';
import { get } from 'svelte/store';
import { GPXStatistics } from 'gpx';
import { ListRootItem } from './file-list/FileList';
let open = false;
let exportOptions: Record<string, boolean> = {
time: true,
hr: true,
cad: true,
atemp: true,
power: true,
extensions: true
};
let hide: Record<string, boolean> = {
time: false,
hr: false,
cad: false,
atemp: false,
power: false,
extensions: false
};
let open = false;
let exportOptions: Record<string, boolean> = {
time: true,
hr: true,
cad: true,
atemp: true,
power: true,
extensions: true,
};
let hide: Record<string, boolean> = {
time: false,
hr: false,
cad: false,
atemp: false,
power: false,
extensions: false,
};
$: if ($exportState !== ExportState.NONE) {
open = true;
$currentTool = null;
$: if ($exportState !== ExportState.NONE) {
open = true;
$currentTool = null;
let statistics = $gpxStatistics;
if ($exportState === ExportState.ALL) {
statistics = Array.from($fileObservers.values())
.map((file) => get(file)?.statistics)
.reduce((acc, cur) => {
if (cur !== undefined) {
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()));
}
return acc;
}, new GPXStatistics());
}
let statistics = $gpxStatistics;
if ($exportState === ExportState.ALL) {
statistics = Array.from($fileObservers.values())
.map((file) => get(file)?.statistics)
.reduce((acc, cur) => {
if (cur !== undefined) {
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()));
}
return acc;
}, new GPXStatistics());
}
hide.time = statistics.global.time.total === 0;
hide.hr = statistics.global.hr.count === 0;
hide.cad = statistics.global.cad.count === 0;
hide.atemp = statistics.global.atemp.count === 0;
hide.power = statistics.global.power.count === 0;
hide.extensions = Object.keys(statistics.global.extensions).length === 0;
}
hide.time = statistics.global.time.total === 0;
hide.hr = statistics.global.hr.count === 0;
hide.cad = statistics.global.cad.count === 0;
hide.atemp = statistics.global.atemp.count === 0;
hide.power = statistics.global.power.count === 0;
hide.extensions = Object.keys(statistics.global.extensions).length === 0;
}
$: exclude = Object.keys(exportOptions).filter((key) => !exportOptions[key]);
$: exclude = Object.keys(exportOptions).filter((key) => !exportOptions[key]);
</script>
<Dialog.Root
bind:open
onOpenChange={(isOpen) => {
if (!isOpen) {
$exportState = ExportState.NONE;
}
}}
bind:open
onOpenChange={(isOpen) => {
if (!isOpen) {
$exportState = ExportState.NONE;
}
}}
>
<Dialog.Trigger class="hidden" />
<Dialog.Portal>
<Dialog.Content
class="fixed left-[50%] top-[50%] z-50 w-fit max-w-full translate-x-[-50%] translate-y-[-50%] flex flex-col items-center gap-3 border bg-background p-3 shadow-lg rounded-md"
>
<div
class="w-full flex flex-row items-center justify-center gap-4 border rounded-md p-2 bg-secondary"
>
<span>⚠️</span>
<span class="max-w-[80%] text-sm">
{$_('menu.support_message')}
</span>
</div>
<div class="w-full flex flex-row flex-wrap gap-2">
<Button class="bg-support grow" href="https://ko-fi.com/gpxstudio" target="_blank">
{$_('menu.support_button')}
<span class="ml-2">🙏</span>
</Button>
<Button
variant="outline"
class="grow"
on:click={() => {
if ($exportState === ExportState.SELECTION) {
exportSelectedFiles(exclude);
} else if ($exportState === ExportState.ALL) {
exportAllFiles(exclude);
}
open = false;
$exportState = ExportState.NONE;
}}
>
<Download size="16" class="mr-1" />
{#if $fileObservers.size === 1 || ($exportState === ExportState.SELECTION && $selection.size === 1)}
{$_('menu.download_file')}
{:else}
{$_('menu.download_files')}
{/if}
</Button>
</div>
<div
class="w-full max-w-xl flex flex-col items-center gap-2 {Object.values(hide).some((v) => !v)
? ''
: 'hidden'}"
>
<div class="w-full flex flex-row items-center gap-3">
<div class="grow">
<Separator />
</div>
<Label class="shrink-0">
{$_('menu.export_options')}
</Label>
<div class="grow">
<Separator />
</div>
</div>
<div class="flex flex-row flex-wrap justify-center gap-x-6 gap-y-2">
<div class="flex flex-row items-center gap-1.5 {hide.time ? 'hidden' : ''}">
<Checkbox id="export-time" bind:checked={exportOptions.time} />
<Label for="export-time" class="flex flex-row items-center gap-1">
<Zap size="16" />
{$_('quantities.time')}
</Label>
</div>
<div class="flex flex-row items-center gap-1.5 {hide.extensions ? 'hidden' : ''}">
<Checkbox id="export-extensions" bind:checked={exportOptions.extensions} />
<Label for="export-extensions" class="flex flex-row items-center gap-1">
<Earth size="16" />
{$_('quantities.osm_extensions')}
</Label>
</div>
<div class="flex flex-row items-center gap-1.5 {hide.hr ? 'hidden' : ''}">
<Checkbox id="export-heartrate" bind:checked={exportOptions.hr} />
<Label for="export-heartrate" class="flex flex-row items-center gap-1">
<HeartPulse size="16" />
{$_('quantities.heartrate')}
</Label>
</div>
<div class="flex flex-row items-center gap-1.5 {hide.cad ? 'hidden' : ''}">
<Checkbox id="export-cadence" bind:checked={exportOptions.cad} />
<Label for="export-cadence" class="flex flex-row items-center gap-1">
<Orbit size="16" />
{$_('quantities.cadence')}
</Label>
</div>
<div class="flex flex-row items-center gap-1.5 {hide.atemp ? 'hidden' : ''}">
<Checkbox id="export-temperature" bind:checked={exportOptions.atemp} />
<Label for="export-temperature" class="flex flex-row items-center gap-1">
<Thermometer size="16" />
{$_('quantities.temperature')}
</Label>
</div>
<div class="flex flex-row items-center gap-1.5 {hide.power ? 'hidden' : ''}">
<Checkbox id="export-power" bind:checked={exportOptions.power} />
<Label for="export-power" class="flex flex-row items-center gap-1">
<SquareActivity size="16" />
{$_('quantities.power')}
</Label>
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
<Dialog.Trigger class="hidden" />
<Dialog.Portal>
<Dialog.Content
class="fixed left-[50%] top-[50%] z-50 w-fit max-w-full translate-x-[-50%] translate-y-[-50%] flex flex-col items-center gap-3 border bg-background p-3 shadow-lg rounded-md"
>
<div
class="w-full flex flex-row items-center justify-center gap-4 border rounded-md p-2 bg-secondary"
>
<span>⚠️</span>
<span class="max-w-[80%] text-sm">
{$_('menu.support_message')}
</span>
</div>
<div class="w-full flex flex-row flex-wrap gap-2">
<Button class="bg-support grow" href="https://ko-fi.com/gpxstudio" target="_blank">
{$_('menu.support_button')}
<span class="ml-2">🙏</span>
</Button>
<Button
variant="outline"
class="grow"
on:click={() => {
if ($exportState === ExportState.SELECTION) {
exportSelectedFiles(exclude);
} else if ($exportState === ExportState.ALL) {
exportAllFiles(exclude);
}
open = false;
$exportState = ExportState.NONE;
}}
>
<Download size="16" class="mr-1" />
{#if $fileObservers.size === 1 || ($exportState === ExportState.SELECTION && $selection.size === 1)}
{$_('menu.download_file')}
{:else}
{$_('menu.download_files')}
{/if}
</Button>
</div>
<div
class="w-full max-w-xl flex flex-col items-center gap-2 {Object.values(hide).some(
(v) => !v
)
? ''
: 'hidden'}"
>
<div class="w-full flex flex-row items-center gap-3">
<div class="grow">
<Separator />
</div>
<Label class="shrink-0">
{$_('menu.export_options')}
</Label>
<div class="grow">
<Separator />
</div>
</div>
<div class="flex flex-row flex-wrap justify-center gap-x-6 gap-y-2">
<div class="flex flex-row items-center gap-1.5 {hide.time ? 'hidden' : ''}">
<Checkbox id="export-time" bind:checked={exportOptions.time} />
<Label for="export-time" class="flex flex-row items-center gap-1">
<Zap size="16" />
{$_('quantities.time')}
</Label>
</div>
<div
class="flex flex-row items-center gap-1.5 {hide.extensions ? 'hidden' : ''}"
>
<Checkbox id="export-extensions" bind:checked={exportOptions.extensions} />
<Label for="export-extensions" class="flex flex-row items-center gap-1">
<Earth size="16" />
{$_('quantities.osm_extensions')}
</Label>
</div>
<div class="flex flex-row items-center gap-1.5 {hide.hr ? 'hidden' : ''}">
<Checkbox id="export-heartrate" bind:checked={exportOptions.hr} />
<Label for="export-heartrate" class="flex flex-row items-center gap-1">
<HeartPulse size="16" />
{$_('quantities.heartrate')}
</Label>
</div>
<div class="flex flex-row items-center gap-1.5 {hide.cad ? 'hidden' : ''}">
<Checkbox id="export-cadence" bind:checked={exportOptions.cad} />
<Label for="export-cadence" class="flex flex-row items-center gap-1">
<Orbit size="16" />
{$_('quantities.cadence')}
</Label>
</div>
<div class="flex flex-row items-center gap-1.5 {hide.atemp ? 'hidden' : ''}">
<Checkbox id="export-temperature" bind:checked={exportOptions.atemp} />
<Label for="export-temperature" class="flex flex-row items-center gap-1">
<Thermometer size="16" />
{$_('quantities.temperature')}
</Label>
</div>
<div class="flex flex-row items-center gap-1.5 {hide.power ? 'hidden' : ''}">
<Checkbox id="export-power" bind:checked={exportOptions.power} />
<Label for="export-power" class="flex flex-row items-center gap-1">
<SquareActivity size="16" />
{$_('quantities.power')}
</Label>
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

@@ -1,125 +1,125 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import LanguageSelect from '$lib/components/LanguageSelect.svelte';
import Logo from '$lib/components/Logo.svelte';
import { AtSign, BookOpenText, Heart, Home, Map } from 'lucide-svelte';
import { _, locale } from 'svelte-i18n';
import { getURLForLanguage } from '$lib/utils';
import { Button } from '$lib/components/ui/button';
import LanguageSelect from '$lib/components/LanguageSelect.svelte';
import Logo from '$lib/components/Logo.svelte';
import { AtSign, BookOpenText, Heart, Home, Map } from 'lucide-svelte';
import { _, locale } from 'svelte-i18n';
import { getURLForLanguage } from '$lib/utils';
</script>
<footer class="w-full">
<div class="mx-6 border-t">
<div class="mx-12 py-10 flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
<div class="grow flex flex-col items-start">
<Logo class="h-8" width="153" />
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE"
target="_blank"
>
MIT © 2024 gpx.studio
</Button>
<LanguageSelect class="w-40 mt-3" />
</div>
<div class="grow max-w-2xl flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
<div class="flex flex-col items-start gap-1">
<span class="font-semibold">{$_('homepage.website')}</span>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href={getURLForLanguage($locale, '/')}
>
<Home size="16" class="mr-1" />
{$_('homepage.home')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href={getURLForLanguage($locale, '/app')}
>
<Map size="16" class="mr-1" />
{$_('homepage.app')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href={getURLForLanguage($locale, '/help')}
>
<BookOpenText size="16" class="mr-1" />
{$_('menu.help')}
</Button>
</div>
<div class="flex flex-col items-start gap-1" id="contact">
<span class="font-semibold">{$_('homepage.contact')}</span>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="https://www.reddit.com/r/gpxstudio/"
target="_blank"
>
<Logo company="reddit" class="h-4 mr-1 fill-muted-foreground" />
{$_('homepage.reddit')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="https://facebook.com/gpx.studio"
target="_blank"
>
<Logo company="facebook" class="h-4 mr-1 fill-muted-foreground" />
{$_('homepage.facebook')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="https://x.com/gpxstudio"
target="_blank"
>
<Logo company="x" class="h-4 mr-1 fill-muted-foreground" />
{$_('homepage.x')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="mailto:hello@gpx.studio"
target="_blank"
>
<AtSign size="16" class="mr-1" />
{$_('homepage.email')}
</Button>
</div>
<div class="flex flex-col items-start gap-1">
<span class="font-semibold">{$_('homepage.contribute')}</span>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="https://ko-fi.com/gpxstudio"
target="_blank"
>
<Heart size="16" class="mr-1" />
{$_('menu.donate')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="https://crowdin.com/project/gpxstudio"
target="_blank"
>
<Logo company="crowdin" class="h-4 mr-1 fill-muted-foreground" />
{$_('homepage.crowdin')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="https://github.com/gpxstudio/gpx.studio"
target="_blank"
>
<Logo company="github" class="h-4 mr-1 fill-muted-foreground" />
{$_('homepage.github')}
</Button>
</div>
</div>
</div>
</div>
<div class="mx-6 border-t">
<div class="mx-12 py-10 flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
<div class="grow flex flex-col items-start">
<Logo class="h-8" width="153" />
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE"
target="_blank"
>
MIT © 2024 gpx.studio
</Button>
<LanguageSelect class="w-40 mt-3" />
</div>
<div class="grow max-w-2xl flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
<div class="flex flex-col items-start gap-1">
<span class="font-semibold">{$_('homepage.website')}</span>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href={getURLForLanguage($locale, '/')}
>
<Home size="16" class="mr-1" />
{$_('homepage.home')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href={getURLForLanguage($locale, '/app')}
>
<Map size="16" class="mr-1" />
{$_('homepage.app')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href={getURLForLanguage($locale, '/help')}
>
<BookOpenText size="16" class="mr-1" />
{$_('menu.help')}
</Button>
</div>
<div class="flex flex-col items-start gap-1" id="contact">
<span class="font-semibold">{$_('homepage.contact')}</span>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="https://www.reddit.com/r/gpxstudio/"
target="_blank"
>
<Logo company="reddit" class="h-4 mr-1 fill-muted-foreground" />
{$_('homepage.reddit')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="https://facebook.com/gpx.studio"
target="_blank"
>
<Logo company="facebook" class="h-4 mr-1 fill-muted-foreground" />
{$_('homepage.facebook')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="https://x.com/gpxstudio"
target="_blank"
>
<Logo company="x" class="h-4 mr-1 fill-muted-foreground" />
{$_('homepage.x')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="mailto:hello@gpx.studio"
target="_blank"
>
<AtSign size="16" class="mr-1" />
{$_('homepage.email')}
</Button>
</div>
<div class="flex flex-col items-start gap-1">
<span class="font-semibold">{$_('homepage.contribute')}</span>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="https://ko-fi.com/gpxstudio"
target="_blank"
>
<Heart size="16" class="mr-1" />
{$_('menu.donate')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="https://crowdin.com/project/gpxstudio"
target="_blank"
>
<Logo company="crowdin" class="h-4 mr-1 fill-muted-foreground" />
{$_('homepage.crowdin')}
</Button>
<Button
variant="link"
class="h-6 px-0 text-muted-foreground"
href="https://github.com/gpxstudio/gpx.studio"
target="_blank"
>
<Logo company="github" class="h-4 mr-1 fill-muted-foreground" />
{$_('homepage.github')}
</Button>
</div>
</div>
</div>
</div>
</footer>

View File

@@ -1,82 +1,88 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import Tooltip from '$lib/components/Tooltip.svelte';
import WithUnits from '$lib/components/WithUnits.svelte';
import * as Card from '$lib/components/ui/card';
import Tooltip from '$lib/components/Tooltip.svelte';
import WithUnits from '$lib/components/WithUnits.svelte';
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from 'lucide-svelte';
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from 'lucide-svelte';
import { _ } from 'svelte-i18n';
import type { GPXStatistics } from 'gpx';
import type { Writable } from 'svelte/store';
import { settings } from '$lib/db';
import { _ } from 'svelte-i18n';
import type { GPXStatistics } from 'gpx';
import type { Writable } from 'svelte/store';
import { settings } from '$lib/db';
export let gpxStatistics: Writable<GPXStatistics>;
export let slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
export let orientation: 'horizontal' | 'vertical';
export let panelSize: number;
export let gpxStatistics: Writable<GPXStatistics>;
export let slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
export let orientation: 'horizontal' | 'vertical';
export let panelSize: number;
const { velocityUnits } = settings;
const { velocityUnits } = settings;
let statistics: GPXStatistics;
let statistics: GPXStatistics;
$: if ($slicedGPXStatistics !== undefined) {
statistics = $slicedGPXStatistics[0];
} else {
statistics = $gpxStatistics;
}
$: if ($slicedGPXStatistics !== undefined) {
statistics = $slicedGPXStatistics[0];
} else {
statistics = $gpxStatistics;
}
</script>
<Card.Root
class="h-full {orientation === 'vertical'
? 'min-w-40 sm:min-w-44 text-sm sm:text-base'
: 'w-full'} border-none shadow-none"
class="h-full {orientation === 'vertical'
? 'min-w-40 sm:min-w-44 text-sm sm:text-base'
: 'w-full'} border-none shadow-none"
>
<Card.Content
class="h-full flex {orientation === 'vertical'
? 'flex-col justify-center'
: 'flex-row w-full justify-between'} gap-4 p-0"
>
<Tooltip label={$_('quantities.distance')}>
<span class="flex flex-row items-center">
<Ruler size="16" class="mr-1" />
<WithUnits value={statistics.global.distance.total} type="distance" />
</span>
</Tooltip>
<Tooltip label={$_('quantities.elevation_gain_loss')}>
<span class="flex flex-row items-center">
<MoveUpRight size="16" class="mr-1" />
<WithUnits value={statistics.global.elevation.gain} type="elevation" />
<MoveDownRight size="16" class="mx-1" />
<WithUnits value={statistics.global.elevation.loss} type="elevation" />
</span>
</Tooltip>
{#if panelSize > 120 || orientation === 'horizontal'}
<Tooltip
class={orientation === 'horizontal' ? 'hidden xs:block' : ''}
label="{$velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')} ({$_(
'quantities.moving'
)} / {$_('quantities.total')})"
>
<span class="flex flex-row items-center">
<Zap size="16" class="mr-1" />
<WithUnits value={statistics.global.speed.moving} type="speed" showUnits={false} />
<span class="mx-1">/</span>
<WithUnits value={statistics.global.speed.total} type="speed" />
</span>
</Tooltip>
{/if}
{#if panelSize > 160 || orientation === 'horizontal'}
<Tooltip
class={orientation === 'horizontal' ? 'hidden md:block' : ''}
label="{$_('quantities.time')} ({$_('quantities.moving')} / {$_('quantities.total')})"
>
<span class="flex flex-row items-center">
<Timer size="16" class="mr-1" />
<WithUnits value={statistics.global.time.moving} type="time" />
<span class="mx-1">/</span>
<WithUnits value={statistics.global.time.total} type="time" />
</span>
</Tooltip>
{/if}
</Card.Content>
<Card.Content
class="h-full flex {orientation === 'vertical'
? 'flex-col justify-center'
: 'flex-row w-full justify-between'} gap-4 p-0"
>
<Tooltip label={$_('quantities.distance')}>
<span class="flex flex-row items-center">
<Ruler size="16" class="mr-1" />
<WithUnits value={statistics.global.distance.total} type="distance" />
</span>
</Tooltip>
<Tooltip label={$_('quantities.elevation_gain_loss')}>
<span class="flex flex-row items-center">
<MoveUpRight size="16" class="mr-1" />
<WithUnits value={statistics.global.elevation.gain} type="elevation" />
<MoveDownRight size="16" class="mx-1" />
<WithUnits value={statistics.global.elevation.loss} type="elevation" />
</span>
</Tooltip>
{#if panelSize > 120 || orientation === 'horizontal'}
<Tooltip
class={orientation === 'horizontal' ? 'hidden xs:block' : ''}
label="{$velocityUnits === 'speed'
? $_('quantities.speed')
: $_('quantities.pace')} ({$_('quantities.moving')} / {$_('quantities.total')})"
>
<span class="flex flex-row items-center">
<Zap size="16" class="mr-1" />
<WithUnits
value={statistics.global.speed.moving}
type="speed"
showUnits={false}
/>
<span class="mx-1">/</span>
<WithUnits value={statistics.global.speed.total} type="speed" />
</span>
</Tooltip>
{/if}
{#if panelSize > 160 || orientation === 'horizontal'}
<Tooltip
class={orientation === 'horizontal' ? 'hidden md:block' : ''}
label="{$_('quantities.time')} ({$_('quantities.moving')} / {$_(
'quantities.total'
)})"
>
<span class="flex flex-row items-center">
<Timer size="16" class="mr-1" />
<WithUnits value={statistics.global.time.moving} type="time" />
<span class="mx-1">/</span>
<WithUnits value={statistics.global.time.total} type="time" />
</span>
</Tooltip>
{/if}
</Card.Content>
</Card.Root>

View File

@@ -1,20 +1,20 @@
<script lang="ts">
import { CircleHelp } from 'lucide-svelte';
import { _ } from 'svelte-i18n';
import { CircleHelp } from 'lucide-svelte';
import { _ } from 'svelte-i18n';
export let link: string | undefined = undefined;
export let link: string | undefined = undefined;
</script>
<div
class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {$$props.class || ''}"
class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {$$props.class || ''}"
>
<CircleHelp size="16" class="w-4 mr-2 shrink-0 grow-0" />
<div>
<slot />
{#if link}
<a href={link} target="_blank" class="text-sm text-link hover:underline">
{$_('menu.more')}
</a>
{/if}
</div>
<CircleHelp size="16" class="w-4 mr-2 shrink-0 grow-0" />
<div>
<slot />
{#if link}
<a href={link} target="_blank" class="text-sm text-link hover:underline">
{$_('menu.more')}
</a>
{/if}
</div>
</div>

View File

@@ -1,51 +1,51 @@
<script lang="ts">
import { page } from '$app/stores';
import * as Select from '$lib/components/ui/select';
import { languages } from '$lib/languages';
import { getURLForLanguage } from '$lib/utils';
import { Languages } from 'lucide-svelte';
import { _, locale } from 'svelte-i18n';
import { page } from '$app/stores';
import * as Select from '$lib/components/ui/select';
import { languages } from '$lib/languages';
import { getURLForLanguage } from '$lib/utils';
import { Languages } from 'lucide-svelte';
import { _, locale } from 'svelte-i18n';
let selected = {
value: '',
label: ''
};
let selected = {
value: '',
label: '',
};
$: if ($locale) {
selected = {
value: $locale,
label: languages[$locale]
};
}
$: if ($locale) {
selected = {
value: $locale,
label: languages[$locale],
};
}
</script>
<Select.Root bind:selected>
<Select.Trigger class="w-[180px] {$$props.class ?? ''}" aria-label={$_('menu.language')}>
<Languages size="16" />
<Select.Value class="ml-2 mr-auto" />
</Select.Trigger>
<Select.Content>
{#each Object.entries(languages) as [lang, label]}
{#if $page.url.pathname.includes('404')}
<a href={getURLForLanguage(lang, '/')}>
<Select.Item value={lang}>{label}</Select.Item>
</a>
{:else}
<a href={getURLForLanguage(lang, $page.url.pathname)}>
<Select.Item value={lang}>{label}</Select.Item>
</a>
{/if}
{/each}
</Select.Content>
<Select.Trigger class="w-[180px] {$$props.class ?? ''}" aria-label={$_('menu.language')}>
<Languages size="16" />
<Select.Value class="ml-2 mr-auto" />
</Select.Trigger>
<Select.Content>
{#each Object.entries(languages) as [lang, label]}
{#if $page.url.pathname.includes('404')}
<a href={getURLForLanguage(lang, '/')}>
<Select.Item value={lang}>{label}</Select.Item>
</a>
{:else}
<a href={getURLForLanguage(lang, $page.url.pathname)}>
<Select.Item value={lang}>{label}</Select.Item>
</a>
{/if}
{/each}
</Select.Content>
</Select.Root>
<!-- hidden links for svelte crawling -->
<div class="hidden">
{#if !$page.url.pathname.includes('404')}
{#each Object.entries(languages) as [lang, label]}
<a href={getURLForLanguage(lang, $page.url.pathname)}>
{label}
</a>
{/each}
{/if}
{#if !$page.url.pathname.includes('404')}
{#each Object.entries(languages) as [lang, label]}
<a href={getURLForLanguage(lang, $page.url.pathname)}>
{label}
</a>
{/each}
{/if}
</div>

View File

@@ -1,73 +1,73 @@
<script lang="ts">
import { base } from '$app/paths';
import { mode, systemPrefersMode } from 'mode-watcher';
import { base } from '$app/paths';
import { mode, systemPrefersMode } from 'mode-watcher';
export let iconOnly = false;
export let company = 'gpx.studio';
export let iconOnly = false;
export let company = 'gpx.studio';
$: effectiveMode = $mode ?? $systemPrefersMode ?? 'light';
$: effectiveMode = $mode ?? $systemPrefersMode ?? 'light';
</script>
{#if company === 'gpx.studio'}
<img
src="{base}/{iconOnly ? 'icon' : 'logo'}{effectiveMode === 'dark' ? '-dark' : ''}.svg"
alt="Logo of gpx.studio."
{...$$restProps}
/>
<img
src="{base}/{iconOnly ? 'icon' : 'logo'}{effectiveMode === 'dark' ? '-dark' : ''}.svg"
alt="Logo of gpx.studio."
{...$$restProps}
/>
{:else if company === 'mapbox'}
<img
src="{base}/mapbox-logo-{effectiveMode === 'dark' ? 'white' : 'black'}.svg"
alt="Logo of Mapbox."
{...$$restProps}
/>
<img
src="{base}/mapbox-logo-{effectiveMode === 'dark' ? 'white' : 'black'}.svg"
alt="Logo of Mapbox."
{...$$restProps}
/>
{:else if company === 'github'}
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {$$restProps.class ?? ''}"
><title>GitHub</title><path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
/></svg
>
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {$$restProps.class ?? ''}"
><title>GitHub</title><path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
/></svg
>
{:else if company === 'crowdin'}
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {$$restProps.class ?? ''}"
><title>Crowdin</title><path
d="M16.119 17.793a2.619 2.619 0 0 1-1.667-.562c-.546-.436-1.004-1.09-1.018-1.858-.008-.388.414-.388.414-.388l1.018-.008c.332.008.43.47.445.586.128 1.04.717 1.495 1.168 1.702.273.123.204.513-.362.528zm-5.695-5.287L8.5 12.252c-.867-.214-.844-.982-.807-1.247a5.119 5.119 0 0 1 .814-2.125c.545-.804 1.303-1.508 2.29-2.073 1.856-1.074 4.45-1.673 7.31-1.673 2.09 0 4.256.27 4.29.27.197.025.328.213.333.437a.377.377 0 0 1-.355.393l-.92-.01c-2.902 0-4.968.394-6.506 1.248-1.527.837-2.57 2.117-3.287 4.012-.076.163-.335 1.12-1.24 1.022zm2.533 7.823c-1.44 0-2.797-.622-3.825-1.746-.87-.96-1.397-1.931-1.493-3.164-.06-.813.3-1.094.788-1.044l1.988.218c.45.092.75.34.825.854.397 2.736 2.122 3.814 3.15 4.046.18.042.292.157.283.365a.412.412 0 0 1-.322.398c-.458.074-.936.073-1.394.073zm-4.101 2.418a14.216 14.216 0 0 1-2.307-.214c-1.202-.214-2.208-.582-3.072-1.13C1.41 20.095.163 17.786.014 15.048c-.037-.65-.11-1.89 1.427-1.797.638.033 1.653.343 2.368.548.887.247 1.314.933 1.314 1.608 0 3.858 3.494 6.408 5.02 6.408.654 0 .414.701.127.779-.502.136-1.15.153-1.413.153zM3.525 11.419c-.605-.109-1.194-.358-1.768-.5C-.018 10.479.284 8.688.45 8.196c1.617-4.757 6.746-6.35 10.887-6.773 3.898-.4 7.978-.092 11.778.967.31.083 1.269.327.718.891-.35.358-1.7-.016-2.073-.041-2.23-.167-4.434-.192-6.656.15-2.349.357-4.768 1.099-6.71 2.665-.938.758-1.76 1.723-2.313 2.866-.144.3-.256.6-.354.9-.11.327-.47 1.91-2.215 1.6zm9.94.917c.332-1.488 1.81-3.848 6.385-3.686 1.05.033.57.749.052.731-2.586-.09-3.815 1.578-4.457 3.27-.219.546-.68.626-1.271.53-.415-.074-.866-.123-.71-.846Z"
/></svg
>
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {$$restProps.class ?? ''}"
><title>Crowdin</title><path
d="M16.119 17.793a2.619 2.619 0 0 1-1.667-.562c-.546-.436-1.004-1.09-1.018-1.858-.008-.388.414-.388.414-.388l1.018-.008c.332.008.43.47.445.586.128 1.04.717 1.495 1.168 1.702.273.123.204.513-.362.528zm-5.695-5.287L8.5 12.252c-.867-.214-.844-.982-.807-1.247a5.119 5.119 0 0 1 .814-2.125c.545-.804 1.303-1.508 2.29-2.073 1.856-1.074 4.45-1.673 7.31-1.673 2.09 0 4.256.27 4.29.27.197.025.328.213.333.437a.377.377 0 0 1-.355.393l-.92-.01c-2.902 0-4.968.394-6.506 1.248-1.527.837-2.57 2.117-3.287 4.012-.076.163-.335 1.12-1.24 1.022zm2.533 7.823c-1.44 0-2.797-.622-3.825-1.746-.87-.96-1.397-1.931-1.493-3.164-.06-.813.3-1.094.788-1.044l1.988.218c.45.092.75.34.825.854.397 2.736 2.122 3.814 3.15 4.046.18.042.292.157.283.365a.412.412 0 0 1-.322.398c-.458.074-.936.073-1.394.073zm-4.101 2.418a14.216 14.216 0 0 1-2.307-.214c-1.202-.214-2.208-.582-3.072-1.13C1.41 20.095.163 17.786.014 15.048c-.037-.65-.11-1.89 1.427-1.797.638.033 1.653.343 2.368.548.887.247 1.314.933 1.314 1.608 0 3.858 3.494 6.408 5.02 6.408.654 0 .414.701.127.779-.502.136-1.15.153-1.413.153zM3.525 11.419c-.605-.109-1.194-.358-1.768-.5C-.018 10.479.284 8.688.45 8.196c1.617-4.757 6.746-6.35 10.887-6.773 3.898-.4 7.978-.092 11.778.967.31.083 1.269.327.718.891-.35.358-1.7-.016-2.073-.041-2.23-.167-4.434-.192-6.656.15-2.349.357-4.768 1.099-6.71 2.665-.938.758-1.76 1.723-2.313 2.866-.144.3-.256.6-.354.9-.11.327-.47 1.91-2.215 1.6zm9.94.917c.332-1.488 1.81-3.848 6.385-3.686 1.05.033.57.749.052.731-2.586-.09-3.815 1.578-4.457 3.27-.219.546-.68.626-1.271.53-.415-.074-.866-.123-.71-.846Z"
/></svg
>
{:else if company === 'facebook'}
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {$$restProps.class ?? ''}"
><title>Facebook</title><path
d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"
/></svg
>
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {$$restProps.class ?? ''}"
><title>Facebook</title><path
d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"
/></svg
>
{:else if company === 'x'}
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {$$restProps.class ?? ''}"
><title>X</title><path
d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"
/></svg
>
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {$$restProps.class ?? ''}"
><title>X</title><path
d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"
/></svg
>
{:else if company === 'reddit'}
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {$$restProps.class ?? ''}"
><title>Reddit</title><path
d="M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z"
/></svg
>
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="fill-foreground {$$restProps.class ?? ''}"
><title>Reddit</title><path
d="M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z"
/></svg
>
{/if}

View File

@@ -1,392 +1,393 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
import { Button } from '$lib/components/ui/button';
import { map } from '$lib/stores';
import { settings } from '$lib/db';
import { _ } from 'svelte-i18n';
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
import { page } from '$app/stores';
import { Button } from '$lib/components/ui/button';
import { map } from '$lib/stores';
import { settings } from '$lib/db';
import { _ } from 'svelte-i18n';
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
import { page } from '$app/stores';
export let accessToken = PUBLIC_MAPBOX_TOKEN;
export let geolocate = true;
export let geocoder = true;
export let hash = true;
export let accessToken = PUBLIC_MAPBOX_TOKEN;
export let geolocate = true;
export let geocoder = true;
export let hash = true;
mapboxgl.accessToken = accessToken;
mapboxgl.accessToken = accessToken;
let webgl2Supported = true;
let embeddedApp = false;
let fitBoundsOptions: mapboxgl.FitBoundsOptions = {
maxZoom: 15,
linear: true,
easing: () => 1
};
let webgl2Supported = true;
let embeddedApp = false;
let fitBoundsOptions: mapboxgl.FitBoundsOptions = {
maxZoom: 15,
linear: true,
easing: () => 1,
};
const { distanceUnits, elevationProfile, treeFileView, bottomPanelSize, rightPanelSize } =
settings;
let scaleControl = new mapboxgl.ScaleControl({
unit: $distanceUnits
});
const { distanceUnits, elevationProfile, treeFileView, bottomPanelSize, rightPanelSize } =
settings;
let scaleControl = new mapboxgl.ScaleControl({
unit: $distanceUnits,
});
onMount(() => {
let gl = document.createElement('canvas').getContext('webgl2');
if (!gl) {
webgl2Supported = false;
return;
}
if (window.top !== window.self && !$page.route.id?.includes('embed')) {
embeddedApp = true;
return;
}
onMount(() => {
let gl = document.createElement('canvas').getContext('webgl2');
if (!gl) {
webgl2Supported = false;
return;
}
if (window.top !== window.self && !$page.route.id?.includes('embed')) {
embeddedApp = true;
return;
}
let language = $page.params.language;
if (language === 'zh') {
language = 'zh-Hans';
} else if (language?.includes('-')) {
language = language.split('-')[0];
} else if (language === '' || language === undefined) {
language = 'en';
}
let language = $page.params.language;
if (language === 'zh') {
language = 'zh-Hans';
} else if (language?.includes('-')) {
language = language.split('-')[0];
} else if (language === '' || language === undefined) {
language = 'en';
}
let newMap = new mapboxgl.Map({
container: 'map',
style: {
version: 8,
sources: {},
layers: [],
imports: [
{
id: 'glyphs-and-sprite', // make Mapbox glyphs and sprite available to other styles
url: '',
data: {
version: 8,
sources: {},
layers: [],
glyphs: 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf',
sprite: `https://api.mapbox.com/styles/v1/mapbox/outdoors-v12/sprite?access_token=${PUBLIC_MAPBOX_TOKEN}`
}
},
{
id: 'basemap',
url: ''
},
{
id: 'overlays',
url: '',
data: {
version: 8,
sources: {},
layers: []
}
}
]
},
projection: 'globe',
zoom: 0,
hash: hash,
language,
attributionControl: false,
logoPosition: 'bottom-right',
boxZoom: false
});
newMap.on('load', () => {
$map = newMap; // only set the store after the map has loaded
window._map = newMap; // entry point for extensions
scaleControl.setUnit($distanceUnits);
});
let newMap = new mapboxgl.Map({
container: 'map',
style: {
version: 8,
sources: {},
layers: [],
imports: [
{
id: 'glyphs-and-sprite', // make Mapbox glyphs and sprite available to other styles
url: '',
data: {
version: 8,
sources: {},
layers: [],
glyphs: 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf',
sprite: `https://api.mapbox.com/styles/v1/mapbox/outdoors-v12/sprite?access_token=${PUBLIC_MAPBOX_TOKEN}`,
},
},
{
id: 'basemap',
url: '',
},
{
id: 'overlays',
url: '',
data: {
version: 8,
sources: {},
layers: [],
},
},
],
},
projection: 'globe',
zoom: 0,
hash: hash,
language,
attributionControl: false,
logoPosition: 'bottom-right',
boxZoom: false,
});
newMap.on('load', () => {
$map = newMap; // only set the store after the map has loaded
window._map = newMap; // entry point for extensions
scaleControl.setUnit($distanceUnits);
});
newMap.addControl(
new mapboxgl.AttributionControl({
compact: true
})
);
newMap.addControl(
new mapboxgl.AttributionControl({
compact: true,
})
);
newMap.addControl(
new mapboxgl.NavigationControl({
visualizePitch: true
})
);
newMap.addControl(
new mapboxgl.NavigationControl({
visualizePitch: true,
})
);
if (geocoder) {
let geocoder = new MapboxGeocoder({
mapboxgl: mapboxgl,
enableEventLogging: false,
collapsed: true,
flyTo: fitBoundsOptions,
language,
localGeocoder: () => [],
localGeocoderOnly: true,
externalGeocoder: (query: string) =>
fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${query}&limit=5&accept-language=${language}`
)
.then((response) => response.json())
.then((data) => {
return data.map((result: any) => {
return {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [result.lon, result.lat]
},
place_name: result.display_name
};
});
})
});
let onKeyDown = geocoder._onKeyDown;
geocoder._onKeyDown = (e: KeyboardEvent) => {
// Trigger search on Enter key only
if (e.key === 'Enter') {
onKeyDown.apply(geocoder, [{ target: geocoder._inputEl }]);
} else if (geocoder._typeahead.data.length > 0) {
geocoder._typeahead.clear();
}
};
newMap.addControl(geocoder);
}
if (geocoder) {
let geocoder = new MapboxGeocoder({
mapboxgl: mapboxgl,
enableEventLogging: false,
collapsed: true,
flyTo: fitBoundsOptions,
language,
localGeocoder: () => [],
localGeocoderOnly: true,
externalGeocoder: (query: string) =>
fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${query}&limit=5&accept-language=${language}`
)
.then((response) => response.json())
.then((data) => {
return data.map((result: any) => {
return {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [result.lon, result.lat],
},
place_name: result.display_name,
};
});
}),
});
let onKeyDown = geocoder._onKeyDown;
geocoder._onKeyDown = (e: KeyboardEvent) => {
// Trigger search on Enter key only
if (e.key === 'Enter') {
onKeyDown.apply(geocoder, [{ target: geocoder._inputEl }]);
} else if (geocoder._typeahead.data.length > 0) {
geocoder._typeahead.clear();
}
};
newMap.addControl(geocoder);
}
if (geolocate) {
newMap.addControl(
new mapboxgl.GeolocateControl({
positionOptions: {
enableHighAccuracy: true
},
fitBoundsOptions,
trackUserLocation: true,
showUserHeading: true
})
);
}
if (geolocate) {
newMap.addControl(
new mapboxgl.GeolocateControl({
positionOptions: {
enableHighAccuracy: true,
},
fitBoundsOptions,
trackUserLocation: true,
showUserHeading: true,
})
);
}
newMap.addControl(scaleControl);
newMap.addControl(scaleControl);
newMap.on('style.load', () => {
newMap.addSource('mapbox-dem', {
type: 'raster-dem',
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
tileSize: 512,
maxzoom: 14
});
if (newMap.getPitch() > 0) {
newMap.setTerrain({
source: 'mapbox-dem',
exaggeration: 1
});
}
newMap.setFog({
color: 'rgb(186, 210, 235)',
'high-color': 'rgb(36, 92, 223)',
'horizon-blend': 0.1,
'space-color': 'rgb(156, 240, 255)'
});
newMap.on('pitch', () => {
if (newMap.getPitch() > 0) {
newMap.setTerrain({
source: 'mapbox-dem',
exaggeration: 1
});
} else {
newMap.setTerrain(null);
}
});
});
});
newMap.on('style.load', () => {
newMap.addSource('mapbox-dem', {
type: 'raster-dem',
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
tileSize: 512,
maxzoom: 14,
});
if (newMap.getPitch() > 0) {
newMap.setTerrain({
source: 'mapbox-dem',
exaggeration: 1,
});
}
newMap.setFog({
color: 'rgb(186, 210, 235)',
'high-color': 'rgb(36, 92, 223)',
'horizon-blend': 0.1,
'space-color': 'rgb(156, 240, 255)',
});
newMap.on('pitch', () => {
if (newMap.getPitch() > 0) {
newMap.setTerrain({
source: 'mapbox-dem',
exaggeration: 1,
});
} else {
newMap.setTerrain(null);
}
});
});
});
onDestroy(() => {
if ($map) {
$map.remove();
$map = null;
}
});
onDestroy(() => {
if ($map) {
$map.remove();
$map = null;
}
});
$: if ($map && (!$treeFileView || !$elevationProfile || $bottomPanelSize || $rightPanelSize)) {
$map.resize();
}
$: if ($map && (!$treeFileView || !$elevationProfile || $bottomPanelSize || $rightPanelSize)) {
$map.resize();
}
</script>
<div {...$$restProps}>
<div id="map" class="h-full {webgl2Supported && !embeddedApp ? '' : 'hidden'}"></div>
<div
class="flex flex-col items-center justify-center gap-3 h-full {webgl2Supported && !embeddedApp
? 'hidden'
: ''} {embeddedApp ? 'z-30' : ''}"
>
{#if !webgl2Supported}
<p>{$_('webgl2_required')}</p>
<Button href="https://get.webgl.org/webgl2/" target="_blank">
{$_('enable_webgl2')}
</Button>
{:else if embeddedApp}
<p>The app cannot be embedded in an iframe.</p>
<Button href="https://gpx.studio/help/integration" target="_blank">
Learn how to create a map for your website
</Button>
{/if}
</div>
<div id="map" class="h-full {webgl2Supported && !embeddedApp ? '' : 'hidden'}"></div>
<div
class="flex flex-col items-center justify-center gap-3 h-full {webgl2Supported &&
!embeddedApp
? 'hidden'
: ''} {embeddedApp ? 'z-30' : ''}"
>
{#if !webgl2Supported}
<p>{$_('webgl2_required')}</p>
<Button href="https://get.webgl.org/webgl2/" target="_blank">
{$_('enable_webgl2')}
</Button>
{:else if embeddedApp}
<p>The app cannot be embedded in an iframe.</p>
<Button href="https://gpx.studio/help/integration" target="_blank">
Learn how to create a map for your website
</Button>
{/if}
</div>
</div>
<style lang="postcss">
div :global(.mapboxgl-map) {
@apply font-sans;
}
div :global(.mapboxgl-map) {
@apply font-sans;
}
div :global(.mapboxgl-ctrl-top-right > .mapboxgl-ctrl) {
@apply shadow-md;
@apply bg-background;
@apply text-foreground;
}
div :global(.mapboxgl-ctrl-top-right > .mapboxgl-ctrl) {
@apply shadow-md;
@apply bg-background;
@apply text-foreground;
}
div :global(.mapboxgl-ctrl-icon) {
@apply dark:brightness-[4.7];
}
div :global(.mapboxgl-ctrl-icon) {
@apply dark:brightness-[4.7];
}
div :global(.mapboxgl-ctrl-geocoder) {
@apply flex;
@apply flex-row;
@apply w-fit;
@apply min-w-fit;
@apply items-center;
@apply shadow-md;
}
div :global(.mapboxgl-ctrl-geocoder) {
@apply flex;
@apply flex-row;
@apply w-fit;
@apply min-w-fit;
@apply items-center;
@apply shadow-md;
}
div :global(.suggestions) {
@apply shadow-md;
@apply bg-background;
@apply text-foreground;
}
div :global(.suggestions) {
@apply shadow-md;
@apply bg-background;
@apply text-foreground;
}
div :global(.mapboxgl-ctrl-geocoder .suggestions > li > a) {
@apply text-foreground;
@apply hover:text-accent-foreground;
@apply hover:bg-accent;
}
div :global(.mapboxgl-ctrl-geocoder .suggestions > li > a) {
@apply text-foreground;
@apply hover:text-accent-foreground;
@apply hover:bg-accent;
}
div :global(.mapboxgl-ctrl-geocoder .suggestions > .active > a) {
@apply bg-background;
}
div :global(.mapboxgl-ctrl-geocoder .suggestions > .active > a) {
@apply bg-background;
}
div :global(.mapboxgl-ctrl-geocoder--button) {
@apply bg-transparent;
@apply hover:bg-transparent;
}
div :global(.mapboxgl-ctrl-geocoder--button) {
@apply bg-transparent;
@apply hover:bg-transparent;
}
div :global(.mapboxgl-ctrl-geocoder--icon) {
@apply fill-foreground;
@apply hover:fill-accent-foreground;
}
div :global(.mapboxgl-ctrl-geocoder--icon) {
@apply fill-foreground;
@apply hover:fill-accent-foreground;
}
div :global(.mapboxgl-ctrl-geocoder--icon-search) {
@apply relative;
@apply top-0;
@apply left-0;
@apply my-2;
@apply w-[29px];
}
div :global(.mapboxgl-ctrl-geocoder--icon-search) {
@apply relative;
@apply top-0;
@apply left-0;
@apply my-2;
@apply w-[29px];
}
div :global(.mapboxgl-ctrl-geocoder--input) {
@apply relative;
@apply w-64;
@apply py-0;
@apply pl-2;
@apply focus:outline-none;
@apply transition-[width];
@apply duration-200;
@apply text-foreground;
}
div :global(.mapboxgl-ctrl-geocoder--input) {
@apply relative;
@apply w-64;
@apply py-0;
@apply pl-2;
@apply focus:outline-none;
@apply transition-[width];
@apply duration-200;
@apply text-foreground;
}
div :global(.mapboxgl-ctrl-geocoder--collapsed .mapboxgl-ctrl-geocoder--input) {
@apply w-0;
@apply p-0;
}
div :global(.mapboxgl-ctrl-geocoder--collapsed .mapboxgl-ctrl-geocoder--input) {
@apply w-0;
@apply p-0;
}
div :global(.mapboxgl-ctrl-top-right) {
@apply z-40;
@apply flex;
@apply flex-col;
@apply items-end;
@apply h-full;
@apply overflow-hidden;
}
div :global(.mapboxgl-ctrl-top-right) {
@apply z-40;
@apply flex;
@apply flex-col;
@apply items-end;
@apply h-full;
@apply overflow-hidden;
}
.horizontal :global(.mapboxgl-ctrl-bottom-left) {
@apply bottom-[42px];
}
.horizontal :global(.mapboxgl-ctrl-bottom-left) {
@apply bottom-[42px];
}
.horizontal :global(.mapboxgl-ctrl-bottom-right) {
@apply bottom-[42px];
}
.horizontal :global(.mapboxgl-ctrl-bottom-right) {
@apply bottom-[42px];
}
div :global(.mapboxgl-ctrl-attrib) {
@apply dark:bg-transparent;
}
div :global(.mapboxgl-ctrl-attrib) {
@apply dark:bg-transparent;
}
div :global(.mapboxgl-compact-show.mapboxgl-ctrl-attrib) {
@apply dark:bg-background;
}
div :global(.mapboxgl-compact-show.mapboxgl-ctrl-attrib) {
@apply dark:bg-background;
}
div :global(.mapboxgl-ctrl-attrib-button) {
@apply dark:bg-foreground;
}
div :global(.mapboxgl-ctrl-attrib-button) {
@apply dark:bg-foreground;
}
div :global(.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button) {
@apply dark:bg-foreground;
}
div :global(.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button) {
@apply dark:bg-foreground;
}
div :global(.mapboxgl-ctrl-attrib a) {
@apply text-foreground;
}
div :global(.mapboxgl-ctrl-attrib a) {
@apply text-foreground;
}
div :global(.mapboxgl-popup) {
@apply w-fit;
@apply z-50;
}
div :global(.mapboxgl-popup) {
@apply w-fit;
@apply z-50;
}
div :global(.mapboxgl-popup-content) {
@apply p-0;
@apply bg-transparent;
@apply shadow-none;
}
div :global(.mapboxgl-popup-content) {
@apply p-0;
@apply bg-transparent;
@apply shadow-none;
}
div :global(.mapboxgl-popup-anchor-top .mapboxgl-popup-tip) {
@apply border-b-background;
}
div :global(.mapboxgl-popup-anchor-top .mapboxgl-popup-tip) {
@apply border-b-background;
}
div :global(.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip) {
@apply border-b-background;
}
div :global(.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip) {
@apply border-b-background;
}
div :global(.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip) {
@apply border-b-background;
}
div :global(.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip) {
@apply border-b-background;
}
div :global(.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip) {
@apply border-t-background;
@apply drop-shadow-md;
}
div :global(.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip) {
@apply border-t-background;
@apply drop-shadow-md;
}
div :global(.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip) {
@apply border-t-background;
@apply drop-shadow-md;
}
div :global(.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip) {
@apply border-t-background;
@apply drop-shadow-md;
}
div :global(.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip) {
@apply border-t-background;
@apply drop-shadow-md;
}
div :global(.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip) {
@apply border-t-background;
@apply drop-shadow-md;
}
div :global(.mapboxgl-popup-anchor-left .mapboxgl-popup-tip) {
@apply border-r-background;
}
div :global(.mapboxgl-popup-anchor-left .mapboxgl-popup-tip) {
@apply border-r-background;
}
div :global(.mapboxgl-popup-anchor-right .mapboxgl-popup-tip) {
@apply border-l-background;
}
div :global(.mapboxgl-popup-anchor-right .mapboxgl-popup-tip) {
@apply border-l-background;
}
</style>

View File

@@ -1,25 +1,25 @@
<svelte:options accessors />
<script lang="ts">
import { TrackPoint, Waypoint } from 'gpx';
import type { Writable } from 'svelte/store';
import WaypointPopup from '$lib/components/gpx-layer/WaypointPopup.svelte';
import TrackpointPopup from '$lib/components/gpx-layer/TrackpointPopup.svelte';
import OverpassPopup from '$lib/components/layer-control/OverpassPopup.svelte';
import type { PopupItem } from './MapPopup';
import { TrackPoint, Waypoint } from 'gpx';
import type { Writable } from 'svelte/store';
import WaypointPopup from '$lib/components/gpx-layer/WaypointPopup.svelte';
import TrackpointPopup from '$lib/components/gpx-layer/TrackpointPopup.svelte';
import OverpassPopup from '$lib/components/layer-control/OverpassPopup.svelte';
import type { PopupItem } from './MapPopup';
export let item: Writable<PopupItem | null>;
export let container: HTMLDivElement | null = null;
export let item: Writable<PopupItem | null>;
export let container: HTMLDivElement | null = null;
</script>
<div bind:this={container}>
{#if $item}
{#if $item.item instanceof Waypoint}
<WaypointPopup waypoint={$item} />
{:else if $item.item instanceof TrackPoint}
<TrackpointPopup trackpoint={$item} />
{:else}
<OverpassPopup poi={$item} />
{/if}
{/if}
{#if $item}
{#if $item.item instanceof Waypoint}
<WaypointPopup waypoint={$item} />
{:else if $item.item instanceof TrackPoint}
<TrackpointPopup trackpoint={$item} />
{:else}
<OverpassPopup poi={$item} />
{/if}
{/if}
</div>

View File

@@ -1,8 +1,8 @@
import { TrackPoint, Waypoint } from "gpx";
import mapboxgl from "mapbox-gl";
import { tick } from "svelte";
import { get, writable, type Writable } from "svelte/store";
import MapPopupComponent from "./MapPopup.svelte";
import { TrackPoint, Waypoint } from 'gpx';
import mapboxgl from 'mapbox-gl';
import { tick } from 'svelte';
import { get, writable, type Writable } from 'svelte/store';
import MapPopupComponent from './MapPopup.svelte';
export type PopupItem<T = Waypoint | TrackPoint | any> = {
item: T;
@@ -23,16 +23,15 @@ export class MapPopup {
let component = new MapPopupComponent({
target: document.body,
props: {
item: this.item
}
item: this.item,
},
});
tick().then(() => this.popup.setDOMContent(component.container));
}
setItem(item: PopupItem | null) {
if (item)
item.hide = () => this.hide();
if (item) item.hide = () => this.hide();
this.item.set(item);
if (item === null) {
this.hide();
@@ -76,6 +75,8 @@ export class MapPopup {
if (i === null) {
return new mapboxgl.LngLat(0, 0);
}
return (i.item instanceof Waypoint || i.item instanceof TrackPoint) ? i.item.getCoordinates() : new mapboxgl.LngLat(i.item.lon, i.item.lat);
return i.item instanceof Waypoint || i.item instanceof TrackPoint
? i.item.getCoordinates()
: new mapboxgl.LngLat(i.item.lon, i.item.lat);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,25 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Moon, Sun } from 'lucide-svelte';
import { mode, setMode, systemPrefersMode } from 'mode-watcher';
import { _ } from 'svelte-i18n';
import { Button } from '$lib/components/ui/button';
import { Moon, Sun } from 'lucide-svelte';
import { mode, setMode, systemPrefersMode } from 'mode-watcher';
import { _ } from 'svelte-i18n';
export let size = '20';
export let size = '20';
$: selectedMode = $mode ?? $systemPrefersMode ?? 'light';
$: selectedMode = $mode ?? $systemPrefersMode ?? 'light';
</script>
<Button
variant="ghost"
class="h-8 px-1.5 {$$props.class ?? ''}"
on:click={() => {
setMode(selectedMode === 'light' ? 'dark' : 'light');
}}
aria-label={$_('menu.mode')}
variant="ghost"
class="h-8 px-1.5 {$$props.class ?? ''}"
on:click={() => {
setMode(selectedMode === 'light' ? 'dark' : 'light');
}}
aria-label={$_('menu.mode')}
>
{#if selectedMode === 'light'}
<Sun {size} />
{:else}
<Moon {size} />
{/if}
{#if selectedMode === 'light'}
<Sun {size} />
{:else}
<Moon {size} />
{/if}
</Button>

View File

@@ -1,32 +1,32 @@
<script lang="ts">
import Logo from '$lib/components/Logo.svelte';
import { Button } from '$lib/components/ui/button';
import AlgoliaDocSearch from '$lib/components/AlgoliaDocSearch.svelte';
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
import { BookOpenText, Home, Map } from 'lucide-svelte';
import { _, locale } from 'svelte-i18n';
import { getURLForLanguage } from '$lib/utils';
import Logo from '$lib/components/Logo.svelte';
import { Button } from '$lib/components/ui/button';
import AlgoliaDocSearch from '$lib/components/AlgoliaDocSearch.svelte';
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
import { BookOpenText, Home, Map } from 'lucide-svelte';
import { _, locale } from 'svelte-i18n';
import { getURLForLanguage } from '$lib/utils';
</script>
<nav class="w-full sticky top-0 bg-background z-50">
<div class="mx-6 py-2 flex flex-row items-center border-b gap-4 sm:gap-8">
<a href={getURLForLanguage($locale, '/')} class="shrink-0 translate-y-0.5">
<Logo class="h-8 sm:hidden" iconOnly={true} width="26" />
<Logo class="h-8 hidden sm:block" width="153" />
</a>
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/')}>
<Home size="18" class="mr-1.5" />
{$_('homepage.home')}
</Button>
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/app')}>
<Map size="18" class="mr-1.5" />
{$_('homepage.app')}
</Button>
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/help')}>
<BookOpenText size="18" class="mr-1.5" />
{$_('menu.help')}
</Button>
<AlgoliaDocSearch class="ml-auto" />
<ModeSwitch class="hidden xs:block" />
</div>
<div class="mx-6 py-2 flex flex-row items-center border-b gap-4 sm:gap-8">
<a href={getURLForLanguage($locale, '/')} class="shrink-0 translate-y-0.5">
<Logo class="h-8 sm:hidden" iconOnly={true} width="26" />
<Logo class="h-8 hidden sm:block" width="153" />
</a>
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/')}>
<Home size="18" class="mr-1.5" />
{$_('homepage.home')}
</Button>
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/app')}>
<Map size="18" class="mr-1.5" />
{$_('homepage.app')}
</Button>
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/help')}>
<BookOpenText size="18" class="mr-1.5" />
{$_('menu.help')}
</Button>
<AlgoliaDocSearch class="ml-auto" />
<ModeSwitch class="hidden xs:block" />
</div>
</nav>

View File

@@ -1,41 +1,42 @@
<script lang="ts">
export let orientation: 'col' | 'row' = 'col';
export let orientation: 'col' | 'row' = 'col';
export let after: number;
export let minAfter: number = 0;
export let maxAfter: number = Number.MAX_SAFE_INTEGER;
export let after: number;
export let minAfter: number = 0;
export let maxAfter: number = Number.MAX_SAFE_INTEGER;
function handleMouseDown(event: PointerEvent) {
const startX = event.clientX;
const startY = event.clientY;
const startAfter = after;
function handleMouseDown(event: PointerEvent) {
const startX = event.clientX;
const startY = event.clientY;
const startAfter = after;
const handleMouseMove = (event: PointerEvent) => {
const newAfter =
startAfter + (orientation === 'col' ? startX - event.clientX : startY - event.clientY);
if (newAfter >= minAfter && newAfter <= maxAfter) {
after = newAfter;
} else if (newAfter < minAfter && after !== minAfter) {
after = minAfter;
} else if (newAfter > maxAfter && after !== maxAfter) {
after = maxAfter;
}
};
const handleMouseMove = (event: PointerEvent) => {
const newAfter =
startAfter +
(orientation === 'col' ? startX - event.clientX : startY - event.clientY);
if (newAfter >= minAfter && newAfter <= maxAfter) {
after = newAfter;
} else if (newAfter < minAfter && after !== minAfter) {
after = minAfter;
} else if (newAfter > maxAfter && after !== maxAfter) {
after = maxAfter;
}
};
const handleMouseUp = () => {
window.removeEventListener('pointermove', handleMouseMove);
window.removeEventListener('pointerup', handleMouseUp);
};
const handleMouseUp = () => {
window.removeEventListener('pointermove', handleMouseMove);
window.removeEventListener('pointerup', handleMouseUp);
};
window.addEventListener('pointermove', handleMouseMove);
window.addEventListener('pointerup', handleMouseUp);
}
window.addEventListener('pointermove', handleMouseMove);
window.addEventListener('pointerup', handleMouseUp);
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="{orientation === 'col'
? 'w-1 h-full cursor-col-resize border-l'
: 'w-full h-1 cursor-row-resize border-t'} {orientation}"
on:pointerdown={handleMouseDown}
class="{orientation === 'col'
? 'w-1 h-full cursor-col-resize border-l'
: 'w-full h-1 cursor-row-resize border-t'} {orientation}"
on:pointerdown={handleMouseDown}
/>

View File

@@ -1,36 +1,36 @@
<script lang="ts">
import { isMac, isSafari } from '$lib/utils';
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { isMac, isSafari } from '$lib/utils';
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
export let key: string | undefined = undefined;
export let shift: boolean = false;
export let ctrl: boolean = false;
export let click: boolean = false;
export let key: string | undefined = undefined;
export let shift: boolean = false;
export let ctrl: boolean = false;
export let click: boolean = false;
let mac = false;
let safari = false;
let mac = false;
let safari = false;
onMount(() => {
mac = isMac();
safari = isSafari();
});
onMount(() => {
mac = isMac();
safari = isSafari();
});
</script>
<div
class="ml-auto pl-2 text-xs tracking-widest text-muted-foreground flex flex-row gap-0 items-baseline"
{...$$props}
class="ml-auto pl-2 text-xs tracking-widest text-muted-foreground flex flex-row gap-0 items-baseline"
{...$$props}
>
{#if shift}
<span></span>
{/if}
{#if ctrl}
<span>{mac && !safari ? '⌘' : $_('menu.ctrl') + '+'}</span>
{/if}
{#if key}
<span class={key === '+' ? 'font-medium text-sm/4' : ''}>{key}</span>
{/if}
{#if click}
<span>{$_('menu.click')}</span>
{/if}
{#if shift}
<span></span>
{/if}
{#if ctrl}
<span>{mac && !safari ? '⌘' : $_('menu.ctrl') + '+'}</span>
{/if}
{#if key}
<span class={key === '+' ? 'font-medium text-sm/4' : ''}>{key}</span>
{/if}
{#if click}
<span>{$_('menu.click')}</span>
{/if}
</div>

View File

@@ -1,18 +1,18 @@
<script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
export let label: string;
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
export let label: string;
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
</script>
<Tooltip.Root>
<Tooltip.Trigger {...$$restProps} aria-label={label}>
<slot />
</Tooltip.Trigger>
<Tooltip.Content {side}>
<div class="flex flex-row items-center">
<span>{label}</span>
<slot name="extra" />
</div>
</Tooltip.Content>
<Tooltip.Trigger {...$$restProps} aria-label={label}>
<slot />
</Tooltip.Trigger>
<Tooltip.Content {side}>
<div class="flex flex-row items-center">
<span>{label}</span>
<slot name="extra" />
</div>
</Tooltip.Content>
</Tooltip.Root>

View File

@@ -1,48 +1,48 @@
<script lang="ts">
import { settings } from '$lib/db';
import {
celsiusToFahrenheit,
getConvertedDistance,
getConvertedElevation,
getConvertedVelocity,
getDistanceUnits,
getElevationUnits,
getVelocityUnits,
secondsToHHMMSS
} from '$lib/units';
import { settings } from '$lib/db';
import {
celsiusToFahrenheit,
getConvertedDistance,
getConvertedElevation,
getConvertedVelocity,
getDistanceUnits,
getElevationUnits,
getVelocityUnits,
secondsToHHMMSS,
} from '$lib/units';
import { _ } from 'svelte-i18n';
import { _ } from 'svelte-i18n';
export let value: number;
export let type: 'distance' | 'elevation' | 'speed' | 'temperature' | 'time';
export let showUnits: boolean = true;
export let decimals: number | undefined = undefined;
export let value: number;
export let type: 'distance' | 'elevation' | 'speed' | 'temperature' | 'time';
export let showUnits: boolean = true;
export let decimals: number | undefined = undefined;
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
</script>
<span class={$$props.class}>
{#if type === 'distance'}
{getConvertedDistance(value, $distanceUnits).toFixed(decimals ?? 2)}
{showUnits ? getDistanceUnits($distanceUnits) : ''}
{:else if type === 'elevation'}
{getConvertedElevation(value, $distanceUnits).toFixed(decimals ?? 0)}
{showUnits ? getElevationUnits($distanceUnits) : ''}
{:else if type === 'speed'}
{#if $velocityUnits === 'speed'}
{getConvertedVelocity(value, $velocityUnits, $distanceUnits).toFixed(decimals ?? 2)}
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
{:else}
{secondsToHHMMSS(getConvertedVelocity(value, $velocityUnits, $distanceUnits))}
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
{/if}
{:else if type === 'temperature'}
{#if $temperatureUnits === 'celsius'}
{value} {showUnits ? $_('units.celsius') : ''}
{:else}
{celsiusToFahrenheit(value)} {showUnits ? $_('units.fahrenheit') : ''}
{/if}
{:else if type === 'time'}
{secondsToHHMMSS(value)}
{/if}
{#if type === 'distance'}
{getConvertedDistance(value, $distanceUnits).toFixed(decimals ?? 2)}
{showUnits ? getDistanceUnits($distanceUnits) : ''}
{:else if type === 'elevation'}
{getConvertedElevation(value, $distanceUnits).toFixed(decimals ?? 0)}
{showUnits ? getElevationUnits($distanceUnits) : ''}
{:else if type === 'speed'}
{#if $velocityUnits === 'speed'}
{getConvertedVelocity(value, $velocityUnits, $distanceUnits).toFixed(decimals ?? 2)}
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
{:else}
{secondsToHHMMSS(getConvertedVelocity(value, $velocityUnits, $distanceUnits))}
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
{/if}
{:else if type === 'temperature'}
{#if $temperatureUnits === 'celsius'}
{value} {showUnits ? $_('units.celsius') : ''}
{:else}
{celsiusToFahrenheit(value)} {showUnits ? $_('units.fahrenheit') : ''}
{/if}
{:else if type === 'time'}
{secondsToHHMMSS(value)}
{/if}
</span>

View File

@@ -1,20 +1,20 @@
<script lang="ts">
import { setContext } from 'svelte';
import { writable } from 'svelte/store';
import { setContext } from 'svelte';
import { writable } from 'svelte/store';
export let defaultState: 'open' | 'closed' = 'open';
export let side: 'left' | 'right' = 'right';
export let nohover: boolean = false;
export let slotInsideTrigger: boolean = true;
export let defaultState: 'open' | 'closed' = 'open';
export let side: 'left' | 'right' = 'right';
export let nohover: boolean = false;
export let slotInsideTrigger: boolean = true;
let open = writable<Record<string, boolean>>({});
let open = writable<Record<string, boolean>>({});
setContext('collapsible-tree-default-state', defaultState);
setContext('collapsible-tree-state', open);
setContext('collapsible-tree-side', side);
setContext('collapsible-tree-nohover', nohover);
setContext('collapsible-tree-parent-id', 'root');
setContext('collapsible-tree-slot-inside-trigger', slotInsideTrigger);
setContext('collapsible-tree-default-state', defaultState);
setContext('collapsible-tree-state', open);
setContext('collapsible-tree-side', side);
setContext('collapsible-tree-nohover', nohover);
setContext('collapsible-tree-parent-id', 'root');
setContext('collapsible-tree-slot-inside-trigger', slotInsideTrigger);
</script>
<slot />

View File

@@ -1,97 +1,97 @@
<script lang="ts">
import * as Collapsible from '$lib/components/ui/collapsible';
import { Button } from '$lib/components/ui/button';
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-svelte';
import { getContext, onMount, setContext } from 'svelte';
import { get, type Writable } from 'svelte/store';
import * as Collapsible from '$lib/components/ui/collapsible';
import { Button } from '$lib/components/ui/button';
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-svelte';
import { getContext, onMount, setContext } from 'svelte';
import { get, type Writable } from 'svelte/store';
export let id: string | number;
export let id: string | number;
let defaultState = getContext<'open' | 'closed'>('collapsible-tree-default-state');
let open = getContext<Writable<Record<string, boolean>>>('collapsible-tree-state');
let side = getContext<'left' | 'right'>('collapsible-tree-side');
let nohover = getContext<boolean>('collapsible-tree-nohover');
let slotInsideTrigger = getContext<boolean>('collapsible-tree-slot-inside-trigger');
let parentId = getContext<string>('collapsible-tree-parent-id');
let defaultState = getContext<'open' | 'closed'>('collapsible-tree-default-state');
let open = getContext<Writable<Record<string, boolean>>>('collapsible-tree-state');
let side = getContext<'left' | 'right'>('collapsible-tree-side');
let nohover = getContext<boolean>('collapsible-tree-nohover');
let slotInsideTrigger = getContext<boolean>('collapsible-tree-slot-inside-trigger');
let parentId = getContext<string>('collapsible-tree-parent-id');
let fullId = `${parentId}.${id}`;
setContext('collapsible-tree-parent-id', fullId);
let fullId = `${parentId}.${id}`;
setContext('collapsible-tree-parent-id', fullId);
onMount(() => {
if (!get(open).hasOwnProperty(fullId)) {
open.update((value) => {
value[fullId] = defaultState === 'open';
return value;
});
}
});
onMount(() => {
if (!get(open).hasOwnProperty(fullId)) {
open.update((value) => {
value[fullId] = defaultState === 'open';
return value;
});
}
});
export function openNode() {
open.update((value) => {
value[fullId] = true;
return value;
});
}
export function openNode() {
open.update((value) => {
value[fullId] = true;
return value;
});
}
</script>
<Collapsible.Root bind:open={$open[fullId]} class={$$props.class ?? ''}>
{#if slotInsideTrigger}
<Collapsible.Trigger class="w-full">
<Button
variant="ghost"
class="w-full flex flex-row {side === 'right'
? 'justify-between'
: 'justify-start'} py-0 px-1 h-fit {nohover
? 'hover:bg-background'
: ''} pointer-events-none"
>
{#if side === 'left'}
{#if $open[fullId]}
<ChevronDown size="16" class="shrink-0" />
{:else}
<ChevronRight size="16" class="shrink-0" />
{/if}
{/if}
<slot name="trigger" />
{#if side === 'right'}
{#if $open[fullId]}
<ChevronDown size="16" class="shrink-0" />
{:else}
<ChevronLeft size="16" class="shrink-0" />
{/if}
{/if}
</Button>
</Collapsible.Trigger>
{:else}
<Button
variant="ghost"
class="w-full flex flex-row {side === 'right'
? 'justify-between'
: 'justify-start'} py-0 px-1 h-fit {nohover ? 'hover:bg-background' : ''}"
>
{#if side === 'left'}
<Collapsible.Trigger>
{#if $open[fullId]}
<ChevronDown size="16" class="shrink-0" />
{:else}
<ChevronRight size="16" class="shrink-0" />
{/if}
</Collapsible.Trigger>
{/if}
<slot name="trigger" />
{#if side === 'right'}
<Collapsible.Trigger>
{#if $open[fullId]}
<ChevronDown size="16" class="shrink-0" />
{:else}
<ChevronLeft size="16" class="shrink-0" />
{/if}
</Collapsible.Trigger>
{/if}
</Button>
{/if}
{#if slotInsideTrigger}
<Collapsible.Trigger class="w-full">
<Button
variant="ghost"
class="w-full flex flex-row {side === 'right'
? 'justify-between'
: 'justify-start'} py-0 px-1 h-fit {nohover
? 'hover:bg-background'
: ''} pointer-events-none"
>
{#if side === 'left'}
{#if $open[fullId]}
<ChevronDown size="16" class="shrink-0" />
{:else}
<ChevronRight size="16" class="shrink-0" />
{/if}
{/if}
<slot name="trigger" />
{#if side === 'right'}
{#if $open[fullId]}
<ChevronDown size="16" class="shrink-0" />
{:else}
<ChevronLeft size="16" class="shrink-0" />
{/if}
{/if}
</Button>
</Collapsible.Trigger>
{:else}
<Button
variant="ghost"
class="w-full flex flex-row {side === 'right'
? 'justify-between'
: 'justify-start'} py-0 px-1 h-fit {nohover ? 'hover:bg-background' : ''}"
>
{#if side === 'left'}
<Collapsible.Trigger>
{#if $open[fullId]}
<ChevronDown size="16" class="shrink-0" />
{:else}
<ChevronRight size="16" class="shrink-0" />
{/if}
</Collapsible.Trigger>
{/if}
<slot name="trigger" />
{#if side === 'right'}
<Collapsible.Trigger>
{#if $open[fullId]}
<ChevronDown size="16" class="shrink-0" />
{:else}
<ChevronLeft size="16" class="shrink-0" />
{/if}
</Collapsible.Trigger>
{/if}
</Button>
{/if}
<Collapsible.Content class="ml-2">
<slot name="content" />
</Collapsible.Content>
<Collapsible.Content class="ml-2">
<slot name="content" />
</Collapsible.Content>
</Collapsible.Root>

View File

@@ -1,2 +1,2 @@
export { default as CollapsibleTree } from './CollapsibleTree.svelte';
export { default as CollapsibleTreeNode } from './CollapsibleTreeNode.svelte';
export { default as CollapsibleTreeNode } from './CollapsibleTreeNode.svelte';

View File

@@ -1,27 +1,27 @@
<script lang="ts">
import CustomControl from './CustomControl';
import { map } from '$lib/stores';
import CustomControl from './CustomControl';
import { map } from '$lib/stores';
export let position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' = 'top-right';
export let position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' = 'top-right';
let container: HTMLDivElement;
let control: CustomControl | undefined = undefined;
let container: HTMLDivElement;
let control: CustomControl | undefined = undefined;
$: if ($map && container) {
if (position.includes('right')) container.classList.add('float-right');
else container.classList.add('float-left');
container.classList.remove('hidden');
if (control === undefined) {
control = new CustomControl(container);
}
$map.addControl(control, position);
}
$: if ($map && container) {
if (position.includes('right')) container.classList.add('float-right');
else container.classList.add('float-left');
container.classList.remove('hidden');
if (control === undefined) {
control = new CustomControl(container);
}
$map.addControl(control, position);
}
</script>
<div
bind:this={container}
class="{$$props.class ||
''} clear-both translate-0 m-[10px] mb-0 last:mb-[10px] pointer-events-auto bg-background rounded shadow-md hidden"
bind:this={container}
class="{$$props.class ||
''} clear-both translate-0 m-[10px] mb-0 last:mb-[10px] pointer-events-auto bg-background rounded shadow-md hidden"
>
<slot />
<slot />
</div>

View File

@@ -17,4 +17,4 @@ export default class CustomControl implements IControl {
this._container?.parentNode?.removeChild(this._container);
this._map = undefined;
}
}
}

View File

@@ -1,82 +1,82 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { _ } from 'svelte-i18n';
export let module;
export let module;
</script>
<div class="markdown flex flex-col gap-3">
<svelte:component this={module} />
<svelte:component this={module} />
</div>
<style lang="postcss">
:global(.markdown) {
@apply text-muted-foreground;
}
:global(.markdown) {
@apply text-muted-foreground;
}
:global(.markdown h1) {
@apply text-foreground;
@apply text-3xl;
@apply font-semibold;
@apply mb-3 pt-6;
}
:global(.markdown h1) {
@apply text-foreground;
@apply text-3xl;
@apply font-semibold;
@apply mb-3 pt-6;
}
:global(.markdown h2) {
@apply text-foreground;
@apply text-2xl;
@apply font-semibold;
@apply pt-3;
}
:global(.markdown h2) {
@apply text-foreground;
@apply text-2xl;
@apply font-semibold;
@apply pt-3;
}
:global(.markdown h3) {
@apply text-foreground;
@apply text-lg;
@apply font-semibold;
@apply pt-1.5;
}
:global(.markdown h3) {
@apply text-foreground;
@apply text-lg;
@apply font-semibold;
@apply pt-1.5;
}
:global(.markdown p > button, .markdown li > button) {
@apply border;
@apply rounded-md;
@apply px-1;
}
:global(.markdown p > button, .markdown li > button) {
@apply border;
@apply rounded-md;
@apply px-1;
}
:global(.markdown > a) {
@apply text-link;
@apply hover:underline;
}
:global(.markdown > a) {
@apply text-link;
@apply hover:underline;
}
:global(.markdown p > a) {
@apply text-link;
@apply hover:underline;
}
:global(.markdown p > a) {
@apply text-link;
@apply hover:underline;
}
:global(.markdown li > a) {
@apply text-link;
@apply hover:underline;
}
:global(.markdown li > a) {
@apply text-link;
@apply hover:underline;
}
:global(.markdown kbd) {
@apply p-1;
@apply rounded-md;
@apply border;
}
:global(.markdown kbd) {
@apply p-1;
@apply rounded-md;
@apply border;
}
:global(.markdown ul) {
@apply list-disc;
@apply pl-4;
}
:global(.markdown ul) {
@apply list-disc;
@apply pl-4;
}
:global(.markdown ol) {
@apply list-decimal;
@apply pl-4;
}
:global(.markdown ol) {
@apply list-decimal;
@apply pl-4;
}
:global(.markdown li) {
@apply mt-1;
@apply first:mt-0;
}
:global(.markdown li) {
@apply mt-1;
@apply first:mt-0;
}
:global(.markdown hr) {
@apply my-5;
}
:global(.markdown hr) {
@apply my-5;
}
</style>

View File

@@ -1,25 +1,29 @@
<script lang="ts">
export let src: 'getting-started/interface' | 'tools/routing' | 'tools/split';
export let alt: string;
export let src: 'getting-started/interface' | 'tools/routing' | 'tools/split';
export let alt: string;
</script>
<div class="flex flex-col items-center py-6 w-full">
<div class="rounded-md overflow-hidden overflow-clip shadow-xl mx-auto">
{#if src === 'getting-started/interface'}
<enhanced:img
src="/src/lib/assets/img/docs/getting-started/interface.png"
{alt}
class="w-full max-w-3xl"
/>
{:else if src === 'tools/routing'}
<enhanced:img
src="/src/lib/assets/img/docs/tools/routing.png"
{alt}
class="w-full max-w-3xl"
/>
{:else if src === 'tools/split'}
<enhanced:img src="/src/lib/assets/img/docs/tools/split.png" {alt} class="w-full max-w-3xl" />
{/if}
</div>
<p class="text-center text-sm text-muted-foreground mt-2">{alt}</p>
<div class="rounded-md overflow-hidden overflow-clip shadow-xl mx-auto">
{#if src === 'getting-started/interface'}
<enhanced:img
src="/src/lib/assets/img/docs/getting-started/interface.png"
{alt}
class="w-full max-w-3xl"
/>
{:else if src === 'tools/routing'}
<enhanced:img
src="/src/lib/assets/img/docs/tools/routing.png"
{alt}
class="w-full max-w-3xl"
/>
{:else if src === 'tools/split'}
<enhanced:img
src="/src/lib/assets/img/docs/tools/split.png"
{alt}
class="w-full max-w-3xl"
/>
{/if}
</div>
<p class="text-center text-sm text-muted-foreground mt-2">{alt}</p>
</div>

View File

@@ -1,13 +1,13 @@
<script lang="ts">
import mapboxOutdoorsMap from '$lib/assets/img/home/mapbox-outdoors.png?enhanced';
import waymarkedMap from '$lib/assets/img/home/waymarked.png?enhanced';
import mapboxOutdoorsMap from '$lib/assets/img/home/mapbox-outdoors.png?enhanced';
import waymarkedMap from '$lib/assets/img/home/waymarked.png?enhanced';
</script>
<div class="relative h-80 aspect-square rounded-2xl shadow-xl overflow-clip">
<enhanced:img src={mapboxOutdoorsMap} alt="Mapbox Outdoors map screenshot." class="absolute" />
<enhanced:img
src={waymarkedMap}
alt="Waymarked Trails map screenshot."
class="absolute opacity-0 hover:opacity-100 transition-opacity duration-200"
/>
<enhanced:img src={mapboxOutdoorsMap} alt="Mapbox Outdoors map screenshot." class="absolute" />
<enhanced:img
src={waymarkedMap}
alt="Waymarked Trails map screenshot."
class="absolute opacity-0 hover:opacity-100 transition-opacity duration-200"
/>
</div>

View File

@@ -1,18 +1,18 @@
<script lang="ts">
export let type: 'note' | 'warning' = 'note';
export let type: 'note' | 'warning' = 'note';
</script>
<div
class="bg-secondary border-l-8 {type === 'note'
? 'border-link'
: 'border-destructive'} p-2 text-sm rounded-md"
class="bg-secondary border-l-8 {type === 'note'
? 'border-link'
: 'border-destructive'} p-2 text-sm rounded-md"
>
<slot />
<slot />
</div>
<style lang="postcss">
div :global(a) {
@apply text-link;
@apply hover:underline;
}
div :global(a) {
@apply text-link;
@apply hover:underline;
}
</style>

View File

@@ -1,39 +1,64 @@
import { File, FilePen, View, type Icon, Settings, Pencil, MapPin, Scissors, CalendarClock, Group, Ungroup, Filter, SquareDashedMousePointer, MountainSnow } from "lucide-svelte";
import type { ComponentType } from "svelte";
import {
File,
FilePen,
View,
type Icon,
Settings,
Pencil,
MapPin,
Scissors,
CalendarClock,
Group,
Ungroup,
Filter,
SquareDashedMousePointer,
MountainSnow,
} from 'lucide-svelte';
import type { ComponentType } from 'svelte';
export const guides: Record<string, string[]> = {
'getting-started': [],
menu: ['file', 'edit', 'view', 'settings'],
'files-and-stats': [],
toolbar: ['routing', 'poi', 'scissors', 'time', 'merge', 'extract', 'elevation', 'minify', 'clean'],
toolbar: [
'routing',
'poi',
'scissors',
'time',
'merge',
'extract',
'elevation',
'minify',
'clean',
],
'map-controls': [],
'gpx': [],
'integration': [],
'faq': [],
gpx: [],
integration: [],
faq: [],
};
export const guideIcons: Record<string, string | ComponentType<Icon>> = {
"getting-started": "🚀",
"menu": "📂 ⚙️",
"file": File,
"edit": FilePen,
"view": View,
"settings": Settings,
"files-and-stats": "🗂 📈",
"toolbar": "🧰",
"routing": Pencil,
"poi": MapPin,
"scissors": Scissors,
"time": CalendarClock,
"merge": Group,
"extract": Ungroup,
"elevation": MountainSnow,
"minify": Filter,
"clean": SquareDashedMousePointer,
"map-controls": "🗺",
"gpx": "💾",
"integration": "{ 👩‍💻 }",
"faq": "🔮",
'getting-started': '🚀',
menu: '📂 ⚙️',
file: File,
edit: FilePen,
view: View,
settings: Settings,
'files-and-stats': '🗂 📈',
toolbar: '🧰',
routing: Pencil,
poi: MapPin,
scissors: Scissors,
time: CalendarClock,
merge: Group,
extract: Ungroup,
elevation: MountainSnow,
minify: Filter,
clean: SquareDashedMousePointer,
'map-controls': '🗺',
gpx: '💾',
integration: '{ 👩‍💻 }',
faq: '🔮',
};
export function getPreviousGuide(currentGuide: string): string | undefined {
@@ -96,4 +121,4 @@ export function getNextGuide(currentGuide: string): string | undefined {
return undefined;
}
}
}
}

View File

@@ -1,267 +1,271 @@
<script lang="ts">
import GPXLayers from '$lib/components/gpx-layer/GPXLayers.svelte';
import ElevationProfile from '$lib/components/ElevationProfile.svelte';
import FileList from '$lib/components/file-list/FileList.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import Map from '$lib/components/Map.svelte';
import LayerControl from '$lib/components/layer-control/LayerControl.svelte';
import OpenIn from '$lib/components/embedding/OpenIn.svelte';
import {
gpxStatistics,
slicedGPXStatistics,
embedding,
loadFile,
map,
updateGPXData
} from '$lib/stores';
import { onDestroy, onMount } from 'svelte';
import { fileObservers, settings, GPXStatisticsTree } from '$lib/db';
import { readable } from 'svelte/store';
import type { GPXFile } from 'gpx';
import { selection } from '$lib/components/file-list/Selection';
import { ListFileItem } from '$lib/components/file-list/FileList';
import {
allowedEmbeddingBasemaps,
getFilesFromEmbeddingOptions,
type EmbeddingOptions
} from './Embedding';
import { mode, setMode } from 'mode-watcher';
import { browser } from '$app/environment';
import GPXLayers from '$lib/components/gpx-layer/GPXLayers.svelte';
import ElevationProfile from '$lib/components/ElevationProfile.svelte';
import FileList from '$lib/components/file-list/FileList.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import Map from '$lib/components/Map.svelte';
import LayerControl from '$lib/components/layer-control/LayerControl.svelte';
import OpenIn from '$lib/components/embedding/OpenIn.svelte';
import {
gpxStatistics,
slicedGPXStatistics,
embedding,
loadFile,
map,
updateGPXData,
} from '$lib/stores';
import { onDestroy, onMount } from 'svelte';
import { fileObservers, settings, GPXStatisticsTree } from '$lib/db';
import { readable } from 'svelte/store';
import type { GPXFile } from 'gpx';
import { selection } from '$lib/components/file-list/Selection';
import { ListFileItem } from '$lib/components/file-list/FileList';
import {
allowedEmbeddingBasemaps,
getFilesFromEmbeddingOptions,
type EmbeddingOptions,
} from './Embedding';
import { mode, setMode } from 'mode-watcher';
import { browser } from '$app/environment';
$embedding = true;
$embedding = true;
const {
currentBasemap,
distanceUnits,
velocityUnits,
temperatureUnits,
fileOrder,
distanceMarkers,
directionMarkers
} = settings;
const {
currentBasemap,
distanceUnits,
velocityUnits,
temperatureUnits,
fileOrder,
distanceMarkers,
directionMarkers,
} = settings;
export let useHash = true;
export let options: EmbeddingOptions;
export let hash: string;
export let useHash = true;
export let options: EmbeddingOptions;
export let hash: string;
let prevSettings = {
distanceMarkers: false,
directionMarkers: false,
distanceUnits: 'metric',
velocityUnits: 'speed',
temperatureUnits: 'celsius',
theme: 'system'
};
let prevSettings = {
distanceMarkers: false,
directionMarkers: false,
distanceUnits: 'metric',
velocityUnits: 'speed',
temperatureUnits: 'celsius',
theme: 'system',
};
function applyOptions() {
fileObservers.update(($fileObservers) => {
$fileObservers.clear();
return $fileObservers;
});
function applyOptions() {
fileObservers.update(($fileObservers) => {
$fileObservers.clear();
return $fileObservers;
});
let downloads: Promise<GPXFile | null>[] = [];
getFilesFromEmbeddingOptions(options).forEach((url) => {
downloads.push(
fetch(url)
.then((response) => response.blob())
.then((blob) => new File([blob], url.split('/').pop() ?? url))
.then(loadFile)
);
});
let downloads: Promise<GPXFile | null>[] = [];
getFilesFromEmbeddingOptions(options).forEach((url) => {
downloads.push(
fetch(url)
.then((response) => response.blob())
.then((blob) => new File([blob], url.split('/').pop() ?? url))
.then(loadFile)
);
});
Promise.all(downloads).then((files) => {
let ids: string[] = [];
let bounds = {
southWest: {
lat: 90,
lon: 180
},
northEast: {
lat: -90,
lon: -180
}
};
Promise.all(downloads).then((files) => {
let ids: string[] = [];
let bounds = {
southWest: {
lat: 90,
lon: 180,
},
northEast: {
lat: -90,
lon: -180,
},
};
fileObservers.update(($fileObservers) => {
files.forEach((file, index) => {
if (file === null) {
return;
}
fileObservers.update(($fileObservers) => {
files.forEach((file, index) => {
if (file === null) {
return;
}
let id = `gpx-${index}-embed`;
file._data.id = id;
let statistics = new GPXStatisticsTree(file);
let id = `gpx-${index}-embed`;
file._data.id = id;
let statistics = new GPXStatisticsTree(file);
$fileObservers.set(
id,
readable({
file,
statistics
})
);
$fileObservers.set(
id,
readable({
file,
statistics,
})
);
ids.push(id);
let fileBounds = statistics.getStatisticsFor(new ListFileItem(id)).global.bounds;
ids.push(id);
let fileBounds = statistics.getStatisticsFor(new ListFileItem(id)).global
.bounds;
bounds.southWest.lat = Math.min(bounds.southWest.lat, fileBounds.southWest.lat);
bounds.southWest.lon = Math.min(bounds.southWest.lon, fileBounds.southWest.lon);
bounds.northEast.lat = Math.max(bounds.northEast.lat, fileBounds.northEast.lat);
bounds.northEast.lon = Math.max(bounds.northEast.lon, fileBounds.northEast.lon);
});
bounds.southWest.lat = Math.min(bounds.southWest.lat, fileBounds.southWest.lat);
bounds.southWest.lon = Math.min(bounds.southWest.lon, fileBounds.southWest.lon);
bounds.northEast.lat = Math.max(bounds.northEast.lat, fileBounds.northEast.lat);
bounds.northEast.lon = Math.max(bounds.northEast.lon, fileBounds.northEast.lon);
});
return $fileObservers;
});
return $fileObservers;
});
$fileOrder = [...$fileOrder.filter((id) => !id.includes('embed')), ...ids];
$fileOrder = [...$fileOrder.filter((id) => !id.includes('embed')), ...ids];
selection.update(($selection) => {
$selection.clear();
ids.forEach((id) => {
$selection.toggle(new ListFileItem(id));
});
return $selection;
});
selection.update(($selection) => {
$selection.clear();
ids.forEach((id) => {
$selection.toggle(new ListFileItem(id));
});
return $selection;
});
if (hash.length === 0) {
map.subscribe(($map) => {
if ($map) {
$map.fitBounds(
[
bounds.southWest.lon,
bounds.southWest.lat,
bounds.northEast.lon,
bounds.northEast.lat
],
{
padding: 80,
linear: true,
easing: () => 1
}
);
}
});
}
});
if (hash.length === 0) {
map.subscribe(($map) => {
if ($map) {
$map.fitBounds(
[
bounds.southWest.lon,
bounds.southWest.lat,
bounds.northEast.lon,
bounds.northEast.lat,
],
{
padding: 80,
linear: true,
easing: () => 1,
}
);
}
});
}
});
if (options.basemap !== $currentBasemap && allowedEmbeddingBasemaps.includes(options.basemap)) {
$currentBasemap = options.basemap;
}
if (
options.basemap !== $currentBasemap &&
allowedEmbeddingBasemaps.includes(options.basemap)
) {
$currentBasemap = options.basemap;
}
if (options.distanceMarkers !== $distanceMarkers) {
$distanceMarkers = options.distanceMarkers;
}
if (options.distanceMarkers !== $distanceMarkers) {
$distanceMarkers = options.distanceMarkers;
}
if (options.directionMarkers !== $directionMarkers) {
$directionMarkers = options.directionMarkers;
}
if (options.directionMarkers !== $directionMarkers) {
$directionMarkers = options.directionMarkers;
}
if (options.distanceUnits !== $distanceUnits) {
$distanceUnits = options.distanceUnits;
}
if (options.distanceUnits !== $distanceUnits) {
$distanceUnits = options.distanceUnits;
}
if (options.velocityUnits !== $velocityUnits) {
$velocityUnits = options.velocityUnits;
}
if (options.velocityUnits !== $velocityUnits) {
$velocityUnits = options.velocityUnits;
}
if (options.temperatureUnits !== $temperatureUnits) {
$temperatureUnits = options.temperatureUnits;
}
if (options.temperatureUnits !== $temperatureUnits) {
$temperatureUnits = options.temperatureUnits;
}
if (options.theme !== $mode) {
setMode(options.theme);
}
}
if (options.theme !== $mode) {
setMode(options.theme);
}
}
onMount(() => {
prevSettings.distanceMarkers = $distanceMarkers;
prevSettings.directionMarkers = $directionMarkers;
prevSettings.distanceUnits = $distanceUnits;
prevSettings.velocityUnits = $velocityUnits;
prevSettings.temperatureUnits = $temperatureUnits;
prevSettings.theme = $mode ?? 'system';
});
onMount(() => {
prevSettings.distanceMarkers = $distanceMarkers;
prevSettings.directionMarkers = $directionMarkers;
prevSettings.distanceUnits = $distanceUnits;
prevSettings.velocityUnits = $velocityUnits;
prevSettings.temperatureUnits = $temperatureUnits;
prevSettings.theme = $mode ?? 'system';
});
$: if (browser && options) {
applyOptions();
}
$: if (browser && options) {
applyOptions();
}
$: if ($fileOrder) {
updateGPXData();
}
$: if ($fileOrder) {
updateGPXData();
}
onDestroy(() => {
if ($distanceMarkers !== prevSettings.distanceMarkers) {
$distanceMarkers = prevSettings.distanceMarkers;
}
onDestroy(() => {
if ($distanceMarkers !== prevSettings.distanceMarkers) {
$distanceMarkers = prevSettings.distanceMarkers;
}
if ($directionMarkers !== prevSettings.directionMarkers) {
$directionMarkers = prevSettings.directionMarkers;
}
if ($directionMarkers !== prevSettings.directionMarkers) {
$directionMarkers = prevSettings.directionMarkers;
}
if ($distanceUnits !== prevSettings.distanceUnits) {
$distanceUnits = prevSettings.distanceUnits;
}
if ($distanceUnits !== prevSettings.distanceUnits) {
$distanceUnits = prevSettings.distanceUnits;
}
if ($velocityUnits !== prevSettings.velocityUnits) {
$velocityUnits = prevSettings.velocityUnits;
}
if ($velocityUnits !== prevSettings.velocityUnits) {
$velocityUnits = prevSettings.velocityUnits;
}
if ($temperatureUnits !== prevSettings.temperatureUnits) {
$temperatureUnits = prevSettings.temperatureUnits;
}
if ($temperatureUnits !== prevSettings.temperatureUnits) {
$temperatureUnits = prevSettings.temperatureUnits;
}
if ($mode !== prevSettings.theme) {
setMode(prevSettings.theme);
}
if ($mode !== prevSettings.theme) {
setMode(prevSettings.theme);
}
$selection.clear();
$fileObservers.clear();
$fileOrder = $fileOrder.filter((id) => !id.includes('embed'));
});
$selection.clear();
$fileObservers.clear();
$fileOrder = $fileOrder.filter((id) => !id.includes('embed'));
});
</script>
<div class="absolute flex flex-col h-full w-full border rounded-xl overflow-clip">
<div class="grow relative">
<Map
class="h-full {$fileObservers.size > 1 ? 'horizontal' : ''}"
accessToken={options.token}
geocoder={false}
geolocate={false}
hash={useHash}
/>
<OpenIn bind:files={options.files} bind:ids={options.ids} />
<LayerControl />
<GPXLayers />
{#if $fileObservers.size > 1}
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
<FileList orientation="horizontal" />
</div>
{/if}
</div>
<div
class="{options.elevation.show ? '' : 'h-10'} flex flex-row gap-2 px-2 sm:px-4"
style={options.elevation.show ? `height: ${options.elevation.height}px` : ''}
>
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
panelSize={options.elevation.height}
orientation={options.elevation.show ? 'vertical' : 'horizontal'}
/>
{#if options.elevation.show}
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
additionalDatasets={[
options.elevation.speed ? 'speed' : null,
options.elevation.hr ? 'hr' : null,
options.elevation.cad ? 'cad' : null,
options.elevation.temp ? 'temp' : null,
options.elevation.power ? 'power' : null
].filter((dataset) => dataset !== null)}
elevationFill={options.elevation.fill}
showControls={options.elevation.controls}
/>
{/if}
</div>
<div class="grow relative">
<Map
class="h-full {$fileObservers.size > 1 ? 'horizontal' : ''}"
accessToken={options.token}
geocoder={false}
geolocate={false}
hash={useHash}
/>
<OpenIn bind:files={options.files} bind:ids={options.ids} />
<LayerControl />
<GPXLayers />
{#if $fileObservers.size > 1}
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
<FileList orientation="horizontal" />
</div>
{/if}
</div>
<div
class="{options.elevation.show ? '' : 'h-10'} flex flex-row gap-2 px-2 sm:px-4"
style={options.elevation.show ? `height: ${options.elevation.height}px` : ''}
>
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
panelSize={options.elevation.height}
orientation={options.elevation.show ? 'vertical' : 'horizontal'}
/>
{#if options.elevation.show}
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
additionalDatasets={[
options.elevation.speed ? 'speed' : null,
options.elevation.hr ? 'hr' : null,
options.elevation.cad ? 'cad' : null,
options.elevation.temp ? 'temp' : null,
options.elevation.power ? 'power' : null,
].filter((dataset) => dataset !== null)}
elevationFill={options.elevation.fill}
showControls={options.elevation.controls}
/>
{/if}
</div>
</div>

View File

@@ -39,14 +39,14 @@ export const defaultEmbeddingOptions = {
hr: false,
cad: false,
temp: false,
power: false
power: false,
},
distanceMarkers: false,
directionMarkers: false,
distanceUnits: 'metric',
velocityUnits: 'speed',
temperatureUnits: 'celsius',
theme: 'system'
theme: 'system',
};
export function getDefaultEmbeddingOptions(): EmbeddingOptions {
@@ -59,7 +59,11 @@ export function getMergedEmbeddingOptions(
): EmbeddingOptions {
const mergedOptions = JSON.parse(JSON.stringify(defaultOptions));
for (const key in options) {
if (typeof options[key] === 'object' && options[key] !== null && !Array.isArray(options[key])) {
if (
typeof options[key] === 'object' &&
options[key] !== null &&
!Array.isArray(options[key])
) {
mergedOptions[key] = getMergedEmbeddingOptions(options[key], defaultOptions[key]);
} else {
mergedOptions[key] = options[key];
@@ -79,7 +83,10 @@ export function getCleanedEmbeddingOptions(
cleanedOptions[key] !== null &&
!Array.isArray(cleanedOptions[key])
) {
cleanedOptions[key] = getCleanedEmbeddingOptions(cleanedOptions[key], defaultOptions[key]);
cleanedOptions[key] = getCleanedEmbeddingOptions(
cleanedOptions[key],
defaultOptions[key]
);
if (Object.keys(cleanedOptions[key]).length === 0) {
delete cleanedOptions[key];
}
@@ -141,7 +148,7 @@ export function convertOldEmbeddingOptions(options: URLSearchParams): any {
}
if (options.has('slope')) {
newOptions.elevation = {
fill: 'slope'
fill: 'slope',
};
}
return newOptions;

View File

@@ -1,328 +1,339 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Label } from '$lib/components/ui/label';
import { Input } from '$lib/components/ui/input';
import * as Select from '$lib/components/ui/select';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as RadioGroup from '$lib/components/ui/radio-group';
import {
Zap,
HeartPulse,
Orbit,
Thermometer,
SquareActivity,
Coins,
Milestone,
Video
} from 'lucide-svelte';
import { _ } from 'svelte-i18n';
import {
allowedEmbeddingBasemaps,
getCleanedEmbeddingOptions,
getDefaultEmbeddingOptions
} from './Embedding';
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
import Embedding from './Embedding.svelte';
import { map } from '$lib/stores';
import { tick } from 'svelte';
import { base } from '$app/paths';
import * as Card from '$lib/components/ui/card';
import { Label } from '$lib/components/ui/label';
import { Input } from '$lib/components/ui/input';
import * as Select from '$lib/components/ui/select';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as RadioGroup from '$lib/components/ui/radio-group';
import {
Zap,
HeartPulse,
Orbit,
Thermometer,
SquareActivity,
Coins,
Milestone,
Video,
} from 'lucide-svelte';
import { _ } from 'svelte-i18n';
import {
allowedEmbeddingBasemaps,
getCleanedEmbeddingOptions,
getDefaultEmbeddingOptions,
} from './Embedding';
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
import Embedding from './Embedding.svelte';
import { map } from '$lib/stores';
import { tick } from 'svelte';
import { base } from '$app/paths';
let options = getDefaultEmbeddingOptions();
options.token = 'YOUR_MAPBOX_TOKEN';
options.files = [
'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx'
];
let options = getDefaultEmbeddingOptions();
options.token = 'YOUR_MAPBOX_TOKEN';
options.files = [
'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx',
];
let files = options.files[0];
$: {
let urls = files.split(',');
urls = urls.filter((url) => url.length > 0);
if (JSON.stringify(urls) !== JSON.stringify(options.files)) {
options.files = urls;
}
}
let driveIds = '';
$: {
let ids = driveIds.split(',');
ids = ids.filter((id) => id.length > 0);
if (JSON.stringify(ids) !== JSON.stringify(options.ids)) {
options.ids = ids;
}
}
let files = options.files[0];
$: {
let urls = files.split(',');
urls = urls.filter((url) => url.length > 0);
if (JSON.stringify(urls) !== JSON.stringify(options.files)) {
options.files = urls;
}
}
let driveIds = '';
$: {
let ids = driveIds.split(',');
ids = ids.filter((id) => id.length > 0);
if (JSON.stringify(ids) !== JSON.stringify(options.ids)) {
options.ids = ids;
}
}
let manualCamera = false;
let manualCamera = false;
let zoom = '0';
let lat = '0';
let lon = '0';
let bearing = '0';
let pitch = '0';
let zoom = '0';
let lat = '0';
let lon = '0';
let bearing = '0';
let pitch = '0';
$: hash = manualCamera ? `#${zoom}/${lat}/${lon}/${bearing}/${pitch}` : '';
$: hash = manualCamera ? `#${zoom}/${lat}/${lon}/${bearing}/${pitch}` : '';
$: iframeOptions =
options.token.length === 0 || options.token === 'YOUR_MAPBOX_TOKEN'
? Object.assign({}, options, { token: PUBLIC_MAPBOX_TOKEN })
: options;
$: iframeOptions =
options.token.length === 0 || options.token === 'YOUR_MAPBOX_TOKEN'
? Object.assign({}, options, { token: PUBLIC_MAPBOX_TOKEN })
: options;
async function resizeMap() {
if ($map) {
await tick();
$map.resize();
}
}
async function resizeMap() {
if ($map) {
await tick();
$map.resize();
}
}
$: if (options.elevation.height || options.elevation.show) {
resizeMap();
}
$: if (options.elevation.height || options.elevation.show) {
resizeMap();
}
function updateCamera() {
if ($map) {
let center = $map.getCenter();
lat = center.lat.toFixed(4);
lon = center.lng.toFixed(4);
zoom = $map.getZoom().toFixed(2);
bearing = $map.getBearing().toFixed(1);
pitch = $map.getPitch().toFixed(0);
}
}
function updateCamera() {
if ($map) {
let center = $map.getCenter();
lat = center.lat.toFixed(4);
lon = center.lng.toFixed(4);
zoom = $map.getZoom().toFixed(2);
bearing = $map.getBearing().toFixed(1);
pitch = $map.getPitch().toFixed(0);
}
}
$: if ($map) {
$map.on('moveend', updateCamera);
}
$: if ($map) {
$map.on('moveend', updateCamera);
}
</script>
<Card.Root id="embedding-playground">
<Card.Header>
<Card.Title>{$_('embedding.title')}</Card.Title>
</Card.Header>
<Card.Content>
<fieldset class="flex flex-col gap-3">
<Label for="token">{$_('embedding.mapbox_token')}</Label>
<Input id="token" type="text" class="h-8" bind:value={options.token} />
<Label for="file_urls">{$_('embedding.file_urls')}</Label>
<Input id="file_urls" type="text" class="h-8" bind:value={files} />
<Label for="drive_ids">{$_('embedding.drive_ids')}</Label>
<Input id="drive_ids" type="text" class="h-8" bind:value={driveIds} />
<Label for="basemap">{$_('embedding.basemap')}</Label>
<Select.Root
selected={{ value: options.basemap, label: $_(`layers.label.${options.basemap}`) }}
onSelectedChange={(selected) => {
if (selected?.value) {
options.basemap = selected?.value;
}
}}
>
<Select.Trigger id="basemap" class="w-full h-8">
<Select.Value />
</Select.Trigger>
<Select.Content class="max-h-60 overflow-y-scroll">
{#each allowedEmbeddingBasemaps as basemap}
<Select.Item value={basemap}>{$_(`layers.label.${basemap}`)}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<div class="flex flex-row items-center gap-2">
<Label for="profile">{$_('menu.elevation_profile')}</Label>
<Checkbox id="profile" bind:checked={options.elevation.show} />
</div>
{#if options.elevation.show}
<div class="grid grid-cols-2 gap-x-6 gap-y-3 rounded-md border p-3 mt-1">
<Label class="flex flex-row items-center gap-2">
{$_('embedding.height')}
<Input type="number" bind:value={options.elevation.height} class="h-8 w-20" />
</Label>
<div class="flex flex-row items-center gap-2">
<span class="shrink-0">
{$_('embedding.fill_by')}
</span>
<Select.Root
selected={{ value: 'none', label: $_('embedding.none') }}
onSelectedChange={(selected) => {
let value = selected?.value;
if (value === 'none') {
options.elevation.fill = undefined;
} else if (value === 'slope' || value === 'surface' || value === 'highway') {
options.elevation.fill = value;
}
}}
>
<Select.Trigger class="grow h-8">
<Select.Value />
</Select.Trigger>
<Select.Content>
<Select.Item value="slope">{$_('quantities.slope')}</Select.Item>
<Select.Item value="surface">{$_('quantities.surface')}</Select.Item>
<Select.Item value="highway">{$_('quantities.highway')}</Select.Item>
<Select.Item value="none">{$_('embedding.none')}</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="controls" bind:checked={options.elevation.controls} />
<Label for="controls">{$_('embedding.show_controls')}</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="show-speed" bind:checked={options.elevation.speed} />
<Label for="show-speed" class="flex flex-row items-center gap-1">
<Zap size="16" />
{$_('quantities.speed')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="show-hr" bind:checked={options.elevation.hr} />
<Label for="show-hr" class="flex flex-row items-center gap-1">
<HeartPulse size="16" />
{$_('quantities.heartrate')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="show-cad" bind:checked={options.elevation.cad} />
<Label for="show-cad" class="flex flex-row items-center gap-1">
<Orbit size="16" />
{$_('quantities.cadence')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="show-temp" bind:checked={options.elevation.temp} />
<Label for="show-temp" class="flex flex-row items-center gap-1">
<Thermometer size="16" />
{$_('quantities.temperature')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="show-power" bind:checked={options.elevation.power} />
<Label for="show-power" class="flex flex-row items-center gap-1">
<SquareActivity size="16" />
{$_('quantities.power')}
</Label>
</div>
</div>
{/if}
<div class="flex flex-row items-center gap-2">
<Checkbox id="distance-markers" bind:checked={options.distanceMarkers} />
<Label for="distance-markers" class="flex flex-row items-center gap-1">
<Coins size="16" />
{$_('menu.distance_markers')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="direction-markers" bind:checked={options.directionMarkers} />
<Label for="direction-markers" class="flex flex-row items-center gap-1">
<Milestone size="16" />
{$_('menu.direction_markers')}
</Label>
</div>
<div class="flex flex-row flex-wrap justify-between gap-3">
<Label class="flex flex-col items-start gap-2">
{$_('menu.distance_units')}
<RadioGroup.Root bind:value={options.distanceUnits}>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="metric" id="metric" />
<Label for="metric">{$_('menu.metric')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="imperial" id="imperial" />
<Label for="imperial">{$_('menu.imperial')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="nautical" id="nautical" />
<Label for="nautical">{$_('menu.nautical')}</Label>
</div>
</RadioGroup.Root>
</Label>
<Label class="flex flex-col items-start gap-2">
{$_('menu.velocity_units')}
<RadioGroup.Root bind:value={options.velocityUnits}>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="speed" id="speed" />
<Label for="speed">{$_('quantities.speed')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="pace" id="pace" />
<Label for="pace">{$_('quantities.pace')}</Label>
</div>
</RadioGroup.Root>
</Label>
<Label class="flex flex-col items-start gap-2">
{$_('menu.temperature_units')}
<RadioGroup.Root bind:value={options.temperatureUnits}>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="celsius" id="celsius" />
<Label for="celsius">{$_('menu.celsius')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="fahrenheit" id="fahrenheit" />
<Label for="fahrenheit">{$_('menu.fahrenheit')}</Label>
</div>
</RadioGroup.Root>
</Label>
</div>
<Label class="flex flex-col items-start gap-2">
{$_('menu.mode')}
<RadioGroup.Root bind:value={options.theme} class="flex flex-row">
<div class="flex items-center space-x-2">
<RadioGroup.Item value="system" id="system" />
<Label for="system">{$_('menu.system')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="light" id="light" />
<Label for="light">{$_('menu.light')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="dark" id="dark" />
<Label for="dark">{$_('menu.dark')}</Label>
</div>
</RadioGroup.Root>
</Label>
<div class="flex flex-col gap-3 p-3 border rounded-md">
<div class="flex flex-row items-center gap-2">
<Checkbox id="manual-camera" bind:checked={manualCamera} />
<Label for="manual-camera" class="flex flex-row items-center gap-1">
<Video size="16" />
{$_('embedding.manual_camera')}
</Label>
</div>
<p class="text-sm text-muted-foreground">
{$_('embedding.manual_camera_description')}
</p>
<div class="flex flex-row flex-wrap items-center gap-6">
<Label class="flex flex-col gap-1">
<span>{$_('embedding.latitude')}</span>
<span>{lat}</span>
</Label>
<Label class="flex flex-col gap-1">
<span>{$_('embedding.longitude')}</span>
<span>{lon}</span>
</Label>
<Label class="flex flex-col gap-1">
<span>{$_('embedding.zoom')}</span>
<span>{zoom}</span>
</Label>
<Label class="flex flex-col gap-1">
<span>{$_('embedding.bearing')}</span>
<span>{bearing}</span>
</Label>
<Label class="flex flex-col gap-1">
<span>{$_('embedding.pitch')}</span>
<span>{pitch}</span>
</Label>
</div>
</div>
<Label>
{$_('embedding.preview')}
</Label>
<div class="relative h-[600px]">
<Embedding bind:options={iframeOptions} bind:hash useHash={false} />
</div>
<Label>
{$_('embedding.code')}
</Label>
<pre class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">
<Card.Header>
<Card.Title>{$_('embedding.title')}</Card.Title>
</Card.Header>
<Card.Content>
<fieldset class="flex flex-col gap-3">
<Label for="token">{$_('embedding.mapbox_token')}</Label>
<Input id="token" type="text" class="h-8" bind:value={options.token} />
<Label for="file_urls">{$_('embedding.file_urls')}</Label>
<Input id="file_urls" type="text" class="h-8" bind:value={files} />
<Label for="drive_ids">{$_('embedding.drive_ids')}</Label>
<Input id="drive_ids" type="text" class="h-8" bind:value={driveIds} />
<Label for="basemap">{$_('embedding.basemap')}</Label>
<Select.Root
selected={{ value: options.basemap, label: $_(`layers.label.${options.basemap}`) }}
onSelectedChange={(selected) => {
if (selected?.value) {
options.basemap = selected?.value;
}
}}
>
<Select.Trigger id="basemap" class="w-full h-8">
<Select.Value />
</Select.Trigger>
<Select.Content class="max-h-60 overflow-y-scroll">
{#each allowedEmbeddingBasemaps as basemap}
<Select.Item value={basemap}>{$_(`layers.label.${basemap}`)}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<div class="flex flex-row items-center gap-2">
<Label for="profile">{$_('menu.elevation_profile')}</Label>
<Checkbox id="profile" bind:checked={options.elevation.show} />
</div>
{#if options.elevation.show}
<div class="grid grid-cols-2 gap-x-6 gap-y-3 rounded-md border p-3 mt-1">
<Label class="flex flex-row items-center gap-2">
{$_('embedding.height')}
<Input
type="number"
bind:value={options.elevation.height}
class="h-8 w-20"
/>
</Label>
<div class="flex flex-row items-center gap-2">
<span class="shrink-0">
{$_('embedding.fill_by')}
</span>
<Select.Root
selected={{ value: 'none', label: $_('embedding.none') }}
onSelectedChange={(selected) => {
let value = selected?.value;
if (value === 'none') {
options.elevation.fill = undefined;
} else if (
value === 'slope' ||
value === 'surface' ||
value === 'highway'
) {
options.elevation.fill = value;
}
}}
>
<Select.Trigger class="grow h-8">
<Select.Value />
</Select.Trigger>
<Select.Content>
<Select.Item value="slope">{$_('quantities.slope')}</Select.Item>
<Select.Item value="surface">{$_('quantities.surface')}</Select.Item
>
<Select.Item value="highway">{$_('quantities.highway')}</Select.Item
>
<Select.Item value="none">{$_('embedding.none')}</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="controls" bind:checked={options.elevation.controls} />
<Label for="controls">{$_('embedding.show_controls')}</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="show-speed" bind:checked={options.elevation.speed} />
<Label for="show-speed" class="flex flex-row items-center gap-1">
<Zap size="16" />
{$_('quantities.speed')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="show-hr" bind:checked={options.elevation.hr} />
<Label for="show-hr" class="flex flex-row items-center gap-1">
<HeartPulse size="16" />
{$_('quantities.heartrate')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="show-cad" bind:checked={options.elevation.cad} />
<Label for="show-cad" class="flex flex-row items-center gap-1">
<Orbit size="16" />
{$_('quantities.cadence')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="show-temp" bind:checked={options.elevation.temp} />
<Label for="show-temp" class="flex flex-row items-center gap-1">
<Thermometer size="16" />
{$_('quantities.temperature')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="show-power" bind:checked={options.elevation.power} />
<Label for="show-power" class="flex flex-row items-center gap-1">
<SquareActivity size="16" />
{$_('quantities.power')}
</Label>
</div>
</div>
{/if}
<div class="flex flex-row items-center gap-2">
<Checkbox id="distance-markers" bind:checked={options.distanceMarkers} />
<Label for="distance-markers" class="flex flex-row items-center gap-1">
<Coins size="16" />
{$_('menu.distance_markers')}
</Label>
</div>
<div class="flex flex-row items-center gap-2">
<Checkbox id="direction-markers" bind:checked={options.directionMarkers} />
<Label for="direction-markers" class="flex flex-row items-center gap-1">
<Milestone size="16" />
{$_('menu.direction_markers')}
</Label>
</div>
<div class="flex flex-row flex-wrap justify-between gap-3">
<Label class="flex flex-col items-start gap-2">
{$_('menu.distance_units')}
<RadioGroup.Root bind:value={options.distanceUnits}>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="metric" id="metric" />
<Label for="metric">{$_('menu.metric')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="imperial" id="imperial" />
<Label for="imperial">{$_('menu.imperial')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="nautical" id="nautical" />
<Label for="nautical">{$_('menu.nautical')}</Label>
</div>
</RadioGroup.Root>
</Label>
<Label class="flex flex-col items-start gap-2">
{$_('menu.velocity_units')}
<RadioGroup.Root bind:value={options.velocityUnits}>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="speed" id="speed" />
<Label for="speed">{$_('quantities.speed')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="pace" id="pace" />
<Label for="pace">{$_('quantities.pace')}</Label>
</div>
</RadioGroup.Root>
</Label>
<Label class="flex flex-col items-start gap-2">
{$_('menu.temperature_units')}
<RadioGroup.Root bind:value={options.temperatureUnits}>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="celsius" id="celsius" />
<Label for="celsius">{$_('menu.celsius')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="fahrenheit" id="fahrenheit" />
<Label for="fahrenheit">{$_('menu.fahrenheit')}</Label>
</div>
</RadioGroup.Root>
</Label>
</div>
<Label class="flex flex-col items-start gap-2">
{$_('menu.mode')}
<RadioGroup.Root bind:value={options.theme} class="flex flex-row">
<div class="flex items-center space-x-2">
<RadioGroup.Item value="system" id="system" />
<Label for="system">{$_('menu.system')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="light" id="light" />
<Label for="light">{$_('menu.light')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="dark" id="dark" />
<Label for="dark">{$_('menu.dark')}</Label>
</div>
</RadioGroup.Root>
</Label>
<div class="flex flex-col gap-3 p-3 border rounded-md">
<div class="flex flex-row items-center gap-2">
<Checkbox id="manual-camera" bind:checked={manualCamera} />
<Label for="manual-camera" class="flex flex-row items-center gap-1">
<Video size="16" />
{$_('embedding.manual_camera')}
</Label>
</div>
<p class="text-sm text-muted-foreground">
{$_('embedding.manual_camera_description')}
</p>
<div class="flex flex-row flex-wrap items-center gap-6">
<Label class="flex flex-col gap-1">
<span>{$_('embedding.latitude')}</span>
<span>{lat}</span>
</Label>
<Label class="flex flex-col gap-1">
<span>{$_('embedding.longitude')}</span>
<span>{lon}</span>
</Label>
<Label class="flex flex-col gap-1">
<span>{$_('embedding.zoom')}</span>
<span>{zoom}</span>
</Label>
<Label class="flex flex-col gap-1">
<span>{$_('embedding.bearing')}</span>
<span>{bearing}</span>
</Label>
<Label class="flex flex-col gap-1">
<span>{$_('embedding.pitch')}</span>
<span>{pitch}</span>
</Label>
</div>
</div>
<Label>
{$_('embedding.preview')}
</Label>
<div class="relative h-[600px]">
<Embedding bind:options={iframeOptions} bind:hash useHash={false} />
</div>
<Label>
{$_('embedding.code')}
</Label>
<pre
class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">
<code class="language-html">
{`<iframe src="https://gpx.studio${base}/embed?options=${encodeURIComponent(JSON.stringify(getCleanedEmbeddingOptions(options)))}${hash}" width="100%" height="600px" frameborder="0" style="outline: none;"/>`}
</code>
</pre>
</fieldset>
</Card.Content>
</fieldset>
</Card.Content>
</Card.Root>

View File

@@ -1,89 +1,89 @@
<script lang="ts">
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
import * as ContextMenu from '$lib/components/ui/context-menu';
import FileListNode from './FileListNode.svelte';
import { fileObservers, settings } from '$lib/db';
import { setContext } from 'svelte';
import { ListFileItem, ListLevel, ListRootItem, allowedPastes } from './FileList';
import { copied, pasteSelection, selectAll, selection } from './Selection';
import { ClipboardPaste, FileStack, Plus } from 'lucide-svelte';
import Shortcut from '$lib/components/Shortcut.svelte';
import { _ } from 'svelte-i18n';
import { createFile } from '$lib/stores';
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
import * as ContextMenu from '$lib/components/ui/context-menu';
import FileListNode from './FileListNode.svelte';
import { fileObservers, settings } from '$lib/db';
import { setContext } from 'svelte';
import { ListFileItem, ListLevel, ListRootItem, allowedPastes } from './FileList';
import { copied, pasteSelection, selectAll, selection } from './Selection';
import { ClipboardPaste, FileStack, Plus } from 'lucide-svelte';
import Shortcut from '$lib/components/Shortcut.svelte';
import { _ } from 'svelte-i18n';
import { createFile } from '$lib/stores';
export let orientation: 'vertical' | 'horizontal';
export let recursive = false;
export let orientation: 'vertical' | 'horizontal';
export let recursive = false;
setContext('orientation', orientation);
setContext('recursive', recursive);
setContext('orientation', orientation);
setContext('recursive', recursive);
const { treeFileView } = settings;
const { treeFileView } = settings;
treeFileView.subscribe(($vertical) => {
if ($vertical) {
selection.update(($selection) => {
$selection.forEach((item) => {
if ($selection.hasAnyChildren(item, false)) {
$selection.toggle(item);
}
});
return $selection;
});
} else {
selection.update(($selection) => {
$selection.forEach((item) => {
if (!(item instanceof ListFileItem)) {
$selection.toggle(item);
$selection.set(new ListFileItem(item.getFileId()), true);
}
});
return $selection;
});
}
});
treeFileView.subscribe(($vertical) => {
if ($vertical) {
selection.update(($selection) => {
$selection.forEach((item) => {
if ($selection.hasAnyChildren(item, false)) {
$selection.toggle(item);
}
});
return $selection;
});
} else {
selection.update(($selection) => {
$selection.forEach((item) => {
if (!(item instanceof ListFileItem)) {
$selection.toggle(item);
$selection.set(new ListFileItem(item.getFileId()), true);
}
});
return $selection;
});
}
});
</script>
<ScrollArea
class="shrink-0 {orientation === 'vertical' ? 'p-0 pr-3' : 'h-10 px-1'}"
{orientation}
scrollbarXClasses={orientation === 'vertical' ? '' : 'mt-1 h-2'}
scrollbarYClasses={orientation === 'vertical' ? '' : ''}
class="shrink-0 {orientation === 'vertical' ? 'p-0 pr-3' : 'h-10 px-1'}"
{orientation}
scrollbarXClasses={orientation === 'vertical' ? '' : 'mt-1 h-2'}
scrollbarYClasses={orientation === 'vertical' ? '' : ''}
>
<div
class="flex {orientation === 'vertical'
? 'flex-col py-1 pl-1 min-h-screen'
: 'flex-row'} {$$props.class ?? ''}"
{...$$restProps}
>
<FileListNode bind:node={$fileObservers} item={new ListRootItem()} />
{#if orientation === 'vertical'}
<ContextMenu.Root>
<ContextMenu.Trigger class="grow" />
<ContextMenu.Content>
<ContextMenu.Item on:click={createFile}>
<Plus size="16" class="mr-1" />
{$_('menu.new_file')}
<Shortcut key="+" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item on:click={selectAll} disabled={$fileObservers.size === 0}>
<FileStack size="16" class="mr-1" />
{$_('menu.select_all')}
<Shortcut key="A" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item
disabled={$copied === undefined ||
$copied.length === 0 ||
!allowedPastes[$copied[0].level].includes(ListLevel.ROOT)}
on:click={pasteSelection}
>
<ClipboardPaste size="16" class="mr-1" />
{$_('menu.paste')}
<Shortcut key="V" ctrl={true} />
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
{/if}
</div>
<div
class="flex {orientation === 'vertical'
? 'flex-col py-1 pl-1 min-h-screen'
: 'flex-row'} {$$props.class ?? ''}"
{...$$restProps}
>
<FileListNode bind:node={$fileObservers} item={new ListRootItem()} />
{#if orientation === 'vertical'}
<ContextMenu.Root>
<ContextMenu.Trigger class="grow" />
<ContextMenu.Content>
<ContextMenu.Item on:click={createFile}>
<Plus size="16" class="mr-1" />
{$_('menu.new_file')}
<Shortcut key="+" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item on:click={selectAll} disabled={$fileObservers.size === 0}>
<FileStack size="16" class="mr-1" />
{$_('menu.select_all')}
<Shortcut key="A" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item
disabled={$copied === undefined ||
$copied.length === 0 ||
!allowedPastes[$copied[0].level].includes(ListLevel.ROOT)}
on:click={pasteSelection}
>
<ClipboardPaste size="16" class="mr-1" />
{$_('menu.paste')}
<Shortcut key="V" ctrl={true} />
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
{/if}
</div>
</ScrollArea>

View File

@@ -1,8 +1,8 @@
import { dbUtils, getFile } from "$lib/db";
import { freeze } from "immer";
import { GPXFile, Track, TrackSegment, Waypoint } from "gpx";
import { selection } from "./Selection";
import { newGPXFile } from "$lib/stores";
import { dbUtils, getFile } from '$lib/db';
import { freeze } from 'immer';
import { GPXFile, Track, TrackSegment, Waypoint } from 'gpx';
import { selection } from './Selection';
import { newGPXFile } from '$lib/stores';
export enum ListLevel {
ROOT,
@@ -10,7 +10,7 @@ export enum ListLevel {
TRACK,
SEGMENT,
WAYPOINTS,
WAYPOINT
WAYPOINT,
}
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
@@ -19,7 +19,7 @@ export const allowedMoves: Record<ListLevel, ListLevel[]> = {
[ListLevel.TRACK]: [ListLevel.FILE, ListLevel.TRACK],
[ListLevel.SEGMENT]: [ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
[ListLevel.WAYPOINTS]: [ListLevel.WAYPOINTS],
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT]
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
};
export const allowedPastes: Record<ListLevel, ListLevel[]> = {
@@ -28,7 +28,7 @@ export const allowedPastes: Record<ListLevel, ListLevel[]> = {
[ListLevel.TRACK]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK],
[ListLevel.SEGMENT]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
[ListLevel.WAYPOINTS]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
[ListLevel.WAYPOINT]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT]
[ListLevel.WAYPOINT]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
};
export abstract class ListItem {
@@ -322,7 +322,13 @@ export function sortItems(items: ListItem[], reverse: boolean = false) {
}
}
export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: ListItem[], toItems: ListItem[], remove: boolean = true) {
export function moveItems(
fromParent: ListItem,
toParent: ListItem,
fromItems: ListItem[],
toItems: ListItem[],
remove: boolean = true
) {
if (fromItems.length === 0) {
return;
}
@@ -338,11 +344,18 @@ export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: L
context.push(file.clone());
} else if (item instanceof ListTrackItem && item.getTrackIndex() < file.trk.length) {
context.push(file.trk[item.getTrackIndex()].clone());
} else if (item instanceof ListTrackSegmentItem && item.getTrackIndex() < file.trk.length && item.getSegmentIndex() < file.trk[item.getTrackIndex()].trkseg.length) {
} else if (
item instanceof ListTrackSegmentItem &&
item.getTrackIndex() < file.trk.length &&
item.getSegmentIndex() < file.trk[item.getTrackIndex()].trkseg.length
) {
context.push(file.trk[item.getTrackIndex()].trkseg[item.getSegmentIndex()].clone());
} else if (item instanceof ListWaypointsItem) {
context.push(file.wpt.map((wpt) => wpt.clone()));
} else if (item instanceof ListWaypointItem && item.getWaypointIndex() < file.wpt.length) {
} else if (
item instanceof ListWaypointItem &&
item.getWaypointIndex() < file.wpt.length
) {
context.push(file.wpt[item.getWaypointIndex()].clone());
}
}
@@ -359,7 +372,12 @@ export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: L
if (item instanceof ListTrackItem) {
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex(), []);
} else if (item instanceof ListTrackSegmentItem) {
file.replaceTrackSegments(item.getTrackIndex(), item.getSegmentIndex(), item.getSegmentIndex(), []);
file.replaceTrackSegments(
item.getTrackIndex(),
item.getSegmentIndex(),
item.getSegmentIndex(),
[]
);
} else if (item instanceof ListWaypointsItem) {
file.replaceWaypoints(0, file.wpt.length - 1, []);
} else if (item instanceof ListWaypointItem) {
@@ -371,25 +389,43 @@ export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: L
toItems.forEach((item, i) => {
if (item instanceof ListTrackItem) {
if (context[i] instanceof Track) {
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [context[i]]);
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [
context[i],
]);
} else if (context[i] instanceof TrackSegment) {
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [new Track({
trkseg: [context[i]]
})]);
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [
new Track({
trkseg: [context[i]],
}),
]);
}
} else if (item instanceof ListTrackSegmentItem && context[i] instanceof TrackSegment) {
file.replaceTrackSegments(item.getTrackIndex(), item.getSegmentIndex(), item.getSegmentIndex() - 1, [context[i]]);
} else if (
item instanceof ListTrackSegmentItem &&
context[i] instanceof TrackSegment
) {
file.replaceTrackSegments(
item.getTrackIndex(),
item.getSegmentIndex(),
item.getSegmentIndex() - 1,
[context[i]]
);
} else if (item instanceof ListWaypointsItem) {
if (Array.isArray(context[i]) && context[i].length > 0 && context[i][0] instanceof Waypoint) {
if (
Array.isArray(context[i]) &&
context[i].length > 0 &&
context[i][0] instanceof Waypoint
) {
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, context[i]);
} else if (context[i] instanceof Waypoint) {
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, [context[i]]);
}
} else if (item instanceof ListWaypointItem && context[i] instanceof Waypoint) {
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex() - 1, [context[i]]);
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex() - 1, [
context[i],
]);
}
});
}
},
];
if (fromParent instanceof ListRootItem) {
@@ -400,35 +436,42 @@ export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: L
callbacks.splice(0, 1);
}
dbUtils.applyEachToFilesAndGlobal(files, callbacks, (files, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
toItems.forEach((item, i) => {
if (item instanceof ListFileItem) {
if (context[i] instanceof GPXFile) {
let newFile = context[i];
if (remove) {
files.delete(newFile._data.id);
dbUtils.applyEachToFilesAndGlobal(
files,
callbacks,
(files, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
toItems.forEach((item, i) => {
if (item instanceof ListFileItem) {
if (context[i] instanceof GPXFile) {
let newFile = context[i];
if (remove) {
files.delete(newFile._data.id);
}
newFile._data.id = item.getFileId();
files.set(item.getFileId(), freeze(newFile));
} else if (context[i] instanceof Track) {
let newFile = newGPXFile();
newFile._data.id = item.getFileId();
if (context[i].name) {
newFile.metadata.name = context[i].name;
}
newFile.replaceTracks(0, 0, [context[i]]);
files.set(item.getFileId(), freeze(newFile));
} else if (context[i] instanceof TrackSegment) {
let newFile = newGPXFile();
newFile._data.id = item.getFileId();
newFile.replaceTracks(0, 0, [
new Track({
trkseg: [context[i]],
}),
]);
files.set(item.getFileId(), freeze(newFile));
}
newFile._data.id = item.getFileId();
files.set(item.getFileId(), freeze(newFile));
} else if (context[i] instanceof Track) {
let newFile = newGPXFile();
newFile._data.id = item.getFileId();
if (context[i].name) {
newFile.metadata.name = context[i].name;
}
newFile.replaceTracks(0, 0, [context[i]]);
files.set(item.getFileId(), freeze(newFile));
} else if (context[i] instanceof TrackSegment) {
let newFile = newGPXFile();
newFile._data.id = item.getFileId();
newFile.replaceTracks(0, 0, [new Track({
trkseg: [context[i]]
})]);
files.set(item.getFileId(), freeze(newFile));
}
}
});
}, context);
});
},
context
);
selection.update(($selection) => {
$selection.clear();

View File

@@ -1,83 +1,84 @@
<script lang="ts">
import {
GPXFile,
Track,
TrackSegment,
Waypoint,
type AnyGPXTreeElement,
type GPXTreeElement
} from 'gpx';
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
import { settings, type GPXFileWithStatistics } from '$lib/db';
import { get, type Readable } from 'svelte/store';
import FileListNodeContent from './FileListNodeContent.svelte';
import FileListNodeLabel from './FileListNodeLabel.svelte';
import { afterUpdate, getContext } from 'svelte';
import {
ListFileItem,
ListTrackSegmentItem,
ListWaypointItem,
ListWaypointsItem,
type ListItem,
type ListTrackItem
} from './FileList';
import { _ } from 'svelte-i18n';
import { selection } from './Selection';
import {
GPXFile,
Track,
TrackSegment,
Waypoint,
type AnyGPXTreeElement,
type GPXTreeElement,
} from 'gpx';
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
import { settings, type GPXFileWithStatistics } from '$lib/db';
import { get, type Readable } from 'svelte/store';
import FileListNodeContent from './FileListNodeContent.svelte';
import FileListNodeLabel from './FileListNodeLabel.svelte';
import { afterUpdate, getContext } from 'svelte';
import {
ListFileItem,
ListTrackSegmentItem,
ListWaypointItem,
ListWaypointsItem,
type ListItem,
type ListTrackItem,
} from './FileList';
import { _ } from 'svelte-i18n';
import { selection } from './Selection';
export let node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint;
export let item: ListItem;
export let node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint;
export let item: ListItem;
let recursive = getContext<boolean>('recursive');
let recursive = getContext<boolean>('recursive');
let collapsible: CollapsibleTreeNode;
let collapsible: CollapsibleTreeNode;
$: label =
node instanceof GPXFile && item instanceof ListFileItem
? node.metadata.name
: node instanceof Track
? (node.name ?? `${$_('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`)
: node instanceof TrackSegment
? `${$_('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}`
: node instanceof Waypoint
? (node.name ?? `${$_('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`)
: node instanceof GPXFile && item instanceof ListWaypointsItem
? $_('gpx.waypoints')
: '';
$: label =
node instanceof GPXFile && item instanceof ListFileItem
? node.metadata.name
: node instanceof Track
? (node.name ?? `${$_('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`)
: node instanceof TrackSegment
? `${$_('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}`
: node instanceof Waypoint
? (node.name ??
`${$_('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`)
: node instanceof GPXFile && item instanceof ListWaypointsItem
? $_('gpx.waypoints')
: '';
const { treeFileView } = settings;
const { treeFileView } = settings;
function openIfSelectedChild() {
if (collapsible && get(treeFileView) && $selection.hasAnyChildren(item, false)) {
collapsible.openNode();
}
}
function openIfSelectedChild() {
if (collapsible && get(treeFileView) && $selection.hasAnyChildren(item, false)) {
collapsible.openNode();
}
}
if ($selection) {
openIfSelectedChild();
}
if ($selection) {
openIfSelectedChild();
}
afterUpdate(openIfSelectedChild);
afterUpdate(openIfSelectedChild);
</script>
{#if node instanceof Map}
<FileListNodeContent {node} {item} />
<FileListNodeContent {node} {item} />
{:else if node instanceof TrackSegment}
<FileListNodeLabel {node} {item} {label} />
<FileListNodeLabel {node} {item} {label} />
{:else if node instanceof Waypoint}
<FileListNodeLabel {node} {item} {label} />
<FileListNodeLabel {node} {item} {label} />
{:else if recursive}
<CollapsibleTreeNode id={item.getId()} bind:this={collapsible}>
<FileListNodeLabel {node} {item} {label} slot="trigger" />
<div slot="content" class="ml-2">
{#key node}
<FileListNodeContent {node} {item} />
{/key}
</div>
</CollapsibleTreeNode>
<CollapsibleTreeNode id={item.getId()} bind:this={collapsible}>
<FileListNodeLabel {node} {item} {label} slot="trigger" />
<div slot="content" class="ml-2">
{#key node}
<FileListNodeContent {node} {item} />
{/key}
</div>
</CollapsibleTreeNode>
{:else}
<FileListNodeLabel {node} {item} {label} />
<FileListNodeLabel {node} {item} {label} />
{/if}

View File

@@ -19,7 +19,7 @@
ListWaypointsItem,
allowedMoves,
moveItems,
type ListItem
type ListItem,
} from './FileList';
import { selection } from './Selection';
import { isMac } from '$lib/utils';
@@ -113,7 +113,7 @@
Sortable.utils.select(element);
element.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
block: 'nearest',
});
} else {
Sortable.utils.deselect(element);
@@ -155,7 +155,7 @@
group: {
name: sortableLevel,
pull: allowedMoves[sortableLevel],
put: true
put: true,
},
direction: orientation,
forceAutoScrollFallback: true,
@@ -233,16 +233,16 @@
moveItems(fromItem, toItem, fromItems, toItems);
}
}
},
});
Object.defineProperty(sortable, '_item', {
value: item,
writable: true
writable: true,
});
Object.defineProperty(sortable, '_waypointRoot', {
value: waypointRoot,
writable: true
writable: true,
});
}

View File

@@ -1,322 +1,335 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as ContextMenu from '$lib/components/ui/context-menu';
import Shortcut from '$lib/components/Shortcut.svelte';
import { dbUtils, getFile } from '$lib/db';
import {
Copy,
Info,
MapPin,
PaintBucket,
Plus,
Trash2,
Waypoints,
Eye,
EyeOff,
ClipboardCopy,
ClipboardPaste,
Maximize,
Scissors,
FileStack,
FileX
} from 'lucide-svelte';
import {
ListFileItem,
ListLevel,
ListTrackItem,
ListWaypointItem,
allowedPastes,
type ListItem
} from './FileList';
import {
copied,
copySelection,
cut,
cutSelection,
pasteSelection,
selectAll,
selectItem,
selection
} from './Selection';
import { getContext } from 'svelte';
import { get } from 'svelte/store';
import {
allHidden,
editMetadata,
editStyle,
embedding,
centerMapOnSelection,
gpxLayers,
map
} from '$lib/stores';
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
import { _ } from 'svelte-i18n';
import MetadataDialog from './MetadataDialog.svelte';
import StyleDialog from './StyleDialog.svelte';
import { waypointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
import { Button } from '$lib/components/ui/button';
import * as ContextMenu from '$lib/components/ui/context-menu';
import Shortcut from '$lib/components/Shortcut.svelte';
import { dbUtils, getFile } from '$lib/db';
import {
Copy,
Info,
MapPin,
PaintBucket,
Plus,
Trash2,
Waypoints,
Eye,
EyeOff,
ClipboardCopy,
ClipboardPaste,
Maximize,
Scissors,
FileStack,
FileX,
} from 'lucide-svelte';
import {
ListFileItem,
ListLevel,
ListTrackItem,
ListWaypointItem,
allowedPastes,
type ListItem,
} from './FileList';
import {
copied,
copySelection,
cut,
cutSelection,
pasteSelection,
selectAll,
selectItem,
selection,
} from './Selection';
import { getContext } from 'svelte';
import { get } from 'svelte/store';
import {
allHidden,
editMetadata,
editStyle,
embedding,
centerMapOnSelection,
gpxLayers,
map,
} from '$lib/stores';
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
import { _ } from 'svelte-i18n';
import MetadataDialog from './MetadataDialog.svelte';
import StyleDialog from './StyleDialog.svelte';
import { waypointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
export let item: ListItem;
export let label: string | undefined;
export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
export let item: ListItem;
export let label: string | undefined;
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
$: singleSelection = $selection.size === 1;
$: singleSelection = $selection.size === 1;
let nodeColors: string[] = [];
let nodeColors: string[] = [];
$: if (node && $map) {
nodeColors = [];
$: if (node && $map) {
nodeColors = [];
if (node instanceof GPXFile) {
let defaultColor = undefined;
if (node instanceof GPXFile) {
let defaultColor = undefined;
let layer = gpxLayers.get(item.getFileId());
if (layer) {
defaultColor = layer.layerColor;
}
let layer = gpxLayers.get(item.getFileId());
if (layer) {
defaultColor = layer.layerColor;
}
let style = node.getStyle(defaultColor);
style.color.forEach((c) => {
if (!nodeColors.includes(c)) {
nodeColors.push(c);
}
});
} else if (node instanceof Track) {
let style = node.getStyle();
if (style) {
if (style['gpx_style:color'] && !nodeColors.includes(style['gpx_style:color'])) {
nodeColors.push(style['gpx_style:color']);
}
}
if (nodeColors.length === 0) {
let layer = gpxLayers.get(item.getFileId());
if (layer) {
nodeColors.push(layer.layerColor);
}
}
}
}
let style = node.getStyle(defaultColor);
style.color.forEach((c) => {
if (!nodeColors.includes(c)) {
nodeColors.push(c);
}
});
} else if (node instanceof Track) {
let style = node.getStyle();
if (style) {
if (style['gpx_style:color'] && !nodeColors.includes(style['gpx_style:color'])) {
nodeColors.push(style['gpx_style:color']);
}
}
if (nodeColors.length === 0) {
let layer = gpxLayers.get(item.getFileId());
if (layer) {
nodeColors.push(layer.layerColor);
}
}
}
}
$: symbolKey = node instanceof Waypoint ? getSymbolKey(node.sym) : undefined;
$: symbolKey = node instanceof Waypoint ? getSymbolKey(node.sym) : undefined;
let openEditMetadata: boolean = false;
let openEditStyle: boolean = false;
let openEditMetadata: boolean = false;
let openEditStyle: boolean = false;
$: openEditMetadata = $editMetadata && singleSelection && $selection.has(item);
$: openEditStyle =
$editStyle &&
$selection.has(item) &&
$selection.getSelected().findIndex((i) => i.getFullId() === item.getFullId()) === 0;
$: hidden = item.level === ListLevel.WAYPOINTS ? node._data.hiddenWpt : node._data.hidden;
$: openEditMetadata = $editMetadata && singleSelection && $selection.has(item);
$: openEditStyle =
$editStyle &&
$selection.has(item) &&
$selection.getSelected().findIndex((i) => i.getFullId() === item.getFullId()) === 0;
$: hidden = item.level === ListLevel.WAYPOINTS ? node._data.hiddenWpt : node._data.hidden;
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<ContextMenu.Root
onOpenChange={(open) => {
if (open) {
if (!get(selection).has(item)) {
selectItem(item);
}
}
}}
onOpenChange={(open) => {
if (open) {
if (!get(selection).has(item)) {
selectItem(item);
}
}
}}
>
<ContextMenu.Trigger class="grow truncate">
<Button
variant="ghost"
class="relative w-full p-0 px-1 border-none overflow-hidden focus-visible:ring-0 focus-visible:ring-offset-0 {orientation ===
'vertical'
? 'h-fit'
: 'h-9 px-1.5 shadow-md'} pointer-events-auto"
>
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
<MetadataDialog bind:open={openEditMetadata} {node} {item} />
<StyleDialog bind:open={openEditStyle} {item} />
{/if}
{#if item.level === ListLevel.FILE || item.level === ListLevel.TRACK}
<div
class="absolute {orientation === 'vertical'
? 'top-0 bottom-0 right-1 w-1'
: 'top-0 h-1 left-0 right-0'}"
style="background:linear-gradient(to {orientation === 'vertical'
? 'bottom'
: 'right'},{nodeColors
.map(
(c, i) =>
`${c} ${Math.floor((100 * i) / nodeColors.length)}% ${Math.floor((100 * (i + 1)) / nodeColors.length)}%`
)
.join(',')})"
/>
{/if}
<span
class="w-full text-left truncate py-1 flex flex-row items-center {hidden
? 'text-muted-foreground'
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId())
? 'text-muted-foreground'
: ''}"
on:contextmenu={(e) => {
if ($embedding) {
e.preventDefault();
e.stopPropagation();
return;
}
if (e.ctrlKey) {
// Add to selection instead of opening context menu
e.preventDefault();
e.stopPropagation();
$selection.toggle(item);
$selection = $selection;
}
}}
on:mouseenter={() => {
if (item instanceof ListWaypointItem) {
let layer = gpxLayers.get(item.getFileId());
let file = getFile(item.getFileId());
if (layer && file) {
let waypoint = file.wpt[item.getWaypointIndex()];
if (waypoint) {
waypointPopup?.setItem({ item: waypoint, fileId: item.getFileId() });
}
}
}
}}
on:mouseleave={() => {
if (item instanceof ListWaypointItem) {
let layer = gpxLayers.get(item.getFileId());
if (layer) {
waypointPopup?.setItem(null);
}
}
}}
>
{#if item.level === ListLevel.SEGMENT}
<Waypoints size="16" class="mr-1 shrink-0" />
{:else if item.level === ListLevel.WAYPOINT}
{#if symbolKey && symbols[symbolKey].icon}
<svelte:component this={symbols[symbolKey].icon} size="16" class="mr-1 shrink-0" />
{:else}
<MapPin size="16" class="mr-1 shrink-0" />
{/if}
{/if}
<span class="grow select-none truncate {orientation === 'vertical' ? 'last:mr-2' : ''}">
{label}
</span>
{#if hidden}
<EyeOff
size="12"
class="shrink-0 mt-1 ml-1 {orientation === 'vertical' ? 'mr-2' : ''} {item.level ===
ListLevel.SEGMENT || item.level === ListLevel.WAYPOINT
? 'mr-3'
: ''}"
/>
{/if}
</span>
</Button>
</ContextMenu.Trigger>
<ContextMenu.Content>
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
<ContextMenu.Item disabled={!singleSelection} on:click={() => ($editMetadata = true)}>
<Info size="16" class="mr-1" />
{$_('menu.metadata.button')}
<Shortcut key="I" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Item on:click={() => ($editStyle = true)}>
<PaintBucket size="16" class="mr-1" />
{$_('menu.style.button')}
</ContextMenu.Item>
{/if}
<ContextMenu.Item
on:click={() => {
if ($allHidden) {
dbUtils.setHiddenToSelection(false);
} else {
dbUtils.setHiddenToSelection(true);
}
}}
>
{#if $allHidden}
<Eye size="16" class="mr-1" />
{$_('menu.unhide')}
{:else}
<EyeOff size="16" class="mr-1" />
{$_('menu.hide')}
{/if}
<Shortcut key="H" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator />
{#if orientation === 'vertical'}
{#if item instanceof ListFileItem}
<ContextMenu.Item
disabled={!singleSelection}
on:click={() => dbUtils.addNewTrack(item.getFileId())}
>
<Plus size="16" class="mr-1" />
{$_('menu.new_track')}
</ContextMenu.Item>
<ContextMenu.Separator />
{:else if item instanceof ListTrackItem}
<ContextMenu.Item
disabled={!singleSelection}
on:click={() => dbUtils.addNewSegment(item.getFileId(), item.getTrackIndex())}
>
<Plus size="16" class="mr-1" />
{$_('menu.new_segment')}
</ContextMenu.Item>
<ContextMenu.Separator />
{/if}
{/if}
{#if item.level !== ListLevel.WAYPOINTS}
<ContextMenu.Item on:click={selectAll}>
<FileStack size="16" class="mr-1" />
{$_('menu.select_all')}
<Shortcut key="A" ctrl={true} />
</ContextMenu.Item>
{/if}
<ContextMenu.Item on:click={centerMapOnSelection}>
<Maximize size="16" class="mr-1" />
{$_('menu.center')}
<Shortcut key="⏎" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item on:click={dbUtils.duplicateSelection}>
<Copy size="16" class="mr-1" />
{$_('menu.duplicate')}
<Shortcut key="D" ctrl={true} /></ContextMenu.Item
>
{#if orientation === 'vertical'}
<ContextMenu.Item on:click={copySelection}>
<ClipboardCopy size="16" class="mr-1" />
{$_('menu.copy')}
<Shortcut key="C" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Item on:click={cutSelection}>
<Scissors size="16" class="mr-1" />
{$_('menu.cut')}
<Shortcut key="X" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Item
disabled={$copied === undefined ||
$copied.length === 0 ||
!allowedPastes[$copied[0].level].includes(item.level)}
on:click={pasteSelection}
>
<ClipboardPaste size="16" class="mr-1" />
{$_('menu.paste')}
<Shortcut key="V" ctrl={true} />
</ContextMenu.Item>
{/if}
<ContextMenu.Separator />
<ContextMenu.Item on:click={dbUtils.deleteSelection}>
{#if item instanceof ListFileItem}
<FileX size="16" class="mr-1" />
{$_('menu.close')}
{:else}
<Trash2 size="16" class="mr-1" />
{$_('menu.delete')}
{/if}
<Shortcut key="⌫" ctrl={true} />
</ContextMenu.Item>
</ContextMenu.Content>
<ContextMenu.Trigger class="grow truncate">
<Button
variant="ghost"
class="relative w-full p-0 px-1 border-none overflow-hidden focus-visible:ring-0 focus-visible:ring-offset-0 {orientation ===
'vertical'
? 'h-fit'
: 'h-9 px-1.5 shadow-md'} pointer-events-auto"
>
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
<MetadataDialog bind:open={openEditMetadata} {node} {item} />
<StyleDialog bind:open={openEditStyle} {item} />
{/if}
{#if item.level === ListLevel.FILE || item.level === ListLevel.TRACK}
<div
class="absolute {orientation === 'vertical'
? 'top-0 bottom-0 right-1 w-1'
: 'top-0 h-1 left-0 right-0'}"
style="background:linear-gradient(to {orientation === 'vertical'
? 'bottom'
: 'right'},{nodeColors
.map(
(c, i) =>
`${c} ${Math.floor((100 * i) / nodeColors.length)}% ${Math.floor((100 * (i + 1)) / nodeColors.length)}%`
)
.join(',')})"
/>
{/if}
<span
class="w-full text-left truncate py-1 flex flex-row items-center {hidden
? 'text-muted-foreground'
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId())
? 'text-muted-foreground'
: ''}"
on:contextmenu={(e) => {
if ($embedding) {
e.preventDefault();
e.stopPropagation();
return;
}
if (e.ctrlKey) {
// Add to selection instead of opening context menu
e.preventDefault();
e.stopPropagation();
$selection.toggle(item);
$selection = $selection;
}
}}
on:mouseenter={() => {
if (item instanceof ListWaypointItem) {
let layer = gpxLayers.get(item.getFileId());
let file = getFile(item.getFileId());
if (layer && file) {
let waypoint = file.wpt[item.getWaypointIndex()];
if (waypoint) {
waypointPopup?.setItem({
item: waypoint,
fileId: item.getFileId(),
});
}
}
}
}}
on:mouseleave={() => {
if (item instanceof ListWaypointItem) {
let layer = gpxLayers.get(item.getFileId());
if (layer) {
waypointPopup?.setItem(null);
}
}
}}
>
{#if item.level === ListLevel.SEGMENT}
<Waypoints size="16" class="mr-1 shrink-0" />
{:else if item.level === ListLevel.WAYPOINT}
{#if symbolKey && symbols[symbolKey].icon}
<svelte:component
this={symbols[symbolKey].icon}
size="16"
class="mr-1 shrink-0"
/>
{:else}
<MapPin size="16" class="mr-1 shrink-0" />
{/if}
{/if}
<span
class="grow select-none truncate {orientation === 'vertical'
? 'last:mr-2'
: ''}"
>
{label}
</span>
{#if hidden}
<EyeOff
size="12"
class="shrink-0 mt-1 ml-1 {orientation === 'vertical'
? 'mr-2'
: ''} {item.level === ListLevel.SEGMENT ||
item.level === ListLevel.WAYPOINT
? 'mr-3'
: ''}"
/>
{/if}
</span>
</Button>
</ContextMenu.Trigger>
<ContextMenu.Content>
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
<ContextMenu.Item disabled={!singleSelection} on:click={() => ($editMetadata = true)}>
<Info size="16" class="mr-1" />
{$_('menu.metadata.button')}
<Shortcut key="I" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Item on:click={() => ($editStyle = true)}>
<PaintBucket size="16" class="mr-1" />
{$_('menu.style.button')}
</ContextMenu.Item>
{/if}
<ContextMenu.Item
on:click={() => {
if ($allHidden) {
dbUtils.setHiddenToSelection(false);
} else {
dbUtils.setHiddenToSelection(true);
}
}}
>
{#if $allHidden}
<Eye size="16" class="mr-1" />
{$_('menu.unhide')}
{:else}
<EyeOff size="16" class="mr-1" />
{$_('menu.hide')}
{/if}
<Shortcut key="H" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator />
{#if orientation === 'vertical'}
{#if item instanceof ListFileItem}
<ContextMenu.Item
disabled={!singleSelection}
on:click={() => dbUtils.addNewTrack(item.getFileId())}
>
<Plus size="16" class="mr-1" />
{$_('menu.new_track')}
</ContextMenu.Item>
<ContextMenu.Separator />
{:else if item instanceof ListTrackItem}
<ContextMenu.Item
disabled={!singleSelection}
on:click={() => dbUtils.addNewSegment(item.getFileId(), item.getTrackIndex())}
>
<Plus size="16" class="mr-1" />
{$_('menu.new_segment')}
</ContextMenu.Item>
<ContextMenu.Separator />
{/if}
{/if}
{#if item.level !== ListLevel.WAYPOINTS}
<ContextMenu.Item on:click={selectAll}>
<FileStack size="16" class="mr-1" />
{$_('menu.select_all')}
<Shortcut key="A" ctrl={true} />
</ContextMenu.Item>
{/if}
<ContextMenu.Item on:click={centerMapOnSelection}>
<Maximize size="16" class="mr-1" />
{$_('menu.center')}
<Shortcut key="⏎" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item on:click={dbUtils.duplicateSelection}>
<Copy size="16" class="mr-1" />
{$_('menu.duplicate')}
<Shortcut key="D" ctrl={true} /></ContextMenu.Item
>
{#if orientation === 'vertical'}
<ContextMenu.Item on:click={copySelection}>
<ClipboardCopy size="16" class="mr-1" />
{$_('menu.copy')}
<Shortcut key="C" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Item on:click={cutSelection}>
<Scissors size="16" class="mr-1" />
{$_('menu.cut')}
<Shortcut key="X" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Item
disabled={$copied === undefined ||
$copied.length === 0 ||
!allowedPastes[$copied[0].level].includes(item.level)}
on:click={pasteSelection}
>
<ClipboardPaste size="16" class="mr-1" />
{$_('menu.paste')}
<Shortcut key="V" ctrl={true} />
</ContextMenu.Item>
{/if}
<ContextMenu.Separator />
<ContextMenu.Item on:click={dbUtils.deleteSelection}>
{#if item instanceof ListFileItem}
<FileX size="16" class="mr-1" />
{$_('menu.close')}
{:else}
<Trash2 size="16" class="mr-1" />
{$_('menu.delete')}
{/if}
<Shortcut key="⌫" ctrl={true} />
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>

View File

@@ -1,23 +1,23 @@
<script lang="ts">
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
import FileListNode from '$lib/components/file-list/FileListNode.svelte';
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
import FileListNode from '$lib/components/file-list/FileListNode.svelte';
import type { GPXFileWithStatistics } from '$lib/db';
import { getContext } from 'svelte';
import type { Readable } from 'svelte/store';
import { ListFileItem } from './FileList';
import type { GPXFileWithStatistics } from '$lib/db';
import { getContext } from 'svelte';
import type { Readable } from 'svelte/store';
import { ListFileItem } from './FileList';
export let file: Readable<GPXFileWithStatistics | undefined>;
export let file: Readable<GPXFileWithStatistics | undefined>;
let recursive = getContext<boolean>('recursive');
let recursive = getContext<boolean>('recursive');
</script>
{#if $file}
{#if recursive}
<CollapsibleTree side="left" defaultState="closed" slotInsideTrigger={false}>
<FileListNode node={$file.file} item={new ListFileItem($file.file._data.id)} />
</CollapsibleTree>
{:else}
<FileListNode node={$file.file} item={new ListFileItem($file.file._data.id)} />
{/if}
{#if recursive}
<CollapsibleTree side="left" defaultState="closed" slotInsideTrigger={false}>
<FileListNode node={$file.file} item={new ListFileItem($file.file._data.id)} />
</CollapsibleTree>
{:else}
<FileListNode node={$file.file} item={new ListFileItem($file.file._data.id)} />
{/if}
{/if}

View File

@@ -17,15 +17,15 @@
let name: string =
node instanceof GPXFile
? node.metadata.name ?? ''
? (node.metadata.name ?? '')
: node instanceof Track
? node.name ?? ''
? (node.name ?? '')
: '';
let description: string =
node instanceof GPXFile
? node.metadata.desc ?? ''
? (node.metadata.desc ?? '')
: node instanceof Track
? node.desc ?? ''
? (node.desc ?? '')
: '';
$: if (!open) {

View File

@@ -1,12 +1,23 @@
import { get, writable } from "svelte/store";
import { ListFileItem, ListItem, ListRootItem, ListTrackItem, ListTrackSegmentItem, ListWaypointItem, ListLevel, sortItems, ListWaypointsItem, moveItems } from "./FileList";
import { fileObservers, getFile, getFileIds, settings } from "$lib/db";
import { get, writable } from 'svelte/store';
import {
ListFileItem,
ListItem,
ListRootItem,
ListTrackItem,
ListTrackSegmentItem,
ListWaypointItem,
ListLevel,
sortItems,
ListWaypointsItem,
moveItems,
} from './FileList';
import { fileObservers, getFile, getFileIds, settings } from '$lib/db';
export class SelectionTreeType {
item: ListItem;
selected: boolean;
children: {
[key: string | number]: SelectionTreeType
[key: string | number]: SelectionTreeType;
};
size: number = 0;
@@ -67,7 +78,11 @@ export class SelectionTreeType {
}
hasAnyParent(item: ListItem, self: boolean = true): boolean {
if (this.selected && this.item.level <= item.level && (self || this.item.level < item.level)) {
if (
this.selected &&
this.item.level <= item.level &&
(self || this.item.level < item.level)
) {
return this.selected;
}
let id = item.getIdAtLevel(this.item.level);
@@ -80,7 +95,11 @@ export class SelectionTreeType {
}
hasAnyChildren(item: ListItem, self: boolean = true, ignoreIds?: (string | number)[]): boolean {
if (this.selected && this.item.level >= item.level && (self || this.item.level > item.level)) {
if (
this.selected &&
this.item.level >= item.level &&
(self || this.item.level > item.level)
) {
return this.selected;
}
let id = item.getIdAtLevel(this.item.level);
@@ -131,7 +150,7 @@ export class SelectionTreeType {
delete this.children[id];
}
}
};
}
export const selection = writable<SelectionTreeType>(new SelectionTreeType(new ListRootItem()));
@@ -181,7 +200,10 @@ export function selectAll() {
let file = getFile(item.getFileId());
if (file) {
file.trk[item.getTrackIndex()].trkseg.forEach((_segment, segmentId) => {
$selection.set(new ListTrackSegmentItem(item.getFileId(), item.getTrackIndex(), segmentId), true);
$selection.set(
new ListTrackSegmentItem(item.getFileId(), item.getTrackIndex(), segmentId),
true
);
});
}
} else if (item instanceof ListWaypointItem) {
@@ -205,14 +227,24 @@ export function getOrderedSelection(reverse: boolean = false): ListItem[] {
return selected;
}
export function applyToOrderedItemsFromFile(selectedItems: ListItem[], callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void, reverse: boolean = true) {
export function applyToOrderedItemsFromFile(
selectedItems: ListItem[],
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
reverse: boolean = true
) {
get(settings.fileOrder).forEach((fileId) => {
let level: ListLevel | undefined = undefined;
let items: ListItem[] = [];
selectedItems.forEach((item) => {
if (item.getFileId() === fileId) {
level = item.level;
if (item instanceof ListFileItem || item instanceof ListTrackItem || item instanceof ListTrackSegmentItem || item instanceof ListWaypointsItem || item instanceof ListWaypointItem) {
if (
item instanceof ListFileItem ||
item instanceof ListTrackItem ||
item instanceof ListTrackSegmentItem ||
item instanceof ListWaypointsItem ||
item instanceof ListWaypointItem
) {
items.push(item);
}
}
@@ -225,7 +257,10 @@ export function applyToOrderedItemsFromFile(selectedItems: ListItem[], callback:
});
}
export function applyToOrderedSelectedItemsFromFile(callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void, reverse: boolean = true) {
export function applyToOrderedSelectedItemsFromFile(
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
reverse: boolean = true
) {
applyToOrderedItemsFromFile(get(selection).getSelected(), callback, reverse);
}
@@ -270,7 +305,11 @@ export function pasteSelection() {
let startIndex: number | undefined = undefined;
if (fromItems[0].level === toParent.level) {
if (toParent instanceof ListTrackItem || toParent instanceof ListTrackSegmentItem || toParent instanceof ListWaypointItem) {
if (
toParent instanceof ListTrackItem ||
toParent instanceof ListTrackSegmentItem ||
toParent instanceof ListWaypointItem
) {
startIndex = toParent.getId() + 1;
}
toParent = toParent.getParent();
@@ -288,20 +327,41 @@ export function pasteSelection() {
fromItems.forEach((item, index) => {
if (toParent instanceof ListFileItem) {
if (item instanceof ListTrackItem || item instanceof ListTrackSegmentItem) {
toItems.push(new ListTrackItem(toParent.getFileId(), (startIndex ?? toFile.trk.length) + index));
toItems.push(
new ListTrackItem(
toParent.getFileId(),
(startIndex ?? toFile.trk.length) + index
)
);
} else if (item instanceof ListWaypointsItem) {
toItems.push(new ListWaypointsItem(toParent.getFileId()));
} else if (item instanceof ListWaypointItem) {
toItems.push(new ListWaypointItem(toParent.getFileId(), (startIndex ?? toFile.wpt.length) + index));
toItems.push(
new ListWaypointItem(
toParent.getFileId(),
(startIndex ?? toFile.wpt.length) + index
)
);
}
} else if (toParent instanceof ListTrackItem) {
if (item instanceof ListTrackSegmentItem) {
let toTrackIndex = toParent.getTrackIndex();
toItems.push(new ListTrackSegmentItem(toParent.getFileId(), toTrackIndex, (startIndex ?? toFile.trk[toTrackIndex].trkseg.length) + index));
toItems.push(
new ListTrackSegmentItem(
toParent.getFileId(),
toTrackIndex,
(startIndex ?? toFile.trk[toTrackIndex].trkseg.length) + index
)
);
}
} else if (toParent instanceof ListWaypointsItem) {
if (item instanceof ListWaypointItem) {
toItems.push(new ListWaypointItem(toParent.getFileId(), (startIndex ?? toFile.wpt.length) + index));
toItems.push(
new ListWaypointItem(
toParent.getFileId(),
(startIndex ?? toFile.wpt.length) + index
)
);
}
}
});
@@ -312,4 +372,4 @@ export function pasteSelection() {
moveItems(fromParent, toParent, fromItems, toItems, get(cut));
resetCopied();
}
}
}

View File

@@ -1,167 +1,173 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label/index.js';
import { Slider } from '$lib/components/ui/slider';
import * as Popover from '$lib/components/ui/popover';
import { dbUtils, getFile, settings } from '$lib/db';
import { Save } from 'lucide-svelte';
import { ListFileItem, ListTrackItem, type ListItem } from './FileList';
import { selection } from './Selection';
import { editStyle, gpxLayers } from '$lib/stores';
import { _ } from 'svelte-i18n';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label/index.js';
import { Slider } from '$lib/components/ui/slider';
import * as Popover from '$lib/components/ui/popover';
import { dbUtils, getFile, settings } from '$lib/db';
import { Save } from 'lucide-svelte';
import { ListFileItem, ListTrackItem, type ListItem } from './FileList';
import { selection } from './Selection';
import { editStyle, gpxLayers } from '$lib/stores';
import { _ } from 'svelte-i18n';
export let item: ListItem;
export let open = false;
export let item: ListItem;
export let open = false;
const { defaultOpacity, defaultWidth } = settings;
const { defaultOpacity, defaultWidth } = settings;
let colors: string[] = [];
let color: string | undefined = undefined;
let opacity: number[] = [];
let width: number[] = [];
let colorChanged = false;
let opacityChanged = false;
let widthChanged = false;
let colors: string[] = [];
let color: string | undefined = undefined;
let opacity: number[] = [];
let width: number[] = [];
let colorChanged = false;
let opacityChanged = false;
let widthChanged = false;
function setStyleInputs() {
colors = [];
opacity = [];
width = [];
function setStyleInputs() {
colors = [];
opacity = [];
width = [];
$selection.forEach((item) => {
if (item instanceof ListFileItem) {
let file = getFile(item.getFileId());
let layer = gpxLayers.get(item.getFileId());
if (file && layer) {
let style = file.getStyle();
style.color.push(layer.layerColor);
$selection.forEach((item) => {
if (item instanceof ListFileItem) {
let file = getFile(item.getFileId());
let layer = gpxLayers.get(item.getFileId());
if (file && layer) {
let style = file.getStyle();
style.color.push(layer.layerColor);
style.color.forEach((c) => {
if (!colors.includes(c)) {
colors.push(c);
}
});
style.opacity.forEach((o) => {
if (!opacity.includes(o)) {
opacity.push(o);
}
});
style.width.forEach((w) => {
if (!width.includes(w)) {
width.push(w);
}
});
}
} else if (item instanceof ListTrackItem) {
let file = getFile(item.getFileId());
let layer = gpxLayers.get(item.getFileId());
if (file && layer) {
let track = file.trk[item.getTrackIndex()];
let style = track.getStyle();
if (style) {
if (style['gpx_style:color'] && !colors.includes(style['gpx_style:color'])) {
colors.push(style['gpx_style:color']);
}
if (style['gpx_style:opacity'] && !opacity.includes(style['gpx_style:opacity'])) {
opacity.push(style['gpx_style:opacity']);
}
if (style['gpx_style:width'] && !width.includes(style['gpx_style:width'])) {
width.push(style['gpx_style:width']);
}
}
if (!colors.includes(layer.layerColor)) {
colors.push(layer.layerColor);
}
}
}
});
style.color.forEach((c) => {
if (!colors.includes(c)) {
colors.push(c);
}
});
style.opacity.forEach((o) => {
if (!opacity.includes(o)) {
opacity.push(o);
}
});
style.width.forEach((w) => {
if (!width.includes(w)) {
width.push(w);
}
});
}
} else if (item instanceof ListTrackItem) {
let file = getFile(item.getFileId());
let layer = gpxLayers.get(item.getFileId());
if (file && layer) {
let track = file.trk[item.getTrackIndex()];
let style = track.getStyle();
if (style) {
if (
style['gpx_style:color'] &&
!colors.includes(style['gpx_style:color'])
) {
colors.push(style['gpx_style:color']);
}
if (
style['gpx_style:opacity'] &&
!opacity.includes(style['gpx_style:opacity'])
) {
opacity.push(style['gpx_style:opacity']);
}
if (style['gpx_style:width'] && !width.includes(style['gpx_style:width'])) {
width.push(style['gpx_style:width']);
}
}
if (!colors.includes(layer.layerColor)) {
colors.push(layer.layerColor);
}
}
}
});
color = colors[0];
opacity = [opacity[0] ?? $defaultOpacity];
width = [width[0] ?? $defaultWidth];
color = colors[0];
opacity = [opacity[0] ?? $defaultOpacity];
width = [width[0] ?? $defaultWidth];
colorChanged = false;
opacityChanged = false;
widthChanged = false;
}
colorChanged = false;
opacityChanged = false;
widthChanged = false;
}
$: if ($selection && open) {
setStyleInputs();
}
$: if ($selection && open) {
setStyleInputs();
}
$: if (!open) {
$editStyle = false;
}
$: if (!open) {
$editStyle = false;
}
</script>
<Popover.Root bind:open>
<Popover.Trigger />
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
<Label class="flex flex-row gap-2 items-center justify-between">
{$_('menu.style.color')}
<Input
bind:value={color}
type="color"
class="p-0 h-6 w-40"
on:change={() => (colorChanged = true)}
/>
</Label>
<Label class="flex flex-row gap-2 items-center justify-between">
{$_('menu.style.opacity')}
<div class="w-40 p-2">
<Slider
bind:value={opacity}
min={0.3}
max={1}
step={0.1}
onValueChange={() => (opacityChanged = true)}
/>
</div>
</Label>
<Label class="flex flex-row gap-2 items-center justify-between">
{$_('menu.style.width')}
<div class="w-40 p-2">
<Slider
bind:value={width}
id="width"
min={1}
max={10}
step={1}
onValueChange={() => (widthChanged = true)}
/>
</div>
</Label>
<Button
variant="outline"
disabled={!colorChanged && !opacityChanged && !widthChanged}
on:click={() => {
let style = {};
if (colorChanged) {
style['gpx_style:color'] = color;
}
if (opacityChanged) {
style['gpx_style:opacity'] = opacity[0];
}
if (widthChanged) {
style['gpx_style:width'] = width[0];
}
dbUtils.setStyleToSelection(style);
<Popover.Trigger />
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
<Label class="flex flex-row gap-2 items-center justify-between">
{$_('menu.style.color')}
<Input
bind:value={color}
type="color"
class="p-0 h-6 w-40"
on:change={() => (colorChanged = true)}
/>
</Label>
<Label class="flex flex-row gap-2 items-center justify-between">
{$_('menu.style.opacity')}
<div class="w-40 p-2">
<Slider
bind:value={opacity}
min={0.3}
max={1}
step={0.1}
onValueChange={() => (opacityChanged = true)}
/>
</div>
</Label>
<Label class="flex flex-row gap-2 items-center justify-between">
{$_('menu.style.width')}
<div class="w-40 p-2">
<Slider
bind:value={width}
id="width"
min={1}
max={10}
step={1}
onValueChange={() => (widthChanged = true)}
/>
</div>
</Label>
<Button
variant="outline"
disabled={!colorChanged && !opacityChanged && !widthChanged}
on:click={() => {
let style = {};
if (colorChanged) {
style['gpx_style:color'] = color;
}
if (opacityChanged) {
style['gpx_style:opacity'] = opacity[0];
}
if (widthChanged) {
style['gpx_style:width'] = width[0];
}
dbUtils.setStyleToSelection(style);
if (item instanceof ListFileItem && $selection.size === gpxLayers.size) {
if (style['gpx_style:opacity']) {
$defaultOpacity = style['gpx_style:opacity'];
}
if (style['gpx_style:width']) {
$defaultWidth = style['gpx_style:width'];
}
}
if (item instanceof ListFileItem && $selection.size === gpxLayers.size) {
if (style['gpx_style:opacity']) {
$defaultOpacity = style['gpx_style:opacity'];
}
if (style['gpx_style:width']) {
$defaultWidth = style['gpx_style:width'];
}
}
open = false;
}}
>
<Save size="16" class="mr-1" />
{$_('menu.metadata.save')}
</Button>
</Popover.Content>
open = false;
}}
>
<Save size="16" class="mr-1" />
{$_('menu.metadata.save')}
</Button>
</Popover.Content>
</Popover.Root>

View File

@@ -1,11 +1,17 @@
import { settings } from "$lib/db";
import { gpxStatistics } from "$lib/stores";
import { get } from "svelte/store";
import { settings } from '$lib/db';
import { gpxStatistics } from '$lib/stores';
import { get } from 'svelte/store';
const { distanceMarkers, distanceUnits } = settings;
const stops = [[100, 0], [50, 7], [25, 8, 10], [10, 10], [5, 11], [1, 13]];
const stops = [
[100, 0],
[50, 7],
[25, 8, 10],
[10, 10],
[5, 11],
[1, 13],
];
export class DistanceMarkers {
map: mapboxgl.Map;
@@ -30,7 +36,7 @@ export class DistanceMarkers {
} else {
this.map.addSource('distance-markers', {
type: 'geojson',
data: this.getDistanceMarkersGeoJSON()
data: this.getDistanceMarkersGeoJSON(),
});
}
stops.forEach(([d, minzoom, maxzoom]) => {
@@ -39,7 +45,14 @@ export class DistanceMarkers {
id: `distance-markers-${d}`,
type: 'symbol',
source: 'distance-markers',
filter: d === 5 ? ['any', ['==', ['get', 'level'], 5], ['==', ['get', 'level'], 25]] : ['==', ['get', 'level'], d],
filter:
d === 5
? [
'any',
['==', ['get', 'level'], 5],
['==', ['get', 'level'], 25],
]
: ['==', ['get', 'level'], d],
minzoom: minzoom,
maxzoom: maxzoom ?? 24,
layout: {
@@ -51,7 +64,7 @@ export class DistanceMarkers {
'text-color': 'black',
'text-halo-width': 2,
'text-halo-color': 'white',
}
},
});
} else {
this.map.moveLayer(`distance-markers-${d}`);
@@ -64,13 +77,14 @@ export class DistanceMarkers {
}
});
}
} catch (e) { // No reliable way to check if the map is ready to add sources and layers
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
return;
}
}
remove() {
this.unsubscribes.forEach(unsubscribe => unsubscribe());
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
}
getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection {
@@ -79,20 +93,28 @@ export class DistanceMarkers {
let features = [];
let currentTargetDistance = 1;
for (let i = 0; i < statistics.local.distance.total.length; i++) {
if (statistics.local.distance.total[i] >= currentTargetDistance * (get(distanceUnits) === 'metric' ? 1 : 1.60934)) {
if (
statistics.local.distance.total[i] >=
currentTargetDistance * (get(distanceUnits) === 'metric' ? 1 : 1.60934)
) {
let distance = currentTargetDistance.toFixed(0);
let [level, minzoom] = stops.find(([d]) => currentTargetDistance % d === 0) ?? [0, 0];
let [level, minzoom] = stops.find(([d]) => currentTargetDistance % d === 0) ?? [
0, 0,
];
features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [statistics.local.points[i].getLongitude(), statistics.local.points[i].getLatitude()]
coordinates: [
statistics.local.points[i].getLongitude(),
statistics.local.points[i].getLatitude(),
],
},
properties: {
distance,
level,
minzoom,
}
},
} as GeoJSON.Feature);
currentTargetDistance += 1;
}
@@ -100,7 +122,7 @@ export class DistanceMarkers {
return {
type: 'FeatureCollection',
features
features,
};
}
}
}

View File

@@ -1,14 +1,28 @@
import { currentTool, map, Tool } from "$lib/stores";
import { settings, type GPXFileWithStatistics, dbUtils } from "$lib/db";
import { get, type Readable } from "svelte/store";
import mapboxgl from "mapbox-gl";
import { waypointPopup, deleteWaypoint, trackpointPopup } from "./GPXLayerPopup";
import { addSelectItem, selectItem, selection } from "$lib/components/file-list/Selection";
import { ListTrackSegmentItem, ListWaypointItem, ListWaypointsItem, ListTrackItem, ListFileItem, ListRootItem } from "$lib/components/file-list/FileList";
import { getClosestLinePoint, getElevation, resetCursor, setGrabbingCursor, setPointerCursor, setScissorsCursor } from "$lib/utils";
import { selectedWaypoint } from "$lib/components/toolbar/tools/Waypoint.svelte";
import { MapPin, Square } from "lucide-static";
import { getSymbolKey, symbols } from "$lib/assets/symbols";
import { currentTool, map, Tool } from '$lib/stores';
import { settings, type GPXFileWithStatistics, dbUtils } from '$lib/db';
import { get, type Readable } from 'svelte/store';
import mapboxgl from 'mapbox-gl';
import { waypointPopup, deleteWaypoint, trackpointPopup } from './GPXLayerPopup';
import { addSelectItem, selectItem, selection } from '$lib/components/file-list/Selection';
import {
ListTrackSegmentItem,
ListWaypointItem,
ListWaypointsItem,
ListTrackItem,
ListFileItem,
ListRootItem,
} from '$lib/components/file-list/FileList';
import {
getClosestLinePoint,
getElevation,
resetCursor,
setGrabbingCursor,
setPointerCursor,
setScissorsCursor,
} from '$lib/utils';
import { selectedWaypoint } from '$lib/components/toolbar/tools/Waypoint.svelte';
import { MapPin, Square } from 'lucide-static';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
const colors = [
'#ff0000',
@@ -21,7 +35,7 @@ const colors = [
'#288228',
'#9933ff',
'#50f0be',
'#8c645a'
'#8c645a',
];
const colorCount: { [key: string]: number } = {};
@@ -56,12 +70,12 @@ class KeyDown {
if (e.key === this.key) {
this.down = true;
}
}
};
onKeyUp = (e: KeyboardEvent) => {
if (e.key === this.key) {
this.down = false;
}
}
};
isDown() {
return this.down;
}
@@ -70,22 +84,26 @@ class KeyDown {
function getMarkerForSymbol(symbol: string | undefined, layerColor: string) {
let symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
${Square
.replace('width="24"', 'width="12"')
.replace('height="24"', 'height="12"')
.replace('stroke="currentColor"', 'stroke="SteelBlue"')
.replace('stroke-width="2"', 'stroke-width="1.5" x="9.6" y="0.4"')
.replace('fill="none"', `fill="${layerColor}"`)}
${MapPin
.replace('width="24"', '')
.replace('height="24"', '')
.replace('stroke="currentColor"', '')
.replace('path', `path fill="#3fb1ce" stroke="SteelBlue" stroke-width="1"`)
.replace('circle', `circle fill="${symbolSvg ? 'none' : 'white'}" stroke="${symbolSvg ? 'none' : 'white'}" stroke-width="2"`)}
${symbolSvg?.replace('width="24"', 'width="10"')
${Square.replace('width="24"', 'width="12"')
.replace('height="24"', 'height="12"')
.replace('stroke="currentColor"', 'stroke="SteelBlue"')
.replace('stroke-width="2"', 'stroke-width="1.5" x="9.6" y="0.4"')
.replace('fill="none"', `fill="${layerColor}"`)}
${MapPin.replace('width="24"', '')
.replace('height="24"', '')
.replace('stroke="currentColor"', '')
.replace('path', `path fill="#3fb1ce" stroke="SteelBlue" stroke-width="1"`)
.replace(
'circle',
`circle fill="${symbolSvg ? 'none' : 'white'}" stroke="${symbolSvg ? 'none' : 'white'}" stroke-width="2"`
)}
${
symbolSvg
?.replace('width="24"', 'width="10"')
.replace('height="24"', 'height="10"')
.replace('stroke="currentColor"', 'stroke="white"')
.replace('stroke-width="2"', 'stroke-width="2.5" x="7" y="5"') ?? ''}
.replace('stroke-width="2"', 'stroke-width="2.5" x="7" y="5"') ?? ''
}
</svg>`;
}
@@ -108,32 +126,40 @@ export class GPXLayer {
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
layerOnContextMenuBinded: (e: any) => void = this.layerOnContextMenu.bind(this);
constructor(map: mapboxgl.Map, fileId: string, file: Readable<GPXFileWithStatistics | undefined>) {
constructor(
map: mapboxgl.Map,
fileId: string,
file: Readable<GPXFileWithStatistics | undefined>
) {
this.map = map;
this.fileId = fileId;
this.file = file;
this.layerColor = getColor();
this.unsubscribe.push(file.subscribe(this.updateBinded));
this.unsubscribe.push(selection.subscribe($selection => {
let newSelected = $selection.hasAnyChildren(new ListFileItem(this.fileId));
if (this.selected || newSelected) {
this.selected = newSelected;
this.update();
}
if (newSelected) {
this.moveToFront();
}
}));
this.unsubscribe.push(
selection.subscribe(($selection) => {
let newSelected = $selection.hasAnyChildren(new ListFileItem(this.fileId));
if (this.selected || newSelected) {
this.selected = newSelected;
this.update();
}
if (newSelected) {
this.moveToFront();
}
})
);
this.unsubscribe.push(directionMarkers.subscribe(this.updateBinded));
this.unsubscribe.push(currentTool.subscribe(tool => {
if (tool === Tool.WAYPOINT && !this.draggable) {
this.draggable = true;
this.markers.forEach(marker => marker.setDraggable(true));
} else if (tool !== Tool.WAYPOINT && this.draggable) {
this.draggable = false;
this.markers.forEach(marker => marker.setDraggable(false));
}
}));
this.unsubscribe.push(
currentTool.subscribe((tool) => {
if (tool === Tool.WAYPOINT && !this.draggable) {
this.draggable = true;
this.markers.forEach((marker) => marker.setDraggable(true));
} else if (tool !== Tool.WAYPOINT && this.draggable) {
this.draggable = false;
this.markers.forEach((marker) => marker.setDraggable(false));
}
})
);
this.draggable = get(currentTool) === Tool.WAYPOINT;
this.map.on('style.import.load', this.updateBinded);
@@ -149,7 +175,11 @@ export class GPXLayer {
return;
}
if (file._data.style && file._data.style.color && this.layerColor !== `#${file._data.style.color}`) {
if (
file._data.style &&
file._data.style.color &&
this.layerColor !== `#${file._data.style.color}`
) {
decrementColor(this.layerColor);
this.layerColor = `#${file._data.style.color}`;
}
@@ -161,7 +191,7 @@ export class GPXLayer {
} else {
this.map.addSource(this.fileId, {
type: 'geojson',
data: this.getGeoJSON()
data: this.getGeoJSON(),
});
}
@@ -172,13 +202,13 @@ export class GPXLayer {
source: this.fileId,
layout: {
'line-join': 'round',
'line-cap': 'round'
'line-cap': 'round',
},
paint: {
'line-color': ['get', 'color'],
'line-width': ['get', 'width'],
'line-opacity': ['get', 'opacity']
}
'line-opacity': ['get', 'opacity'],
},
});
this.map.on('click', this.fileId, this.layerOnClickBinded);
@@ -190,27 +220,30 @@ export class GPXLayer {
if (get(directionMarkers)) {
if (!this.map.getLayer(this.fileId + '-direction')) {
this.map.addLayer({
id: this.fileId + '-direction',
type: 'symbol',
source: this.fileId,
layout: {
'text-field': '»',
'text-offset': [0, -0.1],
'text-keep-upright': false,
'text-max-angle': 361,
'text-allow-overlap': true,
'text-font': ['Open Sans Bold'],
'symbol-placement': 'line',
'symbol-spacing': 20,
this.map.addLayer(
{
id: this.fileId + '-direction',
type: 'symbol',
source: this.fileId,
layout: {
'text-field': '»',
'text-offset': [0, -0.1],
'text-keep-upright': false,
'text-max-angle': 361,
'text-allow-overlap': true,
'text-font': ['Open Sans Bold'],
'symbol-placement': 'line',
'symbol-spacing': 20,
},
paint: {
'text-color': 'white',
'text-opacity': 0.7,
'text-halo-width': 0.2,
'text-halo-color': 'white',
},
},
paint: {
'text-color': 'white',
'text-opacity': 0.7,
'text-halo-width': 0.2,
'text-halo-color': 'white'
}
}, this.map.getLayer('distance-markers') ? 'distance-markers' : undefined);
this.map.getLayer('distance-markers') ? 'distance-markers' : undefined
);
}
} else {
if (this.map.getLayer(this.fileId + '-direction')) {
@@ -225,23 +258,53 @@ export class GPXLayer {
}
});
this.map.setFilter(this.fileId, ['any', ...visibleItems.map(([trackIndex, segmentIndex]) => ['all', ['==', 'trackIndex', trackIndex], ['==', 'segmentIndex', segmentIndex]])], { validate: false });
this.map.setFilter(
this.fileId,
[
'any',
...visibleItems.map(([trackIndex, segmentIndex]) => [
'all',
['==', 'trackIndex', trackIndex],
['==', 'segmentIndex', segmentIndex],
]),
],
{ validate: false }
);
if (this.map.getLayer(this.fileId + '-direction')) {
this.map.setFilter(this.fileId + '-direction', ['any', ...visibleItems.map(([trackIndex, segmentIndex]) => ['all', ['==', 'trackIndex', trackIndex], ['==', 'segmentIndex', segmentIndex]])], { validate: false });
this.map.setFilter(
this.fileId + '-direction',
[
'any',
...visibleItems.map(([trackIndex, segmentIndex]) => [
'all',
['==', 'trackIndex', trackIndex],
['==', 'segmentIndex', segmentIndex],
]),
],
{ validate: false }
);
}
} catch (e) { // No reliable way to check if the map is ready to add sources and layers
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
return;
}
let markerIndex = 0;
if (get(selection).hasAnyChildren(new ListFileItem(this.fileId))) {
file.wpt.forEach((waypoint) => { // Update markers
file.wpt.forEach((waypoint) => {
// Update markers
let symbolKey = getSymbolKey(waypoint.sym);
if (markerIndex < this.markers.length) {
this.markers[markerIndex].getElement().innerHTML = getMarkerForSymbol(symbolKey, this.layerColor);
this.markers[markerIndex].getElement().innerHTML = getMarkerForSymbol(
symbolKey,
this.layerColor
);
this.markers[markerIndex].setLngLat(waypoint.getCoordinates());
Object.defineProperty(this.markers[markerIndex], '_waypoint', { value: waypoint, writable: true });
Object.defineProperty(this.markers[markerIndex], '_waypoint', {
value: waypoint,
writable: true,
});
} else {
let element = document.createElement('div');
element.classList.add('w-8', 'h-8', 'drop-shadow-xl');
@@ -249,7 +312,7 @@ export class GPXLayer {
let marker = new mapboxgl.Marker({
draggable: this.draggable,
element,
anchor: 'bottom'
anchor: 'bottom',
}).setLngLat(waypoint.getCoordinates());
Object.defineProperty(marker, '_waypoint', { value: waypoint, writable: true });
let dragEndTimestamp = 0;
@@ -272,10 +335,20 @@ export class GPXLayer {
}
if (get(treeFileView)) {
if ((e.ctrlKey || e.metaKey) && get(selection).hasAnyChildren(new ListWaypointsItem(this.fileId), false)) {
addSelectItem(new ListWaypointItem(this.fileId, marker._waypoint._data.index));
if (
(e.ctrlKey || e.metaKey) &&
get(selection).hasAnyChildren(
new ListWaypointsItem(this.fileId),
false
)
) {
addSelectItem(
new ListWaypointItem(this.fileId, marker._waypoint._data.index)
);
} else {
selectItem(new ListWaypointItem(this.fileId, marker._waypoint._data.index));
selectItem(
new ListWaypointItem(this.fileId, marker._waypoint._data.index)
);
}
} else if (get(currentTool) === Tool.WAYPOINT) {
selectedWaypoint.set([marker._waypoint, this.fileId]);
@@ -298,12 +371,12 @@ export class GPXLayer {
let wpt = file.wpt[marker._waypoint._data.index];
wpt.setCoordinates({
lat: latLng.lat,
lon: latLng.lng
lon: latLng.lng,
});
wpt.ele = ele[0];
});
});
dragEndTimestamp = Date.now()
dragEndTimestamp = Date.now();
});
this.markers.push(marker);
}
@@ -311,7 +384,8 @@ export class GPXLayer {
});
}
while (markerIndex < this.markers.length) { // Remove extra markers
while (markerIndex < this.markers.length) {
// Remove extra markers
this.markers.pop()?.remove();
}
@@ -364,7 +438,10 @@ export class GPXLayer {
this.map.moveLayer(this.fileId);
}
if (this.map.getLayer(this.fileId + '-direction')) {
this.map.moveLayer(this.fileId + '-direction', this.map.getLayer('distance-markers') ? 'distance-markers' : undefined);
this.map.moveLayer(
this.fileId + '-direction',
this.map.getLayer('distance-markers') ? 'distance-markers' : undefined
);
}
}
@@ -372,7 +449,12 @@ export class GPXLayer {
let trackIndex = e.features[0].properties.trackIndex;
let segmentIndex = e.features[0].properties.segmentIndex;
if (get(currentTool) === Tool.SCISSORS && get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
if (
get(currentTool) === Tool.SCISSORS &&
get(selection).hasAnyParent(
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
)
) {
setScissorsCursor();
} else {
setPointerCursor();
@@ -390,22 +472,36 @@ export class GPXLayer {
const file = get(this.file)?.file;
if (file) {
const closest = getClosestLinePoint(file.trk[trackIndex].trkseg[segmentIndex].trkpt, { lat: e.lngLat.lat, lon: e.lngLat.lng });
const closest = getClosestLinePoint(
file.trk[trackIndex].trkseg[segmentIndex].trkpt,
{ lat: e.lngLat.lat, lon: e.lngLat.lng }
);
trackpointPopup?.setItem({ item: closest, fileId: this.fileId });
}
}
}
layerOnClick(e: any) {
if (get(currentTool) === Tool.ROUTING && get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])) {
if (
get(currentTool) === Tool.ROUTING &&
get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])
) {
return;
}
let trackIndex = e.features[0].properties.trackIndex;
let segmentIndex = e.features[0].properties.segmentIndex;
if (get(currentTool) === Tool.SCISSORS && get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
dbUtils.split(this.fileId, trackIndex, segmentIndex, { lat: e.lngLat.lat, lon: e.lngLat.lng });
if (
get(currentTool) === Tool.SCISSORS &&
get(selection).hasAnyParent(
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
)
) {
dbUtils.split(this.fileId, trackIndex, segmentIndex, {
lat: e.lngLat.lat,
lon: e.lngLat.lng,
});
return;
}
@@ -415,8 +511,12 @@ export class GPXLayer {
}
let item = undefined;
if (get(treeFileView) && file.getSegments().length > 1) { // Select inner item
item = file.children[trackIndex].children.length > 1 ? new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex) : new ListTrackItem(this.fileId, trackIndex);
if (get(treeFileView) && file.getSegments().length > 1) {
// Select inner item
item =
file.children[trackIndex].children.length > 1
? new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
: new ListTrackItem(this.fileId, trackIndex);
} else {
item = new ListFileItem(this.fileId);
}
@@ -439,13 +539,14 @@ export class GPXLayer {
if (!file) {
return {
type: 'FeatureCollection',
features: []
features: [],
};
}
let data = file.toGeoJSON();
let trackIndex = 0, segmentIndex = 0;
let trackIndex = 0,
segmentIndex = 0;
for (let feature of data.features) {
if (!feature.properties) {
feature.properties = {};
@@ -459,7 +560,12 @@ export class GPXLayer {
if (!feature.properties.width) {
feature.properties.width = get(defaultWidth);
}
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)) || get(selection).hasAnyChildren(new ListWaypointsItem(this.fileId), true)) {
if (
get(selection).hasAnyParent(
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
) ||
get(selection).hasAnyChildren(new ListWaypointsItem(this.fileId), true)
) {
feature.properties.width = feature.properties.width + 2;
feature.properties.opacity = Math.min(1, feature.properties.opacity + 0.1);
}
@@ -474,4 +580,4 @@ export class GPXLayer {
}
return data;
}
}
}

View File

@@ -1,5 +1,5 @@
import { dbUtils } from "$lib/db";
import { MapPopup } from "$lib/components/MapPopup";
import { dbUtils } from '$lib/db';
import { MapPopup } from '$lib/components/MapPopup';
export let waypointPopup: MapPopup | null = null;
export let trackpointPopup: MapPopup | null = null;
@@ -11,14 +11,14 @@ export function createPopups(map: mapboxgl.Map) {
focusAfterOpen: false,
maxWidth: undefined,
offset: {
'top': [0, 0],
top: [0, 0],
'top-left': [0, 0],
'top-right': [0, 0],
'bottom': [0, -30],
bottom: [0, -30],
'bottom-left': [0, -30],
'bottom-right': [0, -30],
'left': [10, -15],
'right': [-10, -15],
left: [10, -15],
right: [-10, -15],
},
});
trackpointPopup = new MapPopup(map, {
@@ -41,4 +41,4 @@ export function removePopups() {
export function deleteWaypoint(fileId: string, waypointIndex: number) {
dbUtils.applyToFile(fileId, (file) => file.replaceWaypoints(waypointIndex, waypointIndex, []));
}
}

View File

@@ -1,56 +1,56 @@
<script lang="ts">
import { map, gpxLayers } from '$lib/stores';
import { GPXLayer } from './GPXLayer';
import { fileObservers } from '$lib/db';
import { DistanceMarkers } from './DistanceMarkers';
import { StartEndMarkers } from './StartEndMarkers';
import { onDestroy } from 'svelte';
import { createPopups, removePopups } from './GPXLayerPopup';
import { map, gpxLayers } from '$lib/stores';
import { GPXLayer } from './GPXLayer';
import { fileObservers } from '$lib/db';
import { DistanceMarkers } from './DistanceMarkers';
import { StartEndMarkers } from './StartEndMarkers';
import { onDestroy } from 'svelte';
import { createPopups, removePopups } from './GPXLayerPopup';
let distanceMarkers: DistanceMarkers | undefined = undefined;
let startEndMarkers: StartEndMarkers | undefined = undefined;
let distanceMarkers: DistanceMarkers | undefined = undefined;
let startEndMarkers: StartEndMarkers | undefined = undefined;
$: if ($map && $fileObservers) {
// remove layers for deleted files
gpxLayers.forEach((layer, fileId) => {
if (!$fileObservers.has(fileId)) {
layer.remove();
gpxLayers.delete(fileId);
} else if ($map !== layer.map) {
layer.updateMap($map);
}
});
// add layers for new files
$fileObservers.forEach((file, fileId) => {
if (!gpxLayers.has(fileId)) {
gpxLayers.set(fileId, new GPXLayer($map, fileId, file));
}
});
}
$: if ($map && $fileObservers) {
// remove layers for deleted files
gpxLayers.forEach((layer, fileId) => {
if (!$fileObservers.has(fileId)) {
layer.remove();
gpxLayers.delete(fileId);
} else if ($map !== layer.map) {
layer.updateMap($map);
}
});
// add layers for new files
$fileObservers.forEach((file, fileId) => {
if (!gpxLayers.has(fileId)) {
gpxLayers.set(fileId, new GPXLayer($map, fileId, file));
}
});
}
$: if ($map) {
if (distanceMarkers) {
distanceMarkers.remove();
}
if (startEndMarkers) {
startEndMarkers.remove();
}
createPopups($map);
distanceMarkers = new DistanceMarkers($map);
startEndMarkers = new StartEndMarkers($map);
}
$: if ($map) {
if (distanceMarkers) {
distanceMarkers.remove();
}
if (startEndMarkers) {
startEndMarkers.remove();
}
createPopups($map);
distanceMarkers = new DistanceMarkers($map);
startEndMarkers = new StartEndMarkers($map);
}
onDestroy(() => {
gpxLayers.forEach((layer) => layer.remove());
gpxLayers.clear();
removePopups();
if (distanceMarkers) {
distanceMarkers.remove();
distanceMarkers = undefined;
}
if (startEndMarkers) {
startEndMarkers.remove();
startEndMarkers = undefined;
}
});
onDestroy(() => {
gpxLayers.forEach((layer) => layer.remove());
gpxLayers.clear();
removePopups();
if (distanceMarkers) {
distanceMarkers.remove();
distanceMarkers = undefined;
}
if (startEndMarkers) {
startEndMarkers.remove();
startEndMarkers = undefined;
}
});
</script>

View File

@@ -1,6 +1,6 @@
import { gpxStatistics, slicedGPXStatistics, currentTool, Tool } from "$lib/stores";
import mapboxgl from "mapbox-gl";
import { get } from "svelte/store";
import { gpxStatistics, slicedGPXStatistics, currentTool, Tool } from '$lib/stores';
import mapboxgl from 'mapbox-gl';
import { get } from 'svelte/store';
export class StartEndMarkers {
map: mapboxgl.Map;
@@ -16,7 +16,8 @@ export class StartEndMarkers {
let endElement = document.createElement('div');
startElement.className = `h-4 w-4 rounded-full bg-green-500 border-2 border-white`;
endElement.className = `h-4 w-4 rounded-full border-2 border-white`;
endElement.style.background = 'repeating-conic-gradient(#fff 0 90deg, #000 0 180deg) 0 0/8px 8px round';
endElement.style.background =
'repeating-conic-gradient(#fff 0 90deg, #000 0 180deg) 0 0/8px 8px round';
this.start = new mapboxgl.Marker({ element: startElement });
this.end = new mapboxgl.Marker({ element: endElement });
@@ -31,7 +32,11 @@ export class StartEndMarkers {
let statistics = get(slicedGPXStatistics)?.[0] ?? get(gpxStatistics);
if (statistics.local.points.length > 0 && tool !== Tool.ROUTING) {
this.start.setLngLat(statistics.local.points[0].getCoordinates()).addTo(this.map);
this.end.setLngLat(statistics.local.points[statistics.local.points.length - 1].getCoordinates()).addTo(this.map);
this.end
.setLngLat(
statistics.local.points[statistics.local.points.length - 1].getCoordinates()
)
.addTo(this.map);
} else {
this.start.remove();
this.end.remove();
@@ -39,9 +44,9 @@ export class StartEndMarkers {
}
remove() {
this.unsubscribes.forEach(unsubscribe => unsubscribe());
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
this.start.remove();
this.end.remove();
}
}
}

View File

@@ -1,51 +1,51 @@
<script lang="ts">
import type { TrackPoint } from 'gpx';
import type { PopupItem } from '$lib/components/MapPopup';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import WithUnits from '$lib/components/WithUnits.svelte';
import { Compass, Mountain, Timer, ClipboardCopy } from 'lucide-svelte';
import { df } from '$lib/utils';
import { _ } from 'svelte-i18n';
import type { TrackPoint } from 'gpx';
import type { PopupItem } from '$lib/components/MapPopup';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import WithUnits from '$lib/components/WithUnits.svelte';
import { Compass, Mountain, Timer, ClipboardCopy } from 'lucide-svelte';
import { df } from '$lib/utils';
import { _ } from 'svelte-i18n';
export let trackpoint: PopupItem<TrackPoint>;
export let trackpoint: PopupItem<TrackPoint>;
</script>
<Card.Root class="border-none shadow-md text-base p-2">
<Card.Header class="p-0">
<Card.Title class="text-md"></Card.Title>
</Card.Header>
<Card.Content class="flex flex-col p-0 text-xs gap-1">
<div class="flex flex-row items-center gap-1">
<Compass size="14" />
{trackpoint.item.getLatitude().toFixed(6)}&deg; {trackpoint.item
.getLongitude()
.toFixed(6)}&deg;
</div>
{#if trackpoint.item.ele !== undefined}
<div class="flex flex-row items-center gap-1">
<Mountain size="14" />
<WithUnits value={trackpoint.item.ele} type="elevation" />
</div>
{/if}
{#if trackpoint.item.time}
<div class="flex flex-row items-center gap-1">
<Timer size="14" />
{df.format(trackpoint.item.time)}
</div>
{/if}
<Button
class="w-full px-2 py-1 h-6 justify-start mt-0.5"
variant="secondary"
on:click={() => {
navigator.clipboard.writeText(
`${trackpoint.item.getLatitude().toFixed(6)}, ${trackpoint.item.getLongitude().toFixed(6)}`
);
trackpoint.hide?.();
}}
>
<ClipboardCopy size="16" class="mr-1" />
{$_('menu.copy_coordinates')}
</Button>
</Card.Content>
<Card.Header class="p-0">
<Card.Title class="text-md"></Card.Title>
</Card.Header>
<Card.Content class="flex flex-col p-0 text-xs gap-1">
<div class="flex flex-row items-center gap-1">
<Compass size="14" />
{trackpoint.item.getLatitude().toFixed(6)}&deg; {trackpoint.item
.getLongitude()
.toFixed(6)}&deg;
</div>
{#if trackpoint.item.ele !== undefined}
<div class="flex flex-row items-center gap-1">
<Mountain size="14" />
<WithUnits value={trackpoint.item.ele} type="elevation" />
</div>
{/if}
{#if trackpoint.item.time}
<div class="flex flex-row items-center gap-1">
<Timer size="14" />
{df.format(trackpoint.item.time)}
</div>
{/if}
<Button
class="w-full px-2 py-1 h-6 justify-start mt-0.5"
variant="secondary"
on:click={() => {
navigator.clipboard.writeText(
`${trackpoint.item.getLatitude().toFixed(6)}, ${trackpoint.item.getLongitude().toFixed(6)}`
);
trackpoint.hide?.();
}}
>
<ClipboardCopy size="16" class="mr-1" />
{$_('menu.copy_coordinates')}
</Button>
</Card.Content>
</Card.Root>

View File

@@ -1,102 +1,104 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import Shortcut from '$lib/components/Shortcut.svelte';
import { deleteWaypoint } from './GPXLayerPopup';
import WithUnits from '$lib/components/WithUnits.svelte';
import { Dot, ExternalLink, Trash2 } from 'lucide-svelte';
import { Tool, currentTool } from '$lib/stores';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
import { _ } from 'svelte-i18n';
import sanitizeHtml from 'sanitize-html';
import type { Waypoint } from 'gpx';
import type { PopupItem } from '$lib/components/MapPopup';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import Shortcut from '$lib/components/Shortcut.svelte';
import { deleteWaypoint } from './GPXLayerPopup';
import WithUnits from '$lib/components/WithUnits.svelte';
import { Dot, ExternalLink, Trash2 } from 'lucide-svelte';
import { Tool, currentTool } from '$lib/stores';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
import { _ } from 'svelte-i18n';
import sanitizeHtml from 'sanitize-html';
import type { Waypoint } from 'gpx';
import type { PopupItem } from '$lib/components/MapPopup';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
export let waypoint: PopupItem<Waypoint>;
export let waypoint: PopupItem<Waypoint>;
$: symbolKey = waypoint ? getSymbolKey(waypoint.item.sym) : undefined;
$: symbolKey = waypoint ? getSymbolKey(waypoint.item.sym) : undefined;
function sanitize(text: string | undefined): string {
if (text === undefined) {
return '';
}
return sanitizeHtml(text, {
allowedTags: ['a', 'br', 'img'],
allowedAttributes: {
a: ['href', 'target'],
img: ['src']
}
}).trim();
}
function sanitize(text: string | undefined): string {
if (text === undefined) {
return '';
}
return sanitizeHtml(text, {
allowedTags: ['a', 'br', 'img'],
allowedAttributes: {
a: ['href', 'target'],
img: ['src'],
},
}).trim();
}
</script>
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw]">
<Card.Header class="p-0">
<Card.Title class="text-md">
{#if waypoint.item.link && waypoint.item.link.attributes && waypoint.item.link.attributes.href}
<a href={waypoint.item.link.attributes.href} target="_blank">
{waypoint.item.name ?? waypoint.item.link.attributes.href}
<ExternalLink size="12" class="inline-block mb-1.5" />
</a>
{:else}
{waypoint.item.name ?? $_('gpx.waypoint')}
{/if}
</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col text-sm p-0">
<div class="flex flex-row items-center text-muted-foreground text-xs whitespace-nowrap">
{#if symbolKey}
<span>
{#if symbols[symbolKey].icon}
<svelte:component
this={symbols[symbolKey].icon}
size="12"
class="inline-block mb-0.5"
/>
{:else}
<span class="w-4 inline-block" />
{/if}
{$_(`gpx.symbol.${symbolKey}`)}
</span>
<Dot size="16" />
{/if}
{waypoint.item.getLatitude().toFixed(6)}&deg; {waypoint.item.getLongitude().toFixed(6)}&deg;
{#if waypoint.item.ele !== undefined}
<Dot size="16" />
<WithUnits value={waypoint.item.ele} type="elevation" />
{/if}
</div>
<ScrollArea class="flex flex-col" viewportClasses="max-h-[30dvh]">
{#if waypoint.item.desc}
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.desc)}</span>
{/if}
{#if waypoint.item.cmt && waypoint.item.cmt !== waypoint.item.desc}
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.cmt)}</span>
{/if}
</ScrollArea>
{#if $currentTool === Tool.WAYPOINT}
<Button
class="mt-2 w-full px-2 py-1 h-8 justify-start"
variant="outline"
on:click={() => deleteWaypoint(waypoint.fileId, waypoint.item._data.index)}
>
<Trash2 size="16" class="mr-1" />
{$_('menu.delete')}
<Shortcut shift={true} click={true} />
</Button>
{/if}
</Card.Content>
<Card.Header class="p-0">
<Card.Title class="text-md">
{#if waypoint.item.link && waypoint.item.link.attributes && waypoint.item.link.attributes.href}
<a href={waypoint.item.link.attributes.href} target="_blank">
{waypoint.item.name ?? waypoint.item.link.attributes.href}
<ExternalLink size="12" class="inline-block mb-1.5" />
</a>
{:else}
{waypoint.item.name ?? $_('gpx.waypoint')}
{/if}
</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col text-sm p-0">
<div class="flex flex-row items-center text-muted-foreground text-xs whitespace-nowrap">
{#if symbolKey}
<span>
{#if symbols[symbolKey].icon}
<svelte:component
this={symbols[symbolKey].icon}
size="12"
class="inline-block mb-0.5"
/>
{:else}
<span class="w-4 inline-block" />
{/if}
{$_(`gpx.symbol.${symbolKey}`)}
</span>
<Dot size="16" />
{/if}
{waypoint.item.getLatitude().toFixed(6)}&deg; {waypoint.item
.getLongitude()
.toFixed(6)}&deg;
{#if waypoint.item.ele !== undefined}
<Dot size="16" />
<WithUnits value={waypoint.item.ele} type="elevation" />
{/if}
</div>
<ScrollArea class="flex flex-col" viewportClasses="max-h-[30dvh]">
{#if waypoint.item.desc}
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.desc)}</span>
{/if}
{#if waypoint.item.cmt && waypoint.item.cmt !== waypoint.item.desc}
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.cmt)}</span>
{/if}
</ScrollArea>
{#if $currentTool === Tool.WAYPOINT}
<Button
class="mt-2 w-full px-2 py-1 h-8 justify-start"
variant="outline"
on:click={() => deleteWaypoint(waypoint.fileId, waypoint.item._data.index)}
>
<Trash2 size="16" class="mr-1" />
{$_('menu.delete')}
<Shortcut shift={true} click={true} />
</Button>
{/if}
</Card.Content>
</Card.Root>
<style lang="postcss">
div :global(a) {
@apply text-link;
@apply hover:underline;
}
div :global(a) {
@apply text-link;
@apply hover:underline;
}
div :global(img) {
@apply my-0;
@apply rounded-md;
}
div :global(img) {
@apply my-0;
@apply rounded-md;
}
</style>

View File

@@ -1,422 +1,436 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Button } from '$lib/components/ui/button';
import { Separator } from '$lib/components/ui/separator';
import * as RadioGroup from '$lib/components/ui/radio-group';
import {
CirclePlus,
CircleX,
Minus,
Pencil,
Plus,
Save,
Trash2,
Move,
Map,
Layers2
} from 'lucide-svelte';
import { _ } from 'svelte-i18n';
import { settings } from '$lib/db';
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
import { map } from '$lib/stores';
import { onDestroy, onMount } from 'svelte';
import Sortable from 'sortablejs/Sortable';
import { customBasemapUpdate } from './utils';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Button } from '$lib/components/ui/button';
import { Separator } from '$lib/components/ui/separator';
import * as RadioGroup from '$lib/components/ui/radio-group';
import {
CirclePlus,
CircleX,
Minus,
Pencil,
Plus,
Save,
Trash2,
Move,
Map,
Layers2,
} from 'lucide-svelte';
import { _ } from 'svelte-i18n';
import { settings } from '$lib/db';
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
import { map } from '$lib/stores';
import { onDestroy, onMount } from 'svelte';
import Sortable from 'sortablejs/Sortable';
import { customBasemapUpdate } from './utils';
const {
customLayers,
selectedBasemapTree,
selectedOverlayTree,
currentBasemap,
previousBasemap,
currentOverlays,
previousOverlays,
customBasemapOrder,
customOverlayOrder
} = settings;
const {
customLayers,
selectedBasemapTree,
selectedOverlayTree,
currentBasemap,
previousBasemap,
currentOverlays,
previousOverlays,
customBasemapOrder,
customOverlayOrder,
} = settings;
let name: string = '';
let tileUrls: string[] = [''];
let maxZoom: number = 20;
let layerType: 'basemap' | 'overlay' = 'basemap';
let resourceType: 'raster' | 'vector' = 'raster';
let name: string = '';
let tileUrls: string[] = [''];
let maxZoom: number = 20;
let layerType: 'basemap' | 'overlay' = 'basemap';
let resourceType: 'raster' | 'vector' = 'raster';
let basemapContainer: HTMLElement;
let overlayContainer: HTMLElement;
let basemapContainer: HTMLElement;
let overlayContainer: HTMLElement;
let basemapSortable: Sortable;
let overlaySortable: Sortable;
let basemapSortable: Sortable;
let overlaySortable: Sortable;
onMount(() => {
if ($customBasemapOrder.length === 0) {
$customBasemapOrder = Object.keys($customLayers).filter(
(id) => $customLayers[id].layerType === 'basemap'
);
}
if ($customOverlayOrder.length === 0) {
$customOverlayOrder = Object.keys($customLayers).filter(
(id) => $customLayers[id].layerType === 'overlay'
);
}
onMount(() => {
if ($customBasemapOrder.length === 0) {
$customBasemapOrder = Object.keys($customLayers).filter(
(id) => $customLayers[id].layerType === 'basemap'
);
}
if ($customOverlayOrder.length === 0) {
$customOverlayOrder = Object.keys($customLayers).filter(
(id) => $customLayers[id].layerType === 'overlay'
);
}
basemapSortable = Sortable.create(basemapContainer, {
onSort: (e) => {
$customBasemapOrder = basemapSortable.toArray();
$selectedBasemapTree.basemaps['custom'] = $customBasemapOrder.reduce((acc, id) => {
acc[id] = true;
return acc;
}, {});
}
});
overlaySortable = Sortable.create(overlayContainer, {
onSort: (e) => {
$customOverlayOrder = overlaySortable.toArray();
$selectedOverlayTree.overlays['custom'] = $customOverlayOrder.reduce((acc, id) => {
acc[id] = true;
return acc;
}, {});
}
});
basemapSortable = Sortable.create(basemapContainer, {
onSort: (e) => {
$customBasemapOrder = basemapSortable.toArray();
$selectedBasemapTree.basemaps['custom'] = $customBasemapOrder.reduce((acc, id) => {
acc[id] = true;
return acc;
}, {});
},
});
overlaySortable = Sortable.create(overlayContainer, {
onSort: (e) => {
$customOverlayOrder = overlaySortable.toArray();
$selectedOverlayTree.overlays['custom'] = $customOverlayOrder.reduce((acc, id) => {
acc[id] = true;
return acc;
}, {});
},
});
basemapSortable.sort($customBasemapOrder);
overlaySortable.sort($customOverlayOrder);
});
basemapSortable.sort($customBasemapOrder);
overlaySortable.sort($customOverlayOrder);
});
onDestroy(() => {
basemapSortable.destroy();
overlaySortable.destroy();
});
onDestroy(() => {
basemapSortable.destroy();
overlaySortable.destroy();
});
$: if (tileUrls[0].length > 0) {
if (
tileUrls[0].includes('.json') ||
(tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles'))
) {
resourceType = 'vector';
} else {
resourceType = 'raster';
}
}
$: if (tileUrls[0].length > 0) {
if (
tileUrls[0].includes('.json') ||
(tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles'))
) {
resourceType = 'vector';
} else {
resourceType = 'raster';
}
}
function createLayer() {
if (selectedLayerId && $customLayers[selectedLayerId].layerType !== layerType) {
deleteLayer(selectedLayerId);
}
function createLayer() {
if (selectedLayerId && $customLayers[selectedLayerId].layerType !== layerType) {
deleteLayer(selectedLayerId);
}
if (typeof maxZoom === 'string') {
maxZoom = parseInt(maxZoom);
}
let is512 = tileUrls.some((url) => url.includes('512'));
if (typeof maxZoom === 'string') {
maxZoom = parseInt(maxZoom);
}
let is512 = tileUrls.some((url) => url.includes('512'));
let layerId = selectedLayerId ?? getLayerId();
let layer: CustomLayer = {
id: layerId,
name: name,
tileUrls: tileUrls.map((url) => decodeURI(url.trim())),
maxZoom: maxZoom,
layerType: layerType,
resourceType: resourceType,
value: ''
};
let layerId = selectedLayerId ?? getLayerId();
let layer: CustomLayer = {
id: layerId,
name: name,
tileUrls: tileUrls.map((url) => decodeURI(url.trim())),
maxZoom: maxZoom,
layerType: layerType,
resourceType: resourceType,
value: '',
};
if (resourceType === 'vector') {
layer.value = layer.tileUrls[0];
} else {
layer.value = {
version: 8,
sources: {
[layerId]: {
type: 'raster',
tiles: layer.tileUrls,
tileSize: is512 ? 512 : 256,
maxzoom: maxZoom
}
},
layers: [
{
id: layerId,
type: 'raster',
source: layerId
}
]
};
}
$customLayers[layerId] = layer;
addLayer(layerId);
selectedLayerId = undefined;
setDataFromSelectedLayer();
}
if (resourceType === 'vector') {
layer.value = layer.tileUrls[0];
} else {
layer.value = {
version: 8,
sources: {
[layerId]: {
type: 'raster',
tiles: layer.tileUrls,
tileSize: is512 ? 512 : 256,
maxzoom: maxZoom,
},
},
layers: [
{
id: layerId,
type: 'raster',
source: layerId,
},
],
};
}
$customLayers[layerId] = layer;
addLayer(layerId);
selectedLayerId = undefined;
setDataFromSelectedLayer();
}
function getLayerId() {
for (let id = 0; ; id++) {
if (!$customLayers.hasOwnProperty(`custom-${id}`)) {
return `custom-${id}`;
}
}
}
function getLayerId() {
for (let id = 0; ; id++) {
if (!$customLayers.hasOwnProperty(`custom-${id}`)) {
return `custom-${id}`;
}
}
}
function addLayer(layerId: string) {
if (layerType === 'basemap') {
selectedBasemapTree.update(($tree) => {
if (!$tree.basemaps.hasOwnProperty('custom')) {
$tree.basemaps['custom'] = {};
}
$tree.basemaps['custom'][layerId] = true;
return $tree;
});
function addLayer(layerId: string) {
if (layerType === 'basemap') {
selectedBasemapTree.update(($tree) => {
if (!$tree.basemaps.hasOwnProperty('custom')) {
$tree.basemaps['custom'] = {};
}
$tree.basemaps['custom'][layerId] = true;
return $tree;
});
if ($currentBasemap === layerId) {
$customBasemapUpdate++;
} else {
$currentBasemap = layerId;
}
if ($currentBasemap === layerId) {
$customBasemapUpdate++;
} else {
$currentBasemap = layerId;
}
if (!$customBasemapOrder.includes(layerId)) {
$customBasemapOrder = [...$customBasemapOrder, layerId];
}
} else {
selectedOverlayTree.update(($tree) => {
if (!$tree.overlays.hasOwnProperty('custom')) {
$tree.overlays['custom'] = {};
}
$tree.overlays['custom'][layerId] = true;
return $tree;
});
if (!$customBasemapOrder.includes(layerId)) {
$customBasemapOrder = [...$customBasemapOrder, layerId];
}
} else {
selectedOverlayTree.update(($tree) => {
if (!$tree.overlays.hasOwnProperty('custom')) {
$tree.overlays['custom'] = {};
}
$tree.overlays['custom'][layerId] = true;
return $tree;
});
if (
$currentOverlays.overlays['custom'] &&
$currentOverlays.overlays['custom'][layerId] &&
$map
) {
try {
$map.removeImport(layerId);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
if (
$currentOverlays.overlays['custom'] &&
$currentOverlays.overlays['custom'][layerId] &&
$map
) {
try {
$map.removeImport(layerId);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
if (!$currentOverlays.overlays.hasOwnProperty('custom')) {
$currentOverlays.overlays['custom'] = {};
}
$currentOverlays.overlays['custom'][layerId] = true;
if (!$currentOverlays.overlays.hasOwnProperty('custom')) {
$currentOverlays.overlays['custom'] = {};
}
$currentOverlays.overlays['custom'][layerId] = true;
if (!$customOverlayOrder.includes(layerId)) {
$customOverlayOrder = [...$customOverlayOrder, layerId];
}
}
}
if (!$customOverlayOrder.includes(layerId)) {
$customOverlayOrder = [...$customOverlayOrder, layerId];
}
}
}
function tryDeleteLayer(node: any, id: string): any {
if (node.hasOwnProperty(id)) {
delete node[id];
}
return node;
}
function tryDeleteLayer(node: any, id: string): any {
if (node.hasOwnProperty(id)) {
delete node[id];
}
return node;
}
function deleteLayer(layerId: string) {
let layer = $customLayers[layerId];
if (layer.layerType === 'basemap') {
if (layerId === $currentBasemap) {
$currentBasemap = defaultBasemap;
}
if (layerId === $previousBasemap) {
$previousBasemap = defaultBasemap;
}
function deleteLayer(layerId: string) {
let layer = $customLayers[layerId];
if (layer.layerType === 'basemap') {
if (layerId === $currentBasemap) {
$currentBasemap = defaultBasemap;
}
if (layerId === $previousBasemap) {
$previousBasemap = defaultBasemap;
}
$selectedBasemapTree.basemaps['custom'] = tryDeleteLayer(
$selectedBasemapTree.basemaps['custom'],
layerId
);
if (Object.keys($selectedBasemapTree.basemaps['custom']).length === 0) {
$selectedBasemapTree.basemaps = tryDeleteLayer($selectedBasemapTree.basemaps, 'custom');
}
$customBasemapOrder = $customBasemapOrder.filter((id) => id !== layerId);
} else {
$currentOverlays.overlays['custom'][layerId] = false;
if ($previousOverlays.overlays['custom']) {
$previousOverlays.overlays['custom'] = tryDeleteLayer(
$previousOverlays.overlays['custom'],
layerId
);
}
$selectedBasemapTree.basemaps['custom'] = tryDeleteLayer(
$selectedBasemapTree.basemaps['custom'],
layerId
);
if (Object.keys($selectedBasemapTree.basemaps['custom']).length === 0) {
$selectedBasemapTree.basemaps = tryDeleteLayer(
$selectedBasemapTree.basemaps,
'custom'
);
}
$customBasemapOrder = $customBasemapOrder.filter((id) => id !== layerId);
} else {
$currentOverlays.overlays['custom'][layerId] = false;
if ($previousOverlays.overlays['custom']) {
$previousOverlays.overlays['custom'] = tryDeleteLayer(
$previousOverlays.overlays['custom'],
layerId
);
}
$selectedOverlayTree.overlays['custom'] = tryDeleteLayer(
$selectedOverlayTree.overlays['custom'],
layerId
);
if (Object.keys($selectedOverlayTree.overlays['custom']).length === 0) {
$selectedOverlayTree.overlays = tryDeleteLayer($selectedOverlayTree.overlays, 'custom');
}
$customOverlayOrder = $customOverlayOrder.filter((id) => id !== layerId);
$selectedOverlayTree.overlays['custom'] = tryDeleteLayer(
$selectedOverlayTree.overlays['custom'],
layerId
);
if (Object.keys($selectedOverlayTree.overlays['custom']).length === 0) {
$selectedOverlayTree.overlays = tryDeleteLayer(
$selectedOverlayTree.overlays,
'custom'
);
}
$customOverlayOrder = $customOverlayOrder.filter((id) => id !== layerId);
if (
$currentOverlays.overlays['custom'] &&
$currentOverlays.overlays['custom'][layerId] &&
$map
) {
try {
$map.removeImport(layerId);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
}
$customLayers = tryDeleteLayer($customLayers, layerId);
}
if (
$currentOverlays.overlays['custom'] &&
$currentOverlays.overlays['custom'][layerId] &&
$map
) {
try {
$map.removeImport(layerId);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
}
$customLayers = tryDeleteLayer($customLayers, layerId);
}
let selectedLayerId: string | undefined = undefined;
let selectedLayerId: string | undefined = undefined;
function setDataFromSelectedLayer() {
if (selectedLayerId) {
const layer = $customLayers[selectedLayerId];
name = layer.name;
tileUrls = layer.tileUrls;
maxZoom = layer.maxZoom;
layerType = layer.layerType;
resourceType = layer.resourceType;
} else {
name = '';
tileUrls = [''];
maxZoom = 20;
layerType = 'basemap';
resourceType = 'raster';
}
}
function setDataFromSelectedLayer() {
if (selectedLayerId) {
const layer = $customLayers[selectedLayerId];
name = layer.name;
tileUrls = layer.tileUrls;
maxZoom = layer.maxZoom;
layerType = layer.layerType;
resourceType = layer.resourceType;
} else {
name = '';
tileUrls = [''];
maxZoom = 20;
layerType = 'basemap';
resourceType = 'raster';
}
}
$: selectedLayerId, setDataFromSelectedLayer();
$: selectedLayerId, setDataFromSelectedLayer();
</script>
<div class="flex flex-col">
{#if $customBasemapOrder.length > 0}
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
<Map size="16" />
{$_('layers.label.basemaps')}
<div class="grow">
<Separator />
</div>
</div>
{/if}
<div
bind:this={basemapContainer}
class="ml-1.5 flex flex-col gap-1 {$customBasemapOrder.length > 0 ? 'mb-2' : ''}"
>
{#each $customBasemapOrder as id (id)}
<div class="flex flex-row items-center gap-2" data-id={id}>
<Move size="12" />
<span class="grow">{$customLayers[id].name}</span>
<Button variant="outline" on:click={() => (selectedLayerId = id)} class="p-1 h-7">
<Pencil size="16" />
</Button>
<Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-7">
<Trash2 size="16" />
</Button>
</div>
{/each}
</div>
{#if $customOverlayOrder.length > 0}
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
<Layers2 size="16" />
{$_('layers.label.overlays')}
<div class="grow">
<Separator />
</div>
</div>
{/if}
<div
bind:this={overlayContainer}
class="ml-1.5 flex flex-col gap-1 {$customOverlayOrder.length > 0 ? 'mb-2' : ''}"
>
{#each $customOverlayOrder as id (id)}
<div class="flex flex-row items-center gap-2" data-id={id}>
<Move size="12" />
<span class="grow">{$customLayers[id].name}</span>
<Button variant="outline" on:click={() => (selectedLayerId = id)} class="p-1 h-7">
<Pencil size="16" />
</Button>
<Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-7">
<Trash2 size="16" />
</Button>
</div>
{/each}
</div>
{#if $customBasemapOrder.length > 0}
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
<Map size="16" />
{$_('layers.label.basemaps')}
<div class="grow">
<Separator />
</div>
</div>
{/if}
<div
bind:this={basemapContainer}
class="ml-1.5 flex flex-col gap-1 {$customBasemapOrder.length > 0 ? 'mb-2' : ''}"
>
{#each $customBasemapOrder as id (id)}
<div class="flex flex-row items-center gap-2" data-id={id}>
<Move size="12" />
<span class="grow">{$customLayers[id].name}</span>
<Button variant="outline" on:click={() => (selectedLayerId = id)} class="p-1 h-7">
<Pencil size="16" />
</Button>
<Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-7">
<Trash2 size="16" />
</Button>
</div>
{/each}
</div>
{#if $customOverlayOrder.length > 0}
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
<Layers2 size="16" />
{$_('layers.label.overlays')}
<div class="grow">
<Separator />
</div>
</div>
{/if}
<div
bind:this={overlayContainer}
class="ml-1.5 flex flex-col gap-1 {$customOverlayOrder.length > 0 ? 'mb-2' : ''}"
>
{#each $customOverlayOrder as id (id)}
<div class="flex flex-row items-center gap-2" data-id={id}>
<Move size="12" />
<span class="grow">{$customLayers[id].name}</span>
<Button variant="outline" on:click={() => (selectedLayerId = id)} class="p-1 h-7">
<Pencil size="16" />
</Button>
<Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-7">
<Trash2 size="16" />
</Button>
</div>
{/each}
</div>
<Card.Root>
<Card.Header class="p-3">
<Card.Title class="text-base">
{#if selectedLayerId}
{$_('layers.custom_layers.edit')}
{:else}
{$_('layers.custom_layers.new')}
{/if}
</Card.Title>
</Card.Header>
<Card.Content class="p-3 pt-0">
<fieldset class="flex flex-col gap-2">
<Label for="name">{$_('menu.metadata.name')}</Label>
<Input bind:value={name} id="name" class="h-8" />
<Label for="url">{$_('layers.custom_layers.urls')}</Label>
{#each tileUrls as url, i}
<div class="flex flex-row gap-2">
<Input
bind:value={tileUrls[i]}
id="url"
class="h-8"
placeholder={$_('layers.custom_layers.url_placeholder')}
/>
{#if tileUrls.length > 1}
<Button
on:click={() => (tileUrls = tileUrls.filter((_, index) => index !== i))}
variant="outline"
class="p-1 h-8"
>
<Minus size="16" />
</Button>
{/if}
{#if i === tileUrls.length - 1}
<Button
on:click={() => (tileUrls = [...tileUrls, ''])}
variant="outline"
class="p-1 h-8"
>
<Plus size="16" />
</Button>
{/if}
</div>
{/each}
{#if resourceType === 'raster'}
<Label for="maxZoom">{$_('layers.custom_layers.max_zoom')}</Label>
<Input type="number" bind:value={maxZoom} id="maxZoom" min={0} max={22} class="h-8" />
{/if}
<Label>{$_('layers.custom_layers.layer_type')}</Label>
<RadioGroup.Root bind:value={layerType} class="flex flex-row">
<div class="flex items-center space-x-2">
<RadioGroup.Item value="basemap" id="basemap" />
<Label for="basemap">{$_('layers.custom_layers.basemap')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="overlay" id="overlay" />
<Label for="overlay">{$_('layers.custom_layers.overlay')}</Label>
</div>
</RadioGroup.Root>
{#if selectedLayerId}
<div class="mt-2 flex flex-row gap-2">
<Button variant="outline" on:click={createLayer} class="grow">
<Save size="16" class="mr-1" />
{$_('layers.custom_layers.update')}
</Button>
<Button variant="outline" on:click={() => (selectedLayerId = undefined)}>
<CircleX size="16" />
</Button>
</div>
{:else}
<Button variant="outline" class="mt-2" on:click={createLayer}>
<CirclePlus size="16" class="mr-1" />
{$_('layers.custom_layers.create')}
</Button>
{/if}
</fieldset>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header class="p-3">
<Card.Title class="text-base">
{#if selectedLayerId}
{$_('layers.custom_layers.edit')}
{:else}
{$_('layers.custom_layers.new')}
{/if}
</Card.Title>
</Card.Header>
<Card.Content class="p-3 pt-0">
<fieldset class="flex flex-col gap-2">
<Label for="name">{$_('menu.metadata.name')}</Label>
<Input bind:value={name} id="name" class="h-8" />
<Label for="url">{$_('layers.custom_layers.urls')}</Label>
{#each tileUrls as url, i}
<div class="flex flex-row gap-2">
<Input
bind:value={tileUrls[i]}
id="url"
class="h-8"
placeholder={$_('layers.custom_layers.url_placeholder')}
/>
{#if tileUrls.length > 1}
<Button
on:click={() =>
(tileUrls = tileUrls.filter((_, index) => index !== i))}
variant="outline"
class="p-1 h-8"
>
<Minus size="16" />
</Button>
{/if}
{#if i === tileUrls.length - 1}
<Button
on:click={() => (tileUrls = [...tileUrls, ''])}
variant="outline"
class="p-1 h-8"
>
<Plus size="16" />
</Button>
{/if}
</div>
{/each}
{#if resourceType === 'raster'}
<Label for="maxZoom">{$_('layers.custom_layers.max_zoom')}</Label>
<Input
type="number"
bind:value={maxZoom}
id="maxZoom"
min={0}
max={22}
class="h-8"
/>
{/if}
<Label>{$_('layers.custom_layers.layer_type')}</Label>
<RadioGroup.Root bind:value={layerType} class="flex flex-row">
<div class="flex items-center space-x-2">
<RadioGroup.Item value="basemap" id="basemap" />
<Label for="basemap">{$_('layers.custom_layers.basemap')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="overlay" id="overlay" />
<Label for="overlay">{$_('layers.custom_layers.overlay')}</Label>
</div>
</RadioGroup.Root>
{#if selectedLayerId}
<div class="mt-2 flex flex-row gap-2">
<Button variant="outline" on:click={createLayer} class="grow">
<Save size="16" class="mr-1" />
{$_('layers.custom_layers.update')}
</Button>
<Button variant="outline" on:click={() => (selectedLayerId = undefined)}>
<CircleX size="16" />
</Button>
</div>
{:else}
<Button variant="outline" class="mt-2" on:click={createLayer}>
<CirclePlus size="16" class="mr-1" />
{$_('layers.custom_layers.create')}
</Button>
{/if}
</fieldset>
</Card.Content>
</Card.Root>
</div>

View File

@@ -1,222 +1,222 @@
<script lang="ts">
import CustomControl from '$lib/components/custom-control/CustomControl.svelte';
import LayerTree from './LayerTree.svelte';
import CustomControl from '$lib/components/custom-control/CustomControl.svelte';
import LayerTree from './LayerTree.svelte';
import { Separator } from '$lib/components/ui/separator';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import { Separator } from '$lib/components/ui/separator';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import { Layers } from 'lucide-svelte';
import { Layers } from 'lucide-svelte';
import { basemaps, defaultBasemap, overlays } from '$lib/assets/layers';
import { settings } from '$lib/db';
import { map } from '$lib/stores';
import { get, writable } from 'svelte/store';
import { customBasemapUpdate, getLayers } from './utils';
import { OverpassLayer } from './OverpassLayer';
import { basemaps, defaultBasemap, overlays } from '$lib/assets/layers';
import { settings } from '$lib/db';
import { map } from '$lib/stores';
import { get, writable } from 'svelte/store';
import { customBasemapUpdate, getLayers } from './utils';
import { OverpassLayer } from './OverpassLayer';
let container: HTMLDivElement;
let overpassLayer: OverpassLayer;
let container: HTMLDivElement;
let overpassLayer: OverpassLayer;
const {
currentBasemap,
previousBasemap,
currentOverlays,
currentOverpassQueries,
selectedBasemapTree,
selectedOverlayTree,
selectedOverpassTree,
customLayers,
opacities
} = settings;
const {
currentBasemap,
previousBasemap,
currentOverlays,
currentOverpassQueries,
selectedBasemapTree,
selectedOverlayTree,
selectedOverpassTree,
customLayers,
opacities,
} = settings;
function setStyle() {
if ($map) {
let basemap = basemaps.hasOwnProperty($currentBasemap)
? basemaps[$currentBasemap]
: ($customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap]);
$map.removeImport('basemap');
if (typeof basemap === 'string') {
$map.addImport({ id: 'basemap', url: basemap }, 'overlays');
} else {
$map.addImport(
{
id: 'basemap',
data: basemap
},
'overlays'
);
}
}
}
function setStyle() {
if ($map) {
let basemap = basemaps.hasOwnProperty($currentBasemap)
? basemaps[$currentBasemap]
: ($customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap]);
$map.removeImport('basemap');
if (typeof basemap === 'string') {
$map.addImport({ id: 'basemap', url: basemap }, 'overlays');
} else {
$map.addImport(
{
id: 'basemap',
data: basemap,
},
'overlays'
);
}
}
}
$: if ($map && ($currentBasemap || $customBasemapUpdate)) {
setStyle();
}
$: if ($map && ($currentBasemap || $customBasemapUpdate)) {
setStyle();
}
function addOverlay(id: string) {
try {
let overlay = $customLayers.hasOwnProperty(id) ? $customLayers[id].value : overlays[id];
if (typeof overlay === 'string') {
$map.addImport({ id, url: overlay });
} else {
if ($opacities.hasOwnProperty(id)) {
overlay = {
...overlay,
layers: overlay.layers.map((layer) => {
if (layer.type === 'raster') {
if (!layer.paint) {
layer.paint = {};
}
layer.paint['raster-opacity'] = $opacities[id];
}
return layer;
})
};
}
$map.addImport({
id,
data: overlay
});
}
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
}
}
function addOverlay(id: string) {
try {
let overlay = $customLayers.hasOwnProperty(id) ? $customLayers[id].value : overlays[id];
if (typeof overlay === 'string') {
$map.addImport({ id, url: overlay });
} else {
if ($opacities.hasOwnProperty(id)) {
overlay = {
...overlay,
layers: overlay.layers.map((layer) => {
if (layer.type === 'raster') {
if (!layer.paint) {
layer.paint = {};
}
layer.paint['raster-opacity'] = $opacities[id];
}
return layer;
}),
};
}
$map.addImport({
id,
data: overlay,
});
}
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
}
}
function updateOverlays() {
if ($map && $currentOverlays && $opacities) {
let overlayLayers = getLayers($currentOverlays);
try {
let activeOverlays = $map.getStyle().imports.reduce((acc, i) => {
if (!['basemap', 'overlays', 'glyphs-and-sprite'].includes(i.id)) {
acc[i.id] = i;
}
return acc;
}, {});
let toRemove = Object.keys(activeOverlays).filter((id) => !overlayLayers[id]);
toRemove.forEach((id) => {
$map.removeImport(id);
});
let toAdd = Object.entries(overlayLayers)
.filter(([id, selected]) => selected && !activeOverlays.hasOwnProperty(id))
.map(([id]) => id);
toAdd.forEach((id) => {
addOverlay(id);
});
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
}
}
}
function updateOverlays() {
if ($map && $currentOverlays && $opacities) {
let overlayLayers = getLayers($currentOverlays);
try {
let activeOverlays = $map.getStyle().imports.reduce((acc, i) => {
if (!['basemap', 'overlays', 'glyphs-and-sprite'].includes(i.id)) {
acc[i.id] = i;
}
return acc;
}, {});
let toRemove = Object.keys(activeOverlays).filter((id) => !overlayLayers[id]);
toRemove.forEach((id) => {
$map.removeImport(id);
});
let toAdd = Object.entries(overlayLayers)
.filter(([id, selected]) => selected && !activeOverlays.hasOwnProperty(id))
.map(([id]) => id);
toAdd.forEach((id) => {
addOverlay(id);
});
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
}
}
}
$: if ($map && $currentOverlays && $opacities) {
updateOverlays();
}
$: if ($map && $currentOverlays && $opacities) {
updateOverlays();
}
$: if ($map) {
if (overpassLayer) {
overpassLayer.remove();
}
overpassLayer = new OverpassLayer($map);
overpassLayer.add();
$map.on('style.import.load', updateOverlays);
}
$: if ($map) {
if (overpassLayer) {
overpassLayer.remove();
}
overpassLayer = new OverpassLayer($map);
overpassLayer.add();
$map.on('style.import.load', updateOverlays);
}
let selectedBasemap = writable(get(currentBasemap));
selectedBasemap.subscribe((value) => {
// Updates coming from radio buttons
if (value !== get(currentBasemap)) {
previousBasemap.set(get(currentBasemap));
currentBasemap.set(value);
}
});
currentBasemap.subscribe((value) => {
// Updates coming from the database, or from the user swapping basemaps
if (value !== get(selectedBasemap)) {
selectedBasemap.set(value);
}
});
let selectedBasemap = writable(get(currentBasemap));
selectedBasemap.subscribe((value) => {
// Updates coming from radio buttons
if (value !== get(currentBasemap)) {
previousBasemap.set(get(currentBasemap));
currentBasemap.set(value);
}
});
currentBasemap.subscribe((value) => {
// Updates coming from the database, or from the user swapping basemaps
if (value !== get(selectedBasemap)) {
selectedBasemap.set(value);
}
});
let open = false;
function openLayerControl() {
open = true;
}
function closeLayerControl() {
open = false;
}
let cancelEvents = false;
let open = false;
function openLayerControl() {
open = true;
}
function closeLayerControl() {
open = false;
}
let cancelEvents = false;
</script>
<CustomControl class="group min-w-[29px] min-h-[29px] overflow-hidden">
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
bind:this={container}
class="h-full w-full"
on:mouseenter={openLayerControl}
on:mouseleave={closeLayerControl}
on:pointerenter={() => {
if (!open) {
cancelEvents = true;
openLayerControl();
setTimeout(() => {
cancelEvents = false;
}, 500);
}
}}
>
<div
class="flex flex-row justify-center items-center delay-100 transition-[opacity] duration-0 {open
? 'opacity-0 w-0 h-0 delay-0'
: 'w-[29px] h-[29px]'}"
>
<Layers size="20" />
</div>
<div
class="transition-[grid-template-rows grid-template-cols] grid grid-rows-[0fr] grid-cols-[0fr] duration-150 h-full {open
? 'grid-rows-[1fr] grid-cols-[1fr]'
: ''} {cancelEvents ? 'pointer-events-none' : ''}"
>
<ScrollArea>
<div class="h-fit">
<div class="p-2">
<LayerTree
layerTree={$selectedBasemapTree}
name="basemaps"
bind:selected={$selectedBasemap}
/>
</div>
<Separator class="w-full" />
<div class="p-2">
{#if $currentOverlays}
<LayerTree
layerTree={$selectedOverlayTree}
name="overlays"
multiple={true}
bind:checked={$currentOverlays}
/>
{/if}
</div>
<Separator class="w-full" />
<div class="p-2">
{#if $currentOverpassQueries}
<LayerTree
layerTree={$selectedOverpassTree}
name="overpass"
multiple={true}
bind:checked={$currentOverpassQueries}
/>
{/if}
</div>
</div>
</ScrollArea>
</div>
</div>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
bind:this={container}
class="h-full w-full"
on:mouseenter={openLayerControl}
on:mouseleave={closeLayerControl}
on:pointerenter={() => {
if (!open) {
cancelEvents = true;
openLayerControl();
setTimeout(() => {
cancelEvents = false;
}, 500);
}
}}
>
<div
class="flex flex-row justify-center items-center delay-100 transition-[opacity] duration-0 {open
? 'opacity-0 w-0 h-0 delay-0'
: 'w-[29px] h-[29px]'}"
>
<Layers size="20" />
</div>
<div
class="transition-[grid-template-rows grid-template-cols] grid grid-rows-[0fr] grid-cols-[0fr] duration-150 h-full {open
? 'grid-rows-[1fr] grid-cols-[1fr]'
: ''} {cancelEvents ? 'pointer-events-none' : ''}"
>
<ScrollArea>
<div class="h-fit">
<div class="p-2">
<LayerTree
layerTree={$selectedBasemapTree}
name="basemaps"
bind:selected={$selectedBasemap}
/>
</div>
<Separator class="w-full" />
<div class="p-2">
{#if $currentOverlays}
<LayerTree
layerTree={$selectedOverlayTree}
name="overlays"
multiple={true}
bind:checked={$currentOverlays}
/>
{/if}
</div>
<Separator class="w-full" />
<div class="p-2">
{#if $currentOverpassQueries}
<LayerTree
layerTree={$selectedOverpassTree}
name="overpass"
multiple={true}
bind:checked={$currentOverpassQueries}
/>
{/if}
</div>
</div>
</ScrollArea>
</div>
</div>
</CustomControl>
<svelte:window
on:click={(e) => {
if (open && !cancelEvents && !container.contains(e.target)) {
closeLayerControl();
}
}}
on:click={(e) => {
if (open && !cancelEvents && !container.contains(e.target)) {
closeLayerControl();
}
}}
/>

View File

@@ -1,189 +1,197 @@
<script lang="ts">
import LayerTree from './LayerTree.svelte';
import LayerTree from './LayerTree.svelte';
import { Separator } from '$lib/components/ui/separator';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import * as Sheet from '$lib/components/ui/sheet';
import * as Accordion from '$lib/components/ui/accordion';
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
import { Slider } from '$lib/components/ui/slider';
import { Separator } from '$lib/components/ui/separator';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import * as Sheet from '$lib/components/ui/sheet';
import * as Accordion from '$lib/components/ui/accordion';
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
import { Slider } from '$lib/components/ui/slider';
import {
basemapTree,
defaultBasemap,
overlays,
overlayTree,
overpassTree
} from '$lib/assets/layers';
import { getLayers, isSelected, toggle } from '$lib/components/layer-control/utils';
import { settings } from '$lib/db';
import {
basemapTree,
defaultBasemap,
overlays,
overlayTree,
overpassTree,
} from '$lib/assets/layers';
import { getLayers, isSelected, toggle } from '$lib/components/layer-control/utils';
import { settings } from '$lib/db';
import { _ } from 'svelte-i18n';
import { writable } from 'svelte/store';
import { map } from '$lib/stores';
import CustomLayers from './CustomLayers.svelte';
import { _ } from 'svelte-i18n';
import { writable } from 'svelte/store';
import { map } from '$lib/stores';
import CustomLayers from './CustomLayers.svelte';
const {
selectedBasemapTree,
selectedOverlayTree,
selectedOverpassTree,
currentBasemap,
currentOverlays,
customLayers,
opacities
} = settings;
const {
selectedBasemapTree,
selectedOverlayTree,
selectedOverpassTree,
currentBasemap,
currentOverlays,
customLayers,
opacities,
} = settings;
export let open: boolean;
let accordionValue: string | string[] | undefined = undefined;
export let open: boolean;
let accordionValue: string | string[] | undefined = undefined;
let selectedOverlay = writable(undefined);
let overlayOpacity = writable([1]);
let selectedOverlay = writable(undefined);
let overlayOpacity = writable([1]);
function setOpacityFromSelection() {
if ($selectedOverlay) {
let overlayId = $selectedOverlay.value;
if ($opacities.hasOwnProperty(overlayId)) {
$overlayOpacity = [$opacities[overlayId]];
} else {
$overlayOpacity = [1];
}
} else {
$overlayOpacity = [1];
}
}
function setOpacityFromSelection() {
if ($selectedOverlay) {
let overlayId = $selectedOverlay.value;
if ($opacities.hasOwnProperty(overlayId)) {
$overlayOpacity = [$opacities[overlayId]];
} else {
$overlayOpacity = [1];
}
} else {
$overlayOpacity = [1];
}
}
$: if ($selectedBasemapTree && $currentBasemap) {
if (!isSelected($selectedBasemapTree, $currentBasemap)) {
if (!isSelected($selectedBasemapTree, defaultBasemap)) {
$selectedBasemapTree = toggle($selectedBasemapTree, defaultBasemap);
}
$currentBasemap = defaultBasemap;
}
}
$: if ($selectedBasemapTree && $currentBasemap) {
if (!isSelected($selectedBasemapTree, $currentBasemap)) {
if (!isSelected($selectedBasemapTree, defaultBasemap)) {
$selectedBasemapTree = toggle($selectedBasemapTree, defaultBasemap);
}
$currentBasemap = defaultBasemap;
}
}
$: if ($selectedOverlayTree && $currentOverlays) {
let overlayLayers = getLayers($currentOverlays);
let toRemove = Object.entries(overlayLayers).filter(
([id, checked]) => checked && !isSelected($selectedOverlayTree, id)
);
if (toRemove.length > 0) {
currentOverlays.update((tree) => {
toRemove.forEach(([id]) => {
toggle(tree, id);
});
return tree;
});
}
}
$: if ($selectedOverlayTree && $currentOverlays) {
let overlayLayers = getLayers($currentOverlays);
let toRemove = Object.entries(overlayLayers).filter(
([id, checked]) => checked && !isSelected($selectedOverlayTree, id)
);
if (toRemove.length > 0) {
currentOverlays.update((tree) => {
toRemove.forEach(([id]) => {
toggle(tree, id);
});
return tree;
});
}
}
$: if ($selectedOverlay) {
setOpacityFromSelection();
}
$: if ($selectedOverlay) {
setOpacityFromSelection();
}
</script>
<Sheet.Root bind:open>
<Sheet.Trigger class="hidden" />
<Sheet.Content>
<Sheet.Header class="h-full">
<Sheet.Title>{$_('layers.settings')}</Sheet.Title>
<ScrollArea class="w-[105%] min-h-full pr-4">
<Sheet.Description>
{$_('layers.settings_help')}
</Sheet.Description>
<Accordion.Root class="flex flex-col" bind:value={accordionValue}>
<Accordion.Item value="layer-selection" class="flex flex-col">
<Accordion.Trigger>{$_('layers.selection')}</Accordion.Trigger>
<Accordion.Content class="grow flex flex-col border rounded">
<div class="py-2 pl-1 pr-2">
<LayerTree
layerTree={basemapTree}
name="basemapSettings"
multiple={true}
bind:checked={$selectedBasemapTree}
/>
</div>
<Separator />
<div class="py-2 pl-1 pr-2">
<LayerTree
layerTree={overlayTree}
name="overlaySettings"
multiple={true}
bind:checked={$selectedOverlayTree}
/>
</div>
<Separator />
<div class="py-2 pl-1 pr-2">
<LayerTree
layerTree={overpassTree}
name="overpassSettings"
multiple={true}
bind:checked={$selectedOverpassTree}
/>
</div>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="overlay-opacity">
<Accordion.Trigger>{$_('layers.opacity')}</Accordion.Trigger>
<Accordion.Content class="flex flex-col gap-3 overflow-visible">
<div class="flex flex-row gap-6 items-center">
<Label>
{$_('layers.custom_layers.overlay')}
</Label>
<Select.Root bind:selected={$selectedOverlay}>
<Select.Trigger class="h-8 mr-1">
<Select.Value />
</Select.Trigger>
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
{#each Object.keys(overlays) as id}
{#if isSelected($selectedOverlayTree, id)}
<Select.Item value={id}>{$_(`layers.label.${id}`)}</Select.Item>
{/if}
{/each}
{#each Object.entries($customLayers) as [id, layer]}
{#if layer.layerType === 'overlay'}
<Select.Item value={id}>{layer.name}</Select.Item>
{/if}
{/each}
</Select.Content>
</Select.Root>
</div>
<Label class="flex flex-row gap-6 items-center">
{$_('menu.style.opacity')}
<div class="p-2 pr-3 grow">
<Slider
bind:value={$overlayOpacity}
min={0.1}
max={1}
step={0.1}
disabled={$selectedOverlay === undefined}
onValueChange={(value) => {
if ($selectedOverlay) {
if ($map && isSelected($currentOverlays, $selectedOverlay.value)) {
try {
$map.removeImport($selectedOverlay.value);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
$opacities[$selectedOverlay.value] = value[0];
}
}}
/>
</div>
</Label>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="custom-layers">
<Accordion.Trigger>{$_('layers.custom_layers.title')}</Accordion.Trigger>
<Accordion.Content>
<ScrollArea>
<CustomLayers />
</ScrollArea>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
</ScrollArea>
</Sheet.Header>
</Sheet.Content>
<Sheet.Trigger class="hidden" />
<Sheet.Content>
<Sheet.Header class="h-full">
<Sheet.Title>{$_('layers.settings')}</Sheet.Title>
<ScrollArea class="w-[105%] min-h-full pr-4">
<Sheet.Description>
{$_('layers.settings_help')}
</Sheet.Description>
<Accordion.Root class="flex flex-col" bind:value={accordionValue}>
<Accordion.Item value="layer-selection" class="flex flex-col">
<Accordion.Trigger>{$_('layers.selection')}</Accordion.Trigger>
<Accordion.Content class="grow flex flex-col border rounded">
<div class="py-2 pl-1 pr-2">
<LayerTree
layerTree={basemapTree}
name="basemapSettings"
multiple={true}
bind:checked={$selectedBasemapTree}
/>
</div>
<Separator />
<div class="py-2 pl-1 pr-2">
<LayerTree
layerTree={overlayTree}
name="overlaySettings"
multiple={true}
bind:checked={$selectedOverlayTree}
/>
</div>
<Separator />
<div class="py-2 pl-1 pr-2">
<LayerTree
layerTree={overpassTree}
name="overpassSettings"
multiple={true}
bind:checked={$selectedOverpassTree}
/>
</div>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="overlay-opacity">
<Accordion.Trigger>{$_('layers.opacity')}</Accordion.Trigger>
<Accordion.Content class="flex flex-col gap-3 overflow-visible">
<div class="flex flex-row gap-6 items-center">
<Label>
{$_('layers.custom_layers.overlay')}
</Label>
<Select.Root bind:selected={$selectedOverlay}>
<Select.Trigger class="h-8 mr-1">
<Select.Value />
</Select.Trigger>
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
{#each Object.keys(overlays) as id}
{#if isSelected($selectedOverlayTree, id)}
<Select.Item value={id}
>{$_(`layers.label.${id}`)}</Select.Item
>
{/if}
{/each}
{#each Object.entries($customLayers) as [id, layer]}
{#if layer.layerType === 'overlay'}
<Select.Item value={id}>{layer.name}</Select.Item>
{/if}
{/each}
</Select.Content>
</Select.Root>
</div>
<Label class="flex flex-row gap-6 items-center">
{$_('menu.style.opacity')}
<div class="p-2 pr-3 grow">
<Slider
bind:value={$overlayOpacity}
min={0.1}
max={1}
step={0.1}
disabled={$selectedOverlay === undefined}
onValueChange={(value) => {
if ($selectedOverlay) {
if (
$map &&
isSelected(
$currentOverlays,
$selectedOverlay.value
)
) {
try {
$map.removeImport($selectedOverlay.value);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
$opacities[$selectedOverlay.value] = value[0];
}
}}
/>
</div>
</Label>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="custom-layers">
<Accordion.Trigger>{$_('layers.custom_layers.title')}</Accordion.Trigger>
<Accordion.Content>
<ScrollArea>
<CustomLayers />
</ScrollArea>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
</ScrollArea>
</Sheet.Header>
</Sheet.Content>
</Sheet.Root>

View File

@@ -1,20 +1,20 @@
<script lang="ts">
import LayerTreeNode from './LayerTreeNode.svelte';
import { type LayerTreeType } from '$lib/assets/layers';
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
import LayerTreeNode from './LayerTreeNode.svelte';
import { type LayerTreeType } from '$lib/assets/layers';
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
export let layerTree: LayerTreeType;
export let name: string;
export let selected: string | undefined = undefined;
export let multiple: boolean = false;
export let layerTree: LayerTreeType;
export let name: string;
export let selected: string | undefined = undefined;
export let multiple: boolean = false;
export let checked: LayerTreeType = {};
export let checked: LayerTreeType = {};
</script>
<form>
<fieldset class="min-w-64 mb-1">
<CollapsibleTree nohover={true}>
<LayerTreeNode {name} node={layerTree} bind:selected {multiple} bind:checked />
</CollapsibleTree>
</fieldset>
<fieldset class="min-w-64 mb-1">
<CollapsibleTree nohover={true}>
<LayerTreeNode {name} node={layerTree} bind:selected {multiple} bind:checked />
</CollapsibleTree>
</fieldset>
</form>

View File

@@ -1,86 +1,98 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
import { Checkbox } from '$lib/components/ui/checkbox';
import CollapsibleTreeNode from '../collapsible-tree/CollapsibleTreeNode.svelte';
import { Label } from '$lib/components/ui/label';
import { Checkbox } from '$lib/components/ui/checkbox';
import CollapsibleTreeNode from '../collapsible-tree/CollapsibleTreeNode.svelte';
import { type LayerTreeType } from '$lib/assets/layers';
import { anySelectedLayer } from './utils';
import { type LayerTreeType } from '$lib/assets/layers';
import { anySelectedLayer } from './utils';
import { _ } from 'svelte-i18n';
import { settings } from '$lib/db';
import { beforeUpdate } from 'svelte';
import { _ } from 'svelte-i18n';
import { settings } from '$lib/db';
import { beforeUpdate } from 'svelte';
export let name: string;
export let node: LayerTreeType;
export let selected: string | undefined = undefined;
export let multiple: boolean = false;
export let name: string;
export let node: LayerTreeType;
export let selected: string | undefined = undefined;
export let multiple: boolean = false;
export let checked: LayerTreeType;
export let checked: LayerTreeType;
const { customLayers } = settings;
const { customLayers } = settings;
beforeUpdate(() => {
if (checked !== undefined) {
Object.keys(node).forEach((id) => {
if (!checked.hasOwnProperty(id)) {
if (typeof node[id] == 'boolean') {
checked[id] = false;
} else {
checked[id] = {};
}
}
});
}
});
beforeUpdate(() => {
if (checked !== undefined) {
Object.keys(node).forEach((id) => {
if (!checked.hasOwnProperty(id)) {
if (typeof node[id] == 'boolean') {
checked[id] = false;
} else {
checked[id] = {};
}
}
});
}
});
</script>
<div class="flex flex-col gap-[3px]">
{#each Object.keys(node) as id}
{#if typeof node[id] == 'boolean'}
{#if node[id]}
<div class="flex flex-row items-center gap-2 first:mt-0.5 h-4">
{#if multiple}
<Checkbox
id="{name}-{id}"
{name}
value={id}
bind:checked={checked[id]}
class="scale-90"
aria-label={$_(`layers.label.${id}`)}
/>
{:else}
<input id="{name}-{id}" type="radio" {name} value={id} bind:group={selected} />
{/if}
<Label for="{name}-{id}" class="flex flex-row items-center gap-1">
{#if $customLayers.hasOwnProperty(id)}
{$customLayers[id].name}
{:else}
{$_(`layers.label.${id}`)}
{/if}
</Label>
</div>
{/if}
{:else if anySelectedLayer(node[id])}
<CollapsibleTreeNode {id}>
<span slot="trigger">{$_(`layers.label.${id}`)}</span>
<div slot="content">
<svelte:self node={node[id]} {name} bind:selected {multiple} bind:checked={checked[id]} />
</div>
</CollapsibleTreeNode>
{/if}
{/each}
{#each Object.keys(node) as id}
{#if typeof node[id] == 'boolean'}
{#if node[id]}
<div class="flex flex-row items-center gap-2 first:mt-0.5 h-4">
{#if multiple}
<Checkbox
id="{name}-{id}"
{name}
value={id}
bind:checked={checked[id]}
class="scale-90"
aria-label={$_(`layers.label.${id}`)}
/>
{:else}
<input
id="{name}-{id}"
type="radio"
{name}
value={id}
bind:group={selected}
/>
{/if}
<Label for="{name}-{id}" class="flex flex-row items-center gap-1">
{#if $customLayers.hasOwnProperty(id)}
{$customLayers[id].name}
{:else}
{$_(`layers.label.${id}`)}
{/if}
</Label>
</div>
{/if}
{:else if anySelectedLayer(node[id])}
<CollapsibleTreeNode {id}>
<span slot="trigger">{$_(`layers.label.${id}`)}</span>
<div slot="content">
<svelte:self
node={node[id]}
{name}
bind:selected
{multiple}
bind:checked={checked[id]}
/>
</div>
</CollapsibleTreeNode>
{/if}
{/each}
</div>
<style lang="postcss">
div :global(input[type='radio']) {
@apply appearance-none;
@apply w-4 h-4;
@apply border-[1.5px] border-primary;
@apply rounded-full;
@apply ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2;
@apply cursor-pointer;
@apply checked:bg-primary;
@apply checked:bg-clip-content;
@apply checked:p-0.5;
}
div :global(input[type='radio']) {
@apply appearance-none;
@apply w-4 h-4;
@apply border-[1.5px] border-primary;
@apply rounded-full;
@apply ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2;
@apply cursor-pointer;
@apply checked:bg-primary;
@apply checked:bg-clip-content;
@apply checked:p-0.5;
}
</style>

View File

@@ -1,14 +1,12 @@
import SphericalMercator from "@mapbox/sphericalmercator";
import { getLayers } from "./utils";
import { get, writable } from "svelte/store";
import { liveQuery } from "dexie";
import { db, settings } from "$lib/db";
import { overpassQueryData } from "$lib/assets/layers";
import { MapPopup } from "$lib/components/MapPopup";
import SphericalMercator from '@mapbox/sphericalmercator';
import { getLayers } from './utils';
import { get, writable } from 'svelte/store';
import { liveQuery } from 'dexie';
import { db, settings } from '$lib/db';
import { overpassQueryData } from '$lib/assets/layers';
import { MapPopup } from '$lib/components/MapPopup';
const {
currentOverpassQueries
} = settings;
const { currentOverpassQueries } = settings;
const mercator = new SphericalMercator({
size: 256,
@@ -29,7 +27,7 @@ export class OverpassLayer {
popup: MapPopup;
currentQueries: Set<string> = new Set();
nextQueries: Map<string, { x: number, y: number, queries: string[] }> = new Map();
nextQueries: Map<string, { x: number; y: number; queries: string[] }> = new Map();
unsubscribes: (() => void)[] = [];
queryIfNeededBinded = this.queryIfNeeded.bind(this);
@@ -50,10 +48,12 @@ export class OverpassLayer {
this.map.on('moveend', this.queryIfNeededBinded);
this.map.on('style.import.load', this.updateBinded);
this.unsubscribes.push(data.subscribe(this.updateBinded));
this.unsubscribes.push(currentOverpassQueries.subscribe(() => {
this.updateBinded();
this.queryIfNeededBinded();
}));
this.unsubscribes.push(
currentOverpassQueries.subscribe(() => {
this.updateBinded();
this.queryIfNeededBinded();
})
);
this.update();
}
@@ -126,8 +126,8 @@ export class OverpassLayer {
this.popup.setItem({
item: {
...e.features[0].properties,
sym: overpassQueryData[e.features[0].properties.query].symbol ?? ''
}
sym: overpassQueryData[e.features[0].properties.query].symbol ?? '',
},
});
}
@@ -146,12 +146,23 @@ export class OverpassLayer {
continue;
}
db.overpasstiles.where('[x+y]').equals([x, y]).toArray().then((querytiles) => {
let missingQueries = queries.filter((query) => !querytiles.some((querytile) => querytile.query === query && time - querytile.time < this.expirationTime));
if (missingQueries.length > 0) {
this.queryTile(x, y, missingQueries);
}
});
db.overpasstiles
.where('[x+y]')
.equals([x, y])
.toArray()
.then((querytiles) => {
let missingQueries = queries.filter(
(query) =>
!querytiles.some(
(querytile) =>
querytile.query === query &&
time - querytile.time < this.expirationTime
)
);
if (missingQueries.length > 0) {
this.queryTile(x, y, missingQueries);
}
});
}
}
}
@@ -165,13 +176,16 @@ export class OverpassLayer {
const bounds = mercator.bbox(x, y, this.queryZoom);
fetch(`${this.overpassUrl}?data=${getQueryForBounds(bounds, queries)}`)
.then((response) => {
if (response.ok) {
return response.json();
}
this.currentQueries.delete(`${x},${y}`);
return Promise.reject();
}, () => (this.currentQueries.delete(`${x},${y}`)))
.then(
(response) => {
if (response.ok) {
return response.json();
}
this.currentQueries.delete(`${x},${y}`);
return Promise.reject();
},
() => this.currentQueries.delete(`${x},${y}`)
)
.then((data) => this.storeOverpassData(x, y, queries, data))
.catch(() => this.currentQueries.delete(`${x},${y}`));
}
@@ -179,7 +193,7 @@ export class OverpassLayer {
storeOverpassData(x: number, y: number, queries: string[], data: any) {
let time = Date.now();
let queryTiles = queries.map((query) => ({ x, y, query, time }));
let pois: { query: string, id: number, poi: GeoJSON.Feature }[] = [];
let pois: { query: string; id: number; poi: GeoJSON.Feature }[] = [];
if (data.elements === undefined) {
return;
@@ -195,7 +209,9 @@ export class OverpassLayer {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: element.center ? [element.center.lon, element.center.lat] : [element.lon, element.lat],
coordinates: element.center
? [element.center.lon, element.center.lat]
: [element.lon, element.lat],
},
properties: {
id: element.id,
@@ -203,9 +219,9 @@ export class OverpassLayer {
lon: element.center ? element.center.lon : element.lon,
query: query,
icon: `overpass-${query}`,
tags: element.tags
tags: element.tags,
},
}
},
});
}
}
@@ -228,11 +244,13 @@ export class OverpassLayer {
if (!this.map.hasImage(`overpass-${query}`)) {
this.map.addImage(`overpass-${query}`, icon);
}
}
};
// Lucide icons are SVG files with a 24x24 viewBox
// Create a new SVG with a 32x32 viewBox and center the icon in a circle
icon.src = 'data:image/svg+xml,' + encodeURIComponent(`
icon.src =
'data:image/svg+xml,' +
encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="${overpassQueryData[query].icon.color}" />
<g transform="translate(8 8)">
@@ -264,9 +282,14 @@ function getQuery(query: string) {
function getQueryItem(tags: Record<string, string | boolean | string[]>) {
let arrayEntry = Object.entries(tags).find(([_, value]) => Array.isArray(value));
if (arrayEntry !== undefined) {
return arrayEntry[1].map((val) => `nwr${Object.entries(tags)
.map(([tag, value]) => `[${tag}=${tag === arrayEntry[0] ? val : value}]`)
.join('')};`).join('');
return arrayEntry[1]
.map(
(val) =>
`nwr${Object.entries(tags)
.map(([tag, value]) => `[${tag}=${tag === arrayEntry[0] ? val : value}]`)
.join('')};`
)
.join('');
} else {
return `nwr${Object.entries(tags)
.map(([tag, value]) => `[${tag}=${value}]`)
@@ -283,8 +306,9 @@ function belongsToQuery(element: any, query: string) {
}
function belongsToQueryItem(element: any, tags: Record<string, string | boolean | string[]>) {
return Object.entries(tags)
.every(([tag, value]) => Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value);
return Object.entries(tags).every(([tag, value]) =>
Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value
);
}
function getCurrentQueries() {
@@ -293,5 +317,7 @@ function getCurrentQueries() {
return [];
}
return Object.entries(getLayers(currentQueries)).filter(([_, selected]) => selected).map(([query, _]) => query);
}
return Object.entries(getLayers(currentQueries))
.filter(([_, selected]) => selected)
.map(([query, _]) => query);
}

View File

@@ -1,95 +1,95 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { selection } from '$lib/components/file-list/Selection';
import { PencilLine, MapPin } from 'lucide-svelte';
import { _ } from 'svelte-i18n';
import { dbUtils } from '$lib/db';
import type { PopupItem } from '$lib/components/MapPopup';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { selection } from '$lib/components/file-list/Selection';
import { PencilLine, MapPin } from 'lucide-svelte';
import { _ } from 'svelte-i18n';
import { dbUtils } from '$lib/db';
import type { PopupItem } from '$lib/components/MapPopup';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
export let poi: PopupItem<any>;
export let poi: PopupItem<any>;
let tags = {};
let name = '';
$: if (poi) {
tags = JSON.parse(poi.item.tags);
if (tags.name !== undefined && tags.name !== '') {
name = tags.name;
} else {
name = $_(`layers.label.${poi.item.query}`);
}
}
let tags = {};
let name = '';
$: if (poi) {
tags = JSON.parse(poi.item.tags);
if (tags.name !== undefined && tags.name !== '') {
name = tags.name;
} else {
name = $_(`layers.label.${poi.item.query}`);
}
}
</script>
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw]">
<Card.Header class="p-0">
<Card.Title class="text-md">
<div class="flex flex-row gap-3">
<div class="flex flex-col">
{name}
<div class="text-muted-foreground text-sm font-normal">
{poi.item.lat.toFixed(6)}&deg; {poi.item.lon.toFixed(6)}&deg;
</div>
</div>
<Button
class="ml-auto p-1.5 h-8"
variant="outline"
href="https://www.openstreetmap.org/edit?editor=id&node={poi.item.id}"
target="_blank"
>
<PencilLine size="16" />
</Button>
</div>
</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col p-0 text-sm mt-1 whitespace-normal break-all">
<ScrollArea class="flex flex-col" viewportClasses="max-h-[30dvh]">
{#if tags.image || tags['image:0']}
<div class="w-full rounded-md overflow-clip my-2 max-w-96 mx-auto">
<!-- svelte-ignore a11y-missing-attribute -->
<img src={tags.image ?? tags['image:0']} />
</div>
{/if}
<div class="grid grid-cols-[auto_auto] gap-x-3">
{#each Object.entries(tags) as [key, value]}
{#if key !== 'name' && !key.includes('image')}
<span class="font-mono">{key}</span>
{#if key === 'website' || key.startsWith('website:') || key === 'contact:website' || key === 'contact:facebook' || key === 'contact:instagram' || key === 'contact:twitter'}
<a href={value} target="_blank" class="text-link underline">{value}</a>
{:else if key === 'phone' || key === 'contact:phone'}
<a href={'tel:' + value} class="text-link underline">{value}</a>
{:else if key === 'email' || key === 'contact:email'}
<a href={'mailto:' + value} class="text-link underline">{value}</a>
{:else}
<span>{value}</span>
{/if}
{/if}
{/each}
</div>
</ScrollArea>
<Button
class="mt-2"
variant="outline"
disabled={$selection.size === 0}
on:click={() => {
let desc = Object.entries(tags)
.map(([key, value]) => `${key}: ${value}`)
.join('\n');
dbUtils.addOrUpdateWaypoint({
attributes: {
lat: poi.item.lat,
lon: poi.item.lon
},
name: name,
desc: desc,
cmt: desc,
sym: poi.item.sym
});
}}
>
<MapPin size="16" class="mr-1" />
{$_('toolbar.waypoint.add')}
</Button>
</Card.Content>
<Card.Header class="p-0">
<Card.Title class="text-md">
<div class="flex flex-row gap-3">
<div class="flex flex-col">
{name}
<div class="text-muted-foreground text-sm font-normal">
{poi.item.lat.toFixed(6)}&deg; {poi.item.lon.toFixed(6)}&deg;
</div>
</div>
<Button
class="ml-auto p-1.5 h-8"
variant="outline"
href="https://www.openstreetmap.org/edit?editor=id&node={poi.item.id}"
target="_blank"
>
<PencilLine size="16" />
</Button>
</div>
</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col p-0 text-sm mt-1 whitespace-normal break-all">
<ScrollArea class="flex flex-col" viewportClasses="max-h-[30dvh]">
{#if tags.image || tags['image:0']}
<div class="w-full rounded-md overflow-clip my-2 max-w-96 mx-auto">
<!-- svelte-ignore a11y-missing-attribute -->
<img src={tags.image ?? tags['image:0']} />
</div>
{/if}
<div class="grid grid-cols-[auto_auto] gap-x-3">
{#each Object.entries(tags) as [key, value]}
{#if key !== 'name' && !key.includes('image')}
<span class="font-mono">{key}</span>
{#if key === 'website' || key.startsWith('website:') || key === 'contact:website' || key === 'contact:facebook' || key === 'contact:instagram' || key === 'contact:twitter'}
<a href={value} target="_blank" class="text-link underline">{value}</a>
{:else if key === 'phone' || key === 'contact:phone'}
<a href={'tel:' + value} class="text-link underline">{value}</a>
{:else if key === 'email' || key === 'contact:email'}
<a href={'mailto:' + value} class="text-link underline">{value}</a>
{:else}
<span>{value}</span>
{/if}
{/if}
{/each}
</div>
</ScrollArea>
<Button
class="mt-2"
variant="outline"
disabled={$selection.size === 0}
on:click={() => {
let desc = Object.entries(tags)
.map(([key, value]) => `${key}: ${value}`)
.join('\n');
dbUtils.addOrUpdateWaypoint({
attributes: {
lat: poi.item.lat,
lon: poi.item.lon,
},
name: name,
desc: desc,
cmt: desc,
sym: poi.item.sym,
});
}}
>
<MapPin size="16" class="mr-1" />
{$_('toolbar.waypoint.add')}
</Button>
</Card.Content>
</Card.Root>

View File

@@ -1,24 +1,29 @@
import type { LayerTreeType } from "$lib/assets/layers";
import { writable } from "svelte/store";
import type { LayerTreeType } from '$lib/assets/layers';
import { writable } from 'svelte/store';
export function anySelectedLayer(node: LayerTreeType) {
return Object.keys(node).find((id) => {
if (typeof node[id] == "boolean") {
if (node[id]) {
return true;
return (
Object.keys(node).find((id) => {
if (typeof node[id] == 'boolean') {
if (node[id]) {
return true;
}
} else {
if (anySelectedLayer(node[id])) {
return true;
}
}
} else {
if (anySelectedLayer(node[id])) {
return true;
}
}
return false;
}) !== undefined;
return false;
}) !== undefined
);
}
export function getLayers(node: LayerTreeType, layers: { [key: string]: boolean } = {}): { [key: string]: boolean } {
export function getLayers(
node: LayerTreeType,
layers: { [key: string]: boolean } = {}
): { [key: string]: boolean } {
Object.keys(node).forEach((id) => {
if (typeof node[id] == "boolean") {
if (typeof node[id] == 'boolean') {
layers[id] = node[id];
} else {
getLayers(node[id], layers);
@@ -32,7 +37,7 @@ export function isSelected(node: LayerTreeType, id: string) {
if (key === id) {
return node[key];
}
if (typeof node[key] !== "boolean" && isSelected(node[key], id)) {
if (typeof node[key] !== 'boolean' && isSelected(node[key], id)) {
return true;
}
return false;
@@ -43,11 +48,11 @@ export function toggle(node: LayerTreeType, id: string) {
Object.keys(node).forEach((key) => {
if (key === id) {
node[key] = !node[key];
} else if (typeof node[key] !== "boolean") {
} else if (typeof node[key] !== 'boolean') {
toggle(node[key], id);
}
});
return node;
}
export const customBasemapUpdate = writable(0);
export const customBasemapUpdate = writable(0);

View File

@@ -1,5 +1,5 @@
import { resetCursor, setCrosshairCursor } from "$lib/utils";
import type mapboxgl from "mapbox-gl";
import { resetCursor, setCrosshairCursor } from '$lib/utils';
import type mapboxgl from 'mapbox-gl';
export class GoogleRedirect {
map: mapboxgl.Map;
@@ -30,4 +30,4 @@ export class GoogleRedirect {
`https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${e.lngLat.lat},${e.lngLat.lng}`
);
}
}
}

View File

@@ -1,12 +1,14 @@
import mapboxgl, { type LayerSpecification, type VectorSourceSpecification } from "mapbox-gl";
import mapboxgl, { type LayerSpecification, type VectorSourceSpecification } from 'mapbox-gl';
import { Viewer, type ViewerBearingEvent } from 'mapillary-js/dist/mapillary.module';
import 'mapillary-js/dist/mapillary.css';
import { resetCursor, setPointerCursor } from "$lib/utils";
import type { Writable } from "svelte/store";
import { resetCursor, setPointerCursor } from '$lib/utils';
import type { Writable } from 'svelte/store';
const mapillarySource: VectorSourceSpecification = {
type: 'vector',
tiles: ['https://tiles.mapillary.com/maps/vtp/mly1_computed_public/2/{z}/{x}/{y}?access_token=MLY|4381405525255083|3204871ec181638c3c31320490f03011'],
tiles: [
'https://tiles.mapillary.com/maps/vtp/mly1_computed_public/2/{z}/{x}/{y}?access_token=MLY|4381405525255083|3204871ec181638c3c31320490f03011',
],
minzoom: 6,
maxzoom: 14,
};
@@ -70,7 +72,7 @@ export class MapillaryLayer {
this.marker = new mapboxgl.Marker({
rotationAlignment: 'map',
element
element,
});
this.viewer.on('position', async () => {
@@ -145,4 +147,4 @@ export class MapillaryLayer {
onMouseLeave() {
resetCursor();
}
}
}

View File

@@ -1,74 +1,74 @@
<script lang="ts">
import CustomControl from '$lib/components/custom-control/CustomControl.svelte';
import Tooltip from '$lib/components/Tooltip.svelte';
import { Toggle } from '$lib/components/ui/toggle';
import { PersonStanding, X } from 'lucide-svelte';
import { MapillaryLayer } from './Mapillary';
import { GoogleRedirect } from './Google';
import { map, streetViewEnabled } from '$lib/stores';
import { settings } from '$lib/db';
import { _ } from 'svelte-i18n';
import { writable } from 'svelte/store';
import CustomControl from '$lib/components/custom-control/CustomControl.svelte';
import Tooltip from '$lib/components/Tooltip.svelte';
import { Toggle } from '$lib/components/ui/toggle';
import { PersonStanding, X } from 'lucide-svelte';
import { MapillaryLayer } from './Mapillary';
import { GoogleRedirect } from './Google';
import { map, streetViewEnabled } from '$lib/stores';
import { settings } from '$lib/db';
import { _ } from 'svelte-i18n';
import { writable } from 'svelte/store';
const { streetViewSource } = settings;
const { streetViewSource } = settings;
let googleRedirect: GoogleRedirect;
let mapillaryLayer: MapillaryLayer;
let mapillaryOpen = writable(false);
let container: HTMLElement;
let googleRedirect: GoogleRedirect;
let mapillaryLayer: MapillaryLayer;
let mapillaryOpen = writable(false);
let container: HTMLElement;
$: if ($map) {
googleRedirect = new GoogleRedirect($map);
mapillaryLayer = new MapillaryLayer($map, container, mapillaryOpen);
}
$: if ($map) {
googleRedirect = new GoogleRedirect($map);
mapillaryLayer = new MapillaryLayer($map, container, mapillaryOpen);
}
$: if (mapillaryLayer) {
if ($streetViewSource === 'mapillary') {
googleRedirect.remove();
if ($streetViewEnabled) {
mapillaryLayer.add();
} else {
mapillaryLayer.remove();
}
} else {
mapillaryLayer.remove();
if ($streetViewEnabled) {
googleRedirect.add();
} else {
googleRedirect.remove();
}
}
}
$: if (mapillaryLayer) {
if ($streetViewSource === 'mapillary') {
googleRedirect.remove();
if ($streetViewEnabled) {
mapillaryLayer.add();
} else {
mapillaryLayer.remove();
}
} else {
mapillaryLayer.remove();
if ($streetViewEnabled) {
googleRedirect.add();
} else {
googleRedirect.remove();
}
}
}
</script>
<CustomControl class="w-[29px] h-[29px] shrink-0">
<Tooltip class="w-full h-full" side="left" label={$_('menu.toggle_street_view')}>
<Toggle
bind:pressed={$streetViewEnabled}
class="w-full h-full rounded p-0"
aria-label={$_('menu.toggle_street_view')}
>
<PersonStanding size="22" />
</Toggle>
</Tooltip>
<Tooltip class="w-full h-full" side="left" label={$_('menu.toggle_street_view')}>
<Toggle
bind:pressed={$streetViewEnabled}
class="w-full h-full rounded p-0"
aria-label={$_('menu.toggle_street_view')}
>
<PersonStanding size="22" />
</Toggle>
</Tooltip>
</CustomControl>
<div
bind:this={container}
class="{$mapillaryOpen
? ''
: 'hidden'} !absolute bottom-[44px] right-2.5 z-10 w-[40%] h-[40%] bg-background rounded-md overflow-hidden border-background border-2"
bind:this={container}
class="{$mapillaryOpen
? ''
: 'hidden'} !absolute bottom-[44px] right-2.5 z-10 w-[40%] h-[40%] bg-background rounded-md overflow-hidden border-background border-2"
>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="absolute top-0 right-0 z-10 bg-background p-1 rounded-bl-md cursor-pointer"
on:click={() => {
if (mapillaryLayer) {
mapillaryLayer.closePopup();
}
}}
>
<X size="16" />
</div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="absolute top-0 right-0 z-10 bg-background p-1 rounded-bl-md cursor-pointer"
on:click={() => {
if (mapillaryLayer) {
mapillaryLayer.closePopup();
}
}}
>
<X size="16" />
</div>
</div>

View File

@@ -1,54 +1,54 @@
<script lang="ts">
import { Tool } from '$lib/stores';
import ToolbarItem from './ToolbarItem.svelte';
import {
Group,
CalendarClock,
Pencil,
SquareDashedMousePointer,
Ungroup,
MapPin,
Filter,
Scissors,
MountainSnow
} from 'lucide-svelte';
import { Tool } from '$lib/stores';
import ToolbarItem from './ToolbarItem.svelte';
import {
Group,
CalendarClock,
Pencil,
SquareDashedMousePointer,
Ungroup,
MapPin,
Filter,
Scissors,
MountainSnow,
} from 'lucide-svelte';
import { _ } from 'svelte-i18n';
import ToolbarItemMenu from './ToolbarItemMenu.svelte';
import { _ } from 'svelte-i18n';
import ToolbarItemMenu from './ToolbarItemMenu.svelte';
</script>
<div class="flex flex-row w-full items-center pr-12">
<div
class="h-fit flex flex-col p-1 gap-1.5 bg-background rounded-r-md pointer-events-auto shadow-md {$$props.class ??
''}"
>
<ToolbarItem tool={Tool.ROUTING} label={$_('toolbar.routing.tooltip')}>
<Pencil slot="icon" size="18" />
</ToolbarItem>
<ToolbarItem tool={Tool.WAYPOINT} label={$_('toolbar.waypoint.tooltip')}>
<MapPin slot="icon" size="18" />
</ToolbarItem>
<ToolbarItem tool={Tool.SCISSORS} label={$_('toolbar.scissors.tooltip')}>
<Scissors slot="icon" size="18" />
</ToolbarItem>
<ToolbarItem tool={Tool.TIME} label={$_('toolbar.time.tooltip')}>
<CalendarClock slot="icon" size="18" />
</ToolbarItem>
<ToolbarItem tool={Tool.MERGE} label={$_('toolbar.merge.tooltip')}>
<Group slot="icon" size="18" />
</ToolbarItem>
<ToolbarItem tool={Tool.EXTRACT} label={$_('toolbar.extract.tooltip')}>
<Ungroup slot="icon" size="18" />
</ToolbarItem>
<ToolbarItem tool={Tool.ELEVATION} label={$_('toolbar.elevation.button')}>
<MountainSnow slot="icon" size="18" />
</ToolbarItem>
<ToolbarItem tool={Tool.REDUCE} label={$_('toolbar.reduce.tooltip')}>
<Filter slot="icon" size="18" />
</ToolbarItem>
<ToolbarItem tool={Tool.CLEAN} label={$_('toolbar.clean.tooltip')}>
<SquareDashedMousePointer slot="icon" size="18" />
</ToolbarItem>
</div>
<ToolbarItemMenu class={$$props.class ?? ''} />
<div
class="h-fit flex flex-col p-1 gap-1.5 bg-background rounded-r-md pointer-events-auto shadow-md {$$props.class ??
''}"
>
<ToolbarItem tool={Tool.ROUTING} label={$_('toolbar.routing.tooltip')}>
<Pencil slot="icon" size="18" />
</ToolbarItem>
<ToolbarItem tool={Tool.WAYPOINT} label={$_('toolbar.waypoint.tooltip')}>
<MapPin slot="icon" size="18" />
</ToolbarItem>
<ToolbarItem tool={Tool.SCISSORS} label={$_('toolbar.scissors.tooltip')}>
<Scissors slot="icon" size="18" />
</ToolbarItem>
<ToolbarItem tool={Tool.TIME} label={$_('toolbar.time.tooltip')}>
<CalendarClock slot="icon" size="18" />
</ToolbarItem>
<ToolbarItem tool={Tool.MERGE} label={$_('toolbar.merge.tooltip')}>
<Group slot="icon" size="18" />
</ToolbarItem>
<ToolbarItem tool={Tool.EXTRACT} label={$_('toolbar.extract.tooltip')}>
<Ungroup slot="icon" size="18" />
</ToolbarItem>
<ToolbarItem tool={Tool.ELEVATION} label={$_('toolbar.elevation.button')}>
<MountainSnow slot="icon" size="18" />
</ToolbarItem>
<ToolbarItem tool={Tool.REDUCE} label={$_('toolbar.reduce.tooltip')}>
<Filter slot="icon" size="18" />
</ToolbarItem>
<ToolbarItem tool={Tool.CLEAN} label={$_('toolbar.clean.tooltip')}>
<SquareDashedMousePointer slot="icon" size="18" />
</ToolbarItem>
</div>
<ToolbarItemMenu class={$$props.class ?? ''} />
</div>

View File

@@ -1,29 +1,29 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { currentTool, type Tool } from '$lib/stores';
import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { currentTool, type Tool } from '$lib/stores';
export let tool: Tool;
export let label: string;
export let tool: Tool;
export let label: string;
function toggleTool() {
currentTool.update((current) => (current === tool ? null : tool));
}
function toggleTool() {
currentTool.update((current) => (current === tool ? null : tool));
}
</script>
<Tooltip.Root openDelay={300}>
<Tooltip.Trigger asChild let:builder>
<Button
builders={[builder]}
variant="ghost"
class="h-[26px] px-1 py-1.5 {$currentTool === tool ? 'bg-accent' : ''}"
on:click={toggleTool}
aria-label={label}
>
<slot name="icon" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<span>{label}</span>
</Tooltip.Content>
<Tooltip.Trigger asChild let:builder>
<Button
builders={[builder]}
variant="ghost"
class="h-[26px] px-1 py-1.5 {$currentTool === tool ? 'bg-accent' : ''}"
on:click={toggleTool}
aria-label={label}
>
<slot name="icon" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<span>{label}</span>
</Tooltip.Content>
</Tooltip.Root>

View File

@@ -1,75 +1,75 @@
<script lang="ts">
import { Tool, currentTool } from '$lib/stores';
import { settings } from '$lib/db';
import { flyAndScale } from '$lib/utils';
import * as Card from '$lib/components/ui/card';
import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte';
import Scissors from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
import Waypoint from '$lib/components/toolbar/tools/Waypoint.svelte';
import Time from '$lib/components/toolbar/tools/Time.svelte';
import Merge from '$lib/components/toolbar/tools/Merge.svelte';
import Extract from '$lib/components/toolbar/tools/Extract.svelte';
import Elevation from '$lib/components/toolbar/tools/Elevation.svelte';
import Clean from '$lib/components/toolbar/tools/Clean.svelte';
import Reduce from '$lib/components/toolbar/tools/Reduce.svelte';
import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte';
import { onMount } from 'svelte';
import mapboxgl from 'mapbox-gl';
import { Tool, currentTool } from '$lib/stores';
import { settings } from '$lib/db';
import { flyAndScale } from '$lib/utils';
import * as Card from '$lib/components/ui/card';
import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte';
import Scissors from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
import Waypoint from '$lib/components/toolbar/tools/Waypoint.svelte';
import Time from '$lib/components/toolbar/tools/Time.svelte';
import Merge from '$lib/components/toolbar/tools/Merge.svelte';
import Extract from '$lib/components/toolbar/tools/Extract.svelte';
import Elevation from '$lib/components/toolbar/tools/Elevation.svelte';
import Clean from '$lib/components/toolbar/tools/Clean.svelte';
import Reduce from '$lib/components/toolbar/tools/Reduce.svelte';
import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte';
import { onMount } from 'svelte';
import mapboxgl from 'mapbox-gl';
const { minimizeRoutingMenu } = settings;
const { minimizeRoutingMenu } = settings;
let popupElement: HTMLElement;
let popup: mapboxgl.Popup;
let popupElement: HTMLElement;
let popup: mapboxgl.Popup;
onMount(() => {
popup = new mapboxgl.Popup({
closeButton: false,
maxWidth: undefined
});
popup.setDOMContent(popupElement);
popupElement.classList.remove('hidden');
});
onMount(() => {
popup = new mapboxgl.Popup({
closeButton: false,
maxWidth: undefined,
});
popup.setDOMContent(popupElement);
popupElement.classList.remove('hidden');
});
</script>
{#if $currentTool !== null}
<div
in:flyAndScale={{ x: -2, y: 0, duration: 100 }}
class="translate-x-1 h-full {$$props.class ?? ''}"
>
<div class="rounded-md shadow-md pointer-events-auto">
<Card.Root class="rounded-md border-none">
<Card.Content class="p-2.5">
{#if $currentTool === Tool.ROUTING}
<Routing {popup} {popupElement} bind:minimized={$minimizeRoutingMenu} />
{:else if $currentTool === Tool.SCISSORS}
<Scissors />
{:else if $currentTool === Tool.WAYPOINT}
<Waypoint />
{:else if $currentTool === Tool.TIME}
<Time />
{:else if $currentTool === Tool.MERGE}
<Merge />
{:else if $currentTool === Tool.ELEVATION}
<Elevation />
{:else if $currentTool === Tool.EXTRACT}
<Extract />
{:else if $currentTool === Tool.CLEAN}
<Clean />
{:else if $currentTool === Tool.REDUCE}
<Reduce />
{/if}
</Card.Content>
</Card.Root>
</div>
</div>
<div
in:flyAndScale={{ x: -2, y: 0, duration: 100 }}
class="translate-x-1 h-full {$$props.class ?? ''}"
>
<div class="rounded-md shadow-md pointer-events-auto">
<Card.Root class="rounded-md border-none">
<Card.Content class="p-2.5">
{#if $currentTool === Tool.ROUTING}
<Routing {popup} {popupElement} bind:minimized={$minimizeRoutingMenu} />
{:else if $currentTool === Tool.SCISSORS}
<Scissors />
{:else if $currentTool === Tool.WAYPOINT}
<Waypoint />
{:else if $currentTool === Tool.TIME}
<Time />
{:else if $currentTool === Tool.MERGE}
<Merge />
{:else if $currentTool === Tool.ELEVATION}
<Elevation />
{:else if $currentTool === Tool.EXTRACT}
<Extract />
{:else if $currentTool === Tool.CLEAN}
<Clean />
{:else if $currentTool === Tool.REDUCE}
<Reduce />
{/if}
</Card.Content>
</Card.Root>
</div>
</div>
{/if}
<svelte:window
on:keydown={(e) => {
if ($currentTool !== null && e.key === 'Escape') {
currentTool.set(null);
}
}}
on:keydown={(e) => {
if ($currentTool !== null && e.key === 'Escape') {
currentTool.set(null);
}
}}
/>
<RoutingControlPopup bind:element={popupElement} />

View File

@@ -1,188 +1,188 @@
<script lang="ts" context="module">
enum CleanType {
INSIDE = 'inside',
OUTSIDE = 'outside'
}
enum CleanType {
INSIDE = 'inside',
OUTSIDE = 'outside',
}
</script>
<script lang="ts">
import { Label } from '$lib/components/ui/label/index.js';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as RadioGroup from '$lib/components/ui/radio-group';
import { Button } from '$lib/components/ui/button';
import Help from '$lib/components/Help.svelte';
import { _, locale } from 'svelte-i18n';
import { onDestroy, onMount } from 'svelte';
import { getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
import { Trash2 } from 'lucide-svelte';
import { map } from '$lib/stores';
import { selection } from '$lib/components/file-list/Selection';
import { dbUtils } from '$lib/db';
import { Label } from '$lib/components/ui/label/index.js';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as RadioGroup from '$lib/components/ui/radio-group';
import { Button } from '$lib/components/ui/button';
import Help from '$lib/components/Help.svelte';
import { _, locale } from 'svelte-i18n';
import { onDestroy, onMount } from 'svelte';
import { getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
import { Trash2 } from 'lucide-svelte';
import { map } from '$lib/stores';
import { selection } from '$lib/components/file-list/Selection';
import { dbUtils } from '$lib/db';
let cleanType = CleanType.INSIDE;
let deleteTrackpoints = true;
let deleteWaypoints = true;
let rectangleCoordinates: mapboxgl.LngLat[] = [];
let cleanType = CleanType.INSIDE;
let deleteTrackpoints = true;
let deleteWaypoints = true;
let rectangleCoordinates: mapboxgl.LngLat[] = [];
function updateRectangle() {
if ($map) {
if (rectangleCoordinates.length != 2) {
if ($map.getLayer('rectangle')) {
$map.removeLayer('rectangle');
}
} else {
let data = {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[rectangleCoordinates[0].lng, rectangleCoordinates[0].lat],
[rectangleCoordinates[1].lng, rectangleCoordinates[0].lat],
[rectangleCoordinates[1].lng, rectangleCoordinates[1].lat],
[rectangleCoordinates[0].lng, rectangleCoordinates[1].lat],
[rectangleCoordinates[0].lng, rectangleCoordinates[0].lat]
]
]
}
};
let source = $map.getSource('rectangle');
if (source) {
source.setData(data);
} else {
$map.addSource('rectangle', {
type: 'geojson',
data: data
});
}
if (!$map.getLayer('rectangle')) {
$map.addLayer({
id: 'rectangle',
type: 'fill',
source: 'rectangle',
paint: {
'fill-color': 'SteelBlue',
'fill-opacity': 0.5
}
});
}
}
}
}
function updateRectangle() {
if ($map) {
if (rectangleCoordinates.length != 2) {
if ($map.getLayer('rectangle')) {
$map.removeLayer('rectangle');
}
} else {
let data = {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[rectangleCoordinates[0].lng, rectangleCoordinates[0].lat],
[rectangleCoordinates[1].lng, rectangleCoordinates[0].lat],
[rectangleCoordinates[1].lng, rectangleCoordinates[1].lat],
[rectangleCoordinates[0].lng, rectangleCoordinates[1].lat],
[rectangleCoordinates[0].lng, rectangleCoordinates[0].lat],
],
],
},
};
let source = $map.getSource('rectangle');
if (source) {
source.setData(data);
} else {
$map.addSource('rectangle', {
type: 'geojson',
data: data,
});
}
if (!$map.getLayer('rectangle')) {
$map.addLayer({
id: 'rectangle',
type: 'fill',
source: 'rectangle',
paint: {
'fill-color': 'SteelBlue',
'fill-opacity': 0.5,
},
});
}
}
}
}
$: if (rectangleCoordinates) {
updateRectangle();
}
$: if (rectangleCoordinates) {
updateRectangle();
}
let mousedown = false;
function onMouseDown(e: any) {
mousedown = true;
rectangleCoordinates = [e.lngLat, e.lngLat];
}
let mousedown = false;
function onMouseDown(e: any) {
mousedown = true;
rectangleCoordinates = [e.lngLat, e.lngLat];
}
function onMouseMove(e: any) {
if (mousedown) {
rectangleCoordinates[1] = e.lngLat;
}
}
function onMouseMove(e: any) {
if (mousedown) {
rectangleCoordinates[1] = e.lngLat;
}
}
function onMouseUp(e: any) {
mousedown = false;
}
function onMouseUp(e: any) {
mousedown = false;
}
onMount(() => {
setCrosshairCursor();
});
onMount(() => {
setCrosshairCursor();
});
$: if ($map) {
$map.on('mousedown', onMouseDown);
$map.on('mousemove', onMouseMove);
$map.on('mouseup', onMouseUp);
$map.on('touchstart', onMouseDown);
$map.on('touchmove', onMouseMove);
$map.on('touchend', onMouseUp);
$map.dragPan.disable();
}
$: if ($map) {
$map.on('mousedown', onMouseDown);
$map.on('mousemove', onMouseMove);
$map.on('mouseup', onMouseUp);
$map.on('touchstart', onMouseDown);
$map.on('touchmove', onMouseMove);
$map.on('touchend', onMouseUp);
$map.dragPan.disable();
}
onDestroy(() => {
resetCursor();
if ($map) {
$map.off('mousedown', onMouseDown);
$map.off('mousemove', onMouseMove);
$map.off('mouseup', onMouseUp);
$map.off('touchstart', onMouseDown);
$map.off('touchmove', onMouseMove);
$map.off('touchend', onMouseUp);
$map.dragPan.enable();
onDestroy(() => {
resetCursor();
if ($map) {
$map.off('mousedown', onMouseDown);
$map.off('mousemove', onMouseMove);
$map.off('mouseup', onMouseUp);
$map.off('touchstart', onMouseDown);
$map.off('touchmove', onMouseMove);
$map.off('touchend', onMouseUp);
$map.dragPan.enable();
if ($map.getLayer('rectangle')) {
$map.removeLayer('rectangle');
}
if ($map.getSource('rectangle')) {
$map.removeSource('rectangle');
}
}
});
if ($map.getLayer('rectangle')) {
$map.removeLayer('rectangle');
}
if ($map.getSource('rectangle')) {
$map.removeSource('rectangle');
}
}
});
$: validSelection = $selection.size > 0;
$: validSelection = $selection.size > 0;
</script>
<div class="flex flex-col gap-3 w-full max-w-80 items-center {$$props.class ?? ''}">
<fieldset class="flex flex-col gap-3">
<div class="flex flex-row items-center gap-[6.4px] h-3">
<Checkbox id="delete-trkpt" bind:checked={deleteTrackpoints} class="scale-90" />
<Label for="delete-trkpt">
{$_('toolbar.clean.delete_trackpoints')}
</Label>
</div>
<div class="flex flex-row items-center gap-[6.4px] h-3">
<Checkbox id="delete-wpt" bind:checked={deleteWaypoints} class="scale-90" />
<Label for="delete-wpt">
{$_('toolbar.clean.delete_waypoints')}
</Label>
</div>
<RadioGroup.Root bind:value={cleanType}>
<Label class="flex flex-row items-center gap-2">
<RadioGroup.Item value={CleanType.INSIDE} />
{$_('toolbar.clean.delete_inside')}
</Label>
<Label class="flex flex-row items-center gap-2">
<RadioGroup.Item value={CleanType.OUTSIDE} />
{$_('toolbar.clean.delete_outside')}
</Label>
</RadioGroup.Root>
</fieldset>
<Button
variant="outline"
class="w-full"
disabled={!validSelection || rectangleCoordinates.length != 2}
on:click={() => {
dbUtils.cleanSelection(
[
{
lat: Math.min(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
lon: Math.min(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng)
},
{
lat: Math.max(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
lon: Math.max(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng)
}
],
cleanType === CleanType.INSIDE,
deleteTrackpoints,
deleteWaypoints
);
rectangleCoordinates = [];
}}
>
<Trash2 size="16" class="mr-1" />
{$_('toolbar.clean.button')}
</Button>
<Help link={getURLForLanguage($locale, '/help/toolbar/clean')}>
{#if validSelection}
{$_('toolbar.clean.help')}
{:else}
{$_('toolbar.clean.help_no_selection')}
{/if}
</Help>
<fieldset class="flex flex-col gap-3">
<div class="flex flex-row items-center gap-[6.4px] h-3">
<Checkbox id="delete-trkpt" bind:checked={deleteTrackpoints} class="scale-90" />
<Label for="delete-trkpt">
{$_('toolbar.clean.delete_trackpoints')}
</Label>
</div>
<div class="flex flex-row items-center gap-[6.4px] h-3">
<Checkbox id="delete-wpt" bind:checked={deleteWaypoints} class="scale-90" />
<Label for="delete-wpt">
{$_('toolbar.clean.delete_waypoints')}
</Label>
</div>
<RadioGroup.Root bind:value={cleanType}>
<Label class="flex flex-row items-center gap-2">
<RadioGroup.Item value={CleanType.INSIDE} />
{$_('toolbar.clean.delete_inside')}
</Label>
<Label class="flex flex-row items-center gap-2">
<RadioGroup.Item value={CleanType.OUTSIDE} />
{$_('toolbar.clean.delete_outside')}
</Label>
</RadioGroup.Root>
</fieldset>
<Button
variant="outline"
class="w-full"
disabled={!validSelection || rectangleCoordinates.length != 2}
on:click={() => {
dbUtils.cleanSelection(
[
{
lat: Math.min(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
lon: Math.min(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng),
},
{
lat: Math.max(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
lon: Math.max(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng),
},
],
cleanType === CleanType.INSIDE,
deleteTrackpoints,
deleteWaypoints
);
rectangleCoordinates = [];
}}
>
<Trash2 size="16" class="mr-1" />
{$_('toolbar.clean.button')}
</Button>
<Help link={getURLForLanguage($locale, '/help/toolbar/clean')}>
{#if validSelection}
{$_('toolbar.clean.help')}
{:else}
{$_('toolbar.clean.help_no_selection')}
{/if}
</Help>
</div>

View File

@@ -1,35 +1,35 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { selection } from '$lib/components/file-list/Selection';
import Help from '$lib/components/Help.svelte';
import { MountainSnow } from 'lucide-svelte';
import { dbUtils } from '$lib/db';
import { map } from '$lib/stores';
import { _, locale } from 'svelte-i18n';
import { getURLForLanguage } from '$lib/utils';
import { Button } from '$lib/components/ui/button';
import { selection } from '$lib/components/file-list/Selection';
import Help from '$lib/components/Help.svelte';
import { MountainSnow } from 'lucide-svelte';
import { dbUtils } from '$lib/db';
import { map } from '$lib/stores';
import { _, locale } from 'svelte-i18n';
import { getURLForLanguage } from '$lib/utils';
$: validSelection = $selection.size > 0;
$: validSelection = $selection.size > 0;
</script>
<div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
<Button
variant="outline"
class="whitespace-normal h-fit"
disabled={!validSelection}
on:click={async () => {
if ($map) {
dbUtils.addElevationToSelection($map);
}
}}
>
<MountainSnow size="16" class="mr-1 shrink-0" />
{$_('toolbar.elevation.button')}
</Button>
<Help link={getURLForLanguage($locale, '/help/toolbar/elevation')}>
{#if validSelection}
{$_('toolbar.elevation.help')}
{:else}
{$_('toolbar.elevation.help_no_selection')}
{/if}
</Help>
<Button
variant="outline"
class="whitespace-normal h-fit"
disabled={!validSelection}
on:click={async () => {
if ($map) {
dbUtils.addElevationToSelection($map);
}
}}
>
<MountainSnow size="16" class="mr-1 shrink-0" />
{$_('toolbar.elevation.button')}
</Button>
<Help link={getURLForLanguage($locale, '/help/toolbar/elevation')}>
{#if validSelection}
{$_('toolbar.elevation.help')}
{:else}
{$_('toolbar.elevation.help_no_selection')}
{/if}
</Help>
</div>

View File

@@ -1,53 +1,53 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Ungroup } from 'lucide-svelte';
import { selection } from '$lib/components/file-list/Selection';
import {
ListFileItem,
ListTrackItem,
ListTrackSegmentItem,
ListWaypointItem,
ListWaypointsItem
} from '$lib/components/file-list/FileList';
import Help from '$lib/components/Help.svelte';
import { dbUtils, getFile } from '$lib/db';
import { _, locale } from 'svelte-i18n';
import { getURLForLanguage } from '$lib/utils';
import { Button } from '$lib/components/ui/button';
import { Ungroup } from 'lucide-svelte';
import { selection } from '$lib/components/file-list/Selection';
import {
ListFileItem,
ListTrackItem,
ListTrackSegmentItem,
ListWaypointItem,
ListWaypointsItem,
} from '$lib/components/file-list/FileList';
import Help from '$lib/components/Help.svelte';
import { dbUtils, getFile } from '$lib/db';
import { _, locale } from 'svelte-i18n';
import { getURLForLanguage } from '$lib/utils';
$: validSelection =
$selection.size > 0 &&
$selection.getSelected().every((item) => {
if (
item instanceof ListWaypointsItem ||
item instanceof ListWaypointItem ||
item instanceof ListTrackSegmentItem
) {
return false;
}
let file = getFile(item.getFileId());
if (file) {
if (item instanceof ListFileItem) {
return file.getSegments().length > 1;
} else if (item instanceof ListTrackItem) {
if (item.getTrackIndex() < file.trk.length) {
return file.trk[item.getTrackIndex()].getSegments().length > 1;
}
}
}
return false;
});
$: validSelection =
$selection.size > 0 &&
$selection.getSelected().every((item) => {
if (
item instanceof ListWaypointsItem ||
item instanceof ListWaypointItem ||
item instanceof ListTrackSegmentItem
) {
return false;
}
let file = getFile(item.getFileId());
if (file) {
if (item instanceof ListFileItem) {
return file.getSegments().length > 1;
} else if (item instanceof ListTrackItem) {
if (item.getTrackIndex() < file.trk.length) {
return file.trk[item.getTrackIndex()].getSegments().length > 1;
}
}
}
return false;
});
</script>
<div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
<Button variant="outline" disabled={!validSelection} on:click={dbUtils.extractSelection}>
<Ungroup size="16" class="mr-1" />
{$_('toolbar.extract.button')}
</Button>
<Help link={getURLForLanguage($locale, '/help/toolbar/extract')}>
{#if validSelection}
{$_('toolbar.extract.help')}
{:else}
{$_('toolbar.extract.help_invalid_selection')}
{/if}
</Help>
<Button variant="outline" disabled={!validSelection} on:click={dbUtils.extractSelection}>
<Ungroup size="16" class="mr-1" />
{$_('toolbar.extract.button')}
</Button>
<Help link={getURLForLanguage($locale, '/help/toolbar/extract')}>
{#if validSelection}
{$_('toolbar.extract.help')}
{:else}
{$_('toolbar.extract.help_invalid_selection')}
{/if}
</Help>
</div>

View File

@@ -1,117 +1,117 @@
<script lang="ts" context="module">
enum MergeType {
TRACES = 'traces',
CONTENTS = 'contents'
}
enum MergeType {
TRACES = 'traces',
CONTENTS = 'contents',
}
</script>
<script lang="ts">
import { ListFileItem, ListTrackItem } from '$lib/components/file-list/FileList';
import Help from '$lib/components/Help.svelte';
import { selection } from '$lib/components/file-list/Selection';
import { Button } from '$lib/components/ui/button';
import { Label } from '$lib/components/ui/label/index.js';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as RadioGroup from '$lib/components/ui/radio-group';
import { _, locale } from 'svelte-i18n';
import { dbUtils, getFile } from '$lib/db';
import { Group } from 'lucide-svelte';
import { getURLForLanguage } from '$lib/utils';
import Shortcut from '$lib/components/Shortcut.svelte';
import { gpxStatistics } from '$lib/stores';
import { ListFileItem, ListTrackItem } from '$lib/components/file-list/FileList';
import Help from '$lib/components/Help.svelte';
import { selection } from '$lib/components/file-list/Selection';
import { Button } from '$lib/components/ui/button';
import { Label } from '$lib/components/ui/label/index.js';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as RadioGroup from '$lib/components/ui/radio-group';
import { _, locale } from 'svelte-i18n';
import { dbUtils, getFile } from '$lib/db';
import { Group } from 'lucide-svelte';
import { getURLForLanguage } from '$lib/utils';
import Shortcut from '$lib/components/Shortcut.svelte';
import { gpxStatistics } from '$lib/stores';
let canMergeTraces = false;
let canMergeContents = false;
let removeGaps = false;
let canMergeTraces = false;
let canMergeContents = false;
let removeGaps = false;
$: if ($selection.size > 1) {
canMergeTraces = true;
} else if ($selection.size === 1) {
let selected = $selection.getSelected()[0];
if (selected instanceof ListFileItem) {
let file = getFile(selected.getFileId());
if (file) {
canMergeTraces = file.getSegments().length > 1;
} else {
canMergeTraces = false;
}
} else if (selected instanceof ListTrackItem) {
let trackIndex = selected.getTrackIndex();
let file = getFile(selected.getFileId());
if (file && trackIndex < file.trk.length) {
canMergeTraces = file.trk[trackIndex].getSegments().length > 1;
} else {
canMergeTraces = false;
}
} else {
canMergeContents = false;
}
}
$: if ($selection.size > 1) {
canMergeTraces = true;
} else if ($selection.size === 1) {
let selected = $selection.getSelected()[0];
if (selected instanceof ListFileItem) {
let file = getFile(selected.getFileId());
if (file) {
canMergeTraces = file.getSegments().length > 1;
} else {
canMergeTraces = false;
}
} else if (selected instanceof ListTrackItem) {
let trackIndex = selected.getTrackIndex();
let file = getFile(selected.getFileId());
if (file && trackIndex < file.trk.length) {
canMergeTraces = file.trk[trackIndex].getSegments().length > 1;
} else {
canMergeTraces = false;
}
} else {
canMergeContents = false;
}
}
$: canMergeContents =
$selection.size > 1 &&
$selection
.getSelected()
.some((item) => item instanceof ListFileItem || item instanceof ListTrackItem);
$: canMergeContents =
$selection.size > 1 &&
$selection
.getSelected()
.some((item) => item instanceof ListFileItem || item instanceof ListTrackItem);
let mergeType = MergeType.TRACES;
let mergeType = MergeType.TRACES;
</script>
<div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
<RadioGroup.Root bind:value={mergeType}>
<Label class="flex flex-row items-center gap-1.5 leading-5">
<RadioGroup.Item value={MergeType.TRACES} />
{$_('toolbar.merge.merge_traces')}
</Label>
<Label class="flex flex-row items-center gap-1.5 leading-5">
<RadioGroup.Item value={MergeType.CONTENTS} />
{$_('toolbar.merge.merge_contents')}
</Label>
</RadioGroup.Root>
{#if mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0}
<div class="flex flex-row items-center gap-1.5">
<Checkbox id="remove-gaps" bind:checked={removeGaps} />
<Label for="remove-gaps">{$_('toolbar.merge.remove_gaps')}</Label>
</div>
{/if}
<Button
variant="outline"
class="whitespace-normal h-fit"
disabled={(mergeType === MergeType.TRACES && !canMergeTraces) ||
(mergeType === MergeType.CONTENTS && !canMergeContents)}
on:click={() => {
dbUtils.mergeSelection(
mergeType === MergeType.TRACES,
mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0 && removeGaps
);
}}
>
<Group size="16" class="mr-1 shrink-0" />
{$_('toolbar.merge.merge_selection')}
</Button>
<Help link={getURLForLanguage($locale, '/help/toolbar/merge')}>
{#if mergeType === MergeType.TRACES && canMergeTraces}
{$_('toolbar.merge.help_merge_traces')}
{:else if mergeType === MergeType.TRACES && !canMergeTraces}
{$_('toolbar.merge.help_cannot_merge_traces')}
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[0]}
<Shortcut
ctrl={true}
click={true}
class="inline-flex text-muted-foreground text-xs border rounded p-0.5 gap-0"
/>
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[1]}
{:else if mergeType === MergeType.CONTENTS && canMergeContents}
{$_('toolbar.merge.help_merge_contents')}
{:else if mergeType === MergeType.CONTENTS && !canMergeContents}
{$_('toolbar.merge.help_cannot_merge_contents')}
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[0]}
<Shortcut
ctrl={true}
click={true}
class="inline-flex text-muted-foreground text-xs border rounded p-0.5 gap-0"
/>
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[1]}
{/if}
</Help>
<RadioGroup.Root bind:value={mergeType}>
<Label class="flex flex-row items-center gap-1.5 leading-5">
<RadioGroup.Item value={MergeType.TRACES} />
{$_('toolbar.merge.merge_traces')}
</Label>
<Label class="flex flex-row items-center gap-1.5 leading-5">
<RadioGroup.Item value={MergeType.CONTENTS} />
{$_('toolbar.merge.merge_contents')}
</Label>
</RadioGroup.Root>
{#if mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0}
<div class="flex flex-row items-center gap-1.5">
<Checkbox id="remove-gaps" bind:checked={removeGaps} />
<Label for="remove-gaps">{$_('toolbar.merge.remove_gaps')}</Label>
</div>
{/if}
<Button
variant="outline"
class="whitespace-normal h-fit"
disabled={(mergeType === MergeType.TRACES && !canMergeTraces) ||
(mergeType === MergeType.CONTENTS && !canMergeContents)}
on:click={() => {
dbUtils.mergeSelection(
mergeType === MergeType.TRACES,
mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0 && removeGaps
);
}}
>
<Group size="16" class="mr-1 shrink-0" />
{$_('toolbar.merge.merge_selection')}
</Button>
<Help link={getURLForLanguage($locale, '/help/toolbar/merge')}>
{#if mergeType === MergeType.TRACES && canMergeTraces}
{$_('toolbar.merge.help_merge_traces')}
{:else if mergeType === MergeType.TRACES && !canMergeTraces}
{$_('toolbar.merge.help_cannot_merge_traces')}
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[0]}
<Shortcut
ctrl={true}
click={true}
class="inline-flex text-muted-foreground text-xs border rounded p-0.5 gap-0"
/>
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[1]}
{:else if mergeType === MergeType.CONTENTS && canMergeContents}
{$_('toolbar.merge.help_merge_contents')}
{:else if mergeType === MergeType.CONTENTS && !canMergeContents}
{$_('toolbar.merge.help_cannot_merge_contents')}
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[0]}
<Shortcut
ctrl={true}
click={true}
class="inline-flex text-muted-foreground text-xs border rounded p-0.5 gap-0"
/>
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[1]}
{/if}
</Help>
</div>

View File

@@ -1,178 +1,187 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label/index.js';
import { Button } from '$lib/components/ui/button';
import { Slider } from '$lib/components/ui/slider';
import { selection } from '$lib/components/file-list/Selection';
import { ListItem, ListRootItem, ListTrackSegmentItem } from '$lib/components/file-list/FileList';
import Help from '$lib/components/Help.svelte';
import { Filter } from 'lucide-svelte';
import { _, locale } from 'svelte-i18n';
import WithUnits from '$lib/components/WithUnits.svelte';
import { dbUtils, fileObservers } from '$lib/db';
import { map } from '$lib/stores';
import { onDestroy } from 'svelte';
import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx';
import { derived } from 'svelte/store';
import { getURLForLanguage } from '$lib/utils';
import { Label } from '$lib/components/ui/label/index.js';
import { Button } from '$lib/components/ui/button';
import { Slider } from '$lib/components/ui/slider';
import { selection } from '$lib/components/file-list/Selection';
import {
ListItem,
ListRootItem,
ListTrackSegmentItem,
} from '$lib/components/file-list/FileList';
import Help from '$lib/components/Help.svelte';
import { Filter } from 'lucide-svelte';
import { _, locale } from 'svelte-i18n';
import WithUnits from '$lib/components/WithUnits.svelte';
import { dbUtils, fileObservers } from '$lib/db';
import { map } from '$lib/stores';
import { onDestroy } from 'svelte';
import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx';
import { derived } from 'svelte/store';
import { getURLForLanguage } from '$lib/utils';
let sliderValue = [50];
let maxPoints = 0;
let currentPoints = 0;
const minTolerance = 0.1;
const maxTolerance = 10000;
let sliderValue = [50];
let maxPoints = 0;
let currentPoints = 0;
const minTolerance = 0.1;
const maxTolerance = 10000;
$: validSelection = $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
$: validSelection = $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
$: tolerance =
minTolerance * 2 ** (sliderValue[0] / (100 / Math.log2(maxTolerance / minTolerance)));
$: tolerance =
minTolerance * 2 ** (sliderValue[0] / (100 / Math.log2(maxTolerance / minTolerance)));
let simplified = new Map<string, [ListItem, number, SimplifiedTrackPoint[]]>();
let unsubscribes = new Map<string, () => void>();
let simplified = new Map<string, [ListItem, number, SimplifiedTrackPoint[]]>();
let unsubscribes = new Map<string, () => void>();
function update() {
maxPoints = 0;
currentPoints = 0;
function update() {
maxPoints = 0;
currentPoints = 0;
let data: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: []
};
let data: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [],
};
simplified.forEach(([item, maxPts, points], itemFullId) => {
maxPoints += maxPts;
simplified.forEach(([item, maxPts, points], itemFullId) => {
maxPoints += maxPts;
let current = points.filter(
(point) => point.distance === undefined || point.distance >= tolerance
);
currentPoints += current.length;
let current = points.filter(
(point) => point.distance === undefined || point.distance >= tolerance
);
currentPoints += current.length;
data.features.push({
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: current.map((point) => [
point.point.getLongitude(),
point.point.getLatitude()
])
},
properties: {}
});
});
data.features.push({
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: current.map((point) => [
point.point.getLongitude(),
point.point.getLatitude(),
]),
},
properties: {},
});
});
if ($map) {
let source = $map.getSource('simplified');
if (source) {
source.setData(data);
} else {
$map.addSource('simplified', {
type: 'geojson',
data: data
});
}
if (!$map.getLayer('simplified')) {
$map.addLayer({
id: 'simplified',
type: 'line',
source: 'simplified',
paint: {
'line-color': 'white',
'line-width': 3
}
});
} else {
$map.moveLayer('simplified');
}
}
}
if ($map) {
let source = $map.getSource('simplified');
if (source) {
source.setData(data);
} else {
$map.addSource('simplified', {
type: 'geojson',
data: data,
});
}
if (!$map.getLayer('simplified')) {
$map.addLayer({
id: 'simplified',
type: 'line',
source: 'simplified',
paint: {
'line-color': 'white',
'line-width': 3,
},
});
} else {
$map.moveLayer('simplified');
}
}
}
$: if ($fileObservers) {
unsubscribes.forEach((unsubscribe, fileId) => {
if (!$fileObservers.has(fileId)) {
unsubscribe();
unsubscribes.delete(fileId);
}
});
$fileObservers.forEach((fileStore, fileId) => {
if (!unsubscribes.has(fileId)) {
let unsubscribe = derived([fileStore, selection], ([fs, sel]) => [fs, sel]).subscribe(
([fs, sel]) => {
if (fs) {
fs.file.forEachSegment((segment, trackIndex, segmentIndex) => {
let segmentItem = new ListTrackSegmentItem(fileId, trackIndex, segmentIndex);
if (sel.hasAnyParent(segmentItem)) {
let statistics = fs.statistics.getStatisticsFor(segmentItem);
simplified.set(segmentItem.getFullId(), [
segmentItem,
statistics.local.points.length,
ramerDouglasPeucker(statistics.local.points, minTolerance)
]);
update();
} else if (simplified.has(segmentItem.getFullId())) {
simplified.delete(segmentItem.getFullId());
update();
}
});
}
}
);
unsubscribes.set(fileId, unsubscribe);
}
});
}
$: if ($fileObservers) {
unsubscribes.forEach((unsubscribe, fileId) => {
if (!$fileObservers.has(fileId)) {
unsubscribe();
unsubscribes.delete(fileId);
}
});
$fileObservers.forEach((fileStore, fileId) => {
if (!unsubscribes.has(fileId)) {
let unsubscribe = derived([fileStore, selection], ([fs, sel]) => [
fs,
sel,
]).subscribe(([fs, sel]) => {
if (fs) {
fs.file.forEachSegment((segment, trackIndex, segmentIndex) => {
let segmentItem = new ListTrackSegmentItem(
fileId,
trackIndex,
segmentIndex
);
if (sel.hasAnyParent(segmentItem)) {
let statistics = fs.statistics.getStatisticsFor(segmentItem);
simplified.set(segmentItem.getFullId(), [
segmentItem,
statistics.local.points.length,
ramerDouglasPeucker(statistics.local.points, minTolerance),
]);
update();
} else if (simplified.has(segmentItem.getFullId())) {
simplified.delete(segmentItem.getFullId());
update();
}
});
}
});
unsubscribes.set(fileId, unsubscribe);
}
});
}
$: if (tolerance) {
update();
}
$: if (tolerance) {
update();
}
onDestroy(() => {
if ($map) {
if ($map.getLayer('simplified')) {
$map.removeLayer('simplified');
}
if ($map.getSource('simplified')) {
$map.removeSource('simplified');
}
}
unsubscribes.forEach((unsubscribe) => unsubscribe());
simplified.clear();
});
onDestroy(() => {
if ($map) {
if ($map.getLayer('simplified')) {
$map.removeLayer('simplified');
}
if ($map.getSource('simplified')) {
$map.removeSource('simplified');
}
}
unsubscribes.forEach((unsubscribe) => unsubscribe());
simplified.clear();
});
function reduce() {
let itemsAndPoints = new Map<ListItem, TrackPoint[]>();
simplified.forEach(([item, maxPts, points], itemFullId) => {
itemsAndPoints.set(
item,
points
.filter((point) => point.distance === undefined || point.distance >= tolerance)
.map((point) => point.point)
);
});
dbUtils.reduce(itemsAndPoints);
}
function reduce() {
let itemsAndPoints = new Map<ListItem, TrackPoint[]>();
simplified.forEach(([item, maxPts, points], itemFullId) => {
itemsAndPoints.set(
item,
points
.filter((point) => point.distance === undefined || point.distance >= tolerance)
.map((point) => point.point)
);
});
dbUtils.reduce(itemsAndPoints);
}
</script>
<div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
<div class="p-2">
<Slider bind:value={sliderValue} min={0} max={100} step={1} />
</div>
<Label class="flex flex-row justify-between">
<span>{$_('toolbar.reduce.tolerance')}</span>
<WithUnits value={tolerance / 1000} type="distance" decimals={4} class="font-normal" />
</Label>
<Label class="flex flex-row justify-between">
<span>{$_('toolbar.reduce.number_of_points')}</span>
<span class="font-normal">{currentPoints}/{maxPoints}</span>
</Label>
<Button variant="outline" disabled={!validSelection} on:click={reduce}>
<Filter size="16" class="mr-1" />
{$_('toolbar.reduce.button')}
</Button>
<div class="p-2">
<Slider bind:value={sliderValue} min={0} max={100} step={1} />
</div>
<Label class="flex flex-row justify-between">
<span>{$_('toolbar.reduce.tolerance')}</span>
<WithUnits value={tolerance / 1000} type="distance" decimals={4} class="font-normal" />
</Label>
<Label class="flex flex-row justify-between">
<span>{$_('toolbar.reduce.number_of_points')}</span>
<span class="font-normal">{currentPoints}/{maxPoints}</span>
</Label>
<Button variant="outline" disabled={!validSelection} on:click={reduce}>
<Filter size="16" class="mr-1" />
{$_('toolbar.reduce.button')}
</Button>
<Help link={getURLForLanguage($locale, '/help/toolbar/minify')}>
{#if validSelection}
{$_('toolbar.reduce.help')}
{:else}
{$_('toolbar.reduce.help_no_selection')}
{/if}
</Help>
<Help link={getURLForLanguage($locale, '/help/toolbar/minify')}>
{#if validSelection}
{$_('toolbar.reduce.help')}
{:else}
{$_('toolbar.reduce.help_no_selection')}
{/if}
</Help>
</div>

View File

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

View File

@@ -1,283 +1,292 @@
<script lang="ts" context="module">
import { writable } from 'svelte/store';
import { writable } from 'svelte/store';
export const selectedWaypoint = writable<[Waypoint, string] | undefined>(undefined);
export const selectedWaypoint = writable<[Waypoint, string] | undefined>(undefined);
</script>
<script lang="ts">
import { Input } from '$lib/components/ui/input';
import { Textarea } from '$lib/components/ui/textarea';
import { Label } from '$lib/components/ui/label/index.js';
import { Button } from '$lib/components/ui/button';
import * as Select from '$lib/components/ui/select';
import { selection } from '$lib/components/file-list/Selection';
import { Waypoint } from 'gpx';
import { _, locale } from 'svelte-i18n';
import { ListWaypointItem } from '$lib/components/file-list/FileList';
import { dbUtils, fileObservers, getFile, settings, type GPXFileWithStatistics } from '$lib/db';
import { get } from 'svelte/store';
import Help from '$lib/components/Help.svelte';
import { onDestroy, onMount } from 'svelte';
import { map } from '$lib/stores';
import { getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
import { MapPin, CircleX, Save } from 'lucide-svelte';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
import { Input } from '$lib/components/ui/input';
import { Textarea } from '$lib/components/ui/textarea';
import { Label } from '$lib/components/ui/label/index.js';
import { Button } from '$lib/components/ui/button';
import * as Select from '$lib/components/ui/select';
import { selection } from '$lib/components/file-list/Selection';
import { Waypoint } from 'gpx';
import { _, locale } from 'svelte-i18n';
import { ListWaypointItem } from '$lib/components/file-list/FileList';
import { dbUtils, fileObservers, getFile, settings, type GPXFileWithStatistics } from '$lib/db';
import { get } from 'svelte/store';
import Help from '$lib/components/Help.svelte';
import { onDestroy, onMount } from 'svelte';
import { map } from '$lib/stores';
import { getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
import { MapPin, CircleX, Save } from 'lucide-svelte';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
let name: string;
let description: string;
let link: string;
let longitude: number;
let latitude: number;
let name: string;
let description: string;
let link: string;
let longitude: number;
let latitude: number;
let selectedSymbol = {
value: '',
label: ''
};
let selectedSymbol = {
value: '',
label: '',
};
const { treeFileView } = settings;
const { treeFileView } = settings;
$: canCreate = $selection.size > 0;
$: canCreate = $selection.size > 0;
$: if ($treeFileView && $selection) {
selectedWaypoint.update(() => {
if ($selection.size === 1) {
let item = $selection.getSelected()[0];
if (item instanceof ListWaypointItem) {
let file = getFile(item.getFileId());
let waypoint = file?.wpt[item.getWaypointIndex()];
if (waypoint) {
return [waypoint, item.getFileId()];
}
}
}
return undefined;
});
}
$: if ($treeFileView && $selection) {
selectedWaypoint.update(() => {
if ($selection.size === 1) {
let item = $selection.getSelected()[0];
if (item instanceof ListWaypointItem) {
let file = getFile(item.getFileId());
let waypoint = file?.wpt[item.getWaypointIndex()];
if (waypoint) {
return [waypoint, item.getFileId()];
}
}
}
return undefined;
});
}
let unsubscribe: (() => void) | undefined = undefined;
function updateWaypointData(fileStore: GPXFileWithStatistics | undefined) {
if ($selectedWaypoint) {
if (fileStore) {
if ($selectedWaypoint[0]._data.index < fileStore.file.wpt.length) {
$selectedWaypoint[0] = fileStore.file.wpt[$selectedWaypoint[0]._data.index];
name = $selectedWaypoint[0].name ?? '';
description = $selectedWaypoint[0].desc ?? '';
if (
$selectedWaypoint[0].cmt !== undefined &&
$selectedWaypoint[0].cmt !== $selectedWaypoint[0].desc
) {
description += '\n\n' + $selectedWaypoint[0].cmt;
}
link = $selectedWaypoint[0].link?.attributes?.href ?? '';
let symbol = $selectedWaypoint[0].sym ?? '';
let symbolKey = getSymbolKey(symbol);
if (symbolKey) {
selectedSymbol = {
value: symbol,
label: $_(`gpx.symbol.${symbolKey}`)
};
} else {
selectedSymbol = {
value: symbol,
label: ''
};
}
longitude = parseFloat($selectedWaypoint[0].getLongitude().toFixed(6));
latitude = parseFloat($selectedWaypoint[0].getLatitude().toFixed(6));
} else {
selectedWaypoint.set(undefined);
}
} else {
selectedWaypoint.set(undefined);
}
}
}
let unsubscribe: (() => void) | undefined = undefined;
function updateWaypointData(fileStore: GPXFileWithStatistics | undefined) {
if ($selectedWaypoint) {
if (fileStore) {
if ($selectedWaypoint[0]._data.index < fileStore.file.wpt.length) {
$selectedWaypoint[0] = fileStore.file.wpt[$selectedWaypoint[0]._data.index];
name = $selectedWaypoint[0].name ?? '';
description = $selectedWaypoint[0].desc ?? '';
if (
$selectedWaypoint[0].cmt !== undefined &&
$selectedWaypoint[0].cmt !== $selectedWaypoint[0].desc
) {
description += '\n\n' + $selectedWaypoint[0].cmt;
}
link = $selectedWaypoint[0].link?.attributes?.href ?? '';
let symbol = $selectedWaypoint[0].sym ?? '';
let symbolKey = getSymbolKey(symbol);
if (symbolKey) {
selectedSymbol = {
value: symbol,
label: $_(`gpx.symbol.${symbolKey}`),
};
} else {
selectedSymbol = {
value: symbol,
label: '',
};
}
longitude = parseFloat($selectedWaypoint[0].getLongitude().toFixed(6));
latitude = parseFloat($selectedWaypoint[0].getLatitude().toFixed(6));
} else {
selectedWaypoint.set(undefined);
}
} else {
selectedWaypoint.set(undefined);
}
}
}
function resetWaypointData() {
name = '';
description = '';
link = '';
selectedSymbol = {
value: '',
label: ''
};
longitude = 0;
latitude = 0;
}
function resetWaypointData() {
name = '';
description = '';
link = '';
selectedSymbol = {
value: '',
label: '',
};
longitude = 0;
latitude = 0;
}
$: {
if (unsubscribe) {
unsubscribe();
unsubscribe = undefined;
}
if ($selectedWaypoint) {
let fileStore = get(fileObservers).get($selectedWaypoint[1]);
if (fileStore) {
unsubscribe = fileStore.subscribe(updateWaypointData);
}
} else {
resetWaypointData();
}
}
$: {
if (unsubscribe) {
unsubscribe();
unsubscribe = undefined;
}
if ($selectedWaypoint) {
let fileStore = get(fileObservers).get($selectedWaypoint[1]);
if (fileStore) {
unsubscribe = fileStore.subscribe(updateWaypointData);
}
} else {
resetWaypointData();
}
}
function createOrUpdateWaypoint() {
if (typeof latitude === 'string') {
latitude = parseFloat(latitude);
}
if (typeof longitude === 'string') {
longitude = parseFloat(longitude);
}
latitude = parseFloat(latitude.toFixed(6));
longitude = parseFloat(longitude.toFixed(6));
function createOrUpdateWaypoint() {
if (typeof latitude === 'string') {
latitude = parseFloat(latitude);
}
if (typeof longitude === 'string') {
longitude = parseFloat(longitude);
}
latitude = parseFloat(latitude.toFixed(6));
longitude = parseFloat(longitude.toFixed(6));
dbUtils.addOrUpdateWaypoint(
{
attributes: {
lat: latitude,
lon: longitude
},
name: name.length > 0 ? name : undefined,
desc: description.length > 0 ? description : undefined,
cmt: description.length > 0 ? description : undefined,
link: link.length > 0 ? { attributes: { href: link } } : undefined,
sym: selectedSymbol.value.length > 0 ? selectedSymbol.value : undefined
},
$selectedWaypoint
? new ListWaypointItem($selectedWaypoint[1], $selectedWaypoint[0]._data.index)
: undefined
);
dbUtils.addOrUpdateWaypoint(
{
attributes: {
lat: latitude,
lon: longitude,
},
name: name.length > 0 ? name : undefined,
desc: description.length > 0 ? description : undefined,
cmt: description.length > 0 ? description : undefined,
link: link.length > 0 ? { attributes: { href: link } } : undefined,
sym: selectedSymbol.value.length > 0 ? selectedSymbol.value : undefined,
},
$selectedWaypoint
? new ListWaypointItem($selectedWaypoint[1], $selectedWaypoint[0]._data.index)
: undefined
);
selectedWaypoint.set(undefined);
resetWaypointData();
}
selectedWaypoint.set(undefined);
resetWaypointData();
}
function setCoordinates(e: any) {
latitude = e.lngLat.lat.toFixed(6);
longitude = e.lngLat.lng.toFixed(6);
}
function setCoordinates(e: any) {
latitude = e.lngLat.lat.toFixed(6);
longitude = e.lngLat.lng.toFixed(6);
}
$: sortedSymbols = Object.entries(symbols).sort((a, b) => {
return $_(`gpx.symbol.${a[0]}`).localeCompare($_(`gpx.symbol.${b[0]}`), $locale ?? 'en');
});
$: sortedSymbols = Object.entries(symbols).sort((a, b) => {
return $_(`gpx.symbol.${a[0]}`).localeCompare($_(`gpx.symbol.${b[0]}`), $locale ?? 'en');
});
onMount(() => {
let m = get(map);
m?.on('click', setCoordinates);
setCrosshairCursor();
});
onMount(() => {
let m = get(map);
m?.on('click', setCoordinates);
setCrosshairCursor();
});
onDestroy(() => {
let m = get(map);
m?.off('click', setCoordinates);
resetCursor();
onDestroy(() => {
let m = get(map);
m?.off('click', setCoordinates);
resetCursor();
if (unsubscribe) {
unsubscribe();
unsubscribe = undefined;
}
});
if (unsubscribe) {
unsubscribe();
unsubscribe = undefined;
}
});
</script>
<div class="flex flex-col gap-3 w-full max-w-96 {$$props.class ?? ''}">
<fieldset class="flex flex-col gap-2">
<Label for="name">{$_('menu.metadata.name')}</Label>
<Input
bind:value={name}
id="name"
class="font-semibold h-8"
disabled={!canCreate && !$selectedWaypoint}
/>
<Label for="description">{$_('menu.metadata.description')}</Label>
<Textarea
bind:value={description}
id="description"
disabled={!canCreate && !$selectedWaypoint}
/>
<Label for="symbol">{$_('toolbar.waypoint.icon')}</Label>
<Select.Root bind:selected={selectedSymbol}>
<Select.Trigger id="symbol" class="w-full h-8" disabled={!canCreate && !$selectedWaypoint}>
<Select.Value />
</Select.Trigger>
<Select.Content class="max-h-60 overflow-y-scroll">
{#each sortedSymbols as [key, symbol]}
<Select.Item value={symbol.value}>
<span>
{#if symbol.icon}
<svelte:component
this={symbol.icon}
size="14"
class="inline-block align-sub mr-0.5"
/>
{:else}
<span class="w-4 inline-block" />
{/if}
{$_(`gpx.symbol.${key}`)}
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Label for="link">{$_('toolbar.waypoint.link')}</Label>
<Input bind:value={link} id="link" class="h-8" disabled={!canCreate && !$selectedWaypoint} />
<div class="flex flex-row gap-2">
<div class="grow">
<Label for="latitude">{$_('toolbar.waypoint.latitude')}</Label>
<Input
bind:value={latitude}
type="number"
id="latitude"
step={1e-6}
min={-90}
max={90}
class="text-xs h-8"
disabled={!canCreate && !$selectedWaypoint}
/>
</div>
<div class="grow">
<Label for="longitude">{$_('toolbar.waypoint.longitude')}</Label>
<Input
bind:value={longitude}
type="number"
id="longitude"
step={1e-6}
min={-180}
max={180}
class="text-xs h-8"
disabled={!canCreate && !$selectedWaypoint}
/>
</div>
</div>
</fieldset>
<div class="flex flex-row gap-2 items-center">
<Button
variant="outline"
disabled={!canCreate && !$selectedWaypoint}
class="grow whitespace-normal h-fit"
on:click={createOrUpdateWaypoint}
>
{#if $selectedWaypoint}
<Save size="16" class="mr-1 shrink-0" />
{$_('menu.metadata.save')}
{:else}
<MapPin size="16" class="mr-1 shrink-0" />
{$_('toolbar.waypoint.create')}
{/if}
</Button>
<Button
variant="outline"
on:click={() => {
selectedWaypoint.set(undefined);
resetWaypointData();
}}
>
<CircleX size="16" />
</Button>
</div>
<Help link={getURLForLanguage($locale, '/help/toolbar/poi')}>
{#if $selectedWaypoint || canCreate}
{$_('toolbar.waypoint.help')}
{:else}
{$_('toolbar.waypoint.help_no_selection')}
{/if}
</Help>
<fieldset class="flex flex-col gap-2">
<Label for="name">{$_('menu.metadata.name')}</Label>
<Input
bind:value={name}
id="name"
class="font-semibold h-8"
disabled={!canCreate && !$selectedWaypoint}
/>
<Label for="description">{$_('menu.metadata.description')}</Label>
<Textarea
bind:value={description}
id="description"
disabled={!canCreate && !$selectedWaypoint}
/>
<Label for="symbol">{$_('toolbar.waypoint.icon')}</Label>
<Select.Root bind:selected={selectedSymbol}>
<Select.Trigger
id="symbol"
class="w-full h-8"
disabled={!canCreate && !$selectedWaypoint}
>
<Select.Value />
</Select.Trigger>
<Select.Content class="max-h-60 overflow-y-scroll">
{#each sortedSymbols as [key, symbol]}
<Select.Item value={symbol.value}>
<span>
{#if symbol.icon}
<svelte:component
this={symbol.icon}
size="14"
class="inline-block align-sub mr-0.5"
/>
{:else}
<span class="w-4 inline-block" />
{/if}
{$_(`gpx.symbol.${key}`)}
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Label for="link">{$_('toolbar.waypoint.link')}</Label>
<Input
bind:value={link}
id="link"
class="h-8"
disabled={!canCreate && !$selectedWaypoint}
/>
<div class="flex flex-row gap-2">
<div class="grow">
<Label for="latitude">{$_('toolbar.waypoint.latitude')}</Label>
<Input
bind:value={latitude}
type="number"
id="latitude"
step={1e-6}
min={-90}
max={90}
class="text-xs h-8"
disabled={!canCreate && !$selectedWaypoint}
/>
</div>
<div class="grow">
<Label for="longitude">{$_('toolbar.waypoint.longitude')}</Label>
<Input
bind:value={longitude}
type="number"
id="longitude"
step={1e-6}
min={-180}
max={180}
class="text-xs h-8"
disabled={!canCreate && !$selectedWaypoint}
/>
</div>
</div>
</fieldset>
<div class="flex flex-row gap-2 items-center">
<Button
variant="outline"
disabled={!canCreate && !$selectedWaypoint}
class="grow whitespace-normal h-fit"
on:click={createOrUpdateWaypoint}
>
{#if $selectedWaypoint}
<Save size="16" class="mr-1 shrink-0" />
{$_('menu.metadata.save')}
{:else}
<MapPin size="16" class="mr-1 shrink-0" />
{$_('toolbar.waypoint.create')}
{/if}
</Button>
<Button
variant="outline"
on:click={() => {
selectedWaypoint.set(undefined);
resetWaypointData();
}}
>
<CircleX size="16" />
</Button>
</div>
<Help link={getURLForLanguage($locale, '/help/toolbar/poi')}>
{#if $selectedWaypoint || canCreate}
{$_('toolbar.waypoint.help')}
{:else}
{$_('toolbar.waypoint.help_no_selection')}
{/if}
</Help>
</div>

View File

@@ -1,249 +1,253 @@
<script lang="ts">
import * as Select from '$lib/components/ui/select';
import { Switch } from '$lib/components/ui/switch';
import { Label } from '$lib/components/ui/label/index.js';
import { Button } from '$lib/components/ui/button';
import Help from '$lib/components/Help.svelte';
import ButtonWithTooltip from '$lib/components/ButtonWithTooltip.svelte';
import Tooltip from '$lib/components/Tooltip.svelte';
import Shortcut from '$lib/components/Shortcut.svelte';
import {
Bike,
Footprints,
Waves,
TrainFront,
Route,
TriangleAlert,
ArrowRightLeft,
Home,
RouteOff,
Repeat,
SquareArrowUpLeft,
SquareArrowOutDownRight
} from 'lucide-svelte';
import * as Select from '$lib/components/ui/select';
import { Switch } from '$lib/components/ui/switch';
import { Label } from '$lib/components/ui/label/index.js';
import { Button } from '$lib/components/ui/button';
import Help from '$lib/components/Help.svelte';
import ButtonWithTooltip from '$lib/components/ButtonWithTooltip.svelte';
import Tooltip from '$lib/components/Tooltip.svelte';
import Shortcut from '$lib/components/Shortcut.svelte';
import {
Bike,
Footprints,
Waves,
TrainFront,
Route,
TriangleAlert,
ArrowRightLeft,
Home,
RouteOff,
Repeat,
SquareArrowUpLeft,
SquareArrowOutDownRight,
} from 'lucide-svelte';
import { map, newGPXFile, routingControls, selectFileWhenLoaded } from '$lib/stores';
import { dbUtils, getFile, getFileIds, settings } from '$lib/db';
import { brouterProfiles, routingProfileSelectItem } from './Routing';
import { map, newGPXFile, routingControls, selectFileWhenLoaded } from '$lib/stores';
import { dbUtils, getFile, getFileIds, settings } from '$lib/db';
import { brouterProfiles, routingProfileSelectItem } from './Routing';
import { _, locale } from 'svelte-i18n';
import { RoutingControls } from './RoutingControls';
import mapboxgl from 'mapbox-gl';
import { fileObservers } from '$lib/db';
import { slide } from 'svelte/transition';
import { getOrderedSelection, selection } from '$lib/components/file-list/Selection';
import {
ListFileItem,
ListRootItem,
ListTrackItem,
ListTrackSegmentItem,
type ListItem
} from '$lib/components/file-list/FileList';
import { flyAndScale, getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
import { onDestroy, onMount } from 'svelte';
import { TrackPoint } from 'gpx';
import { _, locale } from 'svelte-i18n';
import { RoutingControls } from './RoutingControls';
import mapboxgl from 'mapbox-gl';
import { fileObservers } from '$lib/db';
import { slide } from 'svelte/transition';
import { getOrderedSelection, selection } from '$lib/components/file-list/Selection';
import {
ListFileItem,
ListRootItem,
ListTrackItem,
ListTrackSegmentItem,
type ListItem,
} from '$lib/components/file-list/FileList';
import { flyAndScale, getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
import { onDestroy, onMount } from 'svelte';
import { TrackPoint } from 'gpx';
export let minimized = false;
export let minimizable = true;
export let popup: mapboxgl.Popup | undefined = undefined;
export let popupElement: HTMLElement | undefined = undefined;
let selectedItem: ListItem | null = null;
export let minimized = false;
export let minimizable = true;
export let popup: mapboxgl.Popup | undefined = undefined;
export let popupElement: HTMLElement | undefined = undefined;
let selectedItem: ListItem | null = null;
const { privateRoads, routing } = settings;
const { privateRoads, routing } = settings;
$: if ($map && popup && popupElement) {
// remove controls for deleted files
routingControls.forEach((controls, fileId) => {
if (!$fileObservers.has(fileId)) {
controls.destroy();
routingControls.delete(fileId);
$: if ($map && popup && popupElement) {
// remove controls for deleted files
routingControls.forEach((controls, fileId) => {
if (!$fileObservers.has(fileId)) {
controls.destroy();
routingControls.delete(fileId);
if (selectedItem && selectedItem.getFileId() === fileId) {
selectedItem = null;
}
} else if ($map !== controls.map) {
controls.updateMap($map);
}
});
// add controls for new files
$fileObservers.forEach((file, fileId) => {
if (!routingControls.has(fileId)) {
routingControls.set(fileId, new RoutingControls($map, fileId, file, popup, popupElement));
}
});
}
if (selectedItem && selectedItem.getFileId() === fileId) {
selectedItem = null;
}
} else if ($map !== controls.map) {
controls.updateMap($map);
}
});
// add controls for new files
$fileObservers.forEach((file, fileId) => {
if (!routingControls.has(fileId)) {
routingControls.set(
fileId,
new RoutingControls($map, fileId, file, popup, popupElement)
);
}
});
}
$: validSelection = $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
$: validSelection = $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
function createFileWithPoint(e: any) {
if ($selection.size === 0) {
let file = newGPXFile();
file.replaceTrackPoints(0, 0, 0, 0, [
new TrackPoint({
attributes: {
lat: e.lngLat.lat,
lon: e.lngLat.lng
}
})
]);
file._data.id = getFileIds(1)[0];
dbUtils.add(file);
selectFileWhenLoaded(file._data.id);
}
}
function createFileWithPoint(e: any) {
if ($selection.size === 0) {
let file = newGPXFile();
file.replaceTrackPoints(0, 0, 0, 0, [
new TrackPoint({
attributes: {
lat: e.lngLat.lat,
lon: e.lngLat.lng,
},
}),
]);
file._data.id = getFileIds(1)[0];
dbUtils.add(file);
selectFileWhenLoaded(file._data.id);
}
}
onMount(() => {
setCrosshairCursor();
$map?.on('click', createFileWithPoint);
});
onMount(() => {
setCrosshairCursor();
$map?.on('click', createFileWithPoint);
});
onDestroy(() => {
resetCursor();
$map?.off('click', createFileWithPoint);
onDestroy(() => {
resetCursor();
$map?.off('click', createFileWithPoint);
routingControls.forEach((controls) => controls.destroy());
routingControls.clear();
});
routingControls.forEach((controls) => controls.destroy());
routingControls.clear();
});
</script>
{#if minimizable && minimized}
<div class="-m-1.5 -mb-2">
<Button variant="ghost" class="px-1 h-[26px]" on:click={() => (minimized = false)}>
<SquareArrowOutDownRight size="18" />
</Button>
</div>
<div class="-m-1.5 -mb-2">
<Button variant="ghost" class="px-1 h-[26px]" on:click={() => (minimized = false)}>
<SquareArrowOutDownRight size="18" />
</Button>
</div>
{:else}
<div
class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}"
in:flyAndScale={{ x: -2, y: 0, duration: 50 }}
>
<div class="flex flex-col gap-3">
<Label class="flex flex-row justify-between items-center gap-2">
<span class="flex flex-row items-center gap-1">
{#if $routing}
<Route size="16" />
{:else}
<RouteOff size="16" />
{/if}
{$_('toolbar.routing.use_routing')}
</span>
<Tooltip label={$_('toolbar.routing.use_routing_tooltip')}>
<Switch class="scale-90" bind:checked={$routing} />
<Shortcut slot="extra" key="F5" />
</Tooltip>
</Label>
{#if $routing}
<div class="flex flex-col gap-3" in:slide>
<Label class="flex flex-row justify-between items-center gap-2">
<span class="shrink-0 flex flex-row items-center gap-1">
{#if $routingProfileSelectItem.value.includes('bike') || $routingProfileSelectItem.value.includes('motorcycle')}
<Bike size="16" />
{:else if $routingProfileSelectItem.value.includes('foot')}
<Footprints size="16" />
{:else if $routingProfileSelectItem.value.includes('water')}
<Waves size="16" />
{:else if $routingProfileSelectItem.value.includes('railway')}
<TrainFront size="16" />
{/if}
{$_('toolbar.routing.activity')}
</span>
<Select.Root bind:selected={$routingProfileSelectItem}>
<Select.Trigger class="h-8 grow">
<Select.Value />
</Select.Trigger>
<Select.Content>
{#each Object.keys(brouterProfiles) as profile}
<Select.Item value={profile}
>{$_(`toolbar.routing.activities.${profile}`)}</Select.Item
>
{/each}
</Select.Content>
</Select.Root>
</Label>
<Label class="flex flex-row justify-between items-center gap-2">
<span class="flex flex-row gap-1">
<TriangleAlert size="16" />
{$_('toolbar.routing.allow_private')}
</span>
<Switch class="scale-90" bind:checked={$privateRoads} />
</Label>
</div>
{/if}
</div>
<div class="flex flex-row flex-wrap justify-center gap-1">
<ButtonWithTooltip
label={$_('toolbar.routing.reverse.tooltip')}
variant="outline"
class="flex flex-row gap-1 text-xs px-2"
disabled={!validSelection}
on:click={dbUtils.reverseSelection}
>
<ArrowRightLeft size="12" />{$_('toolbar.routing.reverse.button')}
</ButtonWithTooltip>
<ButtonWithTooltip
label={$_('toolbar.routing.route_back_to_start.tooltip')}
variant="outline"
class="flex flex-row gap-1 text-xs px-2"
disabled={!validSelection}
on:click={() => {
const selected = getOrderedSelection();
if (selected.length > 0) {
const firstFileId = selected[0].getFileId();
const firstFile = getFile(firstFileId);
if (firstFile) {
let start = (() => {
if (selected[0] instanceof ListFileItem) {
return firstFile.trk[0]?.trkseg[0]?.trkpt[0];
} else if (selected[0] instanceof ListTrackItem) {
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[0]?.trkpt[0];
} else if (selected[0] instanceof ListTrackSegmentItem) {
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[
selected[0].getSegmentIndex()
]?.trkpt[0];
}
})();
<div
class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}"
in:flyAndScale={{ x: -2, y: 0, duration: 50 }}
>
<div class="flex flex-col gap-3">
<Label class="flex flex-row justify-between items-center gap-2">
<span class="flex flex-row items-center gap-1">
{#if $routing}
<Route size="16" />
{:else}
<RouteOff size="16" />
{/if}
{$_('toolbar.routing.use_routing')}
</span>
<Tooltip label={$_('toolbar.routing.use_routing_tooltip')}>
<Switch class="scale-90" bind:checked={$routing} />
<Shortcut slot="extra" key="F5" />
</Tooltip>
</Label>
{#if $routing}
<div class="flex flex-col gap-3" in:slide>
<Label class="flex flex-row justify-between items-center gap-2">
<span class="shrink-0 flex flex-row items-center gap-1">
{#if $routingProfileSelectItem.value.includes('bike') || $routingProfileSelectItem.value.includes('motorcycle')}
<Bike size="16" />
{:else if $routingProfileSelectItem.value.includes('foot')}
<Footprints size="16" />
{:else if $routingProfileSelectItem.value.includes('water')}
<Waves size="16" />
{:else if $routingProfileSelectItem.value.includes('railway')}
<TrainFront size="16" />
{/if}
{$_('toolbar.routing.activity')}
</span>
<Select.Root bind:selected={$routingProfileSelectItem}>
<Select.Trigger class="h-8 grow">
<Select.Value />
</Select.Trigger>
<Select.Content>
{#each Object.keys(brouterProfiles) as profile}
<Select.Item value={profile}
>{$_(`toolbar.routing.activities.${profile}`)}</Select.Item
>
{/each}
</Select.Content>
</Select.Root>
</Label>
<Label class="flex flex-row justify-between items-center gap-2">
<span class="flex flex-row gap-1">
<TriangleAlert size="16" />
{$_('toolbar.routing.allow_private')}
</span>
<Switch class="scale-90" bind:checked={$privateRoads} />
</Label>
</div>
{/if}
</div>
<div class="flex flex-row flex-wrap justify-center gap-1">
<ButtonWithTooltip
label={$_('toolbar.routing.reverse.tooltip')}
variant="outline"
class="flex flex-row gap-1 text-xs px-2"
disabled={!validSelection}
on:click={dbUtils.reverseSelection}
>
<ArrowRightLeft size="12" />{$_('toolbar.routing.reverse.button')}
</ButtonWithTooltip>
<ButtonWithTooltip
label={$_('toolbar.routing.route_back_to_start.tooltip')}
variant="outline"
class="flex flex-row gap-1 text-xs px-2"
disabled={!validSelection}
on:click={() => {
const selected = getOrderedSelection();
if (selected.length > 0) {
const firstFileId = selected[0].getFileId();
const firstFile = getFile(firstFileId);
if (firstFile) {
let start = (() => {
if (selected[0] instanceof ListFileItem) {
return firstFile.trk[0]?.trkseg[0]?.trkpt[0];
} else if (selected[0] instanceof ListTrackItem) {
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[0]
?.trkpt[0];
} else if (selected[0] instanceof ListTrackSegmentItem) {
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[
selected[0].getSegmentIndex()
]?.trkpt[0];
}
})();
if (start !== undefined) {
const lastFileId = selected[selected.length - 1].getFileId();
routingControls
.get(lastFileId)
?.appendAnchorWithCoordinates(start.getCoordinates());
}
}
}
}}
>
<Home size="12" />{$_('toolbar.routing.route_back_to_start.button')}
</ButtonWithTooltip>
<ButtonWithTooltip
label={$_('toolbar.routing.round_trip.tooltip')}
variant="outline"
class="flex flex-row gap-1 text-xs px-2"
disabled={!validSelection}
on:click={dbUtils.createRoundTripForSelection}
>
<Repeat size="12" />{$_('toolbar.routing.round_trip.button')}
</ButtonWithTooltip>
</div>
<div class="w-full flex flex-row gap-2 items-end justify-between">
<Help link={getURLForLanguage($locale, '/help/toolbar/routing')}>
{#if !validSelection}
{$_('toolbar.routing.help_no_file')}
{:else}
{$_('toolbar.routing.help')}
{/if}
</Help>
<Button
variant="ghost"
class="px-1 h-6"
on:click={() => {
if (minimizable) {
minimized = true;
}
}}
>
<SquareArrowUpLeft size="18" />
</Button>
</div>
</div>
if (start !== undefined) {
const lastFileId = selected[selected.length - 1].getFileId();
routingControls
.get(lastFileId)
?.appendAnchorWithCoordinates(start.getCoordinates());
}
}
}
}}
>
<Home size="12" />{$_('toolbar.routing.route_back_to_start.button')}
</ButtonWithTooltip>
<ButtonWithTooltip
label={$_('toolbar.routing.round_trip.tooltip')}
variant="outline"
class="flex flex-row gap-1 text-xs px-2"
disabled={!validSelection}
on:click={dbUtils.createRoundTripForSelection}
>
<Repeat size="12" />{$_('toolbar.routing.round_trip.button')}
</ButtonWithTooltip>
</div>
<div class="w-full flex flex-row gap-2 items-end justify-between">
<Help link={getURLForLanguage($locale, '/help/toolbar/routing')}>
{#if !validSelection}
{$_('toolbar.routing.help_no_file')}
{:else}
{$_('toolbar.routing.help')}
{/if}
</Help>
<Button
variant="ghost"
class="px-1 h-6"
on:click={() => {
if (minimizable) {
minimized = true;
}
}}
>
<SquareArrowUpLeft size="18" />
</Button>
</div>
</div>
{/if}

View File

@@ -1,9 +1,9 @@
import type { Coordinates } from "gpx";
import { TrackPoint, distance } from "gpx";
import { derived, get, writable } from "svelte/store";
import { settings } from "$lib/db";
import { _, isLoading, locale } from "svelte-i18n";
import { getElevation } from "$lib/utils";
import type { Coordinates } from 'gpx';
import { TrackPoint, distance } from 'gpx';
import { derived, get, writable } from 'svelte/store';
import { settings } from '$lib/db';
import { _, isLoading, locale } from 'svelte-i18n';
import { getElevation } from '$lib/utils';
const { routing, routingProfile, privateRoads } = settings;
@@ -15,22 +15,31 @@ export const brouterProfiles: { [key: string]: string } = {
foot: 'Hiking-Alpine-SAC6',
motorcycle: 'Car-FastEco',
water: 'river',
railway: 'rail'
railway: 'rail',
};
export const routingProfileSelectItem = writable({
value: '',
label: ''
label: '',
});
derived([routingProfile, locale, isLoading], ([profile, l, i]) => [profile, l, i]).subscribe(([profile, l, i]) => {
if (!i && profile !== '' && (profile !== get(routingProfileSelectItem).value || get(_)(`toolbar.routing.activities.${profile}`) !== get(routingProfileSelectItem).label) && l !== null) {
routingProfileSelectItem.update((item) => {
item.value = profile;
item.label = get(_)(`toolbar.routing.activities.${profile}`);
return item;
});
derived([routingProfile, locale, isLoading], ([profile, l, i]) => [profile, l, i]).subscribe(
([profile, l, i]) => {
if (
!i &&
profile !== '' &&
(profile !== get(routingProfileSelectItem).value ||
get(_)(`toolbar.routing.activities.${profile}`) !==
get(routingProfileSelectItem).label) &&
l !== null
) {
routingProfileSelectItem.update((item) => {
item.value = profile;
item.label = get(_)(`toolbar.routing.activities.${profile}`);
return item;
});
}
}
});
);
routingProfileSelectItem.subscribe((item) => {
if (item.value !== '' && item.value !== get(routingProfile)) {
routingProfile.set(item.value);
@@ -45,8 +54,12 @@ export function route(points: Coordinates[]): Promise<TrackPoint[]> {
}
}
async function getRoute(points: Coordinates[], brouterProfile: string, privateRoads: boolean): Promise<TrackPoint[]> {
let url = `https://routing.gpx.studio?lonlats=${points.map(point => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile + (privateRoads ? '-private' : '')}&format=geojson&alternativeidx=0`;
async function getRoute(
points: Coordinates[],
brouterProfile: string,
privateRoads: boolean
): Promise<TrackPoint[]> {
let url = `https://routing.gpx.studio?lonlats=${points.map((point) => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile + (privateRoads ? '-private' : '')}&format=geojson&alternativeidx=0`;
let response = await fetch(url);
@@ -61,25 +74,29 @@ async function getRoute(points: Coordinates[], brouterProfile: string, privateRo
let coordinates = geojson.features[0].geometry.coordinates;
let messages = geojson.features[0].properties.messages;
const lngIdx = messages[0].indexOf("Longitude");
const latIdx = messages[0].indexOf("Latitude");
const tagIdx = messages[0].indexOf("WayTags");
const lngIdx = messages[0].indexOf('Longitude');
const latIdx = messages[0].indexOf('Latitude');
const tagIdx = messages[0].indexOf('WayTags');
let messageIdx = 1;
let tags = messageIdx < messages.length ? getTags(messages[messageIdx][tagIdx]) : {};
for (let i = 0; i < coordinates.length; i++) {
let coord = coordinates[i];
route.push(new TrackPoint({
attributes: {
lat: coord[1],
lon: coord[0]
},
ele: coord[2] ?? (i > 0 ? route[i - 1].ele : 0)
}));
route.push(
new TrackPoint({
attributes: {
lat: coord[1],
lon: coord[0],
},
ele: coord[2] ?? (i > 0 ? route[i - 1].ele : 0),
})
);
if (messageIdx < messages.length &&
if (
messageIdx < messages.length &&
coordinates[i][0] == Number(messages[messageIdx][lngIdx]) / 1000000 &&
coordinates[i][1] == Number(messages[messageIdx][latIdx]) / 1000000) {
coordinates[i][1] == Number(messages[messageIdx][latIdx]) / 1000000
) {
messageIdx++;
if (messageIdx == messages.length) tags = {};
@@ -93,10 +110,10 @@ async function getRoute(points: Coordinates[], brouterProfile: string, privateRo
}
function getTags(message: string): { [key: string]: string } {
const fields = message.split(" ");
const fields = message.split(' ');
let tags: { [key: string]: string } = {};
for (let i = 0; i < fields.length; i++) {
let [key, value] = fields[i].split("=");
let [key, value] = fields[i].split('=');
key = key.replace(/:/g, '_');
tags[key] = value;
}
@@ -107,26 +124,31 @@ function getIntermediatePoints(points: Coordinates[]): Promise<TrackPoint[]> {
let route: TrackPoint[] = [];
let step = 0.05;
for (let i = 0; i < points.length - 1; i++) { // Add intermediate points between each pair of points
for (let i = 0; i < points.length - 1; i++) {
// Add intermediate points between each pair of points
let dist = distance(points[i], points[i + 1]) / 1000;
for (let d = 0; d < dist; d += step) {
let lat = points[i].lat + d / dist * (points[i + 1].lat - points[i].lat);
let lon = points[i].lon + d / dist * (points[i + 1].lon - points[i].lon);
route.push(new TrackPoint({
attributes: {
lat: lat,
lon: lon
}
}));
let lat = points[i].lat + (d / dist) * (points[i + 1].lat - points[i].lat);
let lon = points[i].lon + (d / dist) * (points[i + 1].lon - points[i].lon);
route.push(
new TrackPoint({
attributes: {
lat: lat,
lon: lon,
},
})
);
}
}
route.push(new TrackPoint({
attributes: {
lat: points[points.length - 1].lat,
lon: points[points.length - 1].lon
}
}));
route.push(
new TrackPoint({
attributes: {
lat: points[points.length - 1].lat,
lon: points[points.length - 1].lon,
},
})
);
return getElevation(route).then((elevations) => {
route.forEach((point, i) => {
@@ -134,4 +156,4 @@ function getIntermediatePoints(points: Coordinates[]): Promise<TrackPoint[]> {
});
return route;
});
}
}

View File

@@ -1,37 +1,37 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import Shortcut from '$lib/components/Shortcut.svelte';
import { canChangeStart } from './RoutingControls';
import { CirclePlay, Trash2 } from 'lucide-svelte';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import Shortcut from '$lib/components/Shortcut.svelte';
import { canChangeStart } from './RoutingControls';
import { CirclePlay, Trash2 } from 'lucide-svelte';
import { _ } from 'svelte-i18n';
import { _ } from 'svelte-i18n';
export let element: HTMLElement;
export let element: HTMLElement;
</script>
<div bind:this={element} class="hidden">
<Card.Root class="border-none shadow-md text-base">
<Card.Content class="flex flex-col p-1">
{#if $canChangeStart}
<Button
class="w-full px-2 py-1 h-6 justify-start"
variant="ghost"
on:click={() => element.dispatchEvent(new CustomEvent('change-start'))}
>
<CirclePlay size="16" class="mr-1" />
{$_('toolbar.routing.start_loop_here')}
</Button>
{/if}
<Button
class="w-full px-2 py-1 h-6 justify-start"
variant="ghost"
on:click={() => element.dispatchEvent(new CustomEvent('delete'))}
>
<Trash2 size="16" class="mr-1" />
{$_('menu.delete')}
<Shortcut shift={true} click={true} />
</Button>
</Card.Content>
</Card.Root>
<Card.Root class="border-none shadow-md text-base">
<Card.Content class="flex flex-col p-1">
{#if $canChangeStart}
<Button
class="w-full px-2 py-1 h-6 justify-start"
variant="ghost"
on:click={() => element.dispatchEvent(new CustomEvent('change-start'))}
>
<CirclePlay size="16" class="mr-1" />
{$_('toolbar.routing.start_loop_here')}
</Button>
{/if}
<Button
class="w-full px-2 py-1 h-6 justify-start"
variant="ghost"
on:click={() => element.dispatchEvent(new CustomEvent('delete'))}
>
<Trash2 size="16" class="mr-1" />
{$_('menu.delete')}
<Shortcut shift={true} click={true} />
</Button>
</Card.Content>
</Card.Root>
</div>

View File

@@ -1,14 +1,18 @@
import { distance, type Coordinates, TrackPoint, TrackSegment, Track, projectedPoint } from "gpx";
import { get, writable, type Readable } from "svelte/store";
import mapboxgl from "mapbox-gl";
import { route } from "./Routing";
import { toast } from "svelte-sonner";
import { _ } from "svelte-i18n";
import { dbUtils, settings, type GPXFileWithStatistics } from "$lib/db";
import { getOrderedSelection, selection } from "$lib/components/file-list/Selection";
import { ListFileItem, ListTrackItem, ListTrackSegmentItem } from "$lib/components/file-list/FileList";
import { currentTool, streetViewEnabled, Tool } from "$lib/stores";
import { getClosestLinePoint, resetCursor, setGrabbingCursor } from "$lib/utils";
import { distance, type Coordinates, TrackPoint, TrackSegment, Track, projectedPoint } from 'gpx';
import { get, writable, type Readable } from 'svelte/store';
import mapboxgl from 'mapbox-gl';
import { route } from './Routing';
import { toast } from 'svelte-sonner';
import { _ } from 'svelte-i18n';
import { dbUtils, settings, type GPXFileWithStatistics } from '$lib/db';
import { getOrderedSelection, selection } from '$lib/components/file-list/Selection';
import {
ListFileItem,
ListTrackItem,
ListTrackSegmentItem,
} from '$lib/components/file-list/FileList';
import { currentTool, streetViewEnabled, Tool } from '$lib/stores';
import { getClosestLinePoint, resetCursor, setGrabbingCursor } from '$lib/utils';
const { streetViewSource } = settings;
export const canChangeStart = writable(false);
@@ -28,15 +32,22 @@ export class RoutingControls {
popupElement: HTMLElement;
temporaryAnchor: AnchorWithMarker;
lastDragEvent = 0;
fileUnsubscribe: () => void = () => { };
fileUnsubscribe: () => void = () => {};
unsubscribes: Function[] = [];
toggleAnchorsForZoomLevelAndBoundsBinded: () => void = this.toggleAnchorsForZoomLevelAndBounds.bind(this);
toggleAnchorsForZoomLevelAndBoundsBinded: () => void =
this.toggleAnchorsForZoomLevelAndBounds.bind(this);
showTemporaryAnchorBinded: (e: any) => void = this.showTemporaryAnchor.bind(this);
updateTemporaryAnchorBinded: (e: any) => void = this.updateTemporaryAnchor.bind(this);
appendAnchorBinded: (e: mapboxgl.MapMouseEvent) => void = this.appendAnchor.bind(this);
constructor(map: mapboxgl.Map, fileId: string, file: Readable<GPXFileWithStatistics | undefined>, popup: mapboxgl.Popup, popupElement: HTMLElement) {
constructor(
map: mapboxgl.Map,
fileId: string,
file: Readable<GPXFileWithStatistics | undefined>,
popup: mapboxgl.Popup,
popupElement: HTMLElement
) {
this.map = map;
this.fileId = fileId;
this.file = file;
@@ -46,8 +57,8 @@ export class RoutingControls {
let point = new TrackPoint({
attributes: {
lat: 0,
lon: 0
}
lon: 0,
},
});
this.temporaryAnchor = this.createAnchor(point, new TrackSegment(), 0, 0);
this.temporaryAnchor.marker.getElement().classList.remove('z-10'); // Show below the other markers
@@ -65,7 +76,9 @@ export class RoutingControls {
return;
}
let selected = get(selection).hasAnyChildren(new ListFileItem(this.fileId), true, ['waypoints']);
let selected = get(selection).hasAnyChildren(new ListFileItem(this.fileId), true, [
'waypoints',
]);
if (selected) {
if (this.active) {
this.updateControls();
@@ -88,7 +101,8 @@ export class RoutingControls {
this.fileUnsubscribe = this.file.subscribe(this.updateControls.bind(this));
}
updateControls() { // Update the markers when the file changes
updateControls() {
// Update the markers when the file changes
let file = get(this.file)?.file;
if (!file) {
return;
@@ -96,8 +110,13 @@ export class RoutingControls {
let anchorIndex = 0;
file.forEachSegment((segment, trackIndex, segmentIndex) => {
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
for (let point of segment.trkpt) { // Update the existing anchors (could be improved by matching the existing anchors with the new ones?)
if (
get(selection).hasAnyParent(
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
)
) {
for (let point of segment.trkpt) {
// Update the existing anchors (could be improved by matching the existing anchors with the new ones?)
if (point._data.anchor) {
if (anchorIndex < this.anchors.length) {
this.anchors[anchorIndex].point = point;
@@ -106,7 +125,9 @@ export class RoutingControls {
this.anchors[anchorIndex].segmentIndex = segmentIndex;
this.anchors[anchorIndex].marker.setLngLat(point.getCoordinates());
} else {
this.anchors.push(this.createAnchor(point, segment, trackIndex, segmentIndex));
this.anchors.push(
this.createAnchor(point, segment, trackIndex, segmentIndex)
);
}
anchorIndex++;
}
@@ -114,7 +135,8 @@ export class RoutingControls {
}
});
while (anchorIndex < this.anchors.length) { // Remove the extra anchors
while (anchorIndex < this.anchors.length) {
// Remove the extra anchors
this.anchors.pop()?.marker.remove();
}
@@ -141,14 +163,19 @@ export class RoutingControls {
this.map = map;
}
createAnchor(point: TrackPoint, segment: TrackSegment, trackIndex: number, segmentIndex: number): AnchorWithMarker {
createAnchor(
point: TrackPoint,
segment: TrackSegment,
trackIndex: number,
segmentIndex: number
): AnchorWithMarker {
let element = document.createElement('div');
element.className = `h-5 w-5 xs:h-4 xs:w-4 md:h-3 md:w-3 rounded-full bg-white border-2 border-black cursor-pointer`;
let marker = new mapboxgl.Marker({
draggable: true,
className: 'z-10',
element
element,
}).setLngLat(point.getCoordinates());
let anchor = {
@@ -157,7 +184,7 @@ export class RoutingControls {
trackIndex,
segmentIndex,
marker,
inZoom: false
inZoom: false,
};
marker.on('dragstart', (e) => {
@@ -185,7 +212,8 @@ export class RoutingControls {
e.preventDefault();
e.stopPropagation();
if (Date.now() - this.lastDragEvent < 100) { // Prevent click event during drag
if (Date.now() - this.lastDragEvent < 100) {
// Prevent click event during drag
return;
}
@@ -204,7 +232,12 @@ export class RoutingControls {
return false;
}
let segment = anchor.segment;
if (distance(segment.trkpt[0].getCoordinates(), segment.trkpt[segment.trkpt.length - 1].getCoordinates()) > 1000) {
if (
distance(
segment.trkpt[0].getCoordinates(),
segment.trkpt[segment.trkpt.length - 1].getCoordinates()
) > 1000
) {
return false;
}
return true;
@@ -224,7 +257,8 @@ export class RoutingControls {
};
}
toggleAnchorsForZoomLevelAndBounds() { // Show markers only if they are in the current zoom level and bounds
toggleAnchorsForZoomLevelAndBounds() {
// Show markers only if they are in the current zoom level and bounds
this.shownAnchors.splice(0, this.shownAnchors.length);
let center = this.map.getCenter();
@@ -245,7 +279,8 @@ export class RoutingControls {
}
showTemporaryAnchor(e: any) {
if (this.temporaryAnchor.marker.getElement().classList.contains('cursor-grabbing')) { // Do not not change the source point if it is already being dragged
if (this.temporaryAnchor.marker.getElement().classList.contains('cursor-grabbing')) {
// Do not not change the source point if it is already being dragged
return;
}
@@ -253,7 +288,15 @@ export class RoutingControls {
return;
}
if (!get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, e.features[0].properties.trackIndex, e.features[0].properties.segmentIndex))) {
if (
!get(selection).hasAnyParent(
new ListTrackSegmentItem(
this.fileId,
e.features[0].properties.trackIndex,
e.features[0].properties.segmentIndex
)
)
) {
return;
}
@@ -263,7 +306,7 @@ export class RoutingControls {
this.temporaryAnchor.point.setCoordinates({
lat: e.lngLat.lat,
lon: e.lngLat.lng
lon: e.lngLat.lng,
});
this.temporaryAnchor.marker.setLngLat(e.lngLat).addTo(this.map);
@@ -271,12 +314,17 @@ export class RoutingControls {
}
updateTemporaryAnchor(e: any) {
if (this.temporaryAnchor.marker.getElement().classList.contains('cursor-grabbing')) { // Do not hide if it is being dragged, and stop listening for mousemove
if (this.temporaryAnchor.marker.getElement().classList.contains('cursor-grabbing')) {
// Do not hide if it is being dragged, and stop listening for mousemove
this.map.off('mousemove', this.updateTemporaryAnchorBinded);
return;
}
if (e.point.dist(this.map.project(this.temporaryAnchor.point.getCoordinates())) > 20 || this.temporaryAnchorCloseToOtherAnchor(e)) { // Hide if too far from the layer
if (
e.point.dist(this.map.project(this.temporaryAnchor.point.getCoordinates())) > 20 ||
this.temporaryAnchorCloseToOtherAnchor(e)
) {
// Hide if too far from the layer
this.temporaryAnchor.marker.remove();
this.map.off('mousemove', this.updateTemporaryAnchorBinded);
return;
@@ -294,14 +342,16 @@ export class RoutingControls {
return false;
}
async moveAnchor(anchorWithMarker: AnchorWithMarker) { // Move the anchor and update the route from and to the neighbouring anchors
async moveAnchor(anchorWithMarker: AnchorWithMarker) {
// Move the anchor and update the route from and to the neighbouring anchors
let coordinates = {
lat: anchorWithMarker.marker.getLngLat().lat,
lon: anchorWithMarker.marker.getLngLat().lng
lon: anchorWithMarker.marker.getLngLat().lng,
};
let anchor = anchorWithMarker as Anchor;
if (anchorWithMarker === this.temporaryAnchor) { // Temporary anchor, need to find the closest point of the segment and create an anchor for it
if (anchorWithMarker === this.temporaryAnchor) {
// Temporary anchor, need to find the closest point of the segment and create an anchor for it
this.temporaryAnchor.marker.remove();
anchor = this.getPermanentAnchor();
}
@@ -326,7 +376,8 @@ export class RoutingControls {
let success = await this.routeBetweenAnchors(anchors, targetCoordinates);
if (!success) { // Route failed, revert the anchor to the previous position
if (!success) {
// Route failed, revert the anchor to the previous position
anchorWithMarker.marker.setLngLat(anchorWithMarker.point.getCoordinates());
}
}
@@ -338,16 +389,24 @@ export class RoutingControls {
let minDetails: any = { distance: Number.MAX_VALUE };
let minAnchor = this.temporaryAnchor as Anchor;
file?.forEachSegment((segment, trackIndex, segmentIndex) => {
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
if (
get(selection).hasAnyParent(
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
)
) {
let details: any = {};
let closest = getClosestLinePoint(segment.trkpt, this.temporaryAnchor.point, details);
let closest = getClosestLinePoint(
segment.trkpt,
this.temporaryAnchor.point,
details
);
if (details.distance < minDetails.distance) {
minDetails = details;
minAnchor = {
point: closest,
segment,
trackIndex,
segmentIndex
segmentIndex,
};
}
}
@@ -374,41 +433,67 @@ export class RoutingControls {
point: this.temporaryAnchor.point,
trackIndex: -1,
segmentIndex: -1,
trkptIndex: -1
trkptIndex: -1,
};
file?.forEachSegment((segment, trackIndex, segmentIndex) => {
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
if (
get(selection).hasAnyParent(
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
)
) {
let details: any = {};
getClosestLinePoint(segment.trkpt, this.temporaryAnchor.point, details);
if (details.distance < minDetails.distance) {
minDetails = details;
let before = details.before ? details.index : details.index - 1;
let projectedPt = projectedPoint(segment.trkpt[before], segment.trkpt[before + 1], this.temporaryAnchor.point);
let ratio = distance(segment.trkpt[before], projectedPt) / distance(segment.trkpt[before], segment.trkpt[before + 1]);
let projectedPt = projectedPoint(
segment.trkpt[before],
segment.trkpt[before + 1],
this.temporaryAnchor.point
);
let ratio =
distance(segment.trkpt[before], projectedPt) /
distance(segment.trkpt[before], segment.trkpt[before + 1]);
let point = segment.trkpt[before].clone();
point.setCoordinates(projectedPt);
point.ele = (1 - ratio) * (segment.trkpt[before].ele ?? 0) + ratio * (segment.trkpt[before + 1].ele ?? 0);
point.time = (segment.trkpt[before].time && segment.trkpt[before + 1].time) ? new Date((1 - ratio) * segment.trkpt[before].time.getTime() + ratio * segment.trkpt[before + 1].time.getTime()) : undefined;
point.ele =
(1 - ratio) * (segment.trkpt[before].ele ?? 0) +
ratio * (segment.trkpt[before + 1].ele ?? 0);
point.time =
segment.trkpt[before].time && segment.trkpt[before + 1].time
? new Date(
(1 - ratio) * segment.trkpt[before].time.getTime() +
ratio * segment.trkpt[before + 1].time.getTime()
)
: undefined;
point._data = {
anchor: true,
zoom: 0
zoom: 0,
};
minInfo = {
point,
trackIndex,
segmentIndex,
trkptIndex: before + 1
trkptIndex: before + 1,
};
}
}
});
if (minInfo.trackIndex !== -1) {
dbUtils.applyToFile(this.fileId, (file) => file.replaceTrackPoints(minInfo.trackIndex, minInfo.segmentIndex, minInfo.trkptIndex, minInfo.trkptIndex - 1, [minInfo.point]));
dbUtils.applyToFile(this.fileId, (file) =>
file.replaceTrackPoints(
minInfo.trackIndex,
minInfo.segmentIndex,
minInfo.trkptIndex,
minInfo.trkptIndex - 1,
[minInfo.point]
)
);
}
}
@@ -416,22 +501,46 @@ export class RoutingControls {
return () => this.deleteAnchor(anchor);
}
async deleteAnchor(anchor: Anchor) { // Remove the anchor and route between the neighbouring anchors if they exist
async deleteAnchor(anchor: Anchor) {
// Remove the anchor and route between the neighbouring anchors if they exist
this.popup.remove();
let [previousAnchor, nextAnchor] = this.getNeighbouringAnchors(anchor);
if (previousAnchor === null && nextAnchor === null) { // Only one point, remove it
dbUtils.applyToFile(this.fileId, (file) => file.replaceTrackPoints(anchor.trackIndex, anchor.segmentIndex, 0, 0, []));
} else if (previousAnchor === null) { // First point, remove trackpoints until nextAnchor
dbUtils.applyToFile(this.fileId, (file) => file.replaceTrackPoints(anchor.trackIndex, anchor.segmentIndex, 0, nextAnchor.point._data.index - 1, []));
} else if (nextAnchor === null) { // Last point, remove trackpoints from previousAnchor
if (previousAnchor === null && nextAnchor === null) {
// Only one point, remove it
dbUtils.applyToFile(this.fileId, (file) =>
file.replaceTrackPoints(anchor.trackIndex, anchor.segmentIndex, 0, 0, [])
);
} else if (previousAnchor === null) {
// First point, remove trackpoints until nextAnchor
dbUtils.applyToFile(this.fileId, (file) =>
file.replaceTrackPoints(
anchor.trackIndex,
anchor.segmentIndex,
0,
nextAnchor.point._data.index - 1,
[]
)
);
} else if (nextAnchor === null) {
// Last point, remove trackpoints from previousAnchor
dbUtils.applyToFile(this.fileId, (file) => {
let segment = file.getSegment(anchor.trackIndex, anchor.segmentIndex);
file.replaceTrackPoints(anchor.trackIndex, anchor.segmentIndex, previousAnchor.point._data.index + 1, segment.trkpt.length - 1, []);
file.replaceTrackPoints(
anchor.trackIndex,
anchor.segmentIndex,
previousAnchor.point._data.index + 1,
segment.trkpt.length - 1,
[]
);
});
} else { // Route between previousAnchor and nextAnchor
this.routeBetweenAnchors([previousAnchor, nextAnchor], [previousAnchor.point.getCoordinates(), nextAnchor.point.getCoordinates()]);
} else {
// Route between previousAnchor and nextAnchor
this.routeBetweenAnchors(
[previousAnchor, nextAnchor],
[previousAnchor.point.getCoordinates(), nextAnchor.point.getCoordinates()]
);
}
}
@@ -447,27 +556,43 @@ export class RoutingControls {
return;
}
let speed = fileWithStats.statistics.getStatisticsFor(new ListTrackSegmentItem(this.fileId, anchor.trackIndex, anchor.segmentIndex)).global.speed.moving;
let speed = fileWithStats.statistics.getStatisticsFor(
new ListTrackSegmentItem(this.fileId, anchor.trackIndex, anchor.segmentIndex)
).global.speed.moving;
let segment = anchor.segment;
dbUtils.applyToFile(this.fileId, (file) => {
file.replaceTrackPoints(anchor.trackIndex, anchor.segmentIndex, segment.trkpt.length, segment.trkpt.length - 1, segment.trkpt.slice(0, anchor.point._data.index), speed > 0 ? speed : undefined);
file.crop(anchor.point._data.index, anchor.point._data.index + segment.trkpt.length - 1, [anchor.trackIndex], [anchor.segmentIndex]);
file.replaceTrackPoints(
anchor.trackIndex,
anchor.segmentIndex,
segment.trkpt.length,
segment.trkpt.length - 1,
segment.trkpt.slice(0, anchor.point._data.index),
speed > 0 ? speed : undefined
);
file.crop(
anchor.point._data.index,
anchor.point._data.index + segment.trkpt.length - 1,
[anchor.trackIndex],
[anchor.segmentIndex]
);
});
}
async appendAnchor(e: mapboxgl.MapMouseEvent) { // Add a new anchor to the end of the last segment
async appendAnchor(e: mapboxgl.MapMouseEvent) {
// Add a new anchor to the end of the last segment
if (get(streetViewEnabled) && get(streetViewSource) === 'google') {
return;
}
this.appendAnchorWithCoordinates({
lat: e.lngLat.lat,
lon: e.lngLat.lng
lon: e.lngLat.lng,
});
}
async appendAnchorWithCoordinates(coordinates: Coordinates) { // Add a new anchor to the end of the last segment
async appendAnchorWithCoordinates(coordinates: Coordinates) {
// Add a new anchor to the end of the last segment
let selected = getOrderedSelection();
if (selected.length === 0 || selected[selected.length - 1].getFileId() !== this.fileId) {
return;
@@ -477,7 +602,7 @@ export class RoutingControls {
let lastAnchor = this.anchors[this.anchors.length - 1];
let newPoint = new TrackPoint({
attributes: coordinates
attributes: coordinates,
});
newPoint._data.anchor = true;
newPoint._data.zoom = 0;
@@ -488,7 +613,10 @@ export class RoutingControls {
if (item instanceof ListTrackItem || item instanceof ListTrackSegmentItem) {
trackIndex = item.getTrackIndex();
}
let segmentIndex = (file.trk.length > 0 && file.trk[trackIndex].trkseg.length > 0) ? file.trk[trackIndex].trkseg.length - 1 : 0;
let segmentIndex =
file.trk.length > 0 && file.trk[trackIndex].trkseg.length > 0
? file.trk[trackIndex].trkseg.length - 1
: 0;
if (item instanceof ListTrackSegmentItem) {
segmentIndex = item.getSegmentIndex();
}
@@ -512,10 +640,13 @@ export class RoutingControls {
point: newPoint,
segment: lastAnchor.segment,
trackIndex: lastAnchor.trackIndex,
segmentIndex: lastAnchor.segmentIndex
segmentIndex: lastAnchor.segmentIndex,
};
await this.routeBetweenAnchors([lastAnchor, newAnchor], [lastAnchor.point.getCoordinates(), newAnchor.point.getCoordinates()]);
await this.routeBetweenAnchors(
[lastAnchor, newAnchor],
[lastAnchor.point.getCoordinates(), newAnchor.point.getCoordinates()]
);
}
getNeighbouringAnchors(anchor: Anchor): [Anchor | null, Anchor | null] {
@@ -525,11 +656,17 @@ export class RoutingControls {
for (let i = 0; i < this.anchors.length; i++) {
if (this.anchors[i].segment === anchor.segment && this.anchors[i].inZoom) {
if (this.anchors[i].point._data.index < anchor.point._data.index) {
if (!previousAnchor || this.anchors[i].point._data.index > previousAnchor.point._data.index) {
if (
!previousAnchor ||
this.anchors[i].point._data.index > previousAnchor.point._data.index
) {
previousAnchor = this.anchors[i];
}
} else if (this.anchors[i].point._data.index > anchor.point._data.index) {
if (!nextAnchor || this.anchors[i].point._data.index < nextAnchor.point._data.index) {
if (
!nextAnchor ||
this.anchors[i].point._data.index < nextAnchor.point._data.index
) {
nextAnchor = this.anchors[i];
}
}
@@ -539,7 +676,10 @@ export class RoutingControls {
return [previousAnchor, nextAnchor];
}
async routeBetweenAnchors(anchors: Anchor[], targetCoordinates: Coordinates[]): Promise<boolean> {
async routeBetweenAnchors(
anchors: Anchor[],
targetCoordinates: Coordinates[]
): Promise<boolean> {
let segment = anchors[0].segment;
let fileWithStats = get(this.file);
@@ -547,10 +687,15 @@ export class RoutingControls {
return false;
}
if (anchors.length === 1) { // Only one anchor, update the point in the segment
dbUtils.applyToFile(this.fileId, (file) => file.replaceTrackPoints(anchors[0].trackIndex, anchors[0].segmentIndex, 0, 0, [new TrackPoint({
attributes: targetCoordinates[0],
})]));
if (anchors.length === 1) {
// Only one anchor, update the point in the segment
dbUtils.applyToFile(this.fileId, (file) =>
file.replaceTrackPoints(anchors[0].trackIndex, anchors[0].segmentIndex, 0, 0, [
new TrackPoint({
attributes: targetCoordinates[0],
}),
])
);
return true;
}
@@ -559,23 +704,28 @@ export class RoutingControls {
response = await route(targetCoordinates);
} catch (e: any) {
if (e.message.includes('from-position not mapped in existing datafile')) {
toast.error(get(_)("toolbar.routing.error.from"));
toast.error(get(_)('toolbar.routing.error.from'));
} else if (e.message.includes('via1-position not mapped in existing datafile')) {
toast.error(get(_)("toolbar.routing.error.via"));
toast.error(get(_)('toolbar.routing.error.via'));
} else if (e.message.includes('to-position not mapped in existing datafile')) {
toast.error(get(_)("toolbar.routing.error.to"));
toast.error(get(_)('toolbar.routing.error.to'));
} else if (e.message.includes('Time-out')) {
toast.error(get(_)("toolbar.routing.error.timeout"));
toast.error(get(_)('toolbar.routing.error.timeout'));
} else {
toast.error(e.message);
}
return false;
}
if (anchors[0].point._data.index === 0) { // First anchor is the first point of the segment
if (anchors[0].point._data.index === 0) {
// First anchor is the first point of the segment
anchors[0].point = response[0]; // replace the first anchor
anchors[0].point._data.index = 0;
} else if (anchors[0].point._data.index === segment.trkpt.length - 1 && distance(anchors[0].point.getCoordinates(), response[0].getCoordinates()) < 1) { // First anchor is the last point of the segment, and the new point is close enough
} else if (
anchors[0].point._data.index === segment.trkpt.length - 1 &&
distance(anchors[0].point.getCoordinates(), response[0].getCoordinates()) < 1
) {
// First anchor is the last point of the segment, and the new point is close enough
anchors[0].point = response[0]; // replace the first anchor
anchors[0].point._data.index = segment.trkpt.length - 1;
} else {
@@ -583,7 +733,8 @@ export class RoutingControls {
response.splice(0, 0, anchors[0].point); // Insert it in the response to keep it
}
if (anchors[anchors.length - 1].point._data.index === segment.trkpt.length - 1) { // Last anchor is the last point of the segment
if (anchors[anchors.length - 1].point._data.index === segment.trkpt.length - 1) {
// Last anchor is the last point of the segment
anchors[anchors.length - 1].point = response[response.length - 1]; // replace the last anchor
anchors[anchors.length - 1].point._data.index = segment.trkpt.length - 1;
} else {
@@ -594,7 +745,7 @@ export class RoutingControls {
for (let i = 1; i < anchors.length - 1; i++) {
// Find the closest point to the intermediate anchor
// and transfer the marker to that point
anchors[i].point = getClosestLinePoint(response.slice(1, - 1), targetCoordinates[i]);
anchors[i].point = getClosestLinePoint(response.slice(1, -1), targetCoordinates[i]);
}
anchors.forEach((anchor) => {
@@ -602,36 +753,64 @@ export class RoutingControls {
anchor.point._data.zoom = 0; // Make these anchors permanent
});
let stats = fileWithStats.statistics.getStatisticsFor(new ListTrackSegmentItem(this.fileId, anchors[0].trackIndex, anchors[0].segmentIndex));
let stats = fileWithStats.statistics.getStatisticsFor(
new ListTrackSegmentItem(this.fileId, anchors[0].trackIndex, anchors[0].segmentIndex)
);
let speed: number | undefined = undefined;
let startTime = anchors[0].point.time;
if (stats.global.speed.moving > 0) {
let replacingDistance = 0;
for (let i = 1; i < response.length; i++) {
replacingDistance += distance(response[i - 1].getCoordinates(), response[i].getCoordinates()) / 1000;
replacingDistance +=
distance(response[i - 1].getCoordinates(), response[i].getCoordinates()) / 1000;
}
let replacedDistance = stats.local.distance.moving[anchors[anchors.length - 1].point._data.index] - stats.local.distance.moving[anchors[0].point._data.index];
let replacedDistance =
stats.local.distance.moving[anchors[anchors.length - 1].point._data.index] -
stats.local.distance.moving[anchors[0].point._data.index];
let newDistance = stats.global.distance.moving + replacingDistance - replacedDistance;
let newTime = newDistance / stats.global.speed.moving * 3600;
let newTime = (newDistance / stats.global.speed.moving) * 3600;
let remainingTime = stats.global.time.moving - (stats.local.time.moving[anchors[anchors.length - 1].point._data.index] - stats.local.time.moving[anchors[0].point._data.index]);
let remainingTime =
stats.global.time.moving -
(stats.local.time.moving[anchors[anchors.length - 1].point._data.index] -
stats.local.time.moving[anchors[0].point._data.index]);
let replacingTime = newTime - remainingTime;
if (replacingTime <= 0) { // Fallback to simple time difference
replacingTime = stats.local.time.total[anchors[anchors.length - 1].point._data.index] - stats.local.time.total[anchors[0].point._data.index];
if (replacingTime <= 0) {
// Fallback to simple time difference
replacingTime =
stats.local.time.total[anchors[anchors.length - 1].point._data.index] -
stats.local.time.total[anchors[0].point._data.index];
}
speed = replacingDistance / replacingTime * 3600;
speed = (replacingDistance / replacingTime) * 3600;
if (startTime === undefined) { // Replacing the first point
if (startTime === undefined) {
// Replacing the first point
let endIndex = anchors[anchors.length - 1].point._data.index;
startTime = new Date((segment.trkpt[endIndex].time?.getTime() ?? 0) - (replacingTime + stats.local.time.total[endIndex] - stats.local.time.moving[endIndex]) * 1000);
startTime = new Date(
(segment.trkpt[endIndex].time?.getTime() ?? 0) -
(replacingTime +
stats.local.time.total[endIndex] -
stats.local.time.moving[endIndex]) *
1000
);
}
}
dbUtils.applyToFile(this.fileId, (file) => file.replaceTrackPoints(anchors[0].trackIndex, anchors[0].segmentIndex, anchors[0].point._data.index, anchors[anchors.length - 1].point._data.index, response, speed, startTime));
dbUtils.applyToFile(this.fileId, (file) =>
file.replaceTrackPoints(
anchors[0].trackIndex,
anchors[0].segmentIndex,
anchors[0].point._data.index,
anchors[anchors.length - 1].point._data.index,
response,
speed,
startTime
)
);
return true;
}

View File

@@ -1,4 +1,4 @@
import { ramerDouglasPeucker, type GPXFile, type TrackSegment } from "gpx";
import { ramerDouglasPeucker, type GPXFile, type TrackSegment } from 'gpx';
const earthRadius = 6371008.8;
@@ -17,7 +17,8 @@ export function updateAnchorPoints(file: GPXFile) {
let segments = file.getSegments();
for (let segment of segments) {
if (!segment._data.anchors) { // New segment, compute anchor points for it
if (!segment._data.anchors) {
// New segment, compute anchor points for it
computeAnchorPoints(segment);
continue;
}
@@ -42,4 +43,3 @@ function computeAnchorPoints(segment: TrackSegment) {
});
segment._data.anchors = true;
}

View File

@@ -1,146 +1,151 @@
<script lang="ts" context="module">
export enum SplitType {
FILES = 'files',
TRACKS = 'tracks',
SEGMENTS = 'segments'
}
export enum SplitType {
FILES = 'files',
TRACKS = 'tracks',
SEGMENTS = 'segments',
}
</script>
<script lang="ts">
import Help from '$lib/components/Help.svelte';
import { ListRootItem } from '$lib/components/file-list/FileList';
import { selection } from '$lib/components/file-list/Selection';
import { Label } from '$lib/components/ui/label/index.js';
import { Button } from '$lib/components/ui/button';
import { Slider } from '$lib/components/ui/slider';
import * as Select from '$lib/components/ui/select';
import { Separator } from '$lib/components/ui/separator';
import { gpxStatistics, map, slicedGPXStatistics, splitAs } from '$lib/stores';
import { get } from 'svelte/store';
import { _, locale } from 'svelte-i18n';
import { onDestroy, tick } from 'svelte';
import { Crop } from 'lucide-svelte';
import { dbUtils } from '$lib/db';
import { SplitControls } from './SplitControls';
import { getURLForLanguage } from '$lib/utils';
import Help from '$lib/components/Help.svelte';
import { ListRootItem } from '$lib/components/file-list/FileList';
import { selection } from '$lib/components/file-list/Selection';
import { Label } from '$lib/components/ui/label/index.js';
import { Button } from '$lib/components/ui/button';
import { Slider } from '$lib/components/ui/slider';
import * as Select from '$lib/components/ui/select';
import { Separator } from '$lib/components/ui/separator';
import { gpxStatistics, map, slicedGPXStatistics, splitAs } from '$lib/stores';
import { get } from 'svelte/store';
import { _, locale } from 'svelte-i18n';
import { onDestroy, tick } from 'svelte';
import { Crop } from 'lucide-svelte';
import { dbUtils } from '$lib/db';
import { SplitControls } from './SplitControls';
import { getURLForLanguage } from '$lib/utils';
let splitControls: SplitControls | undefined = undefined;
let canCrop = false;
let splitControls: SplitControls | undefined = undefined;
let canCrop = false;
$: if ($map) {
if (splitControls) {
splitControls.destroy();
}
splitControls = new SplitControls($map);
}
$: if ($map) {
if (splitControls) {
splitControls.destroy();
}
splitControls = new SplitControls($map);
}
$: validSelection =
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) &&
$gpxStatistics.local.points.length > 0;
$: validSelection =
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) &&
$gpxStatistics.local.points.length > 0;
let maxSliderValue = 1;
let sliderValues = [0, 1];
let maxSliderValue = 1;
let sliderValues = [0, 1];
function updateCanCrop() {
canCrop = sliderValues[0] != 0 || sliderValues[1] != maxSliderValue;
}
function updateCanCrop() {
canCrop = sliderValues[0] != 0 || sliderValues[1] != maxSliderValue;
}
function updateSlicedGPXStatistics() {
if (validSelection && canCrop) {
$slicedGPXStatistics = [
get(gpxStatistics).slice(sliderValues[0], sliderValues[1]),
sliderValues[0],
sliderValues[1]
];
} else {
$slicedGPXStatistics = undefined;
}
}
function updateSlicedGPXStatistics() {
if (validSelection && canCrop) {
$slicedGPXStatistics = [
get(gpxStatistics).slice(sliderValues[0], sliderValues[1]),
sliderValues[0],
sliderValues[1],
];
} else {
$slicedGPXStatistics = undefined;
}
}
function updateSliderValues() {
if ($slicedGPXStatistics !== undefined) {
sliderValues = [$slicedGPXStatistics[1], $slicedGPXStatistics[2]];
}
}
function updateSliderValues() {
if ($slicedGPXStatistics !== undefined) {
sliderValues = [$slicedGPXStatistics[1], $slicedGPXStatistics[2]];
}
}
async function updateSliderLimits() {
if (validSelection && $gpxStatistics.local.points.length > 0) {
maxSliderValue = $gpxStatistics.local.points.length - 1;
} else {
maxSliderValue = 1;
}
await tick();
sliderValues = [0, maxSliderValue];
}
async function updateSliderLimits() {
if (validSelection && $gpxStatistics.local.points.length > 0) {
maxSliderValue = $gpxStatistics.local.points.length - 1;
} else {
maxSliderValue = 1;
}
await tick();
sliderValues = [0, maxSliderValue];
}
$: if ($gpxStatistics.local.points.length - 1 != maxSliderValue) {
updateSliderLimits();
}
$: if ($gpxStatistics.local.points.length - 1 != maxSliderValue) {
updateSliderLimits();
}
$: if (sliderValues) {
updateCanCrop();
updateSlicedGPXStatistics();
}
$: if (sliderValues) {
updateCanCrop();
updateSlicedGPXStatistics();
}
$: if (
$slicedGPXStatistics !== undefined &&
($slicedGPXStatistics[1] !== sliderValues[0] || $slicedGPXStatistics[2] !== sliderValues[1])
) {
updateSliderValues();
updateCanCrop();
}
$: if (
$slicedGPXStatistics !== undefined &&
($slicedGPXStatistics[1] !== sliderValues[0] || $slicedGPXStatistics[2] !== sliderValues[1])
) {
updateSliderValues();
updateCanCrop();
}
const splitTypes = [
{ value: SplitType.FILES, label: $_('gpx.files') },
{ value: SplitType.TRACKS, label: $_('gpx.tracks') },
{ value: SplitType.SEGMENTS, label: $_('gpx.segments') }
];
const splitTypes = [
{ value: SplitType.FILES, label: $_('gpx.files') },
{ value: SplitType.TRACKS, label: $_('gpx.tracks') },
{ value: SplitType.SEGMENTS, label: $_('gpx.segments') },
];
let splitType = splitTypes.find((type) => type.value === $splitAs) ?? splitTypes[0];
let splitType = splitTypes.find((type) => type.value === $splitAs) ?? splitTypes[0];
$: splitAs.set(splitType.value);
$: splitAs.set(splitType.value);
onDestroy(() => {
$slicedGPXStatistics = undefined;
if (splitControls) {
splitControls.destroy();
splitControls = undefined;
}
});
onDestroy(() => {
$slicedGPXStatistics = undefined;
if (splitControls) {
splitControls.destroy();
splitControls = undefined;
}
});
</script>
<div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
<div class="p-2">
<Slider bind:value={sliderValues} max={maxSliderValue} step={1} disabled={!validSelection} />
</div>
<Button
variant="outline"
disabled={!validSelection || !canCrop}
on:click={() => dbUtils.cropSelection(sliderValues[0], sliderValues[1])}
>
<Crop size="16" class="mr-1" />{$_('toolbar.scissors.crop')}
</Button>
<Separator />
<Label class="flex flex-row flex-wrap gap-3 items-center">
<span class="shrink-0">
{$_('toolbar.scissors.split_as')}
</span>
<Select.Root bind:selected={splitType}>
<Select.Trigger class="h-8 w-fit grow">
<Select.Value />
</Select.Trigger>
<Select.Content>
{#each splitTypes as { value, label }}
<Select.Item {value}>{label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Label>
<Help link={getURLForLanguage($locale, '/help/toolbar/scissors')}>
{#if validSelection}
{$_('toolbar.scissors.help')}
{:else}
{$_('toolbar.scissors.help_invalid_selection')}
{/if}
</Help>
<div class="p-2">
<Slider
bind:value={sliderValues}
max={maxSliderValue}
step={1}
disabled={!validSelection}
/>
</div>
<Button
variant="outline"
disabled={!validSelection || !canCrop}
on:click={() => dbUtils.cropSelection(sliderValues[0], sliderValues[1])}
>
<Crop size="16" class="mr-1" />{$_('toolbar.scissors.crop')}
</Button>
<Separator />
<Label class="flex flex-row flex-wrap gap-3 items-center">
<span class="shrink-0">
{$_('toolbar.scissors.split_as')}
</span>
<Select.Root bind:selected={splitType}>
<Select.Trigger class="h-8 w-fit grow">
<Select.Value />
</Select.Trigger>
<Select.Content>
{#each splitTypes as { value, label }}
<Select.Item {value}>{label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Label>
<Help link={getURLForLanguage($locale, '/help/toolbar/scissors')}>
{#if validSelection}
{$_('toolbar.scissors.help')}
{:else}
{$_('toolbar.scissors.help_invalid_selection')}
{/if}
</Help>
</div>

View File

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

View File

@@ -1,11 +1,58 @@
import Dexie, { liveQuery } from 'dexie';
import { GPXFile, GPXStatistics, Track, TrackSegment, Waypoint, TrackPoint, type Coordinates, distance, type LineStyleExtension, type WaypointType } from 'gpx';
import { enableMapSet, enablePatches, applyPatches, type Patch, type WritableDraft, freeze, produceWithPatches } from 'immer';
import {
GPXFile,
GPXStatistics,
Track,
TrackSegment,
Waypoint,
TrackPoint,
type Coordinates,
distance,
type LineStyleExtension,
type WaypointType,
} from 'gpx';
import {
enableMapSet,
enablePatches,
applyPatches,
type Patch,
type WritableDraft,
freeze,
produceWithPatches,
} from 'immer';
import { writable, get, derived, type Readable, type Writable } from 'svelte/store';
import { gpxStatistics, initTargetMapBounds, map, splitAs, updateAllHidden, updateTargetMapBounds } from './stores';
import { defaultBasemap, defaultBasemapTree, defaultOverlayTree, defaultOverlays, type CustomLayer, defaultOpacities, defaultOverpassQueries, defaultOverpassTree } from './assets/layers';
import { applyToOrderedItemsFromFile, applyToOrderedSelectedItemsFromFile, selection } from '$lib/components/file-list/Selection';
import { ListFileItem, ListItem, ListTrackItem, ListLevel, ListTrackSegmentItem, ListWaypointItem, ListRootItem } from '$lib/components/file-list/FileList';
import {
gpxStatistics,
initTargetMapBounds,
map,
splitAs,
updateAllHidden,
updateTargetMapBounds,
} from './stores';
import {
defaultBasemap,
defaultBasemapTree,
defaultOverlayTree,
defaultOverlays,
type CustomLayer,
defaultOpacities,
defaultOverpassQueries,
defaultOverpassTree,
} from './assets/layers';
import {
applyToOrderedItemsFromFile,
applyToOrderedSelectedItemsFromFile,
selection,
} from '$lib/components/file-list/Selection';
import {
ListFileItem,
ListItem,
ListTrackItem,
ListLevel,
ListTrackSegmentItem,
ListWaypointItem,
ListRootItem,
} from '$lib/components/file-list/FileList';
import { updateAnchorPoints } from '$lib/components/toolbar/tools/routing/Simplify';
import { SplitType } from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
import { getClosestLinePoint, getElevation } from '$lib/utils';
@@ -15,17 +62,22 @@ enableMapSet();
enablePatches();
class Database extends Dexie {
fileids!: Dexie.Table<string, string>;
files!: Dexie.Table<GPXFile, string>;
patches!: Dexie.Table<{ patch: Patch[], inversePatch: Patch[], index: number }, number>;
patches!: Dexie.Table<{ patch: Patch[]; inversePatch: Patch[]; index: number }, number>;
settings!: Dexie.Table<any, string>;
overpasstiles!: Dexie.Table<{ query: string, x: number, y: number, time: number }, [string, number, number]>;
overpassdata!: Dexie.Table<{ query: string, id: number, poi: GeoJSON.Feature }, [string, number]>;
overpasstiles!: Dexie.Table<
{ query: string; x: number; y: number; time: number },
[string, number, number]
>;
overpassdata!: Dexie.Table<
{ query: string; id: number; poi: GeoJSON.Feature },
[string, number]
>;
constructor() {
super("Database", {
cache: 'immutable'
super('Database', {
cache: 'immutable',
});
this.version(1).stores({
fileids: ',&fileid',
@@ -41,10 +93,15 @@ class Database extends Dexie {
export const db = new Database();
// Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber, and updates to the store are pushed to the DB
export function bidirectionalDexieStore<K, V>(table: Dexie.Table<V, K>, key: K, initial: V, initialize: boolean = true): Writable<V | undefined> {
export function bidirectionalDexieStore<K, V>(
table: Dexie.Table<V, K>,
key: K,
initial: V,
initialize: boolean = true
): Writable<V | undefined> {
let first = true;
let store = writable<V | undefined>(initialize ? initial : undefined);
liveQuery(() => table.get(key)).subscribe(value => {
liveQuery(() => table.get(key)).subscribe((value) => {
if (value === undefined) {
if (first) {
if (!initialize) {
@@ -70,11 +127,15 @@ export function bidirectionalDexieStore<K, V>(table: Dexie.Table<V, K>, key: K,
if (typeof newValue === 'object' || newValue !== get(store)) {
table.put(newValue, key);
}
}
},
};
}
export function dexieSettingStore<T>(key: string, initial: T, initialize: boolean = true): Writable<T> {
export function dexieSettingStore<T>(
key: string,
initial: T,
initialize: boolean = true
): Writable<T> {
return bidirectionalDexieStore(db.settings, key, initial, initialize);
}
@@ -96,7 +157,11 @@ export const settings = {
currentOverlays: dexieSettingStore('currentOverlays', defaultOverlays, false),
previousOverlays: dexieSettingStore('previousOverlays', defaultOverlays),
selectedOverlayTree: dexieSettingStore('selectedOverlayTree', defaultOverlayTree),
currentOverpassQueries: dexieSettingStore('currentOverpassQueries', defaultOverpassQueries, false),
currentOverpassQueries: dexieSettingStore(
'currentOverpassQueries',
defaultOverpassQueries,
false
),
selectedOverpassTree: dexieSettingStore('selectedOverpassTree', defaultOverpassTree),
opacities: dexieSettingStore('opacities', defaultOpacities),
customLayers: dexieSettingStore<Record<string, CustomLayer>>('customLayers', {}),
@@ -107,7 +172,7 @@ export const settings = {
streetViewSource: dexieSettingStore('streetViewSource', 'mapillary'),
fileOrder: dexieSettingStore<string[]>('fileOrder', []),
defaultOpacity: dexieSettingStore('defaultOpacity', 0.7),
defaultWidth: dexieSettingStore('defaultWidth', (browser && window.innerWidth < 600) ? 8 : 5),
defaultWidth: dexieSettingStore('defaultWidth', browser && window.innerWidth < 600 ? 8 : 5),
bottomPanelSize: dexieSettingStore('bottomPanelSize', 170),
rightPanelSize: dexieSettingStore('rightPanelSize', 240),
};
@@ -115,7 +180,7 @@ export const settings = {
// Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber
function dexieStore<T>(querier: () => T | Promise<T>, initial?: T): Readable<T> {
let store = writable<T>(initial);
liveQuery(querier).subscribe(value => {
liveQuery(querier).subscribe((value) => {
if (value !== undefined) {
store.set(value);
}
@@ -149,7 +214,7 @@ export class GPXStatisticsTree {
let statistics = new GPXStatistics();
let id = item.getIdAtLevel(this.level);
if (id === undefined || id === 'waypoints') {
Object.keys(this.statistics).forEach(key => {
Object.keys(this.statistics).forEach((key) => {
if (this.statistics[key] instanceof GPXStatistics) {
statistics.mergeWith(this.statistics[key]);
} else {
@@ -166,26 +231,30 @@ export class GPXStatisticsTree {
}
return statistics;
}
};
export type GPXFileWithStatistics = { file: GPXFile, statistics: GPXStatisticsTree };
}
export type GPXFileWithStatistics = { file: GPXFile; statistics: GPXStatisticsTree };
// Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber, also takes care of the conversion to a GPXFile object
function dexieGPXFileStore(id: string): Readable<GPXFileWithStatistics> & { destroy: () => void } {
let store = writable<GPXFileWithStatistics>(undefined);
let query = liveQuery(() => db.files.get(id)).subscribe(value => {
let query = liveQuery(() => db.files.get(id)).subscribe((value) => {
if (value !== undefined) {
let gpx = new GPXFile(value);
updateAnchorPoints(gpx);
let statistics = new GPXStatisticsTree(gpx);
if (!fileState.has(id)) { // Update the map bounds for new files
updateTargetMapBounds(id, statistics.getStatisticsFor(new ListFileItem(id)).global.bounds);
if (!fileState.has(id)) {
// Update the map bounds for new files
updateTargetMapBounds(
id,
statistics.getStatisticsFor(new ListFileItem(id)).global.bounds
);
}
fileState.set(id, gpx);
store.set({
file: gpx,
statistics
statistics,
});
if (get(selection).hasAnyChildren(new ListFileItem(id))) {
@@ -198,7 +267,7 @@ function dexieGPXFileStore(id: string): Readable<GPXFileWithStatistics> & { dest
destroy: () => {
fileState.delete(id);
query.unsubscribe();
}
},
};
}
@@ -210,22 +279,30 @@ function updateSelection(updatedFiles: GPXFile[], deletedFileIds: string[]) {
if (file) {
items.forEach((item) => {
if (item instanceof ListTrackItem) {
let newTrackIndex = file.trk.findIndex((track) => track._data.trackIndex === item.getTrackIndex());
let newTrackIndex = file.trk.findIndex(
(track) => track._data.trackIndex === item.getTrackIndex()
);
if (newTrackIndex === -1) {
removedItems.push(item);
}
} else if (item instanceof ListTrackSegmentItem) {
let newTrackIndex = file.trk.findIndex((track) => track._data.trackIndex === item.getTrackIndex());
let newTrackIndex = file.trk.findIndex(
(track) => track._data.trackIndex === item.getTrackIndex()
);
if (newTrackIndex === -1) {
removedItems.push(item);
} else {
let newSegmentIndex = file.trk[newTrackIndex].trkseg.findIndex((segment) => segment._data.segmentIndex === item.getSegmentIndex());
let newSegmentIndex = file.trk[newTrackIndex].trkseg.findIndex(
(segment) => segment._data.segmentIndex === item.getSegmentIndex()
);
if (newSegmentIndex === -1) {
removedItems.push(item);
}
}
} else if (item instanceof ListWaypointItem) {
let newWaypointIndex = file.wpt.findIndex((wpt) => wpt._data.index === item.getWaypointIndex());
let newWaypointIndex = file.wpt.findIndex(
(wpt) => wpt._data.index === item.getWaypointIndex()
);
if (newWaypointIndex === -1) {
removedItems.push(item);
}
@@ -255,9 +332,10 @@ function updateSelection(updatedFiles: GPXFile[], deletedFileIds: string[]) {
// Commit the changes to the file state to the database
function commitFileStateChange(newFileState: ReadonlyMap<string, GPXFile>, patch: Patch[]) {
let changedFileIds = getChangedFileIds(patch);
let updatedFileIds: string[] = [], deletedFileIds: string[] = [];
let updatedFileIds: string[] = [],
deletedFileIds: string[] = [];
changedFileIds.forEach(id => {
changedFileIds.forEach((id) => {
if (newFileState.has(id)) {
updatedFileIds.push(id);
} else {
@@ -265,8 +343,10 @@ function commitFileStateChange(newFileState: ReadonlyMap<string, GPXFile>, patch
}
});
let updatedFiles = updatedFileIds.map(id => newFileState.get(id)).filter(file => file !== undefined) as GPXFile[];
updatedFileIds = updatedFiles.map(file => file._data.id);
let updatedFiles = updatedFileIds
.map((id) => newFileState.get(id))
.filter((file) => file !== undefined) as GPXFile[];
updatedFileIds = updatedFiles.map((file) => file._data.id);
updateSelection(updatedFiles, deletedFileIds);
@@ -282,13 +362,15 @@ function commitFileStateChange(newFileState: ReadonlyMap<string, GPXFile>, patch
});
}
export const fileObservers: Writable<Map<string, Readable<GPXFileWithStatistics | undefined> & { destroy?: () => void }>> = writable(new Map());
export const fileObservers: Writable<
Map<string, Readable<GPXFileWithStatistics | undefined> & { destroy?: () => void }>
> = writable(new Map());
const fileState: Map<string, GPXFile> = new Map(); // Used to generate patches
// Observe the file ids in the database, and maintain a map of file observers for the corresponding files
export function observeFilesFromDatabase(fitBounds: boolean) {
let initialize = true;
liveQuery(() => db.fileids.toArray()).subscribe(dbFileIds => {
liveQuery(() => db.fileids.toArray()).subscribe((dbFileIds) => {
if (initialize) {
if (fitBounds && dbFileIds.length > 0) {
initTargetMapBounds(dbFileIds);
@@ -296,17 +378,21 @@ export function observeFilesFromDatabase(fitBounds: boolean) {
initialize = false;
}
// Find new files to observe
let newFiles = dbFileIds.filter(id => !get(fileObservers).has(id)).sort((a, b) => parseInt(a.split('-')[1]) - parseInt(b.split('-')[1]));
let newFiles = dbFileIds
.filter((id) => !get(fileObservers).has(id))
.sort((a, b) => parseInt(a.split('-')[1]) - parseInt(b.split('-')[1]));
// Find deleted files to stop observing
let deletedFiles = Array.from(get(fileObservers).keys()).filter(id => !dbFileIds.find(fileId => fileId === id));
let deletedFiles = Array.from(get(fileObservers).keys()).filter(
(id) => !dbFileIds.find((fileId) => fileId === id)
);
// Update the store
if (newFiles.length > 0 || deletedFiles.length > 0) {
fileObservers.update($files => {
newFiles.forEach(id => {
fileObservers.update(($files) => {
newFiles.forEach((id) => {
$files.set(id, dexieGPXFileStore(id));
});
deletedFiles.forEach(id => {
deletedFiles.forEach((id) => {
$files.get(id)?.destroy?.();
$files.delete(id);
});
@@ -341,15 +427,28 @@ export function getStatistics(fileId: string): GPXStatisticsTree | undefined {
}
const patchIndex: Readable<number> = dexieStore(() => db.settings.get('patchIndex'), -1);
const patchMinMaxIndex: Readable<{ min: number, max: number }> = dexieStore(() => db.patches.orderBy(':id').keys().then(keys => {
if (keys.length === 0) {
return { min: 0, max: 0 };
} else {
return { min: keys[0], max: keys[keys.length - 1] + 1 };
}
}), { min: 0, max: 0 });
export const canUndo: Readable<boolean> = derived([patchIndex, patchMinMaxIndex], ([$patchIndex, $patchMinMaxIndex]) => $patchIndex >= $patchMinMaxIndex.min);
export const canRedo: Readable<boolean> = derived([patchIndex, patchMinMaxIndex], ([$patchIndex, $patchMinMaxIndex]) => $patchIndex < $patchMinMaxIndex.max - 1);
const patchMinMaxIndex: Readable<{ min: number; max: number }> = dexieStore(
() =>
db.patches
.orderBy(':id')
.keys()
.then((keys) => {
if (keys.length === 0) {
return { min: 0, max: 0 };
} else {
return { min: keys[0], max: keys[keys.length - 1] + 1 };
}
}),
{ min: 0, max: 0 }
);
export const canUndo: Readable<boolean> = derived(
[patchIndex, patchMinMaxIndex],
([$patchIndex, $patchMinMaxIndex]) => $patchIndex >= $patchMinMaxIndex.min
);
export const canRedo: Readable<boolean> = derived(
[patchIndex, patchMinMaxIndex],
([$patchIndex, $patchMinMaxIndex]) => $patchIndex < $patchMinMaxIndex.max - 1
);
// Helper function to apply a callback to the global file state
function applyGlobal(callback: (files: Map<string, GPXFile>) => void) {
@@ -377,7 +476,12 @@ function applyToFiles(fileIds: string[], callback: (file: WritableDraft<GPXFile>
}
// Helper function to apply different callbacks to multiple files
function applyEachToFilesAndGlobal(fileIds: string[], callbacks: ((file: WritableDraft<GPXFile>, context?: any) => void)[], globalCallback: (files: Map<string, GPXFile>, context?: any) => void, context?: any) {
function applyEachToFilesAndGlobal(
fileIds: string[],
callbacks: ((file: WritableDraft<GPXFile>, context?: any) => void)[],
globalCallback: (files: Map<string, GPXFile>, context?: any) => void,
context?: any
) {
const [newFileState, patch, inversePatch] = produceWithPatches(fileState, (draft) => {
fileIds.forEach((fileId, index) => {
let file = draft.get(fileId);
@@ -400,16 +504,22 @@ async function storePatches(patch: Patch[], inversePatch: Patch[]) {
db.patches.where(':id').above(get(patchIndex)).delete(); // Delete all patches after the current patch to avoid redoing them
let minmax = get(patchMinMaxIndex);
if (minmax.max - minmax.min + 1 > MAX_PATCHES) {
db.patches.where(':id').belowOrEqual(get(patchMinMaxIndex).max - MAX_PATCHES).delete();
db.patches
.where(':id')
.belowOrEqual(get(patchMinMaxIndex).max - MAX_PATCHES)
.delete();
}
}
db.transaction('rw', db.patches, db.settings, async () => {
let index = get(patchIndex) + 1;
await db.patches.put({
patch,
inversePatch,
await db.patches.put(
{
patch,
inversePatch,
index,
},
index
}, index);
);
await db.settings.put(index, 'patchIndex');
});
}
@@ -467,7 +577,12 @@ export const dbUtils = {
applyToFiles: (ids: string[], callback: (file: WritableDraft<GPXFile>) => void) => {
applyToFiles(ids, callback);
},
applyEachToFilesAndGlobal: (ids: string[], callbacks: ((file: WritableDraft<GPXFile>, context?: any) => void)[], globalCallback: (files: Map<string, GPXFile>, context?: any) => void, context?: any) => {
applyEachToFilesAndGlobal: (
ids: string[],
callbacks: ((file: WritableDraft<GPXFile>, context?: any) => void)[],
globalCallback: (files: Map<string, GPXFile>, context?: any) => void,
context?: any
) => {
applyEachToFilesAndGlobal(ids, callbacks, globalCallback, context);
},
duplicateSelection: () => {
@@ -491,20 +606,33 @@ export const dbUtils = {
if (level === ListLevel.TRACK) {
for (let item of items) {
let trackIndex = (item as ListTrackItem).getTrackIndex();
file.replaceTracks(trackIndex + 1, trackIndex, [file.trk[trackIndex].clone()]);
file.replaceTracks(trackIndex + 1, trackIndex, [
file.trk[trackIndex].clone(),
]);
}
} else if (level === ListLevel.SEGMENT) {
for (let item of items) {
let trackIndex = (item as ListTrackSegmentItem).getTrackIndex();
let segmentIndex = (item as ListTrackSegmentItem).getSegmentIndex();
file.replaceTrackSegments(trackIndex, segmentIndex + 1, segmentIndex, [file.trk[trackIndex].trkseg[segmentIndex].clone()]);
file.replaceTrackSegments(
trackIndex,
segmentIndex + 1,
segmentIndex,
[file.trk[trackIndex].trkseg[segmentIndex].clone()]
);
}
} else if (level === ListLevel.WAYPOINTS) {
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, file.wpt.map((wpt) => wpt.clone()));
file.replaceWaypoints(
file.wpt.length,
file.wpt.length - 1,
file.wpt.map((wpt) => wpt.clone())
);
} else if (level === ListLevel.WAYPOINT) {
for (let item of items) {
let waypointIndex = (item as ListWaypointItem).getWaypointIndex();
file.replaceWaypoints(waypointIndex + 1, waypointIndex, [file.wpt[waypointIndex].clone()]);
file.replaceWaypoints(waypointIndex + 1, waypointIndex, [
file.wpt[waypointIndex].clone(),
]);
}
}
}
@@ -513,16 +641,23 @@ export const dbUtils = {
});
},
addNewTrack: (fileId: string) => {
dbUtils.applyToFile(fileId, (file) => file.replaceTracks(file.trk.length, file.trk.length, [new Track()]));
dbUtils.applyToFile(fileId, (file) =>
file.replaceTracks(file.trk.length, file.trk.length, [new Track()])
);
},
addNewSegment: (fileId: string, trackIndex: number) => {
dbUtils.applyToFile(fileId, (file) => {
let track = file.trk[trackIndex];
track.replaceTrackSegments(track.trkseg.length, track.trkseg.length, [new TrackSegment()]);
track.replaceTrackSegments(track.trkseg.length, track.trkseg.length, [
new TrackSegment(),
]);
});
},
reverseSelection: () => {
if (!get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints']) || get(gpxStatistics).local.points?.length <= 1) {
if (
!get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints']) ||
get(gpxStatistics).local.points?.length <= 1
) {
return;
}
applyGlobal((draft) => {
@@ -579,13 +714,13 @@ export const dbUtils = {
let target: ListItem = new ListRootItem();
let targetFile: GPXFile | undefined = undefined;
let toMerge: {
trk: Track[],
trkseg: TrackSegment[],
wpt: Waypoint[]
trk: Track[];
trkseg: TrackSegment[];
wpt: Waypoint[];
} = {
trk: [],
trkseg: [],
wpt: []
wpt: [],
};
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
let file = draft.get(fileId);
@@ -608,8 +743,15 @@ export const dbUtils = {
if (level === ListLevel.TRACK) {
items.forEach((item, index) => {
let trackIndex = (item as ListTrackItem).getTrackIndex();
toMerge.trkseg.splice(0, 0, ...originalFile.trk[trackIndex].trkseg.map((segment) => segment.clone()));
if (index === items.length - 1) { // Order is reversed, so the last track is the first one and the one to keep
toMerge.trkseg.splice(
0,
0,
...originalFile.trk[trackIndex].trkseg.map((segment) =>
segment.clone()
)
);
if (index === items.length - 1) {
// Order is reversed, so the last track is the first one and the one to keep
target = item;
file.trk[trackIndex].trkseg = [];
} else {
@@ -620,10 +762,15 @@ export const dbUtils = {
items.forEach((item, index) => {
let trackIndex = (item as ListTrackSegmentItem).getTrackIndex();
let segmentIndex = (item as ListTrackSegmentItem).getSegmentIndex();
if (index === items.length - 1) { // Order is reversed, so the last segment is the first one and the one to keep
if (index === items.length - 1) {
// Order is reversed, so the last segment is the first one and the one to keep
target = item;
}
toMerge.trkseg.splice(0, 0, originalFile.trk[trackIndex].trkseg[segmentIndex].clone());
toMerge.trkseg.splice(
0,
0,
originalFile.trk[trackIndex].trkseg[segmentIndex].clone()
);
file.trk[trackIndex].trkseg.splice(segmentIndex, 1);
});
}
@@ -635,15 +782,24 @@ export const dbUtils = {
if (mergeTraces) {
let statistics = get(gpxStatistics);
let speed = statistics.global.speed.moving > 0 ? statistics.global.speed.moving : undefined;
let speed =
statistics.global.speed.moving > 0 ? statistics.global.speed.moving : undefined;
let startTime: Date | undefined = undefined;
if (speed !== undefined) {
if (statistics.local.points.length > 0 && statistics.local.points[0].time !== undefined) {
if (
statistics.local.points.length > 0 &&
statistics.local.points[0].time !== undefined
) {
startTime = statistics.local.points[0].time;
} else {
let index = statistics.local.points.findIndex((point) => point.time !== undefined);
let index = statistics.local.points.findIndex(
(point) => point.time !== undefined
);
if (index !== -1) {
startTime = new Date(statistics.local.points[index].time.getTime() - 1000 * 3600 * statistics.local.distance.total[index] / speed);
startTime = new Date(
statistics.local.points[index].time.getTime() -
(1000 * 3600 * statistics.local.distance.total[index]) / speed
);
}
}
}
@@ -652,7 +808,14 @@ export const dbUtils = {
let s = new TrackSegment();
toMerge.trk.map((track) => {
track.trkseg.forEach((segment) => {
s.replaceTrackPoints(s.trkpt.length, s.trkpt.length, segment.trkpt.slice(), speed, startTime, removeGaps);
s.replaceTrackPoints(
s.trkpt.length,
s.trkpt.length,
segment.trkpt.slice(),
speed,
startTime,
removeGaps
);
});
});
toMerge.trk = [toMerge.trk[0]];
@@ -661,7 +824,14 @@ export const dbUtils = {
if (toMerge.trkseg.length > 0) {
let s = new TrackSegment();
toMerge.trkseg.forEach((segment) => {
s.replaceTrackPoints(s.trkpt.length, s.trkpt.length, segment.trkpt.slice(), speed, startTime, removeGaps);
s.replaceTrackPoints(
s.trkpt.length,
s.trkpt.length,
segment.trkpt.slice(),
speed,
startTime,
removeGaps
);
});
toMerge.trkseg = [s];
}
@@ -677,7 +847,12 @@ export const dbUtils = {
} else if (target instanceof ListTrackSegmentItem) {
let trackIndex = target.getTrackIndex();
let segmentIndex = target.getSegmentIndex();
targetFile.replaceTrackSegments(trackIndex, segmentIndex, segmentIndex - 1, toMerge.trkseg);
targetFile.replaceTrackSegments(
trackIndex,
segmentIndex,
segmentIndex - 1,
toMerge.trkseg
);
}
}
});
@@ -700,11 +875,15 @@ export const dbUtils = {
start -= length;
end -= length;
} else if (level === ListLevel.TRACK) {
let trackIndices = items.map((item) => (item as ListTrackItem).getTrackIndex());
let trackIndices = items.map((item) =>
(item as ListTrackItem).getTrackIndex()
);
file.crop(start, end, trackIndices);
} else if (level === ListLevel.SEGMENT) {
let trackIndices = [(items[0] as ListTrackSegmentItem).getTrackIndex()];
let segmentIndices = items.map((item) => (item as ListTrackSegmentItem).getSegmentIndex());
let segmentIndices = items.map((item) =>
(item as ListTrackSegmentItem).getSegmentIndex()
);
file.crop(start, end, trackIndices, segmentIndices);
}
}
@@ -724,14 +903,17 @@ export const dbUtils = {
return {
wptIndex: wptIndex,
index: [0],
distance: Number.MAX_VALUE
distance: Number.MAX_VALUE,
};
})
});
file.trk.forEach((track, index) => {
track.getSegments().forEach((segment) => {
segment.trkpt.forEach((point) => {
file.wpt.forEach((wpt, wptIndex) => {
let dist = distance(point.getCoordinates(), wpt.getCoordinates());
let dist = distance(
point.getCoordinates(),
wpt.getCoordinates()
);
if (dist < closest[wptIndex].distance) {
closest[wptIndex].distance = dist;
closest[wptIndex].index = [index];
@@ -739,7 +921,7 @@ export const dbUtils = {
closest[wptIndex].index.push(index);
}
});
})
});
});
});
@@ -754,9 +936,16 @@ export const dbUtils = {
return t;
});
newFile.replaceTracks(0, file.trk.length - 1, tracks);
newFile.replaceWaypoints(0, file.wpt.length - 1, closest.filter((c) => c.index.includes(index)).map((c) => file.wpt[c.wptIndex]));
newFile.replaceWaypoints(
0,
file.wpt.length - 1,
closest
.filter((c) => c.index.includes(index))
.map((c) => file.wpt[c.wptIndex])
);
newFile._data.id = fileIds[index];
newFile.metadata.name = track.name ?? `${file.metadata.name} (${index + 1})`;
newFile.metadata.name =
track.name ?? `${file.metadata.name} (${index + 1})`;
draft.set(newFile._data.id, freeze(newFile));
});
} else if (file.trk.length === 1) {
@@ -766,13 +955,16 @@ export const dbUtils = {
return {
wptIndex: wptIndex,
index: [0],
distance: Number.MAX_VALUE
distance: Number.MAX_VALUE,
};
})
});
file.trk[0].trkseg.forEach((segment, index) => {
segment.trkpt.forEach((point) => {
file.wpt.forEach((wpt, wptIndex) => {
let dist = distance(point.getCoordinates(), wpt.getCoordinates());
let dist = distance(
point.getCoordinates(),
wpt.getCoordinates()
);
if (dist < closest[wptIndex].distance) {
closest[wptIndex].distance = dist;
closest[wptIndex].index = [index];
@@ -785,8 +977,16 @@ export const dbUtils = {
file.trk[0].trkseg.forEach((segment, index) => {
let newFile = file.clone();
newFile.replaceTrackSegments(0, 0, file.trk[0].trkseg.length - 1, [segment]);
newFile.replaceWaypoints(0, file.wpt.length - 1, closest.filter((c) => c.index.includes(index)).map((c) => file.wpt[c.wptIndex]));
newFile.replaceTrackSegments(0, 0, file.trk[0].trkseg.length - 1, [
segment,
]);
newFile.replaceWaypoints(
0,
file.wpt.length - 1,
closest
.filter((c) => c.index.includes(index))
.map((c) => file.wpt[c.wptIndex])
);
newFile._data.id = fileIds[index];
newFile.metadata.name = `${file.trk[0].name ?? file.metadata.name} (${index + 1})`;
draft.set(newFile._data.id, freeze(newFile));
@@ -815,7 +1015,13 @@ export const dbUtils = {
});
});
},
split(fileId: string, trackIndex: number, segmentIndex: number, coordinates: Coordinates, trkptIndex?: number) {
split(
fileId: string,
trackIndex: number,
segmentIndex: number,
coordinates: Coordinates,
trkptIndex?: number
) {
let splitType = get(splitAs);
return applyGlobal((draft) => {
let file = getFile(fileId);
@@ -833,7 +1039,10 @@ export const dbUtils = {
let absoluteIndex = minIndex;
file.forEachSegment((seg, trkIndex, segIndex) => {
if ((trkIndex < trackIndex && splitType === SplitType.FILES) || (trkIndex === trackIndex && segIndex < segmentIndex)) {
if (
(trkIndex < trackIndex && splitType === SplitType.FILES) ||
(trkIndex === trackIndex && segIndex < segmentIndex)
) {
absoluteIndex += seg.trkpt.length;
}
});
@@ -863,13 +1072,21 @@ export const dbUtils = {
start.crop(0, minIndex);
let end = segment.clone();
end.crop(minIndex, segment.trkpt.length - 1);
newFile.replaceTrackSegments(trackIndex, segmentIndex, segmentIndex, [start, end]);
newFile.replaceTrackSegments(trackIndex, segmentIndex, segmentIndex, [
start,
end,
]);
}
}
}
});
},
cleanSelection: (bounds: [Coordinates, Coordinates], inside: boolean, deleteTrackPoints: boolean, deleteWaypoints: boolean) => {
cleanSelection: (
bounds: [Coordinates, Coordinates],
inside: boolean,
deleteTrackPoints: boolean,
deleteWaypoints: boolean
) => {
if (get(selection).size === 0) {
return;
}
@@ -880,16 +1097,35 @@ export const dbUtils = {
if (level === ListLevel.FILE) {
file.clean(bounds, inside, deleteTrackPoints, deleteWaypoints);
} else if (level === ListLevel.TRACK) {
let trackIndices = items.map((item) => (item as ListTrackItem).getTrackIndex());
file.clean(bounds, inside, deleteTrackPoints, deleteWaypoints, trackIndices);
let trackIndices = items.map((item) =>
(item as ListTrackItem).getTrackIndex()
);
file.clean(
bounds,
inside,
deleteTrackPoints,
deleteWaypoints,
trackIndices
);
} else if (level === ListLevel.SEGMENT) {
let trackIndices = [(items[0] as ListTrackSegmentItem).getTrackIndex()];
let segmentIndices = items.map((item) => (item as ListTrackSegmentItem).getSegmentIndex());
file.clean(bounds, inside, deleteTrackPoints, deleteWaypoints, trackIndices, segmentIndices);
let segmentIndices = items.map((item) =>
(item as ListTrackSegmentItem).getSegmentIndex()
);
file.clean(
bounds,
inside,
deleteTrackPoints,
deleteWaypoints,
trackIndices,
segmentIndices
);
} else if (level === ListLevel.WAYPOINTS) {
file.clean(bounds, inside, false, deleteWaypoints);
} else if (level === ListLevel.WAYPOINT) {
let waypointIndices = items.map((item) => (item as ListWaypointItem).getWaypointIndex());
let waypointIndices = items.map((item) =>
(item as ListWaypointItem).getWaypointIndex()
);
file.clean(bounds, inside, false, deleteWaypoints, [], [], waypointIndices);
}
}
@@ -911,7 +1147,15 @@ export const dbUtils = {
let segmentIndex = item.getSegmentIndex();
let points = itemsAndPoints.get(item);
if (points) {
file.replaceTrackPoints(trackIndex, segmentIndex, 0, file.trk[trackIndex].trkseg[segmentIndex].getNumberOfTrackPoints() - 1, points);
file.replaceTrackPoints(
trackIndex,
segmentIndex,
0,
file.trk[trackIndex].trkseg[
segmentIndex
].getNumberOfTrackPoints() - 1,
points
);
}
}
}
@@ -938,9 +1182,11 @@ export const dbUtils = {
});
} else {
let fileIds = new Set<string>();
get(selection).getSelected().forEach((item) => {
fileIds.add(item.getFileId());
});
get(selection)
.getSelected()
.forEach((item) => {
fileIds.add(item.getFileId());
});
let wpt = new Waypoint(waypoint);
wpt.ele = elevation[0];
dbUtils.applyToFiles(Array.from(fileIds), (file) =>
@@ -984,16 +1230,22 @@ export const dbUtils = {
if (level === ListLevel.FILE) {
file.setHidden(hidden);
} else if (level === ListLevel.TRACK) {
let trackIndices = items.map((item) => (item as ListTrackItem).getTrackIndex());
let trackIndices = items.map((item) =>
(item as ListTrackItem).getTrackIndex()
);
file.setHidden(hidden, trackIndices);
} else if (level === ListLevel.SEGMENT) {
let trackIndices = [(items[0] as ListTrackSegmentItem).getTrackIndex()];
let segmentIndices = items.map((item) => (item as ListTrackSegmentItem).getSegmentIndex());
let segmentIndices = items.map((item) =>
(item as ListTrackSegmentItem).getSegmentIndex()
);
file.setHidden(hidden, trackIndices, segmentIndices);
} else if (level === ListLevel.WAYPOINTS) {
file.setHiddenWaypoints(hidden);
} else if (level === ListLevel.WAYPOINT) {
let waypointIndices = items.map((item) => (item as ListWaypointItem).getWaypointIndex());
let waypointIndices = items.map((item) =>
(item as ListWaypointItem).getWaypointIndex()
);
file.setHiddenWaypoints(hidden, waypointIndices);
}
}
@@ -1020,7 +1272,12 @@ export const dbUtils = {
for (let item of items) {
let trackIndex = (item as ListTrackSegmentItem).getTrackIndex();
let segmentIndex = (item as ListTrackSegmentItem).getSegmentIndex();
file.replaceTrackSegments(trackIndex, segmentIndex, segmentIndex, []);
file.replaceTrackSegments(
trackIndex,
segmentIndex,
segmentIndex,
[]
);
}
} else if (level === ListLevel.WAYPOINTS) {
file.replaceWaypoints(0, file.wpt.length - 1, []);
@@ -1053,14 +1310,18 @@ export const dbUtils = {
});
} else if (level === ListLevel.SEGMENT) {
let trackIndex = (items[0] as ListTrackSegmentItem).getTrackIndex();
let segmentIndices = items.map((item) => (item as ListTrackSegmentItem).getSegmentIndex());
let segmentIndices = items.map((item) =>
(item as ListTrackSegmentItem).getSegmentIndex()
);
segmentIndices.forEach((segmentIndex) => {
points.push(...file.trk[trackIndex].trkseg[segmentIndex].getTrackPoints());
});
} else if (level === ListLevel.WAYPOINTS) {
points.push(...file.wpt);
} else if (level === ListLevel.WAYPOINT) {
let waypointIndices = items.map((item) => (item as ListWaypointItem).getWaypointIndex());
let waypointIndices = items.map((item) =>
(item as ListWaypointItem).getWaypointIndex()
);
points.push(...waypointIndices.map((waypointIndex) => file.wpt[waypointIndex]));
}
}
@@ -1078,16 +1339,22 @@ export const dbUtils = {
if (level === ListLevel.FILE) {
file.addElevation(elevations);
} else if (level === ListLevel.TRACK) {
let trackIndices = items.map((item) => (item as ListTrackItem).getTrackIndex());
let trackIndices = items.map((item) =>
(item as ListTrackItem).getTrackIndex()
);
file.addElevation(elevations, trackIndices, undefined, []);
} else if (level === ListLevel.SEGMENT) {
let trackIndices = [(items[0] as ListTrackSegmentItem).getTrackIndex()];
let segmentIndices = items.map((item) => (item as ListTrackSegmentItem).getSegmentIndex());
let segmentIndices = items.map((item) =>
(item as ListTrackSegmentItem).getSegmentIndex()
);
file.addElevation(elevations, trackIndices, segmentIndices, []);
} else if (level === ListLevel.WAYPOINTS) {
file.addElevation(elevations, [], [], undefined);
} else if (level === ListLevel.WAYPOINT) {
let waypointIndices = items.map((item) => (item as ListWaypointItem).getWaypointIndex());
let waypointIndices = items.map((item) =>
(item as ListWaypointItem).getWaypointIndex()
);
file.addElevation(elevations, [], [], waypointIndices);
}
}
@@ -1114,7 +1381,7 @@ export const dbUtils = {
undo: () => {
if (get(canUndo)) {
let index = get(patchIndex);
db.patches.get(index).then(patch => {
db.patches.get(index).then((patch) => {
if (patch) {
applyPatch(patch.inversePatch);
db.settings.put(index - 1, 'patchIndex');
@@ -1125,12 +1392,12 @@ export const dbUtils = {
redo: () => {
if (get(canRedo)) {
let index = get(patchIndex) + 1;
db.patches.get(index).then(patch => {
db.patches.get(index).then((patch) => {
if (patch) {
applyPatch(patch.patch);
db.settings.put(index, 'patchIndex');
}
});
}
}
}
},
};

View File

@@ -1,10 +1,10 @@
export const languages: Record<string, string> = {
'en': 'English',
'es': 'Español',
'de': 'Deutsch',
'fr': 'Français',
'it': 'Italiano',
'nl': 'Nederlands',
en: 'English',
es: 'Español',
de: 'Deutsch',
fr: 'Français',
it: 'Italiano',
nl: 'Nederlands',
'pt-BR': 'Português (Brasil)',
'zh': '简体中文',
};
zh: '简体中文',
};

View File

@@ -10,13 +10,19 @@ function generateSitemap() {
const pages = glob.sync('**/*.html', { cwd: 'build' }).map((page) => `/${page}`);
let sitemap = '<?xml version="1.0" encoding="UTF-8"?>\n';
sitemap += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">\n';
sitemap +=
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">\n';
pages.forEach((page) => {
const path = page.replace('/index.html', '').replace('.html', '');
const rootDir = path.split('/')[1];
if (path.includes('embed') || path.includes('404') || languages[path] || languages[rootDir]) {
if (
path.includes('embed') ||
path.includes('404') ||
languages[path] ||
languages[rootDir]
) {
// Skip localized pages
return;
}
@@ -40,4 +46,4 @@ function generateSitemap() {
return sitemap;
}
fs.writeFileSync('build/sitemap.xml', generateSitemap());
fs.writeFileSync('build/sitemap.xml', generateSitemap());

View File

@@ -11,7 +11,7 @@ import {
applyToOrderedSelectedItemsFromFile,
selectFile,
selectItem,
selection
selection,
} from '$lib/components/file-list/Selection';
import {
ListFileItem,
@@ -19,7 +19,7 @@ import {
ListTrackItem,
ListTrackSegmentItem,
ListWaypointItem,
ListWaypointsItem
ListWaypointsItem,
} from '$lib/components/file-list/FileList';
import type { RoutingControls } from '$lib/components/toolbar/tools/routing/RoutingControls';
import { SplitType } from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
@@ -43,7 +43,10 @@ export function updateGPXData() {
if (stats) {
let first = true;
items.forEach((item) => {
if (!(item instanceof ListWaypointItem || item instanceof ListWaypointsItem) || first) {
if (
!(item instanceof ListWaypointItem || item instanceof ListWaypointsItem) ||
first
) {
statistics.mergeWith(stats.getStatisticsFor(item));
first = false;
}
@@ -110,7 +113,8 @@ derived([targetMapBounds, map], (x) => x).subscribe(([bounds, $map]) => {
let currentZoom = $map.getZoom();
let currentBounds = $map.getBounds();
if (bounds.total !== get(fileObservers).size &&
if (
bounds.total !== get(fileObservers).size &&
currentBounds &&
currentZoom > 2 // Extend current bounds only if the map is zoomed in
) {
@@ -137,7 +141,10 @@ export function initTargetMapBounds(ids: string[]) {
});
}
export function updateTargetMapBounds(id: string, bounds: { southWest: Coordinates; northEast: Coordinates }) {
export function updateTargetMapBounds(
id: string,
bounds: { southWest: Coordinates; northEast: Coordinates }
) {
if (get(targetMapBounds).ids.indexOf(id) === -1) {
return;
}
@@ -159,8 +166,7 @@ export function updateTargetMapBounds(id: string, bounds: { southWest: Coordinat
});
}
export function centerMapOnSelection(
) {
export function centerMapOnSelection() {
let selected = get(selection).getSelected();
let bounds = new mapboxgl.LngLatBounds();
@@ -187,7 +193,7 @@ export function centerMapOnSelection(
get(map)?.fitBounds(bounds, {
padding: 80,
easing: () => 1,
maxZoom: 15
maxZoom: 15,
});
}
@@ -203,7 +209,7 @@ export enum Tool {
EXTRACT,
ELEVATION,
REDUCE,
CLEAN
CLEAN,
}
export const currentTool = writable<Tool | null>(null);
export const splitAs = writable(SplitType.FILES);
@@ -410,7 +416,7 @@ export function updateSelectionFromKey(down: boolean, shift: boolean) {
async function exportFiles(fileIds: string[], exclude: string[]) {
if (fileIds.length > 1) {
await exportFilesAsZip(fileIds, exclude)
await exportFilesAsZip(fileIds, exclude);
} else {
const firstFileId = fileIds.at(0);
if (firstFileId != null) {
@@ -468,7 +474,10 @@ export function updateAllHidden() {
if (item instanceof ListFileItem) {
hidden = hidden && file._data.hidden === true;
} else if (item instanceof ListTrackItem && item.getTrackIndex() < file.trk.length) {
} else if (
item instanceof ListTrackItem &&
item.getTrackIndex() < file.trk.length
) {
hidden = hidden && file.trk[item.getTrackIndex()]._data.hidden === true;
} else if (
item instanceof ListTrackSegmentItem &&
@@ -477,10 +486,14 @@ export function updateAllHidden() {
) {
hidden =
hidden &&
file.trk[item.getTrackIndex()].trkseg[item.getSegmentIndex()]._data.hidden === true;
file.trk[item.getTrackIndex()].trkseg[item.getSegmentIndex()]._data
.hidden === true;
} else if (item instanceof ListWaypointsItem) {
hidden = hidden && file._data.hiddenWpt === true;
} else if (item instanceof ListWaypointItem && item.getWaypointIndex() < file.wpt.length) {
} else if (
item instanceof ListWaypointItem &&
item.getWaypointIndex() < file.wpt.length
) {
hidden = hidden && file.wpt[item.getWaypointIndex()]._data.hidden === true;
}
}
@@ -496,6 +509,6 @@ export const editStyle = writable(false);
export enum ExportState {
NONE,
SELECTION,
ALL
ALL,
}
export const exportState = writable<ExportState>(ExportState.NONE);

View File

@@ -5,214 +5,216 @@ import { _ } from 'svelte-i18n';
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
export function kilometersToMiles(value: number) {
return value * 0.621371;
return value * 0.621371;
}
export function milesToKilometers(value: number) {
return value * 1.60934;
return value * 1.60934;
}
export function metersToFeet(value: number) {
return value * 3.28084;
return value * 3.28084;
}
export function kilometersToNauticalMiles(value: number) {
return value * 0.539957;
return value * 0.539957;
}
export function nauticalMilesToKilometers(value: number) {
return value * 1.852;
return value * 1.852;
}
export function celsiusToFahrenheit(value: number) {
return value * 1.8 + 32;
return value * 1.8 + 32;
}
export function distancePerHourToSecondsPerDistance(value: number) {
if (value === 0) {
return 0;
}
return 3600 / value;
if (value === 0) {
return 0;
}
return 3600 / value;
}
export function secondsToHHMMSS(value: number) {
var hours = Math.floor(value / 3600);
var minutes = Math.floor(value / 60) % 60;
var seconds = Math.min(59, Math.round(value % 60));
var hours = Math.floor(value / 3600);
var minutes = Math.floor(value / 60) % 60;
var seconds = Math.min(59, Math.round(value % 60));
return [hours, minutes, seconds]
.map((v) => (v < 10 ? '0' + v : v))
.filter((v, i) => v !== '00' || i > 0)
.join(':');
return [hours, minutes, seconds]
.map((v) => (v < 10 ? '0' + v : v))
.filter((v, i) => v !== '00' || i > 0)
.join(':');
}
// Get a string representation of the value with units
export function getDistanceWithUnits(value: number, convert: boolean = true) {
if (convert) {
return getConvertedDistance(value).toFixed(2) + ' ' + getDistanceUnits();
} else {
return value.toFixed(2) + ' ' + getDistanceUnits();
}
if (convert) {
return getConvertedDistance(value).toFixed(2) + ' ' + getDistanceUnits();
} else {
return value.toFixed(2) + ' ' + getDistanceUnits();
}
}
export function getVelocityWithUnits(value: number, convert: boolean = true) {
if (get(velocityUnits) === 'speed') {
if (convert) {
return getConvertedVelocity(value).toFixed(2) + ' ' + getVelocityUnits();
} else {
return value.toFixed(2) + ' ' + getVelocityUnits();
}
} else {
if (convert) {
return secondsToHHMMSS(getConvertedVelocity(value)) + ' ' + getVelocityUnits();
} else {
return secondsToHHMMSS(value) + ' ' + getVelocityUnits();
}
}
if (get(velocityUnits) === 'speed') {
if (convert) {
return getConvertedVelocity(value).toFixed(2) + ' ' + getVelocityUnits();
} else {
return value.toFixed(2) + ' ' + getVelocityUnits();
}
} else {
if (convert) {
return secondsToHHMMSS(getConvertedVelocity(value)) + ' ' + getVelocityUnits();
} else {
return secondsToHHMMSS(value) + ' ' + getVelocityUnits();
}
}
}
export function getElevationWithUnits(value: number, convert: boolean = true) {
if (convert) {
return getConvertedElevation(value).toFixed(0) + ' ' + getElevationUnits();
} else {
return value.toFixed(0) + ' ' + getElevationUnits();
}
if (convert) {
return getConvertedElevation(value).toFixed(0) + ' ' + getElevationUnits();
} else {
return value.toFixed(0) + ' ' + getElevationUnits();
}
}
export function getHeartRateWithUnits(value: number) {
return value.toFixed(0) + ' ' + getHeartRateUnits();
return value.toFixed(0) + ' ' + getHeartRateUnits();
}
export function getCadenceWithUnits(value: number) {
return value.toFixed(0) + ' ' + getCadenceUnits();
return value.toFixed(0) + ' ' + getCadenceUnits();
}
export function getPowerWithUnits(value: number) {
return value.toFixed(0) + ' ' + getPowerUnits();
return value.toFixed(0) + ' ' + getPowerUnits();
}
export function getTemperatureWithUnits(value: number, convert: boolean = true) {
if (convert) {
return getConvertedTemperature(value).toFixed(0) + ' ' + getTemperatureUnits();
} else {
return value.toFixed(0) + ' ' + getTemperatureUnits();
}
if (convert) {
return getConvertedTemperature(value).toFixed(0) + ' ' + getTemperatureUnits();
} else {
return value.toFixed(0) + ' ' + getTemperatureUnits();
}
}
// Get the units
export function getDistanceUnits(targetDistanceUnits = get(distanceUnits)) {
switch (targetDistanceUnits) {
case 'metric':
return get(_)('units.kilometers');
case 'imperial':
return get(_)('units.miles');
case 'nautical':
return get(_)('units.nautical_miles');
}
switch (targetDistanceUnits) {
case 'metric':
return get(_)('units.kilometers');
case 'imperial':
return get(_)('units.miles');
case 'nautical':
return get(_)('units.nautical_miles');
}
}
export function getVelocityUnits(
targetVelocityUnits = get(velocityUnits),
targetDistanceUnits = get(distanceUnits)
targetVelocityUnits = get(velocityUnits),
targetDistanceUnits = get(distanceUnits)
) {
if (targetVelocityUnits === 'speed') {
switch (targetDistanceUnits) {
case 'metric':
return get(_)('units.kilometers_per_hour');
case 'imperial':
return get(_)('units.miles_per_hour');
case 'nautical':
return get(_)('units.knots');
}
} else {
switch (targetDistanceUnits) {
case 'metric':
return get(_)('units.minutes_per_kilometer');
case 'imperial':
return get(_)('units.minutes_per_mile');
case 'nautical':
return get(_)('units.minutes_per_nautical_mile');
}
}
if (targetVelocityUnits === 'speed') {
switch (targetDistanceUnits) {
case 'metric':
return get(_)('units.kilometers_per_hour');
case 'imperial':
return get(_)('units.miles_per_hour');
case 'nautical':
return get(_)('units.knots');
}
} else {
switch (targetDistanceUnits) {
case 'metric':
return get(_)('units.minutes_per_kilometer');
case 'imperial':
return get(_)('units.minutes_per_mile');
case 'nautical':
return get(_)('units.minutes_per_nautical_mile');
}
}
}
export function getElevationUnits(targetDistanceUnits = get(distanceUnits)) {
switch (targetDistanceUnits) {
case 'metric':
return get(_)('units.meters');
case 'imperial':
return get(_)('units.feet');
case 'nautical':
// See https://github.com/gpxstudio/gpx.studio/pull/66#issuecomment-2306568997
return get(_)('units.meters');
}
switch (targetDistanceUnits) {
case 'metric':
return get(_)('units.meters');
case 'imperial':
return get(_)('units.feet');
case 'nautical':
// See https://github.com/gpxstudio/gpx.studio/pull/66#issuecomment-2306568997
return get(_)('units.meters');
}
}
export function getHeartRateUnits() {
return get(_)('units.heartrate');
return get(_)('units.heartrate');
}
export function getCadenceUnits() {
return get(_)('units.cadence');
return get(_)('units.cadence');
}
export function getPowerUnits() {
return get(_)('units.power');
return get(_)('units.power');
}
export function getTemperatureUnits() {
return get(temperatureUnits) === 'celsius' ? get(_)('units.celsius') : get(_)('units.fahrenheit');
return get(temperatureUnits) === 'celsius'
? get(_)('units.celsius')
: get(_)('units.fahrenheit');
}
// Convert only the value
export function getConvertedDistance(value: number, targetDistanceUnits = get(distanceUnits)) {
switch (targetDistanceUnits) {
case 'metric':
return value;
case 'imperial':
return kilometersToMiles(value);
case 'nautical':
return kilometersToNauticalMiles(value);
}
switch (targetDistanceUnits) {
case 'metric':
return value;
case 'imperial':
return kilometersToMiles(value);
case 'nautical':
return kilometersToNauticalMiles(value);
}
}
export function getConvertedElevation(value: number, targetDistanceUnits = get(distanceUnits)) {
switch (targetDistanceUnits) {
case 'metric':
return value;
case 'imperial':
return metersToFeet(value);
case 'nautical':
return value;
}
switch (targetDistanceUnits) {
case 'metric':
return value;
case 'imperial':
return metersToFeet(value);
case 'nautical':
return value;
}
}
export function getConvertedVelocity(
value: number,
targetVelocityUnits = get(velocityUnits),
targetDistanceUnits = get(distanceUnits)
value: number,
targetVelocityUnits = get(velocityUnits),
targetDistanceUnits = get(distanceUnits)
) {
if (targetVelocityUnits === 'speed') {
switch (targetDistanceUnits) {
case 'metric':
return value;
case 'imperial':
return kilometersToMiles(value);
case 'nautical':
return kilometersToNauticalMiles(value);
}
} else {
switch (targetDistanceUnits) {
case 'metric':
return distancePerHourToSecondsPerDistance(value);
case 'imperial':
return distancePerHourToSecondsPerDistance(kilometersToMiles(value));
case 'nautical':
return distancePerHourToSecondsPerDistance(kilometersToNauticalMiles(value));
}
}
if (targetVelocityUnits === 'speed') {
switch (targetDistanceUnits) {
case 'metric':
return value;
case 'imperial':
return kilometersToMiles(value);
case 'nautical':
return kilometersToNauticalMiles(value);
}
} else {
switch (targetDistanceUnits) {
case 'metric':
return distancePerHourToSecondsPerDistance(value);
case 'imperial':
return distancePerHourToSecondsPerDistance(kilometersToMiles(value));
case 'nautical':
return distancePerHourToSecondsPerDistance(kilometersToNauticalMiles(value));
}
}
}
export function getConvertedTemperature(value: number) {
return get(temperatureUnits) === 'celsius' ? value : celsiusToFahrenheit(value);
return get(temperatureUnits) === 'celsius' ? value : celsiusToFahrenheit(value);
}

View File

@@ -1,18 +1,18 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { cubicOut } from "svelte/easing";
import type { TransitionConfig } from "svelte/transition";
import { get } from "svelte/store";
import { map } from "./stores";
import { base } from "$app/paths";
import { languages } from "$lib/languages";
import { locale } from "svelte-i18n";
import { TrackPoint, Waypoint, type Coordinates, crossarcDistance, distance } from "gpx";
import mapboxgl from "mapbox-gl";
import tilebelt from "@mapbox/tilebelt";
import { PUBLIC_MAPBOX_TOKEN } from "$env/static/public";
import PNGReader from "png.js";
import type { DateFormatter } from "@internationalized/date";
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { cubicOut } from 'svelte/easing';
import type { TransitionConfig } from 'svelte/transition';
import { get } from 'svelte/store';
import { map } from './stores';
import { base } from '$app/paths';
import { languages } from '$lib/languages';
import { locale } from 'svelte-i18n';
import { TrackPoint, Waypoint, type Coordinates, crossarcDistance, distance } from 'gpx';
import mapboxgl from 'mapbox-gl';
import tilebelt from '@mapbox/tilebelt';
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
import PNGReader from 'png.js';
import type { DateFormatter } from '@internationalized/date';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -30,7 +30,7 @@ export const flyAndScale = (
params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 50 }
): TransitionConfig => {
const style = getComputedStyle(node);
const transform = style.transform === "none" ? "" : style.transform;
const transform = style.transform === 'none' ? '' : style.transform;
const scaleConversion = (
valueA: number,
@@ -46,13 +46,11 @@ export const flyAndScale = (
return valueB;
};
const styleToString = (
style: Record<string, number | string | undefined>
): string => {
const styleToString = (style: Record<string, number | string | undefined>): string => {
return Object.keys(style).reduce((str, key) => {
if (style[key] === undefined) return str;
return str + `${key}:${style[key]};`;
}, "");
}, '');
};
return {
@@ -65,14 +63,18 @@ export const flyAndScale = (
return styleToString({
transform: `${transform} translate3d(${x}px, ${y}px, 0) scale(${scale})`,
opacity: t
opacity: t,
});
},
easing: cubicOut
easing: cubicOut,
};
};
export function getClosestLinePoint(points: TrackPoint[], point: TrackPoint | Coordinates, details: any = {}): TrackPoint {
export function getClosestLinePoint(
points: TrackPoint[],
point: TrackPoint | Coordinates,
details: any = {}
): TrackPoint {
let closest = points[0];
let closestDist = Number.MAX_VALUE;
for (let i = 0; i < points.length - 1; i++) {
@@ -94,55 +96,85 @@ export function getClosestLinePoint(points: TrackPoint[], point: TrackPoint | Co
return closest;
}
export function getElevation(points: (TrackPoint | Waypoint | Coordinates)[], ELEVATION_ZOOM: number = 13, tileSize = 512): Promise<number[]> {
let coordinates = points.map((point) => (point instanceof TrackPoint || point instanceof Waypoint) ? point.getCoordinates() : point);
export function getElevation(
points: (TrackPoint | Waypoint | Coordinates)[],
ELEVATION_ZOOM: number = 13,
tileSize = 512
): Promise<number[]> {
let coordinates = points.map((point) =>
point instanceof TrackPoint || point instanceof Waypoint ? point.getCoordinates() : point
);
let bbox = new mapboxgl.LngLatBounds();
coordinates.forEach((coord) => bbox.extend(coord));
let tiles = coordinates.map((coord) => tilebelt.pointToTile(coord.lon, coord.lat, ELEVATION_ZOOM));
let uniqueTiles = Array.from(new Set(tiles.map((tile) => tile.join(',')))).map((tile) => tile.split(',').map((x) => parseInt(x)));
let tiles = coordinates.map((coord) =>
tilebelt.pointToTile(coord.lon, coord.lat, ELEVATION_ZOOM)
);
let uniqueTiles = Array.from(new Set(tiles.map((tile) => tile.join(',')))).map((tile) =>
tile.split(',').map((x) => parseInt(x))
);
let pngs = new Map<string, any>();
let promises = uniqueTiles.map((tile) => fetch(`https://api.mapbox.com/v4/mapbox.mapbox-terrain-dem-v1/${ELEVATION_ZOOM}/${tile[0]}/${tile[1]}@2x.pngraw?access_token=${PUBLIC_MAPBOX_TOKEN}`, { cache: 'force-cache' }).then((response) => response.arrayBuffer()).then((buffer) => new Promise((resolve) => {
let png = new PNGReader(new Uint8Array(buffer));
png.parse((err, png) => {
if (err) {
resolve(false); // Also resolve so that Promise.all doesn't fail
} else {
pngs.set(tile.join(','), png);
resolve(true);
let promises = uniqueTiles.map((tile) =>
fetch(
`https://api.mapbox.com/v4/mapbox.mapbox-terrain-dem-v1/${ELEVATION_ZOOM}/${tile[0]}/${tile[1]}@2x.pngraw?access_token=${PUBLIC_MAPBOX_TOKEN}`,
{ cache: 'force-cache' }
)
.then((response) => response.arrayBuffer())
.then(
(buffer) =>
new Promise((resolve) => {
let png = new PNGReader(new Uint8Array(buffer));
png.parse((err, png) => {
if (err) {
resolve(false); // Also resolve so that Promise.all doesn't fail
} else {
pngs.set(tile.join(','), png);
resolve(true);
}
});
})
)
);
return Promise.all(promises).then(() =>
coordinates.map((coord, index) => {
let tile = tiles[index];
let png = pngs.get(tile.join(','));
if (!png) {
return 0;
}
});
})));
return Promise.all(promises).then(() => coordinates.map((coord, index) => {
let tile = tiles[index];
let png = pngs.get(tile.join(','));
let tf = tilebelt.pointToTileFraction(coord.lon, coord.lat, ELEVATION_ZOOM);
let x = tileSize * (tf[0] - tile[0]);
let y = tileSize * (tf[1] - tile[1]);
let _x = Math.floor(x);
let _y = Math.floor(y);
let dx = x - _x;
let dy = y - _y;
if (!png) {
return 0;
}
const p00 = png.getPixel(_x, _y);
const p01 = png.getPixel(_x, _y + (_y + 1 == tileSize ? 0 : 1));
const p10 = png.getPixel(_x + (_x + 1 == tileSize ? 0 : 1), _y);
const p11 = png.getPixel(
_x + (_x + 1 == tileSize ? 0 : 1),
_y + (_y + 1 == tileSize ? 0 : 1)
);
let tf = tilebelt.pointToTileFraction(coord.lon, coord.lat, ELEVATION_ZOOM);
let x = tileSize * (tf[0] - tile[0]);
let y = tileSize * (tf[1] - tile[1]);
let _x = Math.floor(x);
let _y = Math.floor(y);
let dx = x - _x;
let dy = y - _y;
let ele00 = -10000 + (p00[0] * 256 * 256 + p00[1] * 256 + p00[2]) * 0.1;
let ele01 = -10000 + (p01[0] * 256 * 256 + p01[1] * 256 + p01[2]) * 0.1;
let ele10 = -10000 + (p10[0] * 256 * 256 + p10[1] * 256 + p10[2]) * 0.1;
let ele11 = -10000 + (p11[0] * 256 * 256 + p11[1] * 256 + p11[2]) * 0.1;
const p00 = png.getPixel(_x, _y);
const p01 = png.getPixel(_x, _y + (_y + 1 == tileSize ? 0 : 1));
const p10 = png.getPixel(_x + (_x + 1 == tileSize ? 0 : 1), _y);
const p11 = png.getPixel(_x + (_x + 1 == tileSize ? 0 : 1), _y + (_y + 1 == tileSize ? 0 : 1));
let ele00 = -10000 + ((p00[0] * 256 * 256 + p00[1] * 256 + p00[2]) * 0.1);
let ele01 = -10000 + ((p01[0] * 256 * 256 + p01[1] * 256 + p01[2]) * 0.1);
let ele10 = -10000 + ((p10[0] * 256 * 256 + p10[1] * 256 + p10[2]) * 0.1);
let ele11 = -10000 + ((p11[0] * 256 * 256 + p11[1] * 256 + p11[2]) * 0.1);
return ele00 * (1 - dx) * (1 - dy) + ele01 * (1 - dx) * dy + ele10 * dx * (1 - dy) + ele11 * dx * dy;
}));
return (
ele00 * (1 - dx) * (1 - dy) +
ele01 * (1 - dx) * dy +
ele10 * dx * (1 - dy) +
ele11 * dx * dy
);
})
);
}
let previousCursors: string[] = [];
@@ -226,11 +258,11 @@ export function getURLForLanguage(lang: string | null | undefined, path: string)
function getDateFormatter(locale: string) {
return new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'medium'
timeStyle: 'medium',
});
}
export let df: DateFormatter = getDateFormatter('en');
locale.subscribe((l) => {
df = getDateFormatter(l ?? 'en');
});
});