mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-11-01 12:17:18 +00:00
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:
1 parent
6a0d434371
commit
8fe7d8d64d
484 files changed
+53949
-41152
No files matched your search
+4
-4
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+5
@@ -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 {};
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = '',
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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!,
|
||||
}));
|
||||
}
|
||||
@@ -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,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)}° {trackpoint.item
|
||||
.getLongitude()
|
||||
.toFixed(6)}°
|
||||
{trackpoint.item.lat.toFixed(6)}° {trackpoint.item.lng.toFixed(6)}°
|
||||
</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)}° {waypoint.item
|
||||
.getLongitude()
|
||||
.toFixed(6)}°
|
||||
{#if waypoint.item.ele !== undefined}
|
||||
<Dot size="16" />
|
||||
<WithUnits value={waypoint.item.ele} type="elevation" />
|
||||
{/if}
|
||||
{waypoint.item.lat.toFixed(6)}° {waypoint.item.lng.toFixed(6)}°
|
||||
<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)}° {poi.item.lon.toFixed(6)}°
|
||||
{poi.item.lat.toFixed(6)}° {poi.item.lng.toFixed(6)}°
|
||||
</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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in new issue
Block a user