Migration to new Rust-based engine (#401)

* wasm first steps

* parsing start

* parsing continued

* progress

* progress

* progress

* progress

* progress

* progress

* progress

* progress

* fixes

* reorganize

* change type

* fix

* progress

* refactoring

* progress

* progress

* progress

* keep style info

* progress

* progress

* remove (copy) in name

* progress

* merge methods into 1

* splice

* progress

* progress

* progress

* cleaning

* progress

* progress

* progress

* visibility

* progress

* progress

* progress

* progress

* progress

* fixes

* progress

* fix markers

* fix

* progress

* progress

* progress

* progress

* progress

* progress

* small fix

* use engine info

* fix popups

* small fix

* progress

* progress

* progress

* clean tool

* handle self closing elements

* reduce data exchanges across boundary

* copy on wasm side to avoid any data corruption

* speed fix

* routing

* refactoring

* reverse

* reverse

* crop

* split

* merge

* progress

* time tool

* fix bounds for waypoints

* reduce

* fix reactivity

* handle corrupt time data

* export

* progress

* remove gpx js package

* remove todo

* update readme

* cleaning

* progress

* fix link fields

* remove unused fields

* refactoring and tests

* import and export of routes, ref #192

* per-chunk categories storage

* cross-tab sync

* outdated state

* refactoring

* reorder methods

* refactoring

* fix

* improve flow

* renaming

* progress

* progress

* fix

* progress

* per file history

* keep original flag

* use history variant

* fix

* progress

* file explorer

* progress

* fix

* sticky folders

* improved sticky folders

* fix sortable

* fix sortable

* fix

* fix tab size

* no file delete on desktop

* fix routing on other file

* fix sorting

* remember language

* open links in browser

* commit missing changes

* avoid selecting on resize

* clarify filename

* explorer actions

* fix folder styling

* drop anywhere in folder

* cleaning

* fixes

* remove focus-ring

* full screen

* small fix

* consistent highlight color

* improved preview

* performance improvements

* sparser data transfers

* sparser data transfers

* show popup once data is loaded

* fix routing control size

* fix

* sparse desktop build

* remove custom ign styles

* use new endpoint for openhikingmap

* strip native menu

* reorder context menu items

* route full support

* route - track conversion

* create route file action

* route export, filter out anchors at high zoom level

* clarify name handling

* move hide & center to view

* routes continued

* update docs

* fix power format, closes #378

* lint fixes

* fix display of additional data with gaps, closes #361

* fix linting issues

* fix import modules

* fix multidrag

* change base to resolve

* fix type errors

* fix layer typings

* fix last type error

* each keys

* improve typings

* deploy to cloudflare

---------

Co-authored-by: vcoppe <vcoppe@hexaly.com>
This commit is contained in:
vcoppeandvcoppe authored and GitHub committed 2026-10-31 18:08:22 +01:00
1 parent 6a0d434371
commit 8fe7d8d64d
484 files changed
+53949 -41152

No files matched your search

+4 -4
View File
@@ -1,7 +1,7 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import "shadcn-svelte/tailwind.css";
@import "@fontsource-variable/inter";
@import 'shadcn-svelte/tailwind.css';
@import '@fontsource-variable/inter';
@custom-variant dark (&:is(.dark *));
@@ -158,7 +158,7 @@
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
html {
@apply font-sans;
}
}
+5
View File
@@ -8,6 +8,11 @@ declare global {
// interface PageState {}
// interface Platform {}
}
interface Window {
/** The map, for extensions (set once it has loaded). */
_map?: import('maplibre-gl').Map;
}
}
export {};
+11 -6
View File
@@ -1,5 +1,5 @@
import { base } from '$app/paths';
import { languages } from '$lib/languages';
import { asset, resolve as resolvePath } from '$app/paths';
import { languages } from '$lib/i18n/languages';
import { getURLForLanguage } from '$lib/utils';
export async function handle({ event, resolve }) {
@@ -37,21 +37,26 @@ export async function handle({ event, resolve }) {
<meta property="og:description" content="${description}" />
<meta name="twitter:title" content="gpx.studio — ${title}" />
<meta name="twitter:description" content="${description}" />
<meta property="og:image" content="https://gpx.studio${base}/og_logo.png" />
<meta property="og:image" content="https://gpx.studio${asset('/og_logo.png')}" />
<meta property="og:url" content="https://gpx.studio/" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="gpx.studio" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://gpx.studio${base}/og_logo.png" />
<meta name="twitter:image" content="https://gpx.studio${asset('/og_logo.png')}" />
<meta name="twitter:url" content="https://gpx.studio/" />
<meta name="twitter:site" content="@gpxstudio" />
<meta name="twitter:creator" content="@gpxstudio" />
<link rel="alternate" hreflang="x-default" href="https://gpx.studio${getURLForLanguage('en', path)}" />
<link rel="alternate" hreflang="x-default" href="https://gpx.studio${resolvePath(getURLForLanguage('en', path))}" />`;
// the manifests are made for the website, not for the desktop app (`npm run build:desktop`)
if (process.env.BUILD_TARGET !== 'desktop') {
headTag += `
<link rel="manifest" href="/${language}.manifest.webmanifest" />`;
}
if (page !== '404') {
for (let lang of Object.keys(languages)) {
headTag += ` <link rel="alternate" hreflang="${lang}" href="https://gpx.studio${getURLForLanguage(lang, path)}" />
headTag += ` <link rel="alternate" hreflang="${lang}" href="https://gpx.studio${resolvePath(getURLForLanguage(lang, path))}" />
`;
}
}
@@ -0,0 +1,43 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { defaultFileName } from '$lib/actions/default-file-name';
describe('default file name', () => {
it('is the default name when there is no other file with it', () => {
assert.equal(defaultFileName('New file', []), 'New file');
assert.equal(defaultFileName('New file', ['Morning ride', 'track']), 'New file');
});
it('is numbered when other files have the default name', () => {
assert.equal(defaultFileName('New file', ['New file']), 'New file 2');
assert.equal(defaultFileName('New file', ['New file', 'New file 2']), 'New file 3');
});
it('goes after the highest number', () => {
assert.equal(defaultFileName('New file', ['New file 4', 'New file']), 'New file 5');
// a number that was freed is not used again
assert.equal(defaultFileName('New file', ['New file', 'New file 3']), 'New file 4');
// the numbered ones count even if the first one was renamed
assert.equal(defaultFileName('New file', ['New file 2']), 'New file 3');
});
it('ignores the names that only look like it', () => {
assert.equal(defaultFileName('New file', ['New file copy', 'New files 3']), 'New file');
assert.equal(
defaultFileName('New file', ['New file 2b', 'New file 0', 'New file -3']),
'New file'
);
assert.equal(defaultFileName('New file', ['New file ', 'New file 2']), 'New file');
assert.equal(defaultFileName('New file', ['my New file']), 'New file');
});
it('works with any language', () => {
assert.equal(defaultFileName('Nouveau fichier', ['Nouveau fichier']), 'Nouveau fichier 2');
assert.equal(defaultFileName('新文件', ['新文件', '新文件 2']), '新文件 3');
// the default name of another language is just a name
assert.equal(
defaultFileName('Nouveau fichier', ['New file', 'New file 2']),
'Nouveau fichier'
);
});
});
@@ -0,0 +1,19 @@
/**
* The name of a new file: the default name (translated), followed by a number when other files
* already have a default name (the name alone counts as number 1): "New file", "New file 2",
* "New file 3"...
*/
export function defaultFileName(defaultName: string, existingNames: string[]): string {
let highest = 0;
for (const name of existingNames) {
if (name === defaultName) {
highest = Math.max(highest, 1);
} else if (name.startsWith(`${defaultName} `)) {
const suffix = name.slice(defaultName.length + 1);
if (/^[1-9][0-9]*$/.test(suffix)) {
highest = Math.max(highest, parseInt(suffix));
}
}
}
return highest === 0 ? defaultName : `${defaultName} ${highest + 1}`;
}
+204
View File
@@ -0,0 +1,204 @@
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { i18n } from '$lib/i18n/i18n.svelte';
import { get } from 'svelte/store';
import { boundsManager } from '$lib/state/bounds';
import { engine } from '$lib/engine';
import { defaultFileName } from '$lib/actions/default-file-name';
import { askOverwrite, askUnsaved } from '$lib/components/file-list/unsaved-prompt.svelte';
import { toast } from 'svelte-sonner';
import type { UnsavedChoice } from '$lib/engine';
/** The name of a new file: the translated default name, numbered if other files have it. */
export function newFileName() {
const names = [...get(engine.files).values()].map((file) => get(file).structure.name);
return defaultFileName(i18n._('menu.new_file'), names);
}
export function createFile() {
// the engine selects the new file
engine.newFile(newFileName());
currentTool.set(Tool.ROUTING);
}
/** Creates a file that starts with an empty route, which the routing tool then draws. */
export function createRouteFile() {
engine.newFile(newFileName(), undefined, true);
currentTool.set(Tool.ROUTING);
}
/** Asks for the files to open: with the file picker of the system in the desktop app. */
export async function openFiles() {
if (get(engine.workspace)) {
const ids = await engine.openFiles();
if (ids.length > 0) {
boundsManager.fitBoundsOnLoad(ids);
}
} else {
triggerFileInput();
}
}
/** The names of files, to show them to the user. */
function namesOf(ids: string[]): string[] {
const files = get(engine.files);
return ids.map((id) => {
const file = files.get(id);
return file ? get(file).structure.name : id;
});
}
/** The ids among `ids` of the files that have changes that are not saved. */
function unsavedAmong(ids: string[]): string[] {
const workspace = get(engine.workspace);
return ids.filter((id) => workspace?.files[id]?.dirty);
}
/**
* What to do with the files among `ids` that have unsaved changes, which the user is asked when
* there are some: `undefined` when there are none, `'cancel'` when they do not want to go on.
*/
async function askAboutUnsavedFiles(ids: string[]): Promise<UnsavedChoice | undefined | 'cancel'> {
const unsaved = unsavedAmong(ids);
return unsaved.length > 0 ? askUnsaved(namesOf(unsaved)) : undefined;
}
/** All the open files. */
function allFileIds(): string[] {
return get(engine.order);
}
/**
* Opens a folder, which is watched (the desktop app). Its files are not opened, they are in its
* tree. The folder that was open is closed with its files: the user is asked what to do with the
* unsaved ones.
*/
export async function openFolder() {
const unsaved = await askAboutUnsavedFiles(allFileIds());
if (unsaved !== 'cancel') {
await engine.openFolder(unsaved);
}
}
/** Closes the open folder and all the files: the user is asked what to do with the unsaved ones. */
export async function closeFolder() {
const unsaved = await askAboutUnsavedFiles(allFileIds());
if (unsaved !== 'cancel') {
await engine.closeFolder(unsaved);
}
}
/** Closes files: the user is asked what to do with the ones that have unsaved changes. */
export async function closeFiles(ids: string[]) {
if (ids.length === 0) {
return;
}
const unsaved = await askAboutUnsavedFiles(ids);
if (unsaved !== 'cancel') {
await engine.closeFiles(ids, unsaved);
}
}
/** Closes all the files. */
export function closeAll() {
return closeFiles(allFileIds());
}
/**
* Writes files to the file system. The user is asked first about the ones whose file was also
* changed on the disk, which saving overwrites: they can take the disk instead of their changes.
*/
async function saveFiles(ids: string[]) {
if (ids.length === 0) {
return;
}
const workspace = get(engine.workspace);
const replaced = ids.filter((id) => workspace?.files[id]?.replaced);
if (replaced.length > 0) {
const choice = await askOverwrite(namesOf(replaced));
if (choice === 'cancel') {
return;
}
if (choice === 'reload') {
// they are the files on the disk now, there is nothing to save
await engine.reload(replaced);
ids = ids.filter((id) => !replaced.includes(id));
}
}
if (ids.length > 0) {
await engine.save(ids);
}
}
/** Saves the selected files to the file system, all the ones that have unsaved edits if none is. */
export function saveSelected(selectedFileIds: string[]) {
return saveFiles(selectedFileIds.length > 0 ? selectedFileIds : unsavedAmong(allFileIds()));
}
/** Saves all the files that have unsaved edits to the file system. */
export function saveAll() {
return saveFiles(unsavedAmong(allFileIds()));
}
/** Saves a file where the user says, which is where it is from then on (not for several files). */
export async function saveAs(selectedFileIds: string[]) {
if (selectedFileIds.length === 1) {
await engine.saveAs(selectedFileIds[0]);
}
}
/**
* Opens files of the file system, like the ones of the tree of the open folder: as a preview
* (`preview`, a single click), which the next preview replaces, or for good. The map fits the files
* that were opened.
*/
export async function openPaths(paths: string[], preview = false) {
const ids = await engine.openPaths(paths, preview);
if (ids.length > 0) {
boundsManager.fitBoundsOnLoad(ids);
}
}
/** Tells the user why the engine did not do what was asked (a key of the dictionaries, or text). */
export function showEngineError() {
const error = get(engine.lastError);
if (error !== undefined && error !== 'nothing to do') {
toast.error(i18n._(error, error));
}
}
export function triggerFileInput() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.gpx';
input.multiple = true;
input.className = 'hidden';
input.onchange = () => {
if (input.files) {
loadFiles(input.files);
}
};
input.click();
}
/**
* Loads GPX files in the engine, as a single command: one undo step, the first file that could be
* read is selected, and the map fits the files. A file without name is named like the file on
* disk. Returns the ids of the files that were added.
*/
export async function loadFiles(list: FileList | File[]): Promise<string[]> {
const files = await Promise.all(
Array.from(list).map(async (file) => ({
data: new Uint8Array(await file.arrayBuffer()),
name: file.name.split('.').slice(0, -1).join('.'),
}))
);
const before = new Set(get(engine.order));
await engine.loadFiles(files);
const ids = get(engine.order).filter((id) => !before.has(id));
if (ids.length > 0) {
boundsManager.fitBoundsOnLoad(ids);
}
return ids;
}
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
-4
View File
@@ -1,4 +0,0 @@
import { parseGPX } from 'gpx';
export const exampleGPXFile = parseGPX(
`<?xml version="1.0" encoding="UTF-8"?><gpx creator="StravaGPX" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd" version="1.1" xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1" xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3" xmlns:gpxpx="http://www.garmin.com/xmlschemas/PowerExtension/v1" xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2"><metadata><name>Example</name><author><name>gpx.studio</name><link href="https://gpx.studio"/></author><time>2023-08-20T12:42:07.000Z</time></metadata><trk><name>Example</name><type>cycling</type><trkseg><trkpt lat="48.745764" lon="-66.658624"><ele>290.4</ele><time>2022-12-31T23:00:00.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>18</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.745974" lon="-66.658127"><ele>292.8</ele><time>2022-12-31T23:00:14.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>18</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.746592" lon="-66.658472"><ele>303.6</ele><time>2022-12-31T23:00:46.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>18</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.748063" lon="-66.660451"><ele>331.8</ele><time>2022-12-31T23:02:31.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>19</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.749297" lon="-66.650629"><ele>366</ele><time>2022-12-31T23:05:46.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>22</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.751879" lon="-66.646183"><ele>371.2</ele><time>2022-12-31T23:07:04.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>22</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.754389" lon="-66.646388"><ele>376</ele><time>2022-12-31T23:08:01.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>22</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.755973" lon="-66.642677"><ele>376.6</ele><time>2022-12-31T23:09:01.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>21</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.756134" lon="-66.640854"><ele>377.6</ele><time>2022-12-31T23:09:27.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>20</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.759941" lon="-66.633175"><ele>376.8</ele><time>2022-12-31T23:11:23.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>20</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.765632" lon="-66.635593"><ele>410.6</ele><time>2022-12-31T23:14:38.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>23</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.764883" lon="-66.630508"><ele>390.4</ele><time>2022-12-31T23:15:42.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>23</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.772418" lon="-66.633652"><ele>419.8</ele><time>2022-12-31T23:19:41.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>19</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.775935" lon="-66.633982"><ele>430</ele><time>2022-12-31T23:21:25.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>19</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.779656" lon="-66.636621"><ele>430.2</ele><time>2022-12-31T23:23:04.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>18</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.780914" lon="-66.636833"><ele>431.2</ele><time>2022-12-31T23:23:32.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>18</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.7868" lon="-66.632505"><ele>434</ele><time>2022-12-31T23:26:05.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>17</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.787548" lon="-66.632642"><ele>435.6</ele><time>2022-12-31T23:26:28.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>17</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions> Line truncated
);
@@ -2,7 +2,7 @@
import docsearch from '@docsearch/js';
import '@docsearch/css';
import { onMount } from 'svelte';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
let props: {
class?: string;
@@ -60,7 +60,7 @@
</script>
<svelte:head>
<link rel="preconnect" href="https://21XLD94PE3-dsn.algolia.net" crossorigin />
<link rel="preconnect" href="https://21XLD94PE3-dsn.algolia.net" crossorigin="anonymous" />
</svelte:head>
<div id="docsearch" class={props.class ?? ''}></div>
@@ -26,7 +26,7 @@
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Button {...props} {variant} class="bg-inherit {className}" {onclick}>
<Button {...props} {variant} {disabled} class="bg-inherit {className}" {onclick}>
{@render children()}
</Button>
{/snippet}
+5 -4
View File
@@ -1,10 +1,11 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { Button } from '$lib/components/ui/button';
import LanguageSelect from '$lib/components/LanguageSelect.svelte';
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
import Logo from '$lib/components/Logo.svelte';
import { AtSign, BookOpenText, Heart, House, Map } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import { getURLForLanguage } from '$lib/utils';
</script>
@@ -31,7 +32,7 @@
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href={getURLForLanguage(i18n.lang, '/')}
href={resolve(getURLForLanguage(i18n.lang, '/'))}
>
<House size="16" />
{i18n._('homepage.home')}
@@ -40,7 +41,7 @@
data-sveltekit-reload
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href={getURLForLanguage(i18n.lang, '/app')}
href={resolve(getURLForLanguage(i18n.lang, '/app'))}
>
<Map size="16" />
{i18n._('homepage.app')}
@@ -48,7 +49,7 @@
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href={getURLForLanguage(i18n.lang, '/help')}
href={resolve(getURLForLanguage(i18n.lang, '/help'))}
>
<BookOpenText size="16" />
{i18n._('menu.help')}
+21 -17
View File
@@ -5,10 +5,11 @@
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
import { i18n } from '$lib/i18n/i18n.svelte';
import type { Readable } from 'svelte/store';
import { settings } from '$lib/logic/settings';
import type { SelectionStatistics } from '$lib/engine';
import type { SlicedStatistics } from '$lib/state/selection-statistics';
import { settings } from '$lib/state/settings';
const { velocityUnits } = settings;
@@ -16,18 +17,17 @@
let panelWidth: number = $state(0);
let {
gpxStatistics,
slicedGPXStatistics,
statistics: selectionStatistics,
slicedStatistics,
orientation,
}: {
gpxStatistics: Readable<GPXStatisticsGroup>;
slicedGPXStatistics: Readable<[GPXGlobalStatistics, number, number] | undefined>;
statistics: Readable<SelectionStatistics>;
slicedStatistics: Readable<SlicedStatistics | undefined>;
orientation: 'horizontal' | 'vertical';
} = $props();
let statistics = $derived(
$slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics.global
);
// the highlighted part of the selection, or all of it
let statistics = $derived($slicedStatistics?.global ?? $selectionStatistics.global);
</script>
<Card.Root
@@ -46,15 +46,15 @@
<Tooltip label={i18n._('quantities.distance')}>
<span class="flex flex-row items-center">
<Ruler size="16" class="mr-1" />
<WithUnits value={statistics.distance.total} type="distance" />
<WithUnits value={statistics.totalDistance} type="distance" />
</span>
</Tooltip>
<Tooltip label={i18n._('quantities.elevation_gain_loss')}>
<span class="flex flex-row items-center">
<MoveUpRight size="16" class="mr-1" />
<WithUnits value={statistics.elevation.gain} type="elevation" />
<WithUnits value={statistics.elevationGain} type="elevation" />
<MoveDownRight size="16" class="mx-1" />
<WithUnits value={statistics.elevation.loss} type="elevation" />
<WithUnits value={statistics.elevationLoss} type="elevation" />
</span>
</Tooltip>
{#if panelHeight > 120 || (orientation === 'horizontal' && panelWidth > 450)}
@@ -67,9 +67,13 @@
>
<span class="flex flex-row items-center">
<Zap size="16" class="mr-1" />
<WithUnits value={statistics.speed.moving} type="speed" showUnits={false} />
<WithUnits
value={statistics.movingSpeed ?? 0}
type="speed"
showUnits={false}
/>
<span class="mx-1">/</span>
<WithUnits value={statistics.speed.total} type="speed" />
<WithUnits value={statistics.totalSpeed ?? 0} type="speed" />
</span>
</Tooltip>
{/if}
@@ -81,9 +85,9 @@
>
<span class="flex flex-row items-center">
<Timer size="16" class="mr-1" />
<WithUnits value={statistics.time.moving} type="time" />
<WithUnits value={statistics.movingTime ?? 0} type="time" />
<span class="mx-1">/</span>
<WithUnits value={statistics.time.total} type="time" />
<WithUnits value={statistics.totalTime ?? 0} type="time" />
</span>
</Tooltip>
{/if}
+3 -2
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { CircleQuestionMark } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import type { Snippet } from 'svelte';
import { resolve } from '$app/paths';
let {
link,
@@ -19,7 +20,7 @@
<div>
{@render children()}
{#if link}
<a href={link} target="_blank" class="text-[13px] text-link hover:underline">
<a href={resolve(link)} target="_blank" class="text-[13px] text-link hover:underline">
{i18n._('menu.more')}
</a>
{/if}
@@ -1,10 +1,11 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { page } from '$app/state';
import * as Select from '$lib/components/ui/select';
import { languages } from '$lib/languages';
import { languages } from '$lib/i18n/languages';
import { getURLForLanguage } from '$lib/utils';
import { Languages } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
</script>
<Select.Root type="single" value={i18n.lang}>
@@ -15,13 +16,13 @@
</span>
</Select.Trigger>
<Select.Content>
{#each Object.entries(languages) as [lang, label]}
{#each Object.entries(languages) as [lang, label] (lang)}
{#if page.url.pathname.includes('404')}
<a href={getURLForLanguage(lang, '/')}>
<a href={resolve(getURLForLanguage(lang, '/'))}>
<Select.Item value={lang}>{label}</Select.Item>
</a>
{:else}
<a href={getURLForLanguage(lang, page.url.pathname)}>
<a href={resolve(getURLForLanguage(lang, page.url.pathname))}>
<Select.Item value={lang}>{label}</Select.Item>
</a>
{/if}
+3 -3
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { mode } from 'mode-watcher';
import { base } from '$app/paths';
import { asset } from '$app/paths';
let {
iconOnly = false,
@@ -9,13 +9,13 @@
}: {
iconOnly?: boolean;
company?: 'gpx.studio' | 'github' | 'crowdin' | 'facebook' | 'reddit';
[key: string]: any;
[key: string]: unknown;
} = $props();
</script>
{#if company === 'gpx.studio'}
<img
src="{base}/{iconOnly ? 'icon' : 'logo'}{mode.current === 'dark' ? '-dark' : ''}.svg"
src={asset(`/${iconOnly ? 'icon' : 'logo'}${mode.current === 'dark' ? '-dark' : ''}.svg`)}
alt="Logo of gpx.studio."
{...others}
/>
+236 -173
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import * as Menubar from '$lib/components/ui/menubar/index.js';
import { Button } from '$lib/components/ui/button';
import Logo from '$lib/components/Logo.svelte';
@@ -40,40 +41,48 @@
FolderOpen,
FileStack,
FileX,
Save,
SaveAll,
BookOpenText,
ChartArea,
Maximize,
Maximize2,
Minimize2,
FolderX,
ArrowRightLeft,
} from '@lucide/svelte';
import { map } from '$lib/components/map/map';
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
import { exportState, ExportState } from '$lib/components/export/utils.svelte';
import { anySelectedLayer } from '$lib/components/map/layer-control/utils';
import { defaultOverlays } from '$lib/assets/layers';
import { defaultOverlays } from '$lib/data/layers';
import LayerControlSettings from '$lib/components/map/layer-control/LayerControlSettings.svelte';
import { ListFileItem, ListTrackItem } from '$lib/components/file-list/file-list';
import Export from '$lib/components/export/Export.svelte';
import { mode, setMode } from 'mode-watcher';
import { i18n } from '$lib/i18n.svelte';
import { languages } from '$lib/languages';
import { i18n } from '$lib/i18n/i18n.svelte';
import { languages } from '$lib/i18n/languages';
import { getURLForLanguage } from '$lib/utils';
import { settings } from '$lib/logic/settings';
import { settings } from '$lib/state/settings';
import {
closeAll,
closeFiles,
closeFolder,
createFile,
fileActions,
createRouteFile,
loadFiles,
pasteSelection,
triggerFileInput,
} from '$lib/logic/file-actions';
import { fileStateCollection } from '$lib/logic/file-state';
import { fileActionManager } from '$lib/logic/file-action-manager';
import { copied, selection } from '$lib/logic/selection';
import { allHidden } from '$lib/logic/hidden';
import { boundsManager } from '$lib/logic/bounds';
import { tick, onMount } from 'svelte';
import { allowedPastes } from '$lib/components/file-list/sortable-file-list';
openFiles,
openFolder,
saveAll,
saveAs,
saveSelected,
} from '$lib/actions/file-actions';
import UnsavedDialog from '$lib/components/file-list/UnsavedDialog.svelte';
import { engine } from '$lib/engine';
import { allHidden, selectedFilesConversion } from '$lib/engine/selection-stores';
import { selectedFileIds, selectionSize } from '$lib/engine/selection-helpers';
import { boundsManager } from '$lib/state/bounds';
import { onMount } from 'svelte';
const {
distanceUnits,
@@ -91,8 +100,18 @@
routing,
} = settings;
const canUndo = fileActionManager.canUndo;
const canRedo = fileActionManager.canRedo;
const { files, selection, canUndo, canRedo, canPaste, workspace } = engine;
let selectionCount = $derived(selectionSize($selection));
let noFiles = $derived($files.size === 0);
// the desktop app: the files are linked to the file system
let desktop = $derived($workspace !== undefined);
let selectedIds = $derived(selectedFileIds($selection));
let anyUnsaved = $derived(Object.values($workspace?.files ?? {}).some((file) => file.dirty));
// metadata and style can be edited for files, routes and tracks only
let filesOrTracksSelected = $derived(
$selection.type === 'file' || $selection.type === 'route' || $selection.type === 'track'
);
function switchBasemaps() {
[$currentBasemap, $previousBasemap] = [$previousBasemap, $currentBasemap];
@@ -130,7 +149,7 @@
<div
class="w-fit flex flex-row items-center justify-center p-1 bg-background rounded-b-md md:rounded-md pointer-events-auto shadow-md"
>
<a href={getURLForLanguage(i18n.lang, '/')} target="_blank" class="shrink-0">
<a href={resolve(getURLForLanguage(i18n.lang, '/'))} target="_blank" class="shrink-0">
<Logo class="h-5 mt-0.5 mx-2 md:hidden" iconOnly={true} width="16" />
<Logo class="h-5 mt-0.5 mx-2 hidden md:block" width="96" />
</a>
@@ -143,57 +162,103 @@
<Menubar.Content class="border-none">
<Menubar.Item onclick={createFile}>
<Plus size="16" />
{i18n._('menu.new')}
{i18n._('menu.new_file')}
<Shortcut key="+" ctrl={true} />
</Menubar.Item>
<Menubar.Item onclick={createRouteFile}>
<Plus size="16" />
{i18n._('menu.new_route_file')}
</Menubar.Item>
<Menubar.Separator />
<Menubar.Item onclick={triggerFileInput}>
<Menubar.Item onclick={openFiles}>
<FolderOpen size="16" />
{i18n._('menu.open')}
<Shortcut key="O" ctrl={true} />
</Menubar.Item>
{#if desktop}
<Menubar.Item onclick={openFolder}>
<FolderOpen size="16" />
{i18n._('menu.open_folder')}
<Shortcut key="O" ctrl={true} shift={true} />
</Menubar.Item>
{/if}
<Menubar.Separator />
<Menubar.Item
onclick={fileActions.duplicateSelection}
disabled={$selection.size == 0}
>
<Menubar.Item onclick={() => engine.duplicate()} disabled={selectionCount == 0}>
<Copy size="16" />
{i18n._('menu.duplicate')}
<Shortcut key="D" ctrl={true} />
</Menubar.Item>
<Menubar.Separator />
<Menubar.Item
onclick={() => tick().then(fileActions.deleteSelectedFiles)}
disabled={$selection.size == 0}
>
<FileX size="16" />
{i18n._('menu.delete')}
<Shortcut key="⌫" ctrl={true} />
</Menubar.Item>
<Menubar.Item
onclick={fileActions.deleteAllFiles}
disabled={fileStateCollection.size == 0}
>
<FileX size="16" />
{i18n._('menu.delete_all')}
<Shortcut key="⌫" ctrl={true} shift={true} />
</Menubar.Item>
{#if desktop}
<Menubar.Item
onclick={() => saveSelected(selectedIds)}
disabled={selectionCount == 0 && !anyUnsaved}
>
<Save size="16" />
{i18n._('menu.save')}
<Shortcut key="S" ctrl={true} />
</Menubar.Item>
<Menubar.Item onclick={saveAll} disabled={!anyUnsaved}>
<Save size="16" />
{i18n._('menu.save_all')}
<Shortcut key="S" ctrl={true} shift={true} />
</Menubar.Item>
<Menubar.Item
onclick={() => saveAs(selectedIds)}
disabled={selectedIds.length !== 1}
>
<SaveAll size="16" />
{i18n._('menu.save_as')}
</Menubar.Item>
<Menubar.Separator />
<Menubar.Item
onclick={() => closeFiles(selectedIds)}
disabled={selectedIds.length === 0}
>
<FileX size="16" />
{i18n._('menu.close')}
<Shortcut key="W" ctrl={true} />
</Menubar.Item>
<Menubar.Item onclick={closeAll} disabled={noFiles}>
<FileX size="16" />
{i18n._('menu.close_all')}
<Shortcut key="W" ctrl={true} shift={true} />
</Menubar.Item>
<Menubar.Item onclick={closeFolder} disabled={!$workspace?.folder}>
<FolderX size="16" />
{i18n._('menu.close_folder')}
</Menubar.Item>
{:else}
<Menubar.Item
onclick={() => engine.delete(true)}
disabled={selectionCount == 0}
>
<FileX size="16" />
{i18n._('menu.delete')}
<Shortcut key="⌫" ctrl={true} />
</Menubar.Item>
<Menubar.Item onclick={() => engine.deleteAll()} disabled={noFiles}>
<FileX size="16" />
{i18n._('menu.delete_all')}
<Shortcut key="⌫" ctrl={true} shift={true} />
</Menubar.Item>
{/if}
<Menubar.Separator />
<Menubar.Item
onclick={() => (exportState.current = ExportState.SELECTION)}
disabled={$selection.size == 0}
disabled={selectionCount == 0}
>
<Download size="16" />
{i18n._('menu.export')}
<Shortcut key="S" ctrl={true} />
{#if !desktop}<Shortcut key="S" ctrl={true} />{/if}
</Menubar.Item>
<Menubar.Item
onclick={() => (exportState.current = ExportState.ALL)}
disabled={fileStateCollection.size == 0}
disabled={noFiles}
>
<Download size="16" />
{i18n._('menu.export_all')}
<Shortcut key="S" ctrl={true} shift={true} />
{#if !desktop}<Shortcut key="S" ctrl={true} shift={true} />{/if}
</Menubar.Item>
</Menubar.Content>
</Menubar.Menu>
@@ -203,26 +268,19 @@
<span class="hidden md:block">{i18n._('menu.edit')}</span>
</Menubar.Trigger>
<Menubar.Content class="border-none">
<Menubar.Item onclick={() => fileActionManager.undo()} disabled={!$canUndo}>
<Menubar.Item onclick={() => engine.undo()} disabled={!$canUndo}>
<Undo2 size="16" />
{i18n._('menu.undo')}
<Shortcut key="Z" ctrl={true} />
</Menubar.Item>
<Menubar.Item onclick={() => fileActionManager.redo()} disabled={!$canRedo}>
<Menubar.Item onclick={() => engine.redo()} disabled={!$canRedo}>
<Redo2 size="16" />
{i18n._('menu.redo')}
<Shortcut key="Z" ctrl={true} shift={true} />
</Menubar.Item>
<Menubar.Separator />
<Menubar.Item
disabled={$selection.size !== 1 ||
!$selection
.getSelected()
.every(
(item) =>
item instanceof ListFileItem ||
item instanceof ListTrackItem
)}
disabled={selectionCount !== 1 || !filesOrTracksSelected}
onclick={() => (editMetadata.current = true)}
>
<Info size="16" />
@@ -230,132 +288,101 @@
<Shortcut key="I" ctrl={true} />
</Menubar.Item>
<Menubar.Item
disabled={$selection.size === 0 ||
!$selection
.getSelected()
.every(
(item) =>
item instanceof ListFileItem ||
item instanceof ListTrackItem
)}
disabled={selectionCount === 0 || !filesOrTracksSelected}
onclick={() => (editStyle.current = true)}
>
<PaintBucket size="16" />
{i18n._('menu.style.button')}
</Menubar.Item>
<Menubar.Item
onclick={() => {
if ($allHidden) {
fileActions.setHiddenToSelection(false);
} else {
fileActions.setHiddenToSelection(true);
}
}}
disabled={$selection.size == 0}
>
{#if $allHidden}
<Eye size="16" />
{i18n._('menu.unhide')}
{:else}
<EyeOff size="16" />
{i18n._('menu.hide')}
{/if}
<Shortcut key="H" ctrl={true} />
</Menubar.Item>
{#if $treeFileView}
{#if $selection.getSelected().some((item) => item instanceof ListFileItem)}
{#if $selection.type === 'file'}
<Menubar.Separator />
<Menubar.Item
onclick={() =>
fileActions.addNewTrack(
$selection.getSelected()[0].getFileId()
)}
disabled={$selection.size !== 1}
onclick={() => engine.newTrack()}
disabled={selectionCount !== 1}
>
<Plus size="16" />
{i18n._('menu.new_track')}
</Menubar.Item>
{:else if $selection
.getSelected()
.some((item) => item instanceof ListTrackItem)}
<Menubar.Item
onclick={() => engine.newRoute()}
disabled={selectionCount !== 1}
>
<Plus size="16" />
{i18n._('menu.new_route')}
</Menubar.Item>
{:else if $selection.type === 'track'}
<Menubar.Separator />
<Menubar.Item
onclick={() => {
let item = $selection.getSelected()[0];
fileActions.addNewSegment(
item.getFileId(),
item.getTrackIndex()
);
}}
disabled={$selection.size !== 1}
onclick={() => engine.newTrackSegment()}
disabled={selectionCount !== 1}
>
<Plus size="16" />
{i18n._('menu.new_segment')}
</Menubar.Item>
{/if}
{#if $selection.type === 'route' || $selection.type === 'track' || $selection.type === 'segment'}
{#if $selection.type !== 'track'}
<Menubar.Separator />
{/if}
<Menubar.Item onclick={() => engine.convert()}>
<ArrowRightLeft size="16" />
{i18n._(
$selection.type === 'route'
? 'menu.convert_to_track'
: 'menu.convert_to_route'
)}
</Menubar.Item>
{/if}
{/if}
{#if $selectedFilesConversion}
{#if !$treeFileView}
<Menubar.Separator />
{/if}
<Menubar.Item onclick={() => engine.convert()}>
<ArrowRightLeft size="16" />
{i18n._(
$selectedFilesConversion === 'route'
? 'menu.convert_to_routes'
: 'menu.convert_to_tracks'
)}
</Menubar.Item>
{/if}
<Menubar.Separator />
<Menubar.Item
onclick={() => selection.selectAll()}
disabled={fileStateCollection.size == 0}
>
<Menubar.Item onclick={() => engine.selectAll()} disabled={noFiles}>
<FileStack size="16" />
{i18n._('menu.select_all')}
<Shortcut key="A" ctrl={true} />
</Menubar.Item>
<Menubar.Item
onclick={() => {
if ($selection.size > 0) {
boundsManager.centerMapOnSelection();
}
}}
disabled={$selection.size == 0}
>
<Maximize size="16" />
{i18n._('menu.center')}
<Shortcut key="⏎" ctrl={true} />
</Menubar.Item>
{#if $treeFileView}
<Menubar.Separator />
<Menubar.Item
onclick={() => selection.copySelection()}
disabled={$selection.size === 0}
>
<Menubar.Item onclick={() => engine.copy()} disabled={selectionCount === 0}>
<ClipboardCopy size="16" />
{i18n._('menu.copy')}
<Shortcut key="C" ctrl={true} />
</Menubar.Item>
<Menubar.Item
onclick={() => selection.cutSelection()}
disabled={$selection.size === 0}
>
<Menubar.Item onclick={() => engine.cut()} disabled={selectionCount === 0}>
<Scissors size="16" />
{i18n._('menu.cut')}
<Shortcut key="X" ctrl={true} />
</Menubar.Item>
<Menubar.Item
disabled={$copied === undefined ||
$copied.length === 0 ||
($selection.size > 0 &&
!allowedPastes[$copied[0].level].includes(
$selection.getSelected().pop()!.level
))}
onclick={pasteSelection}
>
<Menubar.Item disabled={!$canPaste} onclick={() => engine.paste()}>
<ClipboardPaste size="16" />
{i18n._('menu.paste')}
<Shortcut key="V" ctrl={true} />
</Menubar.Item>
{/if}
<Menubar.Separator />
<Menubar.Item
onclick={() => tick().then(fileActions.deleteSelection)}
disabled={$selection.size == 0}
>
<Trash2 size="16" />
{i18n._('menu.delete')}
<Shortcut key="⌫" ctrl={true} />
</Menubar.Item>
{#if !desktop}
<Menubar.Separator />
<Menubar.Item
onclick={() => engine.delete()}
disabled={selectionCount == 0}
>
<Trash2 size="16" />
{i18n._('menu.delete')}
<Shortcut key="⌫" ctrl={true} />
</Menubar.Item>
{/if}
</Menubar.Content>
</Menubar.Menu>
<Menubar.Menu>
@@ -391,6 +418,34 @@
/>
</Menubar.CheckboxItem>
<Menubar.Separator />
<Menubar.Item
inset
onclick={() => engine.setSelectionHidden(!$allHidden)}
disabled={selectionCount == 0}
>
{#if $allHidden}
<Eye size="16" />
{i18n._('menu.unhide')}
{:else}
<EyeOff size="16" />
{i18n._('menu.hide')}
{/if}
<Shortcut key="H" ctrl={true} />
</Menubar.Item>
<Menubar.Item
inset
onclick={() => {
if (selectionCount > 0) {
boundsManager.centerMapOnSelection();
}
}}
disabled={selectionCount == 0}
>
<Maximize size="16" />
{i18n._('menu.center')}
<Shortcut key="⏎" ctrl={true} />
</Menubar.Item>
<Menubar.Separator />
<Menubar.Item inset onclick={() => map.toggle3D()}>
<Box size="16" />
{i18n._('menu.toggle_3d')}
@@ -472,8 +527,8 @@
</Menubar.SubTrigger>
<Menubar.SubContent>
<Menubar.RadioGroup value={i18n.lang}>
{#each Object.entries(languages) as [lang, label]}
<a href={getURLForLanguage(lang, '/app')}>
{#each Object.entries(languages) as [lang, label] (lang)}
<a href={resolve(getURLForLanguage(lang, '/app'))}>
<Menubar.RadioItem value={lang}>{label}</Menubar.RadioItem>
</a>
{/each}
@@ -560,6 +615,7 @@
</div>
<Export />
<UnsavedDialog />
<LayerControlSettings bind:open={layerSettingsOpen} />
<svelte:window
@@ -581,64 +637,78 @@
if (e.key === '+' && (e.metaKey || e.ctrlKey)) {
createFile();
e.preventDefault();
} else if (e.key === 'o' && (e.metaKey || e.ctrlKey)) {
triggerFileInput();
} else if ((e.key === 'o' || e.key === 'O') && (e.metaKey || e.ctrlKey)) {
if (desktop && e.shiftKey) {
openFolder();
} else {
openFiles();
}
e.preventDefault();
} else if (desktop && (e.key === 'w' || e.key === 'W') && (e.metaKey || e.ctrlKey)) {
if (e.shiftKey) {
closeAll();
} else {
closeFiles(selectedIds);
}
e.preventDefault();
} else if (desktop && (e.key === 's' || e.key === 'S') && (e.metaKey || e.ctrlKey)) {
if (e.shiftKey) {
saveAll();
} else {
saveSelected(selectedIds);
}
e.preventDefault();
} else if (e.key === 'd' && (e.metaKey || e.ctrlKey)) {
fileActions.duplicateSelection();
engine.duplicate();
e.preventDefault();
} else if (e.key === 'c' && (e.metaKey || e.ctrlKey)) {
if (!targetInput) {
selection.copySelection();
engine.copy();
e.preventDefault();
}
} else if (e.key === 'x' && (e.metaKey || e.ctrlKey)) {
if (!targetInput) {
selection.cutSelection();
engine.cut();
e.preventDefault();
}
} else if (e.key === 'v' && (e.metaKey || e.ctrlKey)) {
if (!targetInput) {
pasteSelection();
engine.paste();
e.preventDefault();
}
} else if ((e.key === 's' || e.key == 'S') && (e.metaKey || e.ctrlKey)) {
if (e.shiftKey) {
if (fileStateCollection.size > 0) {
if (!noFiles) {
exportState.current = ExportState.ALL;
}
} else if ($selection.size > 0) {
} else if (selectionCount > 0) {
exportState.current = ExportState.SELECTION;
}
e.preventDefault();
} else if ((e.key === 'z' || e.key == 'Z') && (e.metaKey || e.ctrlKey)) {
if (e.shiftKey) {
fileActionManager.redo();
engine.redo();
} else {
fileActionManager.undo();
engine.undo();
}
e.preventDefault();
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) {
if (!targetInput) {
// the desktop app closes files instead
if (!targetInput && !desktop) {
if (e.shiftKey) {
fileActions.deleteAllFiles();
engine.deleteAll();
} else {
fileActions.deleteSelection();
engine.delete();
}
e.preventDefault();
}
} else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
if (!targetInput) {
selection.selectAll();
engine.selectAll();
e.preventDefault();
}
} else if (e.key === 'i' && (e.metaKey || e.ctrlKey)) {
if (
$selection.size === 1 &&
$selection
.getSelected()
.every((item) => item instanceof ListFileItem || item instanceof ListTrackItem)
) {
if (selectionCount === 1 && filesOrTracksSelected) {
editMetadata.current = true;
}
e.preventDefault();
@@ -649,14 +719,10 @@
$treeFileView = !$treeFileView;
e.preventDefault();
} else if (e.key === 'h' && (e.metaKey || e.ctrlKey)) {
if ($allHidden) {
fileActions.setHiddenToSelection(false);
} else {
fileActions.setHiddenToSelection(true);
}
engine.setSelectionHidden(!$allHidden);
e.preventDefault();
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
if ($selection.size > 0) {
if (selectionCount > 0) {
boundsManager.centerMapOnSelection();
}
} else if (e.key === 'F1') {
@@ -681,10 +747,7 @@
e.key === 'ArrowUp'
) {
if (!targetInput) {
selection.updateFromKey(
e.key === 'ArrowRight' || e.key === 'ArrowDown',
e.shiftKey
);
engine.arrowSelect(e.key === 'ArrowRight' || e.key === 'ArrowDown', e.shiftKey);
e.preventDefault();
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
import { Button } from '$lib/components/ui/button';
import { Moon, Sun } from '@lucide/svelte';
import { mode, setMode } from 'mode-watcher';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
let {
class: className = '',
+6 -5
View File
@@ -1,15 +1,16 @@
<script lang="ts">
import { resolve } from '$app/paths';
import Logo from '$lib/components/Logo.svelte';
import { Button } from '$lib/components/ui/button';
import { BookOpenText, House, Map } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import { getURLForLanguage } from '$lib/utils';
</script>
<nav class="sticky top-0 w-full px-12 py-2 bg-background z-50 flex flex-col items-center border-b">
<div class="w-full max-w-5xl flex flex-row items-center gap-4 sm:gap-8">
<a
href={getURLForLanguage(i18n.lang, '/')}
href={resolve(getURLForLanguage(i18n.lang, '/'))}
class="shrink-0 translate-y-0.25 justify-self-start"
>
<Logo class="h-8 xs:hidden" iconOnly={true} width="26" />
@@ -18,7 +19,7 @@
<Button
variant="link"
class="text-base px-0 has-[>svg]:px-0 ml-auto"
href={getURLForLanguage(i18n.lang, '/')}
href={resolve(getURLForLanguage(i18n.lang, '/'))}
>
<House size="18" />
{i18n._('homepage.home')}
@@ -27,7 +28,7 @@
data-sveltekit-reload
variant="link"
class="text-base px-0 has-[>svg]:px-0"
href={getURLForLanguage(i18n.lang, '/app')}
href={resolve(getURLForLanguage(i18n.lang, '/app'))}
>
<Map size="18" />
{i18n._('homepage.app')}
@@ -35,7 +36,7 @@
<Button
variant="link"
class="text-base px-0 has-[>svg]:px-0"
href={getURLForLanguage(i18n.lang, '/help')}
href={resolve(getURLForLanguage(i18n.lang, '/help'))}
>
<BookOpenText size="18" />
{i18n._('menu.help')}
+16
View File
@@ -12,6 +12,19 @@
} = $props();
function handleMouseDown(event: PointerEvent) {
// the text on either side is not selected while dragging, and the cursor stays the one of
// the resizer when the pointer leaves it
event.preventDefault();
const { style } = document.body;
const previous = {
userSelect: style.userSelect,
webkitUserSelect: style.webkitUserSelect,
cursor: style.cursor,
};
style.userSelect = 'none';
style.webkitUserSelect = 'none';
style.cursor = orientation === 'col' ? 'col-resize' : 'row-resize';
const startX = event.clientX;
const startY = event.clientY;
const startAfter = after;
@@ -32,10 +45,13 @@
const handleMouseUp = () => {
window.removeEventListener('pointermove', handleMouseMove);
window.removeEventListener('pointerup', handleMouseUp);
window.removeEventListener('pointercancel', handleMouseUp);
Object.assign(style, previous);
};
window.addEventListener('pointermove', handleMouseMove);
window.addEventListener('pointerup', handleMouseUp);
window.addEventListener('pointercancel', handleMouseUp);
}
</script>
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { isMac, isSafari } from '$lib/utils';
import { onMount } from 'svelte';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import * as Kbd from '$lib/components/ui/kbd/index.js';
let {
+3 -3
View File
@@ -8,9 +8,9 @@
getElevationUnits,
getVelocityUnits,
secondsToHHMMSS,
} from '$lib/units';
import { i18n } from '$lib/i18n.svelte';
import { settings } from '$lib/logic/settings';
} from '$lib/units/units';
import { i18n } from '$lib/i18n/i18n.svelte';
import { settings } from '$lib/state/settings';
let {
value,
@@ -1,28 +1,48 @@
<script lang="ts">
import { setContext, type Snippet } from 'svelte';
import { CollapsibleTreeState } from './utils.svelte';
import { getContext, setContext, type Snippet } from 'svelte';
import { CollapsibleTreeState, StuckRows } from './utils.svelte';
import { stickyKey, type StickyLevel } from './sticky';
const {
defaultState = 'open',
side = 'right',
nohover = false,
slotInsideTrigger = true,
sticky = false,
treeState,
children,
}: {
defaultState?: 'open' | 'closed';
side?: 'left' | 'right';
nohover?: boolean;
slotInsideTrigger?: boolean;
/**
* The rows of the nodes stick to the top of the scrolled area while some of the content of
* their node is in view, each under the rows of the nodes it is in. A tree in a tree is
* like its node.
*/
sticky?: boolean;
/** Which nodes are open, to keep it or to open nodes from the outside. */
treeState?: CollapsibleTreeState;
children: Snippet;
} = $props();
let open = $state(new CollapsibleTreeState(defaultState));
let open = $state(treeState ?? new CollapsibleTreeState(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<StickyLevel>(
stickyKey,
getContext<StickyLevel | undefined>(stickyKey) ?? {
enabled: sticky,
top: 0,
depth: 0,
stuckRows: new StuckRows(),
}
);
</script>
{@render children()}
@@ -5,31 +5,56 @@
import { getContext, setContext, type Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements';
import type { CollapsibleTreeState } from './utils.svelte';
import { stickyKey, stickyZIndex, trackStuck, type StickyLevel } from './sticky';
const props: {
id: string | number;
class?: ClassValue;
trigger: Snippet;
content: Snippet;
/** Gets whether the node is open, for contents that are only worth building then. */
content: Snippet<[boolean]>;
} = $props();
let state = getContext<CollapsibleTreeState>('collapsible-tree-state');
let treeState = getContext<CollapsibleTreeState>('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');
// where the row sticks, under the rows of the nodes this one is in
let level = getContext<StickyLevel>(stickyKey);
let fullId = `${parentId}.${props.id}`;
setContext('collapsible-tree-parent-id', fullId);
let open = state.get(fullId);
let open = treeState.get(fullId);
// the rows of the nodes in this one stick under this row, whatever its height
let rowHeight = $state(0);
setContext<StickyLevel>(stickyKey, {
get enabled() {
return level.enabled;
},
get top() {
return level.top + rowHeight;
},
get depth() {
return level.depth + 1;
},
get stuckRows() {
return level.stuckRows;
},
});
// the row is on top of the content that goes under it
let stuck = $state(false);
// only the deepest row that is stuck has a border: the rows above it run into it
let bordered = $derived(stuck && level.stuckRows.deepest === level.depth);
export function openNode() {
open.current = true;
}
</script>
<Collapsible.Root bind:open={open.current} class={props.class}>
{#snippet row()}
{#if slotInsideTrigger}
<Collapsible.Trigger class="w-full">
<Button
@@ -87,7 +112,36 @@
{/if}
</Button>
{/if}
{/snippet}
<Collapsible.Root bind:open={open.current} class={props.class}>
{#if level.enabled}
<!-- sticks while some of the content of the node is in view, with a border when it does -->
<div
class={['sticky bg-background', bordered && 'border-b']}
style="top: {level.top}px; z-index: {stickyZIndex(level.depth)}"
bind:clientHeight={rowHeight}
{@attach (row) => {
const stop = trackStuck(
row,
() => level.top,
(value) => {
stuck = value;
level.stuckRows.set(fullId, level.depth, value);
}
);
return () => {
stop?.();
level.stuckRows.set(fullId, level.depth, false);
};
}}
>
{@render row()}
</div>
{:else}
{@render row()}
{/if}
<Collapsible.Content>
{@render props.content()}
{@render props.content(open.current)}
</Collapsible.Content>
</Collapsible.Root>
@@ -0,0 +1,79 @@
import type { StuckRows } from './utils.svelte';
/**
* Where the row of a node of a sticky tree sticks: under the rows of the nodes it is in, which stay
* in view as long as some of their content can be seen.
*/
export type StickyLevel = {
/** Whether the rows stick. */
readonly enabled: boolean;
/** How far from the top of the scrolled area the rows of this level stick. */
readonly top: number;
/** How many nodes the rows of this level are in. */
readonly depth: number;
/** Which rows of the tree are stuck. */
readonly stuckRows: StuckRows;
};
/** The key of the context that holds the `StickyLevel`. */
export const stickyKey = 'collapsible-tree-sticky';
/** The rows of the outer levels are above the ones of the inner levels. */
export function stickyZIndex(depth: number): number {
return Math.max(100 - depth, 1);
}
/** The first ancestor that scrolls (or clips), which a sticky row sticks to. */
function scrollParent(node: HTMLElement): HTMLElement | null {
for (let parent = node.parentElement; parent; parent = parent.parentElement) {
if (getComputedStyle(parent).overflowY !== 'visible') {
return parent;
}
}
return null;
}
/**
* Tells whether a sticky row is stuck, and in view: it is held at `top()` from the top of the
* scrolled area, further down than the beginning of the node it is in. A row that is on its way out
* with the end of its node (pushed up under the row of the node above) is not.
*/
export function trackStuck(
row: HTMLElement,
top: () => number,
onChange: (stuck: boolean) => void
) {
const node = row.parentElement;
const scroller = scrollParent(row);
if (!node || !scroller) {
return;
}
let stuck = false;
let frame = 0;
const check = () => {
frame = 0;
const rowTop = row.getBoundingClientRect().top;
const held = scroller.getBoundingClientRect().top + scroller.clientTop + top();
const now =
rowTop - node.getBoundingClientRect().top > 0.5 && Math.abs(rowTop - held) < 0.5;
if (now !== stuck) {
stuck = now;
onChange(stuck);
}
};
const schedule = () => {
if (!frame) {
frame = requestAnimationFrame(check);
}
};
scroller.addEventListener('scroll', schedule, { passive: true });
// the node changes when it is opened or closed, or its content does
const observer = new ResizeObserver(schedule);
observer.observe(node);
schedule();
return () => {
scroller.removeEventListener('scroll', schedule);
observer.disconnect();
cancelAnimationFrame(frame);
};
}
@@ -28,4 +28,35 @@ export class CollapsibleTreeState {
}
return this._open[id];
}
/** The node `from` is now `to` (it was renamed or moved): it stays open or closed, and so do the nodes in it. */
move(from: string, to: string) {
for (const id of Object.keys(this._open)) {
if (id === from || id.startsWith(`${from}.`)) {
this._open[to + id.slice(from.length)] = this._open[id];
delete this._open[id];
}
}
}
}
/**
* The rows of a sticky tree that are stuck, by node, with how deep they are: only the deepest one
* gets the border that shows where the rows end.
*/
export class StuckRows {
private rows: Record<string, number> = $state({});
set(id: string, depth: number, stuck: boolean) {
if (stuck) {
this.rows[id] = depth;
} else if (id in this.rows) {
delete this.rows[id];
}
}
/** The depth of the deepest row that is stuck, -1 if none is. */
get deepest(): number {
return Math.max(-1, ...Object.values(this.rows));
}
}
+1 -82
View File
@@ -16,27 +16,6 @@ import {
} from '@lucide/svelte';
import type { Component } 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',
],
'map-controls': [],
gpx: [],
integration: [],
faq: [],
};
export const guideIcons: Record<string, string | Component<IconProps>> = {
'getting-started': '🚀',
menu: '📂 ⚙️',
@@ -61,64 +40,4 @@ export const guideIcons: Record<string, string | Component<IconProps>> = {
faq: '🔮',
};
export function getPreviousGuide(currentGuide: string): string | undefined {
let subguides = currentGuide.split('/');
if (subguides.length === 1) {
let keys = Object.keys(guides);
let index = keys.indexOf(currentGuide);
if (index === 0) {
return undefined;
}
let previousGuide = keys[index - 1];
if (previousGuide === undefined) {
return undefined;
} else if (guides[previousGuide].length === 0) {
return previousGuide;
} else {
return `${previousGuide}/${guides[previousGuide][guides[previousGuide].length - 1]}`;
}
} else {
if (guides.hasOwnProperty(subguides[0])) {
let subguideIndex = guides[subguides[0]].indexOf(subguides[1]);
if (subguideIndex > 0) {
return `${subguides[0]}/${guides[subguides[0]][subguideIndex - 1]}`;
} else {
return subguides[0];
}
} else {
return undefined;
}
}
}
export function getNextGuide(currentGuide: string): string | undefined {
let subguides = currentGuide.split('/');
if (subguides.length === 1) {
if (guides.hasOwnProperty(currentGuide)) {
if (guides[currentGuide].length === 0) {
let keys = Object.keys(guides);
let index = keys.indexOf(currentGuide);
return keys[index + 1];
} else {
return `${currentGuide}/${guides[currentGuide][0]}`;
}
} else {
return undefined;
}
} else {
if (guides.hasOwnProperty(subguides[0])) {
let subguideIndex = guides[subguides[0]].indexOf(subguides[1]);
if (subguideIndex < guides[subguides[0]].length - 1) {
return `${subguides[0]}/${guides[subguides[0]][subguideIndex + 1]}`;
} else {
let keys = Object.keys(guides);
let index = keys.indexOf(subguides[0]);
return keys[index + 1];
}
} else {
return undefined;
}
}
}
export { guides, getNextGuide, getPreviousGuide } from './guides';
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { describe, it } from 'node:test';
import { languages } from '$lib/i18n/languages';
import { getNextGuide, getPreviousGuide, guides } from './guides';
/** Every page of the help in reading order, as the links at the bottom of the pages follow them. */
const pages = Object.entries(guides).flatMap(([guide, subguides]) => [
guide,
...subguides.map((subguide) => `${guide}/${subguide}`),
]);
describe('guides', () => {
it('go from one page to the next, subguides included', () => {
assert.equal(getNextGuide('getting-started'), 'menu');
assert.equal(getNextGuide('menu'), 'menu/file');
assert.equal(getNextGuide('menu/file'), 'menu/edit');
assert.equal(getNextGuide('menu/settings'), 'files-and-stats');
assert.equal(getNextGuide('toolbar/clean'), 'map-controls');
assert.equal(getNextGuide('integration'), 'faq');
});
it('go from one page to the previous one, subguides included', () => {
assert.equal(getPreviousGuide('menu'), 'getting-started');
assert.equal(getPreviousGuide('menu/file'), 'menu');
assert.equal(getPreviousGuide('menu/edit'), 'menu/file');
assert.equal(getPreviousGuide('files-and-stats'), 'menu/settings');
assert.equal(getPreviousGuide('map-controls'), 'toolbar/clean');
assert.equal(getPreviousGuide('faq'), 'integration');
});
it('stop at the ends', () => {
assert.equal(getPreviousGuide('getting-started'), undefined);
assert.equal(getNextGuide('faq'), undefined);
});
it('know nothing of pages that do not exist', () => {
assert.equal(getNextGuide('nope'), undefined);
assert.equal(getNextGuide('nope/sub'), undefined);
assert.equal(getPreviousGuide('nope'), undefined);
assert.equal(getPreviousGuide('nope/sub'), undefined);
});
it('walk through every page in both directions', () => {
const forward = [pages[0]];
for (let next = getNextGuide(pages[0]); next; next = getNextGuide(next)) {
forward.push(next);
}
assert.deepEqual(forward, pages);
const backward = [pages.at(-1)!];
for (
let previous = getPreviousGuide(pages.at(-1)!);
previous;
previous = getPreviousGuide(previous)
) {
backward.push(previous);
}
assert.deepEqual(backward.reverse(), pages);
});
it('are written in every language of the site', () => {
const root = new URL('../../docs/', import.meta.url);
const missing: string[] = [];
for (const language of Object.keys(languages)) {
for (const page of pages) {
if (!existsSync(new URL(`${language}/${page}.mdx`, root))) {
missing.push(`${language}/${page}`);
}
}
}
assert.deepEqual(missing, []);
});
it('have a translation of the interface for every language of the site', () => {
const root = new URL('../../../locales/', import.meta.url);
for (const language of Object.keys(languages)) {
assert.ok(existsSync(new URL(`${language}.json`, root)), language);
}
});
});
+84
View File
@@ -0,0 +1,84 @@
// The guides of the help section, and how they follow one another. The icons are in `docs.ts`.
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',
],
'map-controls': [],
gpx: [],
integration: [],
faq: [],
};
export function getPreviousGuide(currentGuide: string): string | undefined {
const subguides = currentGuide.split('/');
if (subguides.length === 1) {
const keys = Object.keys(guides);
const index = keys.indexOf(currentGuide);
if (index === 0) {
return undefined;
}
const previousGuide = keys[index - 1];
if (previousGuide === undefined) {
return undefined;
} else if (guides[previousGuide].length === 0) {
return previousGuide;
} else {
return `${previousGuide}/${guides[previousGuide][guides[previousGuide].length - 1]}`;
}
} else {
if (Object.hasOwn(guides, subguides[0])) {
const subguideIndex = guides[subguides[0]].indexOf(subguides[1]);
if (subguideIndex > 0) {
return `${subguides[0]}/${guides[subguides[0]][subguideIndex - 1]}`;
} else {
return subguides[0];
}
} else {
return undefined;
}
}
}
export function getNextGuide(currentGuide: string): string | undefined {
const subguides = currentGuide.split('/');
if (subguides.length === 1) {
if (Object.hasOwn(guides, currentGuide)) {
if (guides[currentGuide].length === 0) {
const keys = Object.keys(guides);
const index = keys.indexOf(currentGuide);
return keys[index + 1];
} else {
return `${currentGuide}/${guides[currentGuide][0]}`;
}
} else {
return undefined;
}
} else {
if (Object.hasOwn(guides, subguides[0])) {
const subguideIndex = guides[subguides[0]].indexOf(subguides[1]);
if (subguideIndex < guides[subguides[0]].length - 1) {
return `${subguides[0]}/${guides[subguides[0]][subguideIndex + 1]}`;
} else {
const keys = Object.keys(guides);
const index = keys.indexOf(subguides[0]);
return keys[index + 1];
}
} else {
return undefined;
}
}
}
@@ -18,23 +18,25 @@
Construction,
} from '@lucide/svelte';
import type { Readable, Writable } from 'svelte/store';
import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
import { settings } from '$lib/logic/settings';
import { i18n } from '$lib/i18n.svelte';
import type { Coordinates } from '$lib/geo/coordinates';
import type { SelectionStatistics } from '$lib/engine';
import type { SlicedStatistics } from '$lib/state/selection-statistics';
import { settings } from '$lib/state/settings';
import { i18n } from '$lib/i18n/i18n.svelte';
import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile';
const { velocityUnits } = settings;
let {
gpxStatistics,
slicedGPXStatistics,
statistics,
slicedStatistics,
hoveredPoint,
additionalDatasets,
elevationFill,
showControls = true,
}: {
gpxStatistics: Readable<GPXStatisticsGroup>;
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
statistics: Readable<SelectionStatistics>;
slicedStatistics: Writable<SlicedStatistics | undefined>;
hoveredPoint: Writable<Coordinates | null>;
additionalDatasets: Writable<string[]>;
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
@@ -47,8 +49,8 @@
onMount(() => {
elevationProfile = new ElevationProfile(
gpxStatistics,
slicedGPXStatistics,
statistics,
slicedStatistics,
hoveredPoint,
additionalDatasets,
elevationFill,
@@ -1,5 +1,5 @@
import { i18n } from '$lib/i18n.svelte';
import { settings } from '$lib/logic/settings';
import { i18n } from '$lib/i18n/i18n.svelte';
import { settings } from '$lib/state/settings';
import {
getCadenceWithUnits,
getConvertedDistance,
@@ -13,7 +13,7 @@ import {
getPowerWithUnits,
getTemperatureWithUnits,
getVelocityWithUnits,
} from '$lib/units';
} from '$lib/units/units';
import Chart, {
type ChartEvent,
type ChartOptions,
@@ -21,26 +21,37 @@ import Chart, {
type TooltipItem,
} from 'chart.js/auto';
import { get, type Readable, type Writable } from 'svelte/store';
import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
import type { Coordinates } from '$lib/geo/coordinates';
import {
engine,
EMPTY_SELECTION_STATISTICS,
type SelectionPoint,
type SelectionStatistics,
type StatisticsMetric,
type StatisticsRequest,
} from '$lib/engine';
import type { SlicedStatistics } from '$lib/state/selection-statistics';
import { mode } from 'mode-watcher';
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors';
import { categoryValue, intervalAt } from '$lib/engine/trackpoint-categories';
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/data/colors';
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
Chart.defaults.font.family =
'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'; // Tailwind CSS font
/** `undefined` for a missing measure (NaN), so that Chart.js leaves a gap instead of a value. */
function optional(value: number, convert: (value: number) => number = (v) => v) {
return Number.isNaN(value) ? undefined : convert(value);
}
/**
* A point of the chart. The rest of the data of the trackpoint is only read when it is hovered
* (see `SelectionStatistics.point`), and the categories of the fill from their intervals.
*/
interface ElevationProfilePoint {
x: number;
y: number;
time?: Date;
slope: {
at: number;
segment: number;
length: number;
};
extensions: Record<string, any>;
coordinates: Coordinates;
index: number;
}
@@ -51,37 +62,51 @@ export class ElevationProfile {
private _dragging = false;
private _panning = false;
private _gpxStatistics: Readable<GPXStatisticsGroup>;
private _slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
private _statistics: Readable<SelectionStatistics>;
/** The statistics that the chart shows, which the fills read for each of its segments. */
private _data: SelectionStatistics = EMPTY_SELECTION_STATISTICS;
/**
* The hovered trackpoint, and its data once it is read from the engine (`undefined` if the
* statistics changed in the meantime).
*/
private _hovered: { index: number; loaded: boolean; point?: SelectionPoint } | null = null;
private _loadingHovered = false;
/** The last move over the chart, held until the data of its trackpoint is read. */
private _heldMove: MouseEvent | null = null;
private _slicedStatistics: Writable<SlicedStatistics | undefined>;
private _hoveredPoint: Writable<Coordinates | null>;
private _additionalDatasets: Readable<string[]>;
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
/** The statistics that are only read from the engine while the profile shows them. */
private _request: StatisticsRequest;
constructor(
gpxStatistics: Readable<GPXStatisticsGroup>,
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>,
statistics: Readable<SelectionStatistics>,
slicedStatistics: Writable<SlicedStatistics | undefined>,
hoveredPoint: Writable<Coordinates | null>,
additionalDatasets: Readable<string[]>,
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
canvas: HTMLCanvasElement,
overlay: HTMLCanvasElement
) {
this._gpxStatistics = gpxStatistics;
this._slicedGPXStatistics = slicedGPXStatistics;
this._statistics = statistics;
this._slicedStatistics = slicedStatistics;
this._hoveredPoint = hoveredPoint;
this._additionalDatasets = additionalDatasets;
this._elevationFill = elevationFill;
this._canvas = canvas;
this._overlay = overlay;
this._request = engine.requestStatistics();
this.updateRequest();
import('chartjs-plugin-zoom').then((module) => {
Chart.register(module.default);
this.initialize();
this._gpxStatistics.subscribe(() => {
this._statistics.subscribe(() => {
this.updateData();
});
this._slicedGPXStatistics.subscribe(() => {
this._slicedStatistics.subscribe(() => {
this.updateOverlay();
});
distanceUnits.subscribe(() => {
@@ -94,16 +119,36 @@ export class ElevationProfile {
this.updateData();
});
this._additionalDatasets.subscribe(() => {
// requesting a missing metric updates the data, and then the visibility
this.updateRequest();
this.updateDataVisibility();
});
this._elevationFill.subscribe(() => {
this.updateRequest();
this.updateFill();
});
});
}
/** Asks the engine for what the profile currently shows, and nothing more. */
updateRequest() {
const metrics = new Set<StatisticsMetric>();
for (const dataset of get(this._additionalDatasets)) {
if (['speed', 'hr', 'cad', 'atemp', 'power'].includes(dataset)) {
metrics.add(dataset as StatisticsMetric);
}
}
const elevationFill = get(this._elevationFill);
if (elevationFill === 'slope') {
metrics.add('slopeSegment');
} else if (elevationFill) {
metrics.add(elevationFill);
}
this._request.set(metrics);
}
initialize() {
let options: ChartOptions<'line'> = {
const options: ChartOptions<'line'> = {
animation: false,
parsing: false,
maintainAspectRatio: false,
@@ -133,6 +178,7 @@ export class ElevationProfile {
tension: 0.4,
borderWidth: 2,
cubicInterpolationMode: 'monotone',
spanGaps: true,
},
},
interaction: {
@@ -154,13 +200,16 @@ export class ElevationProfile {
return '';
},
label: (context: TooltipItem<'line'>) => {
let point = context.raw as ElevationProfilePoint;
const point = context.raw as ElevationProfilePoint;
if (context.datasetIndex === 0) {
if (this._dragging) {
this._hoveredPoint.set(null);
} else {
this._hoveredPoint.set(point.coordinates);
}
this._hoveredPoint.set(
this._dragging
? null
: {
lat: this._data.lat[point.index],
lng: this._data.lng[point.index],
}
);
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 1) {
return `${get(velocityUnits) === 'speed' ? i18n._('quantities.speed') : i18n._('quantities.pace')}: ${getVelocityWithUnits(point.y, false)}`;
@@ -175,35 +224,47 @@ export class ElevationProfile {
}
},
afterBody: (contexts: TooltipItem<'line'>[]) => {
let context = contexts.filter((context) => context.datasetIndex === 0);
const context = contexts.filter(
(context) => context.datasetIndex === 0
);
if (context.length === 0) return;
let point = context[0].raw as ElevationProfilePoint;
let slope = {
at: point.slope.at.toFixed(1),
segment: point.slope.segment.toFixed(1),
length: getDistanceWithUnits(point.slope.length),
};
let surface = point.extensions.surface
? point.extensions.surface
: 'unknown';
let highway = point.extensions.highway
? point.extensions.highway
: 'unknown';
let sacScale = point.extensions.sac_scale;
let mtbScale = point.extensions.mtb_scale;
const point = context[0].raw as ElevationProfilePoint;
// the rest of the data is there once it is read (see `hover`)
const hovered = this._hovered;
const details =
hovered?.index === point.index ? hovered.point : undefined;
const elevationFill = get(this._elevationFill);
let labels = [
const labels = [
` ${i18n._('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`,
` ${i18n._('quantities.slope')}: ${slope.at} %${get(this._elevationFill) === 'slope' ? ` (${slope.length} @${slope.segment} %)` : ''}`,
];
if (get(this._elevationFill) === 'surface') {
if (!details) {
return labels;
}
if (details.slope !== undefined) {
let slope = `${details.slope.toFixed(1)} %`;
if (
elevationFill === 'slope' &&
details.slopeSegmentSlope !== undefined &&
details.slopeSegmentDistance !== undefined
) {
slope += ` (${getDistanceWithUnits(details.slopeSegmentDistance)} @${details.slopeSegmentSlope.toFixed(1)} %)`;
}
labels.push(` ${i18n._('quantities.slope')}: ${slope}`);
}
const surface = details.surface ?? 'unknown';
const highway = details.highway ?? 'unknown';
const sacScale = details.sacScale;
const mtbScale = details.mtbScale;
if (elevationFill === 'surface') {
labels.push(
` ${i18n._('quantities.surface')}: ${i18n._(`toolbar.routing.surface.${surface}`)}`
);
}
if (get(this._elevationFill) === 'highway') {
if (elevationFill === 'highway') {
labels.push(
` ${i18n._('quantities.highway')}: ${i18n._(`toolbar.routing.highway.${highway}`)}${
sacScale
@@ -218,9 +279,9 @@ export class ElevationProfile {
}
}
if (point.time) {
if (details.time !== undefined) {
labels.push(
` ${i18n._('quantities.time')}: ${i18n.df.format(point.time)}`
` ${i18n._('quantities.time')}: ${i18n.df.format(new Date(details.time))}`
);
}
@@ -235,7 +296,7 @@ export class ElevationProfile {
modifierKey: 'shift',
onPanStart: () => {
this._panning = true;
this._slicedGPXStatistics.set(undefined);
this._slicedStatistics.set(undefined);
return true;
},
onPanComplete: () => {
@@ -247,7 +308,7 @@ export class ElevationProfile {
enabled: true,
},
mode: 'x',
onZoomStart: ({ chart, event }: { chart: Chart; event: any }) => {
onZoomStart: ({ event }: { event: any }) => {
if (!this._chart) {
return false;
}
@@ -260,7 +321,7 @@ export class ElevationProfile {
return false;
}
this._slicedGPXStatistics.set(undefined);
this._slicedStatistics.set(undefined);
},
},
limits: {
@@ -277,7 +338,7 @@ export class ElevationProfile {
},
};
let datasets: string[] = ['speed', 'hr', 'cad', 'atemp', 'power'];
const datasets: string[] = ['speed', 'hr', 'cad', 'atemp', 'power'];
datasets.forEach((id) => {
options.scales![`y${id}`] = {
type: 'linear',
@@ -285,7 +346,8 @@ export class ElevationProfile {
grid: {
display: false,
},
reverse: () => id === 'speed' && get(velocityUnits) === 'pace',
// the pace axis is reversed: set with the data, which the units change
reverse: false,
display: false,
};
});
@@ -302,10 +364,32 @@ export class ElevationProfile {
events: ['mouseout'],
afterEvent: (chart: Chart, args: { event: ChartEvent }) => {
if (args.event.type === 'mouseout') {
this._hovered = null;
this._heldMove = null;
this._hoveredPoint.set(null);
}
},
},
{
// The tooltip only shows a trackpoint once all its data is read: a move to
// another one is held until then, and replayed (see `loadHovered`).
id: 'waitForTrackpoint',
events: ['mousemove'],
beforeEvent: (chart: Chart, args: { event: ChartEvent }) => {
const index = this.indexAt(args.event);
if (index === undefined) {
return;
}
const hovered = this._hovered;
if (hovered?.index === index && hovered.loaded) {
this._heldMove = null;
return;
}
this._heldMove = args.event.native as MouseEvent;
this.hover(index);
return false;
},
},
],
});
@@ -361,14 +445,15 @@ export class ElevationProfile {
if (startIndex === undefined) {
startIndex = endIndex;
} else if (startIndex !== endIndex) {
this._slicedGPXStatistics.set([
get(this._gpxStatistics).sliced(
Math.min(startIndex, endIndex),
Math.max(startIndex, endIndex)
),
Math.min(startIndex, endIndex),
Math.max(startIndex, endIndex),
]);
const start = Math.min(startIndex, endIndex);
const end = Math.max(startIndex, endIndex);
get(this._statistics)
.slice(start, end)
.then((global) => {
if (global) {
this._slicedStatistics.set({ global, start, end });
}
});
}
}
}
@@ -379,7 +464,7 @@ export class ElevationProfile {
this._canvas.style.cursor = '';
endIndex = getIndex(evt);
if (startIndex === endIndex) {
this._slicedGPXStatistics.set(undefined);
this._slicedStatistics.set(undefined);
}
};
this._canvas.addEventListener('pointerdown', onMouseDown);
@@ -391,7 +476,10 @@ export class ElevationProfile {
if (!this._chart) {
return;
}
const data = get(this._gpxStatistics);
const data = get(this._statistics);
this._data = data;
// the indices of the trackpoints are the ones of other statistics
this._hovered = null;
const units = {
distance: get(distanceUnits),
velocity: get(velocityUnits),
@@ -399,52 +487,42 @@ export class ElevationProfile {
};
const datasets: Array<Array<any>> = [[], [], [], [], [], []];
data.forEachTrackPoint((trkpt, distance, speed, slope, index) => {
const { global } = data;
for (let index = 0; index < data.length; index++) {
const x = getConvertedDistance(data.totalDistance[index], units.distance);
datasets[0].push({
x: getConvertedDistance(distance, units.distance),
y: trkpt.ele ? getConvertedElevation(trkpt.ele, units.distance) : 0,
time: trkpt.time,
slope: slope,
extensions: trkpt.getExtensions(),
coordinates: trkpt.getCoordinates(),
index: index,
x,
y: getConvertedElevation(data.ele[index], units.distance),
index,
});
if (data.global.time.total > 0) {
if (data.speed && (global.totalTime ?? 0) > 0) {
datasets[1].push({
x: getConvertedDistance(distance, units.distance),
y: getConvertedVelocity(speed, units.velocity, units.distance),
index: index,
x,
y: optional(data.speed[index], (value) =>
getConvertedVelocity(value, units.velocity, units.distance)
),
index,
});
}
if (data.global.hr.count > 0) {
datasets[2].push({
x: getConvertedDistance(distance, units.distance),
y: trkpt.getHeartRate(),
index: index,
});
if (data.hr) {
datasets[2].push({ x, y: optional(data.hr[index]), index });
}
if (data.global.cad.count > 0) {
datasets[3].push({
x: getConvertedDistance(distance, units.distance),
y: trkpt.getCadence(),
index: index,
});
if (data.cad) {
datasets[3].push({ x, y: optional(data.cad[index]), index });
}
if (data.global.atemp.count > 0) {
if (data.atemp) {
datasets[4].push({
x: getConvertedDistance(distance, units.distance),
y: getConvertedTemperature(trkpt.getTemperature(), units.temperature),
index: index,
x,
y: optional(data.atemp[index], (value) =>
getConvertedTemperature(value, units.temperature)
),
index,
});
}
if (data.global.power.count > 0) {
datasets[5].push({
x: getConvertedDistance(distance, units.distance),
y: trkpt.getPower(),
index: index,
});
if (data.power) {
datasets[5].push({ x, y: optional(data.power[index]), index });
}
});
}
this._chart.data.datasets[0] = {
label: i18n._('quantities.elevation'),
@@ -482,9 +560,11 @@ export class ElevationProfile {
this._chart.options.scales!.x!['min'] = 0;
this._chart.options.scales!.x!['max'] = getConvertedDistance(
data.global.distance.total,
global.totalDistance,
units.distance
);
// a faster pace is a lower value, it goes up like a higher speed
this._chart.options.scales!.yspeed!.reverse = units.velocity === 'pace';
this.setVisibility();
this.setFill();
@@ -506,11 +586,11 @@ export class ElevationProfile {
}
const additionalDatasets = get(this._additionalDatasets);
let includeSpeed = additionalDatasets.includes('speed');
let includeHeartRate = additionalDatasets.includes('hr');
let includeCadence = additionalDatasets.includes('cad');
let includeTemperature = additionalDatasets.includes('atemp');
let includePower = additionalDatasets.includes('power');
const includeSpeed = additionalDatasets.includes('speed');
const includeHeartRate = additionalDatasets.includes('hr');
const includeCadence = additionalDatasets.includes('cad');
const includeTemperature = additionalDatasets.includes('atemp');
const includePower = additionalDatasets.includes('power');
if (this._chart.data.datasets.length == 6) {
this._chart.data.datasets[1].hidden = !includeSpeed;
this._chart.data.datasets[2].hidden = !includeHeartRate;
@@ -563,26 +643,24 @@ export class ElevationProfile {
this._overlay.style.width = `${this._overlay.width}px`;
this._overlay.style.height = `${this._overlay.height}px`;
const slicedGPXStatistics = get(this._slicedGPXStatistics);
if (slicedGPXStatistics) {
let startIndex = slicedGPXStatistics[1];
let endIndex = slicedGPXStatistics[2];
const slicedStatistics = get(this._slicedStatistics);
if (slicedStatistics) {
const startIndex = slicedStatistics.start;
const endIndex = slicedStatistics.end;
// Draw selection rectangle
let selectionContext = this._overlay.getContext('2d');
const selectionContext = this._overlay.getContext('2d');
if (selectionContext) {
selectionContext.fillStyle = mode.current === 'dark' ? 'white' : 'black';
selectionContext.globalAlpha = mode.current === 'dark' ? 0.2 : 0.1;
selectionContext.clearRect(0, 0, this._overlay.width, this._overlay.height);
const gpxStatistics = get(this._gpxStatistics);
let startPixel = this._chart.scales.x.getPixelForValue(
getConvertedDistance(
gpxStatistics.getTrackPoint(startIndex)?.distance.total ?? 0
)
const statistics = get(this._statistics);
const startPixel = this._chart.scales.x.getPixelForValue(
getConvertedDistance(statistics.totalDistance[startIndex] ?? 0)
);
let endPixel = this._chart.scales.x.getPixelForValue(
getConvertedDistance(gpxStatistics.getTrackPoint(endIndex)?.distance.total ?? 0)
const endPixel = this._chart.scales.x.getPixelForValue(
getConvertedDistance(statistics.totalDistance[endIndex] ?? 0)
);
selectionContext.fillRect(
@@ -593,33 +671,93 @@ export class ElevationProfile {
);
}
} else if (this._overlay) {
let selectionContext = this._overlay.getContext('2d');
const selectionContext = this._overlay.getContext('2d');
if (selectionContext) {
selectionContext.clearRect(0, 0, this._overlay.width, this._overlay.height);
}
}
}
slopeFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
const point = context.p0.raw as ElevationProfilePoint;
return getSlopeColor(point.slope.segment);
}
surfaceFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
const point = context.p0.raw as ElevationProfilePoint;
return getSurfaceColor(point.extensions.surface);
}
highwayFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
const point = context.p0.raw as ElevationProfilePoint;
return getHighwayColor(
point.extensions.highway,
point.extensions.sac_scale,
point.extensions.mtb_scale
/** The trackpoint that the tooltip shows for an event, `undefined` outside of the chart. */
private indexAt(event: ChartEvent): number | undefined {
const elements = this._chart?.getElementsAtEventForMode(
event as unknown as Event,
'nearest',
{ axis: 'x', intersect: false },
false
);
const element = elements?.find((element) => element.datasetIndex === 0);
// the data of the chart can be decimated: its points are the ones of the elements
const point = element && this._chart?.data.datasets[0].data[element.index];
return (point as ElevationProfilePoint | undefined)?.index;
}
/**
* Reads the data of the hovered trackpoint from the engine, for the tooltip. One trackpoint is
* read at a time, the last one hovered.
*/
hover(index: number) {
if (this._hovered?.index === index) {
return;
}
this._hovered = { index, loaded: false };
this.loadHovered();
}
private async loadHovered() {
if (this._loadingHovered) {
return; // the one being read is followed by the last one hovered
}
this._loadingHovered = true;
try {
while (this._hovered && !this._hovered.loaded) {
const hovered = this._hovered;
const point = await this._data.point(hovered.index);
if (this._hovered !== hovered) {
continue; // another trackpoint is hovered, or none
}
hovered.loaded = true;
hovered.point = point;
// the move that was held can now show the tooltip
const move = this._heldMove;
this._heldMove = null;
if (move) {
this._canvas.dispatchEvent(new MouseEvent(move.type, move));
}
}
} finally {
this._loadingHovered = false;
}
}
/** The trackpoint that a segment of the chart starts at. */
private static segmentStart(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
return (context.p0.raw as ElevationProfilePoint).index;
}
slopeFillCallback = (context: ScriptableLineSegmentContext & { p0: { raw: any } }) => {
const segments = this._data.slopeSegments;
const index = ElevationProfile.segmentStart(context);
const segment = segments ? intervalAt(segments.starts, index) : -1;
return getSlopeColor(segment >= 0 ? segments!.slopes[segment] : NaN);
};
surfaceFillCallback = (context: ScriptableLineSegmentContext & { p0: { raw: any } }) => {
const index = ElevationProfile.segmentStart(context);
return getSurfaceColor(categoryValue(this._data.surface, index) ?? '');
};
highwayFillCallback = (context: ScriptableLineSegmentContext & { p0: { raw: any } }) => {
const index = ElevationProfile.segmentStart(context);
return getHighwayColor(
categoryValue(this._data.highway, index) ?? '',
categoryValue(this._data.sacScale, index),
categoryValue(this._data.mtbScale, index)
);
};
destroy() {
this._request.release();
if (this._chart) {
this._chart.destroy();
this._chart = null;
@@ -7,21 +7,18 @@
import LayerControl from '$lib/components/map/layer-control/LayerControl.svelte';
import OpenIn from '$lib/components/embedding/OpenIn.svelte';
import { writable } from 'svelte/store';
import type { GPXFile } from 'gpx';
import {
allowedEmbeddingBasemaps,
getFilesFromEmbeddingOptions,
type EmbeddingOptions,
} from './embedding';
import { setMode } from 'mode-watcher';
import { settings } from '$lib/logic/settings';
import { fileStateCollection } from '$lib/logic/file-state';
import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics';
import { loadFile } from '$lib/logic/file-actions';
import { selection } from '$lib/logic/selection';
import { settings } from '$lib/state/settings';
import { loadFiles } from '$lib/actions/file-actions';
import { untrack } from 'svelte';
import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
import { boundsManager } from '$lib/logic/bounds';
import { engine } from '$lib/engine';
import { hoveredPoint, slicedStatistics } from '$lib/state/selection-statistics';
let {
useHash = true,
@@ -29,16 +26,18 @@
hash = $bindable(),
}: { useHash?: boolean; options: EmbeddingOptions; hash: string } = $props();
const { files } = engine;
let additionalDatasets = writable<string[]>([]);
let elevationFill = writable<'slope' | 'surface' | 'highway' | undefined>(undefined);
const { statistics } = engine;
const {
currentBasemap,
selectedBasemapTree,
distanceUnits,
velocityUnits,
temperatureUnits,
fileOrder,
distanceMarkers,
directionMarkers,
} = settings;
@@ -72,27 +71,14 @@
);
elevationFill.set(options.elevation.fill == 'none' ? undefined : options.elevation.fill);
let downloads: Promise<GPXFile | null>[] = getFilesFromEmbeddingOptions(options).map(
(url) => {
return fetch(url)
.then((response) => response.blob())
.then((blob) => new File([blob], url.split('/').pop() ?? url))
.then(loadFile);
}
);
Promise.all(downloads).then((answers) => {
const files = answers.filter((file) => file !== null) as GPXFile[];
let ids: string[] = [];
files.forEach((file, index) => {
let id = `gpx-${index}-embed`;
file._data.id = id;
ids.push(id);
});
fileStateCollection.setEmbeddedFiles(files);
$fileOrder = ids;
selection.selectAll();
boundsManager.fitBoundsOnLoad(ids);
let downloads: Promise<File>[] = getFilesFromEmbeddingOptions(options).map((url) => {
return fetch(url)
.then((response) => response.blob())
.then((blob) => new File([blob], url.split('/').pop() ?? url));
});
Promise.all(downloads)
.then(loadFiles)
.then(() => engine.selectAll());
}
$effect(() => {
@@ -104,7 +90,7 @@
<div class="absolute flex flex-col h-full w-full border rounded-xl overflow-clip">
<div class="grow relative">
<Map
class="h-full {$fileStateCollection.size > 1 ? 'horizontal' : ''}"
class="h-full {$files.size > 1 ? 'horizontal' : ''}"
maptilerKey={options.key}
geocoder={false}
geolocate={true}
@@ -113,8 +99,8 @@
<OpenIn files={options.files} ids={options.ids} />
<LayerControl />
<GPXLayers />
{#if $fileStateCollection.size > 1}
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
{#if $files.size > 1}
<div class="h-10 w-full pointer-events-none absolute bottom-0 left-0 z-30">
<FileList orientation="horizontal" />
</div>
{/if}
@@ -124,14 +110,14 @@
style={options.elevation.show ? `height: ${options.elevation.height}px` : ''}
>
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={options.elevation.show ? 'vertical' : 'horizontal'}
/>
{#if options.elevation.show}
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -15,7 +15,7 @@
Milestone,
Video,
} from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import {
allowedEmbeddingBasemaps,
defaultEmbeddingOptions,
@@ -25,9 +25,10 @@
import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
import Embedding from './Embedding.svelte';
import { onDestroy } from 'svelte';
import { base } from '$app/paths';
import { resolve } from '$app/paths';
import { map } from '$lib/components/map/map';
import { mode } from 'mode-watcher';
import { getURLForLanguage } from '$lib/utils';
let options = $state(
getMergedEmbeddingOptions(
@@ -63,10 +64,10 @@
let manualCamera = $state(false);
let zoom = $state('0');
let lat = $state('0');
let lon = $state('0');
let lng = $state('0');
let bearing = $state('0');
let pitch = $state('0');
let hash = $derived(manualCamera ? `#${zoom}/${lat}/${lon}/${bearing}/${pitch}` : '');
let hash = $derived(manualCamera ? `#${zoom}/${lat}/${lng}/${bearing}/${pitch}` : '');
$effect(() => {
if (options.elevation.show || options.elevation.height) {
@@ -78,7 +79,7 @@
if ($map) {
let center = $map.getCenter();
lat = center.lat.toFixed(4);
lon = center.lng.toFixed(4);
lng = center.lng.toFixed(4);
zoom = $map.getZoom().toFixed(2);
bearing = $map.getBearing().toFixed(1);
pitch = $map.getPitch().toFixed(0);
@@ -114,7 +115,7 @@
{i18n._(`layers.label.${options.basemap}`)}
</Select.Trigger>
<Select.Content class="max-h-60 overflow-y-scroll">
{#each allowedEmbeddingBasemaps as basemap}
{#each allowedEmbeddingBasemaps as basemap (basemap)}
<Select.Item value={basemap}
>{i18n._(`layers.label.${basemap}`)}</Select.Item
>
@@ -293,7 +294,7 @@
</Label>
<Label class="flex flex-col gap-1">
<span>{i18n._('embedding.longitude')}</span>
<span>{lon}</span>
<span>{lng}</span>
</Label>
<Label class="flex flex-col gap-1">
<span>{i18n._('embedding.zoom')}</span>
@@ -321,7 +322,7 @@
<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(iframeOptions)))}${hash}" width="100%" height="600px" frameborder="0" style="outline: none;"/>`}
{`<iframe src="https://gpx.studio${resolve(getURLForLanguage(i18n.lang, '/embed'))}?options=${encodeURIComponent(JSON.stringify(getCleanedEmbeddingOptions(iframeOptions)))}${hash}" width="100%" height="600px" frameborder="0" style="outline: none;"/>`}
</code>
</pre>
</fieldset>
@@ -1,8 +1,9 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { Button } from '$lib/components/ui/button';
import Logo from '$lib/components/Logo.svelte';
import { getURLForLanguage } from '$lib/utils';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
let {
files,
@@ -16,7 +17,7 @@
<Button
variant="ghost"
class="absolute top-0 flex-wrap h-fit bg-background font-semibold rounded-md py-1 px-2 gap-1.5 xs:text-base mt-2.5 ml-2.5 mr-12"
href="{getURLForLanguage(i18n.lang, '/app')}?{files.length > 0
href="{resolve(getURLForLanguage(i18n.lang, '/app'))}?{files.length > 0
? `files=${encodeURIComponent(JSON.stringify(files))}`
: ''}{files.length > 0 && ids.length > 0 ? '&' : ''}{ids.length > 0
? `ids=${encodeURIComponent(JSON.stringify(ids))}`
@@ -0,0 +1,148 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
allowedEmbeddingBasemaps,
convertOldEmbeddingOptions,
defaultEmbeddingOptions,
getCleanedEmbeddingOptions,
getFilesFromEmbeddingOptions,
getMergedEmbeddingOptions,
getURLForGoogleDriveFile,
} from './embedding';
describe('embedding options', () => {
it('are the defaults when nothing is given', () => {
assert.deepEqual(getMergedEmbeddingOptions({}), defaultEmbeddingOptions);
// and a copy of them
const merged = getMergedEmbeddingOptions({});
merged.elevation.show = false;
assert.equal(defaultEmbeddingOptions.elevation.show, true);
});
it('override the defaults, also inside the groups of options', () => {
const merged = getMergedEmbeddingOptions({
basemap: 'osm',
distanceUnits: 'imperial',
elevation: { fill: 'slope', hr: true },
files: ['https://example.com/a.gpx'],
});
assert.equal(merged.basemap, 'osm');
assert.equal(merged.distanceUnits, 'imperial');
assert.equal(merged.elevation.fill, 'slope');
assert.equal(merged.elevation.hr, true);
// the options of a group that are not given stay as they are
assert.equal(merged.elevation.height, 170);
assert.equal(merged.elevation.show, true);
assert.deepEqual(merged.files, ['https://example.com/a.gpx']);
assert.equal(merged.velocityUnits, 'speed');
});
it('take a list or a null as a value, not as a group', () => {
const merged = getMergedEmbeddingOptions({ ids: ['a', 'b'], key: null });
assert.deepEqual(merged.ids, ['a', 'b']);
assert.equal(merged.key, null);
});
it('are cleaned of what is a default', () => {
const options = getMergedEmbeddingOptions({ basemap: 'osm', elevation: { hr: true } });
assert.deepEqual(getCleanedEmbeddingOptions(options), {
basemap: 'osm',
elevation: { hr: true },
});
assert.deepEqual(getCleanedEmbeddingOptions(defaultEmbeddingOptions), {});
// merging what was cleaned gives the options back
assert.deepEqual(getMergedEmbeddingOptions(getCleanedEmbeddingOptions(options)), options);
});
it('are cleaned of the key of the site, and keep the key of the user', () => {
assert.deepEqual(
getCleanedEmbeddingOptions({ ...defaultEmbeddingOptions, key: 'test-key' }),
{}
);
assert.deepEqual(getCleanedEmbeddingOptions({ ...defaultEmbeddingOptions, key: 'mine' }), {
key: 'mine',
});
});
it('are not changed by cleaning them', () => {
const options = getMergedEmbeddingOptions({ elevation: { hr: true } });
const before = JSON.stringify(options);
getCleanedEmbeddingOptions(options);
assert.equal(JSON.stringify(options), before);
});
});
describe('embedded files', () => {
it('are the urls, then the files of Google Drive', () => {
const options = getMergedEmbeddingOptions({
files: ['https://example.com/a.gpx'],
ids: ['abc', 'def'],
});
assert.deepEqual(getFilesFromEmbeddingOptions(options), [
'https://example.com/a.gpx',
getURLForGoogleDriveFile('abc'),
getURLForGoogleDriveFile('def'),
]);
assert.ok(getURLForGoogleDriveFile('abc').includes('/files/abc?alt=media'));
assert.deepEqual(getFilesFromEmbeddingOptions(getMergedEmbeddingOptions({})), []);
});
it('can use the basemaps of the site except a few', () => {
assert.ok(allowedEmbeddingBasemaps.includes('libertyTopo'));
assert.ok(allowedEmbeddingBasemaps.includes('osm'));
assert.ok(!allowedEmbeddingBasemaps.includes('ordnanceSurvey'));
assert.ok(allowedEmbeddingBasemaps.includes(defaultEmbeddingOptions.basemap));
});
});
describe('options of the old embedding', () => {
const convert = (query: string) => convertOldEmbeddingOptions(new URLSearchParams(query));
it('are converted to the new ones', () => {
assert.deepEqual(convert(''), { key: 'test-key', files: [], ids: [] });
assert.deepEqual(
convert(
'source=otm&imperial&running&distance&direction&slope&state=' +
encodeURIComponent(JSON.stringify({ ids: ['a'], urls: ['https://x/y.gpx'] }))
),
{
key: 'test-key',
files: ['https://x/y.gpx'],
ids: ['a'],
basemap: 'openTopoMap',
distanceUnits: 'imperial',
velocityUnits: 'pace',
distanceMarkers: true,
directionMarkers: true,
elevation: { fill: 'slope' },
}
);
});
it('convert the names of the sources', () => {
assert.equal(convert('source=satellite').basemap, 'libertySatellite');
assert.equal(convert('source=otm').basemap, 'openTopoMap');
assert.equal(convert('source=ohm').basemap, 'openHikingMap');
assert.equal(convert('source=unknown').basemap, undefined);
});
it('ignore a state that is not valid', () => {
const empty = { key: 'test-key', files: [], ids: [] };
assert.deepEqual(convert('state=%7Bbroken'), empty);
assert.deepEqual(convert('state=null'), empty);
assert.deepEqual(convert('state=42'), empty);
assert.deepEqual(convert(`state=${encodeURIComponent('{"ids":"abc","urls":3}')}`), empty);
assert.deepEqual(convert(`state=${encodeURIComponent('{"ids":["a"]}')}&imperial`), {
...empty,
ids: ['a'],
distanceUnits: 'imperial',
});
});
it('can be merged with the defaults', () => {
const merged = getMergedEmbeddingOptions(convert('slope&source=satellite'));
assert.equal(merged.elevation.fill, 'slope');
assert.equal(merged.elevation.height, 170);
assert.equal(merged.basemap, 'libertySatellite');
});
});
@@ -1,5 +1,5 @@
import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
import { basemaps } from '$lib/assets/layers';
import { basemaps } from '$lib/data/layers';
export type EmbeddingOptions = {
key: string;
@@ -109,22 +109,26 @@ export function getURLForGoogleDriveFile(fileId: string): string {
}
export function convertOldEmbeddingOptions(options: URLSearchParams): any {
let newOptions: any = {
const newOptions: any = {
key: PUBLIC_MAPTILER_KEY,
files: [],
ids: [],
};
if (options.has('state')) {
let state = JSON.parse(options.get('state')!);
if (state.ids) {
// the state comes from the url: it may be anything
let state: any = {};
try {
state = JSON.parse(options.get('state')!) ?? {};
} catch {}
if (Array.isArray(state.ids)) {
newOptions.ids.push(...state.ids);
}
if (state.urls) {
if (Array.isArray(state.urls)) {
newOptions.files.push(...state.urls);
}
}
if (options.has('source')) {
let basemap = options.get('source')!;
const basemap = options.get('source')!;
if (basemap === 'satellite') {
newOptions.basemap = 'libertySatellite';
} else if (basemap === 'otm') {
@@ -0,0 +1,190 @@
<script lang="ts">
import { setContext, untrack } from 'svelte';
import { CollapsibleTree, CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
import { CollapsibleTreeState } from '$lib/components/collapsible-tree/utils.svelte';
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
import ExplorerTree from './ExplorerTree.svelte';
import ExplorerRow from './ExplorerRow.svelte';
import {
clickedInExplorer,
explorerFoldersKey,
explorerSelection,
renaming,
treeRows,
type ExplorerFolders,
} from './state.svelte';
import { openPaths } from '$lib/actions/file-actions';
import { i18n } from '$lib/i18n/i18n.svelte';
import { engine } from '$lib/engine';
import { selectedFileIds } from '$lib/engine/selection-helpers';
import { folderName, parentOf, subfolder } from '$lib/engine/workspace';
const { workspace, selection } = engine;
let folder = $derived($workspace?.folder);
// which folders are open: new ones when another folder is opened, the folder itself is open at
// first and its folders are closed
let trees = $derived(
folder
? {
root: new CollapsibleTreeState('open'),
folders: new CollapsibleTreeState('closed'),
}
: undefined
);
let container: HTMLElement | undefined = $state();
// the files that are selected, if they are in the folder
let selectedPaths = $derived(
selectedFileIds($selection)
.map((id) => $workspace?.files[id]?.path)
.filter((path): path is string => subfolder(folder, path) !== undefined)
);
let selectedPath = $derived(selectedPaths[0]);
$effect(() => {
// not what the click that was just made depends on, only the selection and the folder
const clicked = untrack(() => clickedInExplorer.path);
if (clicked !== undefined && selectedPath === clicked) {
return;
}
clickedInExplorer.path = undefined;
// what is selected elsewhere (the file list, the map) is selected here
explorerSelection.paths = untrack(() => selectedPaths);
explorerSelection.anchor = selectedPath;
if (folder && selectedPath !== undefined) {
return reveal(folder, selectedPath);
}
});
/**
* Opens the folders that hold a file and scrolls to the file, if it is not in view already. Its
* row may not be there yet, because the content of a folder is read when it is opened.
*/
function reveal(folder: string, path: string) {
openFolder(parentOf(path));
let frame = 0;
let attempts = 0;
const scroll = () => {
const row = container?.querySelector(`[data-path="${CSS.escape(path)}"]`);
if (row) {
row.scrollIntoView({ block: 'nearest' });
} else if (attempts++ < 120) {
frame = requestAnimationFrame(scroll);
}
};
frame = requestAnimationFrame(scroll);
return () => cancelAnimationFrame(frame);
}
/**
* The id of the node of a folder of the tree in `trees.folders`: the names of the folders it is
* in, and its own (see `CollapsibleTreeNode`). `undefined` for the open folder itself, which is
* in `trees.root`, and for what is not in it.
*/
function nodeId(path: string): string | undefined {
const names = subfolder(folder, `${path}/_`)?.split('/').filter(Boolean);
return names && names.length > 0 ? ['root', ...names].join('.') : undefined;
}
/** Opens a folder of the tree, and the folders it is in. */
function openFolder(path: string) {
if (!trees) return;
trees.root.get('root.folder').current = true;
const id = nodeId(path);
if (id === undefined) return;
const names = id.split('.');
names.forEach((_, i) => {
if (i > 0) trees!.folders.get(names.slice(0, i + 1).join('.')).current = true;
});
}
setContext<ExplorerFolders>(explorerFoldersKey, {
open: openFolder,
moved(from, to) {
const [fromId, toId] = [nodeId(from), nodeId(to)];
if (trees && fromId !== undefined && toId !== undefined) {
trees.folders.move(fromId, toId);
}
},
});
/** Enter opens the selected files for good. */
function onkeydown(event: KeyboardEvent) {
if (renaming.path !== undefined || event.key !== 'Enter') return;
const files = treeRows(container)
.filter((row) => row.kind === 'file' && explorerSelection.paths.includes(row.path))
.map((row) => row.path);
if (files.length > 0) {
openPaths(files);
event.preventDefault();
}
}
</script>
<!--
The tree of the open folder: its folders and GPX files, one level read at a time. The files are
opened from it (the file list has the ones that are open).
-->
{#if folder && trees}
<div class="flex flex-col h-full">
<div class="px-2 py-1 text-sm font-semibold shrink-0">{i18n._('menu.explorer')}</div>
<ScrollArea class="grow min-h-0">
<div
bind:this={container}
data-explorer
role="tree"
tabindex="0"
class="flex flex-col pl-1 pr-3 pb-1 outline-none"
{onkeydown}
>
<!-- starts over when another folder is opened -->
{#key folder}
<!-- the folder itself -->
<CollapsibleTree side="left" treeState={trees.root} sticky>
<CollapsibleTreeNode id="folder">
{#snippet trigger()}
<ExplorerRow
path={folder}
name={folderName(folder)}
kind="root"
class="h-7"
/>
{/snippet}
{#snippet content(opened)}
<!-- what is in it is only read when it is open -->
{#if opened}
<div class="ml-4">
<CollapsibleTree side="left" treeState={trees.folders}>
<ExplorerTree />
</CollapsibleTree>
</div>
{/if}
{/snippet}
</CollapsibleTreeNode>
</CollapsibleTree>
{/key}
</div>
</ScrollArea>
</div>
{/if}
<style lang="postcss">
@reference "../../../app.css";
/* the color of the file list: a file and a folder (the whole row of its node, with its
chevron) under the pointer, or selected */
[data-explorer] :global([data-kind='file']:hover),
[data-explorer] :global([data-slot='button']:hover),
[data-explorer] :global([data-kind='file'][aria-selected='true']),
[data-explorer] :global([data-slot='button']:has([aria-selected='true'])) {
@apply bg-[var(--selection)];
}
/* the folder where what is dragged would go */
[data-explorer] :global([data-slot='button']:has([data-drop-target])) {
@apply ring-1 ring-primary bg-[var(--selection)];
}
</style>
@@ -0,0 +1,342 @@
<script lang="ts">
import * as ContextMenu from '$lib/components/ui/context-menu';
import {
ArrowUpRight,
FileX,
FolderOpen,
FolderX,
PenLine,
Plus,
Trash2,
} from '@lucide/svelte';
import { getContext, type Snippet } from 'svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import { engine } from '$lib/engine';
import { editableName, fileIdAt, isWithin, parentOf, relocated } from '$lib/engine/workspace';
import { closeFiles, closeFolder, openPaths, showEngineError } from '$lib/actions/file-actions';
import { isMac } from '$lib/utils';
import NameInput from './NameInput.svelte';
import {
clickedInExplorer,
creating,
dragging,
explorerFoldersKey,
explorerSelection,
renaming,
treeRows,
type ExplorerFolders,
} from './state.svelte';
let {
path,
name,
kind,
icon,
class: className = '',
style = '',
}: {
path: string;
/** What the row shows, the name of the file with its extension. */
name: string;
/** The open folder itself, a folder in it, or a GPX file. */
kind: 'root' | 'folder' | 'file';
icon?: Snippet;
class?: string;
style?: string;
} = $props();
const { workspace } = engine;
const folders = getContext<ExplorerFolders>(explorerFoldersKey);
let row: HTMLElement | undefined = $state();
// the open file of the row
let fileId = $derived(kind === 'file' ? fileIdAt($workspace, path) : undefined);
let selected = $derived(explorerSelection.paths.includes(path));
let editing = $derived(renaming.path === path);
let target = $derived(dragging.target === path);
/** What the menu and dragging act on: the selection, if the row is in it. */
function targets(): string[] {
return selected ? explorerSelection.paths : [path];
}
function select(paths: string[]) {
explorerSelection.paths = paths;
explorerSelection.anchor = path;
}
function onclick(event: MouseEvent) {
if (editing || kind === 'root') {
return;
}
if (event.metaKey || event.ctrlKey) {
// added to the selection, or taken out of it, without opening anything
select(
selected
? explorerSelection.paths.filter((other) => other !== path)
: [...explorerSelection.paths, path]
);
event.preventDefault();
event.stopPropagation();
} else if (event.shiftKey && explorerSelection.anchor !== undefined) {
// everything shown between the row where the selection began and this one
const paths = treeRows(row)
.filter((other) => other.kind !== 'root')
.map((other) => other.path);
const [from, to] = [paths.indexOf(explorerSelection.anchor), paths.indexOf(path)];
if (from >= 0 && to >= 0) {
explorerSelection.paths = paths.slice(Math.min(from, to), Math.max(from, to) + 1);
}
event.preventDefault();
event.stopPropagation();
} else if (event.detail <= 1) {
select([path]);
if (kind === 'file') {
// it is in view, the explorer does not have to scroll to it
clickedInExplorer.path = path;
if (fileId !== undefined) {
engine.select([fileId]);
} else {
openPaths([path], true);
}
}
}
}
function ondblclick() {
if (kind === 'file' && !editing) {
clickedInExplorer.path = path;
openPaths([path], false);
}
}
// the menu
/** The folder where what is made from the menu goes: the folder of the row. */
let folder = $derived(kind === 'file' ? undefined : path);
/**
* Asks for the name of a file or a folder to make in the folder of the row (see `creating`).
* With `route`, the file starts with an empty route.
*/
function create(what: 'file' | 'folder', route = false) {
if (folder === undefined) return;
// where what is being named is shown
folders.open(folder);
creating.kind = what;
creating.route = route;
creating.parent = folder;
}
function openSelected() {
const files = treeRows(row)
.filter((other) => other.kind === 'file' && targets().includes(other.path))
.map((other) => other.path);
if (files.length > 0) {
openPaths(files);
}
}
/** The open files among what the menu acts on. */
function openIds(): string[] {
return targets()
.map((other) => fileIdAt($workspace, other))
.filter((id): id is string => id !== undefined);
}
async function remove() {
const paths = targets();
explorerSelection.paths = [];
if (!(await engine.deletePaths(paths))) {
showEngineError();
}
}
// renaming, in the row
async function commit(value: string) {
if (renaming.path !== path) return;
renaming.path = undefined;
const name_ = value.trim();
if (name_ === '' || name_ === editableName(name, kind !== 'file')) return;
// the folder stays open or closed (before its new row is read, back if it is not renamed)
const to = relocated(path, parentOf(path), name_);
folders.moved(path, to);
if (!(await engine.renamePath(path, name_))) {
folders.moved(to, path);
showEngineError();
}
}
// dragging into folders
function ondragstart(event: DragEvent) {
if (kind === 'root' || editing) {
event.preventDefault();
return;
}
const paths = targets();
dragging.paths = paths;
event.dataTransfer?.setData('text/plain', paths.join('\n'));
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
}
}
function ondragend() {
dragging.paths = [];
dragging.target = undefined;
}
/** The folder where what is dropped on the row goes: the folder of a file, among its files. */
let dropFolder = $derived(kind === 'file' ? parentOf(path) : path);
function ondragover(event: DragEvent) {
if (dragging.paths.length === 0) return;
// not into itself, nor where it is already
if (dragging.paths.some((dragged) => isWithin(dropFolder, dragged))) return;
if (dragging.paths.every((dragged) => parentOf(dragged) === dropFolder)) return;
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
// the row of the folder shows it
dragging.target = dropFolder;
}
function ondragleave(event: DragEvent) {
// to another row, which tells where it would go (it was entered first)
const to = event.relatedTarget as Element | null;
if (to?.closest?.('[data-explorer-path]')) return;
dragging.target = undefined;
}
async function ondrop(event: DragEvent) {
event.preventDefault();
const paths = dragging.paths;
ondragend();
if (paths.length === 0) return;
// the folders stay open or closed (before their new rows are read, back if they are not moved)
const moves = paths.map((dragged) => [dragged, relocated(dragged, dropFolder)]);
moves.forEach(([from, to]) => folders.moved(from, to));
if (await engine.movePaths(paths, dropFolder)) {
// where they went is shown
folders.open(dropFolder);
} else {
moves.forEach(([from, to]) => folders.moved(to, from));
showEngineError();
}
}
</script>
<ContextMenu.Root
onOpenChange={(open) => {
if (open && fileId !== undefined) {
// the file is open for good once its menu is
engine.keepPreview([fileId]);
}
if (open && !selected && kind !== 'root') {
select([path]);
}
}}
>
<ContextMenu.Trigger class="grow min-w-0 pointer-events-auto outline-none">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
bind:this={row}
role="treeitem"
aria-selected={selected}
tabindex="-1"
data-explorer-path={path}
data-path={path}
data-kind={kind}
title={path}
draggable={kind !== 'root' && !editing}
data-drop-target={target || undefined}
class="flex flex-row items-center gap-1.5 min-w-0 rounded-md outline-none {className}"
{style}
{onclick}
{ondblclick}
{ondragstart}
{ondragend}
{ondragover}
ondragenter={ondragover}
{ondragleave}
{ondrop}
>
{@render icon?.()}
{#if editing}
<NameInput
value={editableName(name, kind !== 'file')}
oncommit={commit}
oncancel={() => (renaming.path = undefined)}
/>
{:else}
<span class="grow truncate text-left">{name}</span>
{/if}
</div>
</ContextMenu.Trigger>
<!-- the name being edited keeps the focus, which the menu would give back to the row -->
<ContextMenu.Content
onCloseAutoFocus={(event) => {
if (renaming.path !== undefined || creating.parent !== undefined) {
event.preventDefault();
}
}}
>
{#if kind !== 'file'}
<ContextMenu.Item onclick={() => create('file')}>
<Plus size="16" />
{i18n._('menu.explorer_menu.new_file')}
</ContextMenu.Item>
<ContextMenu.Item onclick={() => create('file', true)}>
<Plus size="16" />
{i18n._('menu.explorer_menu.new_route_file')}
</ContextMenu.Item>
<ContextMenu.Item onclick={() => create('folder')}>
<Plus size="16" />
{i18n._('menu.explorer_menu.new_folder')}
</ContextMenu.Item>
<ContextMenu.Separator />
{:else}
<ContextMenu.Item onclick={openSelected}>
<FolderOpen size="16" />
{i18n._('menu.explorer_menu.open')}
</ContextMenu.Item>
{/if}
<ContextMenu.Item onclick={() => engine.revealPath(path)}>
<ArrowUpRight size="16" />
{i18n._(isMac() ? 'menu.explorer_menu.reveal_finder' : 'menu.explorer_menu.reveal')}
</ContextMenu.Item>
<ContextMenu.Separator />
{#if kind !== 'root'}
<ContextMenu.Item
disabled={targets().length !== 1}
onclick={() => (renaming.path = path)}
>
<PenLine size="16" />
{i18n._('menu.explorer_menu.rename')}
</ContextMenu.Item>
<ContextMenu.Separator />
{/if}
{#if kind === 'root'}
<ContextMenu.Item onclick={closeFolder}>
<FolderX size="16" />
{i18n._('menu.close_folder')}
</ContextMenu.Item>
{:else}
{#if kind === 'file' && openIds().length > 0}
<ContextMenu.Item onclick={() => closeFiles(openIds())}>
<FileX size="16" />
{i18n._('menu.close')}
</ContextMenu.Item>
<ContextMenu.Separator />
{/if}
<ContextMenu.Item onclick={remove}>
<Trash2 size="16" />
{i18n._('menu.delete')}
</ContextMenu.Item>
{/if}
</ContextMenu.Content>
</ContextMenu.Root>
@@ -0,0 +1,155 @@
<script lang="ts">
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
import { FileText, Folder, Route } from '@lucide/svelte';
import { getContext } from 'svelte';
import { get } from 'svelte/store';
import { toast } from 'svelte-sonner';
import { stickyKey, type StickyLevel } from '$lib/components/collapsible-tree/sticky';
import ExplorerTree from './ExplorerTree.svelte';
import ExplorerRow from './ExplorerRow.svelte';
import { engine, type FolderEntry } from '$lib/engine';
import { editableName, fileIdAt } from '$lib/engine/workspace';
import { i18n } from '$lib/i18n/i18n.svelte';
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { showEngineError } from '$lib/actions/file-actions';
import NameInput from './NameInput.svelte';
import { creating, explorerSelection } from './state.svelte';
let {
path,
}: {
/** The folder whose content is listed, the open folder if it is not given. */
path?: string;
} = $props();
const { workspace, folderVersion } = engine;
// the rows of the folders that stick above the files of this one
const level = getContext<StickyLevel>(stickyKey);
let entries: FolderEntry[] = $state([]);
// the folder of the list, where what is being made goes
let folder = $derived(path ?? $workspace?.folder);
let pending = $derived(
creating.parent !== undefined && creating.parent === folder ? creating.kind : undefined
);
// the folders come first, then the files (and what is being made goes at the top of its kind)
let folders = $derived(entries.filter((entry) => entry.isDir));
let files = $derived(entries.filter((entry) => !entry.isDir));
/**
* Makes the file or folder that was asked for, called `value`: nothing is made without a name,
* nor with the name of something that is there (the user is told, and can change it). A new
* file is selected, for the routing tool.
*/
async function create(value: string, retry: boolean) {
const kind = pending;
const parent = folder;
const route = creating.route;
if (kind === undefined || parent === undefined) return;
const name = value.trim();
const taken = entries.some(
(entry) =>
entry.isDir === (kind === 'folder') &&
editableName(entry.name, entry.isDir).toLowerCase() === name.toLowerCase()
);
if (name !== '' && taken) {
toast.error(i18n._('menu.metadata.name_taken'));
if (retry) return;
}
creating.parent = undefined;
if (name === '' || taken) return;
let created: string | undefined;
if (kind === 'file') {
const id = await engine.newFileIn(name, parent, route);
created = id === undefined ? undefined : get(workspace)?.files[id]?.path;
if (created !== undefined) {
currentTool.set(Tool.ROUTING);
}
} else {
created = await engine.createFolder(parent, name);
}
if (created === undefined) {
showEngineError();
return;
}
explorerSelection.paths = [created];
explorerSelection.anchor = created;
}
$effect(() => {
// read again when something changed on the file system
$folderVersion;
let current = true;
engine.listFolder(path).then((list) => {
if (current) {
entries = list;
}
});
return () => {
current = false;
};
});
</script>
<!-- the name of what is being made, which it is made with -->
{#snippet named(kind: 'file' | 'folder')}
<div class="flex flex-row items-center h-7 pl-1 pr-1 {kind === 'file' ? 'gap-1.5' : 'gap-1'}">
{#if kind === 'file' && creating.route}
<Route size="16" class="shrink-0" />
{:else if kind === 'file'}
<FileText size="16" class="shrink-0" />
{:else}
<Folder size="16" class="shrink-0" />
{/if}
<NameInput
placeholder={i18n._(
kind === 'folder'
? 'menu.explorer_menu.new_folder'
: creating.route
? 'menu.explorer_menu.new_route_file'
: 'menu.explorer_menu.new_file'
)}
oncommit={create}
oncancel={() => (creating.parent = undefined)}
/>
</div>
{/snippet}
{#if pending === 'folder'}
{@render named('folder')}
{/if}
{#each folders as entry (entry.path)}
<CollapsibleTreeNode id={entry.name}>
{#snippet trigger()}
<ExplorerRow path={entry.path} name={entry.name} kind="folder" class="h-7" />
{/snippet}
{#snippet content(opened)}
<!-- the content of a folder is only read when it is opened -->
{#if opened}
<div class="ml-4">
<ExplorerTree path={entry.path} />
</div>
{/if}
{/snippet}
</CollapsibleTreeNode>
{/each}
{#if pending === 'file'}
{@render named('file')}
{/if}
{#each files as entry (entry.path)}
<!-- the files that are not open are greyed -->
<ExplorerRow
path={entry.path}
name={entry.name}
kind="file"
class="w-full h-7 px-1 text-sm {fileIdAt($workspace, entry.path) === undefined
? 'text-muted-foreground'
: ''}"
style="scroll-margin-top: {level.top}px"
>
{#snippet icon()}
<FileText size="16" class="shrink-0" />
{/snippet}
</ExplorerRow>
{/each}
@@ -0,0 +1,45 @@
<script lang="ts">
let {
value = '',
placeholder,
oncommit,
oncancel,
}: {
value?: string;
placeholder?: string;
/** The name is given: with Enter (`enter`), or by leaving the field. */
oncommit: (value: string, enter: boolean) => void;
/** Escape: no name is given. */
oncancel: () => void;
} = $props();
function onkeydown(event: KeyboardEvent) {
// not for the row or the explorer it is in (a folder opens with Enter or Space, the selected
// files open with Enter)
event.stopPropagation();
if (event.key === 'Enter') {
oncommit((event.currentTarget as HTMLInputElement).value, true);
} else if (event.key === 'Escape') {
oncancel();
}
}
</script>
<!-- the name of a file or a folder of the explorer, being given or changed -->
<input
class="h-6 grow min-w-0 px-1 text-sm font-normal not-italic bg-background border rounded outline-none"
{value}
{placeholder}
{@attach (input) => {
// once it is shown: it can be in a folder that is opening
const frame = requestAnimationFrame(() => {
input.focus();
input.select();
});
return () => cancelAnimationFrame(frame);
}}
{onkeydown}
onkeyup={(event) => event.stopPropagation()}
onclick={(event) => event.stopPropagation()}
onblur={(event) => oncommit(event.currentTarget.value, false)}
/>
@@ -0,0 +1 @@
export { default as Explorer } from './Explorer.svelte';
@@ -0,0 +1,49 @@
/** The file that the user just selected by clicking it in the explorer, until the selection changes. */
export const clickedInExplorer: { path: string | undefined } = $state({ path: undefined });
/**
* What is selected in the explorer (files and folders, by path), which the menu and dragging act
* on, and where a range selected with shift begins.
*/
export const explorerSelection: { paths: string[]; anchor: string | undefined } = $state({
paths: [],
anchor: undefined,
});
/** The file or folder whose name is being edited in the explorer. */
export const renaming: { path: string | undefined } = $state({ path: undefined });
/**
* The file or folder that is being made in a folder (`parent`) of the explorer: it is only made once
* the user gave it a name. With `route`, the file starts with an empty route.
*/
export const creating: { parent: string | undefined; kind: 'file' | 'folder'; route: boolean } =
$state({
parent: undefined,
kind: 'file',
route: false,
});
/** What is dragged in the explorer, and the folder it would be dropped in. */
export const dragging: { paths: string[]; target: string | undefined } = $state({
paths: [],
target: undefined,
});
/** What the rows of the explorer can do to the folders of its tree (see `Explorer`). */
export type ExplorerFolders = {
/** Opens a folder of the tree, and the folders it is in. */
open(path: string): void;
/** A folder was renamed or moved: it stays open or closed, and so does what is in it. */
moved(from: string, to: string): void;
};
export const explorerFoldersKey = 'explorer-folders';
/** The rows of the explorer that `element` is in, as they are shown: their path and kind. */
export function treeRows(element: Element | null | undefined): { path: string; kind: string }[] {
const tree = element?.closest('[data-explorer]');
return [...(tree?.querySelectorAll<HTMLElement>('[data-explorer-path]') ?? [])].map((row) => ({
path: row.dataset.explorerPath!,
kind: row.dataset.kind!,
}));
}
+61 -48
View File
@@ -5,8 +5,10 @@
import { Separator } from '$lib/components/ui/separator';
import { Dialog } from 'bits-ui';
import {
allFileIds,
exportAllFiles,
exportSelectedFiles,
selectedFileIds,
ExportState,
exportState,
} from '$lib/components/export/utils.svelte';
@@ -20,56 +22,60 @@
Thermometer,
SquareActivity,
} from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { GPXGlobalStatistics } from 'gpx';
import { ListRootItem } from '$lib/components/file-list/file-list';
import { fileStateCollection } from '$lib/logic/file-state';
import { selection } from '$lib/logic/selection';
import { gpxStatistics } from '$lib/logic/statistics';
import { get } from 'svelte/store';
import { i18n } from '$lib/i18n/i18n.svelte';
import { engine, type ExportOptions } from '$lib/engine';
const selection = engine.selection;
const order = engine.order;
const files = engine.files;
let open = $derived(exportState.current !== ExportState.NONE);
let exportOptions: Record<string, boolean> = $state({
let exportOptions: ExportOptions = $state({
time: true,
hr: true,
cad: true,
atemp: true,
power: true,
extensions: false,
osm: false,
});
let hide: Record<string, boolean> = $derived.by(() => {
if (exportState.current === ExportState.NONE) {
return {
time: false,
hr: false,
cad: false,
atemp: false,
power: false,
extensions: false,
};
} else {
let statistics = $gpxStatistics.global;
if (exportState.current === ExportState.ALL) {
statistics = Array.from(get(fileStateCollection).values())
.map((file) => file.statistics)
.reduce((acc, cur) => {
if (cur !== undefined) {
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()).global);
}
return acc;
}, new GPXGlobalStatistics());
}
return {
time: statistics.time.total === 0,
hr: statistics.hr.count === 0,
cad: statistics.cad.count === 0,
atemp: statistics.atemp.count === 0,
power: statistics.power.count === 0,
extensions: Object.keys(statistics.extensions).length === 0,
};
// the files that are exported
let fileIds: string[] = $derived.by(() => {
// the selection and the order change with the files
void $selection;
void $order;
void $files;
if (exportState.current === ExportState.SELECTION) {
return selectedFileIds();
} else if (exportState.current === ExportState.ALL) {
return allFileIds();
}
return [];
});
// the data that none of them has is not offered
let hide: Record<keyof ExportOptions, boolean> = $state({
time: false,
hr: false,
cad: false,
atemp: false,
power: false,
osm: false,
});
$effect(() => {
const ids = fileIds;
engine.exportableData(ids).then((available) => {
// the files may have changed since
if (ids === fileIds) {
hide = {
time: !available.time,
hr: !available.hr,
cad: !available.cad,
atemp: !available.atemp,
power: !available.power,
osm: !available.osm,
};
}
});
});
let exclude = $derived(Object.keys(exportOptions).filter((key) => !exportOptions[key]));
$effect(() => {
if (open) {
@@ -112,17 +118,26 @@
variant="outline"
class="grow"
onclick={() => {
// what is not offered is left out
const options = {
time: exportOptions.time && !hide.time,
hr: exportOptions.hr && !hide.hr,
cad: exportOptions.cad && !hide.cad,
atemp: exportOptions.atemp && !hide.atemp,
power: exportOptions.power && !hide.power,
osm: exportOptions.osm && !hide.osm,
};
if (exportState.current === ExportState.SELECTION) {
exportSelectedFiles(exclude);
exportSelectedFiles(options);
} else if (exportState.current === ExportState.ALL) {
exportAllFiles(exclude);
exportAllFiles(options);
}
open = false;
exportState.current = ExportState.NONE;
}}
>
<Download size="16" />
{#if $fileStateCollection.size === 1 || (exportState.current === ExportState.SELECTION && $selection.size === 1)}
{#if fileIds.length === 1}
{i18n._('menu.download_file')}
{:else}
{i18n._('menu.download_files')}
@@ -183,11 +198,9 @@
{i18n._('quantities.power')}
</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">
<div class="flex flex-row items-center gap-1.5 {hide.osm ? 'hidden' : ''}">
<Checkbox id="export-osm" bind:checked={exportOptions.osm} />
<Label for="export-osm" class="flex flex-row items-center gap-1">
<Earth size="16" />
{i18n._('quantities.osm_extensions')}
</Label>
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { sanitizeFileName, uniqueFileNames } from './file-names';
describe('sanitizeFileName', () => {
it('keeps the names that are fine', () => {
for (const name of [
'Morning ride',
'route-2024_05',
'Café du coin',
'日本の道',
'a.b.c',
'🚴 tour',
]) {
assert.equal(sanitizeFileName(name), name);
}
});
it('replaces what is not allowed in a name', () => {
assert.equal(sanitizeFileName('a/b'), 'a_b');
assert.equal(sanitizeFileName('a\\b'), 'a_b');
assert.equal(sanitizeFileName('../../etc/passwd'), '_.._etc_passwd');
assert.equal(
sanitizeFileName('what? <this> *is* "it": a|b'),
'what_ _this_ _is_ _it__ a_b'
);
assert.equal(sanitizeFileName('tab\there\nnew\u0000line\u007f'), 'tab_here_new_line_');
});
it('has no dots or spaces around, which systems drop or hide', () => {
assert.equal(sanitizeFileName(' name '), 'name');
assert.equal(sanitizeFileName('.hidden'), 'hidden');
assert.equal(sanitizeFileName('name...'), 'name');
assert.equal(sanitizeFileName('. . name . .'), 'name');
});
it('is never empty', () => {
assert.equal(sanitizeFileName(''), 'file');
assert.equal(sanitizeFileName(' '), 'file');
assert.equal(sanitizeFileName('...'), 'file');
});
it('does not use the names Windows keeps for devices', () => {
assert.equal(sanitizeFileName('con'), '_con');
assert.equal(sanitizeFileName('NUL'), '_NUL');
assert.equal(sanitizeFileName('com1'), '_com1');
assert.equal(sanitizeFileName('LPT9'), '_LPT9');
assert.equal(sanitizeFileName('aux.backup'), '_aux.backup');
assert.equal(sanitizeFileName('console'), 'console');
assert.equal(sanitizeFileName('com10'), 'com10');
});
it('is not too long, whatever the characters', () => {
assert.equal(sanitizeFileName('a'.repeat(500)).length, 100);
const emoji = sanitizeFileName('🚴'.repeat(500));
assert.equal([...emoji].length, 100);
// and does not end with what was left of a cut
assert.equal(sanitizeFileName('a'.repeat(99) + '. b'), 'a'.repeat(99));
});
it('is the same when sanitized again', () => {
for (const name of ['a/b', ' .x ', 'con', '', '🚴'.repeat(200), 'what?']) {
const once = sanitizeFileName(name);
assert.equal(sanitizeFileName(once), once);
}
});
});
describe('uniqueFileNames', () => {
it('keeps different names as they are', () => {
assert.deepEqual(uniqueFileNames(['a', 'b', 'c']), ['a', 'b', 'c']);
assert.deepEqual(uniqueFileNames([]), []);
});
it('numbers the names that are taken', () => {
assert.deepEqual(uniqueFileNames(['ride', 'ride', 'ride']), ['ride', 'ride-1', 'ride-2']);
// a name that looks like a number is taken too
assert.deepEqual(uniqueFileNames(['ride-1', 'ride', 'ride']), ['ride-1', 'ride', 'ride-2']);
});
it('does not tell upper and lower case apart', () => {
assert.deepEqual(uniqueFileNames(['Ride', 'ride', 'RIDE']), ['Ride', 'ride-1', 'RIDE-2']);
});
it('compares the names once they are clean', () => {
assert.deepEqual(uniqueFileNames(['a/b', 'a_b', 'a?b']), ['a_b', 'a_b-1', 'a_b-2']);
assert.deepEqual(uniqueFileNames(['', '', '...']), ['file', 'file-1', 'file-2']);
});
it('keeps the numbered names short enough', () => {
const long = 'x'.repeat(300);
const names = uniqueFileNames([long, long, long]);
assert.equal(new Set(names).size, 3);
assert.ok(names.every((name) => name.length <= 100));
assert.ok(names[1].endsWith('-1') && names[2].endsWith('-2'));
});
});
@@ -0,0 +1,47 @@
// The names of the files that are exported come from the files of the user, which can have any
// text as a name: they have to be made names that every system accepts.
/** Characters that no system accepts in a file name, or that mean something in a path. */
// eslint-disable-next-line no-control-regex
const FORBIDDEN = /[<>:"/\\|?*\u0000-\u001f\u007f]/g;
/** Names that Windows keeps for devices, with or without an extension. */
const RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i;
/** Longest name, without the extension, in characters. */
const MAX_LENGTH = 100;
/** A name for a file without its extension, that is allowed on every system. */
export function sanitizeFileName(name: string): string {
let clean = name
.replace(FORBIDDEN, '_')
.normalize('NFC')
// no dots or spaces around the name: Windows drops them, a leading dot hides the file
.replace(/^[\s.]+|[\s.]+$/g, '');
clean = [...clean]
.slice(0, MAX_LENGTH)
.join('')
.replace(/[\s.]+$/, '');
if (clean === '') {
return 'file';
}
return RESERVED.test(clean.split('.')[0]) ? `_${clean}` : clean;
}
/**
* The names of files that go in the same folder (the same zip): clean, and different, even where
* the file system does not tell upper and lower case apart. A name that is taken gets `-1`, `-2`...
*/
export function uniqueFileNames(names: string[]): string[] {
const taken = new Set<string>();
return names.map((name) => {
const clean = sanitizeFileName(name);
let unique = clean;
for (let i = 1; taken.has(unique.toLowerCase()); i++) {
// the suffix is part of the length
unique = `${[...clean].slice(0, MAX_LENGTH - `-${i}`.length).join('')}-${i}`;
}
taken.add(unique.toLowerCase());
return unique;
});
}
@@ -1,10 +1,8 @@
import { selection } from '$lib/logic/selection';
import { fileStateCollection } from '$lib/logic/file-state';
import { settings } from '$lib/logic/settings';
import { buildGPX, type GPXFile } from 'gpx';
import { engine, type ExportOptions } from '$lib/engine';
import FileSaver from 'file-saver';
import JSZip from 'jszip';
import { get } from 'svelte/store';
import { sanitizeFileName, uniqueFileNames } from './file-names';
export enum ExportState {
NONE,
@@ -15,52 +13,55 @@ export const exportState = $state({
current: ExportState.NONE,
});
async function exportFiles(fileIds: string[], exclude: string[]) {
if (fileIds.length > 1) {
await exportFilesAsZip(fileIds, exclude);
} else {
const firstFileId = fileIds.at(0);
if (firstFileId != null) {
const file = fileStateCollection.getFile(firstFileId);
if (file) {
exportFile(file, exclude);
}
}
/** The files that contain the selection, in the order of the selection. */
export function selectedFileIds(): string[] {
const selection = get(engine.selection);
switch (selection.type) {
case 'file':
return selection.fileIds;
case 'empty':
return [];
default:
return [selection.fileId];
}
}
export async function exportSelectedFiles(exclude: string[]) {
const fileIds: string[] = [];
selection.applyToOrderedSelectedItemsFromFile(async (fileId, level, items) => {
fileIds.push(fileId);
});
await exportFiles(fileIds, exclude);
export function allFileIds(): string[] {
return get(engine.order);
}
export async function exportAllFiles(exclude: string[]) {
await exportFiles(get(settings.fileOrder), exclude);
}
function exportFile(file: GPXFile, exclude: string[]) {
const blob = new Blob([buildGPX(file, exclude)], { type: 'application/gpx+xml' });
FileSaver.saveAs(blob, `${file.metadata.name}.gpx`);
}
async function exportFilesAsZip(fileIds: string[], exclude: string[]) {
const zip = new JSZip();
for (const fileId of fileIds) {
const file = fileStateCollection.getFile(fileId);
if (file) {
const gpx = buildGPX(file, exclude);
let filename = file.metadata.name;
for (let i = 1; zip.files[filename + '.gpx']; i++) {
filename = file.metadata.name + `-${i}`;
}
zip.file(filename + '.gpx', gpx);
}
async function exportFiles(fileIds: string[], options: ExportOptions) {
if (get(engine.workspace)) {
// the desktop app: the user chooses where the files go
await engine.exportFiles(fileIds, options);
return;
}
if (Object.keys(zip.files).length > 0) {
const files = (
await Promise.all(
fileIds.map(async (fileId) => {
const gpx = await engine.exportFile(fileId, options);
const name = get(engine.files).get(fileId);
return gpx === undefined || !name ? [] : [{ name: get(name).structure.name, gpx }];
})
)
).flat();
if (files.length === 1) {
const blob = new Blob([files[0].gpx], { type: 'application/gpx+xml' });
FileSaver.saveAs(blob, `${sanitizeFileName(files[0].name)}.gpx`);
} else if (files.length > 1) {
const zip = new JSZip();
const names = uniqueFileNames(files.map((file) => file.name));
files.forEach(({ gpx }, i) => zip.file(`${names[i]}.gpx`, gpx));
const blob = await zip.generateAsync({ type: 'blob' });
FileSaver.saveAs(blob, 'gpx-files.zip');
}
}
export async function exportSelectedFiles(options: ExportOptions) {
await exportFiles(selectedFileIds(), options);
}
export async function exportAllFiles(options: ExportOptions) {
await exportFiles(allFileIds(), options);
}
@@ -1,16 +1,14 @@
<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 FileListNodeContent from './FileListNodeContent.svelte';
import { onMount, setContext } from 'svelte';
import { ListFileItem, ListLevel, ListRootItem } from './file-list';
import { ClipboardPaste, FileStack, Plus } from '@lucide/svelte';
import Shortcut from '$lib/components/Shortcut.svelte';
import { i18n } from '$lib/i18n.svelte';
import { fileStateCollection } from '$lib/logic/file-state';
import { createFile, pasteSelection } from '$lib/logic/file-actions';
import { selection, copied } from '$lib/logic/selection';
import { allowedPastes } from './sortable-file-list';
import { i18n } from '$lib/i18n/i18n.svelte';
import { engine } from '$lib/engine';
import { selectedFileIds } from '$lib/engine/selection-helpers';
import { createFile } from '$lib/actions/file-actions';
let {
orientation,
@@ -27,37 +25,32 @@
setContext('orientation', orientation);
setContext('recursive', recursive);
const { files, selection, canPaste } = engine;
onMount(() => {
if (orientation === 'horizontal') {
selection.update(($selection) => {
$selection.forEach((item) => {
if (!(item instanceof ListFileItem)) {
$selection.toggle(item);
$selection.set(new ListFileItem(item.getFileId()), true);
}
});
return $selection;
});
if (orientation === 'horizontal' && $selection.type !== 'file') {
// only files can be selected here
engine.select(selectedFileIds($selection));
}
});
</script>
<ScrollArea
class="shrink-0 {orientation === 'vertical' ? 'p-0 pr-3' : 'h-10 px-1'}"
class="shrink-0 {orientation === 'vertical' ? 'p-0' : 'h-10 px-1'}"
{orientation}
scrollbarXClasses={orientation === 'vertical' ? '' : 'hidden'}
scrollbarYClasses={orientation === 'vertical' ? '' : ''}
>
<div
class="flex {orientation === 'vertical'
? 'flex-col py-1 pl-1 min-h-screen'
? 'flex-col py-1 pl-1 pr-2 min-h-full'
: 'flex-row'} {className ?? ''}"
{style}
>
<FileListNode node={$fileStateCollection} item={new ListRootItem()} />
<FileListNodeContent node={null} />
{#if orientation === 'vertical'}
<ContextMenu.Root>
<ContextMenu.Trigger class="grow" />
<ContextMenu.Trigger class="grow outline-none" />
<ContextMenu.Content>
<ContextMenu.Item onclick={createFile}>
<Plus size="16" />
@@ -66,20 +59,15 @@
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item
onclick={() => selection.selectAll()}
disabled={$fileStateCollection.size === 0}
onclick={() => engine.selectAll()}
disabled={$files.size === 0}
>
<FileStack size="16" />
{i18n._('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)}
onclick={pasteSelection}
>
<ContextMenu.Item disabled={!$canPaste} onclick={() => engine.paste()}>
<ClipboardPaste size="16" />
{i18n._('menu.paste')}
<Shortcut key="V" ctrl={true} />
@@ -1,89 +1,103 @@
<script lang="ts">
import {
GPXFile,
Track,
TrackSegment,
Waypoint,
type AnyGPXTreeElement,
type GPXTreeElement,
} from 'gpx';
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
import { type Readable } from 'svelte/store';
import FileListNodeContent from './FileListNodeContent.svelte';
import FileListNodeLabel from './FileListNodeLabel.svelte';
import { getContext } from 'svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import { settings } from '$lib/state/settings';
import { engine, type FileState } from '$lib/engine';
import {
ListFileItem,
ListTrackSegmentItem,
ListWaypointItem,
ListWaypointsItem,
type ListItem,
type ListTrackItem,
} from './file-list';
import { i18n } from '$lib/i18n.svelte';
import { settings } from '$lib/logic/settings';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
import { selection } from '$lib/logic/selection';
elementId,
hasSelectionWithin,
isSelected,
type FileTreeNode,
} from '$lib/engine/selection-helpers';
let {
fileState,
node,
item,
}: {
node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint;
item: ListItem;
fileState: FileState;
node: FileTreeNode;
} = $props();
let recursive = getContext<boolean>('recursive');
let collapsible: CollapsibleTreeNode | undefined = $state();
let label = $derived(
node instanceof GPXFile && item instanceof ListFileItem
? node.metadata.name
: node instanceof Track
? (node.name ?? `${i18n._('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`)
: node instanceof TrackSegment
? `${i18n._('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}`
: node instanceof Waypoint
? (node.name ??
`${i18n._('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`)
: node instanceof GPXFile && item instanceof ListWaypointsItem
? i18n._('gpx.waypoints')
: ''
);
let label = $derived.by(() => {
const { structure } = fileState;
switch (node.type) {
case 'file':
return structure.name;
case 'route': {
const routeIndex = structure.routes.findIndex((r) => r.id === node.routeId);
return (
structure.routes[routeIndex]?.name ?? `${i18n._('gpx.route')} ${routeIndex + 1}`
);
}
case 'track': {
const trackIndex = structure.tracks.findIndex((t) => t.id === node.trackId);
return (
structure.tracks[trackIndex]?.name ?? `${i18n._('gpx.track')} ${trackIndex + 1}`
);
}
case 'segment': {
const track = structure.tracks.find((t) => t.id === node.trackId);
const segmentIndex =
track?.segments.findIndex((s) => s.id === node.segmentId) ?? -1;
return `${i18n._('gpx.segment')} ${segmentIndex + 1}`;
}
case 'waypoints':
return i18n._('gpx.waypoints');
case 'waypoint': {
const waypointIndex = structure.waypoints.findIndex(
(w) => w.id === node.waypointId
);
return (
structure.waypoints[waypointIndex]?.name ??
`${i18n._('gpx.waypoint')} ${waypointIndex + 1}`
);
}
}
});
// identifies the node in the tree
let id = $derived(elementId(node));
const { treeFileView } = settings;
const { selection } = engine;
$effect(() => {
if (collapsible && $treeFileView && $selection.hasAnyChildren(item, false)) {
// open the node when something below it is selected
if (
collapsible &&
$treeFileView &&
hasSelectionWithin($selection, node) &&
!isSelected($selection, node)
) {
collapsible.openNode();
}
});
</script>
{#if node instanceof Map}
<FileListNodeContent {node} {item} />
{:else if node instanceof TrackSegment}
<FileListNodeLabel {node} {item} {label} />
{:else if node instanceof Waypoint}
<FileListNodeLabel {node} {item} {label} />
{#if node.type === 'route' || node.type === 'segment' || node.type === 'waypoint'}
<FileListNodeLabel {fileState} {node} {label} />
{:else if recursive}
<CollapsibleTreeNode id={item.getId()} bind:this={collapsible}>
<CollapsibleTreeNode {id} bind:this={collapsible}>
{#snippet trigger()}
<FileListNodeLabel {node} {item} {label} />
<FileListNodeLabel {fileState} {node} {label} />
{/snippet}
{#snippet content()}
<div class="ml-4">
{#key node}
<FileListNodeContent {node} {item} />
{/key}
{#if node.type === 'file' && fileState.structure.routes.length > 0}
<FileListNodeContent {fileState} {node} part="routes" />
{:else}
<FileListNodeContent {fileState} {node} />
{/if}
</div>
{/snippet}
</CollapsibleTreeNode>
{:else}
<FileListNodeLabel {node} {item} {label} />
<FileListNodeLabel {fileState} {node} {label} />
{/if}
@@ -1,41 +1,45 @@
<script lang="ts">
import { GPXFile, Track, Waypoint, type AnyGPXTreeElement, type GPXTreeElement } from 'gpx';
import { getContext, onDestroy, onMount } from 'svelte';
import { type Readable } from 'svelte/store';
import FileListNodeStore from './FileListNodeStore.svelte';
import FileListNode from './FileListNode.svelte';
import FileListNodeContent from './FileListNodeContent.svelte';
import { ListFileItem, ListLevel, ListWaypointsItem, type ListItem } from './file-list';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
import { allowedMoves, dragging, SortableFileList } from './sortable-file-list';
import { allowedMoves, dragging, SortableFileList, type ListLevel } from './sortable-file-list';
import { engine, type FileState } from '$lib/engine';
import type { FileTreeNode } from '$lib/engine/selection-helpers';
let {
fileState,
node,
item,
waypointRoot = false,
part = 'tracks',
}: {
node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint;
item: ListItem;
waypointRoot?: boolean;
/** State of the file of the node (not needed for the root). */
fileState?: FileState;
/** The node whose children are listed, `null` for the list of the files. */
node: FileTreeNode | null;
/**
* For a file, which of its lists this is: its routes, its tracks, or the node standing for
* its waypoints. Each one is followed by the next ones.
*/
part?: 'routes' | 'tracks' | 'waypoints';
} = $props();
let waypointRoot = part === 'waypoints';
const { files, order } = engine;
let container: HTMLElement;
let sortableLevel: ListLevel =
node instanceof Map
? ListLevel.FILE
: node instanceof GPXFile
? waypointRoot
? ListLevel.WAYPOINTS
: item instanceof ListWaypointsItem
? ListLevel.WAYPOINT
: ListLevel.TRACK
: node instanceof Track
? ListLevel.SEGMENT
: ListLevel.WAYPOINT;
node === null
? 'file'
: node.type === 'file'
? part === 'waypoints'
? 'waypoints'
: part === 'routes'
? 'route'
: 'track'
: node.type === 'waypoints'
? 'waypoint'
: 'segment';
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
let canDrop = $derived($dragging !== null && allowedMoves[$dragging].includes(sortableLevel));
@@ -43,18 +47,14 @@
let sortable: SortableFileList;
onMount(() => {
sortable = new SortableFileList(
container,
node,
item,
waypointRoot,
sortableLevel,
orientation
);
sortable = new SortableFileList(container, node, waypointRoot, sortableLevel, orientation);
});
$effect(() => {
if (sortable && node) {
// the elements of the list changed
$order;
fileState;
if (sortable) {
sortable.updateElements();
}
});
@@ -70,45 +70,76 @@
? 'flex-col'
: 'flex-row gap-1'} {canDrop ? 'min-h-5' : ''}"
>
{#if node instanceof Map}
{#each node as [fileId, file] (fileId)}
<div data-id={fileId}>
<FileListNodeStore {file} />
</div>
{/each}
{:else if node instanceof GPXFile}
{#if item instanceof ListWaypointsItem}
{#each node.wpt as wpt, i (wpt)}
<div data-id={i} class="ml-1">
<FileListNode node={wpt} item={item.extend(i)} />
</div>
{/each}
{:else if waypointRoot}
{#if node.wpt.length > 0}
<div data-id="waypoints">
<FileListNode {node} item={item.extend('waypoints')} />
{#if node === null}
{#each $order as fileId (fileId)}
{@const file = $files.get(fileId)}
{#if file}
<div data-id={fileId}>
<FileListNodeStore {file} />
</div>
{/if}
{:else}
{#each node.children as child, i (child)}
<div data-id={i}>
<FileListNode node={child} item={item.extend(i)} />
{/each}
{:else if fileState}
{#if node.type === 'file'}
{#if part === 'waypoints'}
{#if fileState.structure.waypoints.length > 0}
<div data-id="waypoints">
<FileListNode
{fileState}
node={{ type: 'waypoints', fileId: node.fileId }}
/>
</div>
{/if}
{:else if part === 'routes'}
{#each fileState.structure.routes as route (route.id)}
<div data-id={route.id}>
<FileListNode
{fileState}
node={{ type: 'route', fileId: node.fileId, routeId: route.id }}
/>
</div>
{/each}
{:else}
{#each fileState.structure.tracks as track (track.id)}
<div data-id={track.id}>
<FileListNode
{fileState}
node={{ type: 'track', fileId: node.fileId, trackId: track.id }}
/>
</div>
{/each}
{/if}
{:else if node.type === 'waypoints'}
{#each fileState.structure.waypoints as waypoint (waypoint.id)}
<div data-id={waypoint.id} class="ml-1">
<FileListNode
{fileState}
node={{ type: 'waypoint', fileId: node.fileId, waypointId: waypoint.id }}
/>
</div>
{/each}
{:else if node.type === 'track'}
{#each fileState.structure.tracks.find((t) => t.id === node.trackId)?.segments ?? [] as segment (segment.id)}
<div data-id={segment.id} class="ml-1">
<FileListNode
{fileState}
node={{
type: 'segment',
fileId: node.fileId,
trackId: node.trackId,
segmentId: segment.id,
}}
/>
</div>
{/each}
{/if}
{:else if node instanceof Track}
{#each node.children as child, i (child)}
<div data-id={i} class="ml-1">
<FileListNode node={child} item={item.extend(i)} />
</div>
{/each}
{/if}
</div>
{#if node instanceof GPXFile && item instanceof ListFileItem}
{#if !waypointRoot}
<FileListNodeContent {node} {item} waypointRoot={true} />
{/if}
{#if fileState && node?.type === 'file' && part === 'routes'}
<FileListNodeContent {fileState} {node} part="tracks" />
{:else if fileState && node?.type === 'file' && part === 'tracks'}
<FileListNodeContent {fileState} {node} part="waypoints" />
{/if}
<style lang="postcss">
@@ -17,113 +17,140 @@
Maximize,
Scissors,
FileStack,
FileX,
Route,
ArrowRightLeft,
} from '@lucide/svelte';
import {
ListFileItem,
ListLevel,
ListTrackItem,
ListWaypointItem,
type ListItem,
} from './file-list';
import { getContext } from 'svelte';
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
import { i18n } from '$lib/i18n.svelte';
import { getContext, untrack } from 'svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import MetadataDialog from '$lib/components/file-list/metadata/MetadataDialog.svelte';
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
import StyleDialog from '$lib/components/file-list/style/StyleDialog.svelte';
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
import { selection, copied, cut } from '$lib/logic/selection';
import { fileActions, pasteSelection } from '$lib/logic/file-actions';
import { allHidden } from '$lib/logic/hidden';
import { boundsManager } from '$lib/logic/bounds';
import { gpxColors, gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
import { fileStateCollection } from '$lib/logic/file-state';
import { getSymbolKey, symbols } from '$lib/data/symbols';
import { engine, type FileState } from '$lib/engine';
import { allHidden, selectedFilesConversion } from '$lib/engine/selection-stores';
import { normalizeColor } from '$lib/engine/file-colors';
import { isHidden } from '$lib/engine/file-visibility';
import {
elementId,
isInClipboard,
isSelected,
pathInfo,
selectedElementIds,
selectedFileIds,
selectionSize,
type FileTreeNode,
} from '$lib/engine/selection-helpers';
import { boundsManager } from '$lib/state/bounds';
import { closeFiles } from '$lib/actions/file-actions';
import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
import { allowedPastes } from './sortable-file-list';
let {
fileState,
node,
item,
label,
}: {
node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
item: ListItem;
fileState: FileState;
node: FileTreeNode;
label: string | undefined;
} = $props();
const { selection, clipboard, canPaste, workspace } = engine;
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
let embedding = getContext<boolean>('embedding');
let singleSelection = $derived($selection.size === 1);
let level = $derived(node.type);
let singleSelection = $derived(selectionSize($selection) === 1);
// a route or a track: it has a name, a description and a style, like a file
let isPath = $derived(node.type === 'route' || node.type === 'track');
let nodeColors: string[] = $derived.by(() => {
let colors: string[] = [];
if (node) {
if (node instanceof GPXFile) {
let defaultColor = $gpxColors.get(item.getFileId());
let style = node.getStyle(defaultColor);
colors = style.color;
} else if (node instanceof Track) {
let style = node.getStyle();
if (
style &&
style['gpx_style:color'] &&
!colors.includes(style['gpx_style:color'])
) {
colors.push(style['gpx_style:color']);
}
if (colors.length === 0) {
let defaultColor = $gpxColors.get(item.getFileId());
if (defaultColor) {
colors.push(defaultColor);
}
}
}
const { routes, tracks } = fileState.structure;
if (node.type === 'file') {
// the colors defined by the routes and tracks, or else the one of the file
const colors = [
...new Set(
[...routes, ...tracks].flatMap((path) =>
path.color !== undefined ? [normalizeColor(path.color)] : []
)
),
];
return colors.length > 0 ? colors : [fileState.color];
} else if (isPath) {
const color = pathInfo(fileState.structure, node)?.color;
return [color !== undefined ? normalizeColor(color) : fileState.color];
}
return colors;
return [];
});
let symbolKey = $derived(node instanceof Waypoint ? getSymbolKey(node.sym) : undefined);
let openEditMetadata: boolean = $derived(
editMetadata.current && singleSelection && $selection.has(item)
let symbolKey = $derived(
node.type === 'waypoint'
? getSymbolKey(fileState.structure.waypoints.find((w) => w.id === node.waypointId)?.sym)
: undefined
);
let selected = $derived(isSelected($selection, node));
let openEditMetadata: boolean = $derived(editMetadata.current && singleSelection && selected);
let openEditStyle: boolean = $derived(
editStyle.current &&
$selection.has(item) &&
$selection.getSelected().findIndex((i) => i.getFullId() === item.getFullId()) === 0
selected &&
selectedElementIds($selection)[0]?.ids[0] === elementId(node)
);
$effect(() => {
// the file is open for good once its info or its appearance is (the desktop app)
if (openEditMetadata || openEditStyle) {
untrack(() => engine.keepPreview([node.fileId]));
}
});
let hidden = $derived(
item.level === ListLevel.WAYPOINTS ? node._data.hiddenWpt : node._data.hidden
// the desktop app: the files are linked to the file system, they are closed rather than deleted
let desktop = $derived($workspace !== undefined);
// the file is not the one on the disk (the desktop app)
let unsaved = $derived(node.type === 'file' && $workspace?.files[node.fileId]?.dirty === true);
// the file is open as a preview, which the next one replaces (the desktop app)
let preview = $derived(
node.type === 'file' && $workspace?.files[node.fileId]?.preview === true
);
let hidden = $derived(isHidden(fileState, elementId(node)));
// cut elements are greyed until they are pasted
let isCut = $derived($clipboard?.cut === true && isInClipboard($clipboard, node));
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<ContextMenu.Root
onOpenChange={(open) => {
if (open) {
if (!$selection.has(item)) {
selection.selectItem(item);
// the file is open for good once its menu is (the desktop app)
engine.keepPreview([node.fileId]);
if (!selected) {
engine.selectNode(node);
}
}
}}
>
<ContextMenu.Trigger class="grow truncate">
<!-- A tab of the horizontal list is as wide as its content, said explicitly: a width that is a
percentage of a parent that is as wide as its content is not updated by WebKit when the
content changes (the indicators come and go) -->
<!-- it only catches right-clicks: no focus ring when it has the focus (WebKit gives it on a
click), the selection shows where the arrow keys are -->
<ContextMenu.Trigger class="outline-none {orientation === 'vertical' ? 'grow truncate' : ''}">
<Button
variant="ghost"
class="relative w-full p-0 overflow-hidden border-none focus-visible:ring-0 focus-visible:ring-offset-0 flex flex-row {orientation ===
class="relative p-0 overflow-hidden border-none focus-visible:ring-0 focus-visible:ring-offset-0 flex flex-row {orientation ===
'vertical'
? 'h-7'
: 'h-9 px-1.5'} pointer-events-auto"
? 'w-full h-7'
: 'w-max h-9 px-1.5'} pointer-events-auto"
>
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
<MetadataDialog bind:open={openEditMetadata} {node} {item} />
<StyleDialog bind:open={openEditStyle} {item} />
{#if node.type === 'file' || isPath}
<MetadataDialog bind:open={openEditMetadata} {fileState} {node} />
<StyleDialog bind:open={openEditStyle} {fileState} {node} />
{/if}
{#if item.level === ListLevel.FILE || item.level === ListLevel.TRACK}
{#if level === 'file' || isPath}
<div
class="absolute {orientation === 'vertical'
? 'top-0 bottom-0 right-0 w-1'
@@ -139,9 +166,9 @@
></div>
{/if}
<span
class="grow text-left truncate ml-1 flex flex-row items-center {hidden
? 'text-muted-foreground'
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId())
class="grow text-left ml-1 flex flex-row items-center {orientation === 'vertical'
? 'truncate'
: 'whitespace-nowrap'} {hidden ? 'text-muted-foreground' : ''} {isCut
? 'text-muted-foreground'
: ''}"
oncontextmenu={(e) => {
@@ -154,37 +181,32 @@
// Add to selection instead of opening context menu
e.preventDefault();
e.stopPropagation();
$selection.toggle(item);
$selection = $selection;
engine.selectNode(node, 'toggle');
}
}}
onmouseenter={() => {
if (item instanceof ListWaypointItem) {
let layer = gpxLayers.getLayer(item.getFileId());
let file = fileStateCollection.getFile(item.getFileId());
if (layer && file) {
let waypoint = file.wpt[item.getWaypointIndex()];
if (waypoint && !waypoint._data.hidden) {
waypointPopup?.setItem({
item: waypoint,
fileId: item.getFileId(),
});
}
onmouseenter={async () => {
if (node.type === 'waypoint' && !isHidden(fileState, node.waypointId)) {
const waypoint = await engine.waypoint(node.fileId, node.waypointId);
if (waypoint) {
waypointPopup?.setItem({
item: waypoint,
kind: 'waypoint',
fileId: node.fileId,
});
}
}
}}
onmouseleave={() => {
if (item instanceof ListWaypointItem) {
let layer = gpxLayers.getLayer(item.getFileId());
if (layer) {
waypointPopup?.setItem(null);
}
if (node.type === 'waypoint') {
waypointPopup?.setItem(null);
}
}}
>
{#if item.level === ListLevel.SEGMENT}
{#if level === 'route'}
<Route size="16" class="mx-1 shrink-0" />
{:else if level === 'segment'}
<Waypoints size="16" class="mx-1 shrink-0" />
{:else if item.level === ListLevel.WAYPOINT}
{:else if level === 'waypoint'}
{#if symbolKey && symbols[symbolKey].icon}
{@const SymbolIcon = symbols[symbolKey].icon}
<SymbolIcon size="16" class="mx-1 shrink-0" />
@@ -192,26 +214,38 @@
<MapPin size="16" class="mx-1 shrink-0" />
{/if}
{/if}
<!-- a tab of the horizontal list is as wide as its content: the name is not cut -->
<span
class="grow select-none truncate {orientation === 'vertical'
? 'last:mr-2'
: ''}"
class:italic={preview}
class="grow select-none {orientation === 'vertical'
? 'truncate last:mr-2'
: 'shrink-0'}"
>
{label}
</span>
{#if hidden}
<EyeOff
size="10"
class="shrink-0 size-3.5 ml-1 {orientation === 'vertical'
{#if unsaved || hidden}
<span
class="flex flex-row items-center gap-1 shrink-0 pl-1 {orientation ===
'vertical'
? 'mr-3'
: 'mt-0.5'}"
/>
: ''}"
>
{#if unsaved}
<span class="size-1.5 rounded-full bg-foreground"></span>
{/if}
{#if hidden}
<EyeOff
size="10"
class="size-3.5 {orientation === 'vertical' ? '' : 'mt-0.5'}"
/>
{/if}
</span>
{/if}
</span>
</Button>
</ContextMenu.Trigger>
<ContextMenu.Content>
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
{#if node.type === 'file' || isPath}
<ContextMenu.Item
disabled={!singleSelection}
onclick={() => (editMetadata.current = true)}
@@ -224,16 +258,53 @@
<PaintBucket size="16" />
{i18n._('menu.style.button')}
</ContextMenu.Item>
<ContextMenu.Separator />
{/if}
<ContextMenu.Item
onclick={() => {
if ($allHidden) {
fileActions.setHiddenToSelection(false);
} else {
fileActions.setHiddenToSelection(true);
}
}}
>
{#if orientation === 'vertical'}
{#if node.type === 'file'}
<ContextMenu.Item disabled={!singleSelection} onclick={() => engine.newTrack()}>
<Plus size="16" />
{i18n._('menu.new_track')}
</ContextMenu.Item>
<ContextMenu.Item disabled={!singleSelection} onclick={() => engine.newRoute()}>
<Plus size="16" />
{i18n._('menu.new_route')}
</ContextMenu.Item>
{:else if node.type === 'track'}
<ContextMenu.Item
disabled={!singleSelection}
onclick={() => engine.newTrackSegment()}
>
<Plus size="16" />
{i18n._('menu.new_segment')}
</ContextMenu.Item>
{/if}
{#if isPath || node.type === 'segment'}
<ContextMenu.Item onclick={() => engine.convert()}>
<ArrowRightLeft size="16" />
{i18n._(
node.type === 'route' ? 'menu.convert_to_track' : 'menu.convert_to_route'
)}
</ContextMenu.Item>
<ContextMenu.Separator />
{/if}
{/if}
{#if node.type === 'file'}
{#if $selectedFilesConversion}
<ContextMenu.Item onclick={() => engine.convert()}>
<ArrowRightLeft size="16" />
{i18n._(
$selectedFilesConversion === 'route'
? 'menu.convert_to_routes'
: 'menu.convert_to_tracks'
)}
</ContextMenu.Item>
{/if}
{#if orientation === 'vertical' || $selectedFilesConversion}
<ContextMenu.Separator />
{/if}
{/if}
<ContextMenu.Item onclick={() => engine.setSelectionHidden(!$allHidden)}>
{#if $allHidden}
<Eye size="16" />
{i18n._('menu.unhide')}
@@ -243,74 +314,55 @@
{/if}
<Shortcut key="H" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator />
{#if orientation === 'vertical'}
{#if item instanceof ListFileItem}
<ContextMenu.Item
disabled={!singleSelection}
onclick={() => fileActions.addNewTrack(item.getFileId())}
>
<Plus size="16" />
{i18n._('menu.new_track')}
</ContextMenu.Item>
<ContextMenu.Separator />
{:else if item instanceof ListTrackItem}
<ContextMenu.Item
disabled={!singleSelection}
onclick={() =>
fileActions.addNewSegment(item.getFileId(), item.getTrackIndex())}
>
<Plus size="16" />
{i18n._('menu.new_segment')}
</ContextMenu.Item>
<ContextMenu.Separator />
{/if}
{/if}
{#if item.level !== ListLevel.WAYPOINTS}
<ContextMenu.Item onclick={() => selection.selectAll()}>
<FileStack size="16" />
{i18n._('menu.select_all')}
<Shortcut key="A" ctrl={true} />
</ContextMenu.Item>
{/if}
<ContextMenu.Item onclick={() => boundsManager.centerMapOnSelection()}>
<Maximize size="16" />
{i18n._('menu.center')}
<Shortcut key="⏎" ctrl={true} />
</ContextMenu.Item>
{#if level !== 'waypoints'}
<ContextMenu.Separator />
<ContextMenu.Item onclick={() => engine.selectAll()}>
<FileStack size="16" />
{i18n._('menu.select_all')}
<Shortcut key="A" ctrl={true} />
</ContextMenu.Item>
{/if}
<ContextMenu.Separator />
<ContextMenu.Item onclick={fileActions.duplicateSelection}>
<ContextMenu.Item onclick={() => engine.duplicate()}>
<Copy size="16" />
{i18n._('menu.duplicate')}
<Shortcut key="D" ctrl={true} />
</ContextMenu.Item>
{#if orientation === 'vertical'}
<ContextMenu.Item onclick={() => selection.copySelection()}>
<ContextMenu.Item onclick={() => engine.copy()}>
<ClipboardCopy size="16" />
{i18n._('menu.copy')}
<Shortcut key="C" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Item onclick={() => selection.cutSelection()}>
<ContextMenu.Item onclick={() => engine.cut()}>
<Scissors size="16" />
{i18n._('menu.cut')}
<Shortcut key="X" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Item
disabled={$copied === undefined ||
$copied.length === 0 ||
!allowedPastes[$copied[0].level].includes(item.level)}
onclick={pasteSelection}
>
<ContextMenu.Item disabled={!$canPaste} onclick={() => engine.paste()}>
<ClipboardPaste size="16" />
{i18n._('menu.paste')}
<Shortcut key="V" ctrl={true} />
</ContextMenu.Item>
{/if}
<ContextMenu.Separator />
<ContextMenu.Item onclick={fileActions.deleteSelection}>
<Trash2 size="16" />
{i18n._('menu.delete')}
<Shortcut key="⌫" ctrl={true} />
</ContextMenu.Item>
{#if desktop && node.type === 'file'}
<ContextMenu.Item onclick={() => closeFiles(selectedFileIds($selection))}>
<FileX size="16" />
{i18n._('menu.close')}
<Shortcut key="W" ctrl={true} />
</ContextMenu.Item>
{:else}
<ContextMenu.Item onclick={() => engine.delete()}>
<Trash2 size="16" />
{i18n._('menu.delete')}
<Shortcut key="⌫" ctrl={true} />
</ContextMenu.Item>
{/if}
</ContextMenu.Content>
</ContextMenu.Root>
@@ -4,24 +4,21 @@
import { getContext } from 'svelte';
import type { Readable } from 'svelte/store';
import { ListFileItem } from './file-list';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
import type { FileState } from '$lib/engine';
let {
file,
}: {
file: Readable<GPXFileWithStatistics | undefined>;
file: Readable<FileState>;
} = $props();
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 fileState={$file} node={{ type: 'file', fileId: $file.structure.id }} />
</CollapsibleTree>
{:else}
<FileListNode fileState={$file} node={{ type: 'file', fileId: $file.structure.id }} />
{/if}
@@ -0,0 +1,69 @@
<script lang="ts">
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
import { i18n } from '$lib/i18n/i18n.svelte';
import { unsavedPrompt } from './unsaved-prompt.svelte';
let prompt = $derived(unsavedPrompt.current);
/** Closes the dialog, and gives the answer to whoever asked. */
function answer(choice: 'save' | 'discard' | 'reload' | 'cancel') {
const current = unsavedPrompt.current;
unsavedPrompt.current = undefined;
if (current?.kind === 'unsaved' && choice !== 'reload') {
current.resolve(choice);
} else if (current?.kind === 'overwrite') {
current.resolve(
choice === 'save' ? 'overwrite' : choice === 'reload' ? 'reload' : 'cancel'
);
}
}
</script>
<AlertDialog.Root
open={prompt !== undefined}
onOpenChange={(open) => {
if (!open) {
answer('cancel');
}
}}
>
<AlertDialog.Content>
{#if prompt}
<AlertDialog.Header>
<AlertDialog.Title>
{prompt.kind === 'unsaved'
? i18n._('menu.unsaved_title')
: i18n._('menu.overwrite_title')}
</AlertDialog.Title>
<AlertDialog.Description>
{prompt.kind === 'unsaved'
? i18n._('menu.unsaved_description')
: i18n._('menu.overwrite_description')}
</AlertDialog.Description>
</AlertDialog.Header>
<ul class="list-disc pl-6 text-sm max-h-40 overflow-y-auto">
{#each prompt.names as name, i (i)}
<li>{name}</li>
{/each}
</ul>
<AlertDialog.Footer>
<Button variant="outline" onclick={() => answer('cancel')}>
{i18n._('menu.cancel')}
</Button>
{#if prompt.kind === 'unsaved'}
<Button variant="destructive" onclick={() => answer('discard')}>
{i18n._('menu.discard')}
</Button>
{:else}
<Button variant="destructive" onclick={() => answer('reload')}>
{i18n._('menu.reload')}
</Button>
{/if}
<Button onclick={() => answer('save')}>
{prompt.kind === 'unsaved' ? i18n._('menu.save') : i18n._('menu.overwrite')}
</Button>
</AlertDialog.Footer>
{/if}
</AlertDialog.Content>
</AlertDialog.Root>
@@ -1,300 +0,0 @@
export enum ListLevel {
ROOT,
FILE,
TRACK,
SEGMENT,
WAYPOINTS,
WAYPOINT,
}
export abstract class ListItem {
[x: string]: any;
level: ListLevel;
constructor(level: ListLevel) {
this.level = level;
}
abstract getId(): string | number;
abstract getFullId(): string;
abstract getIdAtLevel(level: ListLevel): string | number | undefined;
abstract getFileId(): string;
abstract getParent(): ListItem;
abstract extend(id: string | number): ListItem;
}
export class ListRootItem extends ListItem {
constructor() {
super(ListLevel.ROOT);
}
getId(): string {
return 'root';
}
getFullId(): string {
return 'root';
}
getIdAtLevel(level: ListLevel): string | number | undefined {
return undefined;
}
getFileId(): string {
return '';
}
getParent(): ListItem {
return this;
}
extend(id: string): ListFileItem {
return new ListFileItem(id);
}
}
export class ListFileItem extends ListItem {
fileId: string;
constructor(fileId: string) {
super(ListLevel.FILE);
this.fileId = fileId;
}
getId(): string {
return this.fileId;
}
getFullId(): string {
return this.fileId;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
return this.fileId;
default:
return undefined;
}
}
getFileId(): string {
return this.fileId;
}
getParent(): ListItem {
return new ListRootItem();
}
extend(id: number | 'waypoints'): ListTrackItem | ListWaypointsItem {
if (id === 'waypoints') {
return new ListWaypointsItem(this.fileId);
} else {
return new ListTrackItem(this.fileId, id);
}
}
}
export class ListTrackItem extends ListItem {
fileId: string;
trackIndex: number;
constructor(fileId: string, trackIndex: number) {
super(ListLevel.TRACK);
this.fileId = fileId;
this.trackIndex = trackIndex;
}
getId(): number {
return this.trackIndex;
}
getFullId(): string {
return `${this.fileId}-track-${this.trackIndex}`;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
return this.fileId;
case ListLevel.FILE:
return this.trackIndex;
default:
return undefined;
}
}
getFileId(): string {
return this.fileId;
}
getTrackIndex(): number {
return this.trackIndex;
}
getParent(): ListItem {
return new ListFileItem(this.fileId);
}
extend(id: number): ListTrackSegmentItem {
return new ListTrackSegmentItem(this.fileId, this.trackIndex, id);
}
}
export class ListTrackSegmentItem extends ListItem {
fileId: string;
trackIndex: number;
segmentIndex: number;
constructor(fileId: string, trackIndex: number, segmentIndex: number) {
super(ListLevel.SEGMENT);
this.fileId = fileId;
this.trackIndex = trackIndex;
this.segmentIndex = segmentIndex;
}
getId(): number {
return this.segmentIndex;
}
getFullId(): string {
return `${this.fileId}-track-${this.trackIndex}--${this.segmentIndex}`;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
return this.fileId;
case ListLevel.FILE:
return this.trackIndex;
case ListLevel.TRACK:
return this.segmentIndex;
default:
return undefined;
}
}
getFileId(): string {
return this.fileId;
}
getTrackIndex(): number {
return this.trackIndex;
}
getSegmentIndex(): number {
return this.segmentIndex;
}
getParent(): ListItem {
return new ListTrackItem(this.fileId, this.trackIndex);
}
extend(): ListTrackSegmentItem {
return this;
}
}
export class ListWaypointsItem extends ListItem {
fileId: string;
constructor(fileId: string) {
super(ListLevel.WAYPOINTS);
this.fileId = fileId;
}
getId(): string {
return 'waypoints';
}
getFullId(): string {
return `${this.fileId}-waypoints`;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
return this.fileId;
case ListLevel.FILE:
return 'waypoints';
default:
return undefined;
}
}
getFileId(): string {
return this.fileId;
}
getParent(): ListItem {
return new ListFileItem(this.fileId);
}
extend(id: number): ListWaypointItem {
return new ListWaypointItem(this.fileId, id);
}
}
export class ListWaypointItem extends ListItem {
fileId: string;
waypointIndex: number;
constructor(fileId: string, waypointIndex: number) {
super(ListLevel.WAYPOINT);
this.fileId = fileId;
this.waypointIndex = waypointIndex;
}
getId(): number {
return this.waypointIndex;
}
getFullId(): string {
return `${this.fileId}-waypoint-${this.waypointIndex}`;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
return this.fileId;
case ListLevel.FILE:
return 'waypoints';
case ListLevel.WAYPOINTS:
return this.waypointIndex;
default:
return undefined;
}
}
getFileId(): string {
return this.fileId;
}
getWaypointIndex(): number {
return this.waypointIndex;
}
getParent(): ListItem {
return new ListWaypointsItem(this.fileId);
}
extend(): ListWaypointItem {
return this;
}
}
export function sortItems(items: ListItem[], reverse: boolean = false) {
items.sort((a, b) => {
if (a instanceof ListTrackItem && b instanceof ListTrackItem) {
return a.getTrackIndex() - b.getTrackIndex();
} else if (a instanceof ListTrackSegmentItem && b instanceof ListTrackSegmentItem) {
return a.getSegmentIndex() - b.getSegmentIndex();
} else if (a instanceof ListWaypointItem && b instanceof ListWaypointItem) {
return a.getWaypointIndex() - b.getWaypointIndex();
}
return a.level - b.level;
});
if (reverse) {
items.reverse();
}
}
@@ -3,42 +3,76 @@
import { Input } from '$lib/components/ui/input';
import { Textarea } from '$lib/components/ui/textarea';
import { Label } from '$lib/components/ui/label/index.js';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Popover from '$lib/components/ui/popover';
import { Save } from '@lucide/svelte';
import { ListFileItem, ListTrackItem, type ListItem } from '../file-list';
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
import { fileActionManager } from '$lib/logic/file-action-manager';
import { engine, type FileState } from '$lib/engine';
import { pathInfo, type FileTreeNode } from '$lib/engine/selection-helpers';
import { showEngineError } from '$lib/actions/file-actions';
let {
fileState,
node,
item,
open = $bindable(),
}: {
node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
item: ListItem;
fileState: FileState;
node: FileTreeNode;
open: boolean;
} = $props();
// the route or the track
let path = $derived(pathInfo(fileState.structure, node));
let name: string = $derived(
node instanceof GPXFile
? (node.metadata.name ?? '')
: node instanceof Track
? (node.name ?? '')
: ''
node.type === 'file' ? fileState.structure.name : (path?.name ?? '')
);
let description: string = $derived(
node instanceof GPXFile
? (node.metadata.desc ?? '')
: node instanceof Track
? (node.desc ?? '')
: ''
node.type === 'file' ? (fileState.structure.desc ?? '') : (path?.desc ?? '')
);
const { workspace } = engine;
// the name of the file on the disk, without its extension (the desktop app), which is not the
// name of the file: it can be renamed with it, which is not undone
let fileName = $derived.by(() => {
if (node.type !== 'file') return undefined;
const path = $workspace?.files[node.fileId]?.path;
return path
?.split(/[\\/]/)
.pop()
?.replace(/\.[^.]*$/, '');
});
// asked only when the name is changed, and proposed then
let renamed = $derived(node.type === 'file' && name !== fileState.structure.name);
let renameFile = $state(true);
async function save() {
// closes the dialog now: `open` follows `editMetadata`, and the change of the file would
// open the dialog again before the effect below has reset it
editMetadata.current = false;
open = false;
// the file on the disk first: nothing is changed if it cannot be renamed (the name of
// another file, for instance)
if (
renamed &&
renameFile &&
fileName !== undefined &&
fileName !== name &&
node.type === 'file'
) {
if (!(await engine.renameFile(node.fileId, name))) {
showEngineError();
return;
}
}
engine.metadata(name, description);
}
$effect(() => {
if (!open) {
editMetadata.current = false;
renameFile = true;
}
});
</script>
@@ -50,24 +84,13 @@
<Input bind:value={name} id="name" class="font-semibold h-8" />
<Label for="description">{i18n._('menu.metadata.description')}</Label>
<Textarea bind:value={description} id="description" />
<Button
variant="outline"
onclick={() => {
fileActionManager.applyToFile(item.getFileId(), (file) => {
if (item instanceof ListFileItem && node instanceof GPXFile) {
file.metadata.name = name;
file.metadata.desc = description;
if (file.trk.length === 1) {
file.trk[0].name = name;
}
} else if (item instanceof ListTrackItem && node instanceof Track) {
file.trk[item.getTrackIndex()].name = name;
file.trk[item.getTrackIndex()].desc = description;
}
});
open = false;
}}
>
{#if fileName !== undefined && renamed}
<div class="flex flex-row items-center gap-2">
<Checkbox id="rename-file" bind:checked={renameFile} class="scale-90" />
<Label for="rename-file">{i18n._('menu.metadata.rename_file')}</Label>
</div>
{/if}
<Button variant="outline" onclick={save}>
<Save size="16" />
{i18n._('menu.metadata.save')}
</Button>
@@ -1,64 +1,67 @@
import { isMac } from '$lib/utils';
import Sortable, { type Direction } from 'sortablejs/Sortable';
import { ListItem, ListLevel, ListRootItem } from './file-list';
import { selection } from '$lib/logic/selection';
import { getFileIds, moveItems } from '$lib/logic/file-actions';
import { get, writable, type Readable } from 'svelte/store';
import { settings } from '$lib/logic/settings';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
import type { AnyGPXTreeElement, GPXTreeElement, Waypoint } from 'gpx';
import Sortable from './sortable';
import type { Direction } from 'sortablejs';
import { get, writable } from 'svelte/store';
import { tick } from 'svelte';
import { engine, type MoveTarget, type Selection } from '$lib/engine';
import { isSelected, type FileTreeNode } from '$lib/engine/selection-helpers';
const { fileOrder } = settings;
/**
* What a list holds: the files, the routes or the tracks of a file, the segments of a track, or the
* waypoints.
*/
export type ListLevel = 'file' | 'route' | 'track' | 'segment' | 'waypoints' | 'waypoint';
/**
* Where the elements of a list can be moved to (a segment on its own can become a route). Routes
* and tracks can be dropped among each other, but keep their kind: the engine puts them with the
* others of their kind, the routes coming first.
*/
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
[ListLevel.ROOT]: [],
[ListLevel.FILE]: [ListLevel.FILE],
[ListLevel.TRACK]: [ListLevel.FILE, ListLevel.TRACK],
[ListLevel.SEGMENT]: [ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
[ListLevel.WAYPOINTS]: [ListLevel.WAYPOINTS],
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
};
export const allowedPastes: Record<ListLevel, ListLevel[]> = {
[ListLevel.ROOT]: [],
[ListLevel.FILE]: [ListLevel.ROOT, ListLevel.FILE],
[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],
file: ['file'],
route: ['file', 'route', 'track'],
track: ['file', 'route', 'track'],
segment: ['file', 'route', 'track', 'segment'],
waypoints: ['waypoints'],
waypoint: ['waypoints', 'waypoint'],
};
export const dragging = writable<ListLevel | null>(null);
/** The list of each container, to find the lists a drop is about from its event. */
const lists = new WeakMap<HTMLElement, SortableFileList>();
/**
* Makes a list of the file tree sortable and selectable. The elements of the list are the
* children of `parent` (the files when `parent` is null), their ids are in their `data-id`.
*/
export class SortableFileList {
private _node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint;
private _item: ListItem;
private _parent: FileTreeNode | null;
private _sortableLevel: ListLevel;
private _container: HTMLElement;
private _sortable: Sortable | null = null;
private _elements: { [id: string]: HTMLElement } = {};
private _updatingSelection: boolean = false;
/**
* Whether a change of the selection that this list made is being applied by the engine. A click
* makes several events, handled after one another, and the engine can answer after them (in
* the desktop app, it is in another process): until it has, the list holds the selection, and
* the engine and the list disagree because the engine is behind, not because the user did
* something else.
*/
private _applying: boolean = false;
/** The elements of the list when a drag from it started, to put them back when it ends. */
private _snapshot: ChildNode[] = [];
private _unsubscribes: (() => void)[] = [];
constructor(
container: HTMLElement,
node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint,
item: ListItem,
parent: FileTreeNode | null,
waypointRoot: boolean,
sortableLevel: ListLevel,
orientation: Direction
) {
this._node = node;
this._item = item;
this._parent = parent;
this._sortableLevel = sortableLevel;
this._container = container;
this._sortable = Sortable.create(container, {
@@ -76,92 +79,178 @@ export class SortableFileList {
setTimeout(() => this.updateToSelection(e), 50),
onDeselect: (e: Sortable.SortableEvent) =>
setTimeout(() => this.updateToSelection(e), 50),
onStart: () => dragging.set(sortableLevel),
onChoose: () => {
// all the nodes, not only the elements: the comments are the anchors of the Svelte
// blocks. Taken before the drag starts, when MultiDrag removes the other selected
// elements from the list.
this._snapshot = Array.from(container.childNodes);
},
onStart: (e: Sortable.SortableEvent) => {
dragging.set(sortableLevel);
// the files that are dragged (or whose elements are) are open for good
engine.keepPreview(this.draggedFiles(e));
},
onEnd: () => dragging.set(null),
onSort: (e: Sortable.SortableEvent) => this.onSort(e),
});
Object.defineProperty(this._sortable, '_item', {
value: item,
writable: true,
});
Object.defineProperty(this._sortable, '_waypointRoot', {
value: waypointRoot,
writable: true,
});
lists.set(container, this);
this._unsubscribes.push(
selection.subscribe(() => tick().then(() => this.updateFromSelection()))
engine.selection.subscribe(() => tick().then(() => this.updateFromSelection()))
);
this._unsubscribes.push(fileOrder.subscribe(() => this.updateFromFileOrder()));
}
/** The files of what is dragged: the dragged files, or the file of the dragged elements. */
private draggedFiles(e: Sortable.SortableEvent): string[] {
if (this._parent) {
return [this._parent.fileId];
}
const elements: HTMLElement[] = e.items.length > 0 ? e.items : [e.item];
return elements
.map((element) => element.getAttribute('data-id'))
.filter((id): id is string => id !== null);
}
/** The node of a child of the list. */
childNode(id: string): FileTreeNode | undefined {
const parent = this._parent;
switch (this._sortableLevel) {
case 'file':
return { type: 'file', fileId: id };
case 'route':
return parent ? { type: 'route', fileId: parent.fileId, routeId: id } : undefined;
case 'track':
return parent ? { type: 'track', fileId: parent.fileId, trackId: id } : undefined;
case 'segment':
return parent?.type === 'track'
? {
type: 'segment',
fileId: parent.fileId,
trackId: parent.trackId,
segmentId: id,
}
: undefined;
case 'waypoints':
return parent ? { type: 'waypoints', fileId: parent.fileId } : undefined;
case 'waypoint':
return parent
? { type: 'waypoint', fileId: parent.fileId, waypointId: id }
: undefined;
}
}
/** The elements of the list in the selection form. */
private selectionOf(ids: string[]): Selection | undefined {
const parent = this._parent;
switch (this._sortableLevel) {
case 'file':
return { type: 'file', fileIds: ids };
case 'route':
return parent ? { type: 'route', fileId: parent.fileId, routeIds: ids } : undefined;
case 'track':
return parent ? { type: 'track', fileId: parent.fileId, trackIds: ids } : undefined;
case 'segment':
return parent?.type === 'track'
? {
type: 'segment',
fileId: parent.fileId,
trackId: parent.trackId,
segmentIds: ids,
}
: undefined;
case 'waypoints':
return parent ? { type: 'waypoints', fileId: parent.fileId } : undefined;
case 'waypoint':
return parent
? { type: 'waypoint', fileId: parent.fileId, waypointIds: ids }
: undefined;
}
}
/** A position in the list, as a place to move elements to. */
private targetAt(index: number): MoveTarget | undefined {
const parent = this._parent;
switch (this._sortableLevel) {
case 'file':
return { type: 'files', index };
case 'route':
return parent ? { type: 'routes', fileId: parent.fileId, index } : undefined;
case 'track':
return parent ? { type: 'tracks', fileId: parent.fileId, index } : undefined;
case 'segment':
return parent?.type === 'track'
? { type: 'segments', fileId: parent.fileId, trackId: parent.trackId, index }
: undefined;
case 'waypoints':
// the list holds the node standing for the waypoints: they go after the others
return parent
? { type: 'waypoints', fileId: parent.fileId, index: Infinity }
: undefined;
case 'waypoint':
return parent ? { type: 'waypoints', fileId: parent.fileId, index } : undefined;
}
}
onSort(e: Sortable.SortableEvent) {
this.updateToFileOrder();
const from = Sortable.get(e.from);
const to = Sortable.get(e.to);
const from = lists.get(e.from);
const to = lists.get(e.to);
if (!from || !to) {
return;
}
let fromItem = from._item;
let toItem = to._item;
if (this._item === toItem && !(fromItem instanceof ListRootItem)) {
// Event is triggered on source and destination list, only handle it once
let fromItems = [];
let toItems = [];
if (from._waypointRoot) {
fromItems = [fromItem.extend('waypoints')];
} else {
let oldIndices: number[] =
e.oldIndicies.length > 0 ? e.oldIndicies.map((i) => i.index) : [e.oldIndex];
oldIndices = oldIndices.filter((i) => i >= 0);
oldIndices.sort((a, b) => a - b);
fromItems = oldIndices.map((i) => fromItem.extend(i));
if (from === to && this._sortableLevel === 'file') {
// the order of the files is the one of the list, which is put back where it was like
// below: Svelte moves the elements when the order changes, it has to find them where
// it left them (or removing a file later removes the elements of other files)
const order = this._sortable?.toArray();
this._snapshot.forEach((node) => e.from.appendChild(node));
if (order) {
this.updateToFileOrder(order);
}
return;
}
if (from._waypointRoot && to._waypointRoot) {
toItems = [toItem.extend('waypoints')];
} else {
if (to._waypointRoot) {
toItem = toItem.extend('waypoints');
}
// The event is triggered on the source and on the destination list: handle it once.
// Nothing to move in the list of the single node of the waypoints.
if (to !== this || (from === to && this._sortableLevel === 'waypoints')) {
return;
}
let newIndices: number[] =
e.newIndicies.length > 0 ? e.newIndicies.map((i) => i.index) : [e.newIndex];
newIndices = newIndices.filter((i) => i >= 0);
newIndices.sort((a, b) => a - b);
const elements: HTMLElement[] = e.items.length > 0 ? e.items : [e.item];
const ids = elements
.map((element) => element.getAttribute('data-id'))
.filter((id): id is string => id !== null);
const newIndices = (
e.newIndicies.length > 0
? e.newIndicies.map((i: { index: number }) => i.index)
: [e.newIndex]
).filter((index: number | undefined): index is number => index !== undefined && index >= 0);
const what = from.selectionOf(ids);
const target = to.targetAt(newIndices.length > 0 ? Math.min(...newIndices) : Infinity);
if (toItem instanceof ListRootItem) {
let newFileIds = getFileIds(newIndices.length);
toItems = newIndices.map((i, index) => {
get(fileOrder).splice(i, 0, newFileIds[index]);
return this._item.extend(newFileIds[index]);
});
} else {
toItems = newIndices.map((i) => toItem.extend(i));
}
}
// The list is rendered from the state of the engine: put the elements back where they
// were, the engine moves them for real.
from._snapshot.forEach((node) => e.from.appendChild(node));
moveItems(fromItem, toItem, fromItems, toItems);
if (what && target) {
engine.move(what, target);
}
}
updateFromSelection() {
if (this._applying) {
return;
}
const changed = this.getChangedIds();
if (changed.length === 0) {
return;
}
const selection_ = get(selection);
for (let id of changed) {
let element = this._elements[id];
if (element) {
if (selection_.has(this._item.extend(id))) {
const selection_ = get(engine.selection);
for (const id of changed) {
const element = this._elements[id];
const node = this.childNode(id);
if (element && node) {
if (isSelected(selection_, node)) {
Sortable.utils.select(element);
element.scrollIntoView({
behavior: 'smooth',
@@ -174,78 +263,96 @@ export class SortableFileList {
}
}
updateToSelection(e: Sortable.SortableEvent) {
updateToSelection(e: Sortable.SortableEvent & { originalEvent?: MouseEvent }) {
if (!this._sortable) return;
if (this._updatingSelection) return;
if (this._updatingSelection || this._applying) return;
this._updatingSelection = true;
const changed = this.getChangedIds();
if (changed.length == 0) {
this._updatingSelection = false;
return;
}
selection.update(($selection) => {
$selection.clear();
Object.entries(this._elements).forEach(([id, element]) => {
$selection.set(
this._item.extend(this.getRealId(id)),
element.classList.contains('sortable-selected')
);
});
if (
e.originalEvent &&
!(e.originalEvent.ctrlKey || e.originalEvent.metaKey || e.originalEvent.shiftKey) &&
($selection.size > 1 ||
!$selection.has(this._item.extend(this.getRealId(changed[0]))))
) {
// Fix bug that sometimes causes a single select to be treated as a multi-select
$selection.clear();
$selection.set(this._item.extend(this.getRealId(changed[0])), true);
let selected = Object.entries(this._elements)
.filter(([, element]) => element.classList.contains('sortable-selected'))
.map(([id]) => id);
if (
e.originalEvent &&
!(e.originalEvent.ctrlKey || e.originalEvent.metaKey || e.originalEvent.shiftKey) &&
(selected.length > 1 || !selected.includes(changed[0]))
) {
// Fix bug that sometimes causes a single select to be treated as a multi-select
selected = [changed[0]];
}
const parent = this._parent;
let request: Promise<boolean>;
if (selected.length === 0 || !this.childNode(selected[0])) {
request = engine.select([]);
} else {
switch (this._sortableLevel) {
case 'file':
request = engine.select(selected);
break;
case 'route':
request = engine.selectRoutes(parent!.fileId, selected);
break;
case 'track':
request = engine.selectTracks(parent!.fileId, selected);
break;
case 'segment':
request = engine.selectSegments(
parent!.fileId,
(parent as any).trackId,
selected
);
break;
case 'waypoints':
request = engine.selectWaypointGroup(parent!.fileId);
break;
case 'waypoint':
request = engine.selectWaypoints(parent!.fileId, selected);
break;
}
return $selection;
});
}
this._updatingSelection = false;
// the events of the same click that come before the engine answers are not new choices
this._applying = true;
request.finally(() => {
this._applying = false;
// the list shows what the engine selected
this.updateFromSelection();
});
}
updateFromFileOrder() {
if (!this._sortable || this._sortableLevel !== ListLevel.FILE) {
/** Makes `sortableOrder` (the ids of the elements, as Sortable left them) the order of the files. */
updateToFileOrder(sortableOrder: string[]) {
if (!this._sortable || this._sortableLevel !== 'file') {
return;
}
const fileOrder_ = get(fileOrder);
const sortableOrder = this._sortable.toArray();
const fileOrder_ = get(engine.order);
if (
fileOrder_.length !== sortableOrder.length ||
fileOrder_.some((value, index) => value !== sortableOrder[index])
) {
this._sortable.sort(fileOrder_);
}
}
updateToFileOrder() {
if (!this._sortable || this._sortableLevel !== ListLevel.FILE) {
return;
}
const fileOrder_ = get(fileOrder);
const sortableOrder = this._sortable.toArray();
if (
fileOrder_.length !== sortableOrder.length ||
fileOrder_.some((value, index) => value !== sortableOrder[index])
) {
fileOrder.set(sortableOrder);
engine.reorder(sortableOrder, 0);
}
}
updateElements() {
const previous = this._elements;
this._elements = {};
const files = get(engine.files);
this._container.childNodes.forEach((element) => {
if (element instanceof HTMLElement) {
let attr = element.getAttribute('data-id');
const attr = element.getAttribute('data-id');
if (attr) {
if (this._node instanceof Map && !this._node.has(attr)) {
if (this._sortableLevel === 'file' && !files.has(attr)) {
this.forget(element);
element.remove();
} else {
this._elements[attr] = element;
@@ -253,32 +360,53 @@ export class SortableFileList {
}
}
});
// the elements of what is not there anymore (a closed file, a deleted track...)
Object.values(previous).forEach((element) => {
if (element.parentNode !== this._container) {
this.forget(element);
}
});
}
/**
* Takes an element that left the list out of the selection of Sortable. Sortable keeps the
* selected elements, and puts them all in the list when one of them is dragged: one that Svelte
* removed would come back, as a file that is not there.
*/
private forget(element: HTMLElement) {
if (!element.classList.contains('sortable-selected')) {
return;
}
// Sortable finds the list of an element from its parent
const detached = element.parentNode === null;
if (detached) {
this._container.appendChild(element);
}
Sortable.utils.deselect(element);
if (detached) {
element.remove();
}
}
destroy() {
Object.values(this._elements).forEach((element) => this.forget(element));
this._sortable?.destroy();
this._sortable = null;
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
this._unsubscribes = [];
}
getChangedIds() {
let changed: (string | number)[] = [];
const selection_ = get(selection);
const changed: string[] = [];
const selection_ = get(engine.selection);
Object.entries(this._elements).forEach(([id, element]) => {
let realId = this.getRealId(id);
let realItem = this._item.extend(realId);
let inSelection = selection_.has(realItem);
let isSelected = element.classList.contains('sortable-selected');
if (inSelection !== isSelected) {
changed.push(realId);
const node = this.childNode(id);
const inSelection = node !== undefined && isSelected(selection_, node);
const isSelectedInList = element.classList.contains('sortable-selected');
if (inSelection !== isSelectedInList) {
changed.push(id);
}
});
return changed;
}
getRealId(id: string | number) {
return this._sortableLevel === ListLevel.FILE || this._sortableLevel === ListLevel.WAYPOINTS
? id
: parseInt(id as string);
}
}
@@ -0,0 +1,7 @@
import Sortable, { MultiDrag } from 'sortablejs';
// The ESM build leaves out the MultiDrag plugin. Mounting it twice throws, so it is done here
// rather than in a module that hot reloading runs again.
Sortable.mount(new MultiDrag());
export default Sortable;
@@ -5,30 +5,26 @@
import { Slider } from '$lib/components/ui/slider';
import * as Popover from '$lib/components/ui/popover';
import { Save } from '@lucide/svelte';
import {
ListFileItem,
ListTrackItem,
type ListItem,
} from '$lib/components/file-list/file-list';
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
import { i18n } from '$lib/i18n.svelte';
import type { LineStyleExtension } from 'gpx';
import { settings } from '$lib/logic/settings';
import { selection } from '$lib/logic/selection';
import { fileStateCollection } from '$lib/logic/file-state';
import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
import { i18n } from '$lib/i18n/i18n.svelte';
import { settings } from '$lib/state/settings';
import { engine, type FileState } from '$lib/engine';
import { normalizeColor } from '$lib/engine/file-colors';
import { pathInfo, selectedFileIds, type FileTreeNode } from '$lib/engine/selection-helpers';
import { untrack } from 'svelte';
import { fileActions } from '$lib/logic/file-actions';
let {
item,
fileState,
node,
open = $bindable(),
}: {
item: ListItem;
fileState: FileState;
node: FileTreeNode;
open: boolean;
} = $props();
const { defaultOpacity, defaultWidth } = settings;
const { files, selection } = engine;
let color: string = $state('');
let opacity: number = $state(0);
@@ -40,42 +36,18 @@
function setStyleInputs() {
opacity = $defaultOpacity;
width = $defaultWidth;
color = fileState.color;
$selection.forEach((item) => {
if (item instanceof ListFileItem) {
let file = fileStateCollection.getFile(item.getFileId());
let layer = gpxLayers.getLayer(item.getFileId());
if (file && layer) {
let style = file.getStyle();
color = layer.layerColor;
if (style.opacity.length > 0) {
opacity = style.opacity[0];
}
if (style.width.length > 0) {
width = style.width[0];
}
}
} else if (item instanceof ListTrackItem) {
let file = fileStateCollection.getFile(item.getFileId());
let layer = gpxLayers.getLayer(item.getFileId());
if (file && layer) {
color = layer.layerColor;
let track = file.trk[item.getTrackIndex()];
let style = track.getStyle();
if (style) {
if (style['gpx_style:color']) {
color = style['gpx_style:color'];
}
if (style['gpx_style:opacity']) {
opacity = style['gpx_style:opacity'];
}
if (style['gpx_style:width']) {
width = style['gpx_style:width'];
}
}
}
}
});
const paths = [...fileState.structure.routes, ...fileState.structure.tracks];
const path = pathInfo(fileState.structure, node);
if (node.type === 'file') {
opacity = paths.find((path) => path.opacity !== undefined)?.opacity ?? opacity;
width = paths.find((path) => path.width !== undefined)?.width ?? width;
} else if (path) {
color = path.color !== undefined ? normalizeColor(path.color) : color;
opacity = path.opacity ?? opacity;
width = path.width ?? width;
}
colorChanged = false;
opacityChanged = false;
@@ -95,28 +67,25 @@
});
function applyStyle() {
let style: LineStyleExtension = {};
if (colorChanged) {
style['gpx_style:color'] = color;
}
if (opacityChanged) {
style['gpx_style:opacity'] = opacity;
}
if (widthChanged) {
style['gpx_style:width'] = width;
}
fileActions.setStyleToSelection(style);
if (item instanceof ListFileItem && $selection.size === fileStateCollection.size) {
if (style['gpx_style:opacity']) {
$defaultOpacity = style['gpx_style:opacity'];
}
if (style['gpx_style:width']) {
$defaultWidth = style['gpx_style:width'];
}
}
// closes the dialog now: `open` follows `editStyle`, and the style changes the file, which
// would open the dialog again before the effect below has reset it
editStyle.current = false;
open = false;
engine.style({
color: colorChanged ? color : undefined,
opacity: opacityChanged ? opacity : undefined,
width: widthChanged ? width : undefined,
});
if (node.type === 'file' && selectedFileIds($selection).length === $files.size) {
if (opacityChanged) {
$defaultOpacity = opacity;
}
if (widthChanged) {
$defaultWidth = width;
}
}
}
</script>
@@ -0,0 +1,32 @@
import type { UnsavedChoice } from '$lib/engine';
type Prompt =
| { kind: 'unsaved'; names: string[]; resolve: (choice: UnsavedChoice | 'cancel') => void }
| {
kind: 'overwrite';
names: string[];
resolve: (choice: 'overwrite' | 'reload' | 'cancel') => void;
};
/** The question that the user is asked, shown by `UnsavedDialog`. */
export const unsavedPrompt: { current: Prompt | undefined } = $state({ current: undefined });
/**
* Asks what to do with files that have unsaved changes when they are closed: save them, discard
* the changes, or `'cancel'` to keep going as before.
*/
export function askUnsaved(names: string[]): Promise<UnsavedChoice | 'cancel'> {
return new Promise((resolve) => {
unsavedPrompt.current = { kind: 'unsaved', names, resolve };
});
}
/**
* Asks what to do with files that have changes and were also changed on the disk: `'overwrite'` the
* disk, `'reload'` the disk (the changes are lost), or `'cancel'`.
*/
export function askOverwrite(names: string[]): Promise<'overwrite' | 'reload' | 'cancel'> {
return new Promise((resolve) => {
unsavedPrompt.current = { kind: 'overwrite', names, resolve };
});
}
@@ -1,7 +1,6 @@
<script lang="ts">
import { map } from '$lib/components/map/map';
import { trackpointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
import { TrackPoint } from 'gpx';
map.onLoad((map_) => {
map_.on('contextmenu', (e) => {
@@ -9,19 +8,15 @@
map_.queryRenderedFeatures(e.point, {
layers: map_
.getLayersOrder()
.filter((layerId) => layerId.startsWith('routing-controls')),
.filter((layerId) => layerId == 'routing-controls'),
}).length
) {
// Clicked on routing control, ignoring
return;
}
trackpointPopup?.setItem({
item: new TrackPoint({
attributes: {
lat: e.lngLat.lat,
lon: e.lngLat.lng,
},
}),
kind: 'trackpoint',
item: { lng: e.lngLat.lng, lat: e.lngLat.lat },
});
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { Button } from '$lib/components/ui/button';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import { page } from '$app/state';
import { map } from '$lib/components/map/map';
import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
@@ -1,5 +1,4 @@
<script lang="ts">
import { TrackPoint, Waypoint } from 'gpx';
import WaypointPopup from '$lib/components/map/gpx-layer/WaypointPopup.svelte';
import TrackpointPopup from '$lib/components/map/gpx-layer/TrackpointPopup.svelte';
import OverpassPopup from '$lib/components/map/layer-control/OverpassPopup.svelte';
@@ -23,9 +22,9 @@
<div bind:this={container}>
{#if $item}
{#if $item.item instanceof Waypoint}
{#if $item.kind === 'waypoint'}
<WaypointPopup waypoint={$item} />
{:else if $item.item instanceof TrackPoint}
{:else if $item.kind === 'trackpoint'}
<TrackpointPopup trackpoint={$item} />
{:else}
<OverpassPopup poi={$item} />
@@ -1,15 +1,14 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { ClipboardCopy } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import type { Coordinates } from 'gpx';
import { i18n } from '$lib/i18n/i18n.svelte';
let {
coordinates,
onCopy = () => {},
class: className = '',
}: {
coordinates: Coordinates;
coordinates: { lng: number; lat: number };
onCopy?: () => void;
class?: string;
} = $props();
@@ -20,7 +19,7 @@
variant="outline"
onclick={() => {
navigator.clipboard.writeText(
`${coordinates.lat.toFixed(6)}, ${coordinates.lon.toFixed(6)}`
`${coordinates.lat.toFixed(6)}, ${coordinates.lng.toFixed(6)}`
);
onCopy();
}}
@@ -1,24 +1,26 @@
<script lang="ts">
import type { TrackPoint } from 'gpx';
import { Button } from '$lib/components/ui/button';
import CopyCoordinates from '$lib/components/map/gpx-layer/CopyCoordinates.svelte';
import * as Card from '$lib/components/ui/card';
import WithUnits from '$lib/components/WithUnits.svelte';
import { Compass, Earth, Mountain, Timer } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import type { PopupItem } from '$lib/components/map/map-popup';
import type { TrackpointDetails } from '$lib/engine';
import { map } from '$lib/components/map/map';
let { trackpoint }: { trackpoint: PopupItem<TrackPoint> } = $props();
let {
trackpoint,
}: {
trackpoint: PopupItem<Pick<TrackpointDetails, 'lng' | 'lat'> & Partial<TrackpointDetails>>;
} = $props();
</script>
<Card.Root class="border-none shadow-md text-base p-2">
<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;
{trackpoint.item.lat.toFixed(6)}&deg; {trackpoint.item.lng.toFixed(6)}&deg;
</div>
{#if trackpoint.item.ele !== undefined}
<div class="flex flex-row items-center gap-1">
@@ -26,14 +28,14 @@
<WithUnits value={trackpoint.item.ele} type="elevation" />
</div>
{/if}
{#if trackpoint.item.time}
{#if trackpoint.item.time !== undefined}
<div class="flex flex-row items-center gap-1">
<Timer size="14" />
{i18n.df.format(trackpoint.item.time)}
{i18n.df.format(new Date(trackpoint.item.time))}
</div>
{/if}
<CopyCoordinates
coordinates={trackpoint.item.attributes}
coordinates={trackpoint.item}
onCopy={() => trackpoint.hide?.()}
class="mt-0.5"
/>
@@ -41,7 +43,7 @@
<Button
variant="outline"
class="justify-start"
href={`https://www.openstreetmap.org/edit?#map=${(($map?.getZoom() ?? 17) + 1).toFixed(0)}/${trackpoint.item.getLatitude().toFixed(5)}/${trackpoint.item.getLongitude().toFixed(5)}`}
href={`https://www.openstreetmap.org/edit?#map=${(($map?.getZoom() ?? 17) + 1).toFixed(0)}/${trackpoint.item.lat.toFixed(5)}/${trackpoint.item.lng.toFixed(5)}`}
target="_blank"
>
<Earth size="14" />
@@ -6,24 +6,26 @@
import WithUnits from '$lib/components/WithUnits.svelte';
import { Dot, ExternalLink, Trash2 } from '@lucide/svelte';
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
import { i18n } from '$lib/i18n.svelte';
import { getSymbolKey, symbols } from '$lib/data/symbols';
import { i18n } from '$lib/i18n/i18n.svelte';
import sanitizeHtml from 'sanitize-html';
import type { Waypoint } from 'gpx';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import { fileActions } from '$lib/logic/file-actions';
import type { PopupItem } from '$lib/components/map/map-popup';
import { selection } from '$lib/logic/selection';
import { ListFileItem } from '$lib/components/file-list/file-list';
import { engine, type WaypointDetails } from '$lib/engine';
import { hasSelectionWithin } from '$lib/engine/selection-helpers';
let {
waypoint,
}: {
waypoint: PopupItem<Waypoint>;
waypoint: PopupItem<WaypointDetails>;
} = $props();
const { selection } = engine;
let selected = $derived(
waypoint.fileId ? $selection.hasAnyChildren(new ListFileItem(waypoint.fileId)) : false
waypoint.fileId
? hasSelectionWithin($selection, { type: 'file', fileId: waypoint.fileId })
: false
);
let symbolKey = $derived(waypoint ? getSymbolKey(waypoint.item.sym) : undefined);
@@ -44,9 +46,10 @@
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw] gap-0">
<Card.Header class="p-0 gap-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}
{@const link = waypoint.item.links?.[0]}
{#if link?.href}
<a href={link.href} target="_blank" rel="external">
{waypoint.item.name ?? link.href}
<ExternalLink size="12" class="inline-block mb-1.5" />
</a>
{:else}
@@ -68,30 +71,28 @@
</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}
{waypoint.item.lat.toFixed(6)}&deg; {waypoint.item.lng.toFixed(6)}&deg;
<Dot size="16" />
<WithUnits value={waypoint.item.ele} type="elevation" />
</div>
<ScrollArea class="flex flex-col max-h-[30dvh]">
{#if waypoint.item.desc}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized -->
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.desc)}</span>
{/if}
{#if waypoint.item.cmt && waypoint.item.cmt !== waypoint.item.desc}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized -->
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.cmt)}</span>
{/if}
</ScrollArea>
<div class="mt-2 flex flex-col gap-1">
<CopyCoordinates coordinates={waypoint.item.attributes} />
<CopyCoordinates coordinates={waypoint.item} />
{#if $currentTool === Tool.WAYPOINT && selected}
<Button
variant="outline"
onclick={() => {
if (waypoint.fileId) {
fileActions.deleteWaypoint(waypoint.fileId, waypoint.item._data.index);
engine.deleteWaypoint(waypoint.fileId, waypoint.item.id);
waypoint.hide?.();
}
}}
@@ -1,9 +1,9 @@
import { settings } from '$lib/logic/settings';
import { gpxStatistics } from '$lib/logic/statistics';
import { getConvertedDistanceToKilometers } from '$lib/units';
import { settings } from '$lib/state/settings';
import { engine } from '$lib/engine';
import { getConvertedDistanceToKilometers } from '$lib/units/units';
import { get } from 'svelte/store';
import { map } from '$lib/components/map/map';
import { allHidden } from '$lib/logic/hidden';
import { allHidden } from '$lib/engine/selection-stores';
import type { GeoJSONSource } from 'maplibre-gl';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
@@ -16,7 +16,7 @@ export class DistanceMarkers {
unsubscribes: (() => void)[] = [];
constructor() {
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded));
this.unsubscribes.push(engine.statistics.subscribe(this.updateBinded));
this.unsubscribes.push(distanceMarkers.subscribe(this.updateBinded));
this.unsubscribes.push(distanceUnits.subscribe(this.updateBinded));
this.unsubscribes.push(allHidden.subscribe(this.updateBinded));
@@ -35,7 +35,8 @@ export class DistanceMarkers {
try {
if (get(distanceMarkers) && !get(allHidden)) {
let distanceSource: GeoJSONSource | undefined = map_.getSource('distance-markers');
const distanceSource: GeoJSONSource | undefined =
map_.getSource('distance-markers');
if (distanceSource) {
distanceSource.setData(this.getDistanceMarkersGeoJSON());
} else {
@@ -90,7 +91,7 @@ export class DistanceMarkers {
map_.removeLayer('distance-markers');
}
}
} catch (e) {
} catch {
// No reliable way to check if the map is ready to add sources and layers
return;
}
@@ -101,19 +102,20 @@ export class DistanceMarkers {
}
getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection {
let statistics = get(gpxStatistics);
// the distance from the start of the selection, and the position, of each trackpoint
const { length, totalDistance, lng, lat } = get(engine.statistics);
let features: GeoJSON.Feature[] = [];
const features: GeoJSON.Feature[] = [];
let currentTargetDistance = 1;
statistics.forEachTrackPoint((trkpt, dist) => {
if (dist >= getConvertedDistanceToKilometers(currentTargetDistance)) {
let distance = currentTargetDistance.toFixed(0);
let level = levels.find((level) => currentTargetDistance % level === 0) || 1;
for (let i = 0; i < length; i++) {
if (totalDistance[i] >= getConvertedDistanceToKilometers(currentTargetDistance)) {
const distance = currentTargetDistance.toFixed(0);
const level = levels.find((level) => currentTargetDistance % level === 0) || 1;
features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [trkpt.getLongitude(), trkpt.getLatitude()],
coordinates: [lng[i], lat[i]],
},
properties: {
distance,
@@ -122,7 +124,7 @@ export class DistanceMarkers {
} as GeoJSON.Feature);
currentTargetDistance += 1;
}
});
}
return {
type: 'FeatureCollection',
@@ -8,82 +8,22 @@ import {
} from 'maplibre-gl';
import { map } from '$lib/components/map/map';
import { waypointPopup, trackpointPopup } from './gpx-layer-popup';
import {
ListTrackSegmentItem,
ListWaypointItem,
ListWaypointsItem,
ListTrackItem,
ListFileItem,
ListRootItem,
} from '$lib/components/file-list/file-list';
import { getClosestLinePoint, getElevation, loadSVGIcon } from '$lib/utils';
import { closestPointIndex } from '$lib/geo/closest-point';
import { getElevation, loadSVGIcon } from '$lib/utils';
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
import { MapPin, Square } from 'lucide-static';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
import { selection } from '$lib/logic/selection';
import { settings } from '$lib/logic/settings';
import { getSymbolKey, symbols } from '$lib/data/symbols';
import { engine, type FileState, type Selection } from '$lib/engine';
import { isCovered, hasSelectionWithin, type FileTreeNode } from '$lib/engine/selection-helpers';
import { isSegmentHidden, isWaypointHidden } from '$lib/engine/file-visibility';
import { settings } from '$lib/state/settings';
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { fileActionManager } from '$lib/logic/file-action-manager';
import { fileActions } from '$lib/logic/file-actions';
import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import { splitAtPoint } from '$lib/components/toolbar/tools/scissors/scissors';
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import { gpxColors } from '$lib/components/map/gpx-layer/gpx-layers';
const colors = [
'#ff0000',
'#0000ff',
'#46e646',
'#00ccff',
'#ff9900',
'#ff00ff',
'#ffff32',
'#288228',
'#9933ff',
'#50f0be',
'#8c645a',
];
const colorCount: { [key: string]: number } = {};
for (let color of colors) {
colorCount[color] = 0;
}
// Get the color with the least amount of uses
function getColor(fileId: string) {
let color = colors.reduce((a, b) => (colorCount[a] <= colorCount[b] ? a : b));
colorCount[color]++;
gpxColors.update((colors) => {
colors.set(fileId, color);
return colors;
});
return color;
}
function replaceColor(fileId: string, oldColor: string, newColor: string) {
if (colorCount.hasOwnProperty(oldColor)) {
colorCount[oldColor]--;
}
colorCount[newColor]++;
gpxColors.update((colors) => {
colors.set(fileId, newColor);
return colors;
});
}
function removeColor(fileId: string, color: string) {
if (colorCount.hasOwnProperty(color)) {
colorCount[color]--;
}
gpxColors.update((colors) => {
colors.delete(fileId);
return colors;
});
}
export function getSvgForSymbol(symbol?: string | undefined, layerColor?: string | undefined) {
let symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
const symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
${
layerColor
@@ -116,18 +56,19 @@ const { directionMarkers, treeFileView, defaultOpacity, defaultWidth } = setting
export class GPXLayer {
fileId: string;
file: Readable<GPXFileWithStatistics | undefined>;
layerColor: string;
file: Readable<FileState>;
layerColor: string = '';
selectionState: Selection = { type: 'empty' };
selected: boolean = false;
currentWaypointData: GeoJSON.FeatureCollection | null = null;
draggedWaypointIndex: number | null = null;
draggingStartingPosition: Point = new Point(0, 0);
unsubscribe: Function[] = [];
unsubscribe: (() => void)[] = [];
updateBinded: () => void = this.update.bind(this);
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
layerOnMouseEnterBinded: (e: MapLayerMouseEvent) => void = this.layerOnMouseEnter.bind(this);
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
layerOnMouseMoveBinded: (e: any) => void = this.layerOnMouseMove.bind(this);
layerOnMouseMoveBinded: (e: MapLayerMouseEvent) => void = this.layerOnMouseMove.bind(this);
layerOnClickBinded: (e: MapLayerMouseEvent) => void = this.layerOnClick.bind(this);
layerOnContextMenuBinded: (e: MapLayerMouseEvent) => void = this.layerOnContextMenu.bind(this);
waypointLayerOnMouseEnterBinded: (e: MapLayerMouseEvent) => void =
@@ -145,10 +86,9 @@ export class GPXLayer {
waypointLayerOnMouseUpBinded: (e: MapLayerMouseEvent | MapLayerTouchEvent) => void =
this.waypointLayerOnMouseUp.bind(this);
constructor(fileId: string, file: Readable<GPXFileWithStatistics | undefined>) {
constructor(fileId: string, file: Readable<FileState>) {
this.fileId = fileId;
this.file = file;
this.layerColor = getColor(fileId);
this.unsubscribe.push(
map.subscribe(($map) => {
if ($map) {
@@ -159,8 +99,9 @@ export class GPXLayer {
);
this.unsubscribe.push(file.subscribe(this.updateBinded));
this.unsubscribe.push(
selection.subscribe(($selection) => {
let newSelected = $selection.hasAnyChildren(new ListFileItem(this.fileId));
engine.selection.subscribe(($selection) => {
this.selectionState = $selection;
const newSelected = hasSelectionWithin($selection, this.fileNode());
if (this.selected || newSelected) {
this.selected = newSelected;
this.update();
@@ -173,27 +114,39 @@ export class GPXLayer {
this.unsubscribe.push(directionMarkers.subscribe(this.updateBinded));
}
fileNode(): FileTreeNode {
return { type: 'file', fileId: this.fileId };
}
/** The node of a segment of the map: its route, which has no other, or the segment itself. */
segmentNode(properties: GeoJSON.GeoJsonProperties): FileTreeNode {
if (properties!.routeId !== undefined) {
return { type: 'route', fileId: this.fileId, routeId: properties!.routeId };
}
return {
type: 'segment',
fileId: this.fileId,
trackId: properties!.trackId,
segmentId: properties!.segmentId,
};
}
update() {
const _map = get(map);
const layerEventManager = map.layerEventManager;
let file = get(this.file)?.file;
if (!_map || !layerEventManager || !file) {
const state = get(this.file);
if (!_map || !layerEventManager) {
return;
}
if (
file._data.style &&
file._data.style.color &&
this.layerColor !== `#${file._data.style.color}`
) {
replaceColor(this.fileId, this.layerColor, `#${file._data.style.color}`);
this.layerColor = `#${file._data.style.color}`;
if (this.layerColor !== state.color) {
this.layerColor = state.color;
}
this.loadIcons();
try {
let source = _map.getSource(this.fileId) as GeoJSONSource | undefined;
const source = _map.getSource(this.fileId) as GeoJSONSource | undefined;
if (source) {
source.setData(this.getGeoJSON());
} else {
@@ -229,16 +182,20 @@ export class GPXLayer {
layerEventManager.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
}
let visibleTrackSegmentIds: string[] = [];
file.forEachSegment((segment, trackIndex, segmentIndex) => {
if (!segment._data.hidden) {
visibleTrackSegmentIds.push(`${trackIndex}-${segmentIndex}`);
}
});
const visibleSegmentIds = state.segments.features
.filter(
({ properties }) =>
!isSegmentHidden(
state,
properties.routeId ?? properties.trackId!,
properties.segmentId
)
)
.map(({ properties }) => properties.segmentId);
const segmentFilter: FilterSpecification = [
'in',
['get', 'trackSegmentId'],
['literal', visibleTrackSegmentIds],
['get', 'segmentId'],
['literal', visibleSegmentIds],
];
_map.setFilter(this.fileId, segmentFilter, { validate: false });
@@ -277,7 +234,7 @@ export class GPXLayer {
}
}
let waypointSource = _map.getSource(this.fileId + '-waypoints') as
const waypointSource = _map.getSource(this.fileId + '-waypoints') as
| GeoJSONSource
| undefined;
this.currentWaypointData = this.getWaypointsGeoJSON();
@@ -335,19 +292,16 @@ export class GPXLayer {
);
}
let visibleWaypoints: number[] = [];
file.wpt.forEach((waypoint, waypointIndex) => {
if (!waypoint._data.hidden) {
visibleWaypoints.push(waypointIndex);
}
});
const visibleWaypoints = state.waypoints.features
.filter(({ properties }) => !isWaypointHidden(state, properties.waypointId))
.map(({ properties }) => properties.index);
_map.setFilter(
this.fileId + '-waypoints',
['in', ['get', 'waypointIndex'], ['literal', visibleWaypoints]],
{ validate: false }
);
} catch (e) {
} catch {
// No reliable way to check if the map is ready to add sources and layers
return;
}
@@ -414,8 +368,6 @@ export class GPXLayer {
}
this.unsubscribe.forEach((unsubscribe) => unsubscribe());
removeColor(this.fileId, this.layerColor);
}
moveToFront() {
@@ -434,15 +386,10 @@ export class GPXLayer {
}
}
layerOnMouseEnter(e: any) {
let trackIndex = e.features[0].properties.trackIndex;
let segmentIndex = e.features[0].properties.segmentIndex;
layerOnMouseEnter(e: MapLayerMouseEvent) {
if (
get(currentTool) === Tool.SCISSORS &&
get(selection).hasAnyParent(
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
)
isCovered(this.selectionState, this.segmentNode(e.features![0].properties))
) {
mapCursor.notify(MapCursorState.SCISSORS, true);
} else {
@@ -455,75 +402,74 @@ export class GPXLayer {
mapCursor.notify(MapCursorState.LAYER_HOVER, false);
}
layerOnMouseMove(e: any) {
async layerOnMouseMove(e: MapLayerMouseEvent) {
if (e.originalEvent.shiftKey) {
let trackIndex = e.features[0].properties.trackIndex;
let segmentIndex = e.features[0].properties.segmentIndex;
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 }
);
trackpointPopup?.setItem({ item: closest, fileId: this.fileId });
const { segmentId } = e.features![0].properties;
const lngLat = { lng: e.lngLat.lng, lat: e.lngLat.lat };
const index = closestPointIndex(await engine.segmentCoordinates(segmentId), lngLat);
const trackpoint =
index === undefined
? undefined
: await engine.trackpoint(this.fileId, segmentId, index);
if (trackpoint) {
trackpointPopup?.setItem({
item: trackpoint,
kind: 'trackpoint',
fileId: this.fileId,
});
}
}
}
layerOnClick(e: MapLayerMouseEvent) {
const properties = e.features![0].properties!;
const { routeId, trackId, segmentId } = properties;
const selectionType = this.selectionState.type;
if (
get(currentTool) === Tool.ROUTING &&
get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])
selectionType !== 'empty' &&
selectionType !== 'waypoints' &&
selectionType !== 'waypoint'
) {
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)
)
isCovered(this.selectionState, this.segmentNode(properties))
) {
if (get(map)?.queryRenderedFeatures(e.point, { layers: ['split-controls'] }).length) {
// Clicked on split control, ignoring
return;
}
fileActions.split(get(splitAs), this.fileId, trackIndex, segmentIndex, {
lat: e.lngLat.lat,
lon: e.lngLat.lng,
});
splitAtPoint(segmentId, { lng: e.lngLat.lng, lat: e.lngLat.lat });
return;
}
let file = get(this.file)?.file;
if (!file) {
return;
}
let item = undefined;
if (get(treeFileView) && file.getSegments().length > 1) {
const mode = e.originalEvent.ctrlKey || e.originalEvent.metaKey ? 'add' : 'replace';
const { routes, tracks } = get(this.file).structure;
const segmentCount =
routes.length + tracks.reduce((count, track) => count + track.segments.length, 0);
if (get(treeFileView) && segmentCount > 1) {
// Select inner item
item =
file.children[trackIndex].children.length > 1
? new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
: new ListTrackItem(this.fileId, trackIndex);
if (routeId !== undefined) {
engine.selectRoutes(this.fileId, [routeId], mode);
return;
}
const track = tracks.find((track) => track.id === trackId);
if (track && track.segments.length > 1) {
engine.selectSegments(this.fileId, trackId, [segmentId], mode);
} else {
engine.selectTracks(this.fileId, [trackId], mode);
}
} else {
item = new ListFileItem(this.fileId);
}
if (e.originalEvent.ctrlKey || e.originalEvent.metaKey) {
selection.addSelectItem(item);
} else {
selection.selectItem(item);
engine.select([this.fileId], mode);
}
}
layerOnContextMenu(e: any) {
layerOnContextMenu(e: MapLayerMouseEvent) {
if (e.originalEvent.ctrlKey) {
this.layerOnClick(e);
}
@@ -533,18 +479,19 @@ export class GPXLayer {
if (this.draggedWaypointIndex !== null) {
return;
}
let file = get(this.file)?.file;
if (!file) {
return;
}
let waypointIndex = e.features![0].properties!.waypointIndex;
let waypoint = file.wpt[waypointIndex];
waypointPopup?.setItem({ item: waypoint, fileId: this.fileId });
this.showWaypointPopup(e.features![0].properties!.waypointId);
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, true);
}
async showWaypointPopup(waypointId: string) {
const waypoint = await engine.waypoint(this.fileId, waypointId);
if (waypoint) {
waypointPopup?.setItem({ item: waypoint, kind: 'waypoint', fileId: this.fileId });
}
}
waypointLayerOnMouseLeave() {
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, false);
}
@@ -552,41 +499,36 @@ export class GPXLayer {
waypointLayerOnClick(e: MapLayerMouseEvent) {
e.preventDefault();
let waypointIndex = e.features![0].properties!.waypointIndex;
let file = get(this.file)?.file;
if (!file) {
return;
}
let waypoint = file.wpt[waypointIndex];
const { waypointId } = e.features![0].properties!;
if (get(currentTool) === Tool.WAYPOINT) {
if (this.selected) {
if (e.originalEvent.shiftKey) {
fileActions.deleteWaypoint(this.fileId, waypointIndex);
engine.deleteWaypoint(this.fileId, waypointId);
} else {
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
selectedWaypoint.set([waypoint, this.fileId]);
engine.selectWaypoints(this.fileId, [waypointId]);
selectedWaypoint.set({ fileId: this.fileId, id: waypointId });
}
} else {
if (get(treeFileView)) {
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
engine.selectWaypoints(this.fileId, [waypointId]);
} else {
selection.selectItem(new ListFileItem(this.fileId));
engine.select([this.fileId]);
}
selectedWaypoint.set([waypoint, this.fileId]);
selectedWaypoint.set({ fileId: this.fileId, id: waypointId });
}
} else {
if (get(treeFileView)) {
if ((e.originalEvent.ctrlKey || e.originalEvent.metaKey) && this.selected) {
selection.addSelectItem(new ListWaypointItem(this.fileId, waypointIndex));
} else {
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
}
const add = e.originalEvent.ctrlKey || e.originalEvent.metaKey;
engine.selectWaypoints(
this.fileId,
[waypointId],
add && this.selected ? 'add' : 'replace'
);
} else {
if (!this.selected) {
selection.selectItem(new ListFileItem(this.fileId));
engine.select([this.fileId]);
}
waypointPopup?.setItem({ item: waypoint, fileId: this.fileId });
this.showWaypointPopup(waypointId);
}
}
}
@@ -642,7 +584,7 @@ export class GPXLayer {
this.currentWaypointData!.features[this.draggedWaypointIndex].geometry as GeoJSON.Point
).coordinates = [e.lngLat.lng, e.lngLat.lat];
let waypointSource = get(map)?.getSource(this.fileId + '-waypoints') as
const waypointSource = get(map)?.getSource(this.fileId + '-waypoints') as
| GeoJSONSource
| undefined;
if (waypointSource) {
@@ -681,115 +623,84 @@ export class GPXLayer {
return;
}
// the dragged waypoint, as it is in the features of the source
const waypointId =
this.currentWaypointData?.features[this.draggedWaypointIndex].properties?.waypointId;
getElevation([
{
lat: e.lngLat.lat,
lon: e.lngLat.lng,
lng: e.lngLat.lng,
},
]).then((ele) => {
]).then(async (ele) => {
if (this.draggedWaypointIndex === null) {
return;
}
fileActionManager.applyToFile(this.fileId, (file) => {
let wpt = file.wpt[this.draggedWaypointIndex!];
wpt.setCoordinates({
lat: e.lngLat.lat,
lon: e.lngLat.lng,
});
wpt.ele = ele[0];
});
if (waypointId) {
await engine.moveWaypoint(
this.fileId,
waypointId,
e.lngLat.lng,
e.lngLat.lat,
ele[0]
);
}
this.draggedWaypointIndex = null;
});
}
getGeoJSON(): GeoJSON.FeatureCollection {
let file = get(this.file)?.file;
if (!file) {
return {
type: 'FeatureCollection',
features: [],
};
}
const state = get(this.file);
const waypointsSelected = hasSelectionWithin(this.selectionState, {
type: 'waypoints',
fileId: this.fileId,
});
let data = file.toGeoJSON();
let trackIndex = 0,
segmentIndex = 0;
for (let feature of data.features) {
if (!feature.properties) {
feature.properties = {};
}
if (!feature.properties.color) {
feature.properties.color = this.layerColor;
}
if (!feature.properties.opacity) {
feature.properties.opacity = get(defaultOpacity);
}
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)
) {
feature.properties.width = feature.properties.width + 2;
feature.properties.opacity = Math.min(1, feature.properties.opacity + 0.1);
}
feature.properties.trackIndex = trackIndex;
feature.properties.segmentIndex = segmentIndex;
feature.properties.trackSegmentId = `${trackIndex}-${segmentIndex}`;
segmentIndex++;
if (segmentIndex >= file.trk[trackIndex].trkseg.length) {
segmentIndex = 0;
trackIndex++;
}
}
return data;
return {
type: 'FeatureCollection',
features: state.segments.features.map((feature) => {
const properties = {
...feature.properties,
opacity: feature.properties.opacity ?? get(defaultOpacity),
width: feature.properties.width ?? get(defaultWidth),
};
if (
isCovered(this.selectionState, this.segmentNode(properties)) ||
waypointsSelected
) {
properties.width = properties.width + 2;
properties.opacity = Math.min(1, properties.opacity + 0.1);
}
return { ...feature, properties };
}),
};
}
getWaypointsGeoJSON(): GeoJSON.FeatureCollection {
let file = get(this.file)?.file;
let data: GeoJSON.FeatureCollection = {
return {
type: 'FeatureCollection',
features: [],
};
if (!file) {
return data;
}
file.wpt.forEach((waypoint, index) => {
data.features.push({
features: get(this.file).waypoints.features.map((feature) => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [waypoint.getLongitude(), waypoint.getLatitude()],
},
// copied: the coordinates are changed while a waypoint is dragged
geometry: { type: 'Point', coordinates: [...feature.geometry.coordinates] },
properties: {
fileId: this.fileId,
waypointIndex: index,
icon: `waypoint-${getSymbolKey(waypoint.sym) ?? 'default'}-${this.layerColor}`,
waypointId: feature.properties.waypointId,
waypointIndex: feature.properties.index,
icon: `waypoint-${getSymbolKey(feature.properties.sym) ?? 'default'}-${this.layerColor}`,
},
});
});
return data;
})),
};
}
loadIcons() {
const _map = get(map);
let file = get(this.file)?.file;
if (!_map || !file) {
if (!_map) {
return;
}
let symbols = new Set<string | undefined>();
file.wpt.forEach((waypoint) => {
symbols.add(getSymbolKey(waypoint.sym));
const symbols = new Set<string | undefined>();
get(this.file).waypoints.features.forEach((feature) => {
symbols.add(getSymbolKey(feature.properties.sym));
});
symbols.forEach((symbol) => {
@@ -1,10 +1,10 @@
import { GPXFileStateCollectionObserver } from '$lib/logic/file-state';
import { FileStateCollectionObserver } from '$lib/engine';
import { writable } from 'svelte/store';
import { GPXLayer } from './gpx-layer';
export class GPXLayerCollection {
private _layers: Map<string, GPXLayer>;
private _fileStateCollectionObserver: GPXFileStateCollectionObserver | null = null;
private _fileStateCollectionObserver: FileStateCollectionObserver | null = null;
constructor() {
this._layers = new Map<string, GPXLayer>();
@@ -14,10 +14,10 @@ export class GPXLayerCollection {
if (this._fileStateCollectionObserver) {
return;
}
this._fileStateCollectionObserver = new GPXFileStateCollectionObserver(
this._fileStateCollectionObserver = new FileStateCollectionObserver(
(newFiles) => {
newFiles.forEach((fileState, fileId) => {
const layer = new GPXLayer(fileId, fileState);
newFiles.forEach((fileStore, fileId) => {
const layer = new GPXLayer(fileId, fileStore);
this._layers.set(fileId, layer);
});
},
@@ -1,11 +1,12 @@
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics';
import type { GeoJSONSource } from 'maplibre-gl';
import { get } from 'svelte/store';
import { map } from '$lib/components/map/map';
import { allHidden } from '$lib/logic/hidden';
import { allHidden } from '$lib/engine/selection-stores';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import { loadSVGIcon } from '$lib/utils';
import { hoveredPoint, slicedStatistics } from '$lib/state/selection-statistics';
import { engine } from '$lib/engine';
const startMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="6" fill="#22c55e" stroke="white" stroke-width="1.5"/>
@@ -26,14 +27,16 @@ const hoverMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="h
<circle cx="8" cy="8" r="6" fill="#00b8db" stroke="white" stroke-width="1.5"/>
</svg>`;
const { statistics } = engine;
export class StartEndMarkers {
updateBinded: () => void = this.update.bind(this);
unsubscribes: (() => void)[] = [];
constructor() {
map.onLoad((map_) => map_.on('style.load', this.updateBinded));
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded));
this.unsubscribes.push(slicedGPXStatistics.subscribe(this.updateBinded));
this.unsubscribes.push(statistics.subscribe(this.updateBinded));
this.unsubscribes.push(slicedStatistics.subscribe(this.updateBinded));
this.unsubscribes.push(hoveredPoint.subscribe(this.updateBinded));
this.unsubscribes.push(currentTool.subscribe(this.updateBinded));
this.unsubscribes.push(allHidden.subscribe(this.updateBinded));
@@ -46,8 +49,8 @@ export class StartEndMarkers {
this.loadIcons();
const tool = get(currentTool);
const statistics = get(gpxStatistics);
const slicedStatistics = get(slicedGPXStatistics);
const stats = get(statistics);
const sliced = get(slicedStatistics);
const hovered = get(hoveredPoint);
const hidden = get(allHidden);
if (!hidden) {
@@ -56,18 +59,17 @@ export class StartEndMarkers {
features: [],
};
if (statistics.global.length > 0 && tool !== Tool.ROUTING) {
const start = statistics
.getTrackPoint(slicedStatistics?.[1] ?? 0)!
.trkpt.getCoordinates();
const end = statistics
.getTrackPoint(slicedStatistics?.[2] ?? statistics.global.length - 1)!
.trkpt.getCoordinates();
if (stats.length > 0 && tool !== Tool.ROUTING) {
const start = [stats.lng[sliced?.start ?? 0], stats.lat[sliced?.start ?? 0]];
const end = [
stats.lng[sliced?.end ?? stats.length - 1],
stats.lat[sliced?.end ?? stats.length - 1],
];
data.features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [start.lon, start.lat],
coordinates: start,
},
properties: {
icon: 'start-marker',
@@ -77,7 +79,7 @@ export class StartEndMarkers {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [end.lon, end.lat],
coordinates: end,
},
properties: {
icon: 'end-marker',
@@ -90,7 +92,7 @@ export class StartEndMarkers {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [hovered.lon, hovered.lat],
coordinates: [hovered.lng, hovered.lat],
},
properties: {
icon: 'hover-marker',
@@ -98,7 +100,7 @@ export class StartEndMarkers {
});
}
let source = map_.getSource('start-end-markers') as GeoJSONSource | undefined;
const source = map_.getSource('start-end-markers') as GeoJSONSource | undefined;
if (source) {
source.setData(data);
} else {
@@ -17,11 +17,11 @@
Map,
Layers2,
} from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
import { i18n } from '$lib/i18n/i18n.svelte';
import { defaultBasemap, layerGroup, type CustomLayer } from '$lib/data/layers';
import { onMount } from 'svelte';
import { remove } from './utils';
import { settings } from '$lib/logic/settings';
import { settings } from '$lib/state/settings';
import { dndzone } from 'svelte-dnd-action';
const {
@@ -128,15 +128,16 @@
],
};
}
addLayer(layerId);
$customLayers[layerId] = layer;
addLayer(layerId);
selectedLayerId = undefined;
setDataFromSelectedLayer();
}
function getLayerId() {
for (let id = 0; ; id++) {
if (!$customLayers.hasOwnProperty(`custom-${id}`)) {
if (!Object.hasOwn($customLayers, `custom-${id}`)) {
return `custom-${id}`;
}
}
@@ -145,7 +146,7 @@
function addLayer(layerId: string) {
if (layerType === 'basemap') {
selectedBasemapTree.update(($tree) => {
if (!$tree.basemaps.hasOwnProperty('custom')) {
if (!Object.hasOwn($tree.basemaps, 'custom')) {
$tree.basemaps['custom'] = {};
}
$tree.basemaps['custom'][layerId] = true;
@@ -161,7 +162,7 @@
}
} else {
selectedOverlayTree.update(($tree) => {
if (!$tree.overlays.hasOwnProperty('custom')) {
if (!Object.hasOwn($tree.overlays, 'custom')) {
$tree.overlays['custom'] = {};
}
$tree.overlays['custom'][layerId] = true;
@@ -169,7 +170,7 @@
});
currentOverlays.update(($overlays) => {
if (!$overlays.overlays.hasOwnProperty('custom')) {
if (!Object.hasOwn($overlays.overlays, 'custom')) {
$overlays.overlays['custom'] = {};
}
$overlays.overlays['custom'][layerId] = true;
@@ -182,8 +183,8 @@
}
}
function tryDeleteLayer(node: any, id: string): any {
if (node.hasOwnProperty(id)) {
function tryDeleteLayer<T>(node: Record<string, T>, id: string): Record<string, T> {
if (Object.hasOwn(node, id)) {
delete node[id];
}
return node;
@@ -255,13 +256,12 @@
onfinalize={(e) => {
customBasemapItems = e.detail.items;
$customBasemapOrder = customBasemapItems.map((item) => item.id);
$selectedBasemapTree.basemaps['custom'] = customBasemapItems.reduce(
(acc, item) => {
acc[item.id] = true;
return acc;
},
{}
);
selectedBasemapTree.update(($tree) => {
layerGroup($tree.basemaps)['custom'] = Object.fromEntries(
customBasemapItems.map((item) => [item.id, true])
);
return $tree;
});
}}
>
{#each customBasemapItems as item (item.id)}
@@ -315,13 +315,12 @@
onfinalize={(e) => {
customOverlayItems = e.detail.items;
$customOverlayOrder = customOverlayItems.map((item) => item.id);
$selectedOverlayTree.overlays['custom'] = customOverlayItems.reduce(
(acc, item) => {
acc[item.id] = true;
return acc;
},
{}
);
selectedOverlayTree.update(($tree) => {
layerGroup($tree.overlays)['custom'] = Object.fromEntries(
customOverlayItems.map((item) => [item.id, true])
);
return $tree;
});
}}
>
{#each customOverlayItems as item (item.id)}
@@ -365,7 +364,7 @@
<Label for="name">{i18n._('menu.metadata.name')}</Label>
<Input bind:value={name} id="name" class="h-8" />
<Label for="url">{i18n._('layers.custom_layers.urls')}</Label>
{#each tileUrls as url, i}
{#each tileUrls as _, i (i)}
<div class="flex flex-row gap-2">
<Input
bind:value={tileUrls[i]}
@@ -5,7 +5,7 @@
import { Separator } from '$lib/components/ui/separator';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import { Layers } from '@lucide/svelte';
import { settings } from '$lib/logic/settings';
import { settings } from '$lib/state/settings';
import { map } from '$lib/components/map/map';
import type { Map } from 'maplibre-gl';
@@ -14,12 +14,12 @@
overlayTree,
overpassTree,
terrainSources,
} from '$lib/assets/layers';
} from '$lib/data/layers';
import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import { map } from '$lib/components/map/map';
import CustomLayers from './CustomLayers.svelte';
import { settings } from '$lib/logic/settings';
import { settings } from '$lib/state/settings';
import { untrack } from 'svelte';
import { extensionAPI } from '$lib/components/map/layer-control/extension-api';
@@ -45,7 +45,7 @@
function setOpacityFromSelection() {
if (selectedOverlay) {
if ($opacities.hasOwnProperty(selectedOverlay)) {
if (Object.hasOwn($opacities, selectedOverlay)) {
overlayOpacity = $opacities[selectedOverlay];
} else {
overlayOpacity = 1;
@@ -169,7 +169,7 @@
{#if isSelected($selectedOverlayTree, selectedOverlay)}
{#if $isLayerFromExtension(selectedOverlay)}
{$getLayerName(selectedOverlay)}
{:else if $customLayers.hasOwnProperty(selectedOverlay)}
{:else if Object.hasOwn($customLayers, selectedOverlay)}
{$customLayers[selectedOverlay].name}
{:else}
{i18n._(`layers.label.${selectedOverlay}`)}
@@ -178,7 +178,7 @@
{/if}
</Select.Trigger>
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
{#each Object.keys(overlays) as id}
{#each Object.keys(overlays) as id (id)}
{#if isSelected($selectedOverlayTree, id)}
<Select.Item value={id}>
{#if $isLayerFromExtension(id)}
@@ -189,7 +189,7 @@
</Select.Item>
{/if}
{/each}
{#each Object.entries($customLayers) as [id, layer]}
{#each Object.entries($customLayers) as [id, layer] (id)}
{#if layer.layerType === 'overlay'}
<Select.Item value={id}>{layer.name}</Select.Item>
{/if}
@@ -218,7 +218,7 @@
if ($map.getLayer(selectedOverlay)) {
$map.removeLayer(selectedOverlay);
}
} catch (e) {
} catch {
// No reliable way to check if the map is ready to remove sources and layers
}
}
@@ -247,7 +247,7 @@
{i18n._(`layers.label.${$terrainSource}`)}
</Select.Trigger>
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
{#each Object.keys(terrainSources) as id}
{#each Object.keys(terrainSources) as id (id)}
<Select.Item value={id}>
{i18n._(`layers.label.${id}`)}
</Select.Item>
@@ -1,6 +1,6 @@
<script lang="ts">
import LayerTreeNode from './LayerTreeNode.svelte';
import { type LayerTreeType } from '$lib/assets/layers';
import { type LayerTreeType } from '$lib/data/layers';
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
let {
@@ -3,10 +3,10 @@
import { Label } from '$lib/components/ui/label';
import { Checkbox } from '$lib/components/ui/checkbox';
import CollapsibleTreeNode from '$lib/components/collapsible-tree/CollapsibleTreeNode.svelte';
import { type LayerTreeType } from '$lib/assets/layers';
import { type LayerTreeType } from '$lib/data/layers';
import { anySelectedLayer } from './utils';
import { i18n } from '$lib/i18n.svelte';
import { settings } from '$lib/logic/settings';
import { i18n } from '$lib/i18n/i18n.svelte';
import { settings } from '$lib/state/settings';
import { extensionAPI } from '$lib/components/map/layer-control/extension-api';
let {
@@ -22,7 +22,7 @@
selected?: string;
onselect?: (value: string) => void;
multiple: boolean;
checked: LayerTreeType;
checked?: LayerTreeType;
} = $props();
const { customLayers } = settings;
@@ -31,7 +31,7 @@
$effect.pre(() => {
if (checked !== undefined) {
Object.keys(node).forEach((id) => {
if (!checked.hasOwnProperty(id)) {
if (!Object.hasOwn(checked, id)) {
if (typeof node[id] == 'boolean') {
checked[id] = false;
} else {
@@ -44,7 +44,7 @@
</script>
<div class="flex flex-col gap-[3px]">
{#each Object.keys(node) as id}
{#each Object.keys(node) as id (id)}
{#if typeof node[id] == 'boolean'}
{#if node[id]}
<div class="flex flex-row items-center gap-2 first:mt-0.5 h-4">
@@ -53,7 +53,9 @@
id="{name}-{id}"
{name}
value={id}
bind:checked={checked[id]}
bind:checked={
() => checked[id] === true, (value) => (checked[id] = value)
}
class="scale-90"
aria-label={i18n._(`layers.label.${id}`)}
/>
@@ -72,7 +74,7 @@
/>
{/if}
<Label for="{name}-{id}" class="flex flex-row items-center gap-1">
{#if $customLayers.hasOwnProperty(id)}
{#if Object.hasOwn($customLayers, id)}
{$customLayers[id].name}
{:else if $isLayerFromExtension(id)}
{$getLayerName(id)}
@@ -90,12 +92,18 @@
{#snippet content()}
<div class="ml-2">
<Self
node={node[id]}
node={node[id] as LayerTreeType}
{name}
{selected}
{onselect}
{multiple}
bind:checked={checked[id]}
bind:checked={
() => {
const group = checked[id];
return typeof group === 'object' ? group : undefined;
},
(value) => (checked[id] = value ?? {})
}
/>
</div>
{/snippet}
@@ -2,12 +2,12 @@
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { PencilLine, MapPin } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import type { WaypointType } from 'gpx';
import type { PopupItem } from '$lib/components/map/map-popup';
import { fileActions } from '$lib/logic/file-actions';
import { selection } from '$lib/logic/selection';
import { engine } from '$lib/engine';
import { selectionSize } from '$lib/engine/selection-helpers';
import { getElevation } from '$lib/utils';
let {
poi,
@@ -29,28 +29,22 @@
return '';
});
function addToFile() {
const { selection } = engine;
async function addToFile() {
const desc = Object.entries(tags)
.map(([key, value]) => `${key}: ${value}`)
.join('\n');
let wpt: WaypointType = {
attributes: {
lat: poi.item.lat,
lon: poi.item.lon,
},
name: name,
desc: desc,
cmt: desc,
sym: poi.item.sym,
};
if (tags.website) {
wpt.link = {
attributes: {
href: tags.website,
},
};
}
fileActions.addOrUpdateWaypoint(wpt);
const [ele] = await getElevation([{ lat: poi.item.lat, lng: poi.item.lng }]);
await engine.newWaypoint({
lng: poi.item.lng,
lat: poi.item.lat,
ele,
name,
desc,
icon: poi.item.sym ?? '',
link: tags.website ?? '',
});
}
</script>
@@ -60,7 +54,7 @@
<div class="flex flex-col">
<p>{name}</p>
<div class="text-muted-foreground text-xs font-normal">
{poi.item.lat.toFixed(6)}&deg; {poi.item.lon.toFixed(6)}&deg;
{poi.item.lat.toFixed(6)}&deg; {poi.item.lng.toFixed(6)}&deg;
</div>
</div>
@@ -85,11 +79,16 @@
</div>
{/if}
<div class="grid grid-cols-[auto_auto] gap-x-3">
{#each Object.entries(tags) as [key, value]}
{#each Object.entries(tags) as [key, value] (key)}
{#if key !== 'name' && !key.includes('image')}
<span class="font-mono">{key}</span>
{#if key === 'website' || key.startsWith('website:') || key.endsWith(':website') || key === 'contact:facebook' || key === 'contact:instagram' || key === 'contact:twitter'}
<a href={value} target="_blank" class="text-link underline">{value}</a>
<a
href={value}
target="_blank"
rel="external"
class="text-link underline">{value}</a
>
{:else if key === 'wikipedia' || key.startsWith('wikipedia:') || key.endsWith(':wikipedia')}
<a
href="https://wikipedia.org/wiki/{value}"
@@ -113,7 +112,7 @@
size="sm"
class="mt-1 justify-start"
variant="outline"
disabled={$selection.size === 0}
disabled={selectionSize($selection) === 0}
onclick={addToFile}
>
<MapPin size="14" />
@@ -0,0 +1,153 @@
// The API that browser extensions use to add overlays to the map (`window.gpxstudio`).
import assert from 'node:assert/strict';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { get } from 'svelte/store';
import { overlays, overlayTree, type LayerTreeType } from '$lib/data/layers';
import { engine } from '$lib/engine';
import { settings } from '$lib/state/settings';
import { ExtensionAPI, type CustomOverlay } from './extension-api';
import { isSelected } from './utils';
const original = { set: engine.setSetting, delete: engine.deleteSetting };
/** The raster source that an overlay added by an extension uses. */
function rasterSource(id: string) {
const style = overlays[id];
assert.ok(typeof style === 'object');
const source = style.sources[id];
assert.ok(source?.type === 'raster');
return source;
}
const overlayTreeBefore = JSON.stringify(overlayTree);
const overlayIdsBefore = Object.keys(overlays).sort();
const overlay = (id: string, extensionName = 'My extension'): CustomOverlay => ({
extensionName,
id,
name: `Overlay ${id}`,
tileUrls: [`https://tiles.example.com/${id}/{z}/{x}/{y}.png`],
});
let api: ExtensionAPI;
beforeEach(() => {
engine.setSetting = () => {};
engine.deleteSetting = () => {};
settings.connect({});
settings.initialize();
api = new ExtensionAPI();
});
afterEach(() => {
// the layers are shared by the whole app: leave them as they were
api.filterOverlays([]);
for (const extension of ['My extension', 'Other extension']) {
delete (overlayTree.overlays as LayerTreeType)[extension];
}
engine.setSetting = original.set;
engine.deleteSetting = original.delete;
assert.equal(JSON.stringify(overlayTree), overlayTreeBefore);
assert.deepEqual(Object.keys(overlays).sort(), overlayIdsBefore);
});
describe('ExtensionAPI', () => {
it('adds an overlay as a layer, in the tree of its extension', () => {
api.addOrUpdateOverlay(overlay('one'));
const source = rasterSource('extension-one');
assert.deepEqual(source.tiles, ['https://tiles.example.com/one/{z}/{x}/{y}.png']);
assert.equal(source.tileSize, 256);
assert.equal(source.maxzoom, 22);
assert.ok((overlayTree.overlays as LayerTreeType)['My extension']);
assert.equal(isSelected(overlayTree, 'extension-one'), true);
assert.equal(isSelected(get(settings.selectedOverlayTree), 'extension-one'), true);
// not shown until the user chooses to
assert.equal(isSelected(get(settings.currentOverlays)!, 'extension-one'), false);
assert.ok(get(api.isLayerFromExtension)('extension-one'));
assert.equal(get(api.getLayerName)('extension-one'), 'Overlay one');
assert.equal(get(api.getLayerName)('other'), '');
});
it('uses big tiles when the urls say so, and the max zoom it is given', () => {
api.addOrUpdateOverlay({
...overlay('big'),
tileUrls: ['https://tiles.example.com/512/{z}/{x}/{y}.png'],
maxZoom: 15,
});
const source = rasterSource('extension-big');
assert.equal(source.tileSize, 512);
assert.equal(source.maxzoom, 15);
});
it('refuses an overlay that is not complete', () => {
for (const bad of [
{ ...overlay('a'), extensionName: '' },
{ ...overlay('a'), id: '' },
{ ...overlay('a'), name: '' },
{ ...overlay('a'), tileUrls: [] },
{ ...overlay('a'), tileUrls: undefined as unknown as string[] },
]) {
assert.throws(() => api.addOrUpdateOverlay(bad), /must have/);
}
assert.ok(!('extension-a' in overlays));
});
it('updates an overlay that is added again, keeping it shown', () => {
api.addOrUpdateOverlay(overlay('one'));
settings.currentOverlays.update((current) => {
(current.overlays as LayerTreeType)['My extension'] = { 'extension-one': true };
return current;
});
api.addOrUpdateOverlay({
...overlay('one'),
name: 'Renamed',
tileUrls: ['https://other/{z}'],
});
assert.equal(get(api.getLayerName)('extension-one'), 'Renamed');
assert.deepEqual(rasterSource('extension-one').tiles, ['https://other/{z}']);
assert.equal(isSelected(get(settings.currentOverlays)!, 'extension-one'), true);
});
it('removes the overlays that are not in the list anymore, everywhere', () => {
api.addOrUpdateOverlay(overlay('one'));
api.addOrUpdateOverlay(overlay('two', 'Other extension'));
api.filterOverlays(['two']);
assert.ok(!('extension-one' in overlays));
assert.ok('extension-two' in overlays);
assert.equal(get(api.isLayerFromExtension)('extension-one'), false);
assert.equal(get(api.isLayerFromExtension)('extension-two'), true);
for (const tree of [
overlayTree,
get(settings.selectedOverlayTree),
get(settings.currentOverlays)!,
]) {
assert.equal(isSelected(tree, 'extension-one'), false);
}
assert.equal(isSelected(overlayTree, 'extension-two'), true);
api.filterOverlays([]);
assert.ok(!('extension-two' in overlays));
});
it('puts the overlays in the order it is given', () => {
api.addOrUpdateOverlay(overlay('one'));
api.addOrUpdateOverlay(overlay('two'));
api.addOrUpdateOverlay(overlay('three'));
api.updateOverlaysOrder(['three', 'one', 'two']);
const extension = (get(settings.selectedOverlayTree).overlays as LayerTreeType)[
'My extension'
];
assert.deepEqual(Object.keys(extension as LayerTreeType), [
'extension-three',
'extension-one',
'extension-two',
]);
// ids it does not know are ignored
api.updateOverlaysOrder(['unknown', 'two']);
assert.equal(Object.keys(extension as LayerTreeType).at(-1), 'extension-two');
});
});
@@ -1,7 +1,7 @@
import { settings } from '$lib/logic/settings';
import { settings } from '$lib/state/settings';
import { derived, get, writable, type Writable } from 'svelte/store';
import { isSelected, remove, removeAll } from './utils';
import { overlays, overlayTree } from '$lib/assets/layers';
import { layerGroup, overlays, overlayTree, type LayerTreeType } from '$lib/data/layers';
import { browser } from '$app/environment';
import { map } from '$lib/components/map/map';
@@ -19,7 +19,7 @@ export class ExtensionAPI {
private _overlays: Writable<Map<string, CustomOverlay>> = writable(new Map());
init() {
if (browser && !window.hasOwnProperty('gpxstudio')) {
if (browser && !Object.hasOwn(window, 'gpxstudio')) {
Object.defineProperty(window, 'gpxstudio', {
value: this,
});
@@ -84,14 +84,15 @@ export class ExtensionAPI {
],
};
if (!overlayTree.overlays.hasOwnProperty(overlay.extensionName)) {
overlayTree.overlays[overlay.extensionName] = {};
const extensions = layerGroup(overlayTree.overlays);
if (!Object.hasOwn(extensions, overlay.extensionName)) {
extensions[overlay.extensionName] = {};
}
overlayTree.overlays[overlay.extensionName][overlay.id] = true;
layerGroup(extensions[overlay.extensionName])[overlay.id] = true;
selectedOverlayTree.update((selected) => {
if (!selected.overlays.hasOwnProperty(overlay.extensionName)) {
if (!Object.hasOwn(selected.overlays, overlay.extensionName)) {
selected.overlays[overlay.extensionName] = {};
}
selected.overlays[overlay.extensionName][overlay.id] = true;
@@ -104,13 +105,13 @@ export class ExtensionAPI {
show = true;
try {
get(map)?.removeLayer(overlay.id);
} catch (e) {
} catch {
// No reliable way to check if the map is ready to remove sources and layers
}
}
currentOverlays.update((current) => {
if (!current.overlays.hasOwnProperty(overlay.extensionName)) {
if (!Object.hasOwn(current.overlays, overlay.extensionName)) {
current.overlays[overlay.extensionName] = {};
}
current.overlays[overlay.extensionName][overlay.id] = show;
@@ -155,13 +156,13 @@ export class ExtensionAPI {
updateOverlaysOrder(ids: string[]) {
ids = ids.map((id) => this.getOverlayId(id));
selectedOverlayTree.update((selected) => {
let isSelected: Record<string, boolean> = {};
const isSelected: Record<string, boolean> = {};
ids.forEach((id) => {
const overlay = get(this._overlays).get(id);
if (
overlay &&
selected.overlays.hasOwnProperty(overlay.extensionName) &&
selected.overlays[overlay.extensionName].hasOwnProperty(id)
Object.hasOwn(selected.overlays, overlay.extensionName) &&
Object.hasOwn(selected.overlays[overlay.extensionName] as LayerTreeType, id)
) {
isSelected[id] = selected.overlays[overlay.extensionName][id];
delete selected.overlays[overlay.extensionName][id];
@@ -1,39 +1,38 @@
import { SphericalMercator } from '@mapbox/sphericalmercator';
import { getLayers } from './utils';
import { get, writable } from 'svelte/store';
import { liveQuery } from 'dexie';
import { overpassQueryData } from '$lib/assets/layers';
import { overpassQueryData } from '$lib/data/layers';
import { MapPopup } from '$lib/components/map/map-popup';
import { settings } from '$lib/logic/settings';
import { db } from '$lib/db';
import type { Map as MapLibreMap, GeoJSONSource } from 'maplibre-gl';
import { settings } from '$lib/state/settings';
import type { GeoJSONSource, MapLayerMouseEvent, Map as MapLibreMap } from 'maplibre-gl';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
import { loadSVGIcon } from '$lib/utils';
import { cachedFetch } from '$lib/storage/cached-fetch';
const { currentOverpassQueries } = settings;
/** The responses of Overpass are kept in the browser for a week. */
const CACHE = { name: 'overpass', maxAge: 7 * 24 * 3600 * 1000 };
const mercator = new SphericalMercator({
size: 256,
});
let data = writable<GeoJSON.FeatureCollection>({ type: 'FeatureCollection', features: [] });
liveQuery(() => db.overpassdata.toArray()).subscribe((pois) => {
data.set({ type: 'FeatureCollection', features: pois.map((poi) => poi.poi) });
});
export class OverpassLayer {
overpassUrl = 'https://overpass.gpx.studio/api/interpreter';
minZoom = 12;
queryZoom = 12;
expirationTime = 7 * 24 * 3600 * 1000;
map: MapLibreMap;
layerEventManager: MapLayerEventManager;
popup: MapPopup;
data = writable<GeoJSON.FeatureCollection>({ type: 'FeatureCollection', features: [] });
// What was queried this session: the queries that were made for each tile. The responses are
// kept longer, see `cachedFetch`.
queriedTiles = new Map<string, Set<string>>();
currentQueries: Set<string> = new Set();
nextQueries: Map<string, { x: number; y: number; queries: string[] }> = new Map();
unsubscribes: (() => void)[] = [];
queryIfNeededBinded = this.queryIfNeeded.bind(this);
@@ -54,7 +53,7 @@ export class OverpassLayer {
add() {
this.map.on('moveend', this.queryIfNeededBinded);
this.map.on('style.load', this.updateBinded);
this.unsubscribes.push(data.subscribe(this.updateBinded));
this.unsubscribes.push(this.data.subscribe(this.updateBinded));
this.unsubscribes.push(
currentOverpassQueries.subscribe(() => {
this.updateBinded();
@@ -77,7 +76,7 @@ export class OverpassLayer {
update() {
this.loadIcons();
const fullData = get(data);
const fullData = get(this.data);
const queries = getCurrentQueries();
const d: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
@@ -87,7 +86,7 @@ export class OverpassLayer {
};
try {
let source = this.map.getSource('overpass') as GeoJSONSource | undefined;
const source = this.map.getSource('overpass') as GeoJSONSource | undefined;
if (source) {
source.setData(d);
} else {
@@ -116,7 +115,7 @@ export class OverpassLayer {
this.layerEventManager.on('mouseenter', 'overpass', this.onHoverBinded);
this.layerEventManager.on('click', 'overpass', this.onHoverBinded);
}
} catch (e) {
} catch {
// No reliable way to check if the map is ready to add sources and layers
}
}
@@ -136,28 +135,28 @@ export class OverpassLayer {
if (this.map.getSource('overpass')) {
this.map.removeSource('overpass');
}
} catch (e) {
} catch {
// No reliable way to check if the map is ready to remove sources and layers
}
}
onHover(e: any) {
onHover(e: MapLayerMouseEvent) {
this.popup.setItem({
kind: 'overpass',
item: {
...e.features[0].properties,
sym: overpassQueryData[e.features[0].properties.query].symbol ?? '',
...e.features![0].properties,
sym: overpassQueryData[e.features![0].properties.query].symbol ?? '',
},
});
}
query(bbox: [number, number, number, number]) {
let queries = getCurrentQueries();
const queries = getCurrentQueries();
if (queries.length === 0) {
return;
}
let tileLimits = mercator.xyz(bbox, this.queryZoom);
let time = Date.now();
const tileLimits = mercator.xyz(bbox, this.queryZoom);
for (let x = tileLimits.minX; x <= tileLimits.maxX; x++) {
for (let y = tileLimits.minY; y <= tileLimits.maxY; y++) {
@@ -165,66 +164,35 @@ 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);
}
});
const queried = this.queriedTiles.get(`${x},${y}`);
const missingQueries = queries.filter((query) => !queried?.has(query));
if (missingQueries.length > 0) {
this.queryTile(x, y, missingQueries);
}
}
}
}
queryTile(x: number, y: number, queries: string[]) {
if (this.currentQueries.size > 5) {
return;
}
this.currentQueries.add(`${x},${y}`);
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}`)
)
cachedFetch(`${this.overpassUrl}?data=${getQueryForBounds(bounds, queries)}`, CACHE)
.then((response) => (response.ok ? response.json() : Promise.reject()))
.then((data) => this.storeOverpassData(x, y, queries, data))
.catch(() => this.currentQueries.delete(`${x},${y}`));
}
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 }[] = [];
if (data.elements === undefined) {
return;
}
for (let element of data.elements) {
for (let query of queries) {
if (belongsToQuery(element, query)) {
pois.push({
query,
id: element.id,
poi: {
this.data.update((updatedData) => {
for (const element of data.elements) {
for (const query of queries) {
if (belongsToQuery(element, query)) {
updatedData.features.push({
type: 'Feature',
geometry: {
type: 'Point',
@@ -235,28 +203,28 @@ export class OverpassLayer {
properties: {
id: element.id,
lat: element.center ? element.center.lat : element.lat,
lon: element.center ? element.center.lon : element.lon,
lng: element.center ? element.center.lon : element.lon,
query: query,
icon: `overpass-${query}`,
tags: element.tags,
type: element.type,
},
},
});
});
}
}
}
}
db.transaction('rw', db.overpasstiles, db.overpassdata, async () => {
await db.overpasstiles.bulkPut(queryTiles);
await db.overpassdata.bulkPut(pois);
return updatedData;
});
const queried = this.queriedTiles.get(`${x},${y}`) ?? new Set<string>();
queries.forEach((query) => queried.add(query));
this.queriedTiles.set(`${x},${y}`, queried);
this.currentQueries.delete(`${x},${y}`);
}
loadIcons() {
let currentQueries = getCurrentQueries();
const currentQueries = getCurrentQueries();
currentQueries.forEach((query) => {
loadSVGIcon(
this.map,
@@ -289,7 +257,7 @@ function getQuery(query: string) {
}
function getQueryItem(tags: Record<string, string | string[]>) {
let arrayEntry = Object.entries(tags).find((entry): entry is [string, string[]] =>
const arrayEntry = Object.entries(tags).find((entry): entry is [string, string[]] =>
Array.isArray(entry[1])
);
if (arrayEntry !== undefined) {
@@ -323,12 +291,12 @@ function belongsToQueryItem(element: any, tags: Record<string, string | string[]
}
function getCurrentQueries() {
let currentQueries = get(currentOverpassQueries);
const currentQueries = get(currentOverpassQueries);
if (currentQueries === undefined) {
return [];
}
return Object.entries(getLayers(currentQueries))
.filter(([_, selected]) => selected)
.map(([query, _]) => query);
.filter(([, selected]) => selected)
.map(([query]) => query);
}
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import type { LayerTreeType } from '$lib/data/layers';
import { anySelectedLayer, getLayers, isSelected, remove, removeAll, toggle } from './utils';
// roads: bike (selected), foot
// satellite: esri, ign: fr (selected)
// osm
const tree = (): LayerTreeType => ({
roads: { bike: true, foot: false },
satellite: { esri: false, ign: { fr: true } },
osm: false,
});
describe('layer trees', () => {
it('tells if a layer is selected', () => {
assert.equal(isSelected(tree(), 'bike'), true);
assert.equal(isSelected(tree(), 'foot'), false);
assert.equal(isSelected(tree(), 'fr'), true);
assert.equal(isSelected(tree(), 'osm'), false);
assert.equal(isSelected(tree(), 'unknown'), false);
});
it('tells if anything is selected in a branch', () => {
const t = tree();
assert.equal(anySelectedLayer(t), true);
assert.equal(anySelectedLayer(t.roads as LayerTreeType), true);
assert.equal(anySelectedLayer(t.satellite as LayerTreeType), true);
assert.equal(anySelectedLayer({ a: false, b: { c: false } }), false);
assert.equal(anySelectedLayer({}), false);
});
it('flattens the layers of the leaves', () => {
assert.deepEqual(getLayers(tree()), {
bike: true,
foot: false,
esri: false,
fr: true,
osm: false,
});
assert.deepEqual(getLayers({}), {});
});
it('toggles a layer wherever it is', () => {
const t = toggle(tree(), 'fr');
assert.equal(isSelected(t, 'fr'), false);
assert.equal(isSelected(toggle(t, 'osm'), 'osm'), true);
assert.equal(isSelected(toggle(t, 'osm'), 'bike'), true);
// the tree is edited, and returned
const own = tree();
assert.equal(toggle(own, 'foot'), own);
assert.equal((own.roads as LayerTreeType).foot, true);
// an id that is not there changes nothing
assert.deepEqual(toggle(tree(), 'unknown'), tree());
});
it('removes layers and branches', () => {
assert.deepEqual(remove(tree(), 'bike'), {
roads: { foot: false },
satellite: { esri: false, ign: { fr: true } },
osm: false,
});
assert.deepEqual(remove(tree(), 'ign'), {
roads: { bike: true, foot: false },
satellite: { esri: false },
osm: false,
});
assert.deepEqual(removeAll(tree(), ['bike', 'fr', 'osm']), {
roads: { foot: false },
satellite: { esri: false, ign: {} },
});
assert.deepEqual(removeAll(tree(), []), tree());
});
});
@@ -1,4 +1,4 @@
import type { LayerTreeType } from '$lib/assets/layers';
import type { LayerTreeType } from '$lib/data/layers';
export function anySelectedLayer(node: LayerTreeType) {
return (
@@ -1,13 +1,14 @@
import { fileStateCollection } from '$lib/logic/file-state';
import { engine } from '$lib/engine';
import { get } from 'svelte/store';
import {
LngLatBounds,
MapMouseEvent,
MapTouchEvent,
Point,
type Map,
type MapGeoJSONFeature,
type MapLayerMouseEvent,
type MapLayerTouchEvent,
MapMouseEvent,
MapTouchEvent,
type Point,
} from 'maplibre-gl';
type MapLayerMouseEventListener = (e: MapLayerMouseEvent) => void;
@@ -233,17 +234,23 @@ export class MapLayerEventManager {
}
private _filterLayersIntersectingBounds(layerIds: string[], bounds: LngLatBounds): string[] {
let result = layerIds.filter((layerId) => {
const files = get(engine.files);
const result = layerIds.filter((layerId) => {
if (!this._map.getLayer(layerId)) return false;
const fileId = layerId.replace('-waypoints', '');
if (fileId === layerId) {
return fileStateCollection.getStatistics(fileId)?.intersectsBBox(bounds) ?? true;
} else {
return (
fileStateCollection.getStatistics(fileId)?.intersectsWaypointBBox(bounds) ??
true
);
// the tracks of a file are all within its bounds
const file = files.get(fileId);
const box = file && get(file).statistics.bounds;
return box
? bounds.getWest() <= box.east &&
bounds.getEast() >= box.west &&
bounds.getSouth() <= box.north &&
bounds.getNorth() >= box.south
: true;
}
// the waypoints have no bounds of their own
return true;
});
return result;
}
+6 -6
View File
@@ -1,11 +1,13 @@
import { TrackPoint, Waypoint } from 'gpx';
import { LngLat, Map, MapMouseEvent, Popup, type PopupOptions } from 'maplibre-gl';
import { LngLat, type Map, type MapMouseEvent, Popup, type PopupOptions } from 'maplibre-gl';
import { mount, tick, unmount } from 'svelte';
import { get, writable, type Writable } from 'svelte/store';
import MapPopupComponent from '$lib/components/map/MapPopup.svelte';
export type PopupItem<T = Waypoint | TrackPoint | any> = {
export type PopupItem<T = any> = {
/** Has a position (`lng` and `lat`). */
item: T;
/** What the item is: a `WaypointDetails`, a `TrackpointDetails` or an Overpass element. */
kind: 'waypoint' | 'trackpoint' | 'overpass';
fileId?: string;
hide?: () => void;
};
@@ -77,8 +79,6 @@ export class MapPopup {
if (item === null) {
return new LngLat(0, 0);
}
return item.item instanceof Waypoint || item.item instanceof TrackPoint
? item.item.getCoordinates()
: new LngLat(item.item.lon, item.item.lat);
return new LngLat(item.item.lng, item.item.lat);
}
}
+25 -7
View File
@@ -6,14 +6,14 @@ import MaplibreGeocoder, {
} from '@maplibre/maplibre-gl-geocoder';
import '@maplibre/maplibre-gl-geocoder/dist/maplibre-gl-geocoder.css';
import { get, writable, type Writable } from 'svelte/store';
import { settings } from '$lib/logic/settings';
import { settings } from '$lib/state/settings';
import { tick } from 'svelte';
import { ANCHOR_LAYER_KEY, StyleManager } from '$lib/components/map/style';
import { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
const { treeFileView, elevationProfile, bottomPanelSize, rightPanelSize, distanceUnits } = settings;
let fitBoundsOptions: maplibregl.MapOptions['fitBoundsOptions'] = {
const fitBoundsOptions: maplibregl.MapOptions['fitBoundsOptions'] = {
maxZoom: 15,
linear: true,
easing: () => 1,
@@ -43,6 +43,16 @@ export class MapLibreGLMap {
maplibregl.setWorkerUrl(workerUrl);
this._maptilerKey = maptilerKey;
this._styleManager = new StyleManager(this._mapStore, this._maptilerKey);
// MapLibre gives Safari more workers, but only recognizes it by its user agent: not
// WebKit in the desktop app, where the data of the sources was then processed one source
// after the other, and the map drew the files a frame before the anchors of the routing
// tool. Before the first map, which starts the workers.
const userAgent = navigator.userAgent;
if (userAgent.includes('AppleWebKit') && !userAgent.includes('Chrome')) {
maplibregl.setWorkerCount(
Math.max(Math.min(Math.floor(navigator.hardwareConcurrency / 2), 3), 1)
);
}
const map = new maplibregl.Map({
container: 'map',
style: {
@@ -65,7 +75,7 @@ export class MapLibreGLMap {
})
);
if (geocoder) {
let geocoder = new MaplibreGeocoder(
const geocoder = new MaplibreGeocoder(
{
forwardGeocode: async (config) => {
const results: MaplibreGeocoderFeatureResults = {
@@ -75,18 +85,26 @@ export class MapLibreGLMap {
try {
const request = `https://nominatim.openstreetmap.org/search?format=json&q=${config.query}&limit=5&accept-language=${language}`;
const response = await fetch(request);
const geojson = await response.json();
results.features = geojson.map((result: any) => {
const places: {
lat: string;
lon: string;
type: string;
display_name: string;
}[] = await response.json();
results.features = places.map((result) => {
return {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [result.lon, result.lat],
coordinates: [Number(result.lon), Number(result.lat)],
},
properties: {},
text: result.display_name,
place_name: result.display_name,
place_type: [result.type],
};
});
} catch (e) {}
} catch {}
return results;
},
},
@@ -5,8 +5,8 @@
import { PersonStanding, X } from '@lucide/svelte';
import { MapillaryLayer } from './mapillary';
import { GoogleRedirect } from './google';
import { settings } from '$lib/logic/settings';
import { i18n } from '$lib/i18n.svelte';
import { settings } from '$lib/state/settings';
import { i18n } from '$lib/i18n/i18n.svelte';
import { onMount } from 'svelte';
import ButtonWithTooltip from '$lib/components/ButtonWithTooltip.svelte';
import type { Map } from 'maplibre-gl';
@@ -1,5 +1,5 @@
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import type { Map, MapMouseEvent } from 'maplibre-gl';
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
export class GoogleRedirect {
map: Map;
@@ -5,9 +5,9 @@ import {
type MapLayerMouseEvent,
type VectorSourceSpecification,
} from 'maplibre-gl';
import { Viewer, type ViewerBearingEvent } from 'mapillary-js/dist/mapillary.module';
import { Viewer, type ViewerBearingEvent } from 'mapillary-js';
import 'mapillary-js/dist/mapillary.css';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
@@ -89,7 +89,7 @@ export class MapillaryLayer {
this.viewer.on('position', async () => {
if (this.active) {
popupOpen.value = true;
let latLng = await this.viewer.getPosition();
const latLng = await this.viewer.getPosition();
this.marker.setLngLat(latLng).addTo(this.map);
if (!this.map.getBounds()?.contains(latLng)) {
this.map.panTo(latLng);
+35 -20
View File
@@ -1,4 +1,11 @@
import { settings } from '$lib/logic/settings';
import type {
GeoJSONSourceSpecification,
LayerSpecification,
Map,
SkySpecification,
StyleSpecification,
} from 'maplibre-gl';
import { settings } from '$lib/state/settings';
import { get, type Writable } from 'svelte/store';
import {
basemaps,
@@ -6,15 +13,9 @@ import {
maptilerKeyPlaceHolder,
overlays,
terrainSources,
} from '$lib/assets/layers';
} from '$lib/data/layers';
import { getLayers } from '$lib/components/map/layer-control/utils';
import { i18n } from '$lib/i18n.svelte';
import type {
Map,
GeoJSONSourceSpecification,
LayerSpecification,
StyleSpecification,
} from 'maplibre-gl';
import { i18n } from '$lib/i18n/i18n.svelte';
const { currentBasemap, currentOverlays, customLayers, opacities, terrainSource, distanceUnits } =
settings;
@@ -26,6 +27,16 @@ const emptySource: GeoJSONSourceSpecification = {
features: [],
},
};
/**
* The sky above the horizon when the map is tilted, without the atmosphere around the globe.
*/
const sky: SkySpecification = {
'sky-color': '#88C6FC',
'horizon-color': '#ffffff',
'sky-horizon-blend': 0.8,
'atmosphere-blend': 0,
};
export const ANCHOR_LAYER_KEY = {
overlays: 'overlays-end',
mapillary: 'mapillary-end',
@@ -76,7 +87,7 @@ export class StyleManager {
updateBasemap() {
const map_ = get(this._map);
if (!map_) return;
let basemap = get(currentBasemap);
const basemap = get(currentBasemap);
this.buildStyle(basemap).then((style) => {
if (get(currentBasemap) === basemap) map_.setStyle(style);
});
@@ -114,9 +125,10 @@ export class StyleManager {
}
}
} catch (e) {
console.error(e.message);
console.error(e instanceof Error ? e.message : e);
}
this.merge(style, basemapStyle);
style.sky ??= sky;
const terrain = this.getCurrentTerrain();
style.sources[terrain.source] = terrainSources[terrain.source];
@@ -136,18 +148,18 @@ export class StyleManager {
const overlayOpacities = get(opacities);
try {
const layers = getLayers(get(currentOverlays) ?? {});
for (let overlay in layers) {
for (const overlay in layers) {
if (!layers[overlay]) {
if (this._pastOverlays.has(overlay)) {
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
try {
const overlayStyle = await this.get(overlayInfo);
for (let layer of overlayStyle.layers ?? []) {
for (const layer of overlayStyle.layers ?? []) {
if (map_.getLayer(layer.id)) {
map_.removeLayer(layer.id);
}
}
} catch (e) {
} catch {
// Should not happen
}
this._pastOverlays.delete(overlay);
@@ -158,13 +170,13 @@ export class StyleManager {
const overlayStyle = await this.get(overlayInfo);
const opacity = overlayOpacities[overlay];
for (let sourceId in overlayStyle.sources) {
for (const sourceId in overlayStyle.sources) {
if (!map_.getSource(sourceId)) {
map_.addSource(sourceId, overlayStyle.sources[sourceId]);
}
}
for (let layer of overlayStyle.layers ?? []) {
for (const layer of overlayStyle.layers ?? []) {
if (!map_.getLayer(layer.id)) {
if (opacity !== undefined) {
if (layer.type === 'raster') {
@@ -184,11 +196,11 @@ export class StyleManager {
}
this._pastOverlays.add(overlay);
} catch (e) {
console.error(e.message);
console.error(e instanceof Error ? e.message : e);
}
}
}
} catch (e) {}
} catch {}
}
updateTerrain() {
@@ -211,7 +223,7 @@ export class StyleManager {
async get(styleInfo: StyleSpecification | string): Promise<StyleSpecification> {
if (typeof styleInfo === 'string') {
let styleUrl = styleInfo as string;
const styleUrl = styleInfo as string;
const response = await fetch(styleUrl, { cache: 'force-cache' });
if (!response.ok) {
throw new Error(`HTTP error fetching style "${styleInfo}": ${response.status}`);
@@ -226,7 +238,7 @@ export class StyleManager {
merge(style: StyleSpecification, other: StyleSpecification) {
style.sources = { ...style.sources, ...other.sources };
const units = get(distanceUnits);
for (let layer of other.layers ?? []) {
for (const layer of other.layers ?? []) {
if ('source' in layer) {
if (layer.source == 'contours_m' && units === 'imperial') continue;
if (layer.source == 'contours_ft' && units !== 'imperial') continue;
@@ -275,6 +287,9 @@ export class StyleManager {
if (other.glyphs && !style.glyphs) {
style.glyphs = other.glyphs;
}
if (other.sky && !style.sky) {
style.sky = other.sky;
}
}
getCurrentTerrain() {
@@ -13,7 +13,7 @@
Scissors,
MountainSnow,
} from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
let props: {
class?: string;
@@ -12,7 +12,7 @@
import Reduce from '$lib/components/toolbar/tools/reduce/Reduce.svelte';
import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte';
import { Popup } from 'maplibre-gl';
import { settings } from '$lib/logic/settings';
import { settings } from '$lib/state/settings';
let {
class: className = '',
@@ -11,15 +11,14 @@
import * as RadioGroup from '$lib/components/ui/radio-group';
import { Button } from '$lib/components/ui/button';
import Help from '$lib/components/Help.svelte';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import { onDestroy, onMount } from 'svelte';
import { getURLForLanguage } from '$lib/utils';
import { Trash2 } from '@lucide/svelte';
import { map } from '$lib/components/map/map';
import type { GeoJSONSource, LngLat } from 'maplibre-gl';
import { selection } from '$lib/logic/selection';
import { fileActions } from '$lib/logic/file-actions';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import type { GeoJSONSource, LngLat, MapMouseEvent, MapTouchEvent } from 'maplibre-gl';
import { engine } from '$lib/engine';
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
let props: {
@@ -82,18 +81,18 @@
});
let mousedown = false;
function onMouseDown(e: any) {
function onMouseDown(e: MapMouseEvent | MapTouchEvent) {
mousedown = true;
rectangleCoordinates = [e.lngLat, e.lngLat];
}
function onMouseMove(e: any) {
function onMouseMove(e: MapMouseEvent | MapTouchEvent) {
if (mousedown) {
rectangleCoordinates[1] = e.lngLat;
}
}
function onMouseUp(e: any) {
function onMouseUp() {
mousedown = false;
}
@@ -130,7 +129,8 @@
}
});
let validSelection = $derived($selection.size > 0);
const selection = engine.selection;
let validSelection = $derived($selection.type !== 'empty');
</script>
<div class="flex flex-col gap-3 w-full max-w-80 items-center {props.class ?? ''}">
@@ -163,17 +163,13 @@
class="w-full"
disabled={!validSelection || rectangleCoordinates.length != 2}
onclick={() => {
fileActions.cleanSelection(
[
{
lat: Math.min(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
lon: Math.min(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng),
},
{
lat: Math.max(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
lon: Math.max(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng),
},
],
engine.clean(
{
west: Math.min(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng),
south: Math.min(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
east: Math.max(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng),
north: Math.max(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
},
cleanType === CleanType.INSIDE,
deleteTrackpoints,
deleteWaypoints
@@ -2,16 +2,29 @@
import { Button } from '$lib/components/ui/button';
import Help from '$lib/components/Help.svelte';
import { MountainSnow } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { getURLForLanguage } from '$lib/utils';
import { selection } from '$lib/logic/selection';
import { fileActions } from '$lib/logic/file-actions';
import { i18n } from '$lib/i18n/i18n.svelte';
import { getElevation, getURLForLanguage } from '$lib/utils';
import { engine } from '$lib/engine';
import { get } from 'svelte/store';
let props: {
class?: string;
} = $props();
let validSelection = $derived($selection.size > 0);
const statistics = engine.statistics;
let validSelection = $derived($statistics.length > 0);
async function addElevation() {
const stats = get(statistics);
const points = Array.from(stats.lng, (lng, i) => ({ lng, lat: stats.lat[i] }));
const ele = await getElevation(points);
// the selection or the files may have changed while the elevation was loading
if (get(statistics) === stats) {
await engine.setElevation(Float64Array.from(ele));
}
}
</script>
<div class="flex flex-col gap-3 w-full max-w-80 {props.class ?? ''}">
@@ -19,7 +32,7 @@
variant="outline"
class="whitespace-normal h-fit min-h-8 py-1"
disabled={!validSelection}
onclick={() => fileActions.addElevationToSelection()}
onclick={() => addElevation()}
>
<MountainSnow size="16" class="shrink-0" />
{i18n._('toolbar.elevation.button')}
@@ -1,51 +1,55 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Ungroup } from '@lucide/svelte';
import {
ListFileItem,
ListTrackItem,
ListTrackSegmentItem,
ListWaypointItem,
ListWaypointsItem,
} from '$lib/components/file-list/file-list';
import Help from '$lib/components/Help.svelte';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import { getURLForLanguage } from '$lib/utils';
import { selection } from '$lib/logic/selection';
import { fileStateCollection } from '$lib/logic/file-state';
import { fileActions } from '$lib/logic/file-actions';
import { get } from 'svelte/store';
import { engine } from '$lib/engine';
let props: {
class?: string;
} = $props();
let validSelection = $derived(
$selection.size > 0 &&
$selection.getSelected().every((item) => {
if (
item instanceof ListWaypointsItem ||
item instanceof ListWaypointItem ||
item instanceof ListTrackSegmentItem
) {
return false;
}
let file = fileStateCollection.getFile(item.getFileId());
if (file) {
if (item instanceof ListFileItem) {
return file.getSegments().length > 1;
} else if (item instanceof ListTrackItem) {
if (item.getTrackIndex() < file.trk.length) {
return file.trk[item.getTrackIndex()].getSegments().length > 1;
}
}
}
const selection = engine.selection;
const files = engine.files;
const statistics = engine.statistics;
/** The number of segments of a file (its routes have one each) or of one of its tracks. */
function segmentCount(fileId: string, trackId?: string) {
const file = $files.get(fileId);
const structure = file ? get(file).structure : undefined;
const routes = trackId === undefined ? (structure?.routes.length ?? 0) : 0;
return (structure?.tracks ?? [])
.filter((track) => trackId === undefined || track.id === trackId)
.reduce((count, track) => count + track.segments.length, routes);
}
// files or tracks, which all have several segments
let validSelection = $derived.by(() => {
// what is selected can change without the selection: the files are edited
void $statistics;
switch ($selection.type) {
case 'file':
return (
$selection.fileIds.length > 0 &&
$selection.fileIds.every((fileId) => segmentCount(fileId) > 1)
);
case 'track': {
const { fileId, trackIds } = $selection;
return (
trackIds.length > 0 &&
trackIds.every((trackId) => segmentCount(fileId, trackId) > 1)
);
}
default:
return false;
})
);
}
});
</script>
<div class="flex flex-col gap-3 w-full max-w-80 {props.class ?? ''}">
<Button variant="outline" disabled={!validSelection} onclick={fileActions.extractSelection}>
<Button variant="outline" disabled={!validSelection} onclick={() => engine.extract()}>
<Ungroup size="16" />
{i18n._('toolbar.extract.button')}
</Button>
@@ -6,53 +6,55 @@
</script>
<script lang="ts">
import { ListFileItem, ListTrackItem } from '$lib/components/file-list/file-list';
import Help from '$lib/components/Help.svelte';
import { Button } from '$lib/components/ui/button';
import { Label } from '$lib/components/ui/label/index.js';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as RadioGroup from '$lib/components/ui/radio-group';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import { Group } from '@lucide/svelte';
import { getURLForLanguage } from '$lib/utils';
import Shortcut from '$lib/components/Shortcut.svelte';
import { selection } from '$lib/logic/selection';
import { fileStateCollection } from '$lib/logic/file-state';
import { fileActions } from '$lib/logic/file-actions';
import { gpxStatistics } from '$lib/logic/statistics';
import { onDestroy } from 'svelte';
import { engine } from '$lib/engine';
let props: {
class?: string;
} = $props();
const selection = engine.selection;
const statistics = engine.statistics;
// the number of segments of the selection that have trackpoints
const request = engine.requestStatistics();
request.set(['anchors']);
onDestroy(() => request.release());
let segmentCount = $derived($statistics.anchors?.segmentStarts.length ?? 0);
// several files, routes, tracks or segments, or a single file or track with several segments
let canMergeTraces = $derived.by(() => {
if ($selection.size > 1) {
return true;
} else if ($selection.size === 1) {
let selected = $selection.getSelected()[0];
if (selected instanceof ListFileItem) {
let file = fileStateCollection.getFile(selected.getFileId());
if (file) {
return file.getSegments().length > 1;
}
} else if (selected instanceof ListTrackItem) {
let trackIndex = selected.getTrackIndex();
let file = fileStateCollection.getFile(selected.getFileId());
if (file && trackIndex < file.trk.length) {
return file.trk[trackIndex].getSegments().length > 1;
}
}
return false;
switch ($selection.type) {
case 'file':
return $selection.fileIds.length > 1 || segmentCount > 1;
case 'route':
return $selection.routeIds.length > 1;
case 'track':
return $selection.trackIds.length > 1 || segmentCount > 1;
case 'segment':
return $selection.segmentIds.length > 1;
default:
return false;
}
});
// several files or tracks, that can be put together (a route has a single segment: routes
// cannot)
let canMergeContents = $derived(
$selection.size > 1 &&
$selection
.getSelected()
.some((item) => item instanceof ListFileItem || item instanceof ListTrackItem)
($selection.type === 'file' && $selection.fileIds.length > 1) ||
($selection.type === 'track' && $selection.trackIds.length > 1)
);
let hasTimes = $derived(($statistics.global.totalTime ?? 0) > 0);
let removeGaps = $state(false);
let mergeType = $state(MergeType.TRACES);
</script>
@@ -68,7 +70,7 @@
{i18n._('toolbar.merge.merge_contents')}
</Label>
</RadioGroup.Root>
{#if mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0}
{#if mergeType === MergeType.TRACES && hasTimes}
<div class="flex flex-row items-center gap-1.5">
<Checkbox id="remove-gaps" bind:checked={removeGaps} />
<Label for="remove-gaps">{i18n._('toolbar.merge.remove_gaps')}</Label>
@@ -80,9 +82,9 @@
disabled={(mergeType === MergeType.TRACES && !canMergeTraces) ||
(mergeType === MergeType.CONTENTS && !canMergeContents)}
onclick={() => {
fileActions.mergeSelection(
mergeType === MergeType.TRACES,
mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0 && removeGaps
engine.merge(
mergeType === MergeType.TRACES ? 'connect' : 'group',
mergeType === MergeType.TRACES && hasTimes && removeGaps
);
}}
>
@@ -10,28 +10,24 @@
getConvertedVelocity,
milesToKilometers,
nauticalMilesToKilometers,
} from '$lib/units';
} from '$lib/units/units';
import { CalendarDate, type DateValue } from '@internationalized/date';
import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from '@lucide/svelte';
import { untrack } from 'svelte';
import { i18n } from '$lib/i18n.svelte';
import {
ListFileItem,
ListRootItem,
ListTrackItem,
ListTrackSegmentItem,
} from '$lib/components/file-list/file-list';
import { i18n } from '$lib/i18n/i18n.svelte';
import Help from '$lib/components/Help.svelte';
import { getURLForLanguage } from '$lib/utils';
import { selection } from '$lib/logic/selection';
import { settings } from '$lib/logic/settings';
import { fileActionManager } from '$lib/logic/file-action-manager';
import { gpxStatistics } from '$lib/logic/statistics';
import { settings } from '$lib/state/settings';
import { engine } from '$lib/engine';
let props: {
class?: string;
} = $props();
const selection = engine.selection;
const statistics = engine.statistics;
let global = $derived($statistics.global);
let startDate: DateValue | undefined = $state(undefined);
let startTime: string | undefined = $state(undefined);
let endDate: DateValue | undefined = $state(undefined);
@@ -59,26 +55,28 @@
}
function setGPXData() {
if ($gpxStatistics.global.time.start) {
startDate = toCalendarDate($gpxStatistics.global.time.start);
startTime = toTimeString($gpxStatistics.global.time.start);
if (global.startTime !== undefined) {
const start = new Date(global.startTime);
startDate = toCalendarDate(start);
startTime = toTimeString(start);
} else {
startDate = undefined;
startTime = undefined;
}
if ($gpxStatistics.global.time.end) {
endDate = toCalendarDate($gpxStatistics.global.time.end);
endTime = toTimeString($gpxStatistics.global.time.end);
if (global.endTime !== undefined) {
const end = new Date(global.endTime);
endDate = toCalendarDate(end);
endTime = toTimeString(end);
} else {
endDate = undefined;
endTime = undefined;
}
if ($gpxStatistics.global.time.moving && $gpxStatistics.global.speed.moving) {
movingTime = $gpxStatistics.global.time.moving;
setSpeed($gpxStatistics.global.speed.moving);
} else if ($gpxStatistics.global.time.total && $gpxStatistics.global.speed.total) {
movingTime = $gpxStatistics.global.time.total;
setSpeed($gpxStatistics.global.speed.total);
if (global.movingTime && global.movingSpeed) {
movingTime = global.movingTime;
setSpeed(global.movingSpeed);
} else if (global.totalTime && global.totalSpeed) {
movingTime = global.totalTime;
setSpeed(global.totalSpeed);
} else {
movingTime = undefined;
speed = undefined;
@@ -86,7 +84,7 @@
}
$effect(() => {
if ($gpxStatistics && $velocityUnits && $distanceUnits) {
if ($statistics && $velocityUnits && $distanceUnits) {
untrack(() => setGPXData());
}
});
@@ -102,16 +100,23 @@
return new Date(date.year, date.month - 1, date.day, hours, minutes, seconds);
}
/** Total time over moving time. */
function timeRatio() {
return global.movingTime && global.totalTime ? global.totalTime / global.movingTime : 1;
}
/** The distance to cover at the speed: the moving one if there is one. */
function distance() {
return global.movingDistance ? global.movingDistance : global.totalDistance;
}
function updateEnd() {
if (startDate && movingTime !== undefined) {
if (startTime === undefined) {
startTime = '00:00:00';
}
let start = getDate(startDate, startTime);
let ratio =
$gpxStatistics.global.time.moving > 0
? $gpxStatistics.global.time.total / $gpxStatistics.global.time.moving
: 1;
let ratio = timeRatio();
let end = new Date(start.getTime() + ratio * movingTime * 1000);
endDate = toCalendarDate(end);
endTime = toTimeString(end);
@@ -124,10 +129,7 @@
endTime = '00:00:00';
}
let end = getDate(endDate, endTime);
let ratio =
$gpxStatistics.global.time.moving > 0
? $gpxStatistics.global.time.total / $gpxStatistics.global.time.moving
: 1;
let ratio = timeRatio();
let start = new Date(end.getTime() - ratio * movingTime * 1000);
startDate = toCalendarDate(start);
startTime = toTimeString(start);
@@ -157,11 +159,7 @@
return;
}
let distance =
$gpxStatistics.global.distance.moving > 0
? $gpxStatistics.global.distance.moving
: $gpxStatistics.global.distance.total;
movingTime = (distance / speedValue) * 3600;
movingTime = (distance() / speedValue) * 3600;
updateEnd();
}
@@ -170,16 +168,16 @@
if (movingTime === undefined) {
return;
}
let distance =
$gpxStatistics.global.distance.moving > 0
? $gpxStatistics.global.distance.moving
: $gpxStatistics.global.distance.total;
setSpeed(distance / (movingTime / 3600));
setSpeed(distance() / (movingTime / 3600));
updateEnd();
}
// a single file, route, track or segment
let canUpdate = $derived(
$selection.size === 1 && $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints'])
($selection.type === 'file' && $selection.fileIds.length === 1) ||
($selection.type === 'route' && $selection.routeIds.length === 1) ||
($selection.type === 'track' && $selection.trackIds.length === 1) ||
($selection.type === 'segment' && $selection.segmentIds.length === 1)
);
</script>
@@ -309,7 +307,7 @@
/>
</div>
</div>
{#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined}
{#if !global.movingTime}
<div class="mt-0.5 flex flex-row gap-1 items-center">
<Checkbox id="artificial-time" bind:checked={artificial} disabled={!canUpdate} />
<Label for="artificial-time">
@@ -324,78 +322,33 @@
disabled={!canUpdate}
class="grow shrink whitespace-normal h-fit min-h-8 py-1"
onclick={() => {
let effectiveSpeed = getSpeed();
const speedValue = getSpeed();
if (
startDate === undefined ||
startTime === undefined ||
effectiveSpeed === undefined ||
speedValue === undefined ||
movingTime === undefined
) {
return;
}
if (Math.abs(effectiveSpeed - $gpxStatistics.global.speed.moving) < 0.01) {
effectiveSpeed = $gpxStatistics.global.speed.moving;
let effectiveSpeed: number = speedValue;
const movingSpeed = global.movingSpeed ?? 0;
if (Math.abs(effectiveSpeed - movingSpeed) < 0.01) {
effectiveSpeed = movingSpeed;
}
let ratio = 1;
if (
$gpxStatistics.global.speed.moving > 0 &&
$gpxStatistics.global.speed.moving !== effectiveSpeed
) {
ratio = $gpxStatistics.global.speed.moving / effectiveSpeed;
if (movingSpeed > 0 && movingSpeed !== effectiveSpeed) {
ratio = movingSpeed / effectiveSpeed;
}
let item = $selection.getSelected()[0];
let fileId = item.getFileId();
fileActionManager.applyToFile(fileId, (file) => {
if (item instanceof ListFileItem) {
if (artificial && !$gpxStatistics.global.time.moving) {
file.createArtificialTimestamps(
getDate(startDate!, startTime!),
movingTime!
);
} else {
file.changeTimestamps(
getDate(startDate!, startTime!),
effectiveSpeed,
ratio
);
}
} else if (item instanceof ListTrackItem) {
if (artificial && !$gpxStatistics.global.time.moving) {
file.createArtificialTimestamps(
getDate(startDate!, startTime!),
movingTime!,
item.getTrackIndex()
);
} else {
file.changeTimestamps(
getDate(startDate!, startTime!),
effectiveSpeed,
ratio,
item.getTrackIndex()
);
}
} else if (item instanceof ListTrackSegmentItem) {
if (artificial && !$gpxStatistics.global.time.moving) {
file.createArtificialTimestamps(
getDate(startDate!, startTime!),
movingTime!,
item.getTrackIndex(),
item.getSegmentIndex()
);
} else {
file.changeTimestamps(
getDate(startDate!, startTime!),
effectiveSpeed,
ratio,
item.getTrackIndex(),
item.getSegmentIndex()
);
}
}
});
const start = getDate(startDate, startTime);
if (artificial && !global.movingTime) {
engine.createArtificialTimestamps(start, movingTime);
} else {
engine.changeTimestamps(start, effectiveSpeed, ratio);
}
}}
>
<CalendarClock size="16" class="shrink-0" />
@@ -2,26 +2,46 @@
import { Label } from '$lib/components/ui/label/index.js';
import { Button } from '$lib/components/ui/button';
import { Slider } from '$lib/components/ui/slider';
import { ListRootItem } from '$lib/components/file-list/file-list';
import Help from '$lib/components/Help.svelte';
import { Funnel } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import WithUnits from '$lib/components/WithUnits.svelte';
import { onDestroy } from 'svelte';
import { getURLForLanguage } from '$lib/utils';
import { selection } from '$lib/logic/selection';
import { minTolerance, ReducedGPXLayerCollection, tolerance } from './utils.svelte';
import { engine } from '$lib/engine';
import { coversSegments } from '$lib/engine/selection-helpers';
import { minTolerance, ReducedLayer, tolerance } from './utils.svelte';
let props: { class?: string } = $props();
let sliderValue = $state(50);
const maxTolerance = 10000;
let validSelection = $derived(
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints'])
);
const selection = engine.selection;
const statistics = engine.statistics;
let reducedLayers = new ReducedGPXLayerCollection();
// the preview needs the start of each segment
const request = engine.requestStatistics();
request.set(['anchors']);
let validSelection = $derived(coversSegments($selection));
let reducedLayer = new ReducedLayer();
// read again only when the selection or the files change, not with the tolerance
let distances = $state<Float64Array>(new Float64Array());
$effect(() => {
const current = $statistics;
engine.reductionDistances().then((values) => {
if (current === $statistics) {
distances = values;
}
});
});
$effect(() => {
reducedLayer.update($statistics, distances, $tolerance);
});
$effect(() => {
tolerance.set(
@@ -30,7 +50,8 @@
});
onDestroy(() => {
reducedLayers.destroy();
request.release();
reducedLayer.destroy();
});
</script>
@@ -44,9 +65,9 @@
</Label>
<Label class="flex flex-row justify-between">
<span>{i18n._('toolbar.reduce.number_of_points')}</span>
<span class="font-normal">{reducedLayers.currentPoints}/{reducedLayers.maxPoints}</span>
<span class="font-normal">{reducedLayer.currentPoints}/{reducedLayer.maxPoints}</span>
</Label>
<Button variant="outline" disabled={!validSelection} onclick={() => reducedLayers.reduce()}>
<Button variant="outline" disabled={!validSelection} onclick={() => engine.reduce($tolerance)}>
<Funnel size="16" />
{i18n._('toolbar.reduce.button')}
</Button>
@@ -1,205 +1,85 @@
import { ListItem, ListTrackSegmentItem } from '$lib/components/file-list/file-list';
import { map } from '$lib/components/map/map';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import { fileActions } from '$lib/logic/file-actions';
import { GPXFileStateCollectionObserver, type GPXFileState } from '$lib/logic/file-state';
import { selection } from '$lib/logic/selection';
import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx';
import type { SelectionStatistics } from '$lib/engine';
import type { GeoJSONSource } from 'maplibre-gl';
import { get, writable } from 'svelte/store';
/** Lowest tolerance (m) of the reduction, the one of the engine. */
export const minTolerance = 0.1;
export class ReducedGPXLayer {
private _fileState: GPXFileState;
private _updateSimplified: (
itemId: string,
data: [ListItem, number, SimplifiedTrackPoint[]]
) => void;
private _unsubscribes: (() => void)[] = [];
constructor(
fileState: GPXFileState,
updateSimplified: (itemId: string, data: [ListItem, number, SimplifiedTrackPoint[]]) => void
) {
this._fileState = fileState;
this._updateSimplified = updateSimplified;
this._unsubscribes.push(this._fileState.subscribe(() => this.update()));
}
update() {
const file = this._fileState.file;
if (!file) {
return;
}
file.forEachSegment((segment, trackIndex, segmentIndex) => {
let segmentItem = new ListTrackSegmentItem(file._data.id, trackIndex, segmentIndex);
this._updateSimplified(segmentItem.getFullId(), [
segmentItem,
segment.trkpt.length,
ramerDouglasPeucker(segment.trkpt, minTolerance),
]);
});
}
destroy() {
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
}
}
export const tolerance = writable<number>(0);
export class ReducedGPXLayerCollection {
private _layers: Map<string, ReducedGPXLayer> = new Map();
private _simplified: Map<string, [ListItem, number, SimplifiedTrackPoint[]]>;
private _currentPoints = $state(0);
private _maxPoints = $state(0);
private _fileStateCollectionObserver: GPXFileStateCollectionObserver;
private _updateSimplified = this.updateSimplified.bind(this);
private _unsubscribes: (() => void)[] = [];
const SOURCE = 'simplified';
constructor() {
this._layers = new Map();
this._simplified = new Map();
this._fileStateCollectionObserver = new GPXFileStateCollectionObserver(
(newFiles) => {
newFiles.forEach((fileState, fileId) => {
this._layers.set(
fileId,
new ReducedGPXLayer(fileState, this._updateSimplified)
);
});
},
(fileId) => {
this._layers.get(fileId)?.destroy();
this._layers.delete(fileId);
},
() => {
this._layers.forEach((layer) => layer.destroy());
this._layers.clear();
/**
* Draws on the map what the selection would be reduced to with a tolerance, from the distances of
* the engine (see `Engine.reductionDistances`). It is the same as the result of `Engine.reduce`.
*/
export class ReducedLayer {
/** The number of trackpoints kept by the tolerance. */
currentPoints = $state(0);
/** The number of trackpoints of the selection. */
maxPoints = $state(0);
update(statistics: SelectionStatistics, distances: Float64Array, tolerance: number) {
const { lng, lat, length } = statistics;
const starts = statistics.anchors?.segmentStarts ?? new Uint32Array();
const data: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: [] };
let current = 0;
// distances are for the same selection as the statistics, unless they are about to change
const count = Math.min(length, distances.length);
for (let i = 0; i < starts.length; i++) {
const end = Math.min(i + 1 < starts.length ? starts[i + 1] : length, count);
const coordinates: [number, number][] = [];
for (let j = starts[i]; j < end; j++) {
if (distances[j] > tolerance) {
coordinates.push([lng[j], lat[j]]);
}
}
);
this._unsubscribes.push(selection.subscribe(() => this.update()));
this._unsubscribes.push(tolerance.subscribe(() => this.update()));
}
updateSimplified(itemId: string, data: [ListItem, number, SimplifiedTrackPoint[]]) {
this._simplified.set(itemId, data);
if (get(selection).hasAnyParent(data[0])) {
this.update();
}
}
removeSimplified(itemId: string) {
if (this._simplified.delete(itemId)) {
this.update();
}
}
update() {
this._currentPoints = 0;
this._maxPoints = 0;
let data: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [],
};
this._simplified.forEach(([item, maxPts, points], itemFullId) => {
if (!get(selection).hasAnyParent(item)) {
return;
}
this._maxPoints += maxPts;
let current = points.filter(
(point) => point.distance === undefined || point.distance >= get(tolerance)
);
this._currentPoints += current.length;
current += coordinates.length;
data.features.push({
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: current.map((point) => [
point.point.getLongitude(),
point.point.getLatitude(),
]),
},
geometry: { type: 'LineString', coordinates },
properties: {},
});
});
}
this.currentPoints = current;
this.maxPoints = length;
const map_ = get(map);
if (!map_) {
return;
}
let source: GeoJSONSource | undefined = map_.getSource('simplified');
const source = map_.getSource<GeoJSONSource>(SOURCE);
if (source) {
source.setData(data);
} else {
map_.addSource('simplified', {
type: 'geojson',
data: data,
});
map_.addSource(SOURCE, { type: 'geojson', data });
}
if (!map_.getLayer('simplified')) {
if (!map_.getLayer(SOURCE)) {
map_.addLayer(
{
id: 'simplified',
id: SOURCE,
type: 'line',
source: 'simplified',
paint: {
'line-color': 'white',
'line-width': 3,
},
source: SOURCE,
paint: { 'line-color': 'white', 'line-width': 3 },
},
ANCHOR_LAYER_KEY.interactions
);
}
}
reduce() {
let itemsAndPoints = new Map<ListItem, TrackPoint[]>();
this._simplified.forEach(([item, maxPts, points], itemFullId) => {
if (!get(selection).hasAnyParent(item)) {
return;
}
itemsAndPoints.set(
item,
points
.filter(
(point) => point.distance === undefined || point.distance >= get(tolerance)
)
.map((point) => point.point)
);
});
fileActions.reduce(itemsAndPoints);
}
get currentPoints() {
return this._currentPoints;
}
get maxPoints() {
return this._maxPoints;
}
destroy() {
this._fileStateCollectionObserver.destroy();
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
const map_ = get(map);
if (!map_) {
return;
}
if (map_.getLayer('simplified')) {
map_.removeLayer('simplified');
if (map_.getLayer(SOURCE)) {
map_.removeLayer(SOURCE);
}
if (map_.getSource('simplified')) {
map_.removeSource('simplified');
if (map_.getSource(SOURCE)) {
map_.removeSource(SOURCE);
}
}
}
@@ -1,4 +1,5 @@
<script lang="ts">
import type { MapMouseEvent, Popup } from 'maplibre-gl';
import * as Select from '$lib/components/ui/select';
import { Switch } from '$lib/components/ui/switch';
import { Label } from '$lib/components/ui/label/index.js';
@@ -22,26 +23,18 @@
SquareArrowOutDownRight,
} from '@lucide/svelte';
import { routingProfiles } from '$lib/components/toolbar/tools/routing/routing';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import { slide } from 'svelte/transition';
import {
ListFileItem,
ListRootItem,
ListTrackItem,
ListTrackSegmentItem,
type ListItem,
} from '$lib/components/file-list/file-list';
import { get } from 'svelte/store';
import { getURLForLanguage } from '$lib/utils';
import { onDestroy, onMount } from 'svelte';
import { TrackPoint } from 'gpx';
import { settings } from '$lib/logic/settings';
import { settings } from '$lib/state/settings';
import { map } from '$lib/components/map/map';
import { fileStateCollection, GPXFileStateCollectionObserver } from '$lib/logic/file-state';
import { selection } from '$lib/logic/selection';
import { fileActions, getFileIds, newGPXFile } from '$lib/logic/file-actions';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import { RoutingControls, routingControls } from './routing-controls';
import type { Popup } from 'maplibre-gl';
import { engine } from '$lib/engine';
import { coversSegments } from '$lib/engine/selection-helpers';
import { newFileName } from '$lib/actions/file-actions';
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
import { RoutingControls } from './routing-controls';
let {
minimized = $bindable(false),
@@ -59,52 +52,22 @@
const { privateRoads, routing, routingProfile } = settings;
let fileStateCollectionObserver: GPXFileStateCollectionObserver;
const selection = engine.selection;
let routingControls: RoutingControls | undefined = undefined;
let validSelection = $derived(
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints'])
);
// the tool works on files, routes, tracks and segments, not on waypoints
let validSelection = $derived(coversSegments($selection));
function createFileWithPoint(e: any) {
if ($selection.size === 0) {
let file = newGPXFile();
file.replaceTrackPoints(0, 0, 0, 0, [
new TrackPoint({
attributes: {
lat: e.lngLat.lat,
lon: e.lngLat.lng,
},
}),
]);
file._data.id = getFileIds(1)[0];
fileActions.add(file);
selection.selectFileWhenLoaded(file._data.id);
function createFileWithPoint(e: MapMouseEvent) {
if ($selection.type === 'empty') {
// the engine selects the new file, which starts with the trackpoint: one undo step
engine.newFile(newFileName(), { lng: e.lngLat.lng, lat: e.lngLat.lat });
}
}
onMount(() => {
if ($map && popup && popupElement) {
fileStateCollectionObserver = new GPXFileStateCollectionObserver(
(newFiles) => {
newFiles.forEach((fileState, fileId) => {
routingControls.set(
fileId,
new RoutingControls(fileId, fileState, popup, popupElement)
);
});
},
(fileId) => {
const controls = routingControls.get(fileId);
if (controls) {
controls.destroy();
routingControls.delete(fileId);
}
},
() => {
routingControls.forEach((controls) => controls.destroy());
routingControls.clear();
}
);
routingControls = new RoutingControls(popup, popupElement);
mapCursor.notify(MapCursorState.TOOL_WITH_CROSSHAIR, true);
$map.on('click', createFileWithPoint);
@@ -113,9 +76,8 @@
onDestroy(() => {
if ($map) {
if (fileStateCollectionObserver) {
fileStateCollectionObserver.destroy();
}
routingControls?.destroy();
routingControls = undefined;
mapCursor.notify(MapCursorState.TOOL_WITH_CROSSHAIR, false);
$map.off('click', createFileWithPoint);
@@ -168,7 +130,7 @@
{i18n._(`toolbar.routing.activities.${$routingProfile}`)}
</Select.Trigger>
<Select.Content>
{#each Object.keys(routingProfiles) as profile}
{#each Object.keys(routingProfiles) as profile (profile)}
<Select.Item value={profile}
>{i18n._(
`toolbar.routing.activities.${profile}`
@@ -194,7 +156,7 @@
variant="outline"
class="gap-1 text-xs px-1.5 py-1.5 h-fit"
disabled={!validSelection}
onclick={fileActions.reverseSelection}
onclick={() => engine.reverse()}
>
<ArrowRightLeft class="size-3" />{i18n._('toolbar.routing.reverse.button')}
</ButtonWithTooltip>
@@ -204,31 +166,10 @@
class="gap-1 text-xs px-1.5 py-1.5 h-fit"
disabled={!validSelection}
onclick={() => {
const selected = selection.getOrderedSelection();
if (selected.length > 0) {
const firstFileId = selected[0].getFileId();
const firstFile = fileStateCollection.getFile(firstFileId);
if (firstFile) {
let start = (() => {
if (selected[0] instanceof ListFileItem) {
return firstFile.trk[0]?.trkseg[0]?.trkpt[0];
} else if (selected[0] instanceof ListTrackItem) {
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[0]
?.trkpt[0];
} else if (selected[0] instanceof ListTrackSegmentItem) {
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[
selected[0].getSegmentIndex()
]?.trkpt[0];
}
})();
if (start !== undefined) {
const lastFileId = selected[selected.length - 1].getFileId();
routingControls
.get(lastFileId)
?.appendAnchorWithCoordinates(start.getCoordinates());
}
}
// the first trackpoint of the selection
const { length, lng, lat } = get(engine.statistics);
if (length > 0) {
routingControls?.appendAnchorWithCoordinates({ lng: lng[0], lat: lat[0] });
}
}}
>
@@ -239,7 +180,7 @@
variant="outline"
class="gap-1 text-xs px-1.5 py-1.5 h-fit"
disabled={!validSelection}
onclick={fileActions.createRoundTripForSelection}
onclick={() => engine.roundTrip()}
>
<Repeat class="size-3" />{i18n._('toolbar.routing.round_trip.button')}
</ButtonWithTooltip>
@@ -5,7 +5,7 @@
import { canChangeStart } from './routing-controls';
import { CirclePlay, Trash2 } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
let {
element = $bindable(),
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,282 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { distance } from '$lib/geo/coordinates';
import {
brouterError,
getTags,
graphHopperError,
graphhopperBlockPrivateCustomModels,
interpolatePoints,
parseBRouterRoute,
parseGraphHopperRoute,
routingProfiles,
} from './routing';
describe('routing profiles', () => {
it('block the private roads of every profile of GraphHopper that has a model', () => {
const profiles = Object.values(routingProfiles)
.filter((profile) => profile.engine === 'graphhopper')
.map((profile) => profile.profile);
assert.ok(profiles.length > 0);
for (const profile of profiles) {
assert.ok(graphhopperBlockPrivateCustomModels[profile], profile);
}
// and the models are about profiles that exist
for (const profile of Object.keys(graphhopperBlockPrivateCustomModels)) {
assert.ok(profiles.includes(profile), profile);
}
});
});
describe('GraphHopper', () => {
const response = (details: Record<string, unknown[]>, coordinates: number[][]) => ({
paths: [{ points: { coordinates }, details }],
});
const coordinates = [
[4.0, 50.0, 100],
[4.1, 50.1, 110],
[4.2, 50.2, 120],
[4.3, 50.3, 130],
[4.4, 50.4],
];
it('gives the points of the route, with the elevation of the previous one when missing', () => {
const route = parseGraphHopperRoute(response({}, coordinates));
assert.deepEqual(route.lng, [4.0, 4.1, 4.2, 4.3, 4.4]);
assert.deepEqual(route.lat, [50.0, 50.1, 50.2, 50.3, 50.4]);
assert.deepEqual(route.ele, [100, 110, 120, 130, 130]);
assert.deepEqual(route.surface, new Array(5).fill(undefined));
});
it('gives the details to the points of their ranges, the last range including the last point', () => {
const route = parseGraphHopperRoute(
response(
{
road_class: [
[0, 2, 'cycleway'],
[2, 4, 'path'],
],
surface: [
[0, 3, 'asphalt'],
[3, 4, 'gravel'],
],
hike_rating: [[0, 4, '2']],
mtb_rating: [
[0, 1, '1'],
[1, 4, '5'],
],
},
coordinates
)
);
assert.deepEqual(route.highway, ['cycleway', 'cycleway', 'path', 'path', 'path']);
assert.deepEqual(route.surface, ['asphalt', 'asphalt', 'asphalt', 'gravel', 'gravel']);
// the ratings are converted to the scales
assert.deepEqual(route.sacScale, new Array(5).fill('mountain_hiking'));
assert.deepEqual(route.mtbScale, ['0', '4', '4', '4', '4']);
});
it('ignores the details that are missing or unknown', () => {
const route = parseGraphHopperRoute(
response(
{
road_class: [
[0, 2, 'missing'],
[2, 4, 'primary'],
],
surface: [[0, 4, 'other']],
hike_rating: [[0, 4, '9']],
},
coordinates
)
);
assert.deepEqual(route.highway, [undefined, undefined, 'primary', 'primary', 'primary']);
assert.deepEqual(route.surface, new Array(5).fill(undefined));
assert.deepEqual(route.sacScale, new Array(5).fill(undefined));
});
it('turns its errors into the messages of the interface', () => {
const error = (message: string, details = '', count = 2) =>
graphHopperError({ message, hints: [{ details }] }, count).message;
assert.equal(error('Cannot find point 0: 1,2'), 'toolbar.routing.error.from');
assert.equal(error('Cannot find point 1: 1,2', '', 2), 'toolbar.routing.error.to');
assert.equal(error('Cannot find point 1: 1,2', '', 3), 'toolbar.routing.error.via');
assert.equal(
error('x', 'com.graphhopper.PointDistanceExceededException'),
'toolbar.routing.error.distance'
);
assert.equal(
error('x', 'ConnectionNotFoundException: no way'),
'toolbar.routing.error.connection'
);
assert.equal(error('something else'), 'something else');
// an error without hints is still an error
assert.equal(graphHopperError({ message: 'no hints' }, 2).message, 'no hints');
assert.equal(graphHopperError({}, 2).message, '');
});
});
describe('BRouter', () => {
const messages = [
[
'Longitude',
'Latitude',
'Elevation',
'Distance',
'CostPerKm',
'ElevCost',
'TurnCost',
'NodeCost',
'InitialCost',
'WayTags',
'NodeTags',
],
[
'4100000',
'50100000',
'100',
'10',
'0',
'0',
'0',
'0',
'0',
'highway=path surface=gravel sac_scale=hiking',
'',
],
[
'4300000',
'50300000',
'120',
'10',
'0',
'0',
'0',
'0',
'0',
'highway=residential surface=asphalt mtb:scale=1',
'',
],
];
const geojson = (coordinates: number[][], messages_: string[][] = messages) => ({
features: [{ geometry: { coordinates }, properties: { messages: messages_ } }],
});
it('reads the tags of a way', () => {
assert.deepEqual(getTags('highway=path surface=gravel'), {
highway: 'path',
surface: 'gravel',
});
assert.deepEqual(getTags('sac_scale=hiking mtb:scale=2'), {
sac_scale: 'hiking',
mtb_scale: '2',
});
assert.deepEqual(getTags('a:b:c=d'), { a_b_c: 'd' });
assert.deepEqual(getTags(''), { '': undefined });
});
it('gives each point the tags of its way', () => {
const route = parseBRouterRoute(
geojson([
[4.0, 50.0, 90],
[4.1, 50.1, 100],
[4.2, 50.2, 110],
[4.3, 50.3, 120],
[4.4, 50.4, 130],
])
);
assert.deepEqual(route.ele, [90, 100, 110, 120, 130]);
// the first way is the one of the first message, whose last point is the start of the next
assert.deepEqual(route.highway, [
'path',
'residential',
'residential',
undefined,
undefined,
]);
assert.deepEqual(route.surface, ['gravel', 'asphalt', 'asphalt', undefined, undefined]);
assert.deepEqual(route.sacScale, ['hiking', undefined, undefined, undefined, undefined]);
assert.deepEqual(route.mtbScale, [undefined, '1', '1', undefined, undefined]);
});
it('has no tags for a route without messages', () => {
const route = parseBRouterRoute(
geojson(
[
[4, 50, 1],
[4.1, 50.1, 2],
],
[messages[0]]
)
);
assert.deepEqual(route.lng, [4, 4.1]);
assert.deepEqual(route.highway, [undefined, undefined]);
});
it('turns its errors into the messages of the interface', () => {
assert.equal(
brouterError('from-position not mapped in existing datafile').message,
'toolbar.routing.error.from'
);
assert.equal(
brouterError('via1-position not mapped in existing datafile').message,
'toolbar.routing.error.via'
);
assert.equal(
brouterError('to-position not mapped in existing datafile').message,
'toolbar.routing.error.to'
);
assert.equal(
brouterError('operation killed by Time-out').message,
'toolbar.routing.error.timeout'
);
assert.equal(brouterError('other').message, 'other');
});
});
describe('straight routes', () => {
it('have a point every 50 m and the last point', () => {
const a = { lng: 4, lat: 50 };
const b = { lng: 4.001, lat: 50 };
const route = interpolatePoints([a, b]);
const length = distance(a, b);
assert.equal(route.lng.length, Math.ceil(length / 50) + 1);
assert.deepEqual([route.lng[0], route.lat[0]], [4, 50]);
assert.deepEqual([route.lng.at(-1), route.lat.at(-1)], [4.001, 50]);
// the points are in order, and evenly spaced
for (let i = 1; i < route.lng.length - 1; i++) {
const step = distance(
{ lng: route.lng[i - 1], lat: route.lat[i - 1] },
{ lng: route.lng[i], lat: route.lat[i] }
);
assert.ok(Math.abs(step - 50) < 1, `${step}`);
}
assert.ok(route.ele.every((ele) => ele === 0));
assert.equal(route.surface.length, route.lng.length);
});
it('go through the intermediate points', () => {
const route = interpolatePoints([
{ lng: 4, lat: 50 },
{ lng: 4.001, lat: 50 },
{ lng: 4.001, lat: 50.001 },
]);
assert.ok(route.lng.includes(4.001) && route.lat.includes(50.001));
const corner = route.lng.findIndex((lng, i) => lng === 4.001 && route.lat[i] === 50);
assert.ok(corner > 0);
});
it('are a point for one point, nothing for none', () => {
const route = interpolatePoints([{ lng: 4, lat: 50 }]);
assert.deepEqual(route.lng, [4]);
assert.deepEqual(interpolatePoints([]).lng, []);
// two points at the same place
assert.deepEqual(
interpolatePoints([
{ lng: 4, lat: 50 },
{ lng: 4, lat: 50 },
]).lng,
[4]
);
});
});
Loaded 100 of 484 files, more files were not shown because too many files have changed in this diff. Show more